From 925c717f107366c95025215b2e8174298a63484c Mon Sep 17 00:00:00 2001 From: Chris Krough <461869+ckrough@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:23:47 -0500 Subject: [PATCH] feat: add quick-start one-liner and --launch flag to workspace create After workspace creation, a copyable one-liner is now displayed for quickly launching the agent: cd ~/.agentspaces/project/ws && source .venv/bin/activate && agentspaces agent launch Additionally, the --launch/-l flag allows automatic agent launch after workspace creation, using the workspace purpose as the initial prompt. --- src/agentspaces/cli/formatters.py | 29 ++++++++++++ src/agentspaces/cli/workspace.py | 60 ++++++++++++++++++++++-- src/agentspaces/infrastructure/claude.py | 3 +- tests/unit/cli/test_formatters.py | 54 ++++++++++++++++++--- tests/unit/infrastructure/test_claude.py | 6 +-- 5 files changed, 136 insertions(+), 16 deletions(-) diff --git a/src/agentspaces/cli/formatters.py b/src/agentspaces/cli/formatters.py index d577593..94b7a65 100644 --- a/src/agentspaces/cli/formatters.py +++ b/src/agentspaces/cli/formatters.py @@ -22,6 +22,7 @@ "print_error", "print_info", "print_next_steps", + "print_quick_start", "print_success", "print_warning", "print_workspace_created", @@ -134,6 +135,34 @@ def print_next_steps(workspace_name: str, workspace_path: str, has_venv: bool) - ) console.print(panel) + # Print copyable one-liner for quick start + print_quick_start(workspace_path, has_venv) + + +def print_quick_start(workspace_path: str, has_venv: bool) -> None: + """Print a copyable one-liner command for quick workspace launch. + + Suppressed when --quiet flag is set. + + Args: + workspace_path: Path to the workspace directory. + has_venv: Whether a virtual environment was created. + """ + if CLIContext.get().quiet: + return + + # Build the one-liner command + parts = [f"cd {workspace_path}"] + if has_venv: + parts.append("source .venv/bin/activate") + parts.append("agentspaces agent launch") + + one_liner = " && ".join(parts) + + console.print() + console.print("[dim]Quick start (copy & paste):[/dim]") + console.print(f" [bold cyan]{one_liner}[/bold cyan]") + def format_relative_time(dt: datetime | None) -> str: """Format datetime as relative time string. diff --git a/src/agentspaces/cli/workspace.py b/src/agentspaces/cli/workspace.py index 63bf3b9..aeb83e7 100644 --- a/src/agentspaces/cli/workspace.py +++ b/src/agentspaces/cli/workspace.py @@ -23,6 +23,11 @@ ) from agentspaces.infrastructure import git from agentspaces.infrastructure.similarity import find_similar_names +from agentspaces.modules.agent.launcher import ( + AgentError, + AgentLauncher, + AgentNotFoundError, +) from agentspaces.modules.workspace.service import ( WorkspaceError, WorkspaceNotFoundError, @@ -57,6 +62,12 @@ def create( bool, typer.Option("--no-venv", help="Skip virtual environment creation"), ] = False, + launch: Annotated[ + bool, + typer.Option( + "--launch", "-l", help="Launch Claude Code in workspace after creation" + ), + ] = False, ) -> None: """Create a new isolated workspace from a branch. @@ -69,6 +80,7 @@ def create( agentspaces workspace create main # From main branch agentspaces workspace create -p "Fix auth bug" # With purpose agentspaces workspace create --no-venv # Skip venv setup + agentspaces workspace create --launch # Create and launch agent """ try: workspace = _service.create( @@ -92,11 +104,49 @@ def create( has_venv=workspace.has_venv, ) - print_next_steps( - workspace_name=workspace.name, - workspace_path=str(workspace.path), - has_venv=workspace.has_venv, - ) + # If --launch flag is set, launch agent; otherwise show next steps + if launch: + _launch_agent_in_workspace(workspace.name, workspace.path, purpose) + else: + print_next_steps( + workspace_name=workspace.name, + workspace_path=str(workspace.path), + has_venv=workspace.has_venv, + ) + + +def _launch_agent_in_workspace( + workspace_name: str, workspace_path: Path, purpose: str | None +) -> None: + """Launch Claude Code agent in a newly created workspace. + + Args: + workspace_name: Name of the workspace. + workspace_path: Path to the workspace directory. + purpose: Optional workspace purpose to use as prompt. + """ + print_info(f"Launching Claude Code in '{workspace_name}'...") + + launcher = AgentLauncher() + try: + result = launcher.launch_claude( + workspace_name, + cwd=workspace_path, + prompt=purpose, + ) + + if result.exit_code == 0: + print_success(f"Claude Code session ended in '{workspace_name}'") + else: + print_warning(f"Claude Code exited with code {result.exit_code}") + + except AgentNotFoundError as e: + print_error(str(e)) + print_info("Visit https://claude.ai/download to install Claude Code") + raise typer.Exit(1) from e + except AgentError as e: + print_error(str(e)) + raise typer.Exit(1) from e @app.command("list") diff --git a/src/agentspaces/infrastructure/claude.py b/src/agentspaces/infrastructure/claude.py index 7ebf820..b48090c 100644 --- a/src/agentspaces/infrastructure/claude.py +++ b/src/agentspaces/infrastructure/claude.py @@ -93,7 +93,8 @@ def launch( raise ValueError( f"Prompt too long: {len(prompt)} chars (max {MAX_PROMPT_LENGTH})" ) - cmd.extend(["--prompt", prompt]) + # Prompt is a positional argument in Claude Code CLI + cmd.append(prompt) logger.info("claude_launch", cwd=str(cwd), has_prompt=prompt is not None) diff --git a/tests/unit/cli/test_formatters.py b/tests/unit/cli/test_formatters.py index e481d9d..d28792a 100644 --- a/tests/unit/cli/test_formatters.py +++ b/tests/unit/cli/test_formatters.py @@ -2,7 +2,10 @@ from __future__ import annotations -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch + +from rich.panel import Panel from agentspaces.cli.context import CLIContext from agentspaces.cli.formatters import ( @@ -12,6 +15,31 @@ ) +def _find_next_steps_panel(mock_console: MagicMock) -> Any: + """Find the Next Steps panel from console.print calls. + + Args: + mock_console: The mocked console object. + + Returns: + The Panel object containing "Next Steps". + + Raises: + AssertionError: If no Next Steps panel is found. + """ + for call in mock_console.print.call_args_list: + if call.args and isinstance(call.args[0], Panel): + panel = call.args[0] + if ( + hasattr(panel, "title") + and panel.title + and "Next Steps" in str(panel.title) + ): + return panel + msg = "Could not find Next Steps panel in console.print calls" + raise AssertionError(msg) + + class TestPrintInfo: """Tests for print_info function.""" @@ -56,8 +84,8 @@ def test_prints_cd_step(self) -> None: with patch("agentspaces.cli.formatters.console") as mock_console: print_next_steps("test-ws", "/path/to/workspace", has_venv=False) mock_console.print.assert_called() - # Get the Panel object that was passed to print - panel = mock_console.print.call_args[0][0] + # Get all printed content - find the Panel with "Next Steps" + panel = _find_next_steps_panel(mock_console) # Panel.renderable contains the content assert "/path/to/workspace" in panel.renderable @@ -65,30 +93,42 @@ def test_includes_venv_activation_when_has_venv(self) -> None: """Should include venv activation when has_venv is True.""" with patch("agentspaces.cli.formatters.console") as mock_console: print_next_steps("test-ws", "/path/to/workspace", has_venv=True) - panel = mock_console.print.call_args[0][0] + panel = _find_next_steps_panel(mock_console) assert "source .venv/bin/activate" in panel.renderable def test_excludes_venv_activation_when_no_venv(self) -> None: """Should not include venv activation when has_venv is False.""" with patch("agentspaces.cli.formatters.console") as mock_console: print_next_steps("test-ws", "/path/to/workspace", has_venv=False) - panel = mock_console.print.call_args[0][0] + panel = _find_next_steps_panel(mock_console) assert "source .venv/bin/activate" not in panel.renderable def test_includes_agent_launch(self) -> None: """Should include agentspaces agent launch step.""" with patch("agentspaces.cli.formatters.console") as mock_console: print_next_steps("test-ws", "/path/to/workspace", has_venv=False) - panel = mock_console.print.call_args[0][0] + panel = _find_next_steps_panel(mock_console) assert "agentspaces agent launch" in panel.renderable def test_includes_remove_step(self) -> None: """Should include workspace remove step with workspace name.""" with patch("agentspaces.cli.formatters.console") as mock_console: print_next_steps("test-ws", "/path/to/workspace", has_venv=False) - panel = mock_console.print.call_args[0][0] + panel = _find_next_steps_panel(mock_console) assert "agentspaces workspace remove test-ws" in panel.renderable + def test_prints_quick_start_one_liner(self) -> None: + """Should print a quick start one-liner after the panel.""" + with patch("agentspaces.cli.formatters.console") as mock_console: + print_next_steps("test-ws", "/path/to/workspace", has_venv=True) + # Check all print calls for the one-liner + calls = [str(c) for c in mock_console.print.call_args_list] + content = " ".join(calls) + assert "Quick start" in content + assert "/path/to/workspace" in content + assert "source .venv/bin/activate" in content + assert "agentspaces agent launch" in content + def test_suppressed_in_quiet_mode(self) -> None: """Should not print when quiet mode is enabled.""" CLIContext.get().quiet = True diff --git a/tests/unit/infrastructure/test_claude.py b/tests/unit/infrastructure/test_claude.py index e89b57f..88b30bc 100644 --- a/tests/unit/infrastructure/test_claude.py +++ b/tests/unit/infrastructure/test_claude.py @@ -78,14 +78,14 @@ def test_launch_builds_correct_command(self, tmp_path: Path) -> None: assert call_args == ["claude"] def test_launch_with_prompt(self, tmp_path: Path) -> None: - """Should include prompt in command when provided.""" + """Should include prompt as positional argument when provided.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) launch(tmp_path, prompt="Fix the bug") call_args = mock_run.call_args[0][0] - assert call_args == ["claude", "--prompt", "Fix the bug"] + assert call_args == ["claude", "Fix the bug"] def test_launch_returns_exit_code(self, tmp_path: Path) -> None: """Should return the process exit code.""" @@ -139,7 +139,7 @@ def test_launch_accepts_max_length_prompt(self, tmp_path: Path) -> None: assert result == 0 call_args = mock_run.call_args[0][0] - assert call_args == ["claude", "--prompt", max_prompt] + assert call_args == ["claude", max_prompt] class TestExceptions: