@@ -113,6 +113,18 @@ abstract class AbstractCommand
113113 private ?string $ lastOptionalArgument = null ;
114114 private ?string $ lastArrayArgument = null ;
115115
116+ /**
117+ * Interactive state pinned by `setInteractive()`. When boolean, it takes precedence over
118+ * the per-run flag and TTY detection, and remains in effect across `run()` calls on
119+ * the same instance.
120+ */
121+ private ?bool $ interactive = null ;
122+
123+ /**
124+ * Per-run interactive state derived from `--no-interaction` / `-N` in the current `$options`.
125+ */
126+ private ?bool $ runtimeInteractive = null ;
127+
116128 /**
117129 * @throws InvalidArgumentDefinitionException
118130 * @throws InvalidOptionDefinitionException
@@ -343,27 +355,55 @@ public function hasNegation(string $name): bool
343355 return array_key_exists ($ name , $ this ->negations );
344356 }
345357
358+ /**
359+ * Reports whether the command is currently in interactive mode.
360+ *
361+ * Resolution order:
362+ * 1. An explicit `setInteractive()` call wins.
363+ * 2. Otherwise, the `--no-interaction` / `-N` flag from the current `run()`
364+ * forces non-interactive.
365+ * 3. Otherwise, the command is interactive when STDIN is a TTY.
366+ *
367+ * Non-CLI contexts (e.g., a controller invoking `command()`) don't expose
368+ * `STDIN` at all; those always resolve as non-interactive.
369+ */
370+ public function isInteractive (): bool
371+ {
372+ return $ this ->interactive
373+ ?? $ this ->runtimeInteractive
374+ ?? (defined ('STDIN ' ) && CLI ::streamSupports ('stream_isatty ' , \STDIN ));
375+ }
376+
377+ /**
378+ * Pins the interactive state, overriding both the `--no-interaction` flag
379+ * and STDIN TTY detection.
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 *
349391 * The lifecycle is:
350392 *
351- * 1. {@see initialize()} and {@see interact()} are handed the raw parsed
352- * input by reference, in that order. Both can mutate the tokens before
353- * the framework interprets them against the declared definitions.
354- * 2. The post-hook input is snapshotted into `$unboundArguments` and
355- * `$unboundOptions` so the unbound accessors can report the tokens
356- * carried into binding (as opposed to what defaults resolved to).
357- * Any mutations performed in `initialize()` or `interact()` are
358- * therefore reflected in the snapshot.
359- * 3. {@see bind()} maps the raw tokens onto the declared arguments and
360- * options, applying defaults and coercing flag/negation values.
361- * 4. {@see validate()} rejects the bound result if it violates any of the
362- * declarations — missing required argument, unknown option, value/flag
363- * mismatches, and so on.
364- * 5. The bound-and-validated values are snapshotted into
365- * `$validatedArguments` / `$validatedOptions` and then passed to
366- * {@see execute()}, whose integer return is the command's exit code.
393+ * 1. `initialize()` and `interact()` are handed the raw parsed input by reference, in that order.
394+ * Both can mutate the tokens before the framework interprets them against the declared definitions.
395+ * Note: the per-run interactive state is captured from `$options` before `initialize()` runs, so
396+ * mutating `--no-interaction` from within `initialize()` will not affect this invocation. Use
397+ * `setInteractive()` instead.
398+ * 2. The post-hook input is snapshotted into `$unboundArguments` and `$unboundOptions` so the unbound
399+ * accessors can report the tokens carried into binding (as opposed to what defaults resolved to).
400+ * Any mutations performed in `initialize()` or `interact()` are therefore reflected in the snapshot.
401+ * 3. `bind()` maps the raw tokens onto the declared arguments and options, applying defaults and
402+ * coercing flag/negation values.
403+ * 4. `validate()` rejects the bound result if it violates any of the declarations — missing required
404+ * argument, unknown option, value/flag mismatches, and so on.
405+ * 5. The bound-and-validated values are snapshotted into `$validatedArguments` / `$validatedOptions`
406+ * and then passed to `execute()`, whose integer return is the command's exit code.
367407 *
368408 * @param list<string> $arguments Parsed arguments from command line.
369409 * @param array<string, list<string|null>|string|null> $options Parsed options from command line.
@@ -375,10 +415,14 @@ public function hasNegation(string $name): bool
375415 */
376416 final public function run (array $ arguments , array $ options ): int
377417 {
418+ // Reset per-run interactive state from the current options.
419+ $ this ->runtimeInteractive = $ this ->hasUnboundOption ('no-interaction ' , $ options ) ? false : null ;
420+
378421 $ this ->initialize ($ arguments , $ options );
379422
380- // @todo add interactive mode check
381- $ this ->interact ($ arguments , $ options );
423+ if ($ this ->isInteractive ()) {
424+ $ this ->interact ($ arguments , $ options );
425+ }
382426
383427 $ this ->unboundArguments = $ arguments ;
384428 $ this ->unboundOptions = $ options ;
@@ -447,12 +491,17 @@ abstract protected function execute(array $arguments, array $options): int;
447491 /**
448492 * Calls another command from the current command.
449493 *
450- * @param list<string> $arguments Parsed arguments from command line.
451- * @param array<string, list<string>|string|null> $options Parsed options from command line.
494+ * @param list<string> $arguments Parsed arguments from command line.
495+ * @param array<string, list<string>|string|null> $options Parsed options from command line.
496+ * @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state;
497+ * `true` forces the sub-command non-interactive by injecting
498+ * `--no-interaction`; `false` removes any forwarded
499+ * `--no-interaction` from `$options` so the sub-command
500+ * resolves its own state (TTY detection may still downgrade it).
452501 */
453- protected function call (string $ command , array $ arguments = [], array $ options = []): int
502+ protected function call (string $ command , array $ arguments = [], array $ options = [], ? bool $ noInteractionOverride = null ): int
454503 {
455- return $ this ->commands ->runCommand ($ command , $ arguments , $ options );
504+ return $ this ->commands ->runCommand ($ command , $ arguments , $ this -> resolveChildInteractiveState ( $ options, $ noInteractionOverride ) );
456505 }
457506
458507 /**
@@ -490,13 +539,11 @@ protected function getUnboundOptions(): array
490539 }
491540
492541 /**
493- * Reads the raw (unbound) value of the option with the given declared name,
494- * resolving through its shortcut and negation. Returns `null` when the
495- * option was not provided under any of those aliases.
542+ * Reads the raw (unbound) value of the option with the given declared name, resolving through its
543+ * shortcut and negation. Returns `null` when the option was not provided under any of those aliases.
496544 *
497- * Inside {@see interact()}, pass the `$options` parameter explicitly because
498- * the instance state is not yet populated at that point. Elsewhere, omit
499- * `$options` to read from the instance state.
545+ * Inside `interact()`, pass the `$options` parameter explicitly because the instance state is not yet
546+ * populated at that point. Elsewhere, omit `$options` to read from the instance state.
500547 *
501548 * @param array<string, list<string|null>|string|null>|null $options
502549 *
@@ -528,11 +575,11 @@ protected function getUnboundOption(string $name, ?array $options = null): array
528575 }
529576
530577 /**
531- * Returns whether the option with the given declared name was provided in
532- * the raw (unbound) input — under its long name, shortcut, or negation.
578+ * Returns whether the option with the given declared name was provided in the raw (unbound) input —
579+ * under its long name, shortcut, or negation.
533580 *
534- * Inside {@see interact()} , pass the `$options` parameter explicitly; elsewhere
535- * omit it to read from instance state.
581+ * Inside ` interact()` , pass the `$options` parameter explicitly; elsewhere omit it to read from
582+ * instance state.
536583 *
537584 * @param array<string, list<string|null>|string|null>|null $options
538585 *
@@ -609,11 +656,75 @@ protected function getValidatedOption(string $name): array|bool|string|null
609656 return $ this ->validatedOptions [$ name ];
610657 }
611658
659+ /**
660+ * Registers the options that the framework injects into every modern
661+ * command. Every option registered here is load-bearing:
662+ *
663+ * - `--help` / `-h`: `Console` detects it and routes to the `help` command.
664+ * - `--no-header`: `Console` strips it before rendering the banner.
665+ * - `--no-interaction` / `-N`: `run()` folds it into the interactive state
666+ * and `resolveChildInteractiveState()` reads it to drive the `call()` cascade.
667+ *
668+ * Subclasses that override this hook should re-register these options or
669+ * accept that the corresponding framework features will be broken for
670+ * the subclass.
671+ */
612672 protected function provideDefaultOptions (): void
613673 {
614674 $ this
615675 ->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. ' ));
676+ ->addOption (new Option (name: 'no-header ' , description: 'Do not display the banner when running the command. ' ))
677+ ->addOption (new Option (name: 'no-interaction ' , shortcut: 'N ' , description: 'Do not ask any interactive questions. ' ));
678+ }
679+
680+ /**
681+ * Reconciles the caller's explicit intent (`$noInteractionOverride`) with
682+ * the parent command's own interactive state to produce the `$options`
683+ * that `call()` should hand to the sub-command.
684+ *
685+ * - `null` (default) propagates the parent's non-interactive state by
686+ * adding `--no-interaction` when the parent itself is non-interactive.
687+ * If the caller already supplied `--no-interaction` under any of its
688+ * aliases, their value is preserved.
689+ * - `true` forces the sub-command non-interactive regardless of the
690+ * parent, again deferring to a caller-supplied value if present.
691+ * - `false` removes any `--no-interaction` from `$options` (whether
692+ * caller-supplied or inherited) so the sub-command resolves its own
693+ * state. TTY detection can still force non-interactive if STDIN is
694+ * not a TTY.
695+ *
696+ * @param array<string, list<string|null>|string|null> $options
697+ *
698+ * @return array<string, list<string|null>|string|null>
699+ */
700+ private function resolveChildInteractiveState (array $ options , ?bool $ noInteractionOverride ): array
701+ {
702+ $ this ->assertOptionIsDefined ('no-interaction ' );
703+
704+ if ($ noInteractionOverride === false ) {
705+ $ definition = $ this ->optionsDefinition ['no-interaction ' ];
706+
707+ $ aliases = array_filter (
708+ [$ definition ->name , $ definition ->shortcut , $ definition ->negation ],
709+ static fn (?string $ alias ): bool => $ alias !== null ,
710+ );
711+
712+ foreach ($ aliases as $ alias ) {
713+ unset($ options [$ alias ]);
714+ }
715+
716+ return $ options ;
717+ }
718+
719+ if ($ this ->hasUnboundOption ('no-interaction ' , $ options )) {
720+ return $ options ;
721+ }
722+
723+ if ($ noInteractionOverride === true || ! $ this ->isInteractive ()) {
724+ $ options ['no-interaction ' ] = null ; // simulate --no-interaction being passed
725+ }
726+
727+ return $ options ;
617728 }
618729
619730 /**
0 commit comments