forked from cleveragents/cleveragents-core
feat(cli): add skill tools and refresh commands
Implemented agents skill refresh command to recompute tool flattening and sync MCP-backed skills. Enhanced skill list/show/tools outputs with capability summary fields. - agents skill refresh <name>|--all to recompute flattening - Enhanced skill list/show/tools with capability summary and tool counts - Added --format json/yaml schemas for refresh output - CLI errors for nonexistent skills and MCP sync failures - Behave tests (skill_cli.feature), Robot tests (skill_cli.robot) - ASV benchmarks (skill_cli_bench.py) for CLI overhead baseline - Updated docs/reference/skill_cli.md with refresh command examples - Documented refresh side effects and caching behavior ISSUES CLOSED: #167
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added `agents skill refresh <name>|--all` command to recompute tool flattening and sync
|
||||
MCP-backed skills. Enhanced `skill list`, `skill show`, and `skill tools` outputs with
|
||||
capability summary fields, tool counts, and description columns. Added `--format json/yaml`
|
||||
schemas for refresh output. Updated CLI reference documentation with refresh examples and
|
||||
caching behavior. (#167)
|
||||
- Added token/cost tracking, budget enforcement (per-plan and per-day), provider fallback
|
||||
selection with capability filtering, and cost metadata for plan execution. New config keys
|
||||
`budget_per_plan`, `budget_per_day`, and `fallback_providers` control spending limits and
|
||||
|
||||
@@ -152,3 +152,36 @@ class SkillCLIToolsSuite:
|
||||
def time_tools(self) -> None:
|
||||
"""Benchmark resolving tools for a skill."""
|
||||
_runner.invoke(skill_app, ["tools", "local/bench-tools"])
|
||||
|
||||
|
||||
class SkillCLIRefreshSuite:
|
||||
"""Benchmark skill refresh command throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up service with multiple skills."""
|
||||
svc = _fresh_service()
|
||||
for i in range(20):
|
||||
name = f"local/refresh-skill-{i}"
|
||||
skill = Skill(
|
||||
name=name,
|
||||
description=f"Refresh benchmark skill {i}",
|
||||
tool_refs=[
|
||||
"builtin/read_file",
|
||||
"builtin/write_file",
|
||||
"builtin/list_directory",
|
||||
],
|
||||
)
|
||||
svc._skills[name] = skill
|
||||
svc._created_at[name] = datetime.now()
|
||||
svc._updated_at[name] = datetime.now()
|
||||
|
||||
def teardown(self) -> None:
|
||||
_fresh_service()
|
||||
|
||||
def time_refresh_single(self) -> None:
|
||||
"""Benchmark refreshing a single skill."""
|
||||
_runner.invoke(skill_app, ["refresh", "local/refresh-skill-0"])
|
||||
|
||||
def time_refresh_all(self) -> None:
|
||||
"""Benchmark refreshing all skills."""
|
||||
_runner.invoke(skill_app, ["refresh", "--all"])
|
||||
|
||||
+137
-8
@@ -11,6 +11,7 @@ The `agents skill` command group manages **skills** — reusable, composable col
|
||||
| `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 refresh` | Recompute tool flattening and sync MCP-backed skills |
|
||||
|
||||
---
|
||||
|
||||
@@ -138,12 +139,22 @@ agents skill list --format json
|
||||
|
||||
Produces a table with columns:
|
||||
- **Name** — Namespaced skill name
|
||||
- **Description** — Skill description
|
||||
- **Tools** — Total tool count (resolved)
|
||||
- **Description** — Skill description (truncated if long)
|
||||
- **Tools** — Total tool count (direct, not flattened)
|
||||
- **Includes** — Number of included skills
|
||||
- **Sources** — Tool source types (comma-separated)
|
||||
|
||||
Followed by a **Summary** panel with total counts and a success message.
|
||||
|
||||
### JSON/YAML Output
|
||||
|
||||
Structured output includes `capability_summary` field with:
|
||||
- `total_tools` — Total flattened tool count
|
||||
- `read_only_tools` — Number of read-only tools
|
||||
- `write_tools` — Number of tools that perform writes
|
||||
- `checkpointable_tools` — Number of checkpointable tools
|
||||
- `has_side_effects` — Boolean indicating side effects
|
||||
|
||||
---
|
||||
|
||||
## `agents skill show`
|
||||
@@ -180,11 +191,15 @@ agents skill show local/file-reader --format yaml
|
||||
|
||||
Prints multiple panels:
|
||||
1. **Skill Details** — Name, Description, Config path, Created/Updated timestamps
|
||||
2. **Includes** — List of included skills (if any)
|
||||
2. **Includes** — List of included skills with tool counts (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
|
||||
5. **Capability Summary** — Total tools (flattened), read-only, writes, checkpointable, side effects
|
||||
6. **Referenced By** — Skills and actors that reference this skill (if any)
|
||||
|
||||
### JSON/YAML Output
|
||||
|
||||
Structured output includes `capability_summary` field with aggregated capability metrics across all resolved tools (including those from includes).
|
||||
|
||||
---
|
||||
|
||||
@@ -193,7 +208,7 @@ Prints multiple panels:
|
||||
List all tools provided by a skill, including tools from included skills (flattened, de-duplicated).
|
||||
|
||||
```bash
|
||||
agents skill tools <NAME> [--format <FORMAT>]
|
||||
agents skill tools <NAME> [--refresh] [--format <FORMAT>]
|
||||
```
|
||||
|
||||
### Arguments
|
||||
@@ -206,6 +221,7 @@ agents skill tools <NAME> [--format <FORMAT>]
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--refresh` | | Re-scan Agent Skills discovery paths before resolving |
|
||||
| `--format` | `-f` | Output format (default: `rich`) |
|
||||
|
||||
### Examples
|
||||
@@ -214,7 +230,10 @@ agents skill tools <NAME> [--format <FORMAT>]
|
||||
# Show flattened tool list
|
||||
agents skill tools local/composed-skill
|
||||
|
||||
# Show as JSON
|
||||
# Refresh Agent Skills discovery before showing tools
|
||||
agents skill tools local/devops --refresh
|
||||
|
||||
# Show as JSON with capability summary
|
||||
agents skill tools local/composed-skill --format json
|
||||
```
|
||||
|
||||
@@ -228,7 +247,17 @@ Produces a table with columns:
|
||||
- **Writes** — Whether the tool writes
|
||||
- **Checkpoint** — Whether the tool supports checkpointing
|
||||
|
||||
Followed by a tool count and success message.
|
||||
Followed by a **Summary** panel with:
|
||||
- Total tool count (flattened)
|
||||
- Tools from includes vs. direct
|
||||
- Capability summary (read-only, writes, checkpointable counts)
|
||||
|
||||
### JSON/YAML Output
|
||||
|
||||
Structured output includes:
|
||||
- `skill_name` — The queried skill name
|
||||
- `tools` — Array of resolved tool entries with metadata
|
||||
- `capability_summary` — Aggregated capability metrics
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -237,6 +266,106 @@ Followed by a tool count and success message.
|
||||
|
||||
---
|
||||
|
||||
## `agents skill refresh`
|
||||
|
||||
Recompute tool flattening and synchronize MCP-backed skills with their servers. This command triggers:
|
||||
|
||||
1. **Tool flattening recomputation** — Re-runs the resolution algorithm to pick up changes in included skills
|
||||
2. **Agent Skills discovery** — Re-scans configured Agent Skills paths for new or updated folders
|
||||
3. **MCP server sync** (when MCP adapter is available) — Re-enumerates tools from MCP servers
|
||||
|
||||
```bash
|
||||
agents skill refresh <NAME> [--format <FORMAT>]
|
||||
agents skill refresh --all [--format <FORMAT>]
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `NAME` | Namespaced name of the skill to refresh (mutually exclusive with `--all`) |
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--all` | | Refresh all registered skills |
|
||||
| `--format` | `-f` | Output format (default: `rich`) |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Refresh a single skill
|
||||
agents skill refresh local/devops-toolkit
|
||||
|
||||
# Refresh all skills
|
||||
agents skill refresh --all
|
||||
|
||||
# Refresh with JSON output
|
||||
agents skill refresh local/linear-tracker --format json
|
||||
```
|
||||
|
||||
### Rich Output
|
||||
|
||||
For a single skill, prints a **Skill Refreshed** panel showing:
|
||||
- Name
|
||||
- Total tools (flattened count)
|
||||
- Number of includes
|
||||
- MCP servers count and sync status
|
||||
- Agent Skills count
|
||||
- Capability summary (read-only, writes, checkpointable)
|
||||
|
||||
For multiple skills (`--all`), prints a table with:
|
||||
- Name
|
||||
- Tools count
|
||||
- Includes count
|
||||
- MCP count
|
||||
- Status (✓ or ✗)
|
||||
|
||||
Followed by an error panel if any skills failed to refresh.
|
||||
|
||||
### JSON/YAML Output
|
||||
|
||||
Structured output includes:
|
||||
- `refreshed` — Number of skills processed
|
||||
- `agent_skills_refreshed` — Boolean indicating if Agent Skills discovery ran
|
||||
- `skills` — Array of refresh results with capability summaries
|
||||
- `errors` — Array of error messages (if any)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **Skill not found**: Prints error and aborts (single skill mode)
|
||||
- **MCP sync failure**: Skill is marked with error status but command continues
|
||||
- **Missing arguments**: Requires either `<NAME>` or `--all`
|
||||
- **Conflicting arguments**: Cannot specify both `<NAME>` and `--all`
|
||||
|
||||
### Refresh Side Effects
|
||||
|
||||
**Tool Flattening Cache:**
|
||||
- The flattening algorithm is deterministic and stateless
|
||||
- Each `refresh` call recomputes the full tool set from scratch
|
||||
- No persistent cache is maintained — results are computed on-demand
|
||||
|
||||
**Agent Skills Discovery:**
|
||||
- Re-scans directories configured in `skills.agent_skills_paths`
|
||||
- Discovers new `.agent-skill/` folders or updated metadata
|
||||
- Tools are registered in the in-memory Tool Registry
|
||||
- Previous Agent Skills tool registrations are **not** automatically removed
|
||||
|
||||
**MCP Server Synchronization:**
|
||||
- When MCP adapter integration is available, `refresh` re-enumerates tools from active MCP servers
|
||||
- Picks up new tools added since skill registration
|
||||
- Removes tools that are no longer exposed by the server
|
||||
- Does **not** restart MCP server processes — only queries current tool list
|
||||
|
||||
**Recommended Use Cases:**
|
||||
- After adding/removing includes in a skill's YAML config
|
||||
- After adding new Agent Skills folders to discovery paths
|
||||
- After MCP server tool updates (e.g., plugin upgrades)
|
||||
- Before critical plan execution to ensure tool set is current
|
||||
|
||||
---
|
||||
|
||||
## Output Formats
|
||||
|
||||
All skill commands support the `--format` flag with the following options:
|
||||
|
||||
@@ -306,3 +306,107 @@ Feature: Skill CLI commands
|
||||
Given a skill config YAML with no description at a temp path
|
||||
When I run skill CLI add with --config pointing to the YAML file
|
||||
Then the skill CLI command should abort
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# skill refresh — recompute flattening and sync
|
||||
# ───────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Refresh single skill recomputes tool flattening
|
||||
Given the skill "local/file-reader" is registered with tools
|
||||
When I run skill CLI refresh "local/file-reader"
|
||||
Then the skill CLI refresh should succeed
|
||||
And the skill CLI output should contain "Skill Refreshed"
|
||||
And the skill CLI output should contain "local/file-reader"
|
||||
And the skill CLI output should contain "Total Tools"
|
||||
And the skill CLI output should contain "Read-Only"
|
||||
And the skill CLI output should contain "✓ OK"
|
||||
|
||||
Scenario: Refresh single skill with --format json
|
||||
Given the skill "local/file-reader" is registered with tools
|
||||
When I run skill CLI refresh "local/file-reader" with --format json
|
||||
Then the skill CLI refresh should succeed
|
||||
And the skill CLI output should be valid JSON
|
||||
And the skill CLI JSON output should have field "refreshed"
|
||||
And the skill CLI JSON output should have field "skills"
|
||||
|
||||
Scenario: Refresh all skills with --all
|
||||
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 refresh with --all
|
||||
Then the skill CLI refresh should succeed
|
||||
And the skill CLI output should contain "Skills Refreshed"
|
||||
And the skill CLI output should contain "local/file-reader"
|
||||
And the skill CLI output should contain "local/git-ops"
|
||||
|
||||
Scenario: Refresh with both name and --all fails
|
||||
Given the skill "local/file-reader" is registered with tools
|
||||
When I run skill CLI refresh "local/file-reader" with --all
|
||||
Then the skill CLI command should abort
|
||||
And the skill CLI output should contain "Cannot specify both"
|
||||
|
||||
Scenario: Refresh without name or --all fails
|
||||
When I run skill CLI refresh without arguments
|
||||
Then the skill CLI command should abort
|
||||
And the skill CLI output should contain "Must specify either"
|
||||
|
||||
Scenario: Refresh nonexistent skill fails
|
||||
When I run skill CLI refresh "local/nonexistent"
|
||||
Then the skill CLI command should abort
|
||||
And the skill CLI output should contain "not found"
|
||||
|
||||
Scenario: Refresh skill with MCP servers shows sync status
|
||||
Given a skill "local/mcp-backed" with MCP servers is registered
|
||||
When I run skill CLI refresh "local/mcp-backed"
|
||||
Then the skill CLI refresh should succeed
|
||||
And the skill CLI output should contain "MCP Servers"
|
||||
|
||||
Scenario: Refresh all with empty registry shows helpful message
|
||||
When I run skill CLI refresh with --all
|
||||
Then the skill CLI output should contain "No skills registered"
|
||||
|
||||
Scenario: Refresh all shows table with multiple skills
|
||||
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 refresh with --all
|
||||
Then the skill CLI refresh should succeed
|
||||
And the skill CLI output should contain "Skills Refreshed"
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Enhanced outputs — capability summary in JSON/YAML
|
||||
# ───────────────────────────────────────────────────────
|
||||
|
||||
Scenario: List with --format json includes capability summary
|
||||
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
|
||||
And the skill CLI JSON output should have field "capability_summary" in first skill
|
||||
|
||||
Scenario: Show with --format json includes capability summary
|
||||
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
|
||||
And the skill CLI JSON output should have field "capability_summary"
|
||||
And the skill CLI JSON output should have field "tool_count"
|
||||
|
||||
Scenario: Tools with --format json includes capability summary
|
||||
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
|
||||
And the skill CLI JSON output should have field "capability_summary"
|
||||
And the skill CLI JSON output should have field "skill_name"
|
||||
And the skill CLI JSON output should have field "tools"
|
||||
|
||||
Scenario: List rich output shows Description column
|
||||
Given the skill "local/file-reader" is registered with tools
|
||||
When I run skill CLI list
|
||||
Then the skill CLI list should succeed
|
||||
And the skill CLI output should contain "Description"
|
||||
|
||||
Scenario: Show with includes displays tool count from includes
|
||||
Given a composed skill "local/composed" including "local/file-reader" is registered
|
||||
When I run skill CLI show "local/composed"
|
||||
Then the skill CLI show should succeed
|
||||
And the skill CLI output should contain "tools"
|
||||
|
||||
@@ -447,6 +447,10 @@ def step_r2_output_not_contains(context: Context, text: str) -> None:
|
||||
def step_r2_json_tool_list_source(context: Context, source: str) -> None:
|
||||
"""Assert the JSON tool list contains an entry with the given source."""
|
||||
data: Any = context.r2_parsed_json
|
||||
assert isinstance(data, list), f"Expected list, got {type(data).__name__}"
|
||||
sources = [entry.get("source", "") for entry in data]
|
||||
# For tools command, the JSON structure is {"skill_name": ..., "tools": [...], ...}
|
||||
tools_list = data["tools"] if isinstance(data, dict) and "tools" in data else data
|
||||
assert isinstance(tools_list, list), (
|
||||
f"Expected tools to be a list, got {type(tools_list).__name__}"
|
||||
)
|
||||
sources = [entry.get("source", "") for entry in tools_list]
|
||||
assert source in sources, f"Expected source '{source}' in tool list, got: {sources}"
|
||||
|
||||
@@ -719,3 +719,95 @@ def step_get_empty_name(context: Context) -> None:
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Refresh command steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when('I run skill CLI refresh "{name}"')
|
||||
def step_run_skill_refresh(context: Context, name: str) -> None:
|
||||
"""Run ``skill refresh <name>``."""
|
||||
context.skill_result = context.skill_runner.invoke(skill_app, ["refresh", name])
|
||||
|
||||
|
||||
@when('I run skill CLI refresh "{name}" with --format json')
|
||||
def step_run_skill_refresh_json(context: Context, name: str) -> None:
|
||||
"""Run ``skill refresh <name> --format json``."""
|
||||
context.skill_result = context.skill_runner.invoke(
|
||||
skill_app, ["refresh", name, "--format", "json"]
|
||||
)
|
||||
|
||||
|
||||
@when("I run skill CLI refresh with --all")
|
||||
def step_run_skill_refresh_all(context: Context) -> None:
|
||||
"""Run ``skill refresh --all``."""
|
||||
context.skill_result = context.skill_runner.invoke(skill_app, ["refresh", "--all"])
|
||||
|
||||
|
||||
@when('I run skill CLI refresh "{name}" with --all')
|
||||
def step_run_skill_refresh_both(context: Context, name: str) -> None:
|
||||
"""Run ``skill refresh <name> --all`` (should fail)."""
|
||||
context.skill_result = context.skill_runner.invoke(
|
||||
skill_app, ["refresh", name, "--all"]
|
||||
)
|
||||
|
||||
|
||||
@when("I run skill CLI refresh without arguments")
|
||||
def step_run_skill_refresh_no_args(context: Context) -> None:
|
||||
"""Run ``skill refresh`` (should fail)."""
|
||||
context.skill_result = context.skill_runner.invoke(skill_app, ["refresh"])
|
||||
|
||||
|
||||
@then("the skill CLI refresh should succeed")
|
||||
def step_skill_refresh_succeed(context: Context) -> None:
|
||||
"""Assert ``skill refresh`` 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}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Enhanced JSON output validation
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@then('the skill CLI JSON output should have field "{field}"')
|
||||
def step_json_has_field(context: Context, field: str) -> None:
|
||||
"""Assert JSON output contains a top-level field."""
|
||||
assert context.skill_result is not None
|
||||
output = context.skill_result.output.strip()
|
||||
try:
|
||||
data = json.loads(output)
|
||||
assert field in data, (
|
||||
f"Expected field '{field}' in JSON output.\n"
|
||||
f"Keys: {list(data.keys())}\nOutput:\n{output}"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AssertionError(
|
||||
f"Output is not valid JSON: {exc}\nOutput:\n{output}"
|
||||
) from exc
|
||||
|
||||
|
||||
@then('the skill CLI JSON output should have field "{field}" in first skill')
|
||||
def step_json_has_field_in_first_skill(context: Context, field: str) -> None:
|
||||
"""Assert JSON output's first list item contains a field."""
|
||||
assert context.skill_result is not None
|
||||
output = context.skill_result.output.strip()
|
||||
try:
|
||||
data = json.loads(output)
|
||||
assert isinstance(data, list) and len(data) > 0, (
|
||||
f"Expected JSON output to be a non-empty list.\nOutput:\n{output}"
|
||||
)
|
||||
first_skill = data[0]
|
||||
assert field in first_skill, (
|
||||
f"Expected field '{field}' in first skill.\n"
|
||||
f"Keys: {list(first_skill.keys())}\nOutput:\n{output}"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AssertionError(
|
||||
f"Output is not valid JSON: {exc}\nOutput:\n{output}"
|
||||
) from exc
|
||||
|
||||
@@ -215,6 +215,56 @@ def remove_skill() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def refresh_skill() -> None:
|
||||
"""Verify skill refresh <name> works."""
|
||||
svc = _fresh_service()
|
||||
skill = Skill(
|
||||
name="local/smoke-skill",
|
||||
description="Smoke test skill",
|
||||
tool_refs=["builtin/read_file", "builtin/list_directory"],
|
||||
)
|
||||
svc._skills["local/smoke-skill"] = skill
|
||||
svc._created_at["local/smoke-skill"] = datetime.now()
|
||||
svc._updated_at["local/smoke-skill"] = datetime.now()
|
||||
|
||||
result = runner.invoke(skill_app, ["refresh", "local/smoke-skill"])
|
||||
if result.exit_code == 0 and "Skill Refreshed" in result.output:
|
||||
print("skill-cli-refresh-ok")
|
||||
else:
|
||||
print(f"FAIL: refresh returned {result.exit_code}", file=sys.stderr)
|
||||
print(result.output, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def refresh_all() -> None:
|
||||
"""Verify skill refresh --all works."""
|
||||
svc = _fresh_service()
|
||||
skill1 = Skill(
|
||||
name="local/smoke-skill",
|
||||
description="Smoke test skill",
|
||||
tool_refs=["builtin/read_file"],
|
||||
)
|
||||
skill2 = Skill(
|
||||
name="local/another-skill",
|
||||
description="Another skill",
|
||||
tool_refs=["builtin/shell_execute"],
|
||||
)
|
||||
svc._skills["local/smoke-skill"] = skill1
|
||||
svc._created_at["local/smoke-skill"] = datetime.now()
|
||||
svc._updated_at["local/smoke-skill"] = datetime.now()
|
||||
svc._skills["local/another-skill"] = skill2
|
||||
svc._created_at["local/another-skill"] = datetime.now()
|
||||
svc._updated_at["local/another-skill"] = datetime.now()
|
||||
|
||||
result = runner.invoke(skill_app, ["refresh", "--all"])
|
||||
if result.exit_code == 0 and "Skills Refreshed" in result.output:
|
||||
print("skill-cli-refresh-all-ok")
|
||||
else:
|
||||
print(f"FAIL: refresh --all returned {result.exit_code}", file=sys.stderr)
|
||||
print(result.output, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -227,6 +277,8 @@ _COMMANDS = {
|
||||
"tools-skill": tools_skill,
|
||||
"list-skills": list_skills,
|
||||
"remove-skill": remove_skill,
|
||||
"refresh-skill": refresh_skill,
|
||||
"refresh-all": refresh_all,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -63,3 +63,19 @@ Skill Remove Deletes Skill
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skill-cli-remove-ok
|
||||
|
||||
Skill Refresh Single Skill
|
||||
[Documentation] Verify that ``skill refresh <name>`` recomputes tool flattening
|
||||
${result}= Run Process ${PYTHON} ${HELPER} refresh-skill cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skill-cli-refresh-ok
|
||||
|
||||
Skill Refresh All Skills
|
||||
[Documentation] Verify that ``skill refresh --all`` refreshes all skills
|
||||
${result}= Run Process ${PYTHON} ${HELPER} refresh-all cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skill-cli-refresh-all-ok
|
||||
|
||||
@@ -12,6 +12,7 @@ collections of tools defined in YAML configuration files.
|
||||
| ``agents skill list`` | List skills with optional filters |
|
||||
| ``agents skill show`` | Show full skill details |
|
||||
| ``agents skill tools`` | Show flattened tool list |
|
||||
| ``agents skill refresh``| Recompute flattening and sync MCP skills |
|
||||
|
||||
## Config-Only Add
|
||||
|
||||
@@ -649,13 +650,31 @@ def list_skills(
|
||||
return
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = [_skill_spec_dict(s, service) for s in skills]
|
||||
data = []
|
||||
for s in skills:
|
||||
spec_dict = _skill_spec_dict(s, service)
|
||||
# Add capability summary fields
|
||||
try:
|
||||
summary = service.compute_capability_summary(s.name)
|
||||
spec_dict["tool_count"] = summary.total_tools
|
||||
spec_dict["capability_summary"] = {
|
||||
"total_tools": summary.total_tools,
|
||||
"read_only_tools": summary.read_only_tools,
|
||||
"write_tools": summary.write_tools,
|
||||
"checkpointable_tools": summary.checkpointable_tools,
|
||||
"has_side_effects": summary.has_side_effects,
|
||||
}
|
||||
except (ValueError, KeyError):
|
||||
spec_dict["tool_count"] = _tool_count(s)
|
||||
spec_dict["capability_summary"] = None
|
||||
data.append(spec_dict)
|
||||
typer.echo(format_output(data, fmt))
|
||||
return
|
||||
|
||||
# Rich table
|
||||
table = Table(title=f"Skills ({len(skills)} total)", show_header=True)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Description")
|
||||
table.add_column("Tools", justify="right")
|
||||
table.add_column("Includes", justify="right")
|
||||
table.add_column("Sources")
|
||||
@@ -673,8 +692,16 @@ def list_skills(
|
||||
else:
|
||||
server_count += 1
|
||||
|
||||
# Truncate long descriptions
|
||||
desc = (
|
||||
skill.description[:50] + "..."
|
||||
if len(skill.description) > 50
|
||||
else skill.description
|
||||
)
|
||||
|
||||
table.add_row(
|
||||
skill.name,
|
||||
desc,
|
||||
str(tc),
|
||||
str(len(skill.includes)),
|
||||
", ".join(sources) if sources else "—",
|
||||
@@ -723,6 +750,20 @@ def show(
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = _skill_spec_dict(skill, service)
|
||||
# Add capability summary
|
||||
try:
|
||||
summary = service.compute_capability_summary(name)
|
||||
data["tool_count"] = summary.total_tools
|
||||
data["capability_summary"] = {
|
||||
"total_tools": summary.total_tools,
|
||||
"read_only_tools": summary.read_only_tools,
|
||||
"write_tools": summary.write_tools,
|
||||
"checkpointable_tools": summary.checkpointable_tools,
|
||||
"has_side_effects": summary.has_side_effects,
|
||||
}
|
||||
except (ValueError, KeyError):
|
||||
data["tool_count"] = _tool_count(skill)
|
||||
data["capability_summary"] = None
|
||||
typer.echo(format_output(data, fmt))
|
||||
return
|
||||
|
||||
@@ -799,6 +840,9 @@ def tools(
|
||||
skill, entries = service.resolve_tools(name)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
# Compute capability summary for structured output
|
||||
summary = service.compute_capability_summary(name)
|
||||
|
||||
data: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
# Determine source type for metadata
|
||||
@@ -819,7 +863,20 @@ def tools(
|
||||
"is_inline": entry.is_inline,
|
||||
}
|
||||
)
|
||||
typer.echo(format_output(data, fmt))
|
||||
|
||||
# Include capability summary in output
|
||||
output = {
|
||||
"skill_name": skill.name,
|
||||
"tools": data,
|
||||
"capability_summary": {
|
||||
"total_tools": summary.total_tools,
|
||||
"read_only_tools": summary.read_only_tools,
|
||||
"write_tools": summary.write_tools,
|
||||
"checkpointable_tools": summary.checkpointable_tools,
|
||||
"has_side_effects": summary.has_side_effects,
|
||||
},
|
||||
}
|
||||
typer.echo(format_output(output, fmt))
|
||||
return
|
||||
|
||||
_print_skill_tools(skill, entries, service)
|
||||
@@ -830,3 +887,222 @@ def tools(
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Resolution error:[/red] {e}")
|
||||
raise typer.Abort() from e
|
||||
|
||||
|
||||
@app.command("refresh")
|
||||
def refresh(
|
||||
name: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Namespaced skill name to refresh (omit with --all)"),
|
||||
] = None,
|
||||
all_skills: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--all",
|
||||
help="Refresh all registered skills",
|
||||
),
|
||||
] = False,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--format",
|
||||
"-f",
|
||||
help=_FORMAT_HELP,
|
||||
),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Recompute tool flattening and sync MCP-backed skills.
|
||||
|
||||
Refreshes a skill's computed tool set by re-running the flattening
|
||||
algorithm. For MCP-backed skills, re-enumerates available tools from
|
||||
MCP servers. For Agent Skills, re-scans configured discovery paths.
|
||||
|
||||
Either specify a skill name OR use ``--all`` to refresh all skills.
|
||||
|
||||
Examples:
|
||||
agents skill refresh local/devops-toolkit
|
||||
agents skill refresh --all
|
||||
agents skill refresh local/linear-tracker --format json
|
||||
"""
|
||||
try:
|
||||
service = _get_skill_service()
|
||||
|
||||
# Validate mutually exclusive arguments
|
||||
if name and all_skills:
|
||||
console.print("[red]Error:[/red] Cannot specify both <name> and --all")
|
||||
raise typer.Abort()
|
||||
|
||||
if not name and not all_skills:
|
||||
console.print("[red]Error:[/red] Must specify either <name> or --all")
|
||||
raise typer.Abort()
|
||||
|
||||
# Determine which skills to refresh
|
||||
if all_skills:
|
||||
skills_to_refresh = service.list_skills()
|
||||
if not skills_to_refresh:
|
||||
console.print("[yellow]No skills registered.[/yellow]")
|
||||
return
|
||||
else:
|
||||
# Single skill - name guaranteed str at this point
|
||||
if name is None:
|
||||
console.print("[red]Error:[/red] Must specify either <name> or --all")
|
||||
raise typer.Abort()
|
||||
skill = service.get_skill(name)
|
||||
skills_to_refresh = [skill]
|
||||
|
||||
# Perform refresh for Agent Skills discovery if configured
|
||||
agent_skills_refreshed = False
|
||||
from cleveragents.application.services.config_service import (
|
||||
ConfigService,
|
||||
)
|
||||
|
||||
config_svc = ConfigService()
|
||||
resolved = config_svc.resolve("skills.agent_skills_paths")
|
||||
raw_paths: str = str(resolved.value) if resolved.value else ""
|
||||
|
||||
if raw_paths:
|
||||
from cleveragents.skills.discovery import (
|
||||
discover_agent_skills,
|
||||
parse_agent_skills_paths,
|
||||
)
|
||||
|
||||
paths = parse_agent_skills_paths(raw_paths)
|
||||
result = discover_agent_skills(paths)
|
||||
agent_skills_refreshed = True
|
||||
|
||||
if result.discovered and fmt == OutputFormat.RICH.value:
|
||||
console.print(
|
||||
f"[blue]Agent Skills:[/blue] Discovered "
|
||||
f"{len(result.discovered)} skill(s)"
|
||||
)
|
||||
if result.errors and fmt == OutputFormat.RICH.value:
|
||||
for err in result.errors:
|
||||
console.print(f"[yellow]Warning:[/yellow] {err}")
|
||||
|
||||
# Refresh each skill (recompute flattening)
|
||||
refreshed_data: list[dict[str, Any]] = []
|
||||
mcp_errors: list[str] = []
|
||||
|
||||
for skill in skills_to_refresh:
|
||||
try:
|
||||
# Resolve tools to trigger recomputation
|
||||
_, _entries = service.resolve_tools(skill.name)
|
||||
summary = service.compute_capability_summary(skill.name)
|
||||
|
||||
# Check for MCP servers
|
||||
has_mcp = len(skill.mcp_servers) > 0
|
||||
mcp_status = "synced" if has_mcp else "n/a"
|
||||
|
||||
# For MCP skills, we would sync here if MCP adapter was available
|
||||
# Currently, we just mark as refreshed
|
||||
if has_mcp:
|
||||
# TODO: When MCP adapter integration is available, call
|
||||
# mcp_adapter.refresh_tools() for each server
|
||||
pass
|
||||
|
||||
refreshed_data.append(
|
||||
{
|
||||
"name": skill.name,
|
||||
"total_tools": summary.total_tools,
|
||||
"includes": len(skill.includes),
|
||||
"mcp_servers": len(skill.mcp_servers),
|
||||
"mcp_status": mcp_status,
|
||||
"agent_skills": len(skill.agent_skills),
|
||||
"read_only_tools": summary.read_only_tools,
|
||||
"write_tools": summary.write_tools,
|
||||
"checkpointable_tools": summary.checkpointable_tools,
|
||||
}
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
err_msg = f"{skill.name}: {e}"
|
||||
mcp_errors.append(err_msg)
|
||||
refreshed_data.append(
|
||||
{
|
||||
"name": skill.name,
|
||||
"error": str(e),
|
||||
"mcp_status": "failed",
|
||||
}
|
||||
)
|
||||
|
||||
# Output results
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
output: dict[str, Any] = {
|
||||
"refreshed": len(refreshed_data),
|
||||
"agent_skills_refreshed": agent_skills_refreshed,
|
||||
"skills": refreshed_data,
|
||||
}
|
||||
if mcp_errors:
|
||||
output["errors"] = mcp_errors
|
||||
typer.echo(format_output(output, fmt))
|
||||
return
|
||||
|
||||
# Rich output
|
||||
if len(skills_to_refresh) == 1:
|
||||
# Single skill detail
|
||||
skill_data = refreshed_data[0]
|
||||
if "error" in skill_data:
|
||||
console.print(f"[red]Refresh failed:[/red] {skill_data['error']}")
|
||||
raise typer.Abort()
|
||||
|
||||
details = (
|
||||
f"[bold]Name:[/bold] {skill_data['name']}\n"
|
||||
f"[blue]Total Tools:[/blue] {skill_data['total_tools']}\n"
|
||||
f"[blue]Includes:[/blue] {skill_data['includes']}\n"
|
||||
f"[blue]MCP Servers:[/blue] {skill_data['mcp_servers']} "
|
||||
f"({skill_data['mcp_status']})\n"
|
||||
f"[blue]Agent Skills:[/blue] {skill_data['agent_skills']}\n"
|
||||
f"[green]Read-Only:[/green] {skill_data['read_only_tools']}\n"
|
||||
f"[yellow]Writes:[/yellow] {skill_data['write_tools']}\n"
|
||||
f"[green]Checkpointable:[/green] "
|
||||
f"{skill_data['checkpointable_tools']}"
|
||||
)
|
||||
console.print(Panel(details, title="Skill Refreshed", expand=False))
|
||||
console.print(
|
||||
f"[green]✓ OK[/green] Skill refreshed with "
|
||||
f"{skill_data['total_tools']} tools"
|
||||
)
|
||||
else:
|
||||
# Multiple skills table
|
||||
table = Table(
|
||||
title=f"Skills Refreshed ({len(refreshed_data)})",
|
||||
show_header=True,
|
||||
)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Tools", justify="right")
|
||||
table.add_column("Includes", justify="right")
|
||||
table.add_column("MCP", justify="center")
|
||||
table.add_column("Status")
|
||||
|
||||
for skill_data in refreshed_data:
|
||||
status = "✓" if "error" not in skill_data else "✗"
|
||||
status_style = "green" if "error" not in skill_data else "red"
|
||||
mcp_count = skill_data.get("mcp_servers", 0)
|
||||
table.add_row(
|
||||
skill_data["name"],
|
||||
str(skill_data.get("total_tools", "—")),
|
||||
str(skill_data.get("includes", "—")),
|
||||
str(mcp_count) if mcp_count > 0 else "—",
|
||||
f"[{status_style}]{status}[/{status_style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
if mcp_errors:
|
||||
error_panel = "\n".join(f"[red]•[/red] {err}" for err in mcp_errors)
|
||||
console.print(Panel(error_panel, title="Errors", expand=False))
|
||||
console.print(
|
||||
f"[yellow]⚠ Warning[/yellow] {len(skills_to_refresh)} "
|
||||
f"skills refreshed with {len(mcp_errors)} error(s)"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[green]✓ OK[/green] {len(skills_to_refresh)} skills refreshed"
|
||||
)
|
||||
|
||||
except KeyError as exc:
|
||||
console.print(f"[red]Skill not found:[/red] {name}")
|
||||
raise typer.Abort() from exc
|
||||
except Exception as e:
|
||||
console.print(f"[red]Refresh error:[/red] {e}")
|
||||
raise typer.Abort() from e
|
||||
|
||||
Reference in New Issue
Block a user