From 170f3f3b6440f9c7004c78ee1c6b27aec6a5bcce Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 29 Mar 2026 06:38:02 +0000 Subject: [PATCH 1/2] fix(cli): add missing resource command flags per specification Add missing CLI flags per specification to resource and LSP commands: - resource add --update: Re-register an existing resource by removing the old entry and creating a new one. When the resource does not yet exist, --update behaves as a normal add. - resource add --clone-into REPO_URL:CONTAINER_PATH: Clone a remote repository into a container at a specified path. Only valid for container-instance and devcontainer-instance types. Stored in resource properties as a JSON descriptor. - resource add --mount: Expand container type validation to accept both container-instance and devcontainer-instance (was restricted to only container-instance). - resource list --all: Include auto-discovered child resources in the listing. Default behavior now excludes auto-discovered resources (auto_discovered=True) by filtering at the service layer via a new include_auto_discovered parameter on list_resources(). - resource tree --depth: Change default from -1 (unlimited) to 3, matching the specification. - resource type list [REGEX]: Add optional positional regex argument to filter type names. - lsp list [REGEX]: Add optional positional regex argument to filter server names. All existing test step definitions updated to pass the new parameters. New Behave feature file (resource_cli_flags_904.feature) with 12 scenarios covering each new flag. ISSUES CLOSED: #904 --- features/resource_cli_flags_904.feature | 108 ++++ features/steps/coverage_boost_extra_steps.py | 2 + .../steps/resource_cli_flags_904_steps.py | 550 ++++++++++++++++++ features/steps/resource_cli_steps.py | 8 + .../steps/resource_type_bootstrap_fs_steps.py | 2 + .../resource_type_bootstrap_git_steps.py | 2 + .../services/_resource_registry_ops.py | 5 + src/cleveragents/cli/commands/lsp.py | 19 + src/cleveragents/cli/commands/resource.py | 118 +++- 9 files changed, 802 insertions(+), 12 deletions(-) create mode 100644 features/resource_cli_flags_904.feature create mode 100644 features/steps/resource_cli_flags_904_steps.py diff --git a/features/resource_cli_flags_904.feature b/features/resource_cli_flags_904.feature new file mode 100644 index 000000000..d4f9bff89 --- /dev/null +++ b/features/resource_cli_flags_904.feature @@ -0,0 +1,108 @@ +Feature: Resource and LSP CLI missing flags (issue #904) + As a CleverAgents user + I want the CLI to support all specification-defined flags + So that I can fully manage resources and LSP servers per the spec + + # ---- resource add --update ---- + + Scenario: Add a resource with --update re-registers it + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + And I add a resource flags resource "git-checkout" named "local/upd-test" with path "/tmp/u1" + When I run resource flags add "git-checkout" "local/upd-test" with path "/tmp/u2" and update + Then the resource flags output should contain "Added resource" + And the resource flags output should contain "local/upd-test" + + Scenario: Add with --update when resource does not yet exist + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + When I run resource flags add "git-checkout" "local/new-upd" with path "/tmp/nu" and update + Then the resource flags output should contain "Added resource" + + # ---- resource add --mount (container types) ---- + + Scenario: Mount flag rejected for non-container type + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + When I run resource flags add "git-checkout" "local/bad-mount" with path "/tmp/bm" and mount "local/repo:/workspace" + Then the resource flags command should fail + And the resource flags output should contain "container types" + + # ---- resource add --clone-into ---- + + Scenario: Clone-into flag rejected for non-container type + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + When I run resource flags add "git-checkout" "local/bad-clone" with path "/tmp/bc" and clone-into "https://example.com/repo.git:/workspace" + Then the resource flags command should fail + And the resource flags output should contain "container types" + + Scenario: Clone-into flag with invalid format + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + When I run resource flags add "container-instance" "local/bad-fmt" with clone-into "no-colon-here" + Then the resource flags command should fail + And the resource flags output should contain "REPO_URL" + + # ---- resource list --all ---- + + Scenario: Resource list default excludes auto-discovered resources + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + And a user-added resource flags resource "git-checkout" named "local/user-res" with path "/tmp/ur" + And an auto-discovered resource flags resource named "local/auto-res" + When I run resource flags list + Then the resource flags output should contain "local/user-res" + And the resource flags output should not contain "local/auto-res" + + Scenario: Resource list --all includes auto-discovered resources + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + And a user-added resource flags resource "git-checkout" named "local/user-res2" with path "/tmp/ur2" + And an auto-discovered resource flags resource named "local/auto-res2" + When I run resource flags list with --all + Then the resource flags output should contain "local/user-res2" + And the resource flags output should contain "local/auto-res2" + + # ---- resource tree --depth default ---- + + Scenario: Tree depth defaults to 3 + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + And a resource flags resource "git-checkout" named "local/depth-root" with path "/tmp/dr" + When I run resource flags tree for "local/depth-root" with default depth + Then the resource flags tree depth used should be 3 + + # ---- resource type list [REGEX] ---- + + Scenario: Resource type list with regex filter + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + When I run resource flags type list with regex "git" + Then the resource flags output should contain "git-checkout" + And the resource flags output should not contain "fs-directory" + + Scenario: Resource type list without regex shows all + Given a fresh resource flags test registry + And resource flags built-in types are bootstrapped + When I run resource flags type list without regex + Then the resource flags output should contain "git-checkout" + And the resource flags output should contain "fs-directory" + + # ---- lsp list [REGEX] ---- + + Scenario: LSP list with regex filter + Given a clean LSP flags test environment + And a registered LSP flags server "local/pyright-test" for language "python" + And a registered LSP flags server "local/clangd-test" for language "cpp" + When I run lsp flags list with regex "pyright" + Then the lsp flags output should contain "local/pyright-test" + And the lsp flags output should not contain "local/clangd-test" + + Scenario: LSP list without regex shows all + Given a clean LSP flags test environment + And a registered LSP flags server "local/pyright-test2" for language "python" + And a registered LSP flags server "local/clangd-test2" for language "cpp" + When I run lsp flags list without regex + Then the lsp flags output should contain "local/pyright-test2" + And the lsp flags output should contain "local/clangd-test2" diff --git a/features/steps/coverage_boost_extra_steps.py b/features/steps/coverage_boost_extra_steps.py index 31091b164..eaa4bd4fb 100644 --- a/features/steps/coverage_boost_extra_steps.py +++ b/features/steps/coverage_boost_extra_steps.py @@ -398,6 +398,8 @@ def step_run_resource_add_branch_only( branch=branch, description=None, read_only=False, + clone_into=None, + update=False, fmt="rich", ) context.resource_cli_output = output diff --git a/features/steps/resource_cli_flags_904_steps.py b/features/steps/resource_cli_flags_904_steps.py new file mode 100644 index 000000000..e430189e2 --- /dev/null +++ b/features/steps/resource_cli_flags_904_steps.py @@ -0,0 +1,550 @@ +"""Step definitions for ``resource_cli_flags_904.feature`` (issue #904). + +Tests the new CLI flags: + - resource add --update, --mount (container types), --clone-into + - resource list --all + - resource tree --depth default changed to 3 + - resource type list [REGEX] + - lsp list [REGEX] +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from io import StringIO +from typing import Any + +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.cli.commands.lsp import _reset_registry +from cleveragents.infrastructure.database.models import Base, ResourceModel +from cleveragents.lsp.models import LspCapability, LspServerConfig + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_flags_service(context: Context) -> ResourceRegistryService: + """Create or return a cached ResourceRegistryService for flags tests.""" + if not hasattr(context, "flags_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.flags_cli_service = ResourceRegistryService(session_factory=factory) + return context.flags_cli_service + + +def _capture_flags_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]: + """Run a CLI function capturing its console output and success status.""" + import contextlib + + buf = StringIO() + console = Console( + file=buf, width=200, no_color=True, highlight=False, force_terminal=False + ) + + import cleveragents.cli.commands.resource as resource_mod + + orig_console = resource_mod.console + resource_mod.console = console + + failed = False + try: + with contextlib.redirect_stdout(buf): + func(*args, **kwargs) + except SystemExit: + failed = True + except Exception: + failed = True + finally: + resource_mod.console = orig_console + + return buf.getvalue(), failed + + +def _patch_flags_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_flags_service(context) + + resource_mod._get_registry_service = _mock_get + return orig_fn + + +def _unpatch_flags_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 +# --------------------------------------------------------------------------- + + +@given("a fresh resource flags test registry") +def step_fresh_flags_registry(context: Context) -> None: + 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.flags_cli_service = ResourceRegistryService(session_factory=factory) + context.flags_cli_output = "" + context.flags_cli_failed = False + context.flags_tree_depth_used = None + + +@given("resource flags built-in types are bootstrapped") +def step_flags_bootstrap(context: Context) -> None: + service = _make_flags_service(context) + service.bootstrap_builtin_types() + + +@given( + 'I add a resource flags resource "{type_name}" named "{name}" with path "{path}"' +) +def step_flags_add_resource_given( + context: Context, type_name: str, name: str, path: str +) -> None: + from cleveragents.cli.commands.resource import resource_add + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output( + resource_add, + type_name=type_name, + name=name, + path=path, + branch=None, + description=None, + read_only=False, + clone_into=None, + update=False, + fmt="rich", + ) + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +@given('a resource flags resource "{type_name}" named "{name}" with path "{path}"') +def step_flags_add_resource_given_alt( + context: Context, type_name: str, name: str, path: str +) -> None: + service = _make_flags_service(context) + service.register_resource( + type_name=type_name, + name=name, + location=path, + description=None, + read_only=False, + properties={"path": path}, + ) + + +@given( + 'a user-added resource flags resource "{type_name}" named "{name}" with path "{path}"' +) +def step_flags_add_user_resource( + context: Context, type_name: str, name: str, path: str +) -> None: + service = _make_flags_service(context) + service.register_resource( + type_name=type_name, + name=name, + location=path, + description=None, + read_only=False, + properties={"path": path}, + ) + + +@given('an auto-discovered resource flags resource named "{name}"') +def step_flags_add_auto_discovered(context: Context, name: str) -> None: + """Directly insert an auto-discovered resource into the DB.""" + from ulid import ULID + + service = _make_flags_service(context) + session = service._session() + try: + now_iso = datetime.now(tz=UTC).isoformat() + db_resource = ResourceModel( + resource_id=str(ULID()), + namespaced_name=name, + namespace=name.split("/", 1)[0] if "/" in name else None, + type_name="git-checkout", + resource_kind="physical", + location="/tmp/auto", + description="Auto-discovered", + read_only=False, + auto_discovered=True, + sandbox_strategy=None, + content_hash=None, + properties_json=None, + metadata_json=None, + created_at=now_iso, + updated_at=now_iso, + ) + session.add(db_resource) + session.commit() + finally: + session.close() + + +# --------------------------------------------------------------------------- +# LSP Given +# --------------------------------------------------------------------------- + + +@given("a clean LSP flags test environment") +def step_clean_lsp_flags(context: Context) -> None: + _reset_registry() + context.lsp_flags_output = "" + context.lsp_flags_failed = False + + +@given('a registered LSP flags server "{name}" for language "{lang}"') +def step_register_lsp_flags_server(context: Context, name: str, lang: str) -> None: + from cleveragents.cli.commands.lsp import _get_registry + + registry = _get_registry() + config = LspServerConfig( + name=name, + command="test-server", + args=["--stdio"], + languages=[lang], + capabilities=[LspCapability.DIAGNOSTICS], + ) + registry.register(config) + + +# --------------------------------------------------------------------------- +# When steps — resource add +# --------------------------------------------------------------------------- + + +@when('I run resource flags add "{type_name}" "{name}" with path "{path}" and update') +def step_flags_add_update( + context: Context, type_name: str, name: str, path: str +) -> None: + from cleveragents.cli.commands.resource import resource_add + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output( + resource_add, + type_name=type_name, + name=name, + path=path, + branch=None, + description=None, + read_only=False, + clone_into=None, + update=True, + fmt="rich", + ) + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +@when( + 'I run resource flags add "{type_name}" "{name}" with path "{path}" and mount "{mount_spec}"' +) +def step_flags_add_mount( + context: Context, type_name: str, name: str, path: str, mount_spec: str +) -> None: + from cleveragents.cli.commands.resource import resource_add + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output( + resource_add, + type_name=type_name, + name=name, + path=path, + branch=None, + description=None, + read_only=False, + mount=[mount_spec], + clone_into=None, + update=False, + fmt="rich", + ) + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +@when( + 'I run resource flags add "{type_name}" "{name}" with path "{path}" and clone-into "{clone_spec}"' +) +def step_flags_add_clone_into( + context: Context, type_name: str, name: str, path: str, clone_spec: str +) -> None: + from cleveragents.cli.commands.resource import resource_add + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output( + resource_add, + type_name=type_name, + name=name, + path=path, + branch=None, + description=None, + read_only=False, + clone_into=clone_spec, + update=False, + fmt="rich", + ) + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +@when('I run resource flags add "{type_name}" "{name}" with clone-into "{clone_spec}"') +def step_flags_add_clone_into_no_path( + context: Context, type_name: str, name: str, clone_spec: str +) -> None: + from cleveragents.cli.commands.resource import resource_add + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output( + resource_add, + type_name=type_name, + name=name, + path=None, + branch=None, + description=None, + read_only=False, + clone_into=clone_spec, + update=False, + fmt="rich", + ) + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +# --------------------------------------------------------------------------- +# When steps — resource list +# --------------------------------------------------------------------------- + + +@when("I run resource flags list") +def step_flags_list(context: Context) -> None: + from cleveragents.cli.commands.resource import resource_list + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output( + resource_list, type_filter=None, show_all=False, fmt="rich" + ) + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +@when("I run resource flags list with --all") +def step_flags_list_all(context: Context) -> None: + from cleveragents.cli.commands.resource import resource_list + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output( + resource_list, type_filter=None, show_all=True, fmt="rich" + ) + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +# --------------------------------------------------------------------------- +# When steps — resource tree +# --------------------------------------------------------------------------- + + +@when('I run resource flags tree for "{name}" with default depth') +def step_flags_tree_default(context: Context, name: str) -> None: + """Run resource tree and capture the depth that was actually used.""" + from cleveragents.cli.commands.resource import resource_tree + + orig = _patch_flags_service(context) + try: + # We call resource_tree *without* passing depth so the default is used. + # We capture what depth the function uses by inspecting its default. + import inspect + + sig = inspect.signature(resource_tree) + default_depth = sig.parameters["depth"].default + context.flags_tree_depth_used = default_depth + + output, failed = _capture_flags_output( + resource_tree, resource=name, type_filter=None, fmt="rich" + ) + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +# --------------------------------------------------------------------------- +# When steps — resource type list [REGEX] +# --------------------------------------------------------------------------- + + +@when('I run resource flags type list with regex "{regex}"') +def step_flags_type_list_regex(context: Context, regex: str) -> None: + from cleveragents.cli.commands.resource import type_list + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output(type_list, regex=regex, fmt="rich") + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +@when("I run resource flags type list without regex") +def step_flags_type_list_no_regex(context: Context) -> None: + from cleveragents.cli.commands.resource import type_list + + orig = _patch_flags_service(context) + try: + output, failed = _capture_flags_output(type_list, regex=None, fmt="rich") + context.flags_cli_output = output + context.flags_cli_failed = failed + finally: + _unpatch_flags_service(orig) + + +# --------------------------------------------------------------------------- +# When steps — lsp list [REGEX] +# --------------------------------------------------------------------------- + + +def _capture_lsp_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]: + """Run an LSP CLI function capturing its console output.""" + import contextlib + + buf = StringIO() + console = Console( + file=buf, width=200, no_color=True, highlight=False, force_terminal=False + ) + + import cleveragents.cli.commands.lsp as lsp_mod + + orig_console = lsp_mod.console + lsp_mod.console = console + + failed = False + try: + with contextlib.redirect_stdout(buf): + func(*args, **kwargs) + except SystemExit: + failed = True + except Exception: + failed = True + finally: + lsp_mod.console = orig_console + + return buf.getvalue(), failed + + +@when('I run lsp flags list with regex "{regex}"') +def step_lsp_flags_list_regex(context: Context, regex: str) -> None: + from cleveragents.cli.commands.lsp import list_servers + + output, failed = _capture_lsp_output(list_servers, regex=regex, fmt="rich") + context.lsp_flags_output = output + context.lsp_flags_failed = failed + + +@when("I run lsp flags list without regex") +def step_lsp_flags_list_no_regex(context: Context) -> None: + from cleveragents.cli.commands.lsp import list_servers + + output, failed = _capture_lsp_output(list_servers, regex=None, fmt="rich") + context.lsp_flags_output = output + context.lsp_flags_failed = failed + + +# --------------------------------------------------------------------------- +# Then steps +# --------------------------------------------------------------------------- + + +@then('the resource flags output should contain "{text}"') +def step_flags_output_contains(context: Context, text: str) -> None: + output = context.flags_cli_output + assert text.lower() in output.lower(), ( + f"Expected '{text}' in output, got:\n{output}" + ) + + +@then('the resource flags output should not contain "{text}"') +def step_flags_output_not_contains(context: Context, text: str) -> None: + output = context.flags_cli_output + assert text.lower() not in output.lower(), ( + f"Did not expect '{text}' in output, got:\n{output}" + ) + + +@then("the resource flags command should fail") +def step_flags_command_failed(context: Context) -> None: + assert context.flags_cli_failed, "Expected command to fail but it succeeded" + + +@then("the resource flags tree depth used should be {expected_depth:d}") +def step_flags_tree_depth(context: Context, expected_depth: int) -> None: + assert context.flags_tree_depth_used == expected_depth, ( + f"Expected default depth {expected_depth}, got {context.flags_tree_depth_used}" + ) + + +@then('the lsp flags output should contain "{text}"') +def step_lsp_flags_output_contains(context: Context, text: str) -> None: + output = context.lsp_flags_output + assert text.lower() in output.lower(), ( + f"Expected '{text}' in output, got:\n{output}" + ) + + +@then('the lsp flags output should not contain "{text}"') +def step_lsp_flags_output_not_contains(context: Context, text: str) -> None: + output = context.lsp_flags_output + assert text.lower() not in output.lower(), ( + f"Did not expect '{text}' in output, got:\n{output}" + ) diff --git a/features/steps/resource_cli_steps.py b/features/steps/resource_cli_steps.py index b8e7cd512..5bf3ac3a6 100644 --- a/features/steps/resource_cli_steps.py +++ b/features/steps/resource_cli_steps.py @@ -268,6 +268,8 @@ def _do_resource_add(context: Context, type_name: str, name: str, path: str) -> branch=None, description=None, read_only=False, + clone_into=None, + update=False, fmt="rich", ) context.resource_cli_output = output @@ -519,6 +521,8 @@ def step_run_resource_add_fmt( branch=None, description=None, read_only=False, + clone_into=None, + update=False, fmt=fmt, ) context.resource_cli_output = output @@ -552,6 +556,8 @@ def step_run_resource_add_branch_desc( branch=branch if branch else None, description=desc, read_only=False, + clone_into=None, + update=False, fmt="rich", ) context.resource_cli_output = output @@ -593,6 +599,8 @@ def step_add_resource_long_desc(context: Context) -> None: branch=None, description=long_desc, read_only=False, + clone_into=None, + update=False, fmt="rich", ) context.resource_cli_output = output diff --git a/features/steps/resource_type_bootstrap_fs_steps.py b/features/steps/resource_type_bootstrap_fs_steps.py index 2e46cd16a..504505214 100644 --- a/features/steps/resource_type_bootstrap_fs_steps.py +++ b/features/steps/resource_type_bootstrap_fs_steps.py @@ -141,6 +141,8 @@ def step_run_resource_add_fs( branch=None, description=None, image=None, + clone_into=None, + update=False, read_only=False, fmt="rich", ) diff --git a/features/steps/resource_type_bootstrap_git_steps.py b/features/steps/resource_type_bootstrap_git_steps.py index ee36d695b..bb85589b1 100644 --- a/features/steps/resource_type_bootstrap_git_steps.py +++ b/features/steps/resource_type_bootstrap_git_steps.py @@ -140,6 +140,8 @@ def step_run_resource_add_git( branch=branch, description=None, image=None, + clone_into=None, + update=False, read_only=False, fmt="rich", ) diff --git a/src/cleveragents/application/services/_resource_registry_ops.py b/src/cleveragents/application/services/_resource_registry_ops.py index 3e8b04c21..0d54a1b04 100644 --- a/src/cleveragents/application/services/_resource_registry_ops.py +++ b/src/cleveragents/application/services/_resource_registry_ops.py @@ -179,6 +179,7 @@ class ResourceInstanceMixin: type_name: str | None = None, *, exact: bool = False, + include_auto_discovered: bool = True, ) -> list[Resource]: """List registered resources. @@ -188,6 +189,8 @@ class ResourceInstanceMixin: include resources whose type is a **subtype** of *type_name* (ADR-042 polymorphic listing). When ``True``, match only the exact type name. + include_auto_discovered: When ``False``, exclude resources + with ``auto_discovered=True`` (default shows all). Returns: List of ``Resource`` domain objects. @@ -204,6 +207,8 @@ class ResourceInstanceMixin: subtypes = find_subtypes(type_name, registry) all_types = [type_name, *subtypes] query = query.filter(ResourceModel.type_name.in_(all_types)) + if not include_auto_discovered: + query = query.filter(ResourceModel.auto_discovered == False) # noqa: E712 query = query.order_by(ResourceModel.created_at) rows = query.all() return [db_resource_to_domain(row) for row in rows] diff --git a/src/cleveragents/cli/commands/lsp.py b/src/cleveragents/cli/commands/lsp.py index 221dc91fe..f349aac3c 100644 --- a/src/cleveragents/cli/commands/lsp.py +++ b/src/cleveragents/cli/commands/lsp.py @@ -48,6 +48,7 @@ from __future__ import annotations import json import logging import os +import re as _re from pathlib import Path from typing import Annotated, Any @@ -237,6 +238,12 @@ def remove( @app.command("list") def list_servers( + regex: Annotated[ + str | None, + typer.Argument( + help="Optional regex to filter server names", + ), + ] = None, namespace: Annotated[ str | None, typer.Option("--namespace", "-n", help="Filter by namespace"), @@ -252,8 +259,11 @@ def list_servers( ) -> None: """List registered LSP servers. + An optional positional REGEX argument filters server names. + Examples: agents lsp list + agents lsp list "pyright.*" agents lsp list --namespace local agents lsp list --language python agents lsp list --format json @@ -261,6 +271,15 @@ def list_servers( registry = _get_registry() servers = registry.list_servers(namespace=namespace, language=language) + # Apply optional regex filter on server names. + if regex is not None: + try: + pattern = _re.compile(regex) + except _re.error as re_exc: + console.print(f"[red]Invalid regex:[/red] {re_exc}") + raise typer.Abort() from re_exc + servers = [s for s in servers if pattern.search(s.name)] + if not servers: console.print("[yellow]No LSP servers found.[/yellow]") console.print("Register one with 'agents lsp add --config '") diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index e594ae3be..b4b406d52 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -55,6 +55,7 @@ from __future__ import annotations import json import logging +import re from pathlib import Path from typing import Annotated, Any @@ -100,6 +101,9 @@ console = _get_console() _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" +# Resource types that support --mount and --clone-into flags. +_CONTAINER_TYPES = frozenset({"container-instance", "devcontainer-instance"}) + def _get_registry_service() -> ResourceRegistryService: """Get the ResourceRegistryService from the DI container.""" @@ -291,6 +295,12 @@ def type_remove( @type_app.command("list") def type_list( + regex: Annotated[ + str | None, + typer.Argument( + help="Optional regex to filter type names", + ), + ] = None, fmt: Annotated[ str, typer.Option("--format", "-f", help=_FORMAT_HELP), @@ -298,14 +308,26 @@ def type_list( ) -> None: """List all registered resource types (built-in and custom). + An optional positional REGEX argument filters type names. + Examples: agents resource type list + agents resource type list "git.*" agents resource type list --format json """ try: service = _get_registry_service() types = service.list_types() + # Apply optional regex filter on type names. + if regex is not None: + try: + pattern = re.compile(regex) + except re.error as re_exc: + console.print(f"[red]Invalid regex:[/red] {re_exc}") + raise typer.Abort() from re_exc + types = [t for t in types if pattern.search(t.name)] + if not types: console.print("[yellow]No resource types registered.[/yellow]") return @@ -548,7 +570,7 @@ def resource_add( typer.Option( "--mount", help=( - "Mount specification for container-instance resources. " + "Mount specification for container types. " "Formats: : (resource-ref, rw) or " ":: (host-path, mode=rw|ro). " "Can be specified multiple times." @@ -560,12 +582,19 @@ def resource_add( typer.Option( "--clone-into", help=( - "Clone a remote repository into the container at the specified " - "path. Format: REPO_URL:CONTAINER_PATH (e.g., " - "https://github.com/acme/api.git:/workspace)" + "Clone a repository into the container at a specified path. " + "Format: :. " + "Only valid for container types." ), ), ] = None, + update: Annotated[ + bool, + typer.Option( + "--update", + help="Re-register an existing resource (remove and re-add).", + ), + ] = False, read_only: Annotated[ bool, typer.Option("--read-only", help="Mark resource as read-only"), @@ -577,15 +606,19 @@ def resource_add( ) -> None: """Add a new resource instance of the specified type. + Use ``--update`` to re-register an existing resource by name. + Examples: 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 add devcontainer-instance local/dc --path /project agents resource add container-instance local/ctr --image ubuntu:latest - agents resource add container-instance local/ctr --image node:18 \ - --mount local/api-repo:/workspace \ + agents resource add container-instance local/ctr --image node:18 \\ + --mount local/api-repo:/workspace \\ --mount /var/shared/config:/config:ro - agents resource add container-instance cloud/ci --clone-into https://github.com/acme/api.git:/workspace + agents resource add container-instance local/ctr --image node:18 \\ + --clone-into https://github.com/org/repo.git:/workspace/repo + agents resource add git-checkout local/my-repo --path /tmp --update """ try: # Validate --clone-into is only used with container resource types. @@ -612,6 +645,36 @@ def resource_add( service = _get_registry_service() + # --update: remove existing resource before re-registering + if update: + try: + existing = service.show_resource(name) + # Remove the existing resource so we can re-register + session = service._session() + try: + from cleveragents.infrastructure.database.models import ( + ResourceEdgeModel, + ResourceModel, + ) + + # Remove edges first + session.query(ResourceEdgeModel).filter( + (ResourceEdgeModel.parent_id == existing.resource_id) + | (ResourceEdgeModel.child_id == existing.resource_id) + ).delete(synchronize_session="fetch") + session.query(ResourceModel).filter_by( + resource_id=existing.resource_id + ).delete(synchronize_session="fetch") + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + except NotFoundError: + # Resource doesn't exist yet — proceed with normal add + pass + # Build properties from type-specific flags properties: dict[str, Any] = {} if path is not None: @@ -621,15 +684,32 @@ def resource_add( if image is not None: properties["image"] = image if mount: - if type_name != "container-instance": + if type_name not in _CONTAINER_TYPES: console.print( "[red]Error:[/red] --mount is only valid for " - "container-instance resources." + "container types " + f"({', '.join(sorted(_CONTAINER_TYPES))})." ) raise typer.Abort() properties["mounts"] = json.dumps([_parse_mount_spec(m) for m in mount]) if clone_into is not None: - properties["clone_into"] = clone_into + if type_name not in _CONTAINER_TYPES: + console.print( + "[red]Error:[/red] --clone-into is only valid for " + "container types " + f"({', '.join(sorted(_CONTAINER_TYPES))})." + ) + raise typer.Abort() + parts = clone_into.split(":", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + console.print( + "[red]Error:[/red] --clone-into must be in " + ": format." + ) + raise typer.Abort() + properties["clone_into"] = json.dumps( + {"repo_url": parts[0], "container_path": parts[1]} + ) resource = service.register_resource( type_name=type_name, @@ -672,6 +752,13 @@ def resource_list( str | None, typer.Option("--type", "-t", help="Filter by resource type"), ] = None, + show_all: Annotated[ + bool, + typer.Option( + "--all", + help="Include auto-discovered child resources in the listing.", + ), + ] = False, fmt: Annotated[ str, typer.Option("--format", "-f", help=_FORMAT_HELP), @@ -679,14 +766,21 @@ def resource_list( ) -> None: """List registered resources. + By default only user-added (named) resources are shown. Use + ``--all`` to include auto-discovered children. + Examples: agents resource list + agents resource list --all agents resource list --type git-checkout agents resource list --format json """ try: service = _get_registry_service() - resources = service.list_resources(type_name=type_filter) + resources = service.list_resources( + type_name=type_filter, + include_auto_discovered=show_all, + ) if not resources: console.print("[yellow]No resources found.[/yellow]") @@ -806,7 +900,7 @@ def resource_tree( depth: Annotated[ int, typer.Option("--depth", "-d", help="Maximum tree depth (-1 for unlimited)"), - ] = -1, + ] = 3, type_filter: Annotated[ str | None, typer.Option("--type", "-t", help="Filter children by resource type"), -- 2.52.0 From e522f8ca337f2fcd80a4b34bfd9f7fe53d080b21 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 2 Apr 2026 08:25:18 +0000 Subject: [PATCH 2/2] fix(cli): use rsplit for clone-into URL parsing in properties block The properties-building block (line 703) used split(":", 1) which incorrectly splits HTTPS URLs like https://github.com/org/repo.git:/workspace. The early validation block (line 637) already correctly used rsplit(":", 1). This fix makes both blocks consistent. ISSUES CLOSED: #904 --- src/cleveragents/cli/commands/resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index b4b406d52..420d5f864 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -700,7 +700,7 @@ def resource_add( f"({', '.join(sorted(_CONTAINER_TYPES))})." ) raise typer.Abort() - parts = clone_into.split(":", 1) + parts = clone_into.rsplit(":", 1) if len(parts) != 2 or not parts[0] or not parts[1]: console.print( "[red]Error:[/red] --clone-into must be in " -- 2.52.0