From f99109d1a8bf976d7dc90f0ce7acbde20799b0c2 Mon Sep 17 00:00:00 2001 From: Christer Date: Mon, 15 Sep 2025 09:47:19 +0200 Subject: [PATCH 1/3] feat: Experimental command line completion support --- packages/cli_tools/README_completion.md | 49 ++++ .../better_command_runner/better_command.dart | 8 +- .../better_command_runner.dart | 10 + .../completion_command.dart | 273 ++++++++++++++++++ 4 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 packages/cli_tools/README_completion.md create mode 100644 packages/cli_tools/lib/src/better_command_runner/completion_command.dart diff --git a/packages/cli_tools/README_completion.md b/packages/cli_tools/README_completion.md new file mode 100644 index 0000000..2f793ee --- /dev/null +++ b/packages/cli_tools/README_completion.md @@ -0,0 +1,49 @@ +## Command line completion for scloud + +There is now an experimental feature to generate and install command line +completion in bash for commands using `BetterCommandRunner`. + +### Prerequisites + +This requires the tool `completely`, which also requires ruby to be installed. + +https://github.com/bashly-framework/completely + +```sh +gem install completely +``` + +or with homebrew: + +```sh +brew install brew-gem +brew gem install completely +``` + +### Activate + +Construct `BetterCommandRunner` with the flag `experimentalCompletionCommand` +set to `true`. + +### Install completion + +When the `completely` tool is installed, run e.g: + +```sh +my-command completion -f completely.yaml +completely generate completely.yaml completely.bash +mkdir -p ~/.local/share/bash-completion/completions +cp completely.bash ~/.local/share/bash-completion/completions/my-command.bash +``` + +This will write the completions script to `~/.local/share/bash-completion/completions/`, +where bash picks it up automatically on start. + +In order to update the completions in the current bash shell, run: + +```sh +exec bash +``` + +For end users, the generated bash script can be distributed as a file for them +to install directly. diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command.dart b/packages/cli_tools/lib/src/better_command_runner/better_command.dart index 0385a61..b523f6c 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command.dart @@ -78,12 +78,12 @@ abstract class BetterCommand extends Command { } @override - BetterCommand? get parent => - super.parent as BetterCommand?; + BetterCommand? get parent => + super.parent as BetterCommand?; @override - BetterCommandRunner? get runner => - super.runner as BetterCommandRunner?; + BetterCommandRunner? get runner => + super.runner as BetterCommandRunner?; @override ArgParser get argParser => _argParser; diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart index ee7329b..6606bfd 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart @@ -5,6 +5,8 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:config/config.dart'; +import 'completion_command.dart'; + /// A function type for executing code before running a command. typedef OnBeforeRunCommand = Future Function(BetterCommandRunner runner); @@ -146,6 +148,7 @@ class BetterCommandRunner final OnBeforeRunCommand? onBeforeRunCommand, final OnAnalyticsEvent? onAnalyticsEvent, final int? wrapTextColumn, + final bool experimentalCompletionCommand = false, final List? globalOptions, final Map? env, }) : _messageOutput = messageOutput, @@ -170,8 +173,15 @@ class BetterCommandRunner ); } prepareOptionsForParsing(_globalOptions, argParser); + + if (experimentalCompletionCommand) { + addCommand(CompletionCommand()); + } } + /// The global option definitions. + List get globalOptions => _globalOptions; + /// The [MessageOutput] for the command runner. /// It is also used for the commands unless they have their own. MessageOutput? get messageOutput => _messageOutput; diff --git a/packages/cli_tools/lib/src/better_command_runner/completion_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion_command.dart new file mode 100644 index 0000000..a844f37 --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion_command.dart @@ -0,0 +1,273 @@ +import 'dart:io' show IOSink, stdout; + +import 'package:args/command_runner.dart' show Command; +import 'package:config/config.dart'; + +import 'better_command.dart'; +import 'better_command_runner.dart'; + +enum CompletionTarget { + completely, +} + +enum CompletionOption implements OptionDefinition { + target(EnumOption( + enumParser: EnumParser(CompletionTarget.values), + argName: 'target', + argAbbrev: 't', + defaultsTo: CompletionTarget.completely, + )), + execName(StringOption( + argName: 'exec-name', + argAbbrev: 'e', + helpText: 'Override the name of the executable', + )), + file(FileOption( + argName: 'file', + argAbbrev: 'f', + helpText: 'Write the specification to a file instead of stdout', + )); + + const CompletionOption(this.option); + + @override + final ConfigOptionBase option; +} + +class CompletionCommand extends BetterCommand { + CompletionCommand() : super(options: CompletionOption.values); + + @override + String get name => 'completion'; + + @override + String get description => 'Generate a command line completion specification'; + + @override + Null runWithConfig(final Configuration commandConfig) { + final target = commandConfig.value(CompletionOption.target); + final execName = commandConfig.optionalValue(CompletionOption.execName); + final file = commandConfig.optionalValue(CompletionOption.file); + + final betterRunner = runner; + if (betterRunner == null) { + throw Exception('BetterCommandRunner not set in the completion command'); + } + + final usage = UsageRepresentation.compile( + betterRunner, + execNameOverride: execName, + ); + + final IOSink out = file?.openWrite() ?? stdout; + + switch (target) { + case CompletionTarget.completely: + CompletelyYamlGenerator().generate(out, usage); + break; + } + } +} + +class CommandUsage { + final List commandSequence; + final List options; + final List subcommands; + + CommandUsage({ + required this.commandSequence, + required this.options, + required this.subcommands, + }); + + String get command => commandSequence.last; +} + +abstract class UsageRepresentation { + /// Compiles a usage representation tree for all subcommands and options + /// for a command runner. + static CommandUsage compile( + final BetterCommandRunner runner, { + final String? execNameOverride, + }) { + return _generateForCommand( + [execNameOverride ?? runner.executableName], + _filterOptions(runner.globalOptions), + runner.commands.values, + const [], + ); + } + + static CommandUsage _generateForCommand( + final List commandSequence, + final List globalOptions, + final Iterable subcommands, + final List options, + ) { + final validOptions = [...globalOptions, ..._filterOptions(options)]; + final validSubcommands = + subcommands.whereType().where((final c) => !c.hidden); + + return CommandUsage( + commandSequence: commandSequence, + options: validOptions, + subcommands: validSubcommands + .map((final subcommand) => _generateForCommand( + [...commandSequence, subcommand.name], + globalOptions, + subcommand.subcommands.values, + subcommand.options, + )) + .toList(), + ); + } + + static List _filterOptions( + final List options, + ) { + return options + .where((final o) => !o.option.hide) + .where((final o) => o.option.argName != null || o.option.argPos != null) + .toList(); + } +} + +/// Interface for generating a usage representation for a command. +abstract interface class UsageRepresentationGenerator { + void generate( + final IOSink out, + final CommandUsage usage, + ); +} + +/// Generates usage representation for a command in the YAML format +/// of the `completely` tool. +/// https://github.com/bashly-framework/completely +class CompletelyYamlGenerator implements UsageRepresentationGenerator { + @override + void generate( + final IOSink out, + final CommandUsage usage, + ) { + if (usage.subcommands.isEmpty && usage.options.isEmpty) { + return; + } + + out.writeln('${usage.commandSequence.join(' ')}:'); + + for (final subcommand in usage.subcommands) { + out.writeln('- ${subcommand.command}'); + } + + _generateCompletelyForOptions(out, usage); + out.writeln(); + + for (final subcommand in usage.subcommands) { + generate(out, subcommand); + } + } + + static void _generateCompletelyForOptions( + final IOSink out, + final CommandUsage usage, + ) { + // options + for (final option in usage.options) { + if (option.option.argName != null) { + out.writeln('- --${option.option.argName}'); + + if (option.option case final FlagOption flagOption) { + if (flagOption.negatable && !flagOption.hideNegatedUsage) { + out.writeln('- --no-${flagOption.argName}'); + } + } + } + + if (option.option.argAbbrev != null) { + out.writeln('- -${option.option.argAbbrev}'); + } + + if (option.option.argPos == 0) { + final values = _getOptionValues(option.option); + if (values.isNotEmpty) { + _generateCompletelyForOptionValues(out, values); + } + } + // can't currently complete for positional options after the first one + } + + // value completions for each option + for (final option in usage.options) { + _generateCompletelyForOption(out, usage.commandSequence, option.option); + } + } + + static void _generateCompletelyForOption( + final IOSink out, + final List commandSequence, + final ConfigOptionBase option, + ) { + if (option is FlagOption) { + return; + } + + if (option.argName case final String argName) { + _generateCompletelyForArgNameOption( + out, + commandSequence, + option, + '--$argName', + ); + } + if (option.argAbbrev case final String argAbbrev) { + _generateCompletelyForArgNameOption( + out, + commandSequence, + option, + '-$argAbbrev', + ); + } + } + + static void _generateCompletelyForArgNameOption( + final IOSink out, + final List commandSequence, + final ConfigOptionBase option, + final String argName, + ) { + final values = _getOptionValues(option); + if (values.isNotEmpty) { + out.writeln('${commandSequence.join(' ')}*$argName:'); + _generateCompletelyForOptionValues(out, values); + } + } + + static List _getOptionValues( + final ConfigOptionBase option, + ) { + if (option.allowedValues case final List allowedValues) { + return allowedValues.map((final v) => '$v').toList(); + } + + switch (option.option) { + case EnumOption(): + final enumParser = option.option.valueParser as EnumParser; + return enumParser.enumValues.map((final e) => e.name).toList(); + case FileOption(): + return ['']; + case DirOption(): + return ['']; + default: + return []; + } + } + + static void _generateCompletelyForOptionValues( + final IOSink out, + final Iterable values, + ) { + for (final value in values) { + out.writeln('- $value'); + } + } +} From a2a1d28e2cab22ee02a3652683e76b80ce55c029 Mon Sep 17 00:00:00 2001 From: Christer Date: Mon, 15 Sep 2025 11:54:07 +0200 Subject: [PATCH 2/3] fix(cli_tools): Fixed review comments --- .../completion_command.dart | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/cli_tools/lib/src/better_command_runner/completion_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion_command.dart index a844f37..744c9f0 100644 --- a/packages/cli_tools/lib/src/better_command_runner/completion_command.dart +++ b/packages/cli_tools/lib/src/better_command_runner/completion_command.dart @@ -44,7 +44,8 @@ class CompletionCommand extends BetterCommand { String get description => 'Generate a command line completion specification'; @override - Null runWithConfig(final Configuration commandConfig) { + Future runWithConfig( + final Configuration commandConfig) async { final target = commandConfig.value(CompletionOption.target); final execName = commandConfig.optionalValue(CompletionOption.execName); final file = commandConfig.optionalValue(CompletionOption.file); @@ -66,6 +67,12 @@ class CompletionCommand extends BetterCommand { CompletelyYamlGenerator().generate(out, usage); break; } + + if (file != null) { + await out.flush(); + await out.close(); + } + return null as T; } } @@ -156,7 +163,7 @@ class CompletelyYamlGenerator implements UsageRepresentationGenerator { out.writeln('${usage.commandSequence.join(' ')}:'); for (final subcommand in usage.subcommands) { - out.writeln('- ${subcommand.command}'); + out.writeln(' - ${subcommand.command}'); } _generateCompletelyForOptions(out, usage); @@ -174,17 +181,17 @@ class CompletelyYamlGenerator implements UsageRepresentationGenerator { // options for (final option in usage.options) { if (option.option.argName != null) { - out.writeln('- --${option.option.argName}'); + out.writeln(' - --${option.option.argName}'); if (option.option case final FlagOption flagOption) { if (flagOption.negatable && !flagOption.hideNegatedUsage) { - out.writeln('- --no-${flagOption.argName}'); + out.writeln(' - --no-${flagOption.argName}'); } } } if (option.option.argAbbrev != null) { - out.writeln('- -${option.option.argAbbrev}'); + out.writeln(' - -${option.option.argAbbrev}'); } if (option.option.argPos == 0) { @@ -216,7 +223,7 @@ class CompletelyYamlGenerator implements UsageRepresentationGenerator { out, commandSequence, option, - '--$argName', + ' --$argName', ); } if (option.argAbbrev case final String argAbbrev) { @@ -224,7 +231,7 @@ class CompletelyYamlGenerator implements UsageRepresentationGenerator { out, commandSequence, option, - '-$argAbbrev', + ' -$argAbbrev', ); } } @@ -267,7 +274,7 @@ class CompletelyYamlGenerator implements UsageRepresentationGenerator { final Iterable values, ) { for (final value in values) { - out.writeln('- $value'); + out.writeln(' - $value'); } } } From f70cfa0f4cd805a0a7fde148660fd11f8235f66e Mon Sep 17 00:00:00 2001 From: Christer Date: Mon, 15 Sep 2025 13:30:36 +0200 Subject: [PATCH 3/3] fix: Fixed enum values completion bug --- .../lib/src/better_command_runner/completion_command.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli_tools/lib/src/better_command_runner/completion_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion_command.dart index 744c9f0..2ce9c6c 100644 --- a/packages/cli_tools/lib/src/better_command_runner/completion_command.dart +++ b/packages/cli_tools/lib/src/better_command_runner/completion_command.dart @@ -253,13 +253,13 @@ class CompletelyYamlGenerator implements UsageRepresentationGenerator { final ConfigOptionBase option, ) { if (option.allowedValues case final List allowedValues) { - return allowedValues.map((final v) => '$v').toList(); + return allowedValues.map(option.valueParser.format).toList(); } switch (option.option) { case EnumOption(): final enumParser = option.option.valueParser as EnumParser; - return enumParser.enumValues.map((final e) => e.name).toList(); + return enumParser.enumValues.map(enumParser.format).toList(); case FileOption(): return ['']; case DirOption():