Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a49934b39d |
+7
-292
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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))
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user