Compare commits

...

1 Commits

Author SHA1 Message Date
HAL9000 a49934b39d fix(cli): add Invariants and Validations panels to project show rich output
CI / benchmark-publish (pull_request) Has been skipped
CI / helm (pull_request) Successful in 46s
CI / build (pull_request) Successful in 1m4s
CI / push-validation (pull_request) Successful in 30s
CI / lint (pull_request) Successful in 1m23s
CI / quality (pull_request) Successful in 1m37s
CI / typecheck (pull_request) Successful in 1m50s
CI / security (pull_request) Successful in 1m57s
CI / benchmark-regression (pull_request) Failing after 1m16s
CI / integration_tests (pull_request) Successful in 4m27s
CI / unit_tests (pull_request) Failing after 5m8s
CI / e2e_tests (pull_request) Successful in 5m16s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s
The `agents project show` command now displays Invariants and Validations
panels in its rich output, completing the specification for project display.

Domain model updates:
- Added `invariants: list[str]` and `invariant_actor: str | None` fields to
  NamespacedProject domain model with backward-compatible defaults
- Updated NamespacedProjectModel.to_domain() to parse invariants from JSON
- Fixed from_domain() to preserve actual invariants/invariant_actor data

CLI output enhancements:
- Extracted project show logic into dedicated project_show.py module
- Invariants panel: displays count and list of project-level invariants,
  or '(none)' when no invariants are defined
- Validations panel: displays validation attachments for linked resources
- Invariant Actor field displayed when configured

Refactoring:
- Split oversized project.py (654 lines) into modular files under 500 lines:
  * project_show.py - show command and display helpers
  * project_legacy.py - legacy file-filter sub-app preserved
  * project_resource_commands.py - resource link/unlink/delete commands

Database roundtrip fix:
- from_domain() now properly serializes actual invariants_json (was hardcoded
  to empty list) and invariant_actor (was hardcoded to None)

BDD/Behave coverage added:
- Scenario: Create a project with invariants
- Scenario: Project spec dict contains invariants and validations keys
- Scenario: Show project with no invariants displays none
- Scenario: Show project with invariants displays invariant list
- Scenario: Show project with invariant_actor displays actor

