diff --git a/features/steps/project_cli_commands_steps.py b/features/steps/project_cli_commands_steps.py index 1369a3e93..3bfbf5c9d 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,33 @@ 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 whose set_current_project is a no-op in tests. + from unittest.mock import MagicMock + mock_svc = MagicMock() + mock_svc.set_current_project = lambda name: None + 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: @@ -659,14 +677,14 @@ def step_invoke_delete_short_force(context: Any, name: str) -> None: @when('I invoke project-switch for "{name}" default format') def step_invoke_switch(context: Any, name: str) -> None: - from cleveragents.cli.commands.project import switch + 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 import switch + from cleveragents.cli.commands.project_switch import switch _capture(context, switch, project=name, output_format=fmt) diff --git a/src/cleveragents/cli/commands/project.py b/src/cleveragents/cli/commands/project.py index c067a7701..4a51b2114 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 ( @@ -923,52 +924,9 @@ def show( console.print(format_output(data, output_format)) -@app.command(name="switch") -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 - try: - service, _ = _get_project_service_and_current_project() - 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)) +# 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") 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))