From d0e685aed4181d0d31eea9ce17f1fa1b601b6669 Mon Sep 17 00:00:00 2001 From: CleverAgents Bot Date: Wed, 17 Jun 2026 23:31:23 -0400 Subject: [PATCH] fix(cli): add project switch command ISSUES CLOSED: #8675 --- CHANGELOG.md | 13 +++ CONTRIBUTORS.md | 1 + features/project_cli_switch.feature | 42 +++++++ features/steps/project_cli_commands_steps.py | 93 ++++++++++++++-- src/cleveragents/cli/commands/project.py | 109 +++++++++++++++++++ 5 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 features/project_cli_switch.feature diff --git a/CHANGELOG.md b/CHANGELOG.md index 905586333..40c79398d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 457dee56d..ac904da73 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -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 ` 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. diff --git a/features/project_cli_switch.feature b/features/project_cli_switch.feature new file mode 100644 index 000000000..06faceea4 --- /dev/null +++ b/features/project_cli_switch.feature @@ -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 diff --git a/features/steps/project_cli_commands_steps.py b/features/steps/project_cli_commands_steps.py index 180f23ee2..b62792ce2 100644 --- a/features/steps/project_cli_commands_steps.py +++ b/features/steps/project_cli_commands_steps.py @@ -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}" diff --git a/src/cleveragents/cli/commands/project.py b/src/cleveragents/cli/commands/project.py index c3cfeb551..82a6d08fd 100644 --- a/src/cleveragents/cli/commands/project.py +++ b/src/cleveragents/cli/commands/project.py @@ -9,6 +9,7 @@ Commands: - ``agents project unlink-resource `` - ``agents project list [--namespace NS] [REGEX]`` - ``agents project show `` +- ``agents project switch `` - ``agents project delete [--force|-f] [--yes|-y] `` 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[