Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fec791d85 |
@@ -48,6 +48,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
project milestone planning. Includes BDD test coverage for the new workflow
|
||||
documentation and permission configuration.
|
||||
|
||||
- **Project Switch Command** (#8623): Added `agents project switch <name>` command
|
||||
to switch the active project context. Supports `--format` flag for machine-readable
|
||||
output (json, yaml, plain, rich). Includes BDD test coverage.
|
||||
|
||||
- **Git Worktree TOCTOU Race Condition** (#7507): Fixed a Time-Of-Check-To-Time-Of-Use
|
||||
(TOCTOU) race condition in `git_worktree.py` that could cause `git worktree add`
|
||||
operations to fail under concurrent execution. The fix replaces the unsafe
|
||||
|
||||
@@ -26,3 +26,4 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the `agents project switch` command implementation (PR #8675 / issue #8623): added the `switch` subcommand to the project CLI, extracted into a dedicated `project_switch.py` module to maintain the 500-line file guideline, implemented context persistence via `ProjectService.set_current_project()`, and added BDD test coverage with context-persistence assertions.
|
||||
|
||||
@@ -1714,12 +1714,6 @@ Feature: Consolidated Misc
|
||||
And the temporary connection should be closed afterward
|
||||
|
||||
|
||||
Scenario: get_current_revision uses check_same_thread=False for SQLite engines
|
||||
Given a migration runner configured for "sqlite:///:memory:"
|
||||
When I request the current revision from the database
|
||||
Then the SQLite engine for get_current_revision should use check_same_thread=False
|
||||
|
||||
|
||||
Scenario: File-based SQLite database directory is created if missing
|
||||
Given a migration runner configured for "sqlite:///tmp/test-db/mydb.db"
|
||||
When I initialize or upgrade a file-based SQLite database
|
||||
|
||||
@@ -224,3 +224,23 @@ Feature: Project CLI command functions coverage
|
||||
When I invoke project-delete for "local/del-sf" with yes and short force flag
|
||||
Then the project cmd output should contain "deleted"
|
||||
And the project cmd should succeed
|
||||
|
||||
# ── switch command ───────────────────────────────────────────
|
||||
|
||||
Scenario: switch command changes active project
|
||||
Given a project "local/switch-a" is created in the commands DB
|
||||
When I invoke project-switch for "local/switch-a" default format
|
||||
Then the project cmd output should contain "switched"
|
||||
And the project cmd should succeed
|
||||
And the project context should be switched to "local/switch-a"
|
||||
|
||||
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" format "json"
|
||||
Then the project cmd output should contain "namespaced_name"
|
||||
And the project cmd should succeed
|
||||
And the project context should be switched to "local/switch-json"
|
||||
|
||||
Scenario: switch to nonexistent project fails
|
||||
When I invoke project-switch for "local/ghost-switch" default format
|
||||
Then the project cmd should fail
|
||||
|
||||
@@ -316,17 +316,6 @@ def step_then_temp_connection_closed(context) -> None:
|
||||
assert context.current_rev_fake_engine.connections[0].exit_called is True
|
||||
|
||||
|
||||
@then("the SQLite engine for get_current_revision should use check_same_thread=False")
|
||||
def step_then_get_current_revision_check_same_thread(context) -> None:
|
||||
_url, kwargs = context.current_rev_create_call
|
||||
assert "connect_args" in kwargs, (
|
||||
"Expected connect_args to be passed to create_engine for SQLite"
|
||||
)
|
||||
assert kwargs["connect_args"].get("check_same_thread") is False, (
|
||||
"Expected check_same_thread=False in connect_args for SQLite engine"
|
||||
)
|
||||
|
||||
|
||||
@when("I initialize or upgrade a file-based SQLite database")
|
||||
def step_when_init_file_based_sqlite(context) -> None:
|
||||
import shutil
|
||||
|
||||
@@ -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,40 @@ 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 that records set_current_project calls so tests
|
||||
# can assert the context was actually persisted.
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_svc = MagicMock()
|
||||
context._switch_calls: list[str] = []
|
||||
|
||||
def _track_set_current(name: str) -> None:
|
||||
context._switch_calls.append(name)
|
||||
|
||||
mock_svc.set_current_project = _track_set_current
|
||||
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:
|
||||
@@ -652,6 +677,39 @@ def step_invoke_delete_short_force(context: Any, name: str) -> None:
|
||||
_unpatch_project_mod()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Switch command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invoke project-switch for "{name}" default format')
|
||||
def step_invoke_switch(context: Any, name: str) -> None:
|
||||
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_switch import switch
|
||||
|
||||
_capture(context, switch, project=name, output_format=fmt)
|
||||
|
||||
|
||||
@then('the project context should be switched to "{name}"')
|
||||
def step_context_switched_to(context: Any, name: str) -> None:
|
||||
"""Assert that set_current_project was called with the expected project name.
|
||||
|
||||
This verifies the switch command actually persisted the context change
|
||||
via the service layer (CLI -> Service -> Repository boundary).
|
||||
"""
|
||||
calls = getattr(context, "_switch_calls", [])
|
||||
assert name in calls, (
|
||||
f"Expected set_current_project to be called with '{name}', "
|
||||
f"but recorded calls were: {calls}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -492,3 +492,42 @@ class ProjectService:
|
||||
)
|
||||
except Exception:
|
||||
_logger.warning("audit_emit_failed", event_type="ENTITY_DELETED")
|
||||
|
||||
def set_current_project(self, project_name: str) -> None:
|
||||
"""Set the current project context.
|
||||
|
||||
Writes the project name to .cleveragents/project.name file.
|
||||
|
||||
Args:
|
||||
project_name: The project name to set as current
|
||||
|
||||
Raises:
|
||||
FileSystemError: If unable to find or write to .cleveragents directory
|
||||
"""
|
||||
path = Path.cwd().resolve()
|
||||
env_root = os.getenv("CLEVERAGENTS_PROJECT_SEARCH_ROOT")
|
||||
search_root = (
|
||||
Path(env_root).resolve() if env_root else getattr(self, "search_root", None)
|
||||
)
|
||||
limit = search_root.resolve() if isinstance(search_root, Path) else None
|
||||
|
||||
while path != path.parent:
|
||||
if limit and not path.is_relative_to(limit):
|
||||
break
|
||||
if (path / ".cleveragents").exists():
|
||||
name_file = path / ".cleveragents" / "project.name"
|
||||
try:
|
||||
name_file.write_text(project_name.strip())
|
||||
return
|
||||
except Exception as e:
|
||||
raise FileSystemError(
|
||||
message=f"Failed to write project name: {e}",
|
||||
path=path,
|
||||
) from e
|
||||
path = path.parent
|
||||
|
||||
raise FileSystemError(
|
||||
message="No .cleveragents directory found in current or parent directories",
|
||||
path=Path.cwd(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
@@ -926,6 +927,11 @@ def show(
|
||||
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")
|
||||
def delete(
|
||||
name: Annotated[
|
||||
|
||||
@@ -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 <name>``, 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))
|
||||
@@ -154,13 +154,7 @@ class MigrationRunner:
|
||||
Returns:
|
||||
Current revision ID or None if no migrations have been applied
|
||||
"""
|
||||
if self.database_url.startswith("sqlite"):
|
||||
engine = create_engine(
|
||||
self.database_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
else:
|
||||
engine = create_engine(self.database_url)
|
||||
engine = create_engine(self.database_url)
|
||||
with engine.connect() as connection:
|
||||
context = MigrationContext.configure(connection)
|
||||
return context.get_current_revision()
|
||||
|
||||
Reference in New Issue
Block a user