diff --git a/README.md b/README.md index 70d7707..14633bb 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,25 @@ dg usage --last-week -o yaml When running in a non-TTY environment (pipes, CI, or AI coding tools), the CLI automatically switches to structured JSON output with plain-text status messages. +### Forcing non-interactive mode + +Three explicit ways to skip every prompt and run with defaults — useful from a +real terminal where auto-detection wouldn't otherwise trigger: + +```bash +# Global flag (works at any position) +dg --non-interactive listen recording.wav +dg listen --non-interactive recording.wav + +# Environment variable (good for whole scripts) +CI=1 dg listen recording.wav +``` + +Also recognised: `--agent-friendly` (alias intended for AI coding tools — same +effect plus JSON metadata output), and the auto-detected env vars +`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CODEX_SANDBOX`, and Aider's +`OR_APP_NAME` / `OR_SITE_URL`. + ## Plugins Extend the CLI with custom commands. diff --git a/packages/deepctl-core/src/deepctl_core/output.py b/packages/deepctl-core/src/deepctl_core/output.py index fdc2ea8..685cb72 100644 --- a/packages/deepctl-core/src/deepctl_core/output.py +++ b/packages/deepctl-core/src/deepctl_core/output.py @@ -34,9 +34,13 @@ def is_agentic() -> bool: """ env = os.environ - # Hard signals: explicit agent mode flag or known AI tool env vars + # Hard signals: explicit non-interactive intent or known AI tool env vars + if "--non-interactive" in sys.argv: + return True if "--agent-friendly" in sys.argv: return True + if env.get("CI") in ("true", "1"): + return True if env.get("CLAUDECODE"): return True if env.get("CLAUDE_CODE_ENTRYPOINT"): @@ -56,8 +60,6 @@ def is_agentic() -> bool: score += 1 if not sys.stdout.isatty(): score += 1 - if env.get("CI") in ("true", "1"): - score += 1 if not env.get("TERM") or env.get("TERM") == "dumb": score += 1 if "NO_COLOR" in env: diff --git a/packages/deepctl-core/src/deepctl_core/plugin_manager.py b/packages/deepctl-core/src/deepctl_core/plugin_manager.py index d610d6c..e25628c 100644 --- a/packages/deepctl-core/src/deepctl_core/plugin_manager.py +++ b/packages/deepctl-core/src/deepctl_core/plugin_manager.py @@ -286,6 +286,13 @@ def _agent_friendly_callback( callback=_agent_friendly_callback, )(cmd) + cmd = click.option( + "--non-interactive", + is_flag=True, + expose_value=False, + help="Skip interactive prompts; use defaults for any optional features.", + )(cmd) + return cmd def _build_help_text(self, instance: Any) -> str: diff --git a/packages/deepctl-core/tests/unit/test_output.py b/packages/deepctl-core/tests/unit/test_output.py index 8487868..2f01ed0 100644 --- a/packages/deepctl-core/tests/unit/test_output.py +++ b/packages/deepctl-core/tests/unit/test_output.py @@ -14,9 +14,128 @@ print_warning, print_info, get_console, + is_agentic, ) +class TestIsAgentic: + """Cover every signal that flips the CLI into non-interactive mode.""" + + @pytest.fixture + def baseline(self, monkeypatch): + """Interactive baseline: clean argv, real-looking TTY env, no AI hints.""" + monkeypatch.setattr("sys.argv", ["dg", "listen", "foo.wav"]) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + for var in ( + "CI", + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CODEX_SANDBOX", + "CODEX_SANDBOX_NETWORK_DISABLED", + "OR_APP_NAME", + "OR_SITE_URL", + "NO_COLOR", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + + def test_baseline_is_interactive(self, baseline): + assert is_agentic() is False + + @pytest.mark.parametrize( + "argv", + [ + ["dg", "--non-interactive", "listen", "foo.wav"], + ["dg", "listen", "--non-interactive", "foo.wav"], + ["dg", "listen", "foo.wav", "--non-interactive"], + ], + ) + def test_non_interactive_flag_anywhere_in_argv( + self, baseline, monkeypatch, argv + ): + monkeypatch.setattr("sys.argv", argv) + assert is_agentic() is True + + @pytest.mark.parametrize( + "argv", + [ + ["dg", "--agent-friendly", "listen", "foo.wav"], + ["dg", "listen", "--agent-friendly", "foo.wav"], + ], + ) + def test_agent_friendly_flag_anywhere_in_argv( + self, baseline, monkeypatch, argv + ): + monkeypatch.setattr("sys.argv", argv) + assert is_agentic() is True + + @pytest.mark.parametrize("ci_value", ["1", "true"]) + def test_ci_env_var_is_hard_signal(self, baseline, monkeypatch, ci_value): + monkeypatch.setenv("CI", ci_value) + assert is_agentic() is True + + @pytest.mark.parametrize( + "ci_value", + ["false", "0", "", "yes", "TRUE"], + ) + def test_other_ci_values_do_not_count( + self, baseline, monkeypatch, ci_value + ): + monkeypatch.setenv("CI", ci_value) + assert is_agentic() is False + + @pytest.mark.parametrize( + "env_var", + [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CODEX_SANDBOX", + "CODEX_SANDBOX_NETWORK_DISABLED", + ], + ) + def test_ai_tool_env_var_is_hard_signal( + self, baseline, monkeypatch, env_var + ): + monkeypatch.setenv(env_var, "1") + assert is_agentic() is True + + def test_aider_via_or_app_name(self, baseline, monkeypatch): + monkeypatch.setenv("OR_APP_NAME", "Aider") + assert is_agentic() is True + + def test_aider_via_or_site_url(self, baseline, monkeypatch): + monkeypatch.setenv("OR_SITE_URL", "https://aider.example.com") + assert is_agentic() is True + + def test_one_soft_signal_not_enough(self, baseline, monkeypatch): + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + assert is_agentic() is False + + def test_two_soft_signals_not_enough(self, baseline, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + assert is_agentic() is False + + def test_three_soft_signals_trip_threshold(self, baseline, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + monkeypatch.setenv("NO_COLOR", "1") + assert is_agentic() is True + + def test_dumb_term_counts_as_soft_signal(self, baseline, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + monkeypatch.setenv("TERM", "dumb") + assert is_agentic() is True + + def test_unset_term_counts_as_soft_signal(self, baseline, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + monkeypatch.delenv("TERM", raising=False) + assert is_agentic() is True + + class TestOutputFormatter: """Test OutputFormatter class.""" diff --git a/src/deepctl/main.py b/src/deepctl/main.py index 62f7e59..959f988 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -151,6 +151,13 @@ def preprocess_hyphenated_commands(args: list[str]) -> list[str]: is_flag=True, help="Show detailed performance timing information", ) +@click.option( + "--non-interactive", + is_flag=True, + is_eager=True, + expose_value=False, + help="Skip interactive prompts; use defaults for any optional features.", +) @click.option( "--record-install-method", is_eager=True,