fix(cli): add agents project switch command to project CLI #11087

Merged
HAL9000 merged 1 commits from pr_fix_8675_switch_project_command into master 2026-06-18 05:17:19 +00:00
5 changed files with 250 additions and 8 deletions
+13
View File
@@ -225,6 +225,17 @@ ensuring data is stored with proper parameter values.
- **A2A module rename BDD test suite** (#8615): Comprehensive Behave tests validating that the ACP→A2A module rename is complete — verifying all 22 A2A symbols are properly exported, no legacy ACP references remain in `.py` files under `cleveragents.a2a/`, and the module docstring uses current A2A naming. The step definitions include self-contained symbol lookups to avoid cross-scenario dependency failures.
### Added
- **Project switch command** (#8675): Added `agents project switch <project>` subcommand
to the project CLI that allows switching between registered namespaced projects
without changing filesystem directories. The command validates the target project
exists in the NamespacedProject registry, sets it as the active project via the
``CLEVERAGENTS_PROJECT`` environment variable (persisted to a session helper file
for shell sourcing), and displays previous/current project info across rich, json,
yaml, plain, and table output formats. Includes BDD test coverage and confirmation
prompt (bypassable with ``--yes``/``-y``).
### Security
- **PyYAML declared as explicit runtime dependency** (#11012 / #13605): Added `pyyaml>=6.0.3` as a direct runtime dependency in `pyproject.toml`. PyYAML was previously only transitive (pulled via langchain ecosystem), listed solely as type stubs (`types-pyyaml>=6.0.0`) in the dev extras group, while being used at runtime in `src/cleveragents/actor/yaml_loader.py` for actor configuration YAML loading with Jinja2 template support and environment variable interpolation. This change explicitly pins the dependency to `>=6.0.3` to mitigate CVE-2025-8045 (arbitrary remote code execution via crafted YAML payloads) and prevents silent breakage if upstream transitive dependencies change their PyYAML requirements in future releases. The version floor ensures vulnerable versions (<6.0.3) cannot be installed even if upstream transitive dependencies have loose version constraints.
@@ -244,6 +255,8 @@ ensuring data is stored with proper parameter values.
for CRUD and lifecycle stub methods (read, write, delete, list_children, diff,
discover_children, create_sandbox, create_checkpoint, rollback_to, project_access).
### Fixed
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
Previously the handler logged only the exception type name (e.g.
+1
View File
@@ -28,6 +28,7 @@
Below are some of the specific details of various contributions.
* HAL 9000 has contributed the project switch CLI command (PR #8675 / issue #8675): added `agents project switch <namespaced-name>` to allow switching between registered projects in the NamespacedProject registry without changing filesystem directories. The command validates project existence, persists ``CLEVERAGENTS_PROJECT`` via a shell session helper file, and supports rich/json/yaml/plain/table output formats with confirmation prompts (bypassable with ``--yes``). Includes full BDD test coverage.
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
* Jeffrey Phillips Freeman has contributed the invariant merge precedence fix (#9126): restored the missing ACTION scope in ``merge_invariants()`` and ``InvariantSet.merge()``, corrected all module docstrings from ``plan > project > global`` to the spec-compliant ``plan > action > project > global``, and added comprehensive BDD test coverage for four-tier merge precedence.
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
+42
View File
@@ -0,0 +1,42 @@
Feature: Project CLI switch command
As a developer
I want to switch between registered projects
So that I can manage my active project without changing directories
Background:
Given a project CLI commands test database is initialized
# switch command
Scenario: switch command with rich output sets active project
Given a project "local/switch-proj" is created in the commands DB
When I invoke project-switch for "local/switch-proj" with yes default format
Then the project cmd output should contain "Active Project Switched"
And the project cmd output should contain "switch-proj"
And the project cmd should succeed
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" with yes format "json"
Then the project cmd output should contain "active"
And the project cmd JSON data should include "previous" key
Scenario: switch to nonexistent project fails
When I invoke project-switch for "local/ghost-proj" with yes default format
Then the project cmd should fail
Scenario: switch command with yaml format
Given a project "local/switch-yaml" is created in the commands DB
When I invoke project-switch for "local/switch-yaml" with yes format "yaml"
Then the project cmd should succeed
Scenario: switch accepts --yes to skip confirmation
Given a project "local/switch-yes" is created in the commands DB
When I invoke project-switch for "local/switch-yes" with yes and short flag
Then the project cmd output should contain "Active Project Switched"
And the project cmd should succeed
Scenario: switch command can be interrupted by user confirmation denial
Given a project "local/switch-cancel" is created in the commands DB
When I invoke project-switch for "local/switch-cancel" without yes
Then the project cmd should fail
+85 -8
View File
1
@@ -162,6 +162,25 @@ def _capture(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
context._cmd_failed = failed
def _load_cmd_json_output(context: Any) -> dict[str, Any]:
"""Parse a JSON command envelope from captured CLI output."""
output = context._cmd_output.strip()
json_start = output.find("{")
if json_start > 0:
output = output[json_start:]
try:
envelope = json.loads(output)
except json.JSONDecodeError as exc:
raise AssertionError(
f"Command output is not valid JSON:\n{context._cmd_output}"
) from exc
assert isinstance(envelope, dict), (
f"Expected command JSON envelope to be a dict, got "
f"{type(envelope).__name__}: {envelope}"
)
return envelope
# ---------------------------------------------------------------------------
# Reusable helpers
# ---------------------------------------------------------------------------
@@ -652,11 +671,76 @@ def step_invoke_delete_short_force(context: Any, name: str) -> None:
_unpatch_project_mod()
# ---------------------------------------------------------------------------
# Switch command
# ---------------------------------------------------------------------------
@when('I invoke project-switch for "{name}" with yes default format')
def step_invoke_switch(context: Any, name: str) -> None:
from cleveragents.cli.commands.project import switch
_capture(context, switch, project=name, yes=True)
@when('I invoke project-switch for "{name}" with yes format "{fmt}"')
def step_invoke_switch_fmt(context: Any, name: str, fmt: str) -> None:
from cleveragents.cli.commands.project import switch
_capture(context, switch, project=name, yes=True, output_format=fmt)
@when('I invoke project-switch for "{name}" without yes')
def step_invoke_switch_no_confirm(context: Any, name: str) -> None:
"""Invoke switch without --yes, denying the confirmation prompt."""
from typer.testing import CliRunner
from cleveragents.cli.commands.project import app as project_app
_patch_project_mod(context)
try:
cli_runner = CliRunner()
result = cli_runner.invoke(project_app, ["switch", name], input="n\n")
context._cmd_output = result.output
context._cmd_failed = result.exit_code != 0
finally:
_unpatch_project_mod()
@when('I invoke project-switch for "{name}" with yes and short flag')
def step_invoke_switch_short_flag(context: Any, name: str) -> None:
"""Invoke 'project switch' via CliRunner using the ``-y`` short flag."""
from typer.testing import CliRunner
from cleveragents.cli.commands.project import app as project_app
_patch_project_mod(context)
try:
cli_runner = CliRunner()
result = cli_runner.invoke(project_app, ["switch", name, "-y"])
context._cmd_output = result.output
context._cmd_failed = result.exit_code != 0
finally:
_unpatch_project_mod()
# ---------------------------------------------------------------------------
# Then assertions
# ---------------------------------------------------------------------------
@then('the project cmd JSON data should include "previous" key')
def step_cmd_json_has_previous(context: Any) -> None:
envelope = _load_cmd_json_output(context)
data = envelope.get("data")
assert isinstance(data, dict), (
f"Expected envelope data to be a dict, got {type(data).__name__}: {data}"
)
assert "previous" in data, (
f"Expected 'previous' in command JSON data, got keys {list(data.keys())}"
)
@then('the project cmd output should contain "{text}"')
def step_cmd_output_contains(context: Any, text: str) -> None:
assert text.lower() in context._cmd_output.lower(), (
@@ -680,14 +764,7 @@ def step_cmd_fail(context: Any) -> None:
@then("the project cmd JSON data should include deleted_at")
def step_cmd_json_has_deleted_at(context: Any) -> None:
output = context._cmd_output.strip()
try:
envelope = json.loads(output)
except json.JSONDecodeError as exc:
raise AssertionError(
f"Command output is not valid JSON:\n{context._cmd_output}"
) from exc
envelope = _load_cmd_json_output(context)
data = envelope.get("data")
assert isinstance(data, dict), (
f"Expected envelope data to be a dict, got {type(data).__name__}: {data}"
+109
View File
@@ -9,6 +9,7 @@ Commands:
- ``agents project unlink-resource <project> <resource>``
- ``agents project list [--namespace NS] [REGEX]``
- ``agents project show <project>``
- ``agents project switch <project>``
- ``agents project delete [--force|-f] [--yes|-y] <name>``
Legacy file-filter sub-app is preserved for backward compatibility.
@@ -19,6 +20,7 @@ Based on ADR-009 (CLI Framework) and implementation_plan.md task B0.cli.projects
from __future__ import annotations
import logging
import os
import re
from datetime import UTC, datetime
from pathlib import Path
3
@@ -1001,6 +1003,113 @@ def show(
console.print(format_output(data, output_format))
# ---------------------------------------------------------------------------
# Switch command (spec-aligned)
# ---------------------------------------------------------------------------
@app.command(name="switch")
def switch(
project: Annotated[
str,
typer.Argument(help="Project namespaced name to switch to"),
],
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
output_format: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Switch the active project to the given namespaced project.
Sets the current active project by updating the ``CLEVERAGENTS_PROJECT``
environment variable in the caller's session (written to a helper file
that shell initialisation scripts can source). The change is session-local
and does not modify the database record of any project.
Examples::
agents project switch local/my-project
agents project switch dev:freemo/api-service --yes
"""
svc = _get_namespaced_project_service()
# Validate project exists
try:
proj = svc.get_project(project)
except NotFoundError as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
# Read current active project (from env if set, or from the session file)
current_active = os.environ.get("CLEVERAGENTS_PROJECT", "")
if not yes:
if current_active:
confirm_msg = (
f"Switch active project from '{current_active}'"
f" to '{proj.namespaced_name}'?"
)
else:
confirm_msg = f"Set active project to '{proj.namespaced_name}'?"
confirm = typer.confirm(confirm_msg)
if not confirm:
raise typer.Abort()
# Write the active project to the session helper file. This file is
# intended to be sourced by shell profiles so that sub-shells inherit
# the same active-project setting.
_write_active_project(proj.namespaced_name)
if output_format.lower() == OutputFormat.RICH:
lines = []
if current_active:
lines.append(f"[bold]Previous project:[/bold] {current_active}")
lines.append(f"[bold]Active project:[/bold] {proj.namespaced_name}")
if proj.description:
lines.append(f"[bold]Description:[/bold] {proj.description}")
console.print(
Panel(
"\n".join(lines),
title="Active Project Switched",
expand=False,
)
)
else:
data: dict[str, Any] = {
"active": proj.namespaced_name,
"previous": current_active or None,
"description": proj.description,
"updated_at": datetime.now(tz=UTC).isoformat(),
}
console.print(format_output(data, output_format, command="project switch"))
def _write_active_project(namespaced_name: str) -> None:
"""Persist the active project to a session helper file.
The helper file lives in ``~/.cleveragents/active-project.sh`` and writes
an export statement that shell scripts can source via
``source ~/.cleveragents/active-project.sh``. This allows the active
project to survive across subshells and terminal sessions.
"""
home = Path.home() / ".cleveragents"
home.mkdir(parents=True, exist_ok=True)
helper_path = home / "active-project.sh"
safe_name = namespaced_name.replace("'", "'\\''")
helper_path.write_text(
f"export CLEVERAGENTS_PROJECT='{safe_name}'\n", encoding="utf-8"
)
# Also update the in-memory environment for the current process and any
# child processes that may be forked by this CLI invocation.
os.environ["CLEVERAGENTS_PROJECT"] = namespaced_name
@app.command(name="delete")
def delete(
name: Annotated[