@@ -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,34 @@ 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+ * Note: the `--no-interaction` / `-N` flag is folded into the explicit state
361+ * by `run()` before interactive hooks fire, so callers do not need to
362+ * inspect the options array themselves.
363+ */
364+ public function isInteractive (): bool
365+ {
366+ return $ this ->interactive ?? CLI ::streamSupports ('stream_isatty ' , STDIN );
367+ }
368+
369+ /**
370+ * Pins the interactive state, overriding both the `--no-interaction` flag
371+ * and STDIN TTY detection. Typically called from `initialize()` or by
372+ * an outer caller that needs to force a specific mode.
373+ */
374+ public function setInteractive (bool $ interactive ): static
375+ {
376+ $ this ->interactive = $ interactive ;
377+
378+ return $ this ;
379+ }
380+
346381 /**
347382 * Runs the command.
348383 *
@@ -377,8 +412,13 @@ final public function run(array $arguments, array $options): int
377412 {
378413 $ this ->initialize ($ arguments , $ options );
379414
380- // @todo add interactive mode check
381- $ this ->interact ($ arguments , $ options );
415+ if ($ this ->interactive === null && $ this ->hasUnboundOption ('no-interaction ' , $ options )) {
416+ $ this ->interactive = false ;
417+ }
418+
419+ if ($ this ->isInteractive ()) {
420+ $ this ->interact ($ arguments , $ options );
421+ }
382422
383423 $ this ->unboundArguments = $ arguments ;
384424 $ this ->unboundOptions = $ options ;
@@ -447,12 +487,17 @@ abstract protected function execute(array $arguments, array $options): int;
447487 /**
448488 * Calls another command from the current command.
449489 *
450- * @param list<string> $arguments Parsed arguments from command line.
451- * @param array<string, list<string>|string|null> $options Parsed options from command line.
490+ * @param list<string> $arguments Parsed arguments from command line.
491+ * @param array<string, list<string>|string|null> $options Parsed options from command line.
492+ * @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state;
493+ * `true` forces the sub-command non-interactive by injecting
494+ * `--no-interaction`; `false` strips any inherited
495+ * `--no-interaction` so the sub-command resolves its own state
496+ * (TTY detection may still downgrade it).
452497 */
453- protected function call (string $ command , array $ arguments = [], array $ options = []): int
498+ protected function call (string $ command , array $ arguments = [], array $ options = [], ? bool $ noInteractionOverride = null ): int
454499 {
455- return $ this ->commands ->runCommand ($ command , $ arguments , $ options );
500+ return $ this ->commands ->runCommand ($ command , $ arguments , $ this -> resolveChildInteractiveState ( $ options, $ noInteractionOverride ) );
456501 }
457502
458503 /**
@@ -613,7 +658,55 @@ protected function provideDefaultOptions(): void
613658 {
614659 $ this
615660 ->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. ' ));
661+ ->addOption (new Option (name: 'no-header ' , description: 'Do not display the banner when running the command. ' ))
662+ ->addOption (new Option (name: 'no-interaction ' , shortcut: 'N ' , description: 'Do not ask any interactive questions. ' ));
663+ }
664+
665+ /**
666+ * Reconciles the caller's explicit intent (`$noInteractionOverride`) with
667+ * the parent command's own interactive state to produce the `$options`
668+ * that `call()` should hand to the sub-command.
669+ *
670+ * - `null` (default) propagates the parent's non-interactive state by
671+ * adding `--no-interaction` when the parent itself is non-interactive.
672+ * If the caller already supplied `--no-interaction` under any of its
673+ * aliases, their value is preserved.
674+ * - `true` forces the sub-command non-interactive regardless of the
675+ * parent, again deferring to a caller-supplied value if present.
676+ * - `false` strips any inherited or propagated `--no-interaction` so the
677+ * sub-command resolves its own state. TTY detection can still force
678+ * non-interactive if STDIN is not a TTY.
679+ *
680+ * @param array<string, list<string|null>|string|null> $options
681+ *
682+ * @return array<string, list<string|null>|string|null>
683+ */
684+ private function resolveChildInteractiveState (array $ options , ?bool $ noInteractionOverride ): array
685+ {
686+ if ($ noInteractionOverride === false ) {
687+ $ definition = $ this ->optionsDefinition ['no-interaction ' ];
688+
689+ $ aliases = array_filter (
690+ [$ definition ->name , $ definition ->shortcut , $ definition ->negation ],
691+ static fn (?string $ alias ): bool => $ alias !== null ,
692+ );
693+
694+ foreach ($ aliases as $ alias ) {
695+ unset($ options [$ alias ]);
696+ }
697+
698+ return $ options ;
699+ }
700+
701+ if ($ this ->hasUnboundOption ('no-interaction ' , $ options )) {
702+ return $ options ;
703+ }
704+
705+ if ($ noInteractionOverride === true || ! $ this ->isInteractive ()) {
706+ $ options ['no-interaction ' ] = null ; // simulate --no-interaction being passed
707+ }
708+
709+ return $ options ;
617710 }
618711
619712 /**
0 commit comments