diff --git a/CHANGELOG.md b/CHANGELOG.md index 0663943a4..4d2a0b77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). project milestone planning. Includes BDD test coverage for the new workflow documentation and permission configuration. +- **Project Switch Command** (#8623): Added `agents project switch ` command + to switch the active project context. Supports `--format` flag for machine-readable + output (json, yaml, plain, rich). Includes BDD test coverage. + - **Git Worktree TOCTOU Race Condition** (#7507): Fixed a Time-Of-Check-To-Time-Of-Use (TOCTOU) race condition in `git_worktree.py` that could cause `git worktree add` operations to fail under concurrent execution. The fix replaces the unsafe diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8fd49fa4c..1c0f8274d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -26,3 +26,4 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system. * HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559). * HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration. +* HAL 9000 has contributed the `agents project switch` command implementation (PR #8675 / issue #8623): added the `switch` subcommand to the project CLI, extracted into a dedicated `project_switch.py` module to maintain the 500-line file guideline, implemented context persistence via `ProjectService.set_current_project()`, and added BDD test coverage with context-persistence assertions. diff --git a/features/project_cli_commands.feature b/features/project_cli_commands.feature index 14f12022b..004389cb6 100644 --- a/features/project_cli_commands.feature +++ b/features/project_cli_commands.feature @@ -224,3 +224,23 @@ Feature: Project CLI command functions coverage When I invoke project-delete for "local/del-sf" with yes and short force flag Then the project cmd output should contain "deleted" And the project cmd should succeed + + # ── switch command ─────────────────────────────────────────── + + Scenario: switch command changes active project + Given a project "local/switch-a" is created in the commands DB + When I invoke project-switch for "local/switch-a" default format + Then the project cmd output should contain "switched" + And the project cmd should succeed + And the project context should be switched to "local/switch-a" + + Scenario: switch command with json format + Given a project "local/switch-json" is created in the commands DB + When I invoke project-switch for "local/switch-json" format "json" + Then the project cmd output should contain "namespaced_name" + And the project cmd should succeed + And the project context should be switched to "local/switch-json" + + Scenario: switch to nonexistent project fails + When I invoke project-switch for "local/ghost-switch" default format + Then the project cmd should fail diff --git a/features/steps/project_cli_commands_steps.py b/features/steps/project_cli_commands_steps.py index 180f23ee2..3f265b44f 100644 --- a/features/steps/project_cli_commands_steps.py +++ b/features/steps/project_cli_commands_steps.py @@ -102,6 +102,7 @@ _ORIG_FNS: dict[str, Any] = {} def _patch_project_mod(context: Any) -> None: """Monkey-patch the four DI look-up helpers in project module.""" import cleveragents.cli.commands.project as project_mod + import cleveragents.cli.commands.project_switch as project_switch_mod _ORIG_FNS["repo"] = project_mod._get_namespaced_project_repo _ORIG_FNS["link"] = project_mod._get_resource_link_repo @@ -116,16 +117,40 @@ def _patch_project_mod(context: Any) -> None: _ORIG_FNS["store_extras"] = project_mod._store_project_extras project_mod._store_project_extras = lambda *a, **kw: None + # Patch project_switch module helpers so the switch command works in tests + # without a real DI container or .cleveragents directory. + _ORIG_FNS["switch_repo"] = project_switch_mod._get_namespaced_project_repo + _ORIG_FNS["switch_svc"] = project_switch_mod._get_project_service + project_switch_mod._get_namespaced_project_repo = lambda: context._cmd_project_repo + + # Provide a mock service that records set_current_project calls so tests + # can assert the context was actually persisted. + from unittest.mock import MagicMock + + mock_svc = MagicMock() + context._switch_calls: list[str] = [] + + def _track_set_current(name: str) -> None: + context._switch_calls.append(name) + + mock_svc.set_current_project = _track_set_current + project_switch_mod._get_project_service = lambda: mock_svc + def _unpatch_project_mod() -> None: """Restore original helpers.""" import cleveragents.cli.commands.project as project_mod + import cleveragents.cli.commands.project_switch as project_switch_mod project_mod._get_namespaced_project_repo = _ORIG_FNS["repo"] project_mod._get_resource_link_repo = _ORIG_FNS["link"] project_mod._get_resource_registry_service = _ORIG_FNS["svc"] if _ORIG_FNS.get("store_extras"): project_mod._store_project_extras = _ORIG_FNS["store_extras"] + if _ORIG_FNS.get("switch_repo"): + project_switch_mod._get_namespaced_project_repo = _ORIG_FNS["switch_repo"] + if _ORIG_FNS.get("switch_svc"): + project_switch_mod._get_project_service = _ORIG_FNS["switch_svc"] def _capture(context: Any, func: Any, *args: Any, **kwargs: Any) -> None: @@ -652,6 +677,39 @@ def step_invoke_delete_short_force(context: Any, name: str) -> None: _unpatch_project_mod() +# --------------------------------------------------------------------------- +# Switch command +# --------------------------------------------------------------------------- + + +@when('I invoke project-switch for "{name}" default format') +def step_invoke_switch(context: Any, name: str) -> None: + from cleveragents.cli.commands.project_switch import switch + + _capture(context, switch, project=name) + + +@when('I invoke project-switch for "{name}" format "{fmt}"') +def step_invoke_switch_fmt(context: Any, name: str, fmt: str) -> None: + from cleveragents.cli.commands.project_switch import switch + + _capture(context, switch, project=name, output_format=fmt) + + +@then('the project context should be switched to "{name}"') +def step_context_switched_to(context: Any, name: str) -> None: + """Assert that set_current_project was called with the expected project name. + + This verifies the switch command actually persisted the context change + via the service layer (CLI -> Service -> Repository boundary). + """ + calls = getattr(context, "_switch_calls", []) + assert name in calls, ( + f"Expected set_current_project to be called with '{name}', " + f"but recorded calls were: {calls}" + ) + + # --------------------------------------------------------------------------- # Then assertions # --------------------------------------------------------------------------- diff --git a/src/cleveragents/application/services/project_service.py b/src/cleveragents/application/services/project_service.py index 3480d69fe..4e289e4aa 100644 --- a/src/cleveragents/application/services/project_service.py +++ b/src/cleveragents/application/services/project_service.py @@ -492,3 +492,42 @@ class ProjectService: ) except Exception: _logger.warning("audit_emit_failed", event_type="ENTITY_DELETED") + + def set_current_project(self, project_name: str) -> None: + """Set the current project context. + + Writes the project name to .cleveragents/project.name file. + + Args: + project_name: The project name to set as current + + Raises: + FileSystemError: If unable to find or write to .cleveragents directory + """ + path = Path.cwd().resolve() + env_root = os.getenv("CLEVERAGENTS_PROJECT_SEARCH_ROOT") + search_root = ( + Path(env_root).resolve() if env_root else getattr(self, "search_root", None) + ) + limit = search_root.resolve() if isinstance(search_root, Path) else None + + while path != path.parent: + if limit and not path.is_relative_to(limit): + break + if (path / ".cleveragents").exists(): + name_file = path / ".cleveragents" / "project.name" + try: + name_file.write_text(project_name.strip()) + return + except Exception as e: + raise FileSystemError( + message=f"Failed to write project name: {e}", + path=path, + ) from e + path = path.parent + + raise FileSystemError( + message="No .cleveragents directory found in current or parent directories", + path=Path.cwd(), + ) + diff --git a/src/cleveragents/cli/commands/project.py b/src/cleveragents/cli/commands/project.py index 7b95d6942..f35398dd7 100644 --- a/src/cleveragents/cli/commands/project.py +++ b/src/cleveragents/cli/commands/project.py @@ -29,6 +29,7 @@ from rich.table import Table from cleveragents.application.services.context_service import DEFAULT_IGNORE_PATTERNS from cleveragents.cli.commands.project_context import app as context_app +from cleveragents.cli.commands.project_switch import switch as _switch_fn from cleveragents.cli.formatting import OutputFormat, format_output from cleveragents.cli.renderers import _get_console, _get_err_console from cleveragents.core.exceptions import ( @@ -926,6 +927,11 @@ def show( console.print(format_output(data, output_format)) +# Switch command is implemented in project_switch.py to keep this file +# below the 500-line guideline (CONTRIBUTING.md). +app.command(name="switch")(_switch_fn) + + @app.command(name="delete") def delete( name: Annotated[ diff --git a/src/cleveragents/cli/commands/project_switch.py b/src/cleveragents/cli/commands/project_switch.py new file mode 100644 index 000000000..2564e26e0 --- /dev/null +++ b/src/cleveragents/cli/commands/project_switch.py @@ -0,0 +1,136 @@ +"""Switch subcommand for the project CLI. + +Extracted from ``project.py`` to keep each module below the 500-line +guideline (CONTRIBUTING.md). + +Implements ``agents project switch ``, which updates the active +project context so that subsequent commands operate in the context of +the newly selected project. +""" + +from __future__ import annotations + +from typing import Annotated, Any + +import typer +from rich.panel import Panel + +from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console, _get_err_console + +console = _get_console() +err_console = _get_err_console() + +# Reusable --format option description (mirrors project.py) +_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" + + +# --------------------------------------------------------------------------- +# Internal helpers (patched in tests) +# --------------------------------------------------------------------------- + + +def _get_namespaced_project_repo() -> Any: + """Return a NamespacedProjectRepository from the DI container.""" + from cleveragents.application.container import get_container + + container = get_container() + return container.namespaced_project_repo() + + +def _get_project_service() -> Any: + """Return a ProjectService from the DI container. + + Unlike ``_get_project_service_and_current_project`` in project.py, + this helper does **not** require a current project to exist. The + switch command must work even when no project is currently active. + """ + from cleveragents.application.container import get_container + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + project_service: ProjectService = container.project_service() + return project_service + + +def _project_spec_dict(project: Any) -> dict[str, object]: + """Return project data as a dict using spec field names.""" + linked: list[dict[str, object]] = [] + for lr in project.linked_resources: + linked.append( + { + "resource_id": lr.resource_id, + "read_only": lr.project_read_only, + "alias": lr.alias, + "linked_at": lr.linked_at.isoformat() + if hasattr(lr.linked_at, "isoformat") + else str(lr.linked_at), + } + ) + + return { + "namespaced_name": project.namespaced_name, + "namespace": project.namespace, + "name": project.name, + "description": project.description, + "linked_resources": linked, + "created_at": project.created_at.isoformat() + if hasattr(project.created_at, "isoformat") + else str(project.created_at), + "updated_at": project.updated_at.isoformat() + if hasattr(project.updated_at, "isoformat") + else str(project.updated_at), + } + + +# --------------------------------------------------------------------------- +# Switch command +# --------------------------------------------------------------------------- + + +def switch( + project: Annotated[ + str, + typer.Argument(help="Project namespaced name to switch to"), + ], + output_format: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", +) -> None: + """Switch the active project context to the specified project. + + Updates the current project context so that subsequent commands + operate in the context of the newly selected project. + """ + repo = _get_namespaced_project_repo() + + # Validate project exists + try: + proj = repo.get(project) + except Exception as exc: + err_console.print(f"[red]Project not found:[/red] {project}") + raise typer.Exit(1) from exc + + # Update the active project context via the service layer + # (CLI -> Service -> Repository -- maintains 4-layer architecture boundary) + try: + service = _get_project_service() + service.set_current_project(proj.namespaced_name) + except Exception as exc: + err_console.print(f"[red]Error switching project context:[/red] {exc}") + raise typer.Exit(1) from exc + + # Format output + data = _project_spec_dict(proj) + + if output_format.lower() == OutputFormat.RICH: + console.print( + Panel( + f"[bold]Switched to project:[/bold] {proj.namespaced_name}", + title="Project Switch", + expand=False, + ) + ) + else: + console.print(format_output(data, output_format))