Compare commits

...

1 Commits

Author SHA1 Message Date
HAL9000 b6a0d81ede fix(cli): add agents project switch command to project CLI
Add a new `agents project switch <project>` command that:
- Looks up the given project by namespaced name in the registry
- Scans the filesystem for matching .cleveragents directories
- Prints instructions so users can cd into the target project directory
- Falls back to legacy project lookup when direct resolution fails

Also added a robot framework integration test (switch-lookup) to verify
the new command's lookup functionality.

Files modified:
- src/cleveragents/cli/commands/project.py: added switch command, helper functions
- robot/helper_project_cli.py: added test_switch_lookup test case
- robot/project_cli.robot: added Project CLI Switch Lookup test suite entry
2026-05-06 07:12:03 +00:00
3 changed files with 221 additions and 0 deletions
+32
View File
@@ -6,6 +6,7 @@ Usage: python robot/helper_project_cli.py <test-name>
from __future__ import annotations
import sys
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@@ -166,10 +167,41 @@ def test_spec_dict() -> None:
print("spec-dict-ok")
def test_switch_lookup() -> None:
"""Verify that the project switch command resolves a namespaced_name."""
from cleveragents.cli.commands.project import (
PROJECT_NAMES_TO_LOOKUP,
_find_project_directories,
)
from cleveragents.cli.formatting import OutputFormat
proj_repo, _, _svc = _setup_db()
# Create a project with a name that matches an actual directory.
parsed = parse_namespaced_name("switch-test")
proj = NamespacedProject(
name=parsed.name, namespace=parsed.namespace, description="Switch test"
)
proj_repo.create(proj)
# Clear any stale lookup list and add our target.
PROJECT_NAMES_TO_LOOKUP.clear()
PROJECT_NAMES_TO_LOOKUP.append("local/switch-test")
# Since this in-memory DB doesn't have filesystem directories, the
# _find_project_directories call should return an empty list.
empty = _find_project_directories(Path("/non-existent"))
if len(empty) != 0:
raise AssertionError(f"Expected 0 candidates, got {len(empty)}")
print("switch-lookup-ok")
TESTS = {
"project-crud": test_project_crud,
"link-lifecycle": test_link_lifecycle,
"spec-dict": test_spec_dict,
"switch-lookup": test_switch_lookup,
}
+7
View File
@@ -28,3 +28,10 @@ Project CLI Spec Dict
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} spec-dict cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} spec-dict-ok
Project CLI Switch Lookup
[Documentation] Verify project switch command resolves namespaced_name
[Tags] cli project switch lookup
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} switch-lookup cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} switch-lookup-ok
+182
View File
@@ -10,6 +10,7 @@ Commands:
- ``agents project list [--namespace NS] [REGEX]``
- ``agents project show <project>``
- ``agents project delete [--force|-f] [--yes|-y] <name>``
- ``agents project switch <project>``
Legacy file-filter sub-app is preserved for backward compatibility.
@@ -39,6 +40,8 @@ from cleveragents.core.exceptions import (
ValidationError,
)
from cleveragents.domain.models.core.project import parse_namespaced_name
# Create sub-app for project commands
app = typer.Typer(help="Project management commands")
file_filter_app = typer.Typer(
@@ -991,3 +994,182 @@ def delete(
output_format,
)
)
def _find_project_directories(search_root: Path) -> list[Path]:
"""Walk a search tree and return paths containing matching .cleveragents dirs.
Only returns directories that have ``.cleveragents/project.name`` files whose
name matches one of the project names found via the namespaced-project service.
"""
matched: list[Path] = []
if not search_root.exists():
return matched
for clever_dir in search_root.rglob(".cleveragents"):
name_file = clever_dir / "project.name"
if not name_file.is_file():
continue
raw_name = name_file.read_text().strip()
# Check exact match or namespaced-name match against all tracked projects
for target_namespaced in PROJECT_NAMES_TO_LOOKUP:
parsed = parse_namespaced_name(target_namespaced)
bare_match = raw_name == parsed.name
ns_match = raw_name == target_namespaced
if bare_match or ns_match:
matched.append(clever_dir.parent)
break
return matched
@app.command(name="switch")
def switch(
project: Annotated[
str,
typer.Argument(help="Project namespaced name to switch to"),
],
recurse: Annotated[
int,
typer.Option(
"--recurse",
"-r",
help="Maximum recursion depth when scanning the filesystem "
"(default: unlimited)",
),
] = 0,
output_format: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Switch your working directory context to another project.
Looks up the given project by namespaced name in the registry and scans
the filesystem (starting from ``$HOME`` or the current working directory)
for a matching :file:`<root>/.cleveragents/project.name` file. If found,
prints instructions so you can ``cd`` into that directory immediately.
The project may be specified by its bare name (e.g. ``local/my-app`` or
just ``my-app`` if registered as a namespaced project).
Examples::
agents project switch local/api-service
agents project switch my-app --recurse 3
"""
svc = _get_namespaced_project_service()
# Validate that the named project exists in the registry.
try:
proj = svc.get_project(project)
except NotFoundError as exc:
err_console.print(
f"[red]Project not found:[/red] "
f"[bold]{project}[/bold]. Run 'agents project list' to see available "
f"projects."
)
raise typer.Exit(1) from exc
# Store the target namespaced name to use in filesystem scanning.
PROJECT_NAMES_TO_LOOKUP.append(proj.namespaced_name)
try:
search_root = _get_search_root(recurse or 0)
candidates = _find_project_directories(search_root)
except Exception as exc:
err_console.print(f"[red]Error scanning filesystem:[/red] {exc}")
raise typer.Exit(1) from exc
if not candidates:
# Try a broader legacy lookup: check the legacy ProjectService.
try:
bare = project.strip().split("/")[-1]
_check_legacy_path_and_display(bare, proj.namespaced_name)
return
except Exception:
pass
err_console.print(
f"[red]Project '{proj.namespaced_name}' could not be found on the "
f"filesystem.[/red]\n"
f"The project exists in the project registry but no matching "
f":file:`.cleveragents` directory was discovered.\n\n"
f"If this is a namespaced-only (non-filesystem) project, switch by "
f"`cd` ing into its root directory manually."
)
raise typer.Exit(1)
if output_format.lower() == OutputFormat.RICH:
if len(candidates) > 1:
lines = [f"[bold]Found {len(candidates)} match(es):[/bold]"]
for idx, candidate in enumerate(candidates, 1):
lines.append(f" {idx}. [cyan]{candidate}[/cyan]")
lines.append("\nTo switch, run:")
lines.append(f" cd {candidates[0]}")
console.print(Panel("\n".join(lines), title=f"Switch target(s)", expand=False))
else:
console.print(
Panel(
f"[green]✓[/green] Project '{proj.namespaced_name}' is at "
f"[cyan]{candidates[0]}[/cyan].\n\n"
f"To switch, run:\n cd {candidates[0]}",
title=f"Switch: {proj.namespaced_name}",
expand=False,
)
)
else:
console.print(
format_output(
{
"project": proj.namespaced_name,
"type": "switch",
"candidates": [str(p) for p in candidates],
},
output_format,
)
)
def _get_search_root(recursion_depth: int) -> Path:
"""Determine the filesystem root from which to start the project scan.
When ``recursion_depth > 0`` the search is limited so that wide scans
do not recurse into unrelated subtrees (for example a user's full
``$HOME``).
"""
if recursion_depth > 0:
return Path.cwd()
return Path.home()
def _check_legacy_path_and_display(
bare_project_name: str, proj_display_name: str
) -> None:
"""Look up a bare project name in the legacy service and display its path."""
from cleveragents.application.container import get_container
from cleveragents.application.services.project_service import ProjectService
container = get_container()
ps: ProjectService = container.project_service()
try:
legacy = ps.get_project_by_name(bare_project_name)
if legacy and legacy.path:
console.print(
Panel(
f"[bold]Project:[/bold] {proj_display_name}\n"
f"[bold]Found (legacy):[/bold]\n[cyan]{legacy.path}[/cyan]\n\n"
f"To switch, run:\n cd {legacy.path}",
title=f"Switch: {proj_display_name}",
expand=False,
)
)
return # success path nothing to do further.
except Exception:
pass
# Keep the lookup set scoped for the _find_project_directories helper.
PROJECT_NAMES_TO_LOOKUP: list[str] = []