From 0468366da07dcb8207b2ccadde7c3349eb19e60c Mon Sep 17 00:00:00 2001 From: "unicoderbot[bot]" <269805761+unicoderbot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:58:18 +0000 Subject: [PATCH] fix: address issue #58 Closes #58 Co-authored-by: marcossevilla --- README.md | 6 + doc/README.md | 2 +- lib/src/command_runner/commands/commands.dart | 1 + .../print_completion_script_command.dart | 44 +++++++ .../completion_command_runner.dart | 30 +++++ .../installer/completion_installation.dart | 24 ++++ .../print_completion_script_command_test.dart | 111 ++++++++++++++++++ .../completion_command_runner_test.dart | 35 ++++++ .../completion_installation_test.dart | 50 ++++++++ 9 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 lib/src/command_runner/commands/print_completion_script_command.dart create mode 100644 test/src/command_runner/commands/print_completion_script_command_test.dart diff --git a/README.md b/README.md index 2e43487..e393952 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ When `enableAutoInstall` is set to false, users will have to call `install-compl $ example_cli install-completion-files ``` +Additionally, when `enableAutoInstall` is disabled a `completion-script` command is exposed. It prints the completion script for the current shell to stdout, so users can install it wherever they prefer: + +```bash +$ example_cli completion-script >> ~/.zshrc +``` + ## Documentation 📝 For an overview of how this package works, check out the [documentation][docs_link]. diff --git a/doc/README.md b/doc/README.md index 8cd350b..5729395 100644 --- a/doc/README.md +++ b/doc/README.md @@ -26,7 +26,7 @@ We call this process [parsing](#how-parsing-completion-works). The class `CompletionCommandRunner` tries to create these files upon any command run. It does nothing if the completion files exist and displays a short error message if there is an error in the process. -To disable this behavior, set `enableAutoInstall` to false on your `CompletionCommandRunner` subclass. +To disable this behavior, set `enableAutoInstall` to false on your `CompletionCommandRunner` subclass. In that case, users can install the completion files manually with the `install-completion-files` command, or print the completion script for the current shell with the `completion-script` command (e.g. `example_cli completion-script >> ~/.zshrc`), which is only available while auto installation is disabled. ### How Parsing Completion Works diff --git a/lib/src/command_runner/commands/commands.dart b/lib/src/command_runner/commands/commands.dart index 69ceb3f..abf3335 100644 --- a/lib/src/command_runner/commands/commands.dart +++ b/lib/src/command_runner/commands/commands.dart @@ -1,3 +1,4 @@ export 'handle_completion_command.dart'; export 'install_completion_files_command.dart'; +export 'print_completion_script_command.dart'; export 'uninstall_completion_files_command.dart'; diff --git a/lib/src/command_runner/commands/print_completion_script_command.dart b/lib/src/command_runner/commands/print_completion_script_command.dart new file mode 100644 index 0000000..8388dfd --- /dev/null +++ b/lib/src/command_runner/commands/print_completion_script_command.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:cli_completion/cli_completion.dart'; + +/// {@template print_completion_script_command} +/// A [Command] added by [CompletionCommandRunner] only when auto installation +/// is disabled that prints the completion script for the current shell to +/// stdout. +/// +/// It allows users to install the completion script manually by piping the +/// output into their shell configuration file, for example: +/// ```sh +/// my_cli completion-script >> ~/.zshrc +/// ``` +/// +/// This mirrors the approach used by other CLIs such as npm and the GitHub CLI. +/// {@endtemplate} +class PrintCompletionScriptCommand extends Command { + /// {@macro print_completion_script_command} + PrintCompletionScriptCommand(); + + @override + String get description { + return 'Prints the completion script for the current shell to stdout.'; + } + + /// The string that the user can call to print the completion script. + static const commandName = 'completion-script'; + + @override + String get name => commandName; + + @override + CompletionCommandRunner get runner { + return super.runner! as CompletionCommandRunner; + } + + @override + FutureOr? run() { + runner.printCompletionScript(); + return null; + } +} diff --git a/lib/src/command_runner/completion_command_runner.dart b/lib/src/command_runner/completion_command_runner.dart index 8303754..4128001 100644 --- a/lib/src/command_runner/completion_command_runner.dart +++ b/lib/src/command_runner/completion_command_runner.dart @@ -20,6 +20,10 @@ import 'package:meta/meta.dart'; /// /// Adds [InstallCompletionFilesCommand] to enable the user to /// manually install completion files. +/// +/// When [enableAutoInstall] is disabled, it also adds +/// [PrintCompletionScriptCommand] so the user can print the completion script +/// and install it manually. abstract class CompletionCommandRunner extends CommandRunner { /// {@macro completion_command_runner} CompletionCommandRunner( @@ -31,6 +35,13 @@ abstract class CompletionCommandRunner extends CommandRunner { addCommand(HandleCompletionRequestCommand()); addCommand(InstallCompletionFilesCommand()); addCommand(UnistallCompletionFilesCommand()); + + // The print completion script command is only useful when the completion + // files are not installed automatically. Otherwise, users should rely on + // the auto installation (or the `install-completion-files` command). + if (!enableAutoInstall) { + addCommand(PrintCompletionScriptCommand()); + } } /// The [Logger] used to prompt the completion suggestions. @@ -102,6 +113,25 @@ abstract class CompletionCommandRunner extends CommandRunner { } } + /// Prints the completion script for the current shell to stdout. + /// + /// This is used by [PrintCompletionScriptCommand] to allow users to install + /// the completion script manually, for example: + /// ```sh + /// my_cli completion-script >> ~/.zshrc + /// ``` + @internal + void printCompletionScript() { + try { + final script = completionInstallation.completionScriptFor(executableName); + completionLogger.info(script); + } on CompletionInstallationException catch (e) { + completionInstallationLogger.warn(e.toString()); + } on Exception catch (e) { + completionInstallationLogger.err(e.toString()); + } + } + /// Tries to uninstall completion files for the current shell. @internal void tryUninstallCompletionFiles(Level level) { diff --git a/lib/src/installer/completion_installation.dart b/lib/src/installer/completion_installation.dart index 9403d7a..ccd9c42 100644 --- a/lib/src/installer/completion_installation.dart +++ b/lib/src/installer/completion_installation.dart @@ -156,6 +156,30 @@ class CompletionInstallation { .writeTo(completionConfigurationFile); } + /// Returns the completion script for the [rootCommand] on the current shell + /// without writing it to any file. + /// + /// This can be used to print the completion script to stdout so that a user + /// can source it manually, for example: + /// ```sh + /// my_cli completion-script >> ~/.zshrc + /// ``` + /// + /// Throws a [CompletionInstallationException] if the current shell is + /// unknown. + String completionScriptFor(String rootCommand) { + final configuration = this.configuration; + + if (configuration == null) { + throw CompletionInstallationException( + message: 'Unknown shell.', + rootCommand: rootCommand, + ); + } + + return configuration.scriptTemplate(rootCommand); + } + /// Wether the completion configuration files for a [rootCommand] should be /// installed or not. /// diff --git a/test/src/command_runner/commands/print_completion_script_command_test.dart b/test/src/command_runner/commands/print_completion_script_command_test.dart new file mode 100644 index 0000000..f2a84ab --- /dev/null +++ b/test/src/command_runner/commands/print_completion_script_command_test.dart @@ -0,0 +1,111 @@ +import 'package:cli_completion/cli_completion.dart'; +import 'package:cli_completion/installer.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockCompletionInstallation extends Mock + implements CompletionInstallation {} + +class _TestCompletionCommandRunner extends CompletionCommandRunner { + _TestCompletionCommandRunner() : super('test', 'Test command runner'); + + @override + bool get enableAutoInstall => false; + + @override + // Override acceptable for test files + // ignore: overridden_fields + final Logger completionLogger = _MockLogger(); + + @override + // Override acceptable for test files + // ignore: overridden_fields + final Logger completionInstallationLogger = _MockLogger(); + + @override + final CompletionInstallation completionInstallation = + _MockCompletionInstallation(); +} + +void main() { + group('PrintCompletionScriptCommand', () { + late _TestCompletionCommandRunner commandRunner; + + setUp(() { + commandRunner = _TestCompletionCommandRunner(); + }); + + test('can be instantiated', () { + expect(PrintCompletionScriptCommand(), isNotNull); + }); + + test('is not hidden', () { + expect(PrintCompletionScriptCommand().hidden, isFalse); + }); + + test('description', () { + expect( + PrintCompletionScriptCommand().description, + 'Prints the completion script for the current shell to stdout.', + ); + }); + + group('completion-script', () { + test('prints the completion script to stdout', () async { + when( + () => commandRunner.completionInstallation.completionScriptFor( + commandRunner.executableName, + ), + ).thenReturn('some completion script'); + + await commandRunner.run(['completion-script']); + + verify( + () => commandRunner.completionLogger.info('some completion script'), + ).called(1); + }); + + test( + 'logs a warning when it throws a CompletionInstallationException', + () async { + when( + () => commandRunner.completionInstallation.completionScriptFor( + commandRunner.executableName, + ), + ).thenThrow( + CompletionInstallationException( + message: 'oops', + rootCommand: 'test', + ), + ); + + await commandRunner.run(['completion-script']); + + verify( + () => commandRunner.completionInstallationLogger.warn(any()), + ).called(1); + }, + ); + + test( + 'logs an error when an unknown exception happens', + () async { + when( + () => commandRunner.completionInstallation.completionScriptFor( + commandRunner.executableName, + ), + ).thenThrow(Exception('oops')); + + await commandRunner.run(['completion-script']); + + verify( + () => commandRunner.completionInstallationLogger.err(any()), + ).called(1); + }, + ); + }); + }); +} diff --git a/test/src/command_runner/completion_command_runner_test.dart b/test/src/command_runner/completion_command_runner_test.dart index 3ab73e2..7968dd0 100644 --- a/test/src/command_runner/completion_command_runner_test.dart +++ b/test/src/command_runner/completion_command_runner_test.dart @@ -34,6 +34,15 @@ class _TestCompletionCommandRunner extends CompletionCommandRunner { mockCompletionInstallation ?? super.completionInstallation; } +class _TestNoAutoInstallCompletionCommandRunner + extends CompletionCommandRunner { + _TestNoAutoInstallCompletionCommandRunner() + : super('test', 'Test command runner'); + + @override + bool get enableAutoInstall => false; +} + class _TestUserCommand extends Command { @override String get description => 'some command'; @@ -114,6 +123,32 @@ void main() { ); }); + group('print completion script command', () { + test( + 'is not added when auto install is enabled', + () { + final commandRunner = _TestCompletionCommandRunner(); + + expect( + commandRunner.commands.keys, + isNot(contains('completion-script')), + ); + }, + ); + + test( + 'is added when auto install is disabled', + () { + final commandRunner = _TestNoAutoInstallCompletionCommandRunner(); + + expect( + commandRunner.commands.keys, + contains('completion-script'), + ); + }, + ); + }); + group('auto install', () { test('Tries to install completion files on test subcommand', () async { final commandRunner = _TestCompletionCommandRunner() diff --git a/test/src/installer/completion_installation_test.dart b/test/src/installer/completion_installation_test.dart index bcd302d..6fa9a1f 100644 --- a/test/src/installer/completion_installation_test.dart +++ b/test/src/installer/completion_installation_test.dart @@ -110,6 +110,56 @@ void main() { }); }); + group('completionScriptFor', () { + test('returns the completion script for the given shell', () { + final zshInstallation = CompletionInstallation( + configuration: zshConfiguration, + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDir.path, + }, + ); + + expect( + zshInstallation.completionScriptFor('very_good'), + zshConfiguration.scriptTemplate('very_good'), + ); + + final bashInstallation = CompletionInstallation( + configuration: bashConfiguration, + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDir.path, + }, + ); + + expect( + bashInstallation.completionScriptFor('very_good'), + bashConfiguration.scriptTemplate('very_good'), + ); + }); + + test('throws when the shell is unknown', () { + final installation = CompletionInstallation.fromSystemShell( + systemShell: null, + logger: logger, + ); + + expect( + () => installation.completionScriptFor('very_good'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Unknown shell.', + ), + ), + ); + }); + }); + group('install', () { test('createCompletionConfigDir', () { final installation = CompletionInstallation(