@@ -113,6 +113,13 @@ abstract class AbstractCommand
113113 private ?string $ lastOptionalArgument = null ;
114114 private ?string $ lastArrayArgument = null ;
115115
116+ /**
117+ * Whether the command is in interactive mode. When `null`, the interactive state is resolved based
118+ * on the presence of the `--no-interaction` option and whether STDIN is a TTY. If boolean, this value
119+ * takes precedence over the flag and TTY detection.
120+ */
121+ private ?bool $ interactive = null ;
122+
116123 /**
117124 * @throws InvalidArgumentDefinitionException
118125 * @throws InvalidOptionDefinitionException
@@ -343,6 +350,41 @@ public function hasNegation(string $name): bool
343350 return array_key_exists ($ name , $ this ->negations );
344351 }
345352
353+ /**
354+ * Reports whether the command is currently in interactive mode.
355+ *
356+ * Resolution order:
357+ * 1. An explicit `setInteractive()` call wins.
358+ * 2. Otherwise, the command is interactive when STDIN is a TTY.
359+ *
360+ * Non-CLI contexts (e.g., a controller invoking `command()`) don't expose
361+ * `STDIN` at all — those always resolve as non-interactive.
362+ *
363+ * Note: the `--no-interaction` / `-N` flag is folded into the explicit state
364+ * by `run()` before interactive hooks fire, so callers do not need to
365+ * inspect the options array themselves.
366+ */
367+ public function isInteractive (): bool
368+ {
369+ if ($ this ->interactive !== null ) {
370+ return $ this ->interactive ;
371+ }
372+
373+ return defined ('STDIN ' ) && CLI ::streamSupports ('stream_isatty ' , \STDIN );
374+ }
375+
376+ /**
377+ * Pins the interactive state, overriding both the `--no-interaction` flag
378+ * and STDIN TTY detection. Typically called from `initialize()` or by
379+ * an outer caller that needs to force a specific mode.
380+ */
381+ public function setInteractive (bool $ interactive ): static
382+ {
383+ $ this ->interactive = $ interactive ;
384+
385+ return $ this ;
386+ }
387+
346388 /**
347389 * Runs the command.
348390 *
@@ -377,8 +419,13 @@ final public function run(array $arguments, array $options): int
377419 {
378420 $ this ->initialize ($ arguments , $ options );
379421
380- // @todo add interactive mode check
381- $ this ->interact ($ arguments , $ options );
422+ if ($ this ->interactive === null && $ this ->hasUnboundOption ('no-interaction ' , $ options )) {
423+ $ this ->interactive = false ;
424+ }
425+
426+ if ($ this ->isInteractive ()) {
427+ $ this ->interact ($ arguments , $ options );
428+ }
382429
383430 $ this ->unboundArguments = $ arguments ;
384431 $ this ->unboundOptions = $ options ;
@@ -447,12 +494,17 @@ abstract protected function execute(array $arguments, array $options): int;
447494 /**
448495 * Calls another command from the current command.
449496 *
450- * @param list<string> $arguments Parsed arguments from command line.
451- * @param array<string, list<string>|string|null> $options Parsed options from command line.
497+ * @param list<string> $arguments Parsed arguments from command line.
498+ * @param array<string, list<string>|string|null> $options Parsed options from command line.
499+ * @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state;
500+ * `true` forces the sub-command non-interactive by injecting
501+ * `--no-interaction`; `false` strips any inherited
502+ * `--no-interaction` so the sub-command resolves its own state
503+ * (TTY detection may still downgrade it).
452504 */
453- protected function call (string $ command , array $ arguments = [], array $ options = []): int
505+ protected function call (string $ command , array $ arguments = [], array $ options = [], ? bool $ noInteractionOverride = null ): int
454506 {
455- return $ this ->commands ->runCommand ($ command , $ arguments , $ options );
507+ return $ this ->commands ->runCommand ($ command , $ arguments , $ this -> resolveChildInteractiveState ( $ options, $ noInteractionOverride ) );
456508 }
457509
458510 /**
@@ -609,11 +661,74 @@ protected function getValidatedOption(string $name): array|bool|string|null
609661 return $ this ->validatedOptions [$ name ];
610662 }
611663
664+ /**
665+ * Registers the options that the framework injects into every modern
666+ * command. Every option registered here is load-bearing:
667+ *
668+ * - `--help` / `-h`: `Console` detects it and routes to the `help` command.
669+ * - `--no-header`: `Console` strips it before rendering the banner.
670+ * - `--no-interaction` / `-N`: `run()` folds it into the interactive state
671+ * and `resolveChildInteractiveState()` reads it to drive the `call()` cascade.
672+ *
673+ * Subclasses that override this hook should re-register these options or
674+ * accept that the corresponding framework features will be broken for
675+ * the subclass.
676+ */
612677 protected function provideDefaultOptions (): void
613678 {
614679 $ this
615680 ->addOption (new Option (name: 'help ' , shortcut: 'h ' , description: 'Display help for the given command. ' ))
616- ->addOption (new Option (name: 'no-header ' , description: 'Do not display the banner when running the command. ' ));
681+ ->addOption (new Option (name: 'no-header ' , description: 'Do not display the banner when running the command. ' ))
682+ ->addOption (new Option (name: 'no-interaction ' , shortcut: 'N ' , description: 'Do not ask any interactive questions. ' ));
683+ }
684+
685+ /**
686+ * Reconciles the caller's explicit intent (`$noInteractionOverride`) with
687+ * the parent command's own interactive state to produce the `$options`
688+ * that `call()` should hand to the sub-command.
689+ *
690+ * - `null` (default) propagates the parent's non-interactive state by
691+ * adding `--no-interaction` when the parent itself is non-interactive.
692+ * If the caller already supplied `--no-interaction` under any of its
693+ * aliases, their value is preserved.
694+ * - `true` forces the sub-command non-interactive regardless of the
695+ * parent, again deferring to a caller-supplied value if present.
696+ * - `false` strips any inherited or propagated `--no-interaction` so the
697+ * sub-command resolves its own state. TTY detection can still force
698+ * non-interactive if STDIN is not a TTY.
699+ *
700+ * @param array<string, list<string|null>|string|null> $options
701+ *
702+ * @return array<string, list<string|null>|string|null>
703+ */
704+ private function resolveChildInteractiveState (array $ options , ?bool $ noInteractionOverride ): array
705+ {
706+ $ this ->assertOptionIsDefined ('no-interaction ' );
707+
708+ if ($ noInteractionOverride === false ) {
709+ $ definition = $ this ->optionsDefinition ['no-interaction ' ];
710+
711+ $ aliases = array_filter (
712+ [$ definition ->name , $ definition ->shortcut , $ definition ->negation ],
713+ static fn (?string $ alias ): bool => $ alias !== null ,
714+ );
715+
716+ foreach ($ aliases as $ alias ) {
717+ unset($ options [$ alias ]);
718+ }
719+
720+ return $ options ;
721+ }
722+
723+ if ($ this ->hasUnboundOption ('no-interaction ' , $ options )) {
724+ return $ options ;
725+ }
726+
727+ if ($ noInteractionOverride === true || ! $ this ->isInteractive ()) {
728+ $ options ['no-interaction ' ] = null ; // simulate --no-interaction being passed
729+ }
730+
731+ return $ options ;
617732 }
618733
619734 /**
0 commit comments