Skip to content
Open
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
83 changes: 83 additions & 0 deletions src/agentspaces/cli/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from rich.table import Table

if TYPE_CHECKING:
from agentspaces.infrastructure.beads import BeadsIssue
from agentspaces.modules.workspace.service import WorkspaceInfo

__all__ = [
Expand All @@ -20,10 +21,12 @@
"print_did_you_mean",
"print_error",
"print_info",
"print_issue_next_steps",
"print_next_steps",
"print_success",
"print_warning",
"print_workspace_created",
"print_workspace_created_from_issue",
"print_workspace_removed",
"print_workspace_status",
"print_workspace_table",
Expand Down Expand Up @@ -123,6 +126,86 @@ def print_next_steps(workspace_path: str, has_venv: bool) -> None:
console.print(panel)


def print_workspace_created_from_issue(
issue: BeadsIssue,
workspace: WorkspaceInfo,
) -> None:
"""Print workspace creation summary for issue-based workspace.

Shows issue context prominently and guides user to claude invocation.

Args:
issue: Beads issue the workspace was created for.
workspace: Created workspace information.
"""
# Issue context header
console.print()
console.print(f"[bold cyan]Issue:[/bold cyan] {issue.id}")
console.print(f"[bold]Title:[/bold] {issue.title}")
console.print(
f"[bold]Type:[/bold] {issue.issue_type} [bold]Priority:[/bold] {issue.priority}"
)
console.print()

# Workspace details panel
lines = [
f"[bold]Name:[/bold] {workspace.name}",
f"[bold]Location:[/bold] {workspace.path}",
f"[bold]Branch:[/bold] {workspace.branch} (from {workspace.base_branch})",
]

if workspace.has_venv:
version_str = workspace.python_version or "default"
lines.append(f"[bold]Python:[/bold] {version_str} (.venv created)")
elif workspace.python_version:
lines.append(f"[bold]Python:[/bold] {workspace.python_version}")

panel = Panel(
"\n".join(lines),
title="[green]Workspace Created[/green]",
border_style="green",
)
console.print(panel)


def print_issue_next_steps(
workspace_path: str,
issue_id: str,
has_venv: bool,
) -> None:
"""Print next steps for issue-based workspace.

Displays a copyable command string that includes:
- cd to workspace
- venv activation (if applicable)
- claude invocation with 'plan' prompt and issue ID

Args:
workspace_path: Path to the workspace directory.
issue_id: Beads issue ID.
has_venv: Whether a virtual environment was created.
"""
# Quote values for shell safety
quoted_path = shlex.quote(workspace_path)
quoted_issue = shlex.quote(issue_id)

# Build command string
commands = [f"cd {quoted_path}"]
if has_venv:
commands.append("source .venv/bin/activate")
commands.append(f"claude 'plan' {quoted_issue}")

command_str = " && ".join(commands)

# Display as copyable command
panel = Panel(
f"[cyan]{command_str}[/cyan]",
title="[blue]Next Steps[/blue]",
border_style="blue",
)
console.print(panel)


def format_relative_time(dt: datetime | None) -> str:
"""Format datetime as relative time string.

Expand Down
47 changes: 33 additions & 14 deletions src/agentspaces/cli/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,30 @@
def _get_tab_title(workspace: WorkspaceInfo) -> str:
"""Get tab title from beads issue or workspace name.

If workspace purpose contains a beads issue ID, fetches the issue
title from beads. Otherwise falls back to workspace name.
Prefers issue_id field if available, otherwise checks purpose field
for backward compatibility. Fetches issue title from beads.

Args:
workspace: Workspace to get title for.

Returns:
Tab title string (truncated to 30 chars).
"""
# Check if purpose looks like a beads issue ID
if workspace.purpose and workspace.purpose.startswith("agentspaces-"):
# Prefer issue_id field, fall back to parsing purpose for backward compatibility
issue_id = workspace.issue_id
if (
not issue_id
and workspace.purpose
and workspace.purpose.startswith("agentspaces-")
):
# Backward compatibility: parse from purpose
issue_id = workspace.purpose.split(":")[0].strip()

# Fetch issue title from beads if we have an issue_id
if issue_id:
try:
result = subprocess.run( # nosec: B603, B607
["bd", "show", workspace.purpose, "--json"],
["bd", "show", issue_id, "--json"],
capture_output=True,
text=True,
timeout=5,
Expand All @@ -54,16 +64,16 @@ def _get_tab_title(workspace: WorkspaceInfo) -> str:
title: str = str(issues[0]["title"])
logger.debug(
"tab_title_from_beads",
issue_id=workspace.purpose,
issue_id=issue_id,
title=title,
)
return title[:30]
except subprocess.TimeoutExpired:
logger.warning("beads_timeout", issue_id=workspace.purpose)
logger.warning("beads_timeout", issue_id=issue_id)
except json.JSONDecodeError:
logger.warning("beads_json_error", issue_id=workspace.purpose)
logger.warning("beads_json_error", issue_id=issue_id)
except (KeyError, IndexError, TypeError):
logger.warning("beads_data_error", issue_id=workspace.purpose)
logger.warning("beads_data_error", issue_id=issue_id)

# Fallback to workspace name
return workspace.name[:30]
Expand Down Expand Up @@ -95,11 +105,20 @@ def _build_navigation_commands(workspace: WorkspaceInfo, tab_title: str) -> str:
if venv_activate.exists():
commands.append(f"source {shlex.quote(str(venv_activate))}")

# Launch claude with plan prompt (if issue ID exists in purpose)
if workspace.purpose and workspace.purpose.startswith("agentspaces-"):
# Use shlex.quote to prevent shell injection via workspace.purpose
quoted_purpose = shlex.quote(workspace.purpose)
commands.append(f"claude 'plan' {quoted_purpose}")
# Launch claude with plan prompt (if issue ID exists)
# Prefer issue_id field, fall back to parsing purpose for backward compatibility
issue_id = workspace.issue_id
if (
not issue_id
and workspace.purpose
and workspace.purpose.startswith("agentspaces-")
):
# Backward compatibility: parse from purpose
issue_id = workspace.purpose.split(":")[0].strip()

if issue_id:
quoted_issue = shlex.quote(issue_id)
commands.append(f"claude 'plan' {quoted_issue}")
else:
commands.append("claude")

Expand Down
65 changes: 64 additions & 1 deletion src/agentspaces/cli/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
print_did_you_mean,
print_error,
print_info,
print_issue_next_steps,
print_next_steps,
print_warning,
print_workspace_created,
print_workspace_created_from_issue,
print_workspace_removed,
print_workspace_status,
print_workspace_table,
)
from agentspaces.infrastructure import git
from agentspaces.infrastructure import beads, git
from agentspaces.infrastructure.similarity import find_similar_names
from agentspaces.modules.workspace.service import (
WorkspaceError,
Expand Down Expand Up @@ -50,6 +52,14 @@ def create(
"--attach", "-a", help="Attach to existing branch instead of creating new"
),
] = False,
next_issue: Annotated[
bool,
typer.Option("--next-issue", help="Create workspace from next ready issue"),
] = False,
issue_id: Annotated[
str | None,
typer.Option("--issue-id", help="Create workspace from specific issue ID"),
] = None,
purpose: Annotated[
str | None,
typer.Option("--purpose", "-p", help="Purpose/description for this workspace"),
Expand All @@ -71,14 +81,67 @@ def create(
Use --attach to create a workspace for an existing branch without
creating a new branch. The workspace name will match the branch name.

Use --next-issue to create a workspace from the next ready beads issue.
Use --issue-id to create a workspace from a specific beads issue.

\b
Examples:
agentspaces workspace create # From current HEAD
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 feature/auth --attach # Attach to existing branch
agentspaces workspace create --next-issue # From next ready issue
agentspaces workspace create --issue-id=proj-123 # From specific issue
"""
# Check mutual exclusivity
if sum([attach, next_issue, bool(issue_id)]) > 1:
print_error("Cannot use --attach, --next-issue, --issue-id together")
raise typer.Exit(1)

# Issue-based workspace creation
if next_issue or issue_id:
# Check beads availability
if not beads.is_beads_available():
print_error("bd command not found. Install: pip install beads-project")
raise typer.Exit(1)

try:
# Get issue
if next_issue:
issues = beads.get_ready_issues()
if not issues:
print_error("No ready issues found. Create with: bd create")
raise typer.Exit(1)
issue = issues[0]
else:
issue = beads.get_issue_by_id(issue_id) # type: ignore[arg-type]

# Create workspace from issue
workspace = _service.create_from_issue(
issue,
base_branch=branch,
python_version=python_version,
setup_venv=not no_venv,
)

# Display issue-specific output
print_workspace_created_from_issue(issue, workspace)
print_issue_next_steps(
workspace_path=str(workspace.path),
issue_id=issue.id,
has_venv=workspace.has_venv,
)
return

except beads.BeadsError as e:
print_error(str(e))
raise typer.Exit(1) from e
except WorkspaceError as e:
print_error(str(e))
raise typer.Exit(1) from e

# Standard workspace creation
try:
if attach:
workspace = _service.create(
Expand Down
Loading