From 704f2c314aa50f67396db85b4c5b723673e2af57 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Wed, 4 Nov 2026 16:07:36 +0100 Subject: [PATCH] fix(cli): dispatch executable-named scopes without duplicate argv token --- README.md | 48 +++++++++++++++++++ spec/CliApplicationSpec.php | 92 +++++++++++++++++++++++++++++++++++++ src/CliApplication.php | 7 +-- src/Types/T_CommandSet.php | 37 ++++++++++++++- 4 files changed, 179 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 336946d..9a3456f 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,54 @@ Typen gleichnamiger Scope-Optionen führen bei der Registrierung zu einem Fehler. `CliParameter` benennt nur die CLI-Option um; die Bindung an den PHP-Parameter erfolgt weiterhin über dessen PHP-Namen. +## Executable-Name und impliziter Scope + +`CliApplication::run($argv)` bestimmt den Programmnamen aus dem Basename +von `$argv[0]`. Ein registrierter `CliScope` mit genau diesem Namen ist +automatisch die Programmebene; seine Actions sind ohne Wiederholung des +Programnamens erreichbar. Andere Scopes bleiben eigenständige Gruppen. + +Beispielsweise kann das Executable `tool` die folgenden Klassen registrieren: + +```php +#[CliScope('tool')] +class GeneralCommands +{ + public function init(): void + { + echo 'Initialized'; + } +} + +#[CliScope('edit')] +class EditCommands +{ + public function create(): void + { + echo 'Created'; + } + + public function resolve(): void + { + echo 'Resolved'; + } +} +``` + +```php +$app = new CliApplication(); +$app->addClass(GeneralCommands::class); +$app->addClass(EditCommands::class); +$app->run($argv); +``` + +Damit sind `tool init`, `tool edit create` und `tool edit resolve` +möglich, ohne den Programmnamen künstlich in `$argv` einzufügen. Die +Hilfe unter `tool -h` zeigt die Actions des impliziten Scopes direkt. +Aufrufe mit explizitem Scope wie `tool tool init` bleiben möglich. +Ein Executable ohne namensgleichen Scope verwendet weiterhin die +bisherigen expliziten Gruppen. + ## Sub-Commands und Scope-Optionen Ein `CliScope` kann als Pfad angegeben werden. Dadurch lassen sich beliebig diff --git a/spec/CliApplicationSpec.php b/spec/CliApplicationSpec.php index 74ef2a2..67bf966 100644 --- a/spec/CliApplicationSpec.php +++ b/spec/CliApplicationSpec.php @@ -88,6 +88,29 @@ public function run(): void } } +#[CliScope('tool')] +class ExecutableCommands +{ + public function init(): void + { + echo 'initialized'; + } +} + +#[CliScope('edit')] +class ExecutableEditCommands +{ + public function create(): void + { + echo 'created'; + } + + public function resolve(): void + { + echo 'resolved'; + } +} + class CliApplicationSpec extends ObjectBehavior { public function it_combines_actions_from_classes_in_the_same_scope(): void @@ -217,6 +240,75 @@ public function it_shows_nested_subcommands_and_parent_options_in_help(): void } } + public function it_treats_the_executable_name_as_an_implicit_scope(): void + { + $this->addClass(ExecutableCommands::class); + $this->addClass(ExecutableEditCommands::class); + + ob_start(); + $this->run(['/usr/local/bin/tool', 'init']); + $output = ob_get_clean(); + if (trim($output) !== 'initialized') { + throw new \RuntimeException('Implicit executable scope did not dispatch init.'); + } + + ob_start(); + $this->run(['/usr/local/bin/tool', 'edit', 'create']); + $output = ob_get_clean(); + if (trim($output) !== 'created') { + throw new \RuntimeException('Independent edit scope did not dispatch create.'); + } + + ob_start(); + $this->run(['tool', 'edit', 'resolve']); + $output = ob_get_clean(); + if (trim($output) !== 'resolved') { + throw new \RuntimeException('Independent edit scope did not dispatch resolve.'); + } + + // The previous explicit scope form stays supported. + ob_start(); + $this->run(['tool', 'tool', 'init']); + $output = ob_get_clean(); + if (trim($output) !== 'initialized') { + throw new \RuntimeException('Explicit legacy scope must still work.'); + } + } + + public function it_flattens_only_the_executable_scope_in_help(): void + { + $this->addClass(ExecutableCommands::class); + $this->addClass(ExecutableEditCommands::class); + + ob_start(); + $this->run(['/usr/local/bin/tool', '-h']); + $help = ob_get_clean(); + if (! str_contains($help, "\ntool [command]") || ! str_contains($help, 'init') + || ! str_contains($help, 'edit [COMMAND]') + || str_contains($help, "\n tool [COMMAND]")) { + throw new \RuntimeException('Executable scope must be implicit in help.'); + } + + ob_start(); + $this->run(['tool', 'edit', '-h']); + $help = ob_get_clean(); + if (! str_contains($help, 'create') || ! str_contains($help, 'resolve')) { + throw new \RuntimeException('Edit subcommands must be discoverable in help.'); + } + } + + public function it_preserves_regular_explicit_scopes_without_a_matching_executable_scope(): void + { + $this->addClass(CreateAction::class); + + ob_start(); + $this->run(['tool', 'project', 'inspect', 'example']); + $output = ob_get_clean(); + if (trim($output) !== 'example') { + throw new \RuntimeException('Other executable names must retain explicit scopes.'); + } + } + public function it_keeps_command_descriptions_optional(): void { $command = new CliCommand('plain'); diff --git a/src/CliApplication.php b/src/CliApplication.php index fe6e609..4d653cd 100644 --- a/src/CliApplication.php +++ b/src/CliApplication.php @@ -51,8 +51,9 @@ public function addClass(string $className): void /** * Executes argv and writes command or help output directly to the current output stream. * - * The first element is the executable name. Exceptions propagate to the - * caller. The method deliberately does not start or manipulate PHP output + * The first element is the executable path or name. A registered scope + * matching its basename is implicitly available at the command root. + * Exceptions propagate to the caller. The method deliberately does not start or manipulate PHP output * buffering, so embedding applications retain full control over output. * * Example: $app->run(['tool', 'project', 'create', '--name', 'demo']); @@ -65,7 +66,7 @@ public function addClass(string $className): void public function run(array $argv): void { $name = array_shift($argv); - $this->commandSet->setName($name ?? ''); + $this->commandSet->setName(basename($name ?? '')); $presetContainer = new CliPreset(); $presetFile = getcwd() . '/cli_presets.ini'; diff --git a/src/Types/T_CommandSet.php b/src/Types/T_CommandSet.php index 2f72202..5982684 100644 --- a/src/Types/T_CommandSet.php +++ b/src/Types/T_CommandSet.php @@ -58,12 +58,15 @@ public function getHelp( $sig .= "\n" . $this->desc . "\n"; } + // Der Scope des Executables wird in der Hilfe als Programmebene + // angezeigt; Untergruppen wie edit bleiben explizit sichtbar. + $visibleCommands = $this->commandsForExecutable(); $nameWidth = 0; - foreach ($this->commands as $command) { + foreach ($visibleCommands as $command) { $suffix = $command instanceof T_CommandGroup ? " [COMMAND]" : ""; $nameWidth = max($nameWidth, strlen($command->name . $suffix)); } - foreach ($this->commands as $command) { + foreach ($visibleCommands as $command) { $sig .= $command->getHelp($detailed, $nameWidth, false, 2); } foreach ($this->cliPresets as $preset) { @@ -77,6 +80,26 @@ public function getHelp( return $sig; } + /** + * Shows commands of a scope named like the executable at the root level. + * + * Other scopes remain unaffected; the original registry is not mutated. + * + * @return T_Command[] + */ + private function commandsForExecutable(): array + { + $commands = []; + foreach ($this->commands as $command) { + if ($command instanceof T_CommandGroup && $command->name === $this->name) { + array_push($commands, ...$command->commands); + continue; + } + $commands[] = $command; + } + return $commands; + } + public function setName($name) { $this->name = $name; @@ -94,12 +117,22 @@ public function dispatch(array $argv, array &$arguments = [], $object = null): v echo $this->getHelp(false); return; } + // Explizite Top-Level-Scopes behalten Vorrang. foreach ($this->commands as $cmd) { if ($cmd->name === $command) { $cmd->dispatch($argv, $arguments, $object); return; } } + + // Ein Scope mit dem Namen des Executables ist implizit aktiv. + // Dadurch braucht der Launcher keine doppelten argv-Tokens. + foreach ($this->commands as $cmd) { + if ($cmd instanceof T_CommandGroup && $cmd->name === $this->name) { + $cmd->dispatch([$command, ...$argv], $arguments, $object); + return; + } + } throw new CliException("Command '$command' not found."); } }