c40ea014dc
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 19s
CI / security (pull_request) Successful in 32s
CI / typecheck (pull_request) Successful in 37s
CI / integration_tests (pull_request) Successful in 2m46s
CI / unit_tests (pull_request) Successful in 9m40s
CI / docker (pull_request) Successful in 39s
CI / benchmark-regression (pull_request) Successful in 20m53s
CI / coverage (pull_request) Successful in 34m31s
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
814 lines
29 KiB
Python
814 lines
29 KiB
Python
"""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,
|
|
SkillAgentSource,
|
|
SkillInclude,
|
|
SkillInlineTool,
|
|
SkillMcpSource,
|
|
)
|
|
from cleveragents.domain.models.core.tool import ToolCapability, ToolSource
|
|
|
|
# ────────────────────────────────────────────────────────────
|
|
# 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"
|
|
"""
|
|
|
|
_NO_DESCRIPTION_YAML = """\
|
|
name: local/no-desc
|
|
tools:
|
|
- name: builtin/echo
|
|
"""
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────
|
|
# 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 <file>``."""
|
|
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 <file> --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 <file> --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 <file> --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 <name>``."""
|
|
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 <name> --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 <name>``."""
|
|
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 <name> --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 <ns>``."""
|
|
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 <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 <name> --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 <name> --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
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────
|
|
# Coverage - Given steps for richer skill configurations
|
|
# ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@given('a skill "{name}" with MCP servers and tools is registered')
|
|
def step_skill_mcp_with_tools(context: Context, name: str) -> None:
|
|
"""Register a skill that has MCP servers with explicit tool lists."""
|
|
service = _get_skill_service()
|
|
skill = Skill(
|
|
name=name,
|
|
description="MCP skill with tools",
|
|
mcp_servers=[
|
|
SkillMcpSource(server="github-mcp", tools=["create_issue", "list_prs"]),
|
|
],
|
|
)
|
|
from datetime import datetime
|
|
|
|
now = datetime.now()
|
|
service._skills[name] = skill
|
|
service._created_at[name] = now
|
|
service._updated_at[name] = now
|
|
|
|
|
|
@given('a skill "{name}" with agent_skills and inline tools is registered')
|
|
def step_skill_agent_and_inline(context: Context, name: str) -> None:
|
|
"""Register a skill with agent_skills and anonymous inline tools."""
|
|
service = _get_skill_service()
|
|
skill = Skill(
|
|
name=name,
|
|
description="Mixed sources skill",
|
|
tool_refs=["builtin/read_file"],
|
|
agent_skills=[SkillAgentSource(path="./skills/deploy")],
|
|
anonymous_tools=[
|
|
SkillInlineTool(
|
|
description="Inline formatter",
|
|
source=ToolSource.CUSTOM,
|
|
code="return formatted",
|
|
timeout=300,
|
|
capability=ToolCapability(
|
|
read_only=False,
|
|
writes=True,
|
|
checkpointable=True,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
from datetime import datetime
|
|
|
|
now = datetime.now()
|
|
service._skills[name] = skill
|
|
service._created_at[name] = now
|
|
service._updated_at[name] = now
|
|
|
|
|
|
@given('a skill "{name}" with MCP servers and inline tools is registered')
|
|
def step_skill_mcp_and_inline(context: Context, name: str) -> None:
|
|
"""Register a skill with MCP servers and inline tools for tools cmd."""
|
|
service = _get_skill_service()
|
|
skill = Skill(
|
|
name=name,
|
|
description="MCP and inline skill",
|
|
mcp_servers=[
|
|
SkillMcpSource(server="test-server", tools=["mcp-tool-a"]),
|
|
],
|
|
anonymous_tools=[
|
|
SkillInlineTool(
|
|
description="Inline tool",
|
|
source=ToolSource.CUSTOM,
|
|
code="return 1",
|
|
timeout=300,
|
|
capability=ToolCapability(
|
|
read_only=True,
|
|
writes=False,
|
|
checkpointable=False,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
from datetime import datetime
|
|
|
|
now = datetime.now()
|
|
service._skills[name] = skill
|
|
service._created_at[name] = now
|
|
service._updated_at[name] = now
|
|
|
|
|
|
@given('a skill "{name}" with only tool_refs is registered')
|
|
def step_skill_only_tool_refs(context: Context, name: str) -> None:
|
|
"""Register a skill with only builtin tool_refs."""
|
|
_register_skill_directly(
|
|
name, description="Builtin only", tool_refs=["builtin/echo"]
|
|
)
|
|
|
|
|
|
@given('a skill "{name}" with agent_skills is registered')
|
|
def step_skill_with_agent_skills(context: Context, name: str) -> None:
|
|
"""Register a skill with agent_skill sources."""
|
|
service = _get_skill_service()
|
|
skill = Skill(
|
|
name=name,
|
|
description="Agent skill source",
|
|
agent_skills=[SkillAgentSource(path="./skills/agent-folder")],
|
|
)
|
|
from datetime import datetime
|
|
|
|
now = datetime.now()
|
|
service._skills[name] = skill
|
|
service._created_at[name] = now
|
|
service._updated_at[name] = now
|
|
|
|
|
|
@given("a skill is registered without a config path")
|
|
def step_skill_no_config_path(context: Context) -> None:
|
|
"""Register a skill via add_skill with config_path=None."""
|
|
from cleveragents.skills.schema import SkillConfigSchema
|
|
|
|
service = _get_skill_service()
|
|
schema = SkillConfigSchema(name="local/no-path", description="No path skill")
|
|
service.add_skill(schema, config_path=None)
|
|
context.no_path_skill_name = "local/no-path"
|
|
|
|
|
|
@given("a skill config YAML with no description at a temp path")
|
|
def step_no_desc_yaml(context: Context) -> None:
|
|
"""Write a skill YAML with no description to a temp file."""
|
|
context.skill_yaml_path = _write_temp_yaml(context, _NO_DESCRIPTION_YAML)
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────
|
|
# Coverage - When steps
|
|
# ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@when('I run skill CLI remove "{name}" without --yes and deny')
|
|
def step_run_skill_remove_deny(context: Context, name: str) -> None:
|
|
"""Run ``skill remove <name>`` without --yes and deny the prompt."""
|
|
context.skill_result = context.skill_runner.invoke(
|
|
skill_app, ["remove", name], input="n\n"
|
|
)
|
|
|
|
|
|
@when('I list skills with source "{source}"')
|
|
def step_list_skills_source(context: Context, source: str) -> None:
|
|
"""List skills filtered by source type via the service."""
|
|
service = _get_skill_service()
|
|
context.skill_list_result = service.list_skills(source=source)
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────
|
|
# Coverage - Then steps
|
|
# ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@then("the skill service should report skill count {count:d}")
|
|
def step_skill_count(context: Context, count: int) -> None:
|
|
"""Assert the service skill_count matches."""
|
|
service = _get_skill_service()
|
|
actual = service.skill_count()
|
|
assert actual == count, f"Expected {count}, got {actual}"
|
|
|
|
|
|
@then("the config path should be empty for the skill")
|
|
def step_config_path_empty(context: Context) -> None:
|
|
"""Assert no config path is stored."""
|
|
service = _get_skill_service()
|
|
path = service.get_config_path(context.no_path_skill_name)
|
|
assert path is None, f"Expected None config path, got {path}"
|
|
|
|
|
|
@then('the skill list should contain "{name}"')
|
|
def step_skill_list_contains(context: Context, name: str) -> None:
|
|
"""Assert the filtered skill list contains the given name."""
|
|
names = [s.name for s in context.skill_list_result]
|
|
assert name in names, f"Expected '{name}' in {names}"
|
|
|
|
|
|
@then("removing a skill with empty name should raise ValueError")
|
|
def step_remove_empty_name(context: Context) -> None:
|
|
"""Assert remove_skill('') raises ValueError."""
|
|
service = _get_skill_service()
|
|
try:
|
|
service.remove_skill("")
|
|
raise AssertionError("Expected ValueError")
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then('removing skill "{name}" should raise KeyError')
|
|
def step_remove_nonexistent(context: Context, name: str) -> None:
|
|
"""Assert remove_skill for nonexistent skill raises KeyError."""
|
|
service = _get_skill_service()
|
|
try:
|
|
service.remove_skill(name)
|
|
raise AssertionError("Expected KeyError")
|
|
except KeyError:
|
|
pass
|
|
|
|
|
|
@then("getting a skill with empty name should raise ValueError")
|
|
def step_get_empty_name(context: Context) -> None:
|
|
"""Assert get_skill('') raises ValueError."""
|
|
service = _get_skill_service()
|
|
try:
|
|
service.get_skill("")
|
|
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
|