Compare commits

...

1 Commits

Author SHA1 Message Date
freemo b9707ae165 feat(cli): add resource tree and inspect commands
CI / lint (pull_request) Successful in 25s
CI / quality (pull_request) Successful in 38s
CI / build (pull_request) Successful in 37s
CI / typecheck (pull_request) Successful in 44s
CI / security (pull_request) Successful in 56s
CI / integration_tests (pull_request) Failing after 3m5s
CI / unit_tests (pull_request) Successful in 8m50s
CI / docker (pull_request) Successful in 38s
CI / coverage (pull_request) Successful in 20m53s
Add tree, inspect, link-child, and unlink-child subcommands to the
agents resource CLI group. Extend ResourceRegistryService with
link_child, unlink_child, get_children, and get_resource_tree methods.

- tree: display resource hierarchy with --depth, --type, and format options
- inspect: show resource details with --tree and --file options
- link-child/unlink-child: manage DAG edges with cycle detection
- 24 Behave scenarios, 4 Robot integration tests, ASV benchmarks
- CLI reference documentation in docs/reference/resource_cli.md
2026-02-18 07:23:56 +00:00
8 changed files with 1775 additions and 25 deletions
+174
View File
@@ -0,0 +1,174 @@
"""ASV benchmarks for Resource CLI tree/inspect command overhead.
Measures the performance of:
- Tree rendering for a resource with no children
- Tree rendering for a resource with multiple children
- Inspect command rendering
- Link-child / unlink-child command throughput
"""
from __future__ import annotations
import importlib
import sys
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
# Ensure the local *source* tree is importable even when ASV has an
# older build of the package installed.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.resource import app as resource_app # noqa: E402
from cleveragents.domain.models.core.resource import ( # noqa: E402
PhysVirt,
Resource,
ResourceCapabilities,
)
_runner = CliRunner()
def _mock_resource(
resource_id: str = "01HBENCH0000000000RESOURCE",
name: str = "local/bench-res",
type_name: str = "git-checkout",
) -> Resource:
return Resource(
resource_id=resource_id,
name=name,
resource_type_name=type_name,
classification=PhysVirt.PHYSICAL,
description="Benchmark resource",
properties={"path": "/tmp/bench"},
location="/tmp/bench",
content_hash=None,
sandbox_strategy=None,
capabilities=ResourceCapabilities(),
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
)
def _mock_tree_node(
resource: Resource,
children: list[dict[str, object]] | None = None,
) -> dict[str, object]:
return {
"resource": resource,
"children": children or [],
}
class ResourceTreeSuite:
"""Benchmark resource tree command throughput."""
def setup(self) -> None:
"""Set up mock service."""
root = _mock_resource()
child1 = _mock_resource(
"01HBENCH0000000000CHILD001", "local/child-1", "fs-directory"
)
child2 = _mock_resource(
"01HBENCH0000000000CHILD002", "local/child-2", "fs-directory"
)
tree = [
_mock_tree_node(
root,
[
_mock_tree_node(child1),
_mock_tree_node(child2),
],
)
]
self._mock_service = MagicMock()
self._mock_service.get_resource_tree.return_value = tree
self._mock_service.show_resource.return_value = root
self._patcher = patch(
"cleveragents.cli.commands.resource._get_registry_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_tree_default(self) -> None:
"""Benchmark tree with default settings."""
_runner.invoke(resource_app, ["tree", "local/bench-res"])
def time_tree_json(self) -> None:
"""Benchmark tree with JSON output."""
_runner.invoke(resource_app, ["tree", "local/bench-res", "--format", "json"])
def time_tree_depth(self) -> None:
"""Benchmark tree with depth limit."""
_runner.invoke(resource_app, ["tree", "local/bench-res", "--depth", "1"])
class ResourceInspectSuite:
"""Benchmark resource inspect command throughput."""
def setup(self) -> None:
"""Set up mock service."""
res = _mock_resource()
tree = [_mock_tree_node(res)]
self._mock_service = MagicMock()
self._mock_service.show_resource.return_value = res
self._mock_service.get_resource_tree.return_value = tree
self._patcher = patch(
"cleveragents.cli.commands.resource._get_registry_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_inspect_default(self) -> None:
"""Benchmark inspect with default settings."""
_runner.invoke(resource_app, ["inspect", "local/bench-res"])
def time_inspect_json(self) -> None:
"""Benchmark inspect with JSON output."""
_runner.invoke(resource_app, ["inspect", "local/bench-res", "--format", "json"])
def time_inspect_with_tree(self) -> None:
"""Benchmark inspect with --tree flag."""
_runner.invoke(resource_app, ["inspect", "local/bench-res", "--tree"])
class ResourceLinkSuite:
"""Benchmark resource link-child/unlink-child command throughput."""
def setup(self) -> None:
"""Set up mock service."""
self._mock_service = MagicMock()
self._mock_service.link_child.return_value = None
self._mock_service.unlink_child.return_value = None
self._patcher = patch(
"cleveragents.cli.commands.resource._get_registry_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_link_child(self) -> None:
"""Benchmark link-child command."""
_runner.invoke(resource_app, ["link-child", "local/parent", "local/child"])
def time_unlink_child(self) -> None:
"""Benchmark unlink-child command."""
_runner.invoke(
resource_app, ["unlink-child", "--yes", "local/parent", "local/child"]
)
+170
View File
@@ -0,0 +1,170 @@
# Resource CLI Reference
The `agents resource` command group manages resource types and resource
instances in the CleverAgents resource registry.
## Resource Instance Commands
### `agents resource add`
Add a new resource instance of a given type.
```bash
agents resource add git-checkout local/my-repo --path /home/user/repo
agents resource add fs-directory local/data --path /data --read-only
```
### `agents resource list`
List registered resources with optional type filter.
```bash
agents resource list
agents resource list --type git-checkout
agents resource list --format json
```
### `agents resource show`
Show details of a specific resource by name or ULID.
```bash
agents resource show local/my-repo
agents resource show 01HXYZ1234567890ABCDEFGHIJ
```
### `agents resource remove`
Remove a resource from the registry.
```bash
agents resource remove local/my-repo
agents resource remove --yes local/my-repo
```
### `agents resource tree`
Display a resource and its children as a tree (DAG). Output is
deterministically ordered by resource name.
**Options:**
| Option | Description |
|--------------|------------------------------------------|
| `--depth N` | Maximum tree depth (-1 for unlimited) |
| `--type TYPE`| Filter children by resource type |
| `--format` | Output format (json/yaml/plain/table/rich) |
**Examples:**
```bash
# Show full tree
agents resource tree local/my-repo
# Limit depth to 2 levels
agents resource tree local/my-repo --depth 2
# Show only fs-directory children
agents resource tree local/my-repo --type fs-directory
# JSON output
agents resource tree local/my-repo --format json
```
### `agents resource inspect`
Show detailed information about a resource. Optionally display
the child tree or read file content.
**Options:**
| Option | Description |
|-----------------|----------------------------------------------|
| `--tree` | Also show child tree |
| `--file PATH` | Show file content (for git-checkout/fs-mount) |
| `--format` | Output format (json/yaml/plain/table/rich) |
**Examples:**
```bash
# Basic inspect
agents resource inspect local/my-repo
# Inspect with child tree
agents resource inspect local/my-repo --tree
# Read a file from a git-checkout resource
agents resource inspect local/my-repo --file README.md
# JSON output
agents resource inspect local/my-repo --format json
```
### `agents resource link-child`
Create a DAG edge between two resources (parent -> child). The
child's resource type must be allowed by the parent's type
constraints. Cycles are rejected.
**Examples:**
```bash
agents resource link-child local/my-repo local/my-dir
agents resource link-child local/my-repo local/my-dir --format json
```
### `agents resource unlink-child`
Remove a DAG edge between two resources. Requires confirmation
unless `--yes` is passed.
**Options:**
| Option | Description |
|-----------|---------------------------|
| `--yes` | Skip confirmation prompt |
| `--format`| Output format |
**Examples:**
```bash
agents resource unlink-child local/my-repo local/my-dir
agents resource unlink-child --yes local/my-repo local/my-dir
```
## Resource Type Commands
### `agents resource type add`
Register a custom resource type from a YAML configuration file.
```bash
agents resource type add --config ./resource-types/my-type.yaml
```
### `agents resource type list`
List all registered resource types (built-in and custom).
```bash
agents resource type list
agents resource type list --format json
```
### `agents resource type show`
Show details of a registered resource type.
```bash
agents resource type show git-checkout
agents resource type show --format json local/my-type
```
### `agents resource type remove`
Remove a custom resource type. Built-in types cannot be removed.
```bash
agents resource type remove local/my-type
agents resource type remove --yes local/my-type
```
+198
View File
@@ -0,0 +1,198 @@
Feature: Resource CLI tree, inspect, link-child, and unlink-child commands
As a CleverAgents user
I want to view resource trees, inspect resources, and manage DAG edges via the CLI
So that I can navigate and manipulate resource hierarchies
Background:
Given a fresh resource tree test registry
# ---- Resource Tree ----
Scenario: Tree of a resource with no children
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/root-repo" at "/tmp/root"
When I display resource tree for "local/root-repo"
Then the tree output should contain "local/root-repo"
And the tree output should contain "git-checkout"
Scenario: Tree of a resource with children shows hierarchy
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/parent-repo" at "/tmp/parent"
And a resource tree resource "fs-directory" named "local/child-dir" at "/tmp/child"
And resource tree child "local/child-dir" is linked to parent "local/parent-repo"
When I display resource tree for "local/parent-repo"
Then the tree output should contain "local/parent-repo"
And the tree output should contain "local/child-dir"
Scenario: Tree with depth limit of 1
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/deep-root" at "/tmp/deep"
And a resource tree resource "fs-directory" named "local/deep-child" at "/tmp/dc"
And a resource tree resource "fs-directory" named "local/deep-grandchild" at "/tmp/dgc"
And resource tree child "local/deep-child" is linked to parent "local/deep-root"
And resource tree child "local/deep-grandchild" is linked to parent "local/deep-child"
When I display resource tree depth-limited to 1 for "local/deep-root"
Then the tree output should contain "local/deep-child"
And the tree output should not contain "local/deep-grandchild"
Scenario: Tree with type filter
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/filter-root" at "/tmp/fr"
And a resource tree resource "fs-directory" named "local/filter-dir" at "/tmp/fd"
And resource tree child "local/filter-dir" is linked to parent "local/filter-root"
When I display resource tree type-filtered to "fs-directory" for "local/filter-root"
Then the tree output should contain "local/filter-dir"
Scenario: Tree in JSON format
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/json-tree" at "/tmp/jt"
When I display resource tree as "json" for "local/json-tree"
Then the tree output should be valid JSON
Scenario: Tree of non-existent resource fails
When I display resource tree for "nonexistent-resource"
Then the tree command should fail
And the tree output should contain "not found"
# ---- Deterministic ordering ----
Scenario: Tree children are sorted by name
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/sort-root" at "/tmp/sr"
And a resource tree resource "fs-directory" named "local/z-last" at "/tmp/zl"
And a resource tree resource "fs-directory" named "local/a-first" at "/tmp/af"
And resource tree child "local/z-last" is linked to parent "local/sort-root"
And resource tree child "local/a-first" is linked to parent "local/sort-root"
When I display resource tree for "local/sort-root"
Then the tree output should show "local/a-first" before "local/z-last"
# ---- Resource Inspect ----
Scenario: Inspect a resource shows details
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/inspect-repo" at "/tmp/ir"
When I run basic inspect on "local/inspect-repo"
Then the tree output should contain "local/inspect-repo"
And the tree output should contain "git-checkout"
Scenario: Inspect with --tree shows children
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/inspect-parent" at "/tmp/ip"
And a resource tree resource "fs-directory" named "local/inspect-child" at "/tmp/ic"
And resource tree child "local/inspect-child" is linked to parent "local/inspect-parent"
When I run inspect with subtree on "local/inspect-parent"
Then the tree output should contain "local/inspect-child"
Scenario: Inspect with --tree and no children
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/no-children" at "/tmp/nc"
When I run inspect with subtree on "local/no-children"
Then the tree output should contain "no children"
Scenario: Inspect with --file for existing file
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/file-repo" at a temp directory
And a temp file "hello.txt" with content "Hello World"
When I run inspect with file "hello.txt" on "local/file-repo"
Then the tree output should contain "Hello World"
Scenario: Inspect with --file for missing file
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/no-file" at "/tmp/nf"
When I run inspect with file "nonexistent.txt" on "local/no-file"
Then the tree output should contain "not found"
Scenario: Inspect in JSON format
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/json-inspect" at "/tmp/ji"
When I run inspect formatted as "json" on "local/json-inspect"
Then the tree output should be valid JSON
Scenario: Inspect non-existent resource fails
When I run basic inspect on "nonexistent-resource"
Then the tree command should fail
And the tree output should contain "not found"
# ---- Resource link-child ----
Scenario: Link child successfully
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/link-parent" at "/tmp/lp"
And a resource tree resource "fs-directory" named "local/link-child" at "/tmp/lc"
When I link DAG child "local/link-child" to parent "local/link-parent"
Then the tree output should contain "Linked"
Scenario: Link child in JSON format
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/json-parent" at "/tmp/jp"
And a resource tree resource "fs-directory" named "local/json-child" at "/tmp/jc"
When I link as "json" DAG child "local/json-child" to parent "local/json-parent"
Then the tree output should be valid JSON
And the tree output should contain "linked"
Scenario: Link child with non-existent parent fails
Given resource tree built-in types are bootstrapped
And a resource tree resource "fs-directory" named "local/orphan" at "/tmp/orph"
When I link DAG child "local/orphan" to parent "nonexistent-parent"
Then the tree command should fail
And the tree output should contain "not found"
Scenario: Link child with non-existent child fails
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/lonely-parent" at "/tmp/lonely"
When I link DAG child "nonexistent-child" to parent "local/lonely-parent"
Then the tree command should fail
And the tree output should contain "not found"
Scenario: Duplicate link fails
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/dup-parent" at "/tmp/dp"
And a resource tree resource "fs-directory" named "local/dup-child" at "/tmp/dpc"
And resource tree child "local/dup-child" is linked to parent "local/dup-parent"
When I link DAG child "local/dup-child" to parent "local/dup-parent"
Then the tree command should fail
And the tree output should contain "already exists"
Scenario: Cycle detection rejects link
Given resource tree built-in types are bootstrapped
And a resource tree resource "fs-directory" named "local/cyc-a" at "/tmp/ca"
And a resource tree resource "fs-directory" named "local/cyc-b" at "/tmp/cb"
And resource tree child "local/cyc-b" is linked to parent "local/cyc-a"
When I link DAG child "local/cyc-a" to parent "local/cyc-b"
Then the tree command should fail
And the tree output should contain "ycle"
# ---- Resource unlink-child ----
Scenario: Unlink child successfully
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/unlink-parent" at "/tmp/up"
And a resource tree resource "fs-directory" named "local/unlink-child" at "/tmp/uc"
And resource tree child "local/unlink-child" is linked to parent "local/unlink-parent"
When I unlink DAG child "local/unlink-child" from parent "local/unlink-parent" confirmed
Then the tree output should contain "Unlinked"
Scenario: Unlink child in JSON format
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/json-unlp" at "/tmp/jul"
And a resource tree resource "fs-directory" named "local/json-unlc" at "/tmp/juc"
And resource tree child "local/json-unlc" is linked to parent "local/json-unlp"
When I unlink as "json" DAG child "local/json-unlc" from parent "local/json-unlp" confirmed
Then the tree output should be valid JSON
And the tree output should contain "unlinked"
Scenario: Unlink non-existent link fails
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/nolink-parent" at "/tmp/nlp"
And a resource tree resource "fs-directory" named "local/nolink-child" at "/tmp/nlc"
When I unlink DAG child "local/nolink-child" from parent "local/nolink-parent" confirmed
Then the tree command should fail
And the tree output should contain "not found"
Scenario: Unlink aborted without yes flag
Given resource tree built-in types are bootstrapped
And a resource tree resource "git-checkout" named "local/abort-parent" at "/tmp/ap"
And a resource tree resource "fs-directory" named "local/abort-child" at "/tmp/ac"
And resource tree child "local/abort-child" is linked to parent "local/abort-parent"
When I unlink DAG child "local/abort-child" from parent "local/abort-parent" declined
Then the tree command should fail
+438
View File
@@ -0,0 +1,438 @@
"""Step definitions for resource_cli_tree.feature."""
from __future__ import annotations
import json
import tempfile
from io import StringIO
from pathlib import Path
from typing import Any
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from rich.console import Console
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
from cleveragents.infrastructure.database.models import Base
def _make_tree_service(context: Context) -> ResourceRegistryService:
"""Create or return a cached ResourceRegistryService for tree tests."""
if not hasattr(context, "tree_cli_service"):
engine = create_engine("sqlite:///:memory:", echo=False)
@event.listens_for(engine, "connect")
def _fk(conn: Any, _rec: Any) -> None:
conn.cursor().execute("PRAGMA foreign_keys=ON")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
context.tree_cli_service = ResourceRegistryService(session_factory=factory)
return context.tree_cli_service
def _capture_tree_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]:
"""Run a CLI function capturing its console output and success status."""
buf = StringIO()
console = Console(file=buf, width=200, no_color=True)
import cleveragents.cli.commands.resource as resource_mod
orig_console = resource_mod.console
resource_mod.console = console
failed = False
try:
func(*args, **kwargs)
except SystemExit:
failed = True
except Exception:
failed = True
finally:
resource_mod.console = orig_console
return buf.getvalue(), failed
def _patch_tree_service(context: Context) -> Any:
"""Monkey-patch the container to return our in-memory service."""
import cleveragents.cli.commands.resource as resource_mod
orig_fn = resource_mod._get_registry_service
def _mock_get() -> ResourceRegistryService:
return _make_tree_service(context)
resource_mod._get_registry_service = _mock_get
return orig_fn
def _unpatch_tree_service(orig_fn: Any) -> None:
"""Restore original service getter."""
import cleveragents.cli.commands.resource as resource_mod
resource_mod._get_registry_service = orig_fn
# ---- Background ----
@given("a fresh resource tree test registry")
def step_fresh_tree_registry(context: Context) -> None:
"""Set up a fresh in-memory database for tree tests."""
engine = create_engine("sqlite:///:memory:", echo=False)
@event.listens_for(engine, "connect")
def _fk(conn: Any, _rec: Any) -> None:
conn.cursor().execute("PRAGMA foreign_keys=ON")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
context.tree_cli_service = ResourceRegistryService(session_factory=factory)
context.tree_cli_output = ""
context.tree_cli_failed = False
context.tree_temp_dir = None
@given("resource tree built-in types are bootstrapped")
def step_tree_bootstrap(context: Context) -> None:
"""Bootstrap built-in resource types for tree tests."""
service = _make_tree_service(context)
service.bootstrap_builtin_types()
@given('a resource tree resource "{type_name}" named "{name}" at "{path}"')
def step_tree_add_resource(
context: Context, type_name: str, name: str, path: str
) -> None:
"""Add a resource for tree tests."""
service = _make_tree_service(context)
service.register_resource(
type_name=type_name,
name=name,
location=path,
description=f"Test resource {name}",
read_only=False,
properties={"path": path},
)
@given('a resource tree resource "{type_name}" named "{name}" at a temp directory')
def step_tree_add_resource_temp(context: Context, type_name: str, name: str) -> None:
"""Add a resource at a temp directory."""
tmp_dir = tempfile.mkdtemp()
context.tree_temp_dir = tmp_dir
service = _make_tree_service(context)
service.register_resource(
type_name=type_name,
name=name,
location=tmp_dir,
description=f"Test resource {name}",
read_only=False,
properties={"path": tmp_dir},
)
@given('a temp file "{filename}" with content "{content}"')
def step_tree_create_temp_file(context: Context, filename: str, content: str) -> None:
"""Create a temp file in the temp directory."""
tmp_dir = context.tree_temp_dir
filepath = Path(tmp_dir) / filename
filepath.write_text(content, encoding="utf-8")
@given('resource tree child "{child}" is linked to parent "{parent}"')
def step_tree_link_child(context: Context, child: str, parent: str) -> None:
"""Link a child to a parent."""
service = _make_tree_service(context)
service.link_child(parent, child)
# ---- Tree commands ----
@when('I display resource tree for "{name}"')
def step_run_tree(context: Context, name: str) -> None:
"""Run resource tree command."""
from cleveragents.cli.commands.resource import resource_tree
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=-1, type_filter=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I display resource tree depth-limited to {depth:d} for "{name}"')
def step_run_tree_depth(context: Context, depth: int, name: str) -> None:
"""Run resource tree with depth limit."""
from cleveragents.cli.commands.resource import resource_tree
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=depth, type_filter=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I display resource tree type-filtered to "{type_filter}" for "{name}"')
def step_run_tree_type(context: Context, type_filter: str, name: str) -> None:
"""Run resource tree with type filter."""
from cleveragents.cli.commands.resource import resource_tree
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=-1, type_filter=type_filter, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I display resource tree as "{fmt}" for "{name}"')
def step_run_tree_fmt(context: Context, fmt: str, name: str) -> None:
"""Run resource tree with format."""
from cleveragents.cli.commands.resource import resource_tree
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=-1, type_filter=None, fmt=fmt
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
# ---- Inspect commands ----
@when('I run basic inspect on "{name}"')
def step_run_inspect(context: Context, name: str) -> None:
"""Run resource inspect command."""
from cleveragents.cli.commands.resource import resource_inspect
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=False, file=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I run inspect with subtree on "{name}"')
def step_run_inspect_tree(context: Context, name: str) -> None:
"""Run resource inspect with --tree."""
from cleveragents.cli.commands.resource import resource_inspect
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=True, file=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I run inspect with file "{filepath}" on "{name}"')
def step_run_inspect_file(context: Context, filepath: str, name: str) -> None:
"""Run resource inspect with --file."""
from cleveragents.cli.commands.resource import resource_inspect
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=False, file=filepath, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I run inspect formatted as "{fmt}" on "{name}"')
def step_run_inspect_fmt(context: Context, fmt: str, name: str) -> None:
"""Run resource inspect with format."""
from cleveragents.cli.commands.resource import resource_inspect
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=False, file=None, fmt=fmt
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
# ---- Link-child commands ----
@when('I link DAG child "{child}" to parent "{parent}"')
def step_run_link_child(context: Context, child: str, parent: str) -> None:
"""Run resource link-child command."""
from cleveragents.cli.commands.resource import resource_link_child
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_link_child, parent=parent, child=child, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I link as "{fmt}" DAG child "{child}" to parent "{parent}"')
def step_run_link_child_fmt(
context: Context, fmt: str, child: str, parent: str
) -> None:
"""Run resource link-child with format."""
from cleveragents.cli.commands.resource import resource_link_child
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_link_child, parent=parent, child=child, fmt=fmt
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
# ---- Unlink-child commands ----
@when('I unlink DAG child "{child}" from parent "{parent}" confirmed')
def step_run_unlink_child_yes(context: Context, child: str, parent: str) -> None:
"""Run resource unlink-child with --yes."""
from cleveragents.cli.commands.resource import resource_unlink_child
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_unlink_child, parent=parent, child=child, yes=True, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I unlink as "{fmt}" DAG child "{child}" from parent "{parent}" confirmed')
def step_run_unlink_child_yes_fmt(
context: Context, fmt: str, child: str, parent: str
) -> None:
"""Run resource unlink-child with --yes and format."""
from cleveragents.cli.commands.resource import resource_unlink_child
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_unlink_child, parent=parent, child=child, yes=True, fmt=fmt
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I unlink DAG child "{child}" from parent "{parent}" declined')
def step_run_unlink_child_no_yes(context: Context, child: str, parent: str) -> None:
"""Run resource unlink-child without --yes (confirmation denied)."""
from cleveragents.cli.commands.resource import resource_unlink_child
orig = _patch_tree_service(context)
try:
with patch(
"cleveragents.cli.commands.resource.typer.confirm",
return_value=False,
):
output, failed = _capture_tree_output(
resource_unlink_child,
parent=parent,
child=child,
yes=False,
fmt="rich",
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
# ---- Assertions ----
@then('the tree output should contain "{text}"')
def step_tree_output_contains(context: Context, text: str) -> None:
"""Check that CLI output contains the expected text."""
output = context.tree_cli_output
assert text.lower() in output.lower(), (
f"Expected '{text}' in output, got:\n{output}"
)
@then('the tree output should not contain "{text}"')
def step_tree_output_not_contains(context: Context, text: str) -> None:
"""Check that CLI output does not contain the text."""
output = context.tree_cli_output
assert text.lower() not in output.lower(), (
f"Did not expect '{text}' in output, got:\n{output}"
)
@then("the tree command should fail")
def step_tree_command_failed(context: Context) -> None:
"""Check that the command failed."""
assert context.tree_cli_failed, "Expected command to fail but it succeeded"
@then("the tree output should be valid JSON")
def step_tree_output_valid_json(context: Context) -> None:
"""Check that the output is valid JSON."""
output = context.tree_cli_output.strip()
try:
json.loads(output)
except json.JSONDecodeError as exc:
raise AssertionError(f"Output is not valid JSON: {exc}\n{output}") from exc
@then('the tree output should show "{first}" before "{second}"')
def step_tree_output_order(context: Context, first: str, second: str) -> None:
"""Check that first appears before second in output."""
output = context.tree_cli_output.lower()
first_pos = output.find(first.lower())
second_pos = output.find(second.lower())
assert first_pos >= 0, f"'{first}' not found in output:\n{context.tree_cli_output}"
assert second_pos >= 0, (
f"'{second}' not found in output:\n{context.tree_cli_output}"
)
assert first_pos < second_pos, (
f"'{first}' (pos {first_pos}) should appear before "
f"'{second}' (pos {second_pos}) in output:\n{context.tree_cli_output}"
)
+18 -18
View File
@@ -2575,24 +2575,24 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [X] Git [Jeff]: `git branch -d feature/m2-resource-dag`
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] **COMMIT (Owner: Jeff | Group: B1.cli | Branch: feature/m2-resource-cli-extensions | Planned: Day 13 | Expected: Day 18) - Commit message: "feat(cli): add resource tree and inspect commands"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m2-resource-cli-extensions`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master`
- [ ] Code [Jeff]: Implement `agents resource tree` with `--depth` and `--type` filters and deterministic ordering.
- [ ] Code [Jeff]: Implement `agents resource inspect` with `--tree` and `--file` options for git-checkout/fs-mount.
- [ ] Code [Jeff]: Implement `agents resource link-child`/`unlink-child` to manage DAG edges.
- [ ] Docs [Jeff]: Update CLI reference with resource tree/inspect/link examples.
- [ ] Tests (Behave) [Jeff]: Add scenarios for tree output ordering and link-child error cases.
- [ ] Tests (Robot) [Jeff]: Add Robot test that runs resource tree/inspect on a git-checkout resource.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/resource_cli_tree_bench.py` for CLI overhead baseline.
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(cli): add resource tree and inspect commands"`
- [ ] Git [Jeff]: `git push -u origin feature/m2-resource-cli-extensions`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m2-resource-cli-extensions` to `master` with description "Add resource tree/inspect/link CLI commands with tests/docs.".
- [X] **COMMIT (Owner: Jeff | Group: B1.cli | Branch: feature/m2-resource-cli-extensions | Planned: Day 13 | Done: Day 18, February 18, 2026) - Commit message: "feat(cli): add resource tree and inspect commands"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m2-resource-cli-extensions`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master`
- [X] Code [Jeff]: Implement `agents resource tree` with `--depth` and `--type` filters and deterministic ordering.
- [X] Code [Jeff]: Implement `agents resource inspect` with `--tree` and `--file` options for git-checkout/fs-mount.
- [X] Code [Jeff]: Implement `agents resource link-child`/`unlink-child` to manage DAG edges.
- [X] Docs [Jeff]: Update CLI reference with resource tree/inspect/link examples.
- [X] Tests (Behave) [Jeff]: Add scenarios for tree output ordering and link-child error cases.
- [X] Tests (Robot) [Jeff]: Add Robot test that runs resource tree/inspect on a git-checkout resource.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/resource_cli_tree_bench.py` for CLI overhead baseline.
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Jeff]: `git commit -m "feat(cli): add resource tree and inspect commands"`
- [X] Git [Jeff]: `git push -u origin feature/m2-resource-cli-extensions`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m2-resource-cli-extensions` to `master` with description "Add resource tree/inspect/link CLI commands with tests/docs.".
**Parallel Group B2: Project Context Policies (M3)**
**PARALLEL SUBTRACK B2.model [Jeff]**: Project context policy model + persistence fields
+124
View File
@@ -0,0 +1,124 @@
*** Settings ***
Documentation Resource CLI Tree and Inspect Integration Tests
Library Process
Library OperatingSystem
*** Variables ***
${PYTHON} python
*** Test Cases ***
Resource Tree Shows Root Resource
[Documentation] Run resource tree on a resource with no children
${script}= Catenate SEPARATOR=\n
... import json
... from datetime import datetime, UTC
... from sqlalchemy import create_engine, event
... from sqlalchemy.orm import sessionmaker
... from cleveragents.infrastructure.database.models import Base
... from cleveragents.application.services.resource_registry_service import ResourceRegistryService
... engine = create_engine("sqlite:///:memory:")
... @event.listens_for(engine, "connect")
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
... Base.metadata.create_all(engine)
... factory = sessionmaker(bind=engine, expire_on_commit=False)
... svc = ResourceRegistryService(session_factory=factory)
... svc.bootstrap_builtin_types()
... svc.register_resource(type_name="git-checkout", name="local/tree-test", location="/tmp/tt", properties={"path": "/tmp/tt"})
... tree = svc.get_resource_tree("local/tree-test")
... assert len(tree) == 1
... node = tree[0]
... assert node["resource"].name == "local/tree-test"
... assert node["children"] == []
... print("Resource tree root test passed")
${result}= Run Process ${PYTHON} -c ${script}
Should Be Equal As Integers ${result.rc} 0 Tree root test failed: ${result.stderr}
Should Contain ${result.stdout} Resource tree root test passed
Resource Inspect Shows Details
[Documentation] Inspect a resource and verify basic details
${script}= Catenate SEPARATOR=\n
... import json
... from datetime import datetime, UTC
... from sqlalchemy import create_engine, event
... from sqlalchemy.orm import sessionmaker
... from cleveragents.infrastructure.database.models import Base
... from cleveragents.application.services.resource_registry_service import ResourceRegistryService
... engine = create_engine("sqlite:///:memory:")
... @event.listens_for(engine, "connect")
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
... Base.metadata.create_all(engine)
... factory = sessionmaker(bind=engine, expire_on_commit=False)
... svc = ResourceRegistryService(session_factory=factory)
... svc.bootstrap_builtin_types()
... res = svc.register_resource(type_name="git-checkout", name="local/inspect-test", location="/tmp/it", properties={"path": "/tmp/it"})
... shown = svc.show_resource("local/inspect-test")
... assert shown.name == "local/inspect-test"
... assert shown.resource_type_name == "git-checkout"
... assert shown.location == "/tmp/it"
... print("Resource inspect test passed")
${result}= Run Process ${PYTHON} -c ${script}
Should Be Equal As Integers ${result.rc} 0 Inspect test failed: ${result.stderr}
Should Contain ${result.stdout} Resource inspect test passed
Link And Unlink Child Via Service
[Documentation] Link a child via service and then unlink it
${script}= Catenate SEPARATOR=\n
... import json
... from datetime import datetime, UTC
... from sqlalchemy import create_engine, event
... from sqlalchemy.orm import sessionmaker
... from cleveragents.infrastructure.database.models import Base
... from cleveragents.application.services.resource_registry_service import ResourceRegistryService
... engine = create_engine("sqlite:///:memory:")
... @event.listens_for(engine, "connect")
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
... Base.metadata.create_all(engine)
... factory = sessionmaker(bind=engine, expire_on_commit=False)
... svc = ResourceRegistryService(session_factory=factory)
... svc.bootstrap_builtin_types()
... svc.register_resource(type_name="git-checkout", name="local/link-p", location="/tmp/lp", properties={"path": "/tmp/lp"})
... svc.register_resource(type_name="fs-directory", name="local/link-c", location="/tmp/lc", properties={"path": "/tmp/lc"})
... svc.link_child("local/link-p", "local/link-c")
... children = svc.get_children("local/link-p")
... assert len(children) == 1
... assert children[0].name == "local/link-c"
... svc.unlink_child("local/link-p", "local/link-c")
... children = svc.get_children("local/link-p")
... assert len(children) == 0
... print("Link and unlink child passed")
${result}= Run Process ${PYTHON} -c ${script}
Should Be Equal As Integers ${result.rc} 0 Link/unlink test failed: ${result.stderr}
Should Contain ${result.stdout} Link and unlink child passed
Resource Tree With Children Shows Hierarchy
[Documentation] Resource tree with linked children shows hierarchy
${script}= Catenate SEPARATOR=\n
... import json
... from datetime import datetime, UTC
... from sqlalchemy import create_engine, event
... from sqlalchemy.orm import sessionmaker
... from cleveragents.infrastructure.database.models import Base
... from cleveragents.application.services.resource_registry_service import ResourceRegistryService
... engine = create_engine("sqlite:///:memory:")
... @event.listens_for(engine, "connect")
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
... Base.metadata.create_all(engine)
... factory = sessionmaker(bind=engine, expire_on_commit=False)
... svc = ResourceRegistryService(session_factory=factory)
... svc.bootstrap_builtin_types()
... svc.register_resource(type_name="git-checkout", name="local/tree-parent", location="/tmp/tp", properties={"path": "/tmp/tp"})
... svc.register_resource(type_name="fs-directory", name="local/tree-child", location="/tmp/tc", properties={"path": "/tmp/tc"})
... svc.link_child("local/tree-parent", "local/tree-child")
... tree = svc.get_resource_tree("local/tree-parent")
... assert len(tree) == 1
... root = tree[0]
... assert root["resource"].name == "local/tree-parent"
... assert len(root["children"]) == 1
... child_node = root["children"][0]
... assert child_node["resource"].name == "local/tree-child"
... print("Tree with children test passed")
${result}= Run Process ${PYTHON} -c ${script}
Should Be Equal As Integers ${result.rc} 0 Tree children test failed: ${result.stderr}
Should Contain ${result.stdout} Tree with children test passed
*** Keywords ***
@@ -51,6 +51,7 @@ from cleveragents.domain.models.core.resource_type import (
SandboxStrategy,
)
from cleveragents.infrastructure.database.models import (
ResourceLinkModel,
ResourceModel,
ResourceTypeModel,
)
@@ -429,12 +430,290 @@ class ResourceRegistryService:
finally:
session.close()
# -- DAG operations ------------------------------------------------------
def link_child(self, parent_name_or_id: str, child_name_or_id: str) -> None:
"""Link a child resource to a parent in the DAG.
Resolves resource names or ULIDs and delegates to the
repository's ``link_child`` method with full validation
(type compatibility, cycle detection).
Args:
parent_name_or_id: Parent resource name or ULID.
child_name_or_id: Child resource name or ULID.
Raises:
NotFoundError: If either resource is not found.
ValidationError: If linking fails (cycle, type mismatch, duplicate).
"""
parent = self.show_resource(parent_name_or_id)
child = self.show_resource(child_name_or_id)
session = self._session()
try:
# Check type compatibility
parent_type_row = (
session.query(ResourceTypeModel)
.filter_by(name=parent.resource_type_name)
.first()
)
if parent_type_row is not None:
allowed_raw = getattr(parent_type_row, "allowed_child_types_json", None)
allowed_children: list[str] = (
json.loads(str(allowed_raw)) if allowed_raw else []
)
if (
allowed_children
and child.resource_type_name not in allowed_children
):
raise ValidationError(
message=(
f"Type '{child.resource_type_name}' is not allowed "
f"as a child of '{parent.resource_type_name}'"
),
details={
"parent_type": parent.resource_type_name,
"child_type": child.resource_type_name,
},
)
if parent.resource_id == child.resource_id:
raise ValidationError(
message="Cannot link a resource to itself",
details={
"parent_id": parent.resource_id,
"child_id": child.resource_id,
},
)
# Check for duplicate link
existing = (
session.query(ResourceLinkModel)
.filter_by(
parent_id=parent.resource_id,
child_id=child.resource_id,
)
.first()
)
if existing is not None:
raise ValidationError(
message=(
f"Link already exists: "
f"{parent.resource_id} -> {child.resource_id}"
),
details={
"parent_id": parent.resource_id,
"child_id": child.resource_id,
},
)
# Cycle detection: walk ancestors of parent
ancestors = _get_ancestors(session, parent.resource_id)
if child.resource_id in ancestors:
raise ValidationError(
message=(
f"Cycle detected: linking {child.resource_id} "
f"as child of {parent.resource_id} would "
f"create a cycle"
),
details={
"parent_id": parent.resource_id,
"child_id": child.resource_id,
},
)
link = ResourceLinkModel(
parent_id=parent.resource_id,
child_id=child.resource_id,
created_at=datetime.now(tz=UTC).isoformat(),
)
session.add(link)
session.flush()
session.commit()
except (NotFoundError, ValidationError):
session.rollback()
raise
except Exception:
session.rollback()
raise
finally:
session.close()
def unlink_child(self, parent_name_or_id: str, child_name_or_id: str) -> None:
"""Remove a parent-child link from the DAG.
Args:
parent_name_or_id: Parent resource name or ULID.
child_name_or_id: Child resource name or ULID.
Raises:
NotFoundError: If either resource or the link is not found.
"""
parent = self.show_resource(parent_name_or_id)
child = self.show_resource(child_name_or_id)
session = self._session()
try:
link = (
session.query(ResourceLinkModel)
.filter_by(
parent_id=parent.resource_id,
child_id=child.resource_id,
)
.first()
)
if link is None:
raise NotFoundError(
resource_type="resource_link",
resource_id=(f"{parent.resource_id} -> {child.resource_id}"),
)
session.delete(link)
session.flush()
session.commit()
except NotFoundError:
session.rollback()
raise
except Exception:
session.rollback()
raise
finally:
session.close()
def get_children(self, name_or_id: str) -> list[Resource]:
"""Get direct children of a resource.
Args:
name_or_id: Resource name or ULID.
Returns:
List of child ``Resource`` domain objects, sorted by name.
"""
resource = self.show_resource(name_or_id)
session = self._session()
try:
links = (
session.query(ResourceLinkModel)
.filter_by(parent_id=resource.resource_id)
.all()
)
children: list[Resource] = []
for link in links:
child_row = (
session.query(ResourceModel)
.filter_by(resource_id=str(link.child_id))
.first()
)
if child_row is not None:
children.append(_db_resource_to_domain(child_row))
# Deterministic ordering: sort by name, then resource_id
children.sort(key=lambda r: (r.name or "", r.resource_id))
return children
finally:
session.close()
def get_resource_tree(
self,
name_or_id: str,
depth: int = -1,
type_filter: str | None = None,
) -> list[dict[str, object]]:
"""Get resource tree as nested dicts.
Each node is ``{"resource": Resource, "children": [...]}``.
Args:
name_or_id: Root resource name or ULID.
depth: Maximum depth (-1 for unlimited).
type_filter: Only include resources of this type.
Returns:
A list with a single root node dict.
"""
root = self.show_resource(name_or_id)
return [self._build_tree_node(root, depth, type_filter, set())]
def _build_tree_node(
self,
resource: Resource,
depth: int,
type_filter: str | None,
visited: set[str],
) -> dict[str, object]:
"""Recursively build a tree node.
Args:
resource: Current resource.
depth: Remaining depth (-1 for unlimited).
type_filter: Type filter string or None.
visited: Set of visited resource IDs (cycle protection).
Returns:
Dict with ``resource`` and ``children`` keys.
"""
visited = visited | {resource.resource_id}
children_nodes: list[dict[str, object]] = []
if depth != 0:
next_depth = depth - 1 if depth > 0 else -1
session = self._session()
try:
links = (
session.query(ResourceLinkModel)
.filter_by(parent_id=resource.resource_id)
.all()
)
child_resources: list[Resource] = []
for link in links:
child_row = (
session.query(ResourceModel)
.filter_by(resource_id=str(link.child_id))
.first()
)
if child_row is not None:
child_res = _db_resource_to_domain(child_row)
if type_filter and child_res.resource_type_name != type_filter:
continue
if child_res.resource_id not in visited:
child_resources.append(child_res)
finally:
session.close()
# Deterministic ordering
child_resources.sort(key=lambda r: (r.name or "", r.resource_id))
for child in child_resources:
children_nodes.append(
self._build_tree_node(child, next_depth, type_filter, visited)
)
return {"resource": resource, "children": children_nodes}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _get_ancestors(session: Any, resource_id: str) -> set[str]:
"""Walk parent links to collect all ancestor resource IDs.
Uses BFS to traverse the DAG upward from ``resource_id``.
"""
ancestors: set[str] = set()
queue = [resource_id]
while queue:
current = queue.pop(0)
parent_links = (
session.query(ResourceLinkModel).filter_by(child_id=current).all()
)
for link in parent_links:
pid = str(link.parent_id)
if pid not in ancestors:
ancestors.add(pid)
queue.append(pid)
return ancestors
def _spec_to_db(spec: ResourceTypeSpec, source: str) -> ResourceTypeModel:
"""Convert a ResourceTypeSpec domain object to a DB model."""
now_iso = datetime.now(tz=UTC).isoformat()
+374 -7
View File
@@ -14,12 +14,16 @@ instances in the CleverAgents resource registry.
## Resource Instance Commands
| Command | Description |
|----------------------------|-------------------------------------------|
| ``agents resource add`` | Add a resource instance of a given type |
| ``agents resource list`` | List resources with optional type filter |
| ``agents resource show`` | Show resource details (by name or ULID) |
| ``agents resource remove`` | Remove a resource from the registry |
| Command | Description |
|----------------------------------|-------------------------------------------|
| ``agents resource add`` | Add a resource instance of a given type |
| ``agents resource list`` | List resources with optional type filter |
| ``agents resource show`` | Show resource details (by name or ULID) |
| ``agents resource remove`` | Remove a resource from the registry |
| ``agents resource tree`` | Display resource DAG as a tree |
| ``agents resource inspect`` | Show detailed resource info |
| ``agents resource link-child`` | Create DAG edge between resources |
| ``agents resource unlink-child`` | Remove DAG edge between resources |
## Example Usage
@@ -32,9 +36,19 @@ agents resource list
# Show details
agents resource show local/my-repo
# Display resource tree
agents resource tree local/my-repo --depth 3
# Inspect a resource
agents resource inspect local/my-repo --tree
# Link resources in DAG
agents resource link-child local/parent local/child
agents resource unlink-child local/parent local/child
```
Based on ``implementation_plan.md`` -- Task B0.cli.resources.
Based on ``implementation_plan.md`` -- Tasks B0.cli.resources, B1.cli.
"""
from __future__ import annotations
@@ -602,6 +616,359 @@ def resource_show(
raise typer.Abort() from exc
# ---------------------------------------------------------------------------
# Resource tree command
# ---------------------------------------------------------------------------
@app.command("tree")
def resource_tree(
resource: Annotated[
str,
typer.Argument(
help="Resource name or ULID to display tree for",
),
],
depth: Annotated[
int,
typer.Option("--depth", "-d", help="Maximum tree depth (-1 for unlimited)"),
] = -1,
type_filter: Annotated[
str | None,
typer.Option("--type", "-t", help="Filter children by resource type"),
] = None,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Display a resource and its children as a tree (DAG).
Shows the resource hierarchy with optional depth limit and type filter.
Output is deterministically ordered by resource name.
Examples:
agents resource tree local/my-repo
agents resource tree local/my-repo --depth 2
agents resource tree local/my-repo --type fs-directory
agents resource tree local/my-repo --format json
"""
try:
service = _get_registry_service()
tree = service.get_resource_tree(resource, depth=depth, type_filter=type_filter)
if fmt != OutputFormat.RICH.value:
data = _tree_to_dict(tree)
console.print(format_output(data, fmt))
return
_print_tree_rich(tree, indent=0)
except NotFoundError as exc:
console.print(f"[red]Resource not found:[/red] {resource}")
raise typer.Abort() from exc
except CleverAgentsError as exc:
console.print(f"[red]Error:[/red] {exc.message}")
raise typer.Abort() from exc
def _tree_to_dict(
nodes: list[dict[str, object]],
) -> list[dict[str, object]]:
"""Convert tree nodes to serialisable dicts."""
result: list[dict[str, object]] = []
for node in nodes:
res = node["resource"]
children_raw = node.get("children", [])
children_list: list[dict[str, object]] = []
if isinstance(children_raw, list):
children_list = children_raw
child_dicts = _tree_to_dict(children_list)
entry: dict[str, object] = _resource_dict(res)
entry["children"] = child_dicts
result.append(entry)
return result
def _print_tree_rich(
nodes: list[dict[str, object]],
indent: int,
) -> None:
"""Render tree nodes with rich formatting."""
for node in nodes:
res = node["resource"]
prefix = " " * indent
name_display = getattr(res, "name", None) or getattr(res, "resource_id", "?")
type_name = getattr(res, "resource_type_name", "")
if indent == 0:
console.print(
f"{prefix}[bold cyan]{name_display}[/bold cyan] "
f"[dim]({type_name})[/dim]"
)
else:
console.print(
f"{prefix}[cyan]{name_display}[/cyan] [dim]({type_name})[/dim]"
)
children_raw = node.get("children", [])
children_list: list[dict[str, object]] = []
if isinstance(children_raw, list):
children_list = children_raw
_print_tree_rich(children_list, indent + 1)
# ---------------------------------------------------------------------------
# Resource inspect command
# ---------------------------------------------------------------------------
@app.command("inspect")
def resource_inspect(
resource: Annotated[
str,
typer.Argument(
help="Resource name or ULID to inspect",
),
],
tree: Annotated[
bool,
typer.Option("--tree", help="Also show child tree"),
] = False,
file: Annotated[
str | None,
typer.Option(
"--file",
help="Show file content (for git-checkout/fs-mount resources)",
),
] = None,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Show detailed information about a resource.
Optionally display the child tree with ``--tree`` or show file
content for git-checkout/fs-mount resources with ``--file PATH``.
Examples:
agents resource inspect local/my-repo
agents resource inspect local/my-repo --tree
agents resource inspect local/my-repo --file README.md
agents resource inspect local/my-repo --format json
"""
try:
service = _get_registry_service()
res = service.show_resource(resource)
data: dict[str, object] = _resource_dict(res)
# Optionally include child tree
if tree:
tree_data = service.get_resource_tree(resource, depth=-1)
data["children"] = _tree_to_dict(
tree_data[0].get("children", []) # type: ignore[union-attr]
if tree_data
else []
)
# Optionally show file content
if file is not None:
file_content = _read_resource_file(res, file)
data["file_path"] = file
data["file_content"] = file_content
if fmt != OutputFormat.RICH.value:
console.print(format_output(data, fmt))
return
# Rich display
props_display = ""
if res.properties:
props_lines = [f" {k}: {v}" for k, v in res.properties.items()]
props_display = "\n".join(props_lines)
else:
props_display = " (none)"
details = (
f"[bold]Resource ID:[/bold] {res.resource_id}\n"
f"[bold]Name:[/bold] {res.name or '(unnamed)'}\n"
f"[bold]Type:[/bold] {res.resource_type_name}\n"
f"[bold]Classification:[/bold] {res.classification}\n"
f"[bold]Description:[/bold] {res.description or '(none)'}\n"
f"[bold]Location:[/bold] {res.location or '(none)'}\n"
f"[bold]Properties:[/bold]\n{props_display}\n"
f"[bold]Created:[/bold] {res.created_at}\n"
f"[bold]Updated:[/bold] {res.updated_at}"
)
console.print(Panel(details, title="Resource Inspect", expand=False))
if tree:
console.print("\n[bold]Children:[/bold]")
tree_nodes = service.get_resource_tree(resource, depth=-1)
if tree_nodes:
children_raw = tree_nodes[0].get("children", [])
children_list: list[dict[str, object]] = []
if isinstance(children_raw, list):
children_list = children_raw
if children_list:
_print_tree_rich(children_list, indent=1)
else:
console.print(" (no children)")
if file is not None:
file_content = _read_resource_file(res, file)
console.print(f"\n[bold]File:[/bold] {file}")
console.print(file_content)
except NotFoundError as exc:
console.print(f"[red]Resource not found:[/red] {resource}")
raise typer.Abort() from exc
except CleverAgentsError as exc:
console.print(f"[red]Error:[/red] {exc.message}")
raise typer.Abort() from exc
def _read_resource_file(resource: Any, file_path: str) -> str:
"""Read a file relative to a resource's location.
For git-checkout and fs-mount resources, the file is resolved
relative to the resource's location (path property or location field).
"""
location = resource.location
if not location and resource.properties:
location = resource.properties.get("path")
if not location:
return "(no location set for this resource)"
full_path = Path(location) / file_path
if not full_path.exists():
return f"(file not found: {full_path})"
try:
return full_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
return f"(error reading file: {exc})"
# ---------------------------------------------------------------------------
# Resource link-child command
# ---------------------------------------------------------------------------
@app.command("link-child")
def resource_link_child(
parent: Annotated[
str,
typer.Argument(help="Parent resource name or ULID"),
],
child: Annotated[
str,
typer.Argument(help="Child resource name or ULID"),
],
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Create a DAG edge between two resources (parent -> child).
The child's resource type must be allowed by the parent's type
constraints. Cycles are rejected.
Examples:
agents resource link-child local/my-repo local/my-dir
"""
try:
service = _get_registry_service()
service.link_child(parent, child)
if fmt != OutputFormat.RICH.value:
data: dict[str, object] = {
"status": "linked",
"parent": parent,
"child": child,
}
console.print(format_output(data, fmt))
return
console.print(f"[green]Linked:[/green] {parent} -> {child}")
except NotFoundError as exc:
console.print(f"[red]Resource not found:[/red] {exc.resource_id}")
raise typer.Abort() from exc
except ValidationError as exc:
console.print(f"[red]Validation error:[/red] {exc.message}")
raise typer.Abort() from exc
except CleverAgentsError as exc:
console.print(f"[red]Error:[/red] {exc.message}")
raise typer.Abort() from exc
# ---------------------------------------------------------------------------
# Resource unlink-child command
# ---------------------------------------------------------------------------
@app.command("unlink-child")
def resource_unlink_child(
parent: Annotated[
str,
typer.Argument(help="Parent resource name or ULID"),
],
child: Annotated[
str,
typer.Argument(help="Child resource name or ULID"),
],
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Remove a DAG edge between two resources.
Requires confirmation unless ``--yes`` is passed.
Examples:
agents resource unlink-child local/my-repo local/my-dir
agents resource unlink-child --yes local/my-repo local/my-dir
"""
try:
if not yes:
confirm = typer.confirm(
f"Unlink '{child}' from '{parent}'?",
)
if not confirm:
console.print("[yellow]Aborted.[/yellow]")
raise typer.Abort()
service = _get_registry_service()
service.unlink_child(parent, child)
if fmt != OutputFormat.RICH.value:
data: dict[str, object] = {
"status": "unlinked",
"parent": parent,
"child": child,
}
console.print(format_output(data, fmt))
return
console.print(f"[green]Unlinked:[/green] {parent} -> {child}")
except NotFoundError as exc:
console.print(f"[red]Not found:[/red] {exc.resource_id}")
raise typer.Abort() from exc
except CleverAgentsError as exc:
console.print(f"[red]Error:[/red] {exc.message}")
raise typer.Abort() from exc
@app.command("remove")
def resource_remove(
resource: Annotated[