From 95d7cc3b707c1057888ad0864e0c1b93ccb07cdd Mon Sep 17 00:00:00 2001 From: Chris Krough <461869+ckrough@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:12:41 -0500 Subject: [PATCH 1/4] refactor!: remove agent launching, activation, and ancillary features Simplify the CLI by removing features that added complexity without corresponding value. This reduces the codebase by ~1,500 lines (25%) while keeping core workspace management intact. Removed features: - Agent launching (cli/agent.py, modules/agent/) - Workspace activation/current tracking (infrastructure/active.py) - Skills generation (infrastructure/skills.py) - Plan mode configuration (infrastructure/config.py) - Workspace sync command - CLI context for verbose/quiet flags (cli/context.py) Retained core features: - workspace create/list/status/remove commands - docs subcommand for documentation templates - Purpose field for workspace descriptions BREAKING CHANGE: The `agent` subcommand and `workspace activate`, `workspace current`, and `workspace sync` commands have been removed. --- CLAUDE.md | 19 +- README.md | 32 +-- src/agentspaces/cli/agent.py | 205 -------------- src/agentspaces/cli/app.py | 23 +- src/agentspaces/cli/context.py | 62 ----- src/agentspaces/cli/formatters.py | 67 +---- src/agentspaces/cli/workspace.py | 222 +-------------- src/agentspaces/infrastructure/active.py | 93 ------- src/agentspaces/infrastructure/claude.py | 123 --------- src/agentspaces/infrastructure/config.py | 195 -------------- src/agentspaces/infrastructure/paths.py | 59 +--- src/agentspaces/infrastructure/skills.py | 158 ----------- src/agentspaces/modules/agent/__init__.py | 15 -- src/agentspaces/modules/agent/launcher.py | 197 -------------- src/agentspaces/modules/workflow/__init__.py | 1 - src/agentspaces/modules/workspace/service.py | 197 +------------- tests/unit/cli/test_context.py | 124 --------- tests/unit/cli/test_formatters.py | 56 +--- tests/unit/infrastructure/test_active.py | 121 --------- tests/unit/infrastructure/test_claude.py | 224 --------------- tests/unit/infrastructure/test_config.py | 232 ---------------- tests/unit/infrastructure/test_paths.py | 47 ---- tests/unit/infrastructure/test_skills.py | 167 ------------ tests/unit/modules/agent/__init__.py | 1 - tests/unit/modules/agent/test_launcher.py | 270 ------------------- tests/unit/modules/workspace/test_service.py | 102 ------- 26 files changed, 35 insertions(+), 2977 deletions(-) delete mode 100644 src/agentspaces/cli/agent.py delete mode 100644 src/agentspaces/cli/context.py delete mode 100644 src/agentspaces/infrastructure/active.py delete mode 100644 src/agentspaces/infrastructure/claude.py delete mode 100644 src/agentspaces/infrastructure/config.py delete mode 100644 src/agentspaces/infrastructure/skills.py delete mode 100644 src/agentspaces/modules/agent/__init__.py delete mode 100644 src/agentspaces/modules/agent/launcher.py delete mode 100644 src/agentspaces/modules/workflow/__init__.py delete mode 100644 tests/unit/cli/test_context.py delete mode 100644 tests/unit/infrastructure/test_active.py delete mode 100644 tests/unit/infrastructure/test_claude.py delete mode 100644 tests/unit/infrastructure/test_config.py delete mode 100644 tests/unit/infrastructure/test_skills.py delete mode 100644 tests/unit/modules/agent/__init__.py delete mode 100644 tests/unit/modules/agent/test_launcher.py diff --git a/CLAUDE.md b/CLAUDE.md index 50cc533..6b46a45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,12 +37,11 @@ src/agentspaces/ │ ├── frontmatter.py # YAML frontmatter parser │ └── logging.py # structlog config └── templates/ # Bundled project templates - ├── skeleton/ # Project skeleton templates - │ ├── CLAUDE.md # Agent constitution template - │ ├── TODO.md # Task list template - │ ├── .claude/ # Agent/command templates - │ └── docs/ # ADR and design templates - └── skills/ # Skill templates + └── skeleton/ # Project skeleton templates + ├── CLAUDE.md # Agent constitution template + ├── TODO.md # Task list template + ├── .claude/ # Agent/command templates + └── docs/ # ADR and design templates ``` ## Architecture @@ -68,19 +67,13 @@ A workspace is: - Metadata in `.agentspace/` directory - Optional Python venv in `.venv/` -### Agent Skills - -Uses [agentskills.io](https://agentskills.io) standard: -- `.github/skills/` for project-level skills -- `.agentspace/skills/` for workspace-specific skills -- Auto-discovered by compatible agents - ## Commands ```bash # Workspaces agentspaces workspace create [branch] # Create workspace agentspaces workspace list # List workspaces +agentspaces workspace status # Show workspace details agentspaces workspace remove # Remove workspace # Design templates diff --git a/README.md b/README.md index ff3b65f..fba2b28 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # agentspaces -Workspace orchestration for AI coding agents. Manage isolated workspaces for parallel development with git worktrees, tracked context, and agent integration. +Workspace orchestration for AI coding agents. Manage isolated workspaces for parallel development with git worktrees and tracked context. ## Features - **Parallel Development** - Work on multiple features simultaneously without branch switching - **Isolated Environments** - Each workspace has its own Python venv and dependencies -- **Agent Integration** - Launch Claude Code directly into workspaces with context - **Project Templates** - Generate documentation optimized for AI agents (CLAUDE.md, TODO.md, ADRs) -- **Workspace Tracking** - Purpose, metadata, and activity tracking per workspace +- **Workspace Tracking** - Purpose, metadata, and timestamps per workspace ## Quick Start @@ -35,13 +34,6 @@ agentspaces workspace create main --purpose "Add user authentication" # Created: eager-turing at ~/.agentspaces/my-app/eager-turing ``` -### Launch an Agent - -```bash -agentspaces agent launch eager-turing --use-purpose -# Launches Claude Code with prompt: "Add user authentication" -``` - ## Usage ### Workspace Commands @@ -49,19 +41,10 @@ agentspaces agent launch eager-turing --use-purpose ```bash agentspaces workspace create [branch] # Create from branch (default: HEAD) agentspaces workspace list # List all workspaces -agentspaces workspace status [name] # Show detailed status -agentspaces workspace activate # Set as active workspace -agentspaces workspace sync [name] # Sync dependencies +agentspaces workspace status # Show detailed status agentspaces workspace remove # Remove workspace ``` -### Agent Commands - -```bash -agentspaces agent launch [workspace] # Launch Claude Code -agentspaces agent launch --use-purpose # Use workspace purpose as prompt -``` - ### Documentation Templates ```bash @@ -78,9 +61,8 @@ Workspaces are stored at `~/.agentspaces///`: ``` ~/.agentspaces/my-app/eager-turing/ -├── .agentspace/ # Metadata and skills -│ ├── workspace.json -│ └── skills/ +├── .agentspace/ # Metadata +│ └── workspace.json ├── .venv/ # Isolated Python environment └── # Git worktree ``` @@ -88,12 +70,12 @@ Workspaces are stored at `~/.agentspaces///`: ## Architecture ``` -[CLI] → [Service] → [Git Worktree] → [Python Env] → [Agent Launch] +[CLI] → [Service] → [Git Worktree] → [Python Env] ``` - **CLI Layer** - Typer commands with Rich output - **Service Layer** - WorkspaceService orchestration -- **Infrastructure** - Git, uv, and Claude subprocess wrappers +- **Infrastructure** - Git and uv subprocess wrappers See [docs/design/architecture.md](docs/design/architecture.md) for detailed system design. diff --git a/src/agentspaces/cli/agent.py b/src/agentspaces/cli/agent.py deleted file mode 100644 index 3fc3180..0000000 --- a/src/agentspaces/cli/agent.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Agent management CLI commands.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated - -import typer - -from agentspaces.cli.formatters import ( - print_did_you_mean, - print_error, - print_info, - print_success, - print_warning, -) -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, - WorkspaceService, -) - -app = typer.Typer( - name="agent", - help="Launch AI coding agents in workspaces.", - no_args_is_help=True, -) - -# Shared launcher instance -_launcher = AgentLauncher() - - -def _resolve_plan_mode(plan_mode: bool, no_plan_mode: bool) -> bool: - """Resolve effective plan mode from CLI flags and config. - - Priority: --no-plan-mode > --plan-mode > config default. - - Args: - plan_mode: Explicit enable flag. - no_plan_mode: Explicit disable flag. - - Returns: - Effective plan mode setting. - """ - if no_plan_mode: - return False - if plan_mode: - return True - - # Use config default - from agentspaces.cli.context import CLIContext - - ctx = CLIContext.get() - config = ctx.get_config() - return config.plan_mode_by_default - - -@app.command("launch") -def launch( - workspace: Annotated[ - str | None, - typer.Argument( - help="Workspace name (omit to auto-detect from current directory)" - ), - ] = None, - prompt: Annotated[ - str | None, - typer.Option("--prompt", "-p", help="Initial prompt/instruction for the agent"), - ] = None, - use_purpose: Annotated[ - bool, - typer.Option( - "--use-purpose", - help="Use workspace purpose as initial prompt (mutually exclusive with --prompt)", - ), - ] = False, - plan_mode: Annotated[ - bool, - typer.Option( - "--plan-mode", - help="Enable plan mode (explore before making changes)", - ), - ] = False, - no_plan_mode: Annotated[ - bool, - typer.Option( - "--no-plan-mode", - help="Disable plan mode even if enabled in config", - ), - ] = False, -) -> None: - """Launch Claude Code in a workspace. - - If no workspace is specified, attempts to detect if currently in a - workspace directory. - - \b - Examples: - agentspaces agent launch eager-turing # Launch in specific workspace - agentspaces agent launch # Auto-detect from current directory - agentspaces agent launch -p "Fix auth bug" # With initial prompt - agentspaces agent launch --use-purpose # Use workspace purpose as prompt - """ - # Validate mutually exclusive flags - if use_purpose and prompt: - print_error("Cannot use both --prompt and --use-purpose") - print_info( - "Choose one: provide a prompt with -p, or use workspace purpose with --use-purpose" - ) - raise typer.Exit(1) - - if plan_mode and no_plan_mode: - print_error("Cannot use both --plan-mode and --no-plan-mode") - print_info("Choose one or omit both to use config default") - raise typer.Exit(1) - - # Handle --use-purpose flag - effective_prompt = prompt - if use_purpose: - if not workspace: - print_error("--use-purpose requires a workspace name") - print_info( - "Specify workspace: agentspaces agent launch --use-purpose" - ) - raise typer.Exit(1) - - try: - service = WorkspaceService() - ws_info = service.get(workspace) - if ws_info.purpose: - effective_prompt = ws_info.purpose - print_info(f"Using workspace purpose: {effective_prompt}") - else: - print_error("Workspace has no purpose set") - print_info("Create workspace with --purpose or use --prompt instead") - raise typer.Exit(1) - except WorkspaceNotFoundError: - print_error(f"Workspace not found: {workspace}") - _suggest_similar_workspaces(workspace) - print_info("Use 'agentspaces workspace list' to see available workspaces") - raise typer.Exit(1) from None - except WorkspaceError as e: - print_error(f"Could not read workspace: {e}") - raise typer.Exit(1) from None - - # Determine plan mode setting: CLI flag > config > default - effective_plan_mode = _resolve_plan_mode(plan_mode, no_plan_mode) - - try: - # Show which workspace we're launching in - if workspace: - print_info(f"Launching Claude Code in '{workspace}'...") - else: - print_info("Launching Claude Code (auto-detecting workspace)...") - - result = _launcher.launch_claude( - workspace, - prompt=effective_prompt, - plan_mode=effective_plan_mode, - cwd=Path.cwd(), - ) - - if result.exit_code == 0: - print_success(f"Claude Code session ended in '{result.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 WorkspaceNotFoundError: - print_error(f"Workspace not found: {workspace}") - _suggest_similar_workspaces(workspace) - print_info("Use 'agentspaces workspace list' to see available workspaces") - raise typer.Exit(1) from None - - except (AgentError, WorkspaceError) as e: - print_error(str(e)) - raise typer.Exit(1) from e - - -def _suggest_similar_workspaces(workspace_name: str | None) -> None: - """Try to suggest similar workspace names. - - Args: - workspace_name: The workspace name that was not found. - """ - if not workspace_name: - return - - try: - service = WorkspaceService() - workspaces = service.list() - suggestions = find_similar_names(workspace_name, [ws.name for ws in workspaces]) - print_did_you_mean(suggestions) - except WorkspaceError: - pass # Don't fail on suggestion lookup diff --git a/src/agentspaces/cli/app.py b/src/agentspaces/cli/app.py index cf7092a..b6fd2f0 100644 --- a/src/agentspaces/cli/app.py +++ b/src/agentspaces/cli/app.py @@ -5,8 +5,7 @@ import typer from agentspaces import __version__ -from agentspaces.cli import agent, docs, workspace -from agentspaces.cli.context import CLIContext +from agentspaces.cli import docs, workspace from agentspaces.infrastructure.logging import configure_logging # Main application @@ -18,7 +17,6 @@ ) # Register subcommand groups -app.add_typer(agent.app, name="agent") app.add_typer(docs.app, name="docs") app.add_typer(workspace.app, name="workspace") @@ -46,7 +44,7 @@ def main( "-v", help="Show debug output.", ), - quiet: bool = typer.Option( + quiet: bool = typer.Option( # noqa: ARG001 - reserved for future use False, "--quiet", "-q", @@ -55,22 +53,7 @@ def main( ) -> None: """agentspaces: Workspace orchestration for AI coding agents. - Create isolated workspaces, launch agents with context, and orchestrate - multi-step workflows. + Create isolated workspaces for development tasks. """ - # Validate mutually exclusive flags - if verbose and quiet: - import sys - - from agentspaces.cli.formatters import print_error - - print_error("Cannot use both --verbose and --quiet") - sys.exit(1) - - # Set up CLI context for verbosity control - ctx = CLIContext.get() - ctx.verbose = verbose - ctx.quiet = quiet - # Configure logging (debug only when verbose) configure_logging(debug=verbose) diff --git a/src/agentspaces/cli/context.py b/src/agentspaces/cli/context.py deleted file mode 100644 index 0f45cb7..0000000 --- a/src/agentspaces/cli/context.py +++ /dev/null @@ -1,62 +0,0 @@ -"""CLI context state management.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar - -if TYPE_CHECKING: - from agentspaces.infrastructure.config import GlobalConfig - -__all__ = ["CLIContext"] - - -@dataclass -class CLIContext: - """Global CLI context for verbosity and other state. - - Uses singleton pattern to share state across all CLI commands. - - Note: Mutable dataclass to allow setting verbose/quiet flags at runtime. - Assumes single-threaded CLI environment. - """ - - verbose: bool = False - quiet: bool = False - config: GlobalConfig | None = None - - _instance: ClassVar[CLIContext | None] = None - - @classmethod - def get(cls) -> CLIContext: - """Get the singleton CLI context instance. - - Returns: - The shared CLIContext instance. - """ - if cls._instance is None: - cls._instance = cls() - return cls._instance - - def get_config(self) -> GlobalConfig: - """Get global config, loading and caching on first access. - - Returns: - Loaded or default GlobalConfig instance. - """ - if self.config is None: - from agentspaces.infrastructure.config import load_global_config - - # Cache config after first load - self.config = load_global_config() - - assert self.config is not None # Always set in the if block above - return self.config - - @classmethod - def reset(cls) -> None: - """Reset the singleton instance. - - Useful for testing to ensure clean state. - """ - cls._instance = None diff --git a/src/agentspaces/cli/formatters.py b/src/agentspaces/cli/formatters.py index 94b7a65..78cdf0c 100644 --- a/src/agentspaces/cli/formatters.py +++ b/src/agentspaces/cli/formatters.py @@ -9,8 +9,6 @@ from rich.panel import Panel from rich.table import Table -from agentspaces.cli.context import CLIContext - if TYPE_CHECKING: from agentspaces.modules.workspace.service import WorkspaceInfo @@ -22,7 +20,6 @@ "print_error", "print_info", "print_next_steps", - "print_quick_start", "print_success", "print_warning", "print_workspace_created", @@ -52,12 +49,8 @@ def print_warning(message: str) -> None: def print_info(message: str) -> None: - """Print an info message. - - Suppressed when --quiet flag is set. - """ - if not CLIContext.get().quiet: - console.print(f"[blue]i[/blue] {message}") + """Print an info message.""" + console.print(f"[blue]i[/blue] {message}") def print_did_you_mean(suggestions: list[str]) -> None: @@ -107,25 +100,15 @@ def print_workspace_created( def print_next_steps(workspace_name: str, workspace_path: str, has_venv: bool) -> None: """Print actionable next steps after workspace creation. - Suppressed when --quiet flag is set. - Args: workspace_name: Name of the created workspace. workspace_path: Path to the workspace directory. has_venv: Whether a virtual environment was created. """ - if CLIContext.get().quiet: - return - steps = [f"cd {workspace_path}"] if has_venv: steps.append("source .venv/bin/activate") - steps.extend( - [ - "agentspaces agent launch", - f"agentspaces workspace remove {workspace_name}", - ] - ) + steps.append(f"agentspaces workspace remove {workspace_name}") lines = [f" {i + 1}. [cyan]{step}[/cyan]" for i, step in enumerate(steps)] panel = Panel( @@ -135,34 +118,6 @@ 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. @@ -247,27 +202,15 @@ def print_workspace_status( workspace: WorkspaceInfo, *, is_dirty: bool = False, - is_active: bool = False, ) -> None: """Print detailed workspace status panel. Args: workspace: Workspace information. is_dirty: Whether the workspace has uncommitted changes. - is_active: Whether this is the active workspace. """ # Status badge - status_parts = [] - if is_active: - status_parts.append("[green]● active[/green]") - else: - status_parts.append("[dim]○ inactive[/dim]") - if is_dirty: - status_parts.append("[yellow]● dirty[/yellow]") - else: - status_parts.append("[green]● clean[/green]") - - status_line = " ".join(status_parts) + status_line = "[yellow]● dirty[/yellow]" if is_dirty else "[green]● clean[/green]" lines = [ f"[bold]Status:[/bold] {status_line}", @@ -291,8 +234,6 @@ def print_workspace_status( lines.append("") lines.append("[bold]Timestamps[/bold]") lines.append(f" Created: {format_relative_time(workspace.created_at)}") - lines.append(f" Synced: {format_relative_time(workspace.deps_synced_at)}") - lines.append(f" Activity: {format_relative_time(workspace.last_activity_at)}") panel = Panel( "\n".join(lines), diff --git a/src/agentspaces/cli/workspace.py b/src/agentspaces/cli/workspace.py index 17bd4c3..46cd942 100644 --- a/src/agentspaces/cli/workspace.py +++ b/src/agentspaces/cli/workspace.py @@ -9,13 +9,11 @@ import typer -from agentspaces.cli.agent import _resolve_plan_mode from agentspaces.cli.formatters import ( print_did_you_mean, print_error, print_info, print_next_steps, - print_success, print_warning, print_workspace_created, print_workspace_removed, @@ -24,11 +22,6 @@ ) 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, @@ -69,26 +62,6 @@ 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, - plan_mode: Annotated[ - bool, - typer.Option( - "--plan-mode", - help="Enable plan mode when launching (requires --launch)", - ), - ] = False, - no_plan_mode: Annotated[ - bool, - typer.Option( - "--no-plan-mode", - help="Disable plan mode when launching (requires --launch)", - ), - ] = False, ) -> None: """Create a new isolated workspace from a branch. @@ -105,17 +78,7 @@ def create( 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 --launch # Create and launch agent """ - # Validate plan mode flags require --launch - if (plan_mode or no_plan_mode) and not launch: - print_error("--plan-mode and --no-plan-mode require --launch") - raise typer.Exit(1) - - if plan_mode and no_plan_mode: - print_error("Cannot use both --plan-mode and --no-plan-mode") - raise typer.Exit(1) - try: if attach: workspace = _service.create( @@ -146,61 +109,11 @@ def create( 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, - plan_mode=plan_mode, - no_plan_mode=no_plan_mode, - ) - 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, - plan_mode: bool = False, - no_plan_mode: bool = False, -) -> None: - """Launch Claude Code agent in a newly created workspace. - - Args: - workspace_name: Name of the workspace. - workspace_path: Path to the workspace directory. - plan_mode: Enable plan mode explicitly. - no_plan_mode: Disable plan mode explicitly. - """ - # Determine plan mode setting: CLI flag > config > default - effective_plan_mode = _resolve_plan_mode(plan_mode, no_plan_mode) - - print_info(f"Launching Claude Code in '{workspace_name}'...") - - launcher = AgentLauncher() - try: - result = launcher.launch_claude( - workspace_name, - plan_mode=effective_plan_mode, - cwd=workspace_path, - ) - - 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 + print_next_steps( + workspace_name=workspace.name, + workspace_path=str(workspace.path), + has_venv=workspace.has_venv, + ) @app.command("list") @@ -302,14 +215,7 @@ def remove( _service.remove(name, force=force) except WorkspaceNotFoundError: print_error(f"Workspace not found: {name}") - # Try to suggest similar workspace names - try: - workspaces = _service.list() - suggestions = find_similar_names(name, [ws.name for ws in workspaces]) - print_did_you_mean(suggestions) - except WorkspaceError: - pass # Don't fail on suggestion lookup - print_info("Use 'agentspaces workspace list' to see available workspaces") + _suggest_similar_workspaces(name) raise typer.Exit(1) from None except WorkspaceError as e: print_error(str(e)) @@ -332,9 +238,9 @@ def _suggest_similar_workspaces(name: str) -> None: @app.command("status") def status( name: Annotated[ - str | None, - typer.Argument(help="Workspace name (uses active if not specified)"), - ] = None, + str, + typer.Argument(help="Workspace name"), + ], ) -> None: """Show detailed workspace status. @@ -343,20 +249,8 @@ def status( \b Examples: - agentspaces workspace status # Status of active workspace agentspaces workspace status eager-turing # Status of specific workspace """ - # Determine which workspace to show - if name is None: - active = _service.get_active() - if active is None: - print_error("No workspace specified and no active workspace set.") - print_info( - "Use 'agentspaces workspace status ' or 'agentspaces workspace activate '" - ) - raise typer.Exit(1) - name = active.name - try: workspace = _service.get(name) except WorkspaceNotFoundError: @@ -367,105 +261,9 @@ def status( print_error(str(e)) raise typer.Exit(1) from e - # Check if this is the active workspace - is_active = False - try: - active = _service.get_active() - is_active = active is not None and active.name == name - except WorkspaceError: - pass - # Check git status is_dirty = False with contextlib.suppress(git.GitError): is_dirty = git.is_dirty(workspace.path) - print_workspace_status(workspace, is_dirty=is_dirty, is_active=is_active) - - -@app.command("activate") -def activate( - name: Annotated[ - str, - typer.Argument(help="Workspace name to set as active"), - ], -) -> None: - """Set a workspace as the active workspace. - - The active workspace is used as the default for commands like - 'agentspaces agent launch' when no workspace is specified. - - \b - Examples: - agentspaces workspace activate eager-turing # Set as active - agentspaces workspace current # Show current active - """ - try: - _service.set_active(name) - except WorkspaceNotFoundError: - print_error(f"Workspace not found: {name}") - _suggest_similar_workspaces(name) - raise typer.Exit(1) from None - except WorkspaceError as e: - print_error(str(e)) - raise typer.Exit(1) from e - - print_success(f"Active workspace set to: {name}") - - -@app.command("current") -def current() -> None: - """Show the currently active workspace. - - The active workspace is used as the default for commands like - 'agentspaces agent launch' when no workspace is specified. - - \b - Examples: - agentspaces workspace current # Show active workspace - agentspaces workspace activate eager-turing # Set active workspace - """ - try: - active = _service.get_active() - except WorkspaceError as e: - print_error(str(e)) - raise typer.Exit(1) from e - - if active is None: - print_info("No active workspace set.") - print_info("Use 'agentspaces workspace activate ' to set one.") - raise typer.Exit(0) - - print_info(f"Active workspace: [cyan]{active.name}[/cyan]") - print_info(f"Path: {active.path}") - - -@app.command("sync") -def sync( - name: Annotated[ - str | None, - typer.Argument(help="Workspace name (uses active if not specified)"), - ] = None, -) -> None: - """Sync workspace dependencies with uv sync. - - Runs 'uv sync --all-extras' in the workspace to install or update - dependencies from pyproject.toml. - - \b - Examples: - agentspaces workspace sync # Sync active workspace - agentspaces workspace sync eager-turing # Sync specific workspace - """ - try: - workspace = _service.sync_deps(name) - except WorkspaceNotFoundError as e: - print_error(f"Workspace not found: {e}") - if name: - _suggest_similar_workspaces(name) - raise typer.Exit(1) from None - except WorkspaceError as e: - print_error(str(e)) - raise typer.Exit(1) from e - - print_success(f"Dependencies synced for: {workspace.name}") + print_workspace_status(workspace, is_dirty=is_dirty) diff --git a/src/agentspaces/infrastructure/active.py b/src/agentspaces/infrastructure/active.py deleted file mode 100644 index d0868c3..0000000 --- a/src/agentspaces/infrastructure/active.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Active workspace tracking. - -Manages the .active file that stores the currently active workspace -for a project. This enables the 'agentspaces agent launch' command to fall back -to the active workspace when not inside a workspace directory. -""" - -from __future__ import annotations - -from pathlib import Path # noqa: TC003 - used at runtime in function signatures - -import structlog - -__all__ = [ - "clear_active_workspace", - "get_active_workspace", - "set_active_workspace", -] - -logger = structlog.get_logger() - - -def get_active_workspace(project_dir: Path) -> str | None: - """Read the active workspace name from the .active file. - - Args: - project_dir: Path to the project directory (e.g., ~/.agentspaces/myproject/). - - Returns: - Workspace name if .active exists and contains a valid name, None otherwise. - """ - active_file = project_dir / ".active" - - if not active_file.exists(): - return None - - try: - content = active_file.read_text(encoding="utf-8").strip() - if content: - logger.debug( - "active_workspace_read", project=project_dir.name, workspace=content - ) - return content - return None - except OSError as e: - logger.warning( - "active_workspace_read_error", path=str(active_file), error=str(e) - ) - return None - - -def set_active_workspace(project_dir: Path, workspace_name: str) -> None: - """Write the active workspace name to the .active file. - - Args: - project_dir: Path to the project directory. - workspace_name: Name of the workspace to set as active. - """ - active_file = project_dir / ".active" - - try: - # Ensure project directory exists - project_dir.mkdir(parents=True, exist_ok=True) - - active_file.write_text(workspace_name + "\n", encoding="utf-8") - logger.debug( - "active_workspace_set", - project=project_dir.name, - workspace=workspace_name, - ) - except OSError as e: - logger.warning( - "active_workspace_write_error", path=str(active_file), error=str(e) - ) - raise - - -def clear_active_workspace(project_dir: Path) -> None: - """Remove the .active file, clearing the active workspace. - - Args: - project_dir: Path to the project directory. - """ - active_file = project_dir / ".active" - - try: - active_file.unlink(missing_ok=True) - logger.debug("active_workspace_cleared", project=project_dir.name) - except OSError as e: - logger.warning( - "active_workspace_clear_error", path=str(active_file), error=str(e) - ) - raise diff --git a/src/agentspaces/infrastructure/claude.py b/src/agentspaces/infrastructure/claude.py deleted file mode 100644 index 2bed0ed..0000000 --- a/src/agentspaces/infrastructure/claude.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Claude Code operations via subprocess.""" - -from __future__ import annotations - -import functools -import subprocess -from pathlib import Path # noqa: TC003 - used at runtime for cwd - -import structlog - -__all__ = [ - "ClaudeError", - "ClaudeNotFoundError", - "is_claude_available", - "launch", -] - -logger = structlog.get_logger() - -# Maximum prompt length to prevent excessive command line arguments -MAX_PROMPT_LENGTH = 10000 - - -class ClaudeError(Exception): - """Raised when a Claude Code operation fails.""" - - def __init__(self, message: str, returncode: int) -> None: - super().__init__(message) - self.returncode = returncode - - -class ClaudeNotFoundError(ClaudeError): - """Raised when Claude Code is not installed.""" - - def __init__(self) -> None: - super().__init__( - "Claude Code not found. Install from: https://claude.ai/download", - returncode=-1, - ) - - -@functools.cache -def is_claude_available() -> bool: - """Check if Claude Code is installed and available. - - Returns: - True if Claude Code is available. - - Note: - Result is cached for performance. Restart process if Claude is installed. - """ - try: - result = subprocess.run( - ["claude", "--version"], - capture_output=True, - text=True, - timeout=5, - ) - return result.returncode == 0 - except FileNotFoundError: - return False - except subprocess.TimeoutExpired: - # If it times out, claude exists but something is wrong - return False - - -def launch( - cwd: Path, - *, - prompt: str | None = None, - plan_mode: bool = False, -) -> int: - """Launch Claude Code interactively. - - Unlike git/uv operations, this does NOT capture output. - It runs interactively, streaming to the user's terminal. - - Args: - cwd: Working directory to launch in. - prompt: Optional initial prompt/instruction (max 10000 chars). - plan_mode: If True, launch with --permission-mode plan flag. - - Returns: - Exit code from Claude Code process. - - Raises: - ClaudeNotFoundError: If Claude Code is not installed. - ClaudeError: If launch fails. - ValueError: If prompt exceeds maximum length. - """ - cmd = ["claude"] - - # Add plan mode flag if enabled - if plan_mode: - cmd.extend(["--permission-mode", "plan"]) - - if prompt: - if len(prompt) > MAX_PROMPT_LENGTH: - raise ValueError( - f"Prompt too long: {len(prompt)} chars (max {MAX_PROMPT_LENGTH})" - ) - # 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, - plan_mode=plan_mode, - ) - - try: - # Interactive mode: don't capture output, stream to terminal - result = subprocess.run( - cmd, - cwd=cwd, - # No capture_output - streams to terminal - ) - return result.returncode - except FileNotFoundError as e: - raise ClaudeNotFoundError() from e - except OSError as e: - raise ClaudeError(f"Failed to launch Claude Code: {e}", returncode=-1) from e diff --git a/src/agentspaces/infrastructure/config.py b/src/agentspaces/infrastructure/config.py deleted file mode 100644 index 5c466c9..0000000 --- a/src/agentspaces/infrastructure/config.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Global configuration persistence. - -Handles reading and writing global config.json with schema versioning -and atomic write operations. -""" - -from __future__ import annotations - -import json -import tempfile -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Any - -import structlog - -from agentspaces.infrastructure.paths import PathResolver, default_resolver - -__all__ = [ - "ConfigError", - "GlobalConfig", - "load_global_config", - "save_global_config", -] - -logger = structlog.get_logger() - -# Current schema version - increment when making breaking changes -# v1: Initial schema with plan_mode_by_default -SCHEMA_VERSION = "1" - -# Maximum config file size (1MB - same as metadata) -MAX_CONFIG_SIZE = 1 * 1024 * 1024 - - -class ConfigError(Exception): - """Raised when configuration operations fail.""" - - -@dataclass(frozen=True) -class GlobalConfig: - """Immutable global configuration for agentspaces. - - Attributes: - plan_mode_by_default: When true, automatically use --permission-mode plan - for agent launches unless overridden with --no-plan-mode. - """ - - plan_mode_by_default: bool = True - - -def save_global_config( - config: GlobalConfig, resolver: PathResolver | None = None -) -> None: - """Save global configuration to a JSON file. - - Uses atomic write (temp file + rename) to prevent corruption. - - Args: - config: Configuration to save. - resolver: Path resolver (defaults to default_resolver). - - Raises: - ConfigError: If saving fails. - """ - if resolver is None: - resolver = default_resolver - - path = resolver.global_config() - data = _config_to_dict(config) - - # Ensure parent directory exists - path.parent.mkdir(parents=True, exist_ok=True) - - tmp_path: Path | None = None - try: - # Atomic write: write to temp file, then rename - # This prevents corruption if process is interrupted. - # File handle is closed when exiting context, which is necessary - # for replace() to work correctly on all platforms. - with tempfile.NamedTemporaryFile( - mode="w", - dir=path.parent, - suffix=".tmp", - delete=False, - encoding="utf-8", - ) as tmp: - json.dump(data, tmp, indent=2) - tmp_path = Path(tmp.name) - - # Atomic rename (file handle closed, safe on all platforms) - tmp_path.replace(path) - - logger.debug("config_saved", path=str(path)) - - except OSError as e: - # Clean up temp file if it was created - if tmp_path is not None: - tmp_path.unlink(missing_ok=True) - raise ConfigError(f"Failed to save config: {e}") from e - - -def load_global_config(resolver: PathResolver | None = None) -> GlobalConfig: - """Load global configuration from a JSON file. - - Gracefully handles missing files, invalid JSON, and oversized files. - Returns default config if file doesn't exist or is invalid. - - Args: - resolver: Path resolver (defaults to default_resolver). - - Returns: - GlobalConfig instance (uses defaults if file missing or invalid). - """ - if resolver is None: - resolver = default_resolver - - path = resolver.global_config() - - if not path.exists(): - logger.debug("config_not_found", path=str(path)) - return GlobalConfig() - - try: - # Check file size before reading to prevent DoS - file_size = path.stat().st_size - if file_size > MAX_CONFIG_SIZE: - logger.warning( - "config_too_large", - path=str(path), - size=file_size, - max_size=MAX_CONFIG_SIZE, - ) - return GlobalConfig() - - content = path.read_text(encoding="utf-8") - data = json.loads(content) - - # Check schema version - version = data.get("version") - if version and version != SCHEMA_VERSION: - logger.warning( - "config_version_mismatch", - path=str(path), - expected=SCHEMA_VERSION, - found=version, - ) - # Still try to load - be forward-compatible - - return _dict_to_config(data) - - except json.JSONDecodeError as e: - logger.warning("config_invalid_json", path=str(path), error=str(e)) - return GlobalConfig() - except (KeyError, TypeError, ValueError) as e: - logger.warning("config_parse_error", path=str(path), error=str(e)) - return GlobalConfig() - except OSError as e: - logger.warning("config_read_error", path=str(path), error=str(e)) - return GlobalConfig() - - -def _config_to_dict(config: GlobalConfig) -> dict[str, Any]: - """Convert config to JSON-serializable dict. - - Args: - config: GlobalConfig to convert. - - Returns: - Dict with version field. - """ - data = asdict(config) - data["version"] = SCHEMA_VERSION - return data - - -def _dict_to_config(data: dict[str, Any]) -> GlobalConfig: - """Convert dict to GlobalConfig. - - Args: - data: Dict from JSON. - - Returns: - GlobalConfig instance. - - Raises: - TypeError: If field has invalid type. - """ - plan_mode = data.get("plan_mode_by_default", True) - if not isinstance(plan_mode, bool): - raise TypeError( - f"plan_mode_by_default must be bool, got {type(plan_mode).__name__}" - ) - - return GlobalConfig(plan_mode_by_default=plan_mode) diff --git a/src/agentspaces/infrastructure/paths.py b/src/agentspaces/infrastructure/paths.py index 890e129..1230cc0 100644 --- a/src/agentspaces/infrastructure/paths.py +++ b/src/agentspaces/infrastructure/paths.py @@ -50,15 +50,10 @@ class PathResolver: Storage layout: ~/.agentspaces/ - ├── config.json └── / └── / ├── .agentspace/ - │ ├── workspace.json - │ ├── skills/ - │ │ └── workspace-context/ - │ │ └── SKILL.md - │ └── sessions/ + │ └── workspace.json ├── .venv/ └── """ @@ -123,47 +118,6 @@ def workspace_json(self, project: str, workspace: str) -> Path: """ return self.metadata_dir(project, workspace) / "workspace.json" - def skills_dir(self, project: str, workspace: str) -> Path: - """Skills directory within a workspace. - - Args: - project: Project/repository name. - workspace: Workspace name. - """ - return self.metadata_dir(project, workspace) / "skills" - - def workspace_context_skill(self, project: str, workspace: str) -> Path: - """Path to workspace-context skill directory. - - Args: - project: Project/repository name. - workspace: Workspace name. - """ - return self.skills_dir(project, workspace) / "workspace-context" - - def sessions_dir(self, project: str, workspace: str) -> Path: - """Sessions directory within a workspace. - - Args: - project: Project/repository name. - workspace: Workspace name. - """ - return self.metadata_dir(project, workspace) / "sessions" - - def session_dir(self, project: str, workspace: str, session_id: str) -> Path: - """Directory for a specific session. - - Args: - project: Project/repository name. - workspace: Workspace name. - session_id: Session identifier. - - Raises: - InvalidNameError: If any name is invalid. - """ - _validate_name(session_id, "session") - return self.sessions_dir(project, workspace) / session_id - def venv_dir(self, project: str, workspace: str) -> Path: """Virtual environment directory. @@ -216,17 +170,6 @@ def list_projects(self) -> list[str]: if d.is_dir() and d.name != "config.json" ] - def active_file(self, project: str) -> Path: - """Path to the .active file for a project. - - Args: - project: Project/repository name. - - Returns: - Path to the .active file. - """ - return self.project_dir(project) / ".active" - # Default resolver instance default_resolver = PathResolver() diff --git a/src/agentspaces/infrastructure/skills.py b/src/agentspaces/infrastructure/skills.py deleted file mode 100644 index 83efa29..0000000 --- a/src/agentspaces/infrastructure/skills.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Workspace skill generation. - -Generates skill files (SKILL.md) using Jinja2 templates for agent discovery. -""" - -from __future__ import annotations - -import re -from typing import TYPE_CHECKING - -import structlog -from jinja2 import Environment, FileSystemLoader, TemplateNotFound - -from agentspaces.infrastructure.resources import ResourceError, get_skills_templates_dir - -if TYPE_CHECKING: - from pathlib import Path - - from agentspaces.infrastructure.metadata import WorkspaceMetadata - -__all__ = [ - "SkillError", - "generate_workspace_context_skill", -] - -logger = structlog.get_logger() - - -class SkillError(Exception): - """Raised when skill operations fail.""" - - -def _sanitize_for_markdown(text: str) -> str: - """Sanitize user input for safe Markdown rendering. - - Escapes characters that could be interpreted as Markdown or HTML. - - Args: - text: User-provided text to sanitize. - - Returns: - Sanitized text safe for Markdown templates. - """ - # Remove HTML tags - text = re.sub(r"<[^>]+>", "", text) - # Escape Markdown link syntax to prevent javascript: links - text = re.sub(r"\[([^\]]*)\]\(([^)]*)\)", r"\1", text) - # Escape backticks (code blocks) - text = text.replace("`", "\\`") - return text - - -def _get_template_dir() -> Path: - """Get and validate the skills templates directory path. - - Returns: - Path to the validated templates/skills directory. - - Raises: - SkillError: If templates directory not found or invalid. - """ - try: - skills_dir = get_skills_templates_dir() - except ResourceError as e: - raise SkillError(str(e)) from e - - # Validate expected template structure exists - expected_template = skills_dir / "workspace-context" / "SKILL.md" - if not expected_template.exists(): - raise SkillError( - "Template structure invalid: missing workspace-context/SKILL.md" - ) - - return skills_dir - - -def generate_workspace_context_skill( - metadata: WorkspaceMetadata, - output_dir: Path, -) -> Path: - """Generate the workspace-context skill from template. - - Creates a SKILL.md file that agents can discover to understand - the workspace context. - - Args: - metadata: Workspace metadata for template variables. - output_dir: Directory to write skill files. - - Returns: - Path to the generated SKILL.md file. - - Raises: - SkillError: If generation fails. - """ - try: - skills_dir = _get_template_dir() - skill_template_dir = skills_dir / "workspace-context" - - if not skill_template_dir.exists(): - raise SkillError( - f"Skill template directory not found: {skill_template_dir}" - ) - - # Set up Jinja2 environment - env = Environment( - loader=FileSystemLoader(str(skill_template_dir)), - autoescape=False, # Markdown doesn't need HTML escaping - trim_blocks=True, - lstrip_blocks=True, - ) - - # Load and render template - try: - template = env.get_template("SKILL.md") - except TemplateNotFound as e: - raise SkillError(f"Skill template not found: {e}") from e - - # Build template context with sanitized user input - # Purpose is user-provided and must be sanitized to prevent injection - purpose = metadata.purpose or "No specific purpose defined" - sanitized_purpose = _sanitize_for_markdown(purpose) - - context = { - "name": metadata.name, - "project": metadata.project, - "branch": metadata.branch, - "base_branch": metadata.base_branch, - "created_at": metadata.created_at.isoformat(), - "purpose": sanitized_purpose, - "python_version": metadata.python_version, - "has_venv": metadata.has_venv, - "status": metadata.status, - } - - rendered = template.render(**context) - - # Ensure output directory exists - output_dir.mkdir(parents=True, exist_ok=True) - - # Write skill file - output_path = output_dir / "SKILL.md" - output_path.write_text(rendered, encoding="utf-8") - - logger.debug( - "skill_generated", - skill="workspace-context", - output=str(output_path), - ) - - return output_path - - except SkillError: - raise - except OSError as e: - raise SkillError(f"Failed to write skill file: {e}") from e - except Exception as e: - raise SkillError(f"Failed to generate skill: {e}") from e diff --git a/src/agentspaces/modules/agent/__init__.py b/src/agentspaces/modules/agent/__init__.py deleted file mode 100644 index a155d81..0000000 --- a/src/agentspaces/modules/agent/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Agent integration module.""" - -from agentspaces.modules.agent.launcher import ( - AgentError, - AgentLauncher, - AgentNotFoundError, - LaunchResult, -) - -__all__ = [ - "AgentError", - "AgentLauncher", - "AgentNotFoundError", - "LaunchResult", -] diff --git a/src/agentspaces/modules/agent/launcher.py b/src/agentspaces/modules/agent/launcher.py deleted file mode 100644 index ebe9e5f..0000000 --- a/src/agentspaces/modules/agent/launcher.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Agent launching service.""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -import structlog - -from agentspaces.infrastructure import claude -from agentspaces.infrastructure.paths import PathResolver -from agentspaces.modules.workspace.service import ( - WorkspaceService, -) - -__all__ = [ - "AgentError", - "AgentLauncher", - "AgentNotFoundError", - "LaunchResult", -] - -logger = structlog.get_logger() - - -class AgentError(Exception): - """Base exception for agent operations.""" - - -class AgentNotFoundError(AgentError): - """Raised when an agent is not installed.""" - - -@dataclass(frozen=True) -class LaunchResult: - """Result of launching an agent.""" - - workspace_name: str - workspace_path: Path - exit_code: int - - -class AgentLauncher: - """Service for launching agents in workspaces.""" - - def __init__( - self, - workspace_service: WorkspaceService | None = None, - resolver: PathResolver | None = None, - ) -> None: - """Initialize the agent launcher. - - Args: - workspace_service: Workspace service instance. - resolver: Path resolver for storage locations. - """ - self._resolver = resolver or PathResolver() - self._workspace_service = workspace_service or WorkspaceService(self._resolver) - - def launch_claude( - self, - workspace_name: str | None = None, - *, - cwd: Path | None = None, - prompt: str | None = None, - plan_mode: bool = False, - ) -> LaunchResult: - """Launch Claude Code in a workspace. - - Args: - workspace_name: Workspace to launch in. If None, detects from cwd, - then falls back to active workspace. - cwd: Current working directory (for detection and project context). - prompt: Optional initial prompt (e.g., workspace purpose). - plan_mode: If True, launch with --permission-mode plan flag. - - Returns: - LaunchResult with workspace details and exit code. - - Raises: - AgentNotFoundError: If Claude Code is not installed. - WorkspaceNotFoundError: If workspace doesn't exist. - AgentError: If launch fails. - """ - # Check Claude is installed first - if not claude.is_claude_available(): - raise AgentNotFoundError( - "Claude Code not found. Install from: https://claude.ai/download" - ) - - # Determine workspace - if workspace_name is None: - # First, try to detect from cwd - workspace_name = self.detect_workspace(cwd) - - # Fall back to active workspace - if workspace_name is None: - try: - active = self._workspace_service.get_active(cwd=cwd) - if active is not None: - workspace_name = active.name - logger.debug( - "using_active_workspace", - workspace=workspace_name, - ) - except (OSError, Exception) as e: - # Not in a git repo or other error - no active workspace available - logger.debug("active_workspace_fallback_failed", error=str(e)) - - if workspace_name is None: - raise AgentError( - "No workspace specified, not in a workspace directory, " - "and no active workspace set. " - "Use 'agentspaces agent launch ' or " - "'agentspaces workspace activate '." - ) - - # Get workspace info to validate it exists and get path - workspace = self._workspace_service.get(workspace_name, cwd=cwd) - - # Update activity timestamp before launching - try: - self._workspace_service.update_activity(workspace_name, cwd=cwd) - except Exception as e: - # Activity tracking is non-critical - log warning and continue - logger.warning("activity_update_failed", error=str(e)) - - logger.info( - "agent_launch_start", - agent="claude", - workspace=workspace_name, - path=str(workspace.path), - has_prompt=prompt is not None, - plan_mode=plan_mode, - ) - - try: - exit_code = claude.launch( - workspace.path, prompt=prompt, plan_mode=plan_mode - ) - except claude.ClaudeNotFoundError as e: - raise AgentNotFoundError(str(e)) from e - except claude.ClaudeError as e: - raise AgentError(f"Failed to launch Claude Code: {e}") from e - - logger.info( - "agent_launch_complete", - agent="claude", - workspace=workspace_name, - exit_code=exit_code, - ) - - return LaunchResult( - workspace_name=workspace_name, - workspace_path=workspace.path, - exit_code=exit_code, - ) - - def detect_workspace(self, cwd: Path | None = None) -> str | None: - """Detect if cwd is within a workspace. - - Checks if the path is under ~/.agentspaces/// - - Args: - cwd: Directory to check. Defaults to current working directory. - - Returns: - Workspace name if in a workspace, None otherwise. - """ - if cwd is None: - cwd = Path.cwd() - - cwd = cwd.resolve() - base = self._resolver.base.resolve() - - # Check if cwd is under the agentspaces base directory - try: - relative = cwd.relative_to(base) - except ValueError: - # Not under base directory - return None - - # Path should be at least //... (2 parts minimum) - parts = relative.parts - if len(parts) < 2: - return None - - # The workspace name is the second component - workspace_name = parts[1] - - # Verify it's actually a workspace (has .agentspace dir) - project_name = parts[0] - workspace_path = base / project_name / workspace_name - if (workspace_path / ".agentspace").exists(): - return workspace_name - - return None diff --git a/src/agentspaces/modules/workflow/__init__.py b/src/agentspaces/modules/workflow/__init__.py deleted file mode 100644 index 32e1f48..0000000 --- a/src/agentspaces/modules/workflow/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Workflow orchestration module.""" diff --git a/src/agentspaces/modules/workspace/service.py b/src/agentspaces/modules/workspace/service.py index 78fd1cc..16ee390 100644 --- a/src/agentspaces/modules/workspace/service.py +++ b/src/agentspaces/modules/workspace/service.py @@ -9,20 +9,12 @@ import structlog from agentspaces.infrastructure import git -from agentspaces.infrastructure.active import ( - clear_active_workspace, - get_active_workspace, - set_active_workspace, -) from agentspaces.infrastructure.metadata import ( WorkspaceMetadata, load_workspace_metadata, save_workspace_metadata, ) from agentspaces.infrastructure.paths import InvalidNameError, PathResolver -from agentspaces.infrastructure.skills import ( - generate_workspace_context_skill, -) from agentspaces.modules.workspace import environment, worktree __all__ = [ @@ -72,8 +64,7 @@ class WorkspaceService: """Service for managing workspace lifecycle. Handles creation, listing, and removal of workspaces. - Persists workspace metadata to JSON files and generates - workspace-context skills for agent discovery. + Persists workspace metadata to JSON files. """ def __init__(self, resolver: PathResolver | None = None) -> None: @@ -109,7 +100,7 @@ def create( """Create a new workspace. Creates a git worktree, sets up the Python environment, - persists workspace metadata, and generates a workspace-context skill. + and persists workspace metadata. Args: base_branch: Branch to create workspace from (ignored if attach_branch set). @@ -220,14 +211,6 @@ def create( ) raise WorkspaceError(f"Failed to save workspace metadata: {e}") from e - # Generate workspace-context skill for agent discovery - skill_dir = self._resolver.workspace_context_skill(project, result.name) - try: - generate_workspace_context_skill(metadata, skill_dir) - except Exception as e: - # Skill generation is non-critical - warn and continue - logger.warning("skill_generation_failed", error=str(e)) - workspace = WorkspaceInfo( name=result.name, path=result.path, @@ -423,182 +406,6 @@ def get_project_name(self, cwd: Path | None = None) -> str: except git.GitError as e: raise WorkspaceError(f"Not in a git repository: {e.stderr}") from e - def get_active(self, *, cwd: Path | None = None) -> WorkspaceInfo | None: - """Get the currently active workspace. - - Args: - cwd: Current working directory. - - Returns: - WorkspaceInfo for the active workspace, or None if no active workspace. - - Raises: - WorkspaceError: If not in a git repository. - """ - try: - _, project = worktree.get_repo_info(cwd) - except git.GitError as e: - raise WorkspaceError(f"Not in a git repository: {e.stderr}") from e - - project_dir = self._resolver.project_dir(project) - active_name = get_active_workspace(project_dir) - - if active_name is None: - return None - - try: - return self.get(active_name, cwd=cwd) - except WorkspaceNotFoundError: - # Active workspace no longer exists - clear stale reference - logger.warning( - "active_workspace_missing", - project=project, - workspace=active_name, - ) - clear_active_workspace(project_dir) - return None - - def set_active(self, name: str, *, cwd: Path | None = None) -> None: - """Set a workspace as the active workspace. - - Args: - name: Workspace name. - cwd: Current working directory. - - Raises: - WorkspaceNotFoundError: If workspace doesn't exist. - WorkspaceError: If operation fails. - """ - # Verify workspace exists first - self.get(name, cwd=cwd) - - try: - _, project = worktree.get_repo_info(cwd) - except git.GitError as e: - raise WorkspaceError(f"Not in a git repository: {e.stderr}") from e - - project_dir = self._resolver.project_dir(project) - set_active_workspace(project_dir, name) - - logger.info("workspace_activated", workspace=name, project=project) - - def sync_deps( - self, - name: str | None = None, - *, - cwd: Path | None = None, - ) -> WorkspaceInfo: - """Sync dependencies for a workspace. - - Args: - name: Workspace name. If None, uses active workspace or auto-detect. - cwd: Current working directory. - - Returns: - Updated WorkspaceInfo with new deps_synced_at timestamp. - - Raises: - WorkspaceNotFoundError: If workspace doesn't exist. - WorkspaceError: If sync fails. - """ - # Determine workspace - if name is None: - # Try active workspace first - active = self.get_active(cwd=cwd) - if active is not None: - name = active.name - else: - raise WorkspaceError( - "No workspace specified and no active workspace. " - "Use 'agentspaces workspace sync ' or 'agentspaces workspace activate '." - ) - - workspace = self.get(name, cwd=cwd) - - try: - environment.sync_dependencies(workspace.path) - except environment.EnvironmentError as e: - raise WorkspaceError(str(e)) from e - - # Update deps_synced_at timestamp - self._update_metadata_timestamp( - workspace.name, - workspace.project, - deps_synced_at=datetime.now(UTC), - ) - - logger.info("workspace_deps_synced", workspace=name) - - # Return updated workspace info - return self.get(name, cwd=cwd) - - def update_activity(self, name: str, *, cwd: Path | None = None) -> None: - """Update the last_activity_at timestamp for a workspace. - - Called when an agent is launched or other activity occurs. - - Args: - name: Workspace name. - cwd: Current working directory. - - Raises: - WorkspaceNotFoundError: If workspace doesn't exist. - WorkspaceError: If update fails. - """ - workspace = self.get(name, cwd=cwd) - - self._update_metadata_timestamp( - workspace.name, - workspace.project, - last_activity_at=datetime.now(UTC), - ) - - logger.debug("workspace_activity_updated", workspace=name) - - def _update_metadata_timestamp( - self, - name: str, - project: str, - *, - deps_synced_at: datetime | None = None, - last_activity_at: datetime | None = None, - ) -> None: - """Update timestamp fields in workspace metadata. - - Args: - name: Workspace name. - project: Project name. - deps_synced_at: New deps_synced_at value. - last_activity_at: New last_activity_at value. - """ - metadata_path = self._resolver.workspace_json(project, name) - metadata = load_workspace_metadata(metadata_path) - - if metadata is None: - logger.warning( - "metadata_not_found_for_update", - workspace=name, - project=project, - ) - return - - # Create updated metadata with new timestamps - updated = WorkspaceMetadata( - name=metadata.name, - project=metadata.project, - branch=metadata.branch, - base_branch=metadata.base_branch, - created_at=metadata.created_at, - purpose=metadata.purpose, - python_version=metadata.python_version, - has_venv=metadata.has_venv, - status=metadata.status, - deps_synced_at=deps_synced_at or metadata.deps_synced_at, - last_activity_at=last_activity_at or metadata.last_activity_at, - ) - - save_workspace_metadata(updated, metadata_path) - def _ensure_git_exclude_entry(self, repo_root: Path, entry: str) -> None: """Ensure an entry exists in the repository's git exclude file. diff --git a/tests/unit/cli/test_context.py b/tests/unit/cli/test_context.py deleted file mode 100644 index b46f6fc..0000000 --- a/tests/unit/cli/test_context.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Tests for CLI context module.""" - -from __future__ import annotations - -from unittest.mock import patch - -from agentspaces.cli.context import CLIContext -from agentspaces.infrastructure.config import GlobalConfig - - -class TestCLIContext: - """Tests for CLIContext singleton.""" - - def setup_method(self) -> None: - """Reset context before each test.""" - CLIContext.reset() - - def teardown_method(self) -> None: - """Reset context after each test.""" - CLIContext.reset() - - def test_get_returns_instance(self) -> None: - """get() should return a CLIContext instance.""" - ctx = CLIContext.get() - assert isinstance(ctx, CLIContext) - - def test_get_returns_same_instance(self) -> None: - """get() should return the same singleton instance.""" - ctx1 = CLIContext.get() - ctx2 = CLIContext.get() - assert ctx1 is ctx2 - - def test_default_values(self) -> None: - """Default values should be False for both flags.""" - ctx = CLIContext.get() - assert ctx.verbose is False - assert ctx.quiet is False - - def test_verbose_can_be_set(self) -> None: - """verbose flag should be settable.""" - ctx = CLIContext.get() - ctx.verbose = True - assert ctx.verbose is True - # Should persist across get() calls - assert CLIContext.get().verbose is True - - def test_quiet_can_be_set(self) -> None: - """quiet flag should be settable.""" - ctx = CLIContext.get() - ctx.quiet = True - assert ctx.quiet is True - # Should persist across get() calls - assert CLIContext.get().quiet is True - - def test_reset_clears_instance(self) -> None: - """reset() should clear the singleton instance.""" - ctx1 = CLIContext.get() - ctx1.verbose = True - - CLIContext.reset() - - ctx2 = CLIContext.get() - assert ctx2 is not ctx1 - assert ctx2.verbose is False - - def test_reset_before_get_works(self) -> None: - """reset() should work even if get() was never called.""" - CLIContext.reset() # Should not raise - ctx = CLIContext.get() - assert ctx is not None - - def test_get_config_caches_result(self) -> None: - """get_config() should cache the loaded config.""" - ctx = CLIContext.get() - - with patch("agentspaces.infrastructure.config.load_global_config") as mock_load: - mock_load.return_value = GlobalConfig(plan_mode_by_default=True) - - # First call should load - config1 = ctx.get_config() - assert mock_load.call_count == 1 - assert config1.plan_mode_by_default is True - - # Second call should use cache - config2 = ctx.get_config() - assert mock_load.call_count == 1 # Still 1, not 2 - assert config2 is config1 # Same instance - - def test_reset_clears_config_cache(self) -> None: - """reset() should clear the cached config.""" - ctx1 = CLIContext.get() - - with patch("agentspaces.infrastructure.config.load_global_config") as mock_load: - mock_load.return_value = GlobalConfig(plan_mode_by_default=True) - - # Load config - ctx1.get_config() - assert mock_load.call_count == 1 - - # Reset and get new context - CLIContext.reset() - ctx2 = CLIContext.get() - - # Should reload config - ctx2.get_config() - assert mock_load.call_count == 2 # Called again after reset - - def test_config_is_lazy_loaded(self) -> None: - """Config should not be loaded until get_config() is called.""" - with patch("agentspaces.infrastructure.config.load_global_config") as mock_load: - mock_load.return_value = GlobalConfig() - - # Just getting the context should not load config - ctx = CLIContext.get() - assert mock_load.call_count == 0 - - # Only when we call get_config() - ctx.get_config() - assert mock_load.call_count == 1 - - def test_config_defaults_to_none(self) -> None: - """Config field should default to None.""" - ctx = CLIContext.get() - assert ctx.config is None diff --git a/tests/unit/cli/test_formatters.py b/tests/unit/cli/test_formatters.py index d28792a..9274048 100644 --- a/tests/unit/cli/test_formatters.py +++ b/tests/unit/cli/test_formatters.py @@ -7,7 +7,6 @@ from rich.panel import Panel -from agentspaces.cli.context import CLIContext from agentspaces.cli.formatters import ( print_did_you_mean, print_info, @@ -43,42 +42,18 @@ def _find_next_steps_panel(mock_console: MagicMock) -> Any: class TestPrintInfo: """Tests for print_info function.""" - def setup_method(self) -> None: - """Reset context before each test.""" - CLIContext.reset() - - def teardown_method(self) -> None: - """Reset context after each test.""" - CLIContext.reset() - - def test_prints_message_normally(self) -> None: - """Should print message when not in quiet mode.""" + def test_prints_message(self) -> None: + """Should print message.""" with patch("agentspaces.cli.formatters.console") as mock_console: print_info("Test message") mock_console.print.assert_called_once() call_args = mock_console.print.call_args[0][0] assert "Test message" in call_args - def test_suppressed_in_quiet_mode(self) -> None: - """Should not print when quiet mode is enabled.""" - CLIContext.get().quiet = True - - with patch("agentspaces.cli.formatters.console") as mock_console: - print_info("Test message") - mock_console.print.assert_not_called() - class TestPrintNextSteps: """Tests for print_next_steps function.""" - def setup_method(self) -> None: - """Reset context before each test.""" - CLIContext.reset() - - def teardown_method(self) -> None: - """Reset context after each test.""" - CLIContext.reset() - def test_prints_cd_step(self) -> None: """Should include cd to workspace path.""" with patch("agentspaces.cli.formatters.console") as mock_console: @@ -103,13 +78,6 @@ def test_excludes_venv_activation_when_no_venv(self) -> None: 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 = _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: @@ -117,26 +85,6 @@ def test_includes_remove_step(self) -> None: 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 - - with patch("agentspaces.cli.formatters.console") as mock_console: - print_next_steps("test-ws", "/path/to/workspace", has_venv=True) - mock_console.print.assert_not_called() - class TestPrintDidYouMean: """Tests for print_did_you_mean function.""" diff --git a/tests/unit/infrastructure/test_active.py b/tests/unit/infrastructure/test_active.py deleted file mode 100644 index 71f7ea7..0000000 --- a/tests/unit/infrastructure/test_active.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Tests for active workspace tracking.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from agentspaces.infrastructure.active import ( - clear_active_workspace, - get_active_workspace, - set_active_workspace, -) - -if TYPE_CHECKING: - from pathlib import Path - - -class TestGetActiveWorkspace: - """Tests for get_active_workspace function.""" - - def test_returns_none_when_no_active_file(self, tmp_path: Path) -> None: - """Returns None when .active file doesn't exist.""" - result = get_active_workspace(tmp_path) - assert result is None - - def test_reads_workspace_name(self, tmp_path: Path) -> None: - """Reads workspace name from .active file.""" - active_file = tmp_path / ".active" - active_file.write_text("eager-turing\n") - - result = get_active_workspace(tmp_path) - assert result == "eager-turing" - - def test_strips_whitespace(self, tmp_path: Path) -> None: - """Strips whitespace from workspace name.""" - active_file = tmp_path / ".active" - active_file.write_text(" eager-turing \n\n") - - result = get_active_workspace(tmp_path) - assert result == "eager-turing" - - def test_returns_none_for_empty_file(self, tmp_path: Path) -> None: - """Returns None when .active file is empty.""" - active_file = tmp_path / ".active" - active_file.write_text("") - - result = get_active_workspace(tmp_path) - assert result is None - - def test_returns_none_for_whitespace_only_file(self, tmp_path: Path) -> None: - """Returns None when .active file contains only whitespace.""" - active_file = tmp_path / ".active" - active_file.write_text(" \n\n ") - - result = get_active_workspace(tmp_path) - assert result is None - - -class TestSetActiveWorkspace: - """Tests for set_active_workspace function.""" - - def test_creates_active_file(self, tmp_path: Path) -> None: - """Creates .active file with workspace name.""" - set_active_workspace(tmp_path, "eager-turing") - - active_file = tmp_path / ".active" - assert active_file.exists() - assert active_file.read_text() == "eager-turing\n" - - def test_overwrites_existing_file(self, tmp_path: Path) -> None: - """Overwrites existing .active file.""" - active_file = tmp_path / ".active" - active_file.write_text("old-workspace\n") - - set_active_workspace(tmp_path, "new-workspace") - - assert active_file.read_text() == "new-workspace\n" - - def test_creates_parent_directory(self, tmp_path: Path) -> None: - """Creates parent directory if it doesn't exist.""" - project_dir = tmp_path / "project" - - set_active_workspace(project_dir, "eager-turing") - - active_file = project_dir / ".active" - assert active_file.exists() - assert active_file.read_text() == "eager-turing\n" - - -class TestClearActiveWorkspace: - """Tests for clear_active_workspace function.""" - - def test_removes_active_file(self, tmp_path: Path) -> None: - """Removes .active file.""" - active_file = tmp_path / ".active" - active_file.write_text("eager-turing\n") - - clear_active_workspace(tmp_path) - - assert not active_file.exists() - - def test_does_not_fail_when_no_file(self, tmp_path: Path) -> None: - """Does not raise when .active file doesn't exist.""" - # Should not raise - clear_active_workspace(tmp_path) - - -class TestActiveWorkspaceRoundTrip: - """Tests for active workspace get/set/clear cycle.""" - - def test_set_then_get(self, tmp_path: Path) -> None: - """Can set and then get the active workspace.""" - set_active_workspace(tmp_path, "test-workspace") - result = get_active_workspace(tmp_path) - assert result == "test-workspace" - - def test_set_clear_get(self, tmp_path: Path) -> None: - """After clearing, get returns None.""" - set_active_workspace(tmp_path, "test-workspace") - clear_active_workspace(tmp_path) - result = get_active_workspace(tmp_path) - assert result is None diff --git a/tests/unit/infrastructure/test_claude.py b/tests/unit/infrastructure/test_claude.py deleted file mode 100644 index bd98384..0000000 --- a/tests/unit/infrastructure/test_claude.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Tests for Claude Code subprocess operations.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path # noqa: TC003 - used at runtime with tmp_path -from unittest.mock import MagicMock, patch - -import pytest - -from agentspaces.infrastructure.claude import ( - ClaudeError, - ClaudeNotFoundError, - is_claude_available, - launch, -) - - -class TestIsClaudeAvailable: - """Tests for is_claude_available function.""" - - def setup_method(self) -> None: - """Clear cache before each test.""" - is_claude_available.cache_clear() - - def teardown_method(self) -> None: - """Clear cache after each test.""" - is_claude_available.cache_clear() - - def test_returns_true_when_claude_installed(self) -> None: - """Should return True when claude --version succeeds.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - assert is_claude_available() is True - - def test_returns_false_when_claude_not_found(self) -> None: - """Should return False when claude command not found.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError() - assert is_claude_available() is False - - def test_returns_false_on_timeout(self) -> None: - """Should return False when claude --version times out.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired(cmd="claude", timeout=5) - assert is_claude_available() is False - - def test_returns_false_on_nonzero_exit(self) -> None: - """Should return False when claude returns non-zero exit code.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=1) - assert is_claude_available() is False - - -class TestLaunch: - """Tests for launch function.""" - - def test_launch_uses_correct_cwd(self, tmp_path: Path) -> None: - """Should pass cwd to subprocess.run.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - - result = launch(tmp_path) - - assert result == 0 - mock_run.assert_called_once() - call_kwargs = mock_run.call_args - assert call_kwargs.kwargs["cwd"] == tmp_path - - def test_launch_builds_correct_command(self, tmp_path: Path) -> None: - """Should build correct command without prompt.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - - launch(tmp_path) - - call_args = mock_run.call_args[0][0] - assert call_args == ["claude"] - - def test_launch_with_prompt(self, tmp_path: Path) -> None: - """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", "Fix the bug"] - - def test_launch_returns_exit_code(self, tmp_path: Path) -> None: - """Should return the process exit code.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=42) - - result = launch(tmp_path) - - assert result == 42 - - def test_launch_raises_not_found_when_claude_missing(self, tmp_path: Path) -> None: - """Should raise ClaudeNotFoundError when claude not installed.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError() - - with pytest.raises(ClaudeNotFoundError) as exc_info: - launch(tmp_path) - - assert "Claude Code not found" in str(exc_info.value) - assert exc_info.value.returncode == -1 - - def test_launch_raises_error_on_os_error(self, tmp_path: Path) -> None: - """Should raise ClaudeError on other OS errors.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = OSError("Permission denied") - - with pytest.raises(ClaudeError) as exc_info: - launch(tmp_path) - - assert "Failed to launch" in str(exc_info.value) - assert exc_info.value.returncode == -1 - - def test_launch_rejects_oversized_prompt(self, tmp_path: Path) -> None: - """Should raise ValueError for prompts exceeding max length.""" - long_prompt = "x" * 10001 # Exceeds MAX_PROMPT_LENGTH - - with pytest.raises(ValueError) as exc_info: - launch(tmp_path, prompt=long_prompt) - - assert "Prompt too long" in str(exc_info.value) - assert "10001" in str(exc_info.value) - - def test_launch_accepts_max_length_prompt(self, tmp_path: Path) -> None: - """Should accept prompts at exactly max length.""" - max_prompt = "x" * 10000 # Exactly MAX_PROMPT_LENGTH - - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - - result = launch(tmp_path, prompt=max_prompt) - - assert result == 0 - call_args = mock_run.call_args[0][0] - assert call_args == ["claude", max_prompt] - - def test_launch_with_plan_mode_enabled(self, tmp_path: Path) -> None: - """Should include --permission-mode plan flag when plan_mode=True.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - - launch(tmp_path, plan_mode=True) - - call_args = mock_run.call_args[0][0] - assert call_args == ["claude", "--permission-mode", "plan"] - - def test_launch_with_plan_mode_and_custom_prompt(self, tmp_path: Path) -> None: - """Should include both plan mode flag and prompt when both provided.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - - launch(tmp_path, prompt="Fix the bug", plan_mode=True) - - call_args = mock_run.call_args[0][0] - assert call_args == ["claude", "--permission-mode", "plan", "Fix the bug"] - - def test_launch_without_plan_mode(self, tmp_path: Path) -> None: - """Should not include plan mode flag when plan_mode=False.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - - launch(tmp_path, plan_mode=False) - - call_args = mock_run.call_args[0][0] - assert call_args == ["claude"] - - def test_launch_default_plan_mode_is_false(self, tmp_path: Path) -> None: - """Plan mode should default to False when not specified.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - - # Not passing plan_mode at all - launch(tmp_path) - - call_args = mock_run.call_args[0][0] - # Should not include permission-mode flag - assert call_args == ["claude"] - - -class TestExceptions: - """Tests for exception classes.""" - - def test_claude_error_stores_returncode(self) -> None: - """ClaudeError should store returncode.""" - error = ClaudeError("test error", returncode=42) - assert error.returncode == 42 - assert str(error) == "test error" - - def test_claude_not_found_error_has_install_instructions(self) -> None: - """ClaudeNotFoundError should have installation instructions.""" - error = ClaudeNotFoundError() - assert "claude.ai/download" in str(error) - assert error.returncode == -1 - - -class TestCaching: - """Tests for caching behavior.""" - - def test_is_claude_available_is_cached(self) -> None: - """is_claude_available should cache results.""" - # Clear the cache first - is_claude_available.cache_clear() - - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - - # Call twice - result1 = is_claude_available() - result2 = is_claude_available() - - # Should only call subprocess once due to caching - assert result1 is True - assert result2 is True - assert mock_run.call_count == 1 - - # Clear cache after test - is_claude_available.cache_clear() diff --git a/tests/unit/infrastructure/test_config.py b/tests/unit/infrastructure/test_config.py deleted file mode 100644 index ae393d5..0000000 --- a/tests/unit/infrastructure/test_config.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Tests for global configuration module.""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -import pytest - -from agentspaces.infrastructure.config import ( - GlobalConfig, - load_global_config, - save_global_config, -) -from agentspaces.infrastructure.paths import PathResolver - -if TYPE_CHECKING: - from pathlib import Path - - -class TestGlobalConfig: - """Tests for GlobalConfig dataclass.""" - - def test_config_is_frozen(self) -> None: - """GlobalConfig should be immutable.""" - config = GlobalConfig() - - with pytest.raises((AttributeError, TypeError)): # Frozen dataclass error - config.plan_mode_by_default = True # type: ignore - - def test_config_defaults(self) -> None: - """Should have sensible defaults.""" - config = GlobalConfig() - - assert config.plan_mode_by_default is True - - def test_config_can_be_created_with_values(self) -> None: - """Can create config with custom values.""" - config = GlobalConfig(plan_mode_by_default=True) - - assert config.plan_mode_by_default is True - - -class TestLoadGlobalConfig: - """Tests for load_global_config function.""" - - def test_returns_defaults_when_file_missing(self, tmp_path: Path) -> None: - """Returns default config when file doesn't exist.""" - resolver = PathResolver(base=tmp_path) - - config = load_global_config(resolver) - - assert isinstance(config, GlobalConfig) - assert config.plan_mode_by_default is True - - def test_loads_config_from_file(self, tmp_path: Path) -> None: - """Loads configuration from JSON file.""" - resolver = PathResolver(base=tmp_path) - config_path = resolver.global_config() - config_path.parent.mkdir(parents=True, exist_ok=True) - - config_path.write_text( - json.dumps( - { - "version": "1", - "plan_mode_by_default": True, - } - ) - ) - - config = load_global_config(resolver) - - assert config.plan_mode_by_default is True - - def test_handles_invalid_json(self, tmp_path: Path) -> None: - """Returns defaults on invalid JSON.""" - resolver = PathResolver(base=tmp_path) - config_path = resolver.global_config() - config_path.parent.mkdir(parents=True, exist_ok=True) - - config_path.write_text("not valid json {") - - config = load_global_config(resolver) - - # Should return defaults, not raise - assert isinstance(config, GlobalConfig) - assert config.plan_mode_by_default is True - - def test_handles_schema_version_mismatch(self, tmp_path: Path) -> None: - """Handles schema version mismatches gracefully.""" - resolver = PathResolver(base=tmp_path) - config_path = resolver.global_config() - config_path.parent.mkdir(parents=True, exist_ok=True) - - # Future version - config_path.write_text( - json.dumps( - { - "version": "999", - "plan_mode_by_default": True, - } - ) - ) - - config = load_global_config(resolver) - - # Should still try to load - assert config.plan_mode_by_default is True - - def test_rejects_oversized_file(self, tmp_path: Path) -> None: - """Rejects files over MAX_CONFIG_SIZE.""" - resolver = PathResolver(base=tmp_path) - config_path = resolver.global_config() - config_path.parent.mkdir(parents=True, exist_ok=True) - - # Create a file > 1MB - large_content = "x" * (1024 * 1024 + 1) - config_path.write_text(large_content) - - config = load_global_config(resolver) - - # Should return defaults - assert isinstance(config, GlobalConfig) - assert config.plan_mode_by_default is True - - def test_handles_missing_fields_with_defaults(self, tmp_path: Path) -> None: - """Uses defaults for missing fields.""" - resolver = PathResolver(base=tmp_path) - config_path = resolver.global_config() - config_path.parent.mkdir(parents=True, exist_ok=True) - - # Config with no plan_mode_by_default field - config_path.write_text(json.dumps({"version": "1"})) - - config = load_global_config(resolver) - - assert config.plan_mode_by_default is True - - -class TestSaveGlobalConfig: - """Tests for save_global_config function.""" - - def test_saves_config_atomically(self, tmp_path: Path) -> None: - """Saves config using atomic write.""" - resolver = PathResolver(base=tmp_path) - config = GlobalConfig(plan_mode_by_default=True) - - save_global_config(config, resolver) - - # Config should exist - config_path = resolver.global_config() - assert config_path.exists() - - # Should be valid JSON - data = json.loads(config_path.read_text()) - assert data["plan_mode_by_default"] is True - - def test_includes_schema_version(self, tmp_path: Path) -> None: - """Saved JSON includes version field.""" - resolver = PathResolver(base=tmp_path) - config = GlobalConfig() - - save_global_config(config, resolver) - - config_path = resolver.global_config() - data = json.loads(config_path.read_text()) - - assert "version" in data - assert data["version"] == "1" - - def test_creates_parent_directory(self, tmp_path: Path) -> None: - """Creates parent directory if it doesn't exist.""" - resolver = PathResolver(base=tmp_path / "nonexistent") - config = GlobalConfig() - - save_global_config(config, resolver) - - config_path = resolver.global_config() - assert config_path.exists() - assert config_path.parent.exists() - - def test_overwrites_existing_config(self, tmp_path: Path) -> None: - """Overwrites existing config file.""" - resolver = PathResolver(base=tmp_path) - - # Save initial config - config1 = GlobalConfig(plan_mode_by_default=False) - save_global_config(config1, resolver) - - # Save updated config - config2 = GlobalConfig(plan_mode_by_default=True) - save_global_config(config2, resolver) - - # Should have new value - loaded = load_global_config(resolver) - assert loaded.plan_mode_by_default is True - - -class TestGlobalConfigRoundTrip: - """Tests for save/load round trip.""" - - def test_save_then_load(self, tmp_path: Path) -> None: - """Can save and load config.""" - resolver = PathResolver(base=tmp_path) - config = GlobalConfig(plan_mode_by_default=True) - - save_global_config(config, resolver) - loaded = load_global_config(resolver) - - assert loaded.plan_mode_by_default == config.plan_mode_by_default - - def test_multiple_save_load_cycles(self, tmp_path: Path) -> None: - """Multiple save/load cycles work correctly.""" - resolver = PathResolver(base=tmp_path) - - # Cycle 1: False - config1 = GlobalConfig(plan_mode_by_default=False) - save_global_config(config1, resolver) - loaded1 = load_global_config(resolver) - assert loaded1.plan_mode_by_default is False - - # Cycle 2: True - config2 = GlobalConfig(plan_mode_by_default=True) - save_global_config(config2, resolver) - loaded2 = load_global_config(resolver) - assert loaded2.plan_mode_by_default is True - - # Cycle 3: Back to False - config3 = GlobalConfig(plan_mode_by_default=False) - save_global_config(config3, resolver) - loaded3 = load_global_config(resolver) - assert loaded3.plan_mode_by_default is False diff --git a/tests/unit/infrastructure/test_paths.py b/tests/unit/infrastructure/test_paths.py index fb84ac7..597a2d3 100644 --- a/tests/unit/infrastructure/test_paths.py +++ b/tests/unit/infrastructure/test_paths.py @@ -148,48 +148,6 @@ def test_workspace_json(self, resolver: PathResolver) -> None: ) assert path == expected - def test_skills_dir(self, resolver: PathResolver) -> None: - """skills_dir should return skills directory path.""" - path = resolver.skills_dir("my-project", "eager-turing") - expected = ( - resolver.base / "my-project" / "eager-turing" / ".agentspace" / "skills" - ) - assert path == expected - - def test_workspace_context_skill(self, resolver: PathResolver) -> None: - """workspace_context_skill should return skill directory path.""" - path = resolver.workspace_context_skill("my-project", "eager-turing") - expected = ( - resolver.base - / "my-project" - / "eager-turing" - / ".agentspace" - / "skills" - / "workspace-context" - ) - assert path == expected - - def test_sessions_dir(self, resolver: PathResolver) -> None: - """sessions_dir should return sessions directory path.""" - path = resolver.sessions_dir("my-project", "eager-turing") - expected = ( - resolver.base / "my-project" / "eager-turing" / ".agentspace" / "sessions" - ) - assert path == expected - - def test_session_dir(self, resolver: PathResolver) -> None: - """session_dir should return specific session directory.""" - path = resolver.session_dir("my-project", "eager-turing", "abc123") - expected = ( - resolver.base - / "my-project" - / "eager-turing" - / ".agentspace" - / "sessions" - / "abc123" - ) - assert path == expected - def test_venv_dir(self, resolver: PathResolver) -> None: """venv_dir should return .venv directory path.""" path = resolver.venv_dir("my-project", "eager-turing") @@ -258,8 +216,3 @@ def test_workspace_dir_validates_workspace_name( """workspace_dir should reject invalid workspace names.""" with pytest.raises(InvalidNameError): resolver.workspace_dir("valid-project", "../escape") - - def test_session_dir_validates_session_id(self, resolver: PathResolver) -> None: - """session_dir should reject invalid session IDs.""" - with pytest.raises(InvalidNameError): - resolver.session_dir("valid-project", "valid-workspace", "../escape") diff --git a/tests/unit/infrastructure/test_skills.py b/tests/unit/infrastructure/test_skills.py deleted file mode 100644 index b971f51..0000000 --- a/tests/unit/infrastructure/test_skills.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Tests for the skills module.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from typing import TYPE_CHECKING - -from agentspaces.infrastructure.metadata import WorkspaceMetadata -from agentspaces.infrastructure.skills import ( - SkillError, - generate_workspace_context_skill, -) - -if TYPE_CHECKING: - from pathlib import Path - - -class TestGenerateWorkspaceContextSkill: - """Tests for generate_workspace_context_skill function.""" - - def test_generates_skill_file(self, temp_dir: Path) -> None: - """Should create SKILL.md in skill directory.""" - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - ) - output_dir = temp_dir / "skills" / "workspace-context" - - result = generate_workspace_context_skill(metadata, output_dir) - - assert result.exists() - assert result.name == "SKILL.md" - - def test_includes_workspace_name(self, temp_dir: Path) -> None: - """Should render workspace name in template.""" - metadata = WorkspaceMetadata( - name="eager-turing", - project="test-project", - branch="eager-turing", - base_branch="main", - created_at=datetime.now(UTC), - ) - output_dir = temp_dir / "skills" / "workspace-context" - - result = generate_workspace_context_skill(metadata, output_dir) - - content = result.read_text(encoding="utf-8") - assert "eager-turing" in content - - def test_includes_purpose(self, temp_dir: Path) -> None: - """Should render purpose when provided.""" - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - purpose="Implement new authentication system", - ) - output_dir = temp_dir / "skills" / "workspace-context" - - result = generate_workspace_context_skill(metadata, output_dir) - - content = result.read_text(encoding="utf-8") - assert "Implement new authentication system" in content - - def test_purpose_default_when_none(self, temp_dir: Path) -> None: - """Should use default text when purpose is None.""" - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - purpose=None, - ) - output_dir = temp_dir / "skills" / "workspace-context" - - result = generate_workspace_context_skill(metadata, output_dir) - - content = result.read_text(encoding="utf-8") - # Should contain the default purpose message from the template - assert "No specific purpose defined" in content - - def test_includes_branch_info(self, temp_dir: Path) -> None: - """Should render branch information.""" - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="develop", - created_at=datetime.now(UTC), - ) - output_dir = temp_dir / "skills" / "workspace-context" - - result = generate_workspace_context_skill(metadata, output_dir) - - content = result.read_text(encoding="utf-8") - assert "test-workspace" in content - assert "develop" in content - - def test_creates_output_directory(self, temp_dir: Path) -> None: - """Should create output directory if needed.""" - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - ) - output_dir = temp_dir / "deep" / "nested" / "skills" - - result = generate_workspace_context_skill(metadata, output_dir) - - assert output_dir.exists() - assert result.exists() - - def test_includes_python_version_when_set(self, temp_dir: Path) -> None: - """Should include Python version when available.""" - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - python_version="3.13", - has_venv=True, - ) - output_dir = temp_dir / "skills" / "workspace-context" - - result = generate_workspace_context_skill(metadata, output_dir) - - content = result.read_text(encoding="utf-8") - assert "3.13" in content - - def test_overwrites_existing_skill(self, temp_dir: Path) -> None: - """Should overwrite existing skill file.""" - output_dir = temp_dir / "skills" / "workspace-context" - output_dir.mkdir(parents=True) - existing = output_dir / "SKILL.md" - existing.write_text("old content", encoding="utf-8") - - metadata = WorkspaceMetadata( - name="new-workspace", - project="test-project", - branch="new-workspace", - base_branch="main", - created_at=datetime.now(UTC), - ) - - result = generate_workspace_context_skill(metadata, output_dir) - - content = result.read_text(encoding="utf-8") - assert "old content" not in content - assert "new-workspace" in content - - -class TestSkillError: - """Tests for SkillError exception.""" - - def test_skill_error_message(self) -> None: - """SkillError should store message.""" - error = SkillError("Template not found") - assert str(error) == "Template not found" diff --git a/tests/unit/modules/agent/__init__.py b/tests/unit/modules/agent/__init__.py deleted file mode 100644 index e341623..0000000 --- a/tests/unit/modules/agent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Agent module tests.""" diff --git a/tests/unit/modules/agent/test_launcher.py b/tests/unit/modules/agent/test_launcher.py deleted file mode 100644 index dcf314f..0000000 --- a/tests/unit/modules/agent/test_launcher.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Tests for the agent launcher service.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from agentspaces.infrastructure.paths import PathResolver -from agentspaces.modules.agent.launcher import ( - AgentError, - AgentLauncher, - AgentNotFoundError, - LaunchResult, -) -from agentspaces.modules.workspace.service import ( - WorkspaceInfo, - WorkspaceNotFoundError, - WorkspaceService, -) - - -class TestLaunchResult: - """Tests for LaunchResult dataclass.""" - - def test_launch_result_attributes(self) -> None: - """LaunchResult should store all attributes.""" - result = LaunchResult( - workspace_name="test-workspace", - workspace_path=Path("/path/to/workspace"), - exit_code=0, - ) - - assert result.workspace_name == "test-workspace" - assert result.workspace_path == Path("/path/to/workspace") - assert result.exit_code == 0 - - def test_launch_result_is_frozen(self) -> None: - """LaunchResult should be immutable.""" - result = LaunchResult( - workspace_name="test", - workspace_path=Path("/test"), - exit_code=0, - ) - - with pytest.raises(AttributeError): - result.exit_code = 1 # type: ignore[misc] - - -class TestAgentExceptions: - """Tests for agent exception classes.""" - - def test_agent_error_message(self) -> None: - """AgentError should store message.""" - error = AgentError("Something went wrong") - assert str(error) == "Something went wrong" - - def test_agent_not_found_error(self) -> None: - """AgentNotFoundError should be an AgentError.""" - error = AgentNotFoundError("Claude not found") - assert str(error) == "Claude not found" - assert isinstance(error, AgentError) - - -class TestAgentLauncherInit: - """Tests for AgentLauncher initialization.""" - - def test_init_with_defaults(self) -> None: - """Should initialize with default services.""" - launcher = AgentLauncher() - - assert launcher._resolver is not None - assert launcher._workspace_service is not None - - def test_init_with_custom_resolver(self, temp_dir: Path) -> None: - """Should accept custom resolver.""" - resolver = PathResolver(base=temp_dir) - launcher = AgentLauncher(resolver=resolver) - - assert launcher._resolver == resolver - - -class TestAgentLauncherLaunchClaude: - """Tests for AgentLauncher.launch_claude method.""" - - def test_launch_claude_success(self, temp_dir: Path) -> None: - """Should launch Claude in workspace and return result.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - - # Create mock workspace service - mock_service = MagicMock(spec=WorkspaceService) - mock_service.get.return_value = WorkspaceInfo( - name="test-workspace", - path=temp_dir / "test-workspace", - branch="test-workspace", - base_branch="main", - project="test-project", - ) - - launcher = AgentLauncher(workspace_service=mock_service, resolver=resolver) - - with ( - patch( - "agentspaces.modules.agent.launcher.claude.is_claude_available" - ) as mock_available, - patch("agentspaces.modules.agent.launcher.claude.launch") as mock_launch, - ): - mock_available.return_value = True - mock_launch.return_value = 0 - - result = launcher.launch_claude("test-workspace") - - assert result.workspace_name == "test-workspace" - assert result.exit_code == 0 - mock_launch.assert_called_once_with( - temp_dir / "test-workspace", - prompt=None, - plan_mode=False, - ) - - def test_launch_claude_with_prompt(self, temp_dir: Path) -> None: - """Should pass prompt to Claude.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - - mock_service = MagicMock(spec=WorkspaceService) - mock_service.get.return_value = WorkspaceInfo( - name="test-workspace", - path=temp_dir / "test-workspace", - branch="test-workspace", - base_branch="main", - project="test-project", - ) - - launcher = AgentLauncher(workspace_service=mock_service, resolver=resolver) - - with ( - patch( - "agentspaces.modules.agent.launcher.claude.is_claude_available" - ) as mock_available, - patch("agentspaces.modules.agent.launcher.claude.launch") as mock_launch, - ): - mock_available.return_value = True - mock_launch.return_value = 0 - - launcher.launch_claude("test-workspace", prompt="Fix the bug") - - mock_launch.assert_called_once_with( - temp_dir / "test-workspace", - prompt="Fix the bug", - plan_mode=False, - ) - - def test_launch_claude_not_installed(self, temp_dir: Path) -> None: - """Should raise AgentNotFoundError when Claude not installed.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - launcher = AgentLauncher(resolver=resolver) - - with patch( - "agentspaces.modules.agent.launcher.claude.is_claude_available" - ) as mock_available: - mock_available.return_value = False - - with pytest.raises(AgentNotFoundError) as exc_info: - launcher.launch_claude("test-workspace") - - assert "Claude Code not found" in str(exc_info.value) - - def test_launch_claude_workspace_not_found(self, temp_dir: Path) -> None: - """Should raise WorkspaceNotFoundError for missing workspace.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - - mock_service = MagicMock(spec=WorkspaceService) - mock_service.get.side_effect = WorkspaceNotFoundError("Not found") - - launcher = AgentLauncher(workspace_service=mock_service, resolver=resolver) - - with patch( - "agentspaces.modules.agent.launcher.claude.is_claude_available" - ) as mock_available: - mock_available.return_value = True - - with pytest.raises(WorkspaceNotFoundError): - launcher.launch_claude("missing-workspace") - - def test_launch_claude_no_workspace_no_detection(self, temp_dir: Path) -> None: - """Should raise AgentError when no workspace specified and not in workspace.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - launcher = AgentLauncher(resolver=resolver) - - with patch( - "agentspaces.modules.agent.launcher.claude.is_claude_available" - ) as mock_available: - mock_available.return_value = True - - with pytest.raises(AgentError) as exc_info: - # cwd is temp_dir which is not a workspace - launcher.launch_claude(cwd=temp_dir) - - assert "No workspace specified" in str(exc_info.value) - - -class TestAgentLauncherDetectWorkspace: - """Tests for AgentLauncher.detect_workspace method.""" - - def test_detect_workspace_when_in_workspace(self, temp_dir: Path) -> None: - """Should detect workspace name when inside workspace directory.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - launcher = AgentLauncher(resolver=resolver) - - # Create workspace structure - workspace_path = temp_dir / ".agentspaces" / "test-project" / "test-workspace" - workspace_path.mkdir(parents=True) - (workspace_path / ".agentspace").mkdir() - - result = launcher.detect_workspace(cwd=workspace_path) - - assert result == "test-workspace" - - def test_detect_workspace_when_in_subdirectory(self, temp_dir: Path) -> None: - """Should detect workspace when in a subdirectory of workspace.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - launcher = AgentLauncher(resolver=resolver) - - # Create workspace structure with subdirectory - workspace_path = temp_dir / ".agentspaces" / "test-project" / "test-workspace" - workspace_path.mkdir(parents=True) - (workspace_path / ".agentspace").mkdir() - subdir = workspace_path / "src" / "components" - subdir.mkdir(parents=True) - - result = launcher.detect_workspace(cwd=subdir) - - assert result == "test-workspace" - - def test_detect_workspace_when_outside(self, temp_dir: Path) -> None: - """Should return None when not in a workspace.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - launcher = AgentLauncher(resolver=resolver) - - result = launcher.detect_workspace(cwd=temp_dir) - - assert result is None - - def test_detect_workspace_in_base_but_not_workspace(self, temp_dir: Path) -> None: - """Should return None when in base dir but not a workspace.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - launcher = AgentLauncher(resolver=resolver) - - # Create project dir but not a workspace (no .agentspace) - project_path = temp_dir / ".agentspaces" / "test-project" - project_path.mkdir(parents=True) - - result = launcher.detect_workspace(cwd=project_path) - - assert result is None - - def test_detect_workspace_directory_without_marker(self, temp_dir: Path) -> None: - """Should return None when directory exists but lacks .agentspace marker.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - launcher = AgentLauncher(resolver=resolver) - - # Create directory structure but no .agentspace marker - workspace_path = temp_dir / ".agentspaces" / "test-project" / "test-workspace" - workspace_path.mkdir(parents=True) - # Note: NOT creating .agentspace - - result = launcher.detect_workspace(cwd=workspace_path) - - assert result is None diff --git a/tests/unit/modules/workspace/test_service.py b/tests/unit/modules/workspace/test_service.py index 9d9fbf1..fd6fb19 100644 --- a/tests/unit/modules/workspace/test_service.py +++ b/tests/unit/modules/workspace/test_service.py @@ -375,105 +375,3 @@ def test_get_workspace_path(self, temp_dir: Path) -> None: expected = temp_dir / ".agentspaces" / "test-project" / "test-workspace" assert path == expected - - -class TestWorkspaceServiceActiveWorkspace: - """Tests for active workspace management methods.""" - - def test_get_active_returns_none_when_not_set( - self, git_repo: Path, temp_dir: Path - ) -> None: - """Should return None when no active workspace is set.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - service = WorkspaceService(resolver=resolver) - - result = service.get_active(cwd=git_repo) - - assert result is None - - def test_set_active_and_get_active(self, git_repo: Path, temp_dir: Path) -> None: - """Should set and retrieve active workspace.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - service = WorkspaceService(resolver=resolver) - - # Create a workspace - created = service.create( - base_branch="HEAD", - setup_venv=False, - cwd=git_repo, - ) - - # Set it as active - service.set_active(created.name, cwd=git_repo) - - # Retrieve active - active = service.get_active(cwd=git_repo) - - assert active is not None - assert active.name == created.name - - def test_set_active_not_found(self, git_repo: Path, temp_dir: Path) -> None: - """Should raise WorkspaceNotFoundError for non-existent workspace.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - service = WorkspaceService(resolver=resolver) - - with pytest.raises(WorkspaceNotFoundError): - service.set_active("nonexistent-workspace", cwd=git_repo) - - def test_get_active_returns_none_when_workspace_deleted( - self, git_repo: Path, temp_dir: Path - ) -> None: - """Should return None if active workspace was deleted.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - service = WorkspaceService(resolver=resolver) - - # Create and activate - created = service.create( - base_branch="HEAD", - setup_venv=False, - cwd=git_repo, - ) - service.set_active(created.name, cwd=git_repo) - - # Delete the workspace (no force needed, .agentspace/ is gitignored) - service.remove(created.name, cwd=git_repo) - - # Active should return None now - result = service.get_active(cwd=git_repo) - assert result is None - - -class TestWorkspaceServiceUpdateActivity: - """Tests for update_activity method.""" - - def test_update_activity_updates_timestamp( - self, git_repo: Path, temp_dir: Path - ) -> None: - """Should update last_activity_at timestamp.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - service = WorkspaceService(resolver=resolver) - - # Create a workspace - created = service.create( - base_branch="HEAD", - setup_venv=False, - cwd=git_repo, - ) - - # Initially no activity - assert created.last_activity_at is None - - # Update activity - service.update_activity(created.name, cwd=git_repo) - - # Check timestamp is set - updated = service.get(created.name, cwd=git_repo) - assert updated.last_activity_at is not None - - def test_update_activity_not_found(self, git_repo: Path, temp_dir: Path) -> None: - """Should raise WorkspaceNotFoundError for non-existent workspace.""" - resolver = PathResolver(base=temp_dir / ".agentspaces") - service = WorkspaceService(resolver=resolver) - - with pytest.raises(WorkspaceNotFoundError): - service.update_activity("nonexistent-workspace", cwd=git_repo) From c0740ff6c61bc117715bfe6b1d460038f4ca18b5 Mon Sep 17 00:00:00 2001 From: Chris Krough <461869+ckrough@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:19:35 -0500 Subject: [PATCH 2/4] chore: address code review feedback - Remove unused `deps_synced_at` and `last_activity_at` fields from WorkspaceInfo and WorkspaceMetadata - Remove unused `--quiet` CLI flag parameter - Bump metadata schema version to v3 - Add backwards compatibility test for v2 metadata files --- src/agentspaces/cli/app.py | 6 -- src/agentspaces/infrastructure/metadata.py | 18 ++-- src/agentspaces/modules/workspace/service.py | 6 -- tests/unit/infrastructure/test_metadata.py | 96 ++++---------------- 4 files changed, 26 insertions(+), 100 deletions(-) diff --git a/src/agentspaces/cli/app.py b/src/agentspaces/cli/app.py index b6fd2f0..d170d16 100644 --- a/src/agentspaces/cli/app.py +++ b/src/agentspaces/cli/app.py @@ -44,12 +44,6 @@ def main( "-v", help="Show debug output.", ), - quiet: bool = typer.Option( # noqa: ARG001 - reserved for future use - False, - "--quiet", - "-q", - help="Suppress info messages.", - ), ) -> None: """agentspaces: Workspace orchestration for AI coding agents. diff --git a/src/agentspaces/infrastructure/metadata.py b/src/agentspaces/infrastructure/metadata.py index 866c7e7..7c98732 100644 --- a/src/agentspaces/infrastructure/metadata.py +++ b/src/agentspaces/infrastructure/metadata.py @@ -26,8 +26,9 @@ # Current schema version - increment when making breaking changes # v1: Initial schema -# v2: Added deps_synced_at and last_activity_at fields -SCHEMA_VERSION = "2" +# v2: Added deps_synced_at and last_activity_at fields (removed in v3) +# v3: Removed unused timestamp fields (deps_synced_at, last_activity_at) +SCHEMA_VERSION = "3" # Maximum metadata file size (1MB - generous for workspace metadata) MAX_METADATA_SIZE = 1 * 1024 * 1024 @@ -51,8 +52,6 @@ class WorkspaceMetadata: python_version: Python version used for venv. has_venv: Whether a virtual environment was created. status: Workspace status (active, archived). - deps_synced_at: Timestamp when dependencies were last synced. - last_activity_at: Timestamp of last agent launch or sync. """ name: str @@ -64,8 +63,6 @@ class WorkspaceMetadata: python_version: str | None = None has_venv: bool = False status: str = "active" - deps_synced_at: datetime | None = None - last_activity_at: datetime | None = None def save_workspace_metadata(metadata: WorkspaceMetadata, path: Path) -> None: @@ -178,9 +175,8 @@ def _metadata_to_dict(metadata: WorkspaceMetadata) -> dict[str, Any]: data["version"] = SCHEMA_VERSION # Convert datetime fields to ISO 8601 strings - for field in ("created_at", "deps_synced_at", "last_activity_at"): - if isinstance(data.get(field), datetime): - data[field] = data[field].isoformat() + if isinstance(data.get("created_at"), datetime): + data["created_at"] = data["created_at"].isoformat() return data @@ -220,7 +216,7 @@ def _dict_to_metadata(data: dict[str, Any]) -> WorkspaceMetadata: """Convert dict to WorkspaceMetadata. Args: - data: Dict from JSON. + data: Dict from JSON (may contain legacy fields that are ignored). Returns: WorkspaceMetadata instance. @@ -244,6 +240,4 @@ def _dict_to_metadata(data: dict[str, Any]) -> WorkspaceMetadata: python_version=data.get("python_version"), has_venv=data.get("has_venv", False), status=data.get("status", "active"), - deps_synced_at=_parse_datetime(data.get("deps_synced_at")), - last_activity_at=_parse_datetime(data.get("last_activity_at")), ) diff --git a/src/agentspaces/modules/workspace/service.py b/src/agentspaces/modules/workspace/service.py index 16ee390..cc5b067 100644 --- a/src/agentspaces/modules/workspace/service.py +++ b/src/agentspaces/modules/workspace/service.py @@ -44,8 +44,6 @@ class WorkspaceInfo: python_version: str | None = None has_venv: bool = False status: str = "active" - deps_synced_at: datetime | None = None - last_activity_at: datetime | None = None class WorkspaceError(Exception): @@ -282,8 +280,6 @@ def list(self, *, cwd: Path | None = None) -> list[WorkspaceInfo]: python_version=metadata.python_version if metadata else None, has_venv=metadata.has_venv if metadata else False, status=metadata.status if metadata else "active", - deps_synced_at=metadata.deps_synced_at if metadata else None, - last_activity_at=metadata.last_activity_at if metadata else None, ) ) @@ -338,8 +334,6 @@ def get(self, name: str, *, cwd: Path | None = None) -> WorkspaceInfo: if metadata else (workspace_path / ".venv").exists(), status=metadata.status if metadata else "active", - deps_synced_at=metadata.deps_synced_at if metadata else None, - last_activity_at=metadata.last_activity_at if metadata else None, ) def remove( diff --git a/tests/unit/infrastructure/test_metadata.py b/tests/unit/infrastructure/test_metadata.py index f346fd5..3a901a0 100644 --- a/tests/unit/infrastructure/test_metadata.py +++ b/tests/unit/infrastructure/test_metadata.py @@ -48,8 +48,6 @@ def test_metadata_defaults(self) -> None: assert metadata.python_version is None assert metadata.has_venv is False assert metadata.status == "active" - assert metadata.deps_synced_at is None - assert metadata.last_activity_at is None def test_metadata_all_fields(self) -> None: """Should store all provided fields.""" @@ -112,7 +110,7 @@ def test_save_includes_version(self, temp_dir: Path) -> None: data = json.loads(path.read_text(encoding="utf-8")) assert "version" in data - assert data["version"] == "2" # Schema version 2 with timestamp fields + assert data["version"] == "3" # Schema version 3 def test_save_creates_parent_directories(self, temp_dir: Path) -> None: """Should create parent directories if needed.""" @@ -243,86 +241,32 @@ def test_load_handles_future_version(self, temp_dir: Path) -> None: assert loaded.name == "test-workspace" -class TestMetadataTimestampFields: - """Tests for new timestamp fields in WorkspaceMetadata.""" +class TestMetadataLegacyFields: + """Tests for backwards compatibility with legacy fields.""" - def test_deps_synced_at_stored(self, temp_dir: Path) -> None: - """Should store and load deps_synced_at timestamp.""" - synced_at = datetime(2025, 12, 20, 12, 0, 0, tzinfo=UTC) - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - deps_synced_at=synced_at, - ) - path = temp_dir / "workspace.json" - - save_workspace_metadata(metadata, path) - loaded = load_workspace_metadata(path) - - assert loaded is not None - assert loaded.deps_synced_at == synced_at - - def test_last_activity_at_stored(self, temp_dir: Path) -> None: - """Should store and load last_activity_at timestamp.""" - activity_at = datetime(2025, 12, 20, 14, 30, 0, tzinfo=UTC) - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - last_activity_at=activity_at, - ) - path = temp_dir / "workspace.json" - - save_workspace_metadata(metadata, path) - loaded = load_workspace_metadata(path) - - assert loaded is not None - assert loaded.last_activity_at == activity_at - - def test_both_timestamps_stored(self, temp_dir: Path) -> None: - """Should store and load both timestamps together.""" - synced_at = datetime(2025, 12, 20, 12, 0, 0, tzinfo=UTC) - activity_at = datetime(2025, 12, 20, 14, 30, 0, tzinfo=UTC) - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - deps_synced_at=synced_at, - last_activity_at=activity_at, - ) - path = temp_dir / "workspace.json" - - save_workspace_metadata(metadata, path) - loaded = load_workspace_metadata(path) - - assert loaded is not None - assert loaded.deps_synced_at == synced_at - assert loaded.last_activity_at == activity_at + def test_loads_v2_metadata_with_legacy_fields(self, temp_dir: Path) -> None: + """Should gracefully load v2 metadata that has legacy timestamp fields.""" + import json - def test_none_timestamps_remain_none(self, temp_dir: Path) -> None: - """None timestamps should remain None after round-trip.""" - metadata = WorkspaceMetadata( - name="test-workspace", - project="test-project", - branch="test-workspace", - base_branch="main", - created_at=datetime.now(UTC), - ) path = temp_dir / "workspace.json" + # Simulate a v2 file with legacy fields + data = { + "version": "2", + "name": "test-workspace", + "project": "test-project", + "branch": "test-workspace", + "base_branch": "main", + "created_at": datetime.now(UTC).isoformat(), + "deps_synced_at": datetime.now(UTC).isoformat(), # Legacy field + "last_activity_at": datetime.now(UTC).isoformat(), # Legacy field + } + path.write_text(json.dumps(data), encoding="utf-8") - save_workspace_metadata(metadata, path) loaded = load_workspace_metadata(path) + # Should load successfully, ignoring legacy fields assert loaded is not None - assert loaded.deps_synced_at is None - assert loaded.last_activity_at is None + assert loaded.name == "test-workspace" class TestMetadataError: From 055421c104aa7c485971f65e94a38a5eec900261 Mon Sep 17 00:00:00 2001 From: Chris Krough <461869+ckrough@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:22:52 -0500 Subject: [PATCH 3/4] chore: bump version to 0.2.0 --- pyproject.toml | 2 +- src/agentspaces/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4391eb..66faac8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentspaces" -version = "0.1.0" +version = "0.2.0" description = "Workspace orchestration tool for AI coding agents" readme = "README.md" license = "MIT" diff --git a/src/agentspaces/__init__.py b/src/agentspaces/__init__.py index 7d07797..70ea284 100644 --- a/src/agentspaces/__init__.py +++ b/src/agentspaces/__init__.py @@ -1,3 +1,3 @@ """agentspaces: Workspace orchestration tool for AI coding agents.""" -__version__ = "0.1.0" +__version__ = "0.2.0" From 93f9f0fd9c1ac8c214755d42680e194a7da5fccb Mon Sep 17 00:00:00 2001 From: Chris Krough <461869+ckrough@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:27:18 -0500 Subject: [PATCH 4/4] remove token hoggin claude code review --- .github/workflows/claude-code-review.yml | 57 ------------------------ .github/workflows/claude.yml | 50 --------------------- 2 files changed, 107 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 8452b0f..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index d300267..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' -