@@ -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 /**
@@ -613,7 +665,55 @@ protected function provideDefaultOptions(): void
613665 {
614666 $ this
615667 ->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. ' ));
668+ ->addOption (new Option (name: 'no-header ' , description: 'Do not display the banner when running the command. ' ))
669+ ->addOption (new Option (name: 'no-interaction ' , shortcut: 'N ' , description: 'Do not ask any interactive questions. ' ));
670+ }
671+
672+ /**
673+ * Reconciles the caller's explicit intent (`$noInteractionOverride`) with
674+ * the parent command's own interactive state to produce the `$options`
675+ * that `call()` should hand to the sub-command.
676+ *
677+ * - `null` (default) propagates the parent's non-interactive state by
678+ * adding `--no-interaction` when the parent itself is non-interactive.
679+ * If the caller already supplied `--no-interaction` under any of its
680+ * aliases, their value is preserved.
681+ * - `true` forces the sub-command non-interactive regardless of the
682+ * parent, again deferring to a caller-supplied value if present.
683+ * - `false` strips any inherited or propagated `--no-interaction` so the
684+ * sub-command resolves its own state. TTY detection can still force
685+ * non-interactive if STDIN is not a TTY.
686+ *
687+ * @param array<string, list<string|null>|string|null> $options
688+ *
689+ * @return array<string, list<string|null>|string|null>
690+ */
691+ private function resolveChildInteractiveState (array $ options , ?bool $ noInteractionOverride ): array
692+ {
693+ if ($ noInteractionOverride === false ) {
694+ $ definition = $ this ->optionsDefinition ['no-interaction ' ];
695+
696+ $ aliases = array_filter (
697+ [$ definition ->name , $ definition ->shortcut , $ definition ->negation ],
698+ static fn (?string $ alias ): bool => $ alias !== null ,
699+ );
700+
701+ foreach ($ aliases as $ alias ) {
702+ unset($ options [$ alias ]);
703+ }
704+
705+ return $ options ;
706+ }
707+
708+ if ($ this ->hasUnboundOption ('no-interaction ' , $ options )) {
709+ return $ options ;
710+ }
711+
712+ if ($ noInteractionOverride === true || ! $ this ->isInteractive ()) {
713+ $ options ['no-interaction ' ] = null ; // simulate --no-interaction being passed
714+ }
715+
716+ return $ options ;
617717 }
618718
619719 /**
0 commit comments