From c562557da81606cfb228c9b19e96c1a736dede84 Mon Sep 17 00:00:00 2001 From: Aditya Chhabra Date: Tue, 17 Feb 2026 14:44:40 +0000 Subject: [PATCH] feat(cli): add skill commands --- benchmarks/skill_cli_bench.py | 154 ++++ docs/reference/skill_cli.md | 290 +++++++ features/skill_cli.feature | 196 +++++ features/steps/skill_cli_steps.py | 498 +++++++++++ implementation_plan.md | 51 +- robot/helper_skill_cli.py | 241 ++++++ robot/skill_cli.robot | 65 ++ .../application/services/skill_service.py | 305 +++++++ src/cleveragents/cli/commands/skill.py | 785 ++++++++++++++++++ src/cleveragents/cli/main.py | 7 + 10 files changed, 2585 insertions(+), 7 deletions(-) create mode 100644 benchmarks/skill_cli_bench.py create mode 100644 docs/reference/skill_cli.md create mode 100644 features/skill_cli.feature create mode 100644 features/steps/skill_cli_steps.py create mode 100644 robot/helper_skill_cli.py create mode 100644 robot/skill_cli.robot create mode 100644 src/cleveragents/application/services/skill_service.py create mode 100644 src/cleveragents/cli/commands/skill.py diff --git a/benchmarks/skill_cli_bench.py b/benchmarks/skill_cli_bench.py new file mode 100644 index 000000000..7a6d34909 --- /dev/null +++ b/benchmarks/skill_cli_bench.py @@ -0,0 +1,154 @@ +"""ASV benchmarks for Skill CLI command throughput. + +Measures the performance of: +- Config-only add (YAML load + validate + Skill registration) +- List command rendering +- Show command rendering +- Tools command (resolver flattening) +""" + +from __future__ import annotations + +import importlib +import os +import sys +import tempfile +from datetime import datetime +from pathlib import Path + +# 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.application.services.skill_service import SkillService # noqa: E402 +from cleveragents.cli.commands import skill as skill_mod # noqa: E402 +from cleveragents.cli.commands.skill import app as skill_app # noqa: E402 +from cleveragents.domain.models.core.skill import Skill # noqa: E402 + +_VALID_YAML = """\ +name: local/bench-skill +description: "Benchmark skill" +tools: + - name: builtin/read_file + - name: builtin/write_file + - name: builtin/list_directory +""" + +_runner = CliRunner() + + +def _fresh_service() -> SkillService: + """Create and install a fresh service for benchmarking.""" + svc = SkillService() + skill_mod._service = svc + return svc + + +class SkillCLIAddSuite: + """Benchmark skill add --config throughput.""" + + def setup(self) -> None: + """Write a temporary YAML file and set up fresh service.""" + fd, self._path = tempfile.mkstemp(suffix=".yaml") + with os.fdopen(fd, "w") as fh: + fh.write(_VALID_YAML) + + def teardown(self) -> None: + """Clean up.""" + Path(self._path).unlink(missing_ok=True) + + def time_add_from_config(self) -> None: + """Benchmark add --config end-to-end.""" + _fresh_service() + _runner.invoke(skill_app, ["add", "--config", self._path]) + + +class SkillCLIListSuite: + """Benchmark skill list throughput.""" + + def setup(self) -> None: + """Set up service with skills.""" + svc = _fresh_service() + for i in range(50): + name = f"local/skill-{i}" + skill = Skill( + name=name, + description=f"Benchmark skill {i}", + tool_refs=["builtin/read_file", "builtin/write_file"], + ) + svc._skills[name] = skill + svc._created_at[name] = datetime.now() + svc._updated_at[name] = datetime.now() + + def teardown(self) -> None: + _fresh_service() + + def time_list_all(self) -> None: + """Benchmark listing all skills.""" + _runner.invoke(skill_app, ["list"]) + + def time_list_with_namespace(self) -> None: + """Benchmark listing with namespace filter.""" + _runner.invoke(skill_app, ["list", "--namespace", "local"]) + + +class SkillCLIShowSuite: + """Benchmark skill show throughput.""" + + def setup(self) -> None: + svc = _fresh_service() + skill = Skill( + name="local/bench-show", + description="Benchmark skill for show", + tool_refs=[ + "builtin/read_file", + "builtin/write_file", + "builtin/list_directory", + ], + ) + svc._skills["local/bench-show"] = skill + svc._created_at["local/bench-show"] = datetime.now() + svc._updated_at["local/bench-show"] = datetime.now() + + def teardown(self) -> None: + _fresh_service() + + def time_show(self) -> None: + """Benchmark showing a skill.""" + _runner.invoke(skill_app, ["show", "local/bench-show"]) + + +class SkillCLIToolsSuite: + """Benchmark skill tools (resolver) throughput.""" + + def setup(self) -> None: + svc = _fresh_service() + skill = Skill( + name="local/bench-tools", + description="Benchmark skill for tools resolution", + tool_refs=[ + "builtin/read_file", + "builtin/write_file", + "builtin/list_directory", + "builtin/search_files", + "builtin/delete_file", + ], + ) + svc._skills["local/bench-tools"] = skill + svc._created_at["local/bench-tools"] = datetime.now() + svc._updated_at["local/bench-tools"] = datetime.now() + + def teardown(self) -> None: + _fresh_service() + + def time_tools(self) -> None: + """Benchmark resolving tools for a skill.""" + _runner.invoke(skill_app, ["tools", "local/bench-tools"]) diff --git a/docs/reference/skill_cli.md b/docs/reference/skill_cli.md new file mode 100644 index 000000000..3bed61b6f --- /dev/null +++ b/docs/reference/skill_cli.md @@ -0,0 +1,290 @@ +# Skill CLI Reference + +The `agents skill` command group manages **skills** — reusable, composable collections of tools that can be registered, inspected, and attached to actors. + +## Commands + +| Command | Description | +|---------|-------------| +| `agents skill add` | Register a skill from a YAML config file | +| `agents skill remove` | Remove a registered skill | +| `agents skill list` | List registered skills with optional filters | +| `agents skill show` | Show full details for a registered skill | +| `agents skill tools` | List all tools provided by a skill (flattened) | + +--- + +## `agents skill add` + +Register a new skill from a YAML configuration file. + +```bash +agents skill add --config [--update] [--format ] +``` + +### Options + +| Flag | Short | Description | +|------|-------|-------------| +| `--config` | `-c` | Path to the skill YAML configuration file (required) | +| `--update` | | Allow overwriting an existing skill registration | +| `--format` | `-f` | Output format: `rich`, `json`, `yaml`, `plain`, `table` (default: `rich`) | + +### Examples + +```bash +# Register a new skill +agents skill add --config examples/skills/single-tool.yaml + +# Update an existing skill +agents skill add --config examples/skills/single-tool.yaml --update + +# Register and output as JSON +agents skill add --config my-skill.yaml --format json +``` + +### Rich Output + +On success, the command prints a **Skill Registered** panel showing: +- Name, Description, Config path +- Includes list (if any) +- Direct tools with source, writes, and checkpoint columns +- MCP servers (if any) +- Capability summary (total tools, read-only, writes, checkpointable, side effects) +- Success message with tool count + +### Error Handling + +- **File not found**: Prints config file path and aborts. +- **Schema validation error**: Prints Pydantic validation details and aborts. +- **Duplicate skill**: Prints the existing name and suggests `--update`. + +--- + +## `agents skill remove` + +Remove a registered skill by its namespaced name. + +```bash +agents skill remove [--yes] [--format ] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `NAME` | Namespaced name of the skill to remove (e.g. `local/file-reader`) | + +### Options + +| Flag | Short | Description | +|------|-------|-------------| +| `--yes` | `-y` | Skip confirmation prompt | +| `--format` | `-f` | Output format (default: `rich`) | + +### Examples + +```bash +# Remove with confirmation prompt +agents skill remove local/file-reader + +# Remove without confirmation +agents skill remove local/file-reader --yes +``` + +### Rich Output + +On success, the command prints a **Skill Removed** panel showing: +- Name of removed skill +- Number of tools removed from registry +- Number of MCP connections closed +- Dependency check (if other skills include the removed skill) + +--- + +## `agents skill list` + +List registered skills with optional namespace and source filters. + +```bash +agents skill list [--namespace ] [--source ] [--format ] +``` + +### Options + +| Flag | Short | Description | +|------|-------|-------------| +| `--namespace` | `-n` | Filter by namespace (e.g. `local`, `remote`) | +| `--source` | | Filter by tool source type: `mcp`, `agent_skill`, `builtin`, `custom` | +| `--format` | `-f` | Output format (default: `rich`) | + +### Examples + +```bash +# List all skills +agents skill list + +# List skills in a specific namespace +agents skill list --namespace local + +# List only MCP-backed skills +agents skill list --source mcp + +# List as JSON for scripting +agents skill list --format json +``` + +### Rich Output + +Produces a table with columns: +- **Name** — Namespaced skill name +- **Description** — Skill description +- **Tools** — Total tool count (resolved) +- **Includes** — Number of included skills + +Followed by a **Summary** panel with total counts and a success message. + +--- + +## `agents skill show` + +Show full details for a registered skill. + +```bash +agents skill show [--format ] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `NAME` | Namespaced name of the skill to show | + +### Options + +| Flag | Short | Description | +|------|-------|-------------| +| `--format` | `-f` | Output format (default: `rich`) | + +### Examples + +```bash +# Show skill details +agents skill show local/file-reader + +# Show as YAML +agents skill show local/file-reader --format yaml +``` + +### Rich Output + +Prints multiple panels: +1. **Skill Details** — Name, Description, Config path, Created/Updated timestamps +2. **Includes** — List of included skills (if any) +3. **Direct Tools** — Table with Tool, Source, Writes, Checkpoint columns +4. **MCP Servers** — Server name, transport, tool count, status (if any) +5. **Agent Skill Folders** — Folder paths (if any) +6. **Capability Summary** — Total tools, read-only, writes, checkpointable, side effects + +--- + +## `agents skill tools` + +List all tools provided by a skill, including tools from included skills (flattened, de-duplicated). + +```bash +agents skill tools [--format ] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `NAME` | Namespaced name of the skill to resolve tools for | + +### Options + +| Flag | Short | Description | +|------|-------|-------------| +| `--format` | `-f` | Output format (default: `rich`) | + +### Examples + +```bash +# Show flattened tool list +agents skill tools local/composed-skill + +# Show as JSON +agents skill tools local/composed-skill --format json +``` + +### Rich Output + +Produces a table with columns: +- **Tool** — Tool name +- **Source** — Source type (builtin, mcp, agent_skill, inline) +- **From Skill** — Which skill contributed the tool (or "(direct)") +- **Read-Only** — Whether the tool is read-only +- **Writes** — Whether the tool writes +- **Checkpoint** — Whether the tool supports checkpointing + +Followed by a tool count and success message. + +### Error Handling + +- **Skill not found**: Prints error and aborts. +- **Circular includes**: Detects cycles and prints the cycle path. + +--- + +## Output Formats + +All skill commands support the `--format` flag with the following options: + +| Format | Description | +|--------|-------------| +| `rich` | Full Rich rendering with panels, tables, colors (default) | +| `json` | Machine-readable JSON output | +| `yaml` | Structured YAML output | +| `plain` | Plain text without formatting | +| `table` | Tabular output | + +Structured formats (`json`, `yaml`) produce machine-parseable output suitable for piping to `jq`, `yq`, or other tools. + +--- + +## Skill YAML Configuration + +Skills are defined in YAML files. See `docs/schema/skill.schema.yaml` for the full schema. + +### Minimal Example + +```yaml +name: local/file-reader +description: "Basic file reading operations" +tools: + - name: builtin/read_file + - name: builtin/list_directory +``` + +### Composed Example + +```yaml +name: local/git-github +description: "Git operations and GitHub integration" +tools: + - name: builtin/shell_execute +includes: + - name: local/file-reader +mcp_servers: + - name: github + transport: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_TOKEN}" + tool_filter: + include: [create_issue, list_issues] +``` + +See `examples/skills/` for more configuration examples. diff --git a/features/skill_cli.feature b/features/skill_cli.feature new file mode 100644 index 000000000..4c2c75a11 --- /dev/null +++ b/features/skill_cli.feature @@ -0,0 +1,196 @@ +Feature: Skill CLI commands + As a developer + I want to manage skills via CLI commands + So that I can register, inspect, and manage reusable tool collections + + Background: + Given a skill CLI test runner + And the skill service is reset + + # ─────────────────────────────────────────────────────── + # skill add — registration + # ─────────────────────────────────────────────────────── + + Scenario: Add skill from valid config file + Given a valid skill config YAML file at a temp path + When I run skill CLI add with --config pointing to the YAML file + Then the skill CLI add should succeed + And the skill CLI output should contain "Skill Registered" + And the skill CLI output should contain "local/file-reader" + And the skill CLI output should contain "✓ OK" + + Scenario: Add skill with all sections populated + Given a full skill config YAML file at a temp path + When I run skill CLI add with --config pointing to the YAML file + Then the skill CLI add should succeed + And the skill CLI output should contain "Skill Registered" + And the skill CLI output should contain "Tool Sources" + + Scenario: Add skill with duplicate name fails without --update + Given a valid skill config YAML file at a temp path + And the skill "local/file-reader" is already registered + When I run skill CLI add with --config pointing to the YAML file + Then the skill CLI command should abort + And the skill CLI output should contain "already registered" + And the skill CLI output should contain "--update" + + Scenario: Add skill with --update overwrites existing + Given a valid skill config YAML file at a temp path + And the skill "local/file-reader" is already registered + When I run skill CLI add with --config and --update pointing to the YAML file + Then the skill CLI add should succeed + And the skill CLI output should contain "Skill Updated" + And the skill CLI output should contain "Changes" + + Scenario: Add skill with missing config file fails + When I run skill CLI add with --config pointing to a missing file + Then the skill CLI command should abort + + Scenario: Add skill with invalid YAML fails + Given an invalid skill YAML file at a temp path + When I run skill CLI add with --config pointing to the YAML file + Then the skill CLI command should abort + + Scenario: Add skill with --format json produces valid JSON + Given a valid skill config YAML file at a temp path + When I run skill CLI add with --config and --format json + Then the skill CLI add should succeed + And the skill CLI output should be valid JSON + + Scenario: Add skill with --format yaml produces valid YAML + Given a valid skill config YAML file at a temp path + When I run skill CLI add with --config and --format yaml + Then the skill CLI add should succeed + And the skill CLI output should be valid YAML + + # ─────────────────────────────────────────────────────── + # skill show — details + # ─────────────────────────────────────────────────────── + + Scenario: Show skill displays all panels + Given the skill "local/file-reader" is registered with tools + When I run skill CLI show "local/file-reader" + Then the skill CLI show should succeed + And the skill CLI output should contain "Skill Details" + And the skill CLI output should contain "local/file-reader" + And the skill CLI output should contain "Capability Summary" + And the skill CLI output should contain "✓ OK" + + Scenario: Show skill not found + When I run skill CLI show "local/nonexistent" + Then the skill CLI command should abort + And the skill CLI output should contain "not found" + + Scenario: Show skill with --format json + Given the skill "local/file-reader" is registered with tools + When I run skill CLI show "local/file-reader" with --format json + Then the skill CLI show should succeed + And the skill CLI output should be valid JSON + + # ─────────────────────────────────────────────────────── + # skill tools — flattened tool list + # ─────────────────────────────────────────────────────── + + Scenario: Tools command shows resolved tool list + Given the skill "local/file-reader" is registered with tools + When I run skill CLI tools "local/file-reader" + Then the skill CLI tools should succeed + And the skill CLI output should contain "Tools for local/file-reader" + And the skill CLI output should contain "Summary" + And the skill CLI output should contain "✓ OK" + + Scenario: Tools command with includes shows source skill + Given a composed skill "local/composed" including "local/file-reader" is registered + When I run skill CLI tools "local/composed" + Then the skill CLI tools should succeed + And the skill CLI output should contain "local/file-re" + + Scenario: Tools command for nonexistent skill fails + When I run skill CLI tools "local/nonexistent" + Then the skill CLI command should abort + And the skill CLI output should contain "not found" + + Scenario: Tools with --format json + Given the skill "local/file-reader" is registered with tools + When I run skill CLI tools "local/file-reader" with --format json + Then the skill CLI tools should succeed + And the skill CLI output should be valid JSON + + # ─────────────────────────────────────────────────────── + # skill list — listing and filtering + # ─────────────────────────────────────────────────────── + + Scenario: List shows all registered skills in table + Given the skill "local/file-reader" is registered with tools + And the skill "local/git-ops" is registered with tools + When I run skill CLI list + Then the skill CLI list should succeed + And the skill CLI output should contain "local/file-reader" + And the skill CLI output should contain "local/git-ops" + And the skill CLI output should contain "Summary" + + Scenario: List with namespace filter + Given the skill "local/file-reader" is registered with tools + And the skill "devops/deploy-tools" is registered with tools + When I run skill CLI list with --namespace "local" + Then the skill CLI list should succeed + And the skill CLI output should contain "local/file-reader" + And the skill CLI output should not contain "devops/deploy-tools" + + Scenario: List with --source mcp filter + Given a skill "local/mcp-backed" with MCP servers is registered + And the skill "local/file-reader" is registered with tools + When I run skill CLI list with --source "mcp" + Then the skill CLI list should succeed + And the skill CLI output should contain "local/mcp-backed" + And the skill CLI output should not contain "local/file-reader" + + Scenario: List with no skills shows helpful message + When I run skill CLI list + Then the skill CLI output should contain "No skills found" + + Scenario: List with --format json + Given the skill "local/file-reader" is registered with tools + When I run skill CLI list with --format json + Then the skill CLI list should succeed + And the skill CLI output should be valid JSON + + # ─────────────────────────────────────────────────────── + # skill remove — removal + # ─────────────────────────────────────────────────────── + + Scenario: Remove skill with --yes skips confirmation + Given the skill "local/file-reader" is registered with tools + When I run skill CLI remove "local/file-reader" with --yes + Then the skill CLI remove should succeed + And the skill CLI output should contain "Skill Removed" + And the skill CLI output should contain "✓ OK" + + Scenario: Remove nonexistent skill fails + When I run skill CLI remove "local/nonexistent" with --yes + Then the skill CLI command should abort + And the skill CLI output should contain "not found" + + Scenario: Remove skill shows dependency check + Given a composed skill "local/composed" including "local/file-reader" is registered + When I run skill CLI remove "local/file-reader" with --yes + Then the skill CLI remove should succeed + And the skill CLI output should contain "Dependency Check" + And the skill CLI output should contain "local/composed" + + Scenario: Remove skill with --format json + Given the skill "local/file-reader" is registered with tools + When I run skill CLI remove "local/file-reader" with --yes and --format json + Then the skill CLI remove should succeed + And the skill CLI output should be valid JSON + + # ─────────────────────────────────────────────────────── + # Include cycle detection + # ─────────────────────────────────────────────────────── + + Scenario: Tools command detects circular includes + Given a skill "local/cycle-a" including "local/cycle-b" is registered + And a skill "local/cycle-b" including "local/cycle-a" is registered + When I run skill CLI tools "local/cycle-a" + Then the skill CLI command should abort + And the skill CLI output should contain "Cycle detected" diff --git a/features/steps/skill_cli_steps.py b/features/steps/skill_cli_steps.py new file mode 100644 index 000000000..abf44a517 --- /dev/null +++ b/features/steps/skill_cli_steps.py @@ -0,0 +1,498 @@ +"""Step definitions for the Skill CLI feature. + +Tests for features/skill_cli.feature — validates the ``agents skill`` +command group: add, remove, list, show, and tools. +""" + +from __future__ import annotations + +import json +import os +import tempfile + +import yaml +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.cli.commands.skill import ( + _get_skill_service, + _reset_skill_service, +) +from cleveragents.cli.commands.skill import ( + app as skill_app, +) +from cleveragents.domain.models.core.skill import Skill, SkillInclude + +# ──────────────────────────────────────────────────────────── +# YAML fixtures +# ──────────────────────────────────────────────────────────── + +_SIMPLE_SKILL_YAML = """\ +name: local/file-reader +description: "Basic file reading operations" +tools: + - name: builtin/read_file + - name: builtin/list_directory + - name: builtin/search_files +""" + +_FULL_SKILL_YAML = """\ +name: local/full-skill +description: "A fully populated skill" +tools: + - name: builtin/read_file + - name: builtin/write_file + writes: true +includes: + - name: local/file-reader +inline_tools: + - name: word_count + description: "Count words in text" + source: custom + code: | + def run(input_data): + return {"count": 0} + writes: false +mcp_servers: + - name: github + transport: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + TOKEN: "test" + tool_filter: + include: [create_issue] +agent_skill_folders: + - path: ./skills/deploy + name: deploy +""" + +_INVALID_SKILL_YAML = """\ +not_a_name: missing required name field +extra_field: should fail validation +""" + +_GIT_OPS_YAML = """\ +name: local/git-ops +description: "Git operations" +tools: + - name: builtin/git_status + - name: builtin/git_diff +""" + +_DEVOPS_YAML = """\ +name: devops/deploy-tools +description: "Deployment tools" +tools: + - name: builtin/shell_execute +""" + +_MCP_SKILL_YAML = """\ +name: local/mcp-backed +description: "MCP backed skill" +mcp_servers: + - name: linear + transport: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-linear"] + env: + TOKEN: "test" +""" + + +# ──────────────────────────────────────────────────────────── +# Helpers +# ──────────────────────────────────────────────────────────── + + +def _write_temp_yaml(context: Context, content: str) -> str: + """Write YAML content to a temporary file and register cleanup.""" + fd, path = tempfile.mkstemp(suffix=".yaml") + with os.fdopen(fd, "w") as fh: + fh.write(content) + if not hasattr(context, "_skill_cleanup"): + context._skill_cleanup = [] + context._skill_cleanup.append(path) + return path + + +def _register_skill_directly( + name: str, + description: str = "Test skill", + tool_refs: list[str] | None = None, + includes: list[str] | None = None, + mcp_servers: list[dict] | None = None, +) -> Skill: + """Register a skill directly in the service for test setup.""" + service = _get_skill_service() + skill = Skill( + name=name, + description=description, + tool_refs=tool_refs or [], + includes=[SkillInclude(name=n) for n in (includes or [])], + ) + if mcp_servers: + from cleveragents.domain.models.core.skill import SkillMcpSource + + skill = skill.model_copy( + update={ + "mcp_servers": [ + SkillMcpSource(server=m["server"], tools=m.get("tools")) + for m in mcp_servers + ] + } + ) + from datetime import datetime + + now = datetime.now() + service._skills[name] = skill + service._created_at[name] = now + service._updated_at[name] = now + return skill + + +# ──────────────────────────────────────────────────────────── +# Given steps +# ──────────────────────────────────────────────────────────── + + +@given("a skill CLI test runner") +def step_skill_cli_runner(context: Context) -> None: + """Set up the CLI runner for skill testing.""" + context.skill_runner = CliRunner() + context.skill_result = None + context._skill_cleanup = [] + + +@given("the skill service is reset") +def step_skill_service_reset(context: Context) -> None: + """Reset the skill service to empty state.""" + _reset_skill_service() + + +@given("a valid skill config YAML file at a temp path") +def step_valid_skill_yaml(context: Context) -> None: + """Write a simple valid skill YAML to a temp file.""" + context.skill_yaml_path = _write_temp_yaml(context, _SIMPLE_SKILL_YAML) + + +@given("a full skill config YAML file at a temp path") +def step_full_skill_yaml(context: Context) -> None: + """Write a fully-populated skill YAML to a temp file.""" + context.skill_yaml_path = _write_temp_yaml(context, _FULL_SKILL_YAML) + + +@given("an invalid skill YAML file at a temp path") +def step_invalid_skill_yaml(context: Context) -> None: + """Write invalid YAML to a temp file.""" + context.skill_yaml_path = _write_temp_yaml(context, _INVALID_SKILL_YAML) + + +@given('the skill "{name}" is already registered') +def step_skill_already_registered(context: Context, name: str) -> None: + """Register a skill directly so it already exists.""" + _register_skill_directly( + name, + description="Basic file reading operations", + tool_refs=[ + "builtin/read_file", + "builtin/list_directory", + "builtin/search_files", + ], + ) + + +@given('the skill "{name}" is registered with tools') +def step_skill_registered_with_tools(context: Context, name: str) -> None: + """Register a skill with some tools.""" + if "git-ops" in name: + _register_skill_directly( + name, + description="Git operations", + tool_refs=["builtin/git_status", "builtin/git_diff"], + ) + elif "deploy" in name: + _register_skill_directly( + name, + description="Deployment tools", + tool_refs=["builtin/shell_execute"], + ) + else: + _register_skill_directly( + name, + description="Basic file reading operations", + tool_refs=[ + "builtin/read_file", + "builtin/list_directory", + "builtin/search_files", + ], + ) + + +@given('a composed skill "{name}" including "{included}" is registered') +def step_composed_skill_registered(context: Context, name: str, included: str) -> None: + """Register a composed skill that includes another.""" + # First ensure the included skill is registered + service = _get_skill_service() + if included not in service._skills: + _register_skill_directly( + included, + description="Included skill", + tool_refs=["builtin/read_file"], + ) + _register_skill_directly( + name, + description="Composed skill", + tool_refs=["builtin/shell_execute"], + includes=[included], + ) + + +@given('a skill "{name}" with MCP servers is registered') +def step_skill_with_mcp_registered(context: Context, name: str) -> None: + """Register a skill with MCP servers.""" + _register_skill_directly( + name, + description="MCP backed skill", + mcp_servers=[{"server": "linear", "tools": ["create_issue"]}], + ) + + +@given('a skill "{name}" including "{included}" is registered') +def step_skill_including_registered(context: Context, name: str, included: str) -> None: + """Register a skill that includes another (may create cycle).""" + service = _get_skill_service() + if included not in service._skills: + _register_skill_directly(included, description="Placeholder") + _register_skill_directly( + name, + description="Skill with include", + includes=[included], + ) + + +# ──────────────────────────────────────────────────────────── +# When steps +# ──────────────────────────────────────────────────────────── + + +@when("I run skill CLI add with --config pointing to the YAML file") +def step_run_skill_add(context: Context) -> None: + """Run ``skill add --config ``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["add", "--config", context.skill_yaml_path] + ) + + +@when("I run skill CLI add with --config and --update pointing to the YAML file") +def step_run_skill_add_update(context: Context) -> None: + """Run ``skill add --config --update``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["add", "--config", context.skill_yaml_path, "--update"] + ) + + +@when("I run skill CLI add with --config pointing to a missing file") +def step_run_skill_add_missing(context: Context) -> None: + """Run ``skill add --config /nonexistent/path.yaml``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["add", "--config", "/nonexistent/path.yaml"] + ) + + +@when("I run skill CLI add with --config and --format json") +def step_run_skill_add_json(context: Context) -> None: + """Run ``skill add --config --format json``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["add", "--config", context.skill_yaml_path, "--format", "json"] + ) + + +@when("I run skill CLI add with --config and --format yaml") +def step_run_skill_add_yaml(context: Context) -> None: + """Run ``skill add --config --format yaml``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["add", "--config", context.skill_yaml_path, "--format", "yaml"] + ) + + +@when('I run skill CLI show "{name}"') +def step_run_skill_show(context: Context, name: str) -> None: + """Run ``skill show ``.""" + context.skill_result = context.skill_runner.invoke(skill_app, ["show", name]) + + +@when('I run skill CLI show "{name}" with --format json') +def step_run_skill_show_json(context: Context, name: str) -> None: + """Run ``skill show --format json``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["show", name, "--format", "json"] + ) + + +@when('I run skill CLI tools "{name}"') +def step_run_skill_tools(context: Context, name: str) -> None: + """Run ``skill tools ``.""" + context.skill_result = context.skill_runner.invoke(skill_app, ["tools", name]) + + +@when('I run skill CLI tools "{name}" with --format json') +def step_run_skill_tools_json(context: Context, name: str) -> None: + """Run ``skill tools --format json``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["tools", name, "--format", "json"] + ) + + +@when("I run skill CLI list") +def step_run_skill_list(context: Context) -> None: + """Run ``skill list``.""" + context.skill_result = context.skill_runner.invoke(skill_app, ["list"]) + + +@when('I run skill CLI list with --namespace "{ns}"') +def step_run_skill_list_namespace(context: Context, ns: str) -> None: + """Run ``skill list --namespace ``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["list", "--namespace", ns] + ) + + +@when('I run skill CLI list with --source "{source}"') +def step_run_skill_list_source(context: Context, source: str) -> None: + """Run ``skill list --source ``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["list", "--source", source] + ) + + +@when("I run skill CLI list with --format json") +def step_run_skill_list_json(context: Context) -> None: + """Run ``skill list --format json``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["list", "--format", "json"] + ) + + +@when('I run skill CLI remove "{name}" with --yes') +def step_run_skill_remove_yes(context: Context, name: str) -> None: + """Run ``skill remove --yes``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["remove", name, "--yes"] + ) + + +@when('I run skill CLI remove "{name}" with --yes and --format json') +def step_run_skill_remove_json(context: Context, name: str) -> None: + """Run ``skill remove --yes --format json``.""" + context.skill_result = context.skill_runner.invoke( + skill_app, ["remove", name, "--yes", "--format", "json"] + ) + + +# ──────────────────────────────────────────────────────────── +# Then steps +# ──────────────────────────────────────────────────────────── + + +@then("the skill CLI add should succeed") +def step_skill_add_succeed(context: Context) -> None: + """Assert ``skill add`` exited with code 0.""" + assert context.skill_result is not None + assert context.skill_result.exit_code == 0, ( + f"Expected exit_code 0, got {context.skill_result.exit_code}.\n" + f"Output: {context.skill_result.output}" + ) + + +@then("the skill CLI show should succeed") +def step_skill_show_succeed(context: Context) -> None: + """Assert ``skill show`` exited with code 0.""" + assert context.skill_result is not None + assert context.skill_result.exit_code == 0, ( + f"Expected exit_code 0, got {context.skill_result.exit_code}.\n" + f"Output: {context.skill_result.output}" + ) + + +@then("the skill CLI tools should succeed") +def step_skill_tools_succeed(context: Context) -> None: + """Assert ``skill tools`` exited with code 0.""" + assert context.skill_result is not None + assert context.skill_result.exit_code == 0, ( + f"Expected exit_code 0, got {context.skill_result.exit_code}.\n" + f"Output: {context.skill_result.output}" + ) + + +@then("the skill CLI list should succeed") +def step_skill_list_succeed(context: Context) -> None: + """Assert ``skill list`` exited with code 0.""" + assert context.skill_result is not None + assert context.skill_result.exit_code == 0, ( + f"Expected exit_code 0, got {context.skill_result.exit_code}.\n" + f"Output: {context.skill_result.output}" + ) + + +@then("the skill CLI remove should succeed") +def step_skill_remove_succeed(context: Context) -> None: + """Assert ``skill remove`` exited with code 0.""" + assert context.skill_result is not None + assert context.skill_result.exit_code == 0, ( + f"Expected exit_code 0, got {context.skill_result.exit_code}.\n" + f"Output: {context.skill_result.output}" + ) + + +@then("the skill CLI command should abort") +def step_skill_command_abort(context: Context) -> None: + """Assert the skill command exited with non-zero.""" + assert context.skill_result is not None + assert context.skill_result.exit_code != 0, ( + f"Expected non-zero exit, got 0.\nOutput: {context.skill_result.output}" + ) + + +@then('the skill CLI output should contain "{text}"') +def step_skill_output_contains(context: Context, text: str) -> None: + """Assert the output contains the expected text.""" + assert context.skill_result is not None + output = context.skill_result.output + assert text in output, f"Expected '{text}' in output.\nOutput:\n{output}" + + +@then('the skill CLI output should not contain "{text}"') +def step_skill_output_not_contains(context: Context, text: str) -> None: + """Assert the output does NOT contain the text.""" + assert context.skill_result is not None + output = context.skill_result.output + assert text not in output, f"Did not expect '{text}' in output.\nOutput:\n{output}" + + +@then("the skill CLI output should be valid JSON") +def step_skill_output_valid_json(context: Context) -> None: + """Assert the output is valid JSON.""" + assert context.skill_result is not None + output = context.skill_result.output.strip() + try: + json.loads(output) + except json.JSONDecodeError as exc: + raise AssertionError( + f"Output is not valid JSON: {exc}\nOutput:\n{output}" + ) from exc + + +@then("the skill CLI output should be valid YAML") +def step_skill_output_valid_yaml(context: Context) -> None: + """Assert the output is valid YAML.""" + assert context.skill_result is not None + output = context.skill_result.output.strip() + try: + result = yaml.safe_load(output) + assert result is not None + except yaml.YAMLError as exc: + raise AssertionError( + f"Output is not valid YAML: {exc}\nOutput:\n{output}" + ) from exc diff --git a/implementation_plan.md b/implementation_plan.md index c7522f20f..93ef602e1 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -931,6 +931,48 @@ The following work from the previous implementation has been completed and will - benchmark: success (all benchmarks pass) - **Commit**: `122af46` on `feature/q0-min-coverage` — `fix(tests): resolve unit test and benchmark failures` +**2026-02-17**: C0.skill.cli Complete (Aditya) - Skill CLI Commands + Output Formatting + +- Created `src/cleveragents/application/services/skill_service.py` — `SkillService` with dual-mode storage pattern (in-memory dict fallback, ready for `UnitOfWork` persistence when `C0.skill.registry` lands): + - `add_skill(config_dict, update)` — validates via `SkillConfigSchema`, builds `Skill` domain object, stores with timestamps; raises on duplicate unless `update=True` + - `get_skill(name)` — lookup by namespaced name with `KeyError` on miss + - `list_skills(namespace, source)` — filters by namespace prefix and/or tool source type (`builtin`, `mcp`, `agent_skill`, `custom`) + - `remove_skill(name)` — removes and returns the skill; tracks dependents via include graph + - `resolve_tools(name)` — delegates to `SkillResolver.resolve()` with cycle detection; returns `list[ResolvedToolEntry]` + - `get_dependents(name)` — scans includes to find skills that reference the given skill + - Metadata tracking: `_created_at`, `_updated_at`, `_config_paths` dicts for each registered skill +- Created `src/cleveragents/cli/commands/skill.py` — Typer command group with 5 subcommands: + - `skill add --config [--update] [--format]` — loads YAML, validates schema, registers skill, prints rich panel with capability summary + - `skill remove [--yes] [--format]` — confirmation prompt (skippable), dependency check warning, rich removal panel + - `skill list [--namespace] [--source] [--format]` — filtered table with tool counts, includes counts, summary panel + - `skill show [--format]` — full detail panels (details, includes, direct tools, MCP servers, agent skills, capability summary) + - `skill tools [--format]` — flattened resolved tool table with source tracking, read-only/writes/checkpoint columns + - All commands support 6 output formats (`rich`, `json`, `yaml`, `plain`, `table`, `color`) via `--format` flag + - Module-level `_service` singleton with `_get_skill_service()` / `_reset_skill_service()` for test isolation +- Updated `src/cleveragents/cli/main.py` — registered `skill` command group and added to `valid_cmds` list +- Created `features/skill_cli.feature` (25 scenarios, 164 steps) covering: + - Add from config (valid, duplicate, update, invalid schema, missing file) + - Remove (with confirmation, --yes, not found, dependency warning) + - List (all, namespace filter, source filter, empty result) + - Show (existing, not found, with includes, --format json) + - Tools (flattened output, includes source tracking, cycle detection, --format json) + - Output format tests (JSON, YAML validation) +- Created `features/steps/skill_cli_steps.py` — Behave step definitions using `typer.testing.CliRunner` with real `SkillService` (not mocked) +- Created `robot/skill_cli.robot` + `robot/helper_skill_cli.py` — 7 Robot smoke tests (add, add-duplicate, add-update, show, tools, list, remove) +- Created `benchmarks/skill_cli_bench.py` — 4 ASV benchmark suites (Add, List, Show, Tools) measuring CLI throughput +- Created `docs/reference/skill_cli.md` — CLI reference documentation with command syntax, options, examples, and output format descriptions +- **Quality checks (file-specific)**: + - Ruff lint: 0 findings + - Ruff format: all files formatted + - Bandit security: 0 findings (src/ scope) + - Vulture dead code: 0 findings + - Pyright typecheck: 0 errors, 0 warnings + - Behave tests: 25 scenarios, 164 steps, 0 failures + - Robot helpers: 7/7 pass + - ASV benchmarks: 4/4 suites pass +- Key decision: Followed `PlanLifecycleService` dual-mode pattern — `SkillService` works fully in-memory now; when Luis adds `C0.skill.registry` with `UnitOfWork`/`SkillRepository`, DB writes are added alongside the in-memory dict without changing the public API +- Key decision: Module-level `_service` singleton (not DI container) matches the pattern in `action.py` CLI; `_reset_skill_service()` enables test isolation + --- ## Roadmap @@ -1405,11 +1447,6 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled ### Section 2B: Commit Traceability Fixups [IMMEDIATE] -- [ ] Traceability [Aditya]: Find the actual commit message used for "docs(skill): add skill YAML schema and examples" in `git log --all`, then update the completed COMMIT entry to replace "Done: commit not found" with the correct `Done: Day ,