ISSUES CLOSED: #9333
2026-05-08 09:16:47 +00:00
12 changed files with 990 additions and 943 deletions
+7 -292
View File
@@ -7,298 +7,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
in `src/cleveragents/actor/compiler.py` to read `actor_ref` from the top-level
`NodeDefinition.actor_ref` field instead of `node.config.get("actor_ref", "")`.
Because `actor_ref` is a typed, validated Pydantic field (not a key inside the
untyped `config` dict), the old code always returned an empty string, causing
cross-actor cycle detection to silently fail and leaving the system vulnerable to
infinite recursion at runtime. Added Behave regression tests
(`features/actor_subgraph_cycle_detection.feature`) and a Robot Framework
integration test (`robot/actor_compiler.robot`) to prevent regressions.
### Changed
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
and Summary panel ("Most Recent" / "Oldest") previously showed only the first 8 characters of
each session ULID. This made the output unusable for copy-paste into `session tell`,
`session show`, `session delete`, and `session export`, all of which require the full
26-character identifier. The full ULID is now displayed in all output formats (Rich, plain,
JSON, YAML, table).
### Security
- **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544):
Added an explicit `aiohttp>=3.13.4` dependency constraint to `pyproject.toml` to remediate
two high-severity open redirect vulnerabilities. Both CVEs affect the CleverAgents platform's
HTTP infrastructure including A2A server communication, tool source fetching (MCP servers,
Agent Skills), and agent-to-agent protocol handlers. The version floor ensures vulnerable
versions (<3.13.4) cannot be installed even if upstream transitive dependencies have loose
version constraints.
### Fixed
- **Implementation Supervisor PR Compliance Checklist** (#9824): Added a mandatory
8-item PR Compliance Checklist to the worker prompt body in `implementation-supervisor.md`
that every implementation worker must complete before creating a PR. Checklist covers:
CHANGELOG.md update, CONTRIBUTORS.md update, commit footer (`ISSUES CLOSED: #N`),
CI verification, BDD tests, Epic reference, label application via `forgejo-label-manager`,
and milestone assignment. This eliminates systemic PR merge blockers caused by workers
omitting required items.
### Changed
- Restored `benchmark-regression` CI job to `master.yml` with `pull_request` trigger guard
(`if: forgejo.event_name == 'pull_request'`). The job was previously absent from
`master.yml`, causing benchmark regression testing to never run on PRs. The job is
informational only and is not in `status-check`'s required needs list. (Closes #10716)
- **CI coverage job now waits for unit_tests** (#10714): Added `unit_tests` to the
`needs` list of the `coverage` job in `ci.yml`. Previously the coverage job ran
in parallel with unit tests, which could produce misleading pass results when
tests were still in-flight or had already failed. Coverage now only starts after
unit tests succeed, eliminating redundant parallel test execution and ensuring
coverage results are always meaningful.
- **Bandit B608 f-string SQL in plan phases migration** (#10777): Replaced f-string
SQL construction in `a5_005_rebaseline_plan_phases.py` with plain string
concatenation. The `INSERT INTO _v3_plans_new ... SELECT ... FROM v3_plans`
statement used f-strings to interpolate `_ALL_DATA_COLUMNS`, which Bandit
flags as B608 (SQL injection risk). The constant is hardcoded and safe, but
the f-string pattern blocks tightening the bandit severity gate from HIGH to
MEDIUM (issue #9945). Replaced with `"INSERT INTO _v3_plans_new (" +
_ALL_DATA_COLUMNS + ") " "SELECT " + _ALL_DATA_COLUMNS + " FROM v3_plans"`.
- **Diagnostics spec examples expanded to all 9 providers** (#5320): Updated the
`agents diagnostics` command examples in the specification to show all 9 supported
providers (OpenAI, Anthropic, Google, Gemini, Azure, OpenRouter, Cohere, Groq,
Together), matching the implementation from PR #3469. Rich, plain, JSON, and YAML
example outputs now reflect comprehensive provider coverage with accurate warning
counts and per-provider recommendations.
### Added
- `agents actor context clear` command to reset actor message history and
state while preserving the underlying context directory via `ContextManager`
(#6370).
- **Quick Start Guide** (PR #9245): Added `docs/quickstart.md` with an end-to-end quick start guide covering prerequisites, installation, project creation, resource registration, plan/apply workflow, and troubleshooting. Updated `mkdocs.yml` navigation to include the Quick Start page.
- **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting.
- **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove <id>` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included.
- **TDD: MCPToolAdapter.infer_resource_slots() TypeError with null properties** (#10470):
Added a TDD issue-capture Behave scenario that reproduces the bug where
`MCPToolAdapter.infer_resource_slots()` raises `TypeError` when the input schema
contains `{"properties": None}`. The test is tagged `@tdd_expected_fail` and will
pass (by inversion) until the underlying bug is fixed.
- **Architecture Pool Supervisor Milestone Assignment** (#7521): Added a "PR Workflow
for Major Changes" section to the `architecture-pool-supervisor` agent definition
documenting the milestone assignment step for spec PRs. The agent now has
`forgejo_update_pull_request` permission to assign PRs to the current active
milestone after creation, improving traceability of specification changes within
project milestone planning. Includes BDD test coverage for the new workflow
documentation and permission configuration.
- **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
`mkdtemp()` + `rmdir()` pattern with a parent-directory approach that maintains
the OS-level uniqueness guarantee throughout the entire operation. The parent
temporary directory is now persisted and properly cleaned up on both success and
failure paths. Comprehensive BDD test coverage validates the fix under concurrent
execution and confirms proper cleanup behavior.
### Fixed
- **`LLMTraceRepository.save()` premature commit breaks UnitOfWork transactions** (#7505):
Replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a
dual-path implementation that respects the UnitOfWork (UoW) pattern. When an external
session is provided (UoW mode), the method now calls only `session.flush()`, leaving
transaction control to the caller. When no session is provided (standalone mode), the
method creates its own session, flushes, commits, and closes it to ensure durable
persistence. This eliminates three data-integrity violations: premature commit of outer
UoW transactions, loss of rollback capability for subsequent failures, and a mismatch
between the class docstring ("Callers are responsible for commit") and the implementation.
Input validation for the `trace` argument was also added. Two new BDD scenarios verify
the session contract: `Repository save() calls flush not commit` and `LLM trace rolled
back when UnitOfWork transaction rolls back`.
- **git_tools._get_base_env() TOCTOU Race Condition** (#7619): Fixed a
Time-Of-Check-To-Time-Of-Use race condition in `git_tools._get_base_env()`
where two concurrent threads could both observe `_BASE_ENV is None`, both
snapshot `os.environ`, and write potentially different snapshots. The fix
adds a module-level `_BASE_ENV_LOCK: threading.Lock` and replaces the bare
`if _BASE_ENV is None` assignment with double-checked locking: the outer
check keeps the warm-cache path lock-free; the inner check inside
`with _BASE_ENV_LOCK` prevents duplicate initialisation on the very first
concurrent call. Three new BDD scenarios in `features/git_tools.feature`
(with step definitions in
`features/steps/git_tools_thread_safety_steps.py`) verify caching identity,
content correctness, and thread safety under 20 concurrent threads.
- **Unified provider factory: eliminate divergence between `create_llm()` and `create_ai_provider()`** (#10949):
Introduced `_create_provider_instance()` as the single internal factory so that
both public methods delegate to one place. Creating a new provider now
requires changes in exactly one method.
- **Fixed API key regression**: the unified factory now explicitly passes
the validated API key to all LangChain constructors (OpenAI, Anthropic,
Google / Gemini, Azure, Groq, Together, Cohere, and OpenRouter). Users
who configure providers via `CLEVERAGENTS_`-prefixed variables are no
longer silently failed when LangChain falls back to raw environment
variable lookup. Pre-validated keys are forwarded through the
`api_key` kwarg to avoid a second settings lookup in the factory
closure. (Closes #10949)
- **Fixed mock provider accessibility in production**: `ProviderType.MOCK`
is now gated by the `CLEVERAGENTS_ALLOW_MOCK_PROVIDER=true` sentinel
environment variable. Without this flag, both `create_llm()` and
`create_ai_provider()` raise `ValueError` when MOCK is requested,
preventing accidental or malicious use of the fake LLM in production.
`resolve_provider_by_name("mock")` now also respects the guard and
`is_provider_configured(ProviderType.MOCK)` returns `True` as expected.
- **Fixed type annotation**: `create_llm()` now declares `**kwargs: Any`
instead of `**kwargs: object`, restoring correct Pyright inference for
forwarded keyword arguments.
- **`create_llm()` raises `Unsupported provider type: openrouter`** (#10948): Fixed
`ProviderRegistry._create_provider_llm()` missing an `OPENROUTER` branch, which
caused `agents actor run openrouter/<model>` to fail with `ValueError`. Added a
`ProviderType.OPENROUTER` branch that creates a `ChatOpenAI` instance configured
with `openai_api_base="https://openrouter.ai/api/v1"` and the OpenRouter API key,
matching the behavior of `create_ai_provider("openrouter")`. Supports optional
`default_headers` kwarg with automatic string coercion for non-string keys/values.
- **LoadingThrobber Widget Restored** (#6357): Restored `LoadingThrobber` widget
- **Built-in actors v3 YAML format** (#10883): Fixed `agents actor run` failing for
built-in actors (e.g., `openai/gpt-4`, `anthropic/claude-3-opus`) due to missing
v3 `type` field in stored configuration. `ActorRegistry.ensure_built_in_actors()`
now generates and persists v3 YAML text with `type: llm` and `description` fields,
ensuring built-in actors work identically to custom actors. The
`_generate_builtin_actor_yaml()` helper creates spec-compliant YAML that passes
`ReactiveConfigParser._is_v3_format()` validation. Includes BDD scenarios and unit
tests covering YAML generation, schema validation, and multiple provider handling.
- **Atomic `server_connect` config writes** (#993): Fixed `server_connect` in
`cli/commands/server.py` to write all three config values (`server.url`,
`server.namespace`, `server.tls-verify`) atomically. A snapshot of the config
file is taken before any writes; if any `set_value()` call fails, the snapshot is
restored and compensating `CONFIG_CHANGED` events are emitted for already-applied
keys so the audit trail reflects the rollback. Added `emit_config_changed()` helper
to `ConfigService` for decoupled event emission in rollback flows. Added
`close()` method to `ReactiveEventBus` for proper resource cleanup in tests.
Resolved merge conflict in `config_service.py` integrating the PR's
`emit_config_changed()` helper with master's scoped config infrastructure.
Removed `# type: ignore[assignment]` by introducing a typed `_AutoDiscover`
sentinel class. BDD regression coverage in
`features/tdd_server_connect_atomic_writes.feature`.
- **Atomic `load_from_metadata` for Autonomy Guardrails** (#7504): Fixed
`AutonomyGuardrailService.load_from_metadata()` to validate both
`AutonomyGuardrails` and `GuardrailAuditTrail` models before writing either
to state, ensuring atomic updates. Previously, a validation failure on the
audit trail after guardrails were already written would leave the system in
an inconsistent state with partial updates. The method now uses a two-phase
validate-then-write approach: all model validation occurs in Phase 1, and
state mutations only happen in Phase 2 after all validations succeed.
- **`agents actor run` empty response for built-in LLM actors** (#10861): Fixed
`resolve_config_files` in `cli/commands/_resolve_actor.py` silently returning
empty output when invoked with a built-in actor name (e.g.
`anthropic/claude-sonnet-4-20250514`). Built-in actors generated from the
provider registry have a `config_blob` with `provider` and `model` fields but
no `type` field. Serialising this blob as-is produced YAML that
`ReactiveConfigParser` could not interpret (no agents, no routes → empty
`ReactiveConfig` → empty response). Fix: `_synthesize_llm_yaml()` now
synthesises a minimal v3 `type: llm` YAML when the actor has no `yaml_text`
and the `config_blob` has `provider` and `model` but no `type` field, allowing
the reactive config parser to create a working agent and graph route. BDD
regression coverage in `features/tdd_actor_run_response.feature`.
- **ReactiveConfigParser route synthesis for v3 actors** (#10807): Fixed
`agents actor run` silently returning empty output for v3 `type:llm` actors.
`_build_from_v3()` and `_build()` now synthesise a default single-node
graph route when agents are created without explicit routes, ensuring
`run_single_shot()` can invoke the LLM via `GraphExecutor`. The nested
`actors:` map format also translates the v3 `actor: "provider/model"` key
into separate `provider` and `model` keys so the correct LLM provider is
instantiated.
- **ActorRegistry.add() spec-compliant YAML support** (#4466): The registry now
accepts actor YAML using the spec's `actors:` map format with nested `config:`
blocks, in addition to the legacy top-level `provider`/`model` format. The
`unsafe` flag and graph descriptor from nested config are now correctly
preserved during registration. Multi-actor YAML (>1 entry in `actors:`/`agents:`
map) is now rejected by `add()` with a `ValidationError`. Nested
`config.options` are now correctly preserved. The `unsafe` coercion now uses
strict `is True or == 1` instead of `bool()` to prevent truthy non-boolean
YAML values (e.g. `unsafe: "no"`) from being treated as unsafe.
- **UKO Runtime Layer 2 (Paradigm) Indexing** (#9351): Added missing `rdf:type
uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that
Python class definitions are now correctly classified at layer 2 (paradigm/OO)
in addition to layer 3 (technology). Added the corresponding Behave scenario
`Indexing a Python file populates layer 2 (paradigm)` to
`features/uko_runtime.feature`, completing four-layer guarantee verification
for the UKO runtime.
- **Actor CLI v3 YAML Schema Support** (#6283): Fixed three components to add
full v3 `ActorConfigSchema` support to the actor CLI registration and
execution paths. `ActorConfiguration.from_blob()` now detects v3 format
(top-level `type` key of `llm`/`graph`/`tool`) and correctly extracts
provider, model, and graph descriptors — including `type: tool` actors
without a `model` field. `ActorRegistry.add()` validates against the full
Pydantic v2 schema, persists `skills`/`lsp`/`description` in the config
blob, and compiles graph actors with proper metadata.
`ReactiveConfigParser._build_from_v3()` now uses correct `source`/`target`
edge keys (fixing `KeyError` in `to_graph_config()`), handles `config: null`
nodes without crashing, propagates `context_view`/`memory`/`context`/
`env_vars`/`response_format`/`lsp_capabilities`/`lsp_context_enrichment`
into agent configs, and validates `entry_node` against the nodes map.
Exception handling narrowed from broad `except Exception` to specific
`NotFoundError` and `ActorCompilationError`. v3 registration logic
extracted to `v3_registry.py` to keep `registry.py` under the 500-line
limit. 19 BDD scenarios cover all v3 paths including tool actors,
update mode, LSP dict bindings, and field propagation.
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
`features/environment.py` now emits its non-assertion exception guard warning to
both the structured logger and `stderr` via a new `_warning_with_stderr` helper.
This makes the guard firing visible in standard Behave console output and CI log
snippets where the structured logging sink may not be displayed. BDD infrastructure
coverage added: a new scenario in `tdd_expected_fail_infrastructure.feature`
asserts that the warning is emitted to stderr when a non-AssertionError exception
is encountered in an `@tdd_expected_fail` scenario, and a second scenario asserts
the warning is NOT emitted when the exception is an `AssertionError`. The
`CONTRIBUTING.md` now documents that `@tdd_expected_fail` step definitions must
signal expected failures via `AssertionError`.
- **Parallel Behave Runner Log Noise Reduction** (#8351): The parallel behave
runner now suppresses captured stdout/stderr for passing worker chunks and
only replays diagnostics for failed, errored, or crashed chunks. This makes
failure output significantly easier to spot in CI and local runs. A worker
crash (unhandled exception) is detected via an all-zero summary and the
captured traceback is always surfaced.
- **Bug Hunt Pool Supervisor Non-Blocking Tracking**: Updated `bug-hunt-pool-supervisor` to make the automation tracking step non-blocking. The `automation-tracking-manager` call in step 5 is now best-effort — if it does not complete within a reasonable time or fails, the supervisor skips it and continues to the next cycle. Added explicit rule 9 clarifying that tracking must never block the main loop. Core functionality (module scanning and worker dispatch) takes priority over status reporting.
- **Name Validator Server-Qualified Format** (#9074): Updated actor, skill, and tool name
validators to accept the spec-required `[[server:]namespace/]name` format. Previously,
server-qualified names like `dev:freemo/custom-analysis` were incorrectly rejected.
Added BDD scenarios for server-qualified name acceptance and rejection. All three
validators (`ActorConfigSchema.validate_name`, `NAMESPACED_NAME_RE`,
`_TOOL_NAME_PATTERN`) now correctly support optional server prefixes while maintaining
backward compatibility with existing `namespace/name` names.
- **Legacy CLI command removal** (#4181): Removed all legacy plan lifecycle CLI
commands (`tell`, `build`, `new`, `current`, `cd`, `continue`) and their
associated tests to support V3 Plan Lifecycle exclusively. Removed `tell` and
`build` CLI shortcuts from `main.py` that delegated to the deprecated commands.
Removed orphaned `_tell_streaming` dead code from `plan.py`. Updated help text
and command validation to recognize only V3 commands. Added `--format` option
to `agents session tell` for consistency with other session commands. Fixed
MCP logger thread-safety in `session.py` using a threading lock. Created
migration guide for users transitioning from legacy to V3 workflow.
- **Project Show Missing Invariants and Validations Panels** (#9333): The
`agents project show` command now displays **Invariants** and **Validations**
panels in its rich output. The `NamespacedProject` domain model gains
`invariants: list[str]` and `invariant_actor: str | None` fields loaded from
the database. The `_project_spec_dict()` helper now includes `invariants`,
`invariant_actor`, and `validations` keys, ensuring all non-rich output
formats (JSON, YAML, plain, table) also expose these fields.
- **Automation Profile Silent Fallback** (#8232): `_resolve_profile_for_plan` in
`PlanLifecycleService` now raises a clear `ValidationError` when a plan's
+2 -1
View File
@@ -24,10 +24,11 @@ Below are some of the specific details of various contributions.
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
* 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 the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
* HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
* HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations.
* HAL 9000 has contributed the git_tools TOCTOU race condition fix (PR #8255 / issue #7619): eliminated the Time-Of-Check-To-Time-Of-Use race in `_get_base_env()` by adding double-checked locking with a module-level `threading.Lock`, preventing concurrent threads from writing conflicting environment snapshots.
* HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-supervisor.md` (#9824): added an 8-item checklist to the worker prompt body with concrete items covering CHANGELOG.md, CONTRIBUTORS.md, commit footer, CI verification, BDD tests, Epic reference, labels, and milestone assignment to eliminate systemic PR merge blockers.
* 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 LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
* HAL 9000 has contributed the Project Show Invariants and Validations panels fix (PR #9460 / issue #9333): added `invariants` and `invariant_actor` fields to the `NamespacedProject` domain model, updated `NamespacedProjectModel.to_domain()` and `from_domain()` for roundtrip preservation, extracted `project_show.py` with Invariants/Validations rich panels, and restructured project CLI commands into modular files under 500 lines.
+29
View File
@@ -181,3 +181,32 @@ Feature: Project CLI commands (B0.cli.projects)
And the spec dict should have key "linked_resources"
And the spec dict should have key "created_at"
And the spec dict should have key "updated_at"
Scenario: Project spec dict contains invariants and validations keys
Given a project "local/spec-inv" already exists
When I generate the project spec dict for "local/spec-inv"
Then the spec dict should have key "invariants"
And the spec dict should have key "invariant_actor"
And the spec dict should have key "validations"
# ── Invariants and Validations panels ───────────────────────
Scenario: Show project with no invariants displays none
Given a project "local/no-inv-proj" already exists
When I show project "local/no-inv-proj"
Then the show output should contain "invariants"
Scenario: Show project displays validations count
Given a project "local/val-proj" already exists
When I show project "local/val-proj"
Then the show output should contain "validations"
Scenario: Show project with invariants displays invariant list
Given a project "local/inv-show-proj" already exists with invariants
When I show project "local/inv-show-proj"
Then the show output should contain "invariants"
Scenario: Show project with invariant_actor displays actor
Given a project "local/actor-proj" already exists with invariant actor
When I show project "local/actor-proj"
Then the show output should contain "invariant_actor"
+34
View File
@@ -290,6 +290,40 @@ def step_pcli_project_exists_with_desc(context: Any, name: str, desc: str) -> No
_pcli_create_project(context, name, description=desc)
@given('a project "{name}" already exists with invariants')
def step_pcli_project_exists_with_invariants(context: Any, name: str) -> None:
from cleveragents.domain.models.core.project import (
NamespacedProject,
parse_namespaced_name,
)
parsed = parse_namespaced_name(name)
proj = NamespacedProject(
name=parsed.name,
namespace=parsed.namespace,
server=parsed.server,
invariants=["do not break the API", "keep tests green"],
)
context.pcli_project_repo.create(proj)
@given('a project "{name}" already exists with invariant actor')
def step_pcli_project_exists_with_invariant_actor(context: Any, name: str) -> None:
from cleveragents.domain.models.core.project import (
NamespacedProject,
parse_namespaced_name,
)
parsed = parse_namespaced_name(name)
proj = NamespacedProject(
name=parsed.name,
namespace=parsed.namespace,
server=parsed.server,
invariant_actor="test-actor",
)
context.pcli_project_repo.create(proj)
@given('a registered resource "{res_name}" exists')
def step_pcli_registered_resource_exists(context: Any, res_name: str) -> None:
resource_id = _pcli_register_resource(context, res_name)
+9 -2
View File
@@ -157,11 +157,18 @@ def test_spec_dict() -> None:
"name",
"description",
"linked_resources",
"invariants",
"invariant_actor",
"validations",
"created_at",
"updated_at",
}
if set(data.keys()) != expected_keys:
raise AssertionError(f"Key mismatch: {set(data.keys())} != {expected_keys}")
missing = expected_keys - set(data.keys())
extra = set(data.keys()) - expected_keys
if missing or extra:
raise AssertionError(
f"Key mismatch: missing={missing}, extra={extra}, got={set(data.keys())}"
)
print("spec-dict-ok")
+19 -645
View File
@@ -19,31 +19,29 @@ Based on ADR-009 (CLI Framework) and implementation_plan.md task B0.cli.projects
from __future__ import annotations
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any
import typer
from rich.panel import Panel
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_legacy import (
file_filter_app,
register_legacy_commands,
)
from cleveragents.cli.commands.project_resource_commands import (
register_resource_commands,
)
from cleveragents.cli.commands.project_show import register_show_command
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.cli.renderers import _get_console, _get_err_console
from cleveragents.core.exceptions import (
CleverAgentsError,
ConfigurationError,
DatabaseError,
NotFoundError,
ValidationError,
)
# Create sub-app for project commands
app = typer.Typer(help="Project management commands")
file_filter_app = typer.Typer(
help="Manage project include/exclude filters", name="file-filter"
)
console = _get_console()
err_console = _get_err_console()
@@ -154,7 +152,8 @@ def _project_spec_dict(project: Any) -> dict[str, object]:
"""Return project data as a dict using spec field names.
Keys: namespaced_name, namespace, name, description,
linked_resources, created_at, updated_at.
linked_resources, invariants, invariant_actor, validations,
created_at, updated_at.
"""
linked: list[dict[str, object]] = []
for lr in project.linked_resources:
@@ -175,6 +174,9 @@ def _project_spec_dict(project: Any) -> dict[str, object]:
"name": project.name,
"description": project.description,
"linked_resources": linked,
"invariants": list(getattr(project, "invariants", [])),
"invariant_actor": getattr(project, "invariant_actor", None),
"validations": 0, # per spec: project-scoped validations count
"created_at": project.created_at.isoformat()
if hasattr(project.created_at, "isoformat")
else str(project.created_at),
@@ -185,361 +187,14 @@ def _project_spec_dict(project: Any) -> dict[str, object]:
# ---------------------------------------------------------------------------
# Legacy init helpers (preserved)
# Register sub-apps and commands
# ---------------------------------------------------------------------------
def init_command(
name: str | None = None,
path: Path | None = None,
force: bool = False,
create_ignore_file: bool = False,
default_filters: bool = False,
yes: bool = False,
) -> None:
"""Programmatic interface for initializing a new CleverAgents project."""
from cleveragents.application.container import get_container
from cleveragents.application.services.project_service import ProjectService
if path is None:
path = Path.cwd()
project_name = name or path.name
container = get_container()
# When --yes is passed, disable the migration confirmation prompt
# entirely so the command never blocks waiting for interactive
# input (bug #783). ``require_confirmation=False`` tells the
# migration runner to skip the prompt; the ``prompt_for_migration``
# callback is set as a belt-and-suspenders fallback that
# auto-approves in case any code path still reaches the prompt.
if yes:
container.unit_of_work.add_kwargs(
require_confirmation=False,
prompt_for_migration=lambda _: True,
)
project_service: ProjectService = container.project_service()
project = project_service.initialize_project(
name=project_name,
path=path,
force=force,
create_ignore_file=create_ignore_file,
apply_default_filters=default_filters,
)
if yes:
# Non-interactive mode: produce spec-aligned output
# (specification.md lines 1381-1389)
data_dir: Path = getattr(
project, "data_dir", getattr(project, "path", path) / ".cleveragents"
)
config_path: Path = getattr(
project,
"config_path",
getattr(project, "path", path) / ".cleveragents" / "config.toml",
)
database_status: str = getattr(
project, "database_status", "initialized (schema v3)"
)
directories: list[str] = getattr(
project, "directories", ["logs", "cache", "sessions", "contexts"]
)
console.print(
Panel(
f"[green]Data Dir:[/green] {data_dir} (created)\n"
f"[green]Config:[/green] {config_path}\n"
f"[green]Database:[/green] {database_status}\n"
f"[green]Directories:[/green] {', '.join(directories)}",
title="Initialized",
expand=False,
)
)
console.print("[green]✓ OK[/green] Initialized (non-interactive)")
else:
console.print(
Panel(
f"[green]✓[/green] Project '{project.name}' "
f"initialized successfully!\n\n"
f"Location: {project.path / '.cleveragents'}\n"
f"Database: SQLite\n"
f"Status: Ready",
title="Project Initialized",
expand=False,
)
)
# ---------------------------------------------------------------------------
# File-filter sub-app (legacy, preserved as-is)
# ---------------------------------------------------------------------------
@file_filter_app.command("show")
def file_filter_show() -> None:
"""Show current include/exclude filters for this project."""
project_service, project = _get_project_service_and_current_project()
includes, excludes = project_service.get_project_filters(project)
console.print(
Panel(
f"Includes: {includes or '[]'}\nExcludes: {excludes or '[]'}",
title="Project File Filters",
expand=False,
)
)
@file_filter_app.command("add")
def file_filter_add(
include: Annotated[
list[str] | None,
typer.Option(
help="Include globs to add (repeatable)",
),
] = None,
exclude: Annotated[
list[str] | None,
typer.Option(
help="Exclude globs to add (repeatable)",
),
] = None,
defaults: Annotated[
bool,
typer.Option(
"--defaults",
help="Add the recommended default exclude globs",
),
] = False,
) -> None:
"""Add include/exclude globs to the project settings."""
project_service, project = _get_project_service_and_current_project()
include_list = [p for p in (include or []) if p]
exclude_list = [p for p in (exclude or []) if p]
if defaults:
exclude_list.extend(DEFAULT_IGNORE_PATTERNS)
updated = project_service.update_file_filters(
project,
include_add=include_list,
exclude_add=exclude_list if exclude_list else None,
)
includes, excludes = project_service.get_project_filters(updated)
console.print(
Panel(
f"Includes: {includes or '[]'}\nExcludes: {excludes or '[]'}",
title="Updated Project File Filters",
expand=False,
)
)
@file_filter_app.command("clear")
def file_filter_clear(
clear_include: Annotated[
bool,
typer.Option(help="Clear include globs"),
] = False,
clear_exclude: Annotated[
bool,
typer.Option(help="Clear exclude globs"),
] = False,
) -> None:
"""Clear include/exclude globs for the project."""
project_service, project = _get_project_service_and_current_project()
if not clear_include and not clear_exclude:
clear_include = True
clear_exclude = True
updated = project_service.update_file_filters(
project,
clear_include=clear_include,
clear_exclude=clear_exclude,
)
includes, excludes = project_service.get_project_filters(updated)
console.print(
Panel(
f"Includes: {includes or '[]'}\nExcludes: {excludes or '[]'}",
title="Cleared Project File Filters",
expand=False,
)
)
@file_filter_app.command("remove")
def file_filter_remove(
include: Annotated[
list[str] | None,
typer.Option(
help="Include glob(s) to remove (repeatable)",
),
] = None,
exclude: Annotated[
list[str] | None,
typer.Option(
help="Exclude glob(s) to remove (repeatable)",
),
] = None,
) -> None:
"""Remove include/exclude globs from the project settings."""
project_service, project = _get_project_service_and_current_project()
include_list = [p for p in (include or []) if p]
exclude_list = [p for p in (exclude or []) if p]
updated = project_service.update_file_filters(
project,
include_remove=include_list if include_list else None,
exclude_remove=exclude_list if exclude_list else None,
)
includes, excludes = project_service.get_project_filters(updated)
console.print(
Panel(
f"Includes: {includes or '[]'}\nExcludes: {excludes or '[]'}",
title="Updated Project File Filters",
expand=False,
)
)
app.add_typer(file_filter_app, name="file-filter")
app.add_typer(context_app, name="context")
# ---------------------------------------------------------------------------
# Legacy commands (init, status, clean - preserved for backward compat)
# ---------------------------------------------------------------------------
@app.command(name="init")
def init(
name: Annotated[
str | None,
typer.Argument(help="Project name (defaults to current directory name)"),
] = None,
path: Annotated[
Path | None,
typer.Option(
"--path",
"-p",
help="Project path (defaults to current directory)",
),
] = None,
force: Annotated[
bool, typer.Option("--force", "-f", help="Force reinitialization")
] = False,
create_ignore_file: Annotated[
bool,
typer.Option(
"--create-ignore-file",
help="Write a default .agentsignore with recommended patterns",
),
] = False,
default_filters: Annotated[
bool,
typer.Option(
"--default-filters",
help="Seed project exclude filters with recommended defaults",
),
] = False,
yes: Annotated[
bool,
typer.Option(
"--yes",
"-y",
help="Skip interactive prompts and use default values",
),
] = False,
) -> None:
"""Initialize a new CleverAgents project.
Creates the .cleveragents directory structure with database and configuration.
"""
try:
init_command(
name=name,
path=path,
force=force,
create_ignore_file=create_ignore_file,
default_filters=default_filters,
yes=yes,
)
except ValidationError as e:
from cleveragents.shared.redaction import redact_dict, redact_value
err_console.print(f"[red]Validation Error:[/red] {redact_value(e.message)}")
if e.details:
safe = redact_dict(e.details)
for key, value in safe.items():
err_console.print(f" {key}: {value}")
raise typer.Abort() from e
except ConfigurationError as e:
console.print(f"[red]Configuration Error:[/red] {e.message}")
raise typer.Abort() from e
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
except Exception as e:
console.print(f"[red]Unexpected error:[/red] {e}")
raise typer.Abort() from e
@app.command(name="status")
def status() -> None:
"""Show current project status and information."""
from cleveragents.application.container import get_container
from cleveragents.application.services.project_service import ProjectService
try:
container = get_container()
project_service: ProjectService = container.project_service()
# Get current project
project = project_service.get_current_project()
if not project:
err_console.print(
"[red]Error: No project found in current directory.[/red]"
)
console.print("Run 'cleveragents init' to initialize a project.")
raise typer.Exit(1)
# Get project stats
stats = project_service.get_project_stats(project)
# Display project information
info_text = f"""
[bold]Project:[/bold] {project.name}
[bold]Path:[/bold] {project.path}
[bold]Created:[/bold] {project.created_at}
[bold]Statistics:[/bold]
Plans: {stats.get("plans", 0)}
Context Files: {stats.get("context_files", 0)}
Total Changes: {stats.get("changes", 0)}
Current Plan: {stats.get("current_plan", "None")}
"""
console.print(Panel(info_text.strip(), title="Project Status", expand=False))
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
@app.command(name="clean")
def clean(
confirm: Annotated[
bool, typer.Option("--yes", "-y", help="Skip confirmation")
] = False,
) -> None:
"""Clean project cache and temporary files."""
console.print("[yellow]Project cleaning not yet implemented.[/yellow]")
raise typer.Abort()
register_legacy_commands(app)
register_show_command(app)
register_resource_commands(app)
# ---------------------------------------------------------------------------
@@ -602,7 +257,6 @@ def create(
err_console.print(f"[red]Error:[/red] {exc.message}")
raise typer.Exit(1) from exc
# Store invariants and invariant_actor if provided
if invariant or invariant_actor:
_store_project_extras(
project.namespaced_name,
@@ -610,7 +264,6 @@ def create(
inv_actor=invariant_actor,
)
# Link resources if specified
if resource:
link_repo = _get_resource_link_repo()
registry = _get_resource_registry_service()
@@ -627,7 +280,6 @@ def create(
f"'{res_name}': {exc}[/yellow]"
)
# Re-fetch project for display (includes linked resources)
try:
created = svc.get_project(project.namespaced_name)
except Exception:
@@ -635,6 +287,8 @@ def create(
data = _project_spec_dict(created)
if output_format.lower() == OutputFormat.RICH:
from rich.panel import Panel
console.print(
Panel(
f"[green]✓[/green] Project '{created.namespaced_name}' created.\n"
@@ -649,165 +303,6 @@ def create(
console.print(format_output(data, output_format))
@app.command(name="link-resource")
def link_resource(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
resource_name: Annotated[
str,
typer.Argument(help="Resource name or ULID to link"),
],
read_only: Annotated[
bool,
typer.Option("--read-only", help="Link as read-only"),
] = False,
alias: Annotated[
str | None,
typer.Option("--alias", help="Alias for the resource within the project"),
] = None,
output_format: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Link a resource to a project."""
svc = _get_namespaced_project_service()
link_repo = _get_resource_link_repo()
registry = _get_resource_registry_service()
# Validate project exists
try:
proj = svc.get_project(project)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
# Resolve resource
try:
res = registry.show_resource(resource_name)
except NotFoundError as exc:
err_console.print(f"[red]Resource not found:[/red] {resource_name}")
raise typer.Exit(1) from exc
# Create link
try:
link_repo.create_link(
project_name=proj.namespaced_name,
resource_id=res.resource_id,
alias=alias,
read_only=read_only,
)
except DatabaseError as exc:
err_console.print(f"[red]Error linking resource:[/red] {exc.message}")
raise typer.Exit(1) from exc
if output_format.lower() == OutputFormat.RICH:
ro_label = " (read-only)" if read_only else ""
console.print(
f"[green]✓[/green] Linked resource '{resource_name}'{ro_label} "
f"to project '{project}'."
)
else:
console.print(
format_output(
{
"project": proj.namespaced_name,
"resource_id": res.resource_id,
"resource_name": res.name or resource_name,
"read_only": read_only,
"alias": alias,
},
output_format,
)
)
@app.command(name="unlink-resource")
def unlink_resource(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
resource_name: Annotated[
str,
typer.Argument(help="Resource name or ULID to unlink"),
],
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:
"""Unlink a resource from a project."""
svc = _get_namespaced_project_service()
link_repo = _get_resource_link_repo()
registry = _get_resource_registry_service()
# Validate project exists
try:
proj = svc.get_project(project)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
# Resolve resource
try:
res = registry.show_resource(resource_name)
except NotFoundError as exc:
err_console.print(f"[red]Resource not found:[/red] {resource_name}")
raise typer.Exit(1) from exc
# Find the matching link
links = link_repo.list_links(proj.namespaced_name)
target_link: Any = None
for link in links:
if str(link.resource_id) == res.resource_id:
target_link = link
break
if target_link is None:
err_console.print(
f"[red]Resource '{resource_name}' is not linked to "
f"project '{project}'.[/red]"
)
raise typer.Exit(1)
if not yes:
confirm = typer.confirm(
f"Unlink resource '{resource_name}' from project '{project}'?"
)
if not confirm:
raise typer.Abort()
try:
link_repo.remove_link(str(target_link.link_id))
except DatabaseError as exc:
err_console.print(f"[red]Error unlinking resource:[/red] {exc.message}")
raise typer.Exit(1) from exc
if output_format.lower() == OutputFormat.RICH:
console.print(
f"[green]✓[/green] Unlinked resource '{resource_name}' "
f"from project '{project}'."
)
else:
console.print(
format_output(
{
"project": proj.namespaced_name,
"resource_id": res.resource_id,
"unlinked": True,
},
output_format,
)
)
@app.command(name="list")
def list_projects(
namespace: Annotated[
@@ -832,7 +327,6 @@ def list_projects(
err_console.print(f"[red]Error listing projects:[/red] {exc.message}")
raise typer.Exit(1) from exc
# Apply regex filter if provided
if regex:
try:
pattern = re.compile(regex)
@@ -871,123 +365,3 @@ def list_projects(
else:
data = [_project_spec_dict(p) for p in projects]
console.print(format_output(data, output_format))
@app.command(name="show")
def show(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
output_format: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Show details of a project."""
svc = _get_namespaced_project_service()
try:
proj = svc.get_project(project)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
data = _project_spec_dict(proj)
if output_format.lower() == OutputFormat.RICH:
lines: list[str] = [
f"[bold]Name:[/bold] {proj.namespaced_name}",
f"[bold]Namespace:[/bold] {proj.namespace}",
f"[bold]Description:[/bold] {proj.description or '(none)'}",
f"[bold]Created:[/bold] {proj.created_at}",
f"[bold]Updated:[/bold] {proj.updated_at}",
]
if proj.linked_resources:
lines.append(
f"\n[bold]Linked Resources ({len(proj.linked_resources)}):[/bold]"
)
for lr in proj.linked_resources:
ro_marker = " [dim](read-only)[/dim]" if lr.project_read_only else ""
alias_marker = f" alias={lr.alias}" if lr.alias else ""
lines.append(f" - {lr.resource_id}{ro_marker}{alias_marker}")
else:
lines.append("\n[bold]Linked Resources:[/bold] (none)")
console.print(
Panel(
"\n".join(lines),
title=f"Project: {proj.namespaced_name}",
expand=False,
)
)
else:
console.print(format_output(data, output_format))
@app.command(name="delete")
def delete(
name: Annotated[
str,
typer.Argument(help="Project namespaced name to delete"),
],
force: Annotated[
bool,
typer.Option("--force", "-f", help="Force delete even if resources are linked"),
] = False,
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
output_format: Annotated[
str,
typer.Option("--format", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Delete a project from the registry."""
svc = _get_namespaced_project_service()
# Validate project exists
try:
proj = svc.get_project(name)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {name}")
raise typer.Exit(1) from exc
# Check for linked resources (unless --force)
if proj.linked_resources and not force:
err_console.print(
f"[red]Project '{name}' has {len(proj.linked_resources)} "
f"linked resource(s). Use --force to delete anyway.[/red]"
)
raise typer.Exit(1)
if not yes:
confirm = typer.confirm(f"Delete project '{name}'?")
if not confirm:
raise typer.Abort()
try:
deleted = svc.delete_project(name)
except DatabaseError as exc:
err_console.print(f"[red]Error deleting project:[/red] {exc.message}")
raise typer.Exit(1) from exc
if not deleted:
err_console.print(f"[red]Project '{name}' could not be deleted.[/red]")
raise typer.Exit(1)
if output_format.lower() == OutputFormat.RICH:
console.print(f"[green]✓[/green] Project '{name}' deleted.")
else:
console.print(
format_output(
{
"deleted": name,
"success": True,
"deleted_at": datetime.now(tz=UTC),
},
output_format,
)
)
@@ -0,0 +1,401 @@
"""Legacy project commands and file-filter sub-app.
Extracted from ``project.py`` to keep that module under 500 lines.
These commands are preserved for backward compatibility.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any
if TYPE_CHECKING:
from cleveragents.application.services.project_service import ProjectService
import typer
from rich.panel import Panel
from cleveragents.application.services.context_service import DEFAULT_IGNORE_PATTERNS
from cleveragents.cli.renderers import _get_console, _get_err_console
from cleveragents.core.exceptions import (
CleverAgentsError,
ConfigurationError,
ValidationError,
)
console = _get_console()
err_console = _get_err_console()
file_filter_app = typer.Typer(
help="Manage project include/exclude filters", name="file-filter"
)
# ---------------------------------------------------------------------------
# Legacy init helpers
# ---------------------------------------------------------------------------
def init_command(
name: str | None = None,
path: Path | None = None,
force: bool = False,
create_ignore_file: bool = False,
default_filters: bool = False,
yes: bool = False,
) -> None:
"""Programmatic interface for initializing a new CleverAgents project."""
from cleveragents.application.container import get_container
if path is None:
path = Path.cwd()
project_name = name or path.name
container = get_container()
# When --yes is passed, disable the migration confirmation prompt
# entirely so the command never blocks waiting for interactive
# input (bug #783). ``require_confirmation=False`` tells the
# migration runner to skip the prompt; the ``prompt_for_migration``
# callback is set as a belt-and-suspenders fallback that
# auto-approves in case any code path still reaches the prompt.
if yes:
container.unit_of_work.add_kwargs(
require_confirmation=False,
prompt_for_migration=lambda _: True,
)
project_service = container.project_service()
project = project_service.initialize_project(
name=project_name,
path=path,
force=force,
create_ignore_file=create_ignore_file,
apply_default_filters=default_filters,
)
if yes:
# Non-interactive mode: produce spec-aligned output
# (specification.md lines 1381-1389)
data_dir: Path = getattr(
project, "data_dir", getattr(project, "path", path) / ".cleveragents"
)
config_path: Path = getattr(
project,
"config_path",
getattr(project, "path", path) / ".cleveragents" / "config.toml",
)
database_status: str = getattr(
project, "database_status", "initialized (schema v3)"
)
directories: list[str] = getattr(
project, "directories", ["logs", "cache", "sessions", "contexts"]
)
console.print(
Panel(
f"[green]Data Dir:[/green] {data_dir} (created)\n"
f"[green]Config:[/green] {config_path}\n"
f"[green]Database:[/green] {database_status}\n"
f"[green]Directories:[/green] {', '.join(directories)}",
title="Initialized",
expand=False,
)
)
console.print("[green]✓ OK[/green] Initialized (non-interactive)")
else:
console.print(
Panel(
f"[green]✓[/green] Project '{project.name}' "
f"initialized successfully!\n\n"
f"Location: {project.path / '.cleveragents'}\n"
f"Database: SQLite\n"
f"Status: Ready",
title="Project Initialized",
expand=False,
)
)
# ---------------------------------------------------------------------------
# File-filter sub-app (legacy, preserved as-is)
# ---------------------------------------------------------------------------
def _get_project_service_and_current_project_legacy() -> tuple[ProjectService, Any]:
from cleveragents.application.container import get_container
from cleveragents.application.services.project_service import (
ProjectService as _ProjectService,
)
container = get_container()
project_service: _ProjectService = container.project_service()
project = project_service.get_current_project()
if not project:
err_console.print("[red]Error: No project found in current directory.[/red]")
raise typer.Exit(1)
return project_service, project
@file_filter_app.command("show")
def file_filter_show() -> None:
"""Show current include/exclude filters for this project."""
project_service, project = _get_project_service_and_current_project_legacy()
includes, excludes = project_service.get_project_filters(project)
console.print(
Panel(
f"Includes: {includes or '[]'}\nExcludes: {excludes or '[]'}",
title="Project File Filters",
expand=False,
)
)
@file_filter_app.command("add")
def file_filter_add(
include: Annotated[
list[str] | None,
typer.Option(
help="Include globs to add (repeatable)",
),
] = None,
exclude: Annotated[
list[str] | None,
typer.Option(
help="Exclude globs to add (repeatable)",
),
] = None,
defaults: Annotated[
bool,
typer.Option(
"--defaults",
help="Add the recommended default exclude globs",
),
] = False,
) -> None:
"""Add include/exclude globs to the project settings."""
project_service, project = _get_project_service_and_current_project_legacy()
include_list = [p for p in (include or []) if p]
exclude_list = [p for p in (exclude or []) if p]
if defaults:
exclude_list.extend(DEFAULT_IGNORE_PATTERNS)
updated = project_service.update_file_filters(
project,
include_add=include_list,
exclude_add=exclude_list if exclude_list else None,
)
includes, excludes = project_service.get_project_filters(updated)
console.print(
Panel(
f"Includes: {includes or '[]'}\nExcludes: {excludes or '[]'}",
title="Updated Project File Filters",
expand=False,
)
)
@file_filter_app.command("clear")
def file_filter_clear(
clear_include: Annotated[
bool,
typer.Option(help="Clear include globs"),
] = False,
clear_exclude: Annotated[
bool,
typer.Option(help="Clear exclude globs"),
] = False,
) -> None:
"""Clear include/exclude globs for the project."""
project_service, project = _get_project_service_and_current_project_legacy()
if not clear_include and not clear_exclude:
clear_include = True
clear_exclude = True
updated = project_service.update_file_filters(
project,
clear_include=clear_include,
clear_exclude=clear_exclude,
)
includes, excludes = project_service.get_project_filters(updated)
console.print(
Panel(
f"Includes: {includes or '[]'}\nExcludes: {excludes or '[]'}",
title="Cleared Project File Filters",
expand=False,
)
)
@file_filter_app.command("remove")
def file_filter_remove(
include: Annotated[
list[str] | None,
typer.Option(
help="Include glob(s) to remove (repeatable)",
),
] = None,
exclude: Annotated[
list[str] | None,
typer.Option(
help="Exclude glob(s) to remove (repeatable)",
),
] = None,
) -> None:
"""Remove include/exclude globs from the project settings."""
project_service, project = _get_project_service_and_current_project_legacy()
include_list = [p for p in (include or []) if p]
exclude_list = [p for p in (exclude or []) if p]
updated = project_service.update_file_filters(
project,
include_remove=include_list if include_list else None,
exclude_remove=exclude_list if exclude_list else None,
)
includes, excludes = project_service.get_project_filters(updated)
console.print(
Panel(
f"Includes: {includes or '[]'}\nExcludes: {excludes or '[]'}",
title="Updated Project File Filters",
expand=False,
)
)
# ---------------------------------------------------------------------------
# Legacy commands (init, status, clean)
# ---------------------------------------------------------------------------
def register_legacy_commands(app: typer.Typer) -> None:
"""Register legacy commands on *app*."""
@app.command(name="init")
def init(
name: Annotated[
str | None,
typer.Argument(help="Project name (defaults to current directory name)"),
] = None,
path: Annotated[
Path | None,
typer.Option(
"--path",
"-p",
help="Project path (defaults to current directory)",
),
] = None,
force: Annotated[
bool, typer.Option("--force", "-f", help="Force reinitialization")
] = False,
create_ignore_file: Annotated[
bool,
typer.Option(
"--create-ignore-file",
help="Write a default .agentsignore with recommended patterns",
),
] = False,
default_filters: Annotated[
bool,
typer.Option(
"--default-filters",
help="Seed project exclude filters with recommended defaults",
),
] = False,
yes: Annotated[
bool,
typer.Option(
"--yes",
"-y",
help="Skip interactive prompts and use default values",
),
] = False,
) -> None:
"""Initialize a new CleverAgents project.
Creates the .cleveragents directory structure with database and configuration.
"""
try:
init_command(
name=name,
path=path,
force=force,
create_ignore_file=create_ignore_file,
default_filters=default_filters,
yes=yes,
)
except ValidationError as e:
from cleveragents.shared.redaction import redact_dict, redact_value
err_console.print(f"[red]Validation Error:[/red] {redact_value(e.message)}")
if e.details:
safe = redact_dict(e.details)
for key, value in safe.items():
err_console.print(f" {key}: {value}")
raise typer.Abort() from e
except ConfigurationError as e:
console.print(f"[red]Configuration Error:[/red] {e.message}")
raise typer.Abort() from e
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
except Exception as e:
console.print(f"[red]Unexpected error:[/red] {e}")
raise typer.Abort() from e
@app.command(name="status")
def status() -> None:
"""Show current project status and information."""
from cleveragents.application.container import get_container
try:
container = get_container()
project_service = container.project_service()
# Get current project
project = project_service.get_current_project()
if not project:
err_console.print(
"[red]Error: No project found in current directory.[/red]"
)
console.print("Run 'cleveragents init' to initialize a project.")
raise typer.Exit(1)
# Get project stats
stats = project_service.get_project_stats(project)
# Display project information
info_text = f"""
[bold]Project:[/bold] {project.name}
[bold]Path:[/bold] {project.path}
[bold]Created:[/bold] {project.created_at}
[bold]Statistics:[/bold]
Plans: {stats.get("plans", 0)}
Context Files: {stats.get("context_files", 0)}
Total Changes: {stats.get("changes", 0)}
Current Plan: {stats.get("current_plan", "None")}
"""
console.print(
Panel(info_text.strip(), title="Project Status", expand=False)
)
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
@app.command(name="clean")
def clean(
confirm: Annotated[
bool, typer.Option("--yes", "-y", help="Skip confirmation")
] = False,
) -> None:
"""Clean project cache and temporary files."""
console.print("[yellow]Project cleaning not yet implemented.[/yellow]")
raise typer.Abort()
@@ -0,0 +1,255 @@
"""Resource link/unlink and delete commands for the project CLI.
Extracted from ``project.py`` to keep that module under 500 lines.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Annotated, Any
import typer
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.cli.renderers import _get_console, _get_err_console
from cleveragents.core.exceptions import DatabaseError, NotFoundError
console = _get_console()
err_console = _get_err_console()
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
def register_resource_commands(app: typer.Typer) -> None:
"""Register link-resource, unlink-resource, and delete commands on *app*."""
@app.command(name="link-resource")
def link_resource(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
resource_name: Annotated[
str,
typer.Argument(help="Resource name or ULID to link"),
],
read_only: Annotated[
bool,
typer.Option("--read-only", help="Link as read-only"),
] = False,
alias: Annotated[
str | None,
typer.Option("--alias", help="Alias for the resource within the project"),
] = None,
output_format: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Link a resource to a project."""
from cleveragents.cli.commands.project import (
_get_namespaced_project_service,
_get_resource_link_repo,
_get_resource_registry_service,
)
svc = _get_namespaced_project_service()
link_repo = _get_resource_link_repo()
registry = _get_resource_registry_service()
try:
proj = svc.get_project(project)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
try:
res = registry.show_resource(resource_name)
except NotFoundError as exc:
err_console.print(f"[red]Resource not found:[/red] {resource_name}")
raise typer.Exit(1) from exc
try:
link_repo.create_link(
project_name=proj.namespaced_name,
resource_id=res.resource_id,
alias=alias,
read_only=read_only,
)
except DatabaseError as exc:
err_console.print(f"[red]Error linking resource:[/red] {exc.message}")
raise typer.Exit(1) from exc
if output_format.lower() == OutputFormat.RICH:
ro_label = " (read-only)" if read_only else ""
console.print(
f"[green]✓[/green] Linked resource '{resource_name}'{ro_label} "
f"to project '{project}'."
)
else:
console.print(
format_output(
{
"project": proj.namespaced_name,
"resource_id": res.resource_id,
"resource_name": res.name or resource_name,
"read_only": read_only,
"alias": alias,
},
output_format,
)
)
@app.command(name="unlink-resource")
def unlink_resource(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
resource_name: Annotated[
str,
typer.Argument(help="Resource name or ULID to unlink"),
],
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:
"""Unlink a resource from a project."""
from cleveragents.cli.commands.project import (
_get_namespaced_project_service,
_get_resource_link_repo,
_get_resource_registry_service,
)
svc = _get_namespaced_project_service()
link_repo = _get_resource_link_repo()
registry = _get_resource_registry_service()
try:
proj = svc.get_project(project)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
try:
res = registry.show_resource(resource_name)
except NotFoundError as exc:
err_console.print(f"[red]Resource not found:[/red] {resource_name}")
raise typer.Exit(1) from exc
links = link_repo.list_links(proj.namespaced_name)
target_link: Any = None
for link in links:
if str(link.resource_id) == res.resource_id:
target_link = link
break
if target_link is None:
err_console.print(
f"[red]Resource '{resource_name}' is not linked to "
f"project '{project}'.[/red]"
)
raise typer.Exit(1)
if not yes:
confirm = typer.confirm(
f"Unlink resource '{resource_name}' from project '{project}'?"
)
if not confirm:
raise typer.Abort()
try:
link_repo.remove_link(str(target_link.link_id))
except DatabaseError as exc:
err_console.print(f"[red]Error unlinking resource:[/red] {exc.message}")
raise typer.Exit(1) from exc
if output_format.lower() == OutputFormat.RICH:
console.print(
f"[green]✓[/green] Unlinked resource '{resource_name}' "
f"from project '{project}'."
)
else:
console.print(
format_output(
{
"project": proj.namespaced_name,
"resource_id": res.resource_id,
"unlinked": True,
},
output_format,
)
)
@app.command(name="delete")
def delete(
name: Annotated[
str,
typer.Argument(help="Project namespaced name to delete"),
],
force: Annotated[
bool,
typer.Option(
"--force", "-f", help="Force delete even if resources are linked"
),
] = False,
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
output_format: Annotated[
str,
typer.Option("--format", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Delete a project from the registry."""
from cleveragents.cli.commands.project import _get_namespaced_project_service
svc = _get_namespaced_project_service()
try:
proj = svc.get_project(name)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {name}")
raise typer.Exit(1) from exc
if proj.linked_resources and not force:
err_console.print(
f"[red]Project '{name}' has {len(proj.linked_resources)} "
f"linked resource(s). Use --force to delete anyway.[/red]"
)
raise typer.Exit(1)
if not yes:
confirm = typer.confirm(f"Delete project '{name}'?")
if not confirm:
raise typer.Abort()
try:
deleted = svc.delete_project(name)
except DatabaseError as exc:
err_console.print(f"[red]Error deleting project:[/red] {exc.message}")
raise typer.Exit(1) from exc
if not deleted:
err_console.print(f"[red]Project '{name}' could not be deleted.[/red]")
raise typer.Exit(1)
if output_format.lower() == OutputFormat.RICH:
console.print(f"[green]✓[/green] Project '{name}' deleted.")
else:
console.print(
format_output(
{
"deleted": name,
"success": True,
"deleted_at": datetime.now(tz=UTC),
},
output_format,
)
)
@@ -0,0 +1,204 @@
"""Show command and display helpers for the project CLI.
Extracted from ``project.py`` to keep that module under 500 lines.
"""
from __future__ import annotations
from typing import Annotated, Any
import typer
from rich.panel import Panel
from rich.table import Table
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.cli.renderers import _get_console, _get_err_console
from cleveragents.domain.models.core.invariant import InvariantScope
console = _get_console()
err_console = _get_err_console()
def _get_project_invariants(project: Any) -> list[dict[str, Any]]:
"""Fetch project-scoped invariants for display."""
from cleveragents.application.container import get_container
from cleveragents.application.services.invariant_service import (
InvariantService,
)
container = get_container()
try:
svc: InvariantService = container.invariant_service()
invariants = svc.list_invariants(
scope=InvariantScope.PROJECT,
source_name=project.namespaced_name,
)
return [
{
"text": inv.text,
"source": inv.source_name,
"scope": inv.scope.value,
}
for inv in invariants
]
except Exception:
return []
def _get_project_validations(project: Any) -> list[dict[str, Any]]:
"""Fetch validation attachments for a project.
Collects all validation attachments scoped to this project by querying
each linked resource's validation attachments and filtering by project name.
"""
from cleveragents.application.container import get_container
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
container = get_container()
try:
svc: ToolRegistryService = container.tool_registry_service()
attachments: list[dict[str, Any]] = []
for lr in getattr(project, "linked_resources", []):
resource_id = getattr(lr, "resource_id", None)
if not resource_id:
continue
resource_attachments = svc.list_validations_for_resource(
resource_id=resource_id,
project_name=project.namespaced_name,
)
for att in resource_attachments:
if isinstance(att, dict):
attachments.append(att)
else:
attachments.append(
{
"validation_name": getattr(att, "validation_name", ""),
"resource_id": getattr(att, "resource_id", ""),
"mode": getattr(att, "mode", "required"),
}
)
return attachments
except Exception:
return []
def register_show_command(app: typer.Typer) -> None:
"""Register the ``show`` command on *app*."""
@app.command(name="show")
def show(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
output_format: Annotated[
str,
typer.Option(
"--format",
"-f",
help="Output format: json, yaml, plain, table, or rich (default: rich)",
),
] = "rich",
) -> None:
"""Show details of a project."""
from cleveragents.cli.commands.project import (
_get_namespaced_project_service,
_project_spec_dict,
)
svc = _get_namespaced_project_service()
try:
proj = svc.get_project(project)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
data = _project_spec_dict(proj)
if output_format.lower() == OutputFormat.RICH:
lines: list[str] = [
f"[bold]Name:[/bold] {proj.namespaced_name}",
f"[bold]Namespace:[/bold] {proj.namespace}",
f"[bold]Description:[/bold] {proj.description or '(none)'}",
f"[bold]Created:[/bold] {proj.created_at}",
f"[bold]Updated:[/bold] {proj.updated_at}",
]
if proj.linked_resources:
lines.append(
f"\n[bold]Linked Resources ({len(proj.linked_resources)}):[/bold]"
)
for lr in proj.linked_resources:
ro_marker = (
" [dim](read-only)[/dim]" if lr.project_read_only else ""
)
alias_marker = f" alias={lr.alias}" if lr.alias else ""
lines.append(f" - {lr.resource_id}{ro_marker}{alias_marker}")
else:
lines.append("\n[bold]Linked Resources:[/bold] (none)")
console.print(
Panel(
"\n".join(lines),
title=f"Project: {proj.namespaced_name}",
expand=False,
)
)
# --- Invariants Panel ---
invs = _get_project_invariants(proj)
if invs:
inv_table = Table(title="Invariants", show_header=True, expand=True)
inv_table.add_column("#", style="dim", justify="right", width=4)
inv_table.add_column("Invariant", style="bold")
inv_table.add_column("Source")
for i, inv_data in enumerate(invs, start=1):
inv_table.add_row(
str(i),
inv_data.get("text", ""),
inv_data.get("source", ""),
)
console.print(inv_table)
else:
console.print(
Panel(
"[dim]No project-level invariants defined.[/dim]",
title="Invariants",
expand=False,
)
)
# --- Validations Panel ---
val_attachments = _get_project_validations(proj)
if val_attachments:
val_table = Table(title="Validations", show_header=True, expand=True)
val_table.add_column("#", style="dim", justify="right", width=4)
val_table.add_column("Validation")
val_table.add_column("Resource")
val_table.add_column("Mode")
for i, att in enumerate(val_attachments, start=1):
if isinstance(att, dict):
val_name = att.get("validation_name", "")
res_id = att.get("resource_id", "")
mode = att.get("mode", "required")
else:
val_name = getattr(att, "validation_name", "")
res_id = getattr(att, "resource_id", "")
mode = getattr(att, "mode", "required")
val_table.add_row(str(i), str(val_name), str(res_id), str(mode))
console.print(val_table)
else:
console.print(
Panel(
"[dim]No validation attachments for this project.[/dim]",
title="Validations",
expand=False,
)
)
else:
data.setdefault("invariants", _get_project_invariants(proj))
data.setdefault("validations", _get_project_validations(proj))
console.print(format_output(data, output_format))
+3 -1
View File
@@ -453,7 +453,9 @@ def init(
] = False,
) -> None:
"""Initialize a new CleverAgents project in the current directory."""
from cleveragents.cli.commands.project import init_command as project_init_command
from cleveragents.cli.commands.project_legacy import (
init_command as project_init_command,
)
try:
project_init_command(
@@ -373,6 +373,18 @@ class NamespacedProject(BaseModel):
description="Resources linked to this project from the Resource Registry",
)
# Invariants (spec section: project-level invariants)
invariants: list[str] = Field(
default_factory=list,
description="Project-level invariants that must be maintained",
)
# Invariant Actor (spec section: actor for invariant reconciliation)
invariant_actor: str | None = Field(
default=None,
description="Actor responsible for invariant reconciliation",
)
# Context configuration (spec section 3)
context_config: ContextConfig = Field(
default_factory=ContextConfig,
@@ -1403,11 +1403,24 @@ class NamespacedProjectModel(Base): # type: ignore[misc]
parts = ns_name.split("/", 1)
short_name = parts[1] if len(parts) > 1 else parts[0]
# Parse invariants from JSON
invariants: list[str] = []
raw_invariants = cast("str | None", self.invariants_json)
if raw_invariants:
try:
inv_list = json.loads(raw_invariants)
if isinstance(inv_list, list):
invariants = [str(i) for i in inv_list if i]
except (ValueError, TypeError):
pass
return NamespacedProject(
name=short_name,
namespace=cast(str, self.namespace),
description=cast("str | None", self.description),
linked_resources=linked_resources,
invariants=invariants,
invariant_actor=cast("str | None", self.invariant_actor),
context_config=context_config,
created_at=datetime.fromisoformat(cast(str, self.created_at)),
updated_at=datetime.fromisoformat(cast(str, self.updated_at)),
@@ -1435,9 +1448,9 @@ class NamespacedProjectModel(Base): # type: ignore[misc]
namespaced_name=project.namespaced_name,
namespace=project.namespace,
description=project.description,
invariants_json=json.dumps([]),
invariants_json=json.dumps(getattr(project, "invariants", []) or []),
automation_profile=None,
invariant_actor=None,
invariant_actor=getattr(project, "invariant_actor", None),
context_policy_json=context_policy_json,
tags_json=tags_json_str,
created_by=None,