From 6cfa8cede6d1e40edb6e825fa03d9d40cc4d3ecc Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 24 Apr 2026 02:37:25 +0800 Subject: [PATCH] feat: add non-interactive session checking to modern commands --- system/CLI/AbstractCommand.php | 129 +++++++++++++- system/Commands/Housekeeping/ClearLogs.php | 5 +- .../Modern/InteractiveStateProbeCommand.php | 47 +++++ .../ParentCallsInteractFixtureCommand.php | 40 +++++ tests/system/CLI/AbstractCommandTest.php | 165 +++++++++++++++++- tests/system/Commands/HelpCommandTest.php | 29 +-- .../Commands/Housekeeping/ClearLogsTest.php | 30 ++++ user_guide_src/source/changelogs/v4.8.0.rst | 4 + .../source/cli/cli_modern_commands.rst | 85 +++++++-- 9 files changed, 494 insertions(+), 40 deletions(-) create mode 100644 tests/_support/Commands/Modern/InteractiveStateProbeCommand.php create mode 100644 tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index f966155dcffe..f859651edc9a 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -113,6 +113,13 @@ abstract class AbstractCommand private ?string $lastOptionalArgument = null; private ?string $lastArrayArgument = null; + /** + * Whether the command is in interactive mode. When `null`, the interactive state is resolved based + * on the presence of the `--no-interaction` option and whether STDIN is a TTY. If boolean, this value + * takes precedence over the flag and TTY detection. + */ + private ?bool $interactive = null; + /** * @throws InvalidArgumentDefinitionException * @throws InvalidOptionDefinitionException @@ -343,6 +350,41 @@ public function hasNegation(string $name): bool return array_key_exists($name, $this->negations); } + /** + * Reports whether the command is currently in interactive mode. + * + * Resolution order: + * 1. An explicit `setInteractive()` call wins. + * 2. Otherwise, the command is interactive when STDIN is a TTY. + * + * Non-CLI contexts (e.g., a controller invoking `command()`) don't expose + * `STDIN` at all — those always resolve as non-interactive. + * + * Note: the `--no-interaction` / `-N` flag is folded into the explicit state + * by `run()` before interactive hooks fire, so callers do not need to + * inspect the options array themselves. + */ + public function isInteractive(): bool + { + if ($this->interactive !== null) { + return $this->interactive; + } + + return defined('STDIN') && CLI::streamSupports('stream_isatty', \STDIN); + } + + /** + * Pins the interactive state, overriding both the `--no-interaction` flag + * and STDIN TTY detection. Typically called from `initialize()` or by + * an outer caller that needs to force a specific mode. + */ + public function setInteractive(bool $interactive): static + { + $this->interactive = $interactive; + + return $this; + } + /** * Runs the command. * @@ -377,8 +419,13 @@ final public function run(array $arguments, array $options): int { $this->initialize($arguments, $options); - // @todo add interactive mode check - $this->interact($arguments, $options); + if ($this->interactive === null && $this->hasUnboundOption('no-interaction', $options)) { + $this->interactive = false; + } + + if ($this->isInteractive()) { + $this->interact($arguments, $options); + } $this->unboundArguments = $arguments; $this->unboundOptions = $options; @@ -447,12 +494,17 @@ abstract protected function execute(array $arguments, array $options): int; /** * Calls another command from the current command. * - * @param list $arguments Parsed arguments from command line. - * @param array|string|null> $options Parsed options from command line. + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + * @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state; + * `true` forces the sub-command non-interactive by injecting + * `--no-interaction`; `false` strips any inherited + * `--no-interaction` so the sub-command resolves its own state + * (TTY detection may still downgrade it). */ - protected function call(string $command, array $arguments = [], array $options = []): int + protected function call(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null): int { - return $this->commands->runCommand($command, $arguments, $options); + return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride)); } /** @@ -609,11 +661,74 @@ protected function getValidatedOption(string $name): array|bool|string|null return $this->validatedOptions[$name]; } + /** + * Registers the options that the framework injects into every modern + * command. Every option registered here is load-bearing: + * + * - `--help` / `-h`: `Console` detects it and routes to the `help` command. + * - `--no-header`: `Console` strips it before rendering the banner. + * - `--no-interaction` / `-N`: `run()` folds it into the interactive state + * and `resolveChildInteractiveState()` reads it to drive the `call()` cascade. + * + * Subclasses that override this hook should re-register these options or + * accept that the corresponding framework features will be broken for + * the subclass. + */ protected function provideDefaultOptions(): void { $this ->addOption(new Option(name: 'help', shortcut: 'h', description: 'Display help for the given command.')) - ->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.')); + ->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.')) + ->addOption(new Option(name: 'no-interaction', shortcut: 'N', description: 'Do not ask any interactive questions.')); + } + + /** + * Reconciles the caller's explicit intent (`$noInteractionOverride`) with + * the parent command's own interactive state to produce the `$options` + * that `call()` should hand to the sub-command. + * + * - `null` (default) propagates the parent's non-interactive state by + * adding `--no-interaction` when the parent itself is non-interactive. + * If the caller already supplied `--no-interaction` under any of its + * aliases, their value is preserved. + * - `true` forces the sub-command non-interactive regardless of the + * parent, again deferring to a caller-supplied value if present. + * - `false` strips any inherited or propagated `--no-interaction` so the + * sub-command resolves its own state. TTY detection can still force + * non-interactive if STDIN is not a TTY. + * + * @param array|string|null> $options + * + * @return array|string|null> + */ + private function resolveChildInteractiveState(array $options, ?bool $noInteractionOverride): array + { + $this->assertOptionIsDefined('no-interaction'); + + if ($noInteractionOverride === false) { + $definition = $this->optionsDefinition['no-interaction']; + + $aliases = array_filter( + [$definition->name, $definition->shortcut, $definition->negation], + static fn (?string $alias): bool => $alias !== null, + ); + + foreach ($aliases as $alias) { + unset($options[$alias]); + } + + return $options; + } + + if ($this->hasUnboundOption('no-interaction', $options)) { + return $options; + } + + if ($noInteractionOverride === true || ! $this->isInteractive()) { + $options['no-interaction'] = null; // simulate --no-interaction being passed + } + + return $options; } /** diff --git a/system/Commands/Housekeeping/ClearLogs.php b/system/Commands/Housekeeping/ClearLogs.php index 1eb375aa3481..4c6633076a58 100644 --- a/system/Commands/Housekeeping/ClearLogs.php +++ b/system/Commands/Housekeeping/ClearLogs.php @@ -51,8 +51,9 @@ protected function execute(array $arguments, array $options): int if ($options['force'] === false) { CLI::error('Deleting logs aborted.'); - // @todo to re-add under non-interactive mode - // CLI::error('If you want, use the "--force" option to force delete all log files.'); + if (! $this->isInteractive()) { + CLI::error('If you want, use the "--force" option to force delete all log files.'); + } return EXIT_ERROR; } diff --git a/tests/_support/Commands/Modern/InteractiveStateProbeCommand.php b/tests/_support/Commands/Modern/InteractiveStateProbeCommand.php new file mode 100644 index 000000000000..2696da5e6282 --- /dev/null +++ b/tests/_support/Commands/Modern/InteractiveStateProbeCommand.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'test:probe', description: 'Fixture that records its interactive state so the caller can assert on it.', group: 'Fixtures')] +final class InteractiveStateProbeCommand extends AbstractCommand +{ + /** + * Records whether `interact()` fired during the last run — a side-channel + * for asserting on a child fixture created anonymously by `Commands::runCommand()`. + */ + public static bool $interactCalled = false; + + public static ?bool $observedInteractive = null; + + public static function reset(): void + { + self::$interactCalled = false; + self::$observedInteractive = null; + } + + protected function interact(array &$arguments, array &$options): void + { + self::$interactCalled = true; + } + + protected function execute(array $arguments, array $options): int + { + self::$observedInteractive = $this->isInteractive(); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php b/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php new file mode 100644 index 000000000000..cbe7f0285338 --- /dev/null +++ b/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'test:parent-interact', description: 'Fixture that delegates to test:probe via call().', group: 'Fixtures')] +final class ParentCallsInteractFixtureCommand extends AbstractCommand +{ + /** + * Forwarded verbatim as the `$noInteractionOverride` argument of `call()`. + * `null` leaves the default propagation behavior in place. + */ + public ?bool $childNoInteractionOverride = null; + + /** + * Forwarded verbatim as the `$options` argument of `call()`. Lets tests + * exercise the resolver's caller-provided-flag code paths. + * + * @var array|string|null> + */ + public array $childOptions = []; + + protected function execute(array $arguments, array $options): int + { + return $this->call('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride); + } +} diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index aa7bce9110e0..86ae7fe69c54 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -36,6 +36,8 @@ use ReflectionClass; use Tests\Support\Commands\Modern\AppAboutCommand; use Tests\Support\Commands\Modern\InteractFixtureCommand; +use Tests\Support\Commands\Modern\InteractiveStateProbeCommand; +use Tests\Support\Commands\Modern\ParentCallsInteractFixtureCommand; use Tests\Support\Commands\Modern\TestFixtureCommand; use Throwable; @@ -55,6 +57,8 @@ protected function resetAll(): void $this->resetServices(); CLI::reset(); + + InteractiveStateProbeCommand::reset(); } private function getUndecoratedBuffer(): string @@ -93,14 +97,14 @@ public function testCommandCanGetDefinitions(): void $command = new Help(new Commands()); $this->assertCount(1, $command->getArgumentsDefinition()); - $this->assertCount(2, $command->getOptionsDefinition()); - $this->assertCount(1, $command->getShortcuts()); + $this->assertCount(3, $command->getOptionsDefinition()); + $this->assertCount(2, $command->getShortcuts()); $this->assertEmpty($command->getNegations()); } public function testCommandHasDefaultOptions(): void { - $defaultOptions = ['help', 'no-header']; + $defaultOptions = ['help', 'no-header', 'no-interaction']; $this->assertSame($defaultOptions, array_keys((new Help(new Commands()))->getOptionsDefinition())); } @@ -237,8 +241,10 @@ public function testCheckingOfArgumentsAndOptions(): void $this->assertFalse($command->hasArgument('lorem')); $this->assertTrue($command->hasOption('help')); $this->assertTrue($command->hasOption('no-header')); + $this->assertTrue($command->hasOption('no-interaction')); $this->assertFalse($command->hasOption('lorem')); $this->assertTrue($command->hasShortcut('h')); + $this->assertTrue($command->hasShortcut('N')); $this->assertFalse($command->hasShortcut('x')); $this->assertFalse($command->hasNegation('no-help')); } @@ -673,6 +679,146 @@ public function testInteractMutationsCarryThroughToExecute(): void $this->assertTrue($command->executedOptions['force']); } + public function testInteractIsSkippedWhenNoInteractionFlagIsPassed(): void + { + $command = new InteractFixtureCommand(new Commands()); + $command->run([], ['no-interaction' => null]); + + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + $this->assertFalse($command->executedOptions['force']); + } + + public function testInteractIsSkippedWhenShortcutFlagIsPassed(): void + { + $command = new InteractFixtureCommand(new Commands()); + $command->run([], ['N' => null]); + + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + $this->assertFalse($command->executedOptions['force']); + } + + public function testInteractIsSkippedWhenSetInteractiveFalseIsCalled(): void + { + $command = new InteractFixtureCommand(new Commands()); + $command->setInteractive(false); + $command->run([], []); + + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + $this->assertFalse($command->executedOptions['force']); + } + + public function testSetInteractiveTrueOverridesNoInteractionFlag(): void + { + // Explicit caller intent wins over the CLI flag. + $command = new InteractFixtureCommand(new Commands()); + $command->setInteractive(true); + $command->run([], ['no-interaction' => null]); + + $this->assertSame(['name' => 'from-interact'], $command->executedArguments); + $this->assertTrue($command->executedOptions['force']); + } + + public function testIsInteractiveReflectsExplicitState(): void + { + $command = new InteractFixtureCommand(new Commands()); + + // Default: in the testing env, `CLI::streamSupports('stream_isatty', STDIN)` + // resolves to `function_exists('stream_isatty')`, which is true on PHP 8.1+. + $this->assertTrue($command->isInteractive()); + + $command->setInteractive(false); + $this->assertFalse($command->isInteractive()); + + $command->setInteractive(true); + $this->assertTrue($command->isInteractive()); + } + + public function testNoInteractionCascadesToSubCommandsViaCall(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $exitCode = $command->run([], ['no-interaction' => null]); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFalse(InteractiveStateProbeCommand::$interactCalled); + $this->assertFalse(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testSubCommandStaysInteractiveWhenParentIsInteractive(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue(InteractiveStateProbeCommand::$interactCalled); + $this->assertTrue(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testCallAllowsSubCommandInteractiveEvenWhenParentIsNonInteractive(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + $command->setInteractive(false); + $command->childNoInteractionOverride = false; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue(InteractiveStateProbeCommand::$interactCalled); + $this->assertTrue(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testCallForcesSubCommandNonInteractiveEvenWhenParentIsInteractive(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $command->childNoInteractionOverride = true; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFalse(InteractiveStateProbeCommand::$interactCalled); + $this->assertFalse(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testCallStripsInheritedNoInteractionWhenCallerAllowsInteraction(): void + { + // Caller passes --no-interaction in the sub-command's options, but also + // sets noInteractionOverride to false: the explicit parameter wins and + // the inherited flag is stripped under both its long name and its shortcut. + + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $command->childNoInteractionOverride = false; + + $command->childOptions = ['no-interaction' => null, 'N' => null]; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue(InteractiveStateProbeCommand::$interactCalled); + $this->assertTrue(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testCallPreservesCallerFlagWhenForcingNonInteractive(): void + { + // When $noInteractionOverride is true and the caller already supplied the flag, + // the resolver must not touch the caller's entry — proved by the child + // still seeing a non-interactive state. + + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $command->childNoInteractionOverride = true; + + $command->childOptions = ['no-interaction' => null]; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFalse(InteractiveStateProbeCommand::$interactCalled); + $this->assertFalse(InteractiveStateProbeCommand::$observedInteractive); + } + /** * @param array|string|null> $options */ @@ -833,12 +979,13 @@ public function testGetValidatedOptionsReflectsDefaultsAfterBinding(): void $this->assertSame( [ - 'foo' => 'provided', - 'bar' => null, - 'baz' => ['a'], - 'quux' => false, - 'help' => false, - 'no-header' => false, + 'foo' => 'provided', + 'bar' => null, + 'baz' => ['a'], + 'quux' => false, + 'help' => false, + 'no-header' => false, + 'no-interaction' => false, ], $command->callGetValidatedOptions(), ); diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index 4cd8dbbde3dd..ab916b055739 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -55,11 +55,12 @@ public function testNoArgumentDescribesItself(): void Displays basic usage information. Arguments: - command_name The command name. [default: "help"] + command_name The command name. [default: "help"] Options: - -h, --help Display help for the given command. - --no-header Do not display the banner when running the command. + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. EOT, $this->getUndecoratedBuffer(), @@ -92,6 +93,7 @@ public function testDescribeCommandNoArguments(): void --quux|--no-quux Negatable option. -h, --help Display help for the given command. --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. EOT, $this->getUndecoratedBuffer(), @@ -112,11 +114,12 @@ public function testDescribeSpecificCommand(): void Clears the current system caches. Arguments: - driver The cache driver to use. [default: "file"] + driver The cache driver to use. [default: "file"] Options: - -h, --help Display help for the given command. - --no-header Do not display the banner when running the command. + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. EOT, $this->getUndecoratedBuffer(), @@ -204,11 +207,12 @@ public function testDescribeUsingHelpOption(): void Clears the current system caches. Arguments: - driver The cache driver to use. [default: "file"] + driver The cache driver to use. [default: "file"] Options: - -h, --help Display help for the given command. - --no-header Do not display the banner when running the command. + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. EOT, $this->getUndecoratedBuffer(), @@ -229,11 +233,12 @@ public function testDescribeUsingHelpShortOption(): void Clears the current system caches. Arguments: - driver The cache driver to use. [default: "file"] + driver The cache driver to use. [default: "file"] Options: - -h, --help Display help for the given command. - --no-header Do not display the banner when running the command. + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. EOT, $this->getUndecoratedBuffer(), diff --git a/tests/system/Commands/Housekeeping/ClearLogsTest.php b/tests/system/Commands/Housekeeping/ClearLogsTest.php index a8469873e84d..bf9b43efd111 100644 --- a/tests/system/Commands/Housekeeping/ClearLogsTest.php +++ b/tests/system/Commands/Housekeeping/ClearLogsTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; @@ -127,6 +128,35 @@ public function testClearLogsAbortsClearWithoutForceWithDefaultAnswer(): void ); } + #[DataProvider('provideClearLogsAbortsNonInteractivelyAndHintsAboutForceFlag')] + public function testClearLogsAbortsNonInteractivelyAndHintsAboutForceFlag(string $flag): void + { + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + + command("logs:clear {$flag}"); + + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + $this->assertSame( + <<<'EOT' + + Deleting logs aborted. + If you want, use the "--force" option to force delete all log files. + + EOT, + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); + } + + /** + * @return iterable + */ + public static function provideClearLogsAbortsNonInteractivelyAndHintsAboutForceFlag(): iterable + { + yield 'long form' => ['--no-interaction']; + + yield 'short form' => ['-N']; + } + public function testClearLogsWithoutForceButWithConfirmation(): void { $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index a9a33ee50165..89275526d231 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -179,6 +179,10 @@ Commands - Added a new attribute-based command style built on :php:class:`AbstractCommand ` and the ``#[Command]`` attribute, with ``configure()`` / ``initialize()`` / ``interact()`` / ``execute()`` hooks and typed ``Argument`` / ``Option`` definitions. The legacy ``BaseCommand`` style continues to work. See :doc:`../cli/cli_modern_commands`. +- Every modern command now ships with a ``--no-interaction`` / ``-N`` flag that skips the ``interact()`` hook, plus public + ``isInteractive()`` / ``setInteractive()`` methods on ``AbstractCommand``. ``isInteractive()`` also auto-detects piped or + CI environments by probing STDIN for a TTY, and the state cascades to sub-commands invoked via ``$this->call(...)``. + See :ref:`non-interactive-mode`. - You can now retrieve the last executed command in the console using the new ``Console::getCommand()`` method. This is useful for logging, debugging, or any situation where you need to know which command was run. - ``CLI`` now supports the ``--`` separator to mean that what follows are arguments, not options. This allows you to have arguments that start with ``-`` without them being treated as options. For example: ``spark my:command -- --myarg`` will pass ``--myarg`` as an argument instead of an option. diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index 7166a19c24c2..688e8a2cec23 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -65,15 +65,17 @@ this order: 1. **Construction.** The ``#[Command]`` attribute is read, then your ``configure(): void`` hook runs so you can register arguments, options, and - extra usage examples. A default ``--help``/ ``-h`` flag and ``--no-header`` - flag are added automatically afterwards. + extra usage examples. A default ``--help``/ ``-h`` flag, ``--no-header`` + flag, and ``--no-interaction``/ ``-N`` flag are added automatically + afterwards. 2. ``initialize(array &$arguments, array &$options): void`` receives the raw arguments and options by reference. Useful when your command needs to massage input — for instance, to unfold an alias argument into the canonical form before anything else runs. 3. ``interact(array &$arguments, array &$options): void`` also receives the raw arguments and options by reference. This is where you prompt the user - for missing input, set values conditionally, or abort early. + for missing input, set values conditionally, or abort early. This hook is + skipped when the command is non-interactive — see :ref:`non-interactive-mode`. 4. **Bind & validate.** The framework maps the raw input to the definitions you declared in ``configure()``, applies defaults, and rejects input that violates the definitions (missing required argument, unknown option, array @@ -137,7 +139,11 @@ A few quirks are worth knowing: - A negatable option cannot accept a value or be an array. Its default must be a boolean. - A negatable option's auto-generated ``--no-`` form will clash if another option is already named ``no-``. - Option names must match ``[A-Za-z0-9_-]+`` and the name ``extra_options`` is reserved. -- ``--help`` / ``-h`` and ``--no-header`` are reserved for the framework and registered on every command automatically. +- The following options are reserved for the framework and registered on every command automatically: + + - ``--help`` / ``-h`` + - ``--no-header`` + - ``--no-interaction`` / ``-N`` Configuration-time violations raise ``InvalidOptionDefinitionException``. @@ -177,6 +183,46 @@ snapshot taken right after ``interact()`` returns and before bind and validate. Any change you make to ``$arguments`` or ``$options`` inside ``interact()`` carries through to bind, validate, and ``execute()``. +.. _non-interactive-mode: + +Non-Interactive Mode +==================== + +Every modern command accepts ``--no-interaction`` / ``-N`` out of the box. +When the flag is present — or when the command is otherwise non-interactive — +the ``interact()`` hook is skipped entirely and the command proceeds straight +to bind, validate, and ``execute()``. + +Programmatically, the state is exposed through two public methods on +``AbstractCommand``: + +- ``isInteractive(): bool`` — reports the current state. +- ``setInteractive(bool $interactive): static`` — pins the state, overriding + both the CLI flag and TTY detection. Returns ``$this`` for chaining. + +The resolved state follows this precedence: + +1. An explicit ``setInteractive(bool)`` call wins — useful when a command + must force a specific mode for safety. +2. Otherwise, the CLI flag ``--no-interaction`` / ``-N`` forces non-interactive state. +3. Otherwise, **STDIN** is probed: if it is not a TTY (piped input, cron, + CI, ``nohup``), the command is non-interactive. +4. Otherwise, the command is interactive. + +When the current command invokes another via ``$this->call(...)``, the +parent's non-interactive state is propagated to the sub-command +automatically. A caller that passes ``no-interaction`` (or ``N``) in the +sub-command's ``$options`` wins over that propagation. + +The propagation can be overridden with the ``$noInteractionOverride`` +parameter of ``call()``: + +- ``null`` (default) — propagate the parent's state. +- ``true`` — force the sub-command non-interactive regardless of the parent. +- ``false`` — strip any inherited ``--no-interaction`` so the sub-command + resolves its own state. Note: TTY detection can still downgrade the + sub-command if STDIN is not a TTY. + ****************** Inside execute() ****************** @@ -185,7 +231,8 @@ Inside execute() - ``$arguments`` contains every declared argument, bound to the provided value or the declared default. - ``$options`` contains every declared option plus the framework defaults - (``help``, ``no-header``), bound to the provided value or the declared default. + (``help``, ``no-header``, ``no-interaction``), bound to the provided value + or the declared default. Within ``execute()`` itself, reaching into ``$arguments`` / ``$options`` directly is the simplest thing to do. The same data is also available through helpers @@ -460,6 +507,20 @@ covered in the sections above and are not listed here. Returns ``true`` if the negation is registered by one of the declared options. + .. php:method:: isInteractive(): bool + + Reports whether the command will prompt the user. See + :ref:`non-interactive-mode` for the resolution order. + + .. php:method:: setInteractive(bool $interactive): static + + :param bool $interactive: The state to pin. + :returns: The current command instance for chaining. + + Overrides both the ``--no-interaction`` / ``-N`` flag and TTY + detection for this command instance. Typically called from + ``initialize()`` or by an outer caller. + .. php:method:: run(array $arguments, array $options): int :param array $arguments: The raw positional arguments parsed from the command line. @@ -471,12 +532,16 @@ covered in the sections above and are not listed here. on your behalf — you rarely invoke it directly, but you can when driving a command manually (for instance, from a test). - .. php:method:: call(string $command[, array $arguments = [], array $options = []]): int + .. php:method:: call(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null]): int - :param string $command: The name of the modern command to call. - :param array $arguments: Positional arguments to forward. - :param array $options: Options to forward, keyed by long name, shortcut, or negation. - :returns: The exit code returned by the called command. + :param string $command: The name of the modern command to call. + :param array $arguments: Positional arguments to forward. + :param array $options: Options to forward, keyed by long name, shortcut, or negation. + :param bool|null $noInteractionOverride: Override the sub-command's interactive state. + ``null`` propagates the parent's state (default); + ``true`` forces non-interactive; ``false`` strips + any inherited ``--no-interaction`` flag. + :returns: The exit code returned by the called command. Invokes another modern command. The arguments and options go through bind and validate on the target command, just like a user invocation.