fix(cli): add agents project switch command to project CLI #11087
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -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[
|
||||
|
||||
Reference in New Issue
Block a user