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
29 changes: 29 additions & 0 deletions src/agentspaces/cli/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"print_error",
"print_info",
"print_next_steps",
"print_quick_start",
"print_success",
"print_warning",
"print_workspace_created",
Expand Down Expand Up @@ -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.
Expand Down
60 changes: 55 additions & 5 deletions src/agentspaces/cli/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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(
Expand All @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion src/agentspaces/infrastructure/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
54 changes: 47 additions & 7 deletions tests/unit/cli/test_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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."""

Expand Down Expand Up @@ -56,39 +84,51 @@ 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

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
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/infrastructure/test_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down