Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions packages/deepctl-cmd-login/tests/unit/test_login_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
)
Expand Down Expand Up @@ -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}")
Expand Down
29 changes: 27 additions & 2 deletions packages/deepctl-core/src/deepctl_core/base_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading