"""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 ``.""" 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 # ──────────────────────────────────────────────────────────── # 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 `` 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 ``.""" 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 --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 --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