diff --git a/packages/deepctl-cmd-debug-browser/src/deepctl_cmd_debug_browser/command.py b/packages/deepctl-cmd-debug-browser/src/deepctl_cmd_debug_browser/command.py index 1e6ab4a..389a785 100644 --- a/packages/deepctl-cmd-debug-browser/src/deepctl_cmd_debug_browser/command.py +++ b/packages/deepctl-cmd-debug-browser/src/deepctl_cmd_debug_browser/command.py @@ -358,10 +358,15 @@ def handle( # Prompt user to open browser if not no_browser: - console.print( - "\n[yellow]Press Enter to open the debugger in your browser...[/yellow]" - ) - input() + if self._guided: + console.print( + "\n[yellow]Press Enter to open the debugger in your browser...[/yellow]" + ) + input() + else: + console.print( + f"\n[dim]Opening [cyan]{url}[/cyan] in your browser...[/dim]" + ) webbrowser.open(url) browser_opened = True else: diff --git a/packages/deepctl-cmd-debug-browser/tests/unit/test_browser_command.py b/packages/deepctl-cmd-debug-browser/tests/unit/test_browser_command.py index e02969a..986f41f 100644 --- a/packages/deepctl-cmd-debug-browser/tests/unit/test_browser_command.py +++ b/packages/deepctl-cmd-debug-browser/tests/unit/test_browser_command.py @@ -178,3 +178,54 @@ async def test_websocket_handler(self): assert cmd.messages[0].message == "Test message" assert "web_audio_api" in cmd.capabilities_data assert result == mock_ws + + +class TestBrowserGuidedGate: + """Verify the press-Enter prompt only fires in guided (bare) invocations.""" + + def _run_handle(self, guided: bool): + """Run BrowserCommand.handle and capture whether input() was called.""" + cmd = BrowserCommand() + cmd._guided = guided + called = {"input": 0, "open": 0} + + with patch("builtins.input", side_effect=lambda: called.__setitem__("input", called["input"] + 1)), patch( + "deepctl_cmd_debug_browser.command.webbrowser.open", + side_effect=lambda _url: called.__setitem__("open", called["open"] + 1), + ), patch.object( + cmd, "find_available_port", return_value=3100 + ), patch( + "deepctl_cmd_debug_browser.command.web.AppRunner" + ) as mock_runner_cls, patch( + "deepctl_cmd_debug_browser.command.web.TCPSite" + ) as mock_site_cls, patch( + "deepctl_cmd_debug_browser.command.asyncio.sleep", + new=AsyncMock(), + ): + mock_runner = AsyncMock() + mock_runner_cls.return_value = mock_runner + mock_runner.setup = AsyncMock() + mock_runner.cleanup = AsyncMock() + mock_site = AsyncMock() + mock_site_cls.return_value = mock_site + mock_site.start = AsyncMock() + cmd.handle( + config=Mock(), + auth_manager=Mock(), + client=Mock(), + port=None, + no_browser=False, + timeout=0, + save_report=None, + ) + return called + + def test_guided_invocation_waits_for_press_enter(self): + called = self._run_handle(guided=True) + assert called["input"] == 1 + assert called["open"] == 1 + + def test_non_guided_invocation_opens_browser_immediately(self): + called = self._run_handle(guided=False) + assert called["input"] == 0 + assert called["open"] == 1 diff --git a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py index 2c0464a..2f3db58 100644 --- a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py +++ b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py @@ -182,7 +182,7 @@ def _maybe_prompt_skills_setup(self) -> None: """After login, offer interactive skills setup for detected AI tools.""" import sys - if not sys.stdout.isatty(): + if not sys.stdout.isatty() or not getattr(self, "_guided", True): return # Non-interactive — skip try: diff --git a/packages/deepctl-cmd-login/tests/unit/test_login_command.py b/packages/deepctl-cmd-login/tests/unit/test_login_command.py index e0a1c4f..7634af7 100644 --- a/packages/deepctl-cmd-login/tests/unit/test_login_command.py +++ b/packages/deepctl-cmd-login/tests/unit/test_login_command.py @@ -365,3 +365,40 @@ def test_list_profiles_shows_current( assert isinstance(result, ProfilesResult) assert len(result.profiles) == 2 + + +class TestMaybePromptSkillsSetup: + """Verify the post-login skills-setup prompt respects _guided + non-tty.""" + + def test_returns_early_when_not_guided(self): + cmd = LoginCommand() + cmd._guided = False + with patch("sys.stdout") as mock_stdout, patch( + "deepctl_core.skill_generator.detect_ai_clis" + ) as mock_detect: + mock_stdout.isatty.return_value = True + cmd._maybe_prompt_skills_setup() + mock_detect.assert_not_called() + + def test_returns_early_when_not_tty(self): + cmd = LoginCommand() + cmd._guided = True + with patch("sys.stdout") as mock_stdout, patch( + "deepctl_core.skill_generator.detect_ai_clis" + ) as mock_detect: + mock_stdout.isatty.return_value = False + cmd._maybe_prompt_skills_setup() + mock_detect.assert_not_called() + + def test_proceeds_when_guided_and_tty(self): + cmd = LoginCommand() + cmd._guided = True + with patch("sys.stdout") as mock_stdout, patch( + "deepctl_core.skill_generator.detect_ai_clis", return_value=[] + ) as mock_detect, patch( + "deepctl_core.skill_generator.get_skills_state", + return_value={"installed_skills": {}}, + ): + mock_stdout.isatty.return_value = True + cmd._maybe_prompt_skills_setup() + mock_detect.assert_called_once() diff --git a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py index 97ba216..837cd5c 100644 --- a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py +++ b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py @@ -270,7 +270,7 @@ def _handle_install( print_warning( f"{generators[0].display_name} was not detected on this system." ) - if not click.confirm("Install anyway?", default=False): + if not self.confirm("Install anyway?", default=False): return else: generators = detect_ai_clis() @@ -296,7 +296,7 @@ def _handle_install( if ( not install_all and not cli_name - and not click.confirm( + and not self.confirm( f"Install deepctl skills for {gen.display_name}?", default=True, ) @@ -479,7 +479,7 @@ def _handle_setup(self, install_all: bool = False) -> None: # 2. Interactive selection (or --all for CI) if install_all: selected = list(detected) - elif is_tty: + elif is_tty and self._guided: console.print("\n[bold]Detected AI coding tools:[/bold]\n") for i, g in enumerate(detected, 1): console.print(f" [green]{i}.[/green] {g.display_name}") diff --git a/packages/deepctl-core/src/deepctl_core/base_command.py b/packages/deepctl-core/src/deepctl_core/base_command.py index e94fc84..fc31d11 100644 --- a/packages/deepctl-core/src/deepctl_core/base_command.py +++ b/packages/deepctl-core/src/deepctl_core/base_command.py @@ -39,6 +39,7 @@ def __init__(self) -> None: raise ValueError("Command must have a name") if not self.help: raise ValueError("Command must have help text") + self._guided: bool = True def execute(self, ctx: click.Context, **kwargs: Any) -> None: """Execute the command with Click context. @@ -104,6 +105,7 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: ) raise click.ClickException("Project ID required") + self._guided = self.is_guided(ctx) self._tag_telemetry_start(ctx) # Execute the command @@ -133,6 +135,27 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: stderr_console.print_exception() raise click.ClickException(str(e)) + def is_guided(self, ctx: click.Context) -> bool: + """True only when the user invoked this command with no input at all. + + 'No input' = no positional arg, no flag, no option value, no env-var + override. The single rule for every command: any user-provided signal + means scripting intent — skip prompts. Only the bare invocation gets + the guided/interactive flow. + + Returns False whenever telemetry's `_agentic` heuristic fires + (CI=1, --agent-friendly, --non-interactive, AI tool env vars, etc.). + """ + if _agentic: + return False + for param in ctx.command.params: + if not param.name: + continue + src = ctx.get_parameter_source(param.name) + if src is not None and src.name in ("COMMANDLINE", "ENVIRONMENT"): + return False + return True + def _tag_telemetry_start(self, ctx: click.Context) -> None: """Annotate the active Sentry transaction with command-level usage signal. @@ -349,7 +372,7 @@ def confirm(self, message: str, default: bool = False) -> bool: Returns: True if confirmed, False otherwise """ - if _agentic or not self.ci_friendly: + if _agentic or not self.ci_friendly or not getattr(self, "_guided", True): return default try: @@ -373,7 +396,9 @@ def prompt( Returns: User input """ - if (_agentic or not self.ci_friendly) and default is not None: + if ( + _agentic or not self.ci_friendly or not getattr(self, "_guided", True) + ) and default is not None: return default try: diff --git a/packages/deepctl-core/tests/unit/test_base.py b/packages/deepctl-core/tests/unit/test_base.py index a20a19d..ec62c5d 100644 --- a/packages/deepctl-core/tests/unit/test_base.py +++ b/packages/deepctl-core/tests/unit/test_base.py @@ -965,6 +965,215 @@ def get_arguments(self) -> list[dict[str, Any]]: assert args[1]["default"] == 42 +class TestGuidedAttribute: + """Verify _guided attribute lifecycle: __init__ default + execute() wiring.""" + + @pytest.fixture + def mock_command_class(self): + class MockCommand(BaseCommand): + name = "test" + help = "Test command" + + def handle(self, *args, **kwargs): + MockCommand.captured_guided = self._guided + return None + + MockCommand.captured_guided = None + return MockCommand + + @pytest.mark.unit + def test_init_defaults_guided_to_true(self, mock_command_class): + cmd = mock_command_class() + assert cmd._guided is True + + @pytest.mark.unit + @patch("deepctl_core.base_command.AuthManager") + @patch("deepctl_core.base_command.DeepgramClient") + def test_execute_sets_guided_false_when_user_provided_args( + self, _client_class, _auth_class, mock_command_class + ): + cmd = mock_command_class() + ctx = Mock(spec=click.Context) + ctx.obj = {"config": Config()} + ctx.command_path = "deepctl test" + ctx.params = {} + ctx.command = Mock() + ctx.command.params = [_param("foo")] + src = Mock() + src.name = "COMMANDLINE" + ctx.get_parameter_source = lambda _name: src + + with patch("deepctl_core.base_command._agentic", False): + cmd.execute(ctx) + + assert mock_command_class.captured_guided is False + + @pytest.mark.unit + @patch("deepctl_core.base_command.AuthManager") + @patch("deepctl_core.base_command.DeepgramClient") + def test_execute_sets_guided_true_for_bare_invocation( + self, _client_class, _auth_class, mock_command_class + ): + cmd = mock_command_class() + ctx = Mock(spec=click.Context) + ctx.obj = {"config": Config()} + ctx.command_path = "deepctl test" + ctx.params = {} + ctx.command = Mock() + ctx.command.params = [_param("foo")] + src = Mock() + src.name = "DEFAULT" + ctx.get_parameter_source = lambda _name: src + + with patch("deepctl_core.base_command._agentic", False): + cmd.execute(ctx) + + assert mock_command_class.captured_guided is True + + +class TestConfirmPromptGating: + """Verify confirm() and prompt() respect _guided alongside _agentic and ci_friendly.""" + + @pytest.fixture + def command(self): + class MockCommand(BaseCommand): + name = "test" + help = "Test command" + ci_friendly = True + + def handle(self, *args, **kwargs): + return None + + return MockCommand() + + @pytest.mark.unit + def test_confirm_calls_click_when_guided_and_not_agentic(self, command): + command._guided = True + with patch("deepctl_core.base_command._agentic", False), patch( + "deepctl_core.base_command.click.confirm", return_value=True + ) as mock: + assert command.confirm("OK?", default=False) is True + mock.assert_called_once() + + @pytest.mark.unit + def test_confirm_returns_default_when_not_guided(self, command): + command._guided = False + with patch("deepctl_core.base_command._agentic", False), patch( + "deepctl_core.base_command.click.confirm" + ) as mock: + assert command.confirm("OK?", default=False) is False + mock.assert_not_called() + + @pytest.mark.unit + def test_confirm_returns_default_when_agentic(self, command): + command._guided = True + with patch("deepctl_core.base_command._agentic", True), patch( + "deepctl_core.base_command.click.confirm" + ) as mock: + assert command.confirm("OK?", default=True) is True + mock.assert_not_called() + + @pytest.mark.unit + def test_confirm_returns_default_when_not_ci_friendly(self, command): + command.ci_friendly = False + command._guided = True + with patch("deepctl_core.base_command._agentic", False), patch( + "deepctl_core.base_command.click.confirm" + ) as mock: + assert command.confirm("OK?", default=False) is False + mock.assert_not_called() + + @pytest.mark.unit + def test_prompt_calls_click_when_guided(self, command): + command._guided = True + with patch("deepctl_core.base_command._agentic", False), patch( + "deepctl_core.base_command.click.prompt", return_value="user-input" + ) as mock: + assert command.prompt("Name?", default="alice") == "user-input" + mock.assert_called_once() + + @pytest.mark.unit + def test_prompt_returns_default_when_not_guided(self, command): + command._guided = False + with patch("deepctl_core.base_command._agentic", False), patch( + "deepctl_core.base_command.click.prompt" + ) as mock: + assert command.prompt("Name?", default="alice") == "alice" + mock.assert_not_called() + + @pytest.mark.unit + def test_prompt_with_no_default_still_prompts_when_not_guided(self, command): + # Pre-existing safety: prompt() only short-circuits when default is not None + command._guided = False + with patch("deepctl_core.base_command._agentic", False), patch( + "deepctl_core.base_command.click.prompt", return_value="typed" + ) as mock: + assert command.prompt("Name?", default=None) == "typed" + mock.assert_called_once() + + +class TestIsGuided: + """Verify is_guided() distinguishes bare invocations from scripted ones.""" + + @pytest.fixture + def command(self): + class MockCommand(BaseCommand): + name = "test" + help = "Test command" + + def handle(self, *args, **kwargs): + return None + + return MockCommand() + + @staticmethod + def _ctx_with_sources(sources): + ctx = Mock(spec=click.Context) + ctx.command = Mock() + ctx.command.params = [_param(n) for n in sources.keys()] + + def get_source(name): + src_name = sources.get(name) + if src_name is None: + return None + src = Mock() + src.name = src_name + return src + + ctx.get_parameter_source = get_source + return ctx + + @pytest.mark.unit + def test_bare_invocation_is_guided(self, command): + ctx = self._ctx_with_sources( + {"foo": "DEFAULT", "bar": "DEFAULT", "baz": "DEFAULT_MAP"} + ) + with patch("deepctl_core.base_command._agentic", False): + assert command.is_guided(ctx) is True + + @pytest.mark.unit + def test_any_commandline_arg_breaks_guided(self, command): + ctx = self._ctx_with_sources( + {"foo": "DEFAULT", "bar": "COMMANDLINE", "baz": "DEFAULT"} + ) + with patch("deepctl_core.base_command._agentic", False): + assert command.is_guided(ctx) is False + + @pytest.mark.unit + def test_env_var_breaks_guided(self, command): + ctx = self._ctx_with_sources( + {"foo": "DEFAULT", "bar": "ENVIRONMENT"} + ) + with patch("deepctl_core.base_command._agentic", False): + assert command.is_guided(ctx) is False + + @pytest.mark.unit + def test_agentic_short_circuits_to_false(self, command): + ctx = self._ctx_with_sources({"foo": "DEFAULT", "bar": "DEFAULT"}) + with patch("deepctl_core.base_command._agentic", True): + assert command.is_guided(ctx) is False + + class TestTelemetryTagging: """Verify _tag_telemetry_start and _tag_telemetry_status emit usage signal."""