feat: enhancement workflow preset system with multi-target CLI
- Add YAML-based enhancement workflow presets shipped inside the package (default, minimal, security-focus, architecture-comprehensive, api-documentation) - Add `skill-seekers workflows` subcommand: list, show, copy, add, remove, validate - copy/add/remove all accept multiple names/files in one invocation with partial-failure behaviour - `add --name` override restricted to single-file operations - Add 5 MCP tools: list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow - Fix: create command _add_common_args() now correctly forwards each --enhance-workflow as a separate flag instead of passing the whole list as a single argument - Update README: reposition as "data layer for AI systems" with AI Skills front and centre - Update CHANGELOG, QUICK_REFERENCE, CLAUDE.md with workflow preset details - 1,880+ tests passing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
301
tests/test_workflow_tools_mcp.py
Normal file
301
tests/test_workflow_tools_mcp.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Tests for the workflow MCP tools.
|
||||
|
||||
Covers:
|
||||
- list_workflows_tool
|
||||
- get_workflow_tool
|
||||
- create_workflow_tool
|
||||
- update_workflow_tool
|
||||
- delete_workflow_tool
|
||||
"""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
MINIMAL_YAML = textwrap.dedent("""\
|
||||
name: test-workflow
|
||||
description: A test workflow
|
||||
version: "1.0"
|
||||
applies_to:
|
||||
- codebase_analysis
|
||||
variables: {}
|
||||
stages:
|
||||
- name: step1
|
||||
type: custom
|
||||
target: all
|
||||
uses_history: false
|
||||
enabled: true
|
||||
prompt: "Do something useful."
|
||||
post_process:
|
||||
reorder_sections: []
|
||||
add_metadata: {}
|
||||
""")
|
||||
|
||||
INVALID_YAML_NO_STAGES = textwrap.dedent("""\
|
||||
name: broken
|
||||
description: Missing stages key
|
||||
version: "1.0"
|
||||
""")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Fixtures & helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_user_dir(tmp_path, monkeypatch):
|
||||
"""Redirect USER_WORKFLOWS_DIR in workflow_tools to a temp dir."""
|
||||
fake_dir = tmp_path / "workflows"
|
||||
fake_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
"skill_seekers.mcp.tools.workflow_tools.USER_WORKFLOWS_DIR", fake_dir
|
||||
)
|
||||
return fake_dir
|
||||
|
||||
|
||||
def _mock_bundled_names(names=("default", "security-focus")):
|
||||
return patch(
|
||||
"skill_seekers.mcp.tools.workflow_tools._bundled_names",
|
||||
return_value=list(names),
|
||||
)
|
||||
|
||||
|
||||
def _mock_bundled_text(mapping: dict):
|
||||
def _read(name):
|
||||
return mapping.get(name)
|
||||
return patch(
|
||||
"skill_seekers.mcp.tools.workflow_tools._read_bundled",
|
||||
side_effect=_read,
|
||||
)
|
||||
|
||||
|
||||
def _text(result) -> str:
|
||||
"""Extract text from first TextContent in result."""
|
||||
if isinstance(result, list) and result:
|
||||
item = result[0]
|
||||
return item.text if hasattr(item, "text") else str(item)
|
||||
return str(result)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# list_workflows_tool
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestListWorkflowsTool:
|
||||
def test_lists_bundled_and_user(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import list_workflows_tool
|
||||
|
||||
(tmp_user_dir / "my-workflow.yaml").write_text(MINIMAL_YAML, encoding="utf-8")
|
||||
|
||||
bundled_map = {"default": MINIMAL_YAML}
|
||||
with _mock_bundled_names(["default"]), _mock_bundled_text(bundled_map):
|
||||
result = list_workflows_tool({})
|
||||
|
||||
text = _text(result)
|
||||
assert "default" in text
|
||||
assert "bundled" in text
|
||||
assert "my-workflow" in text
|
||||
assert "user" in text
|
||||
|
||||
def test_empty_lists(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import list_workflows_tool
|
||||
|
||||
with _mock_bundled_names([]):
|
||||
result = list_workflows_tool({})
|
||||
|
||||
text = _text(result)
|
||||
# Should return a valid (possibly empty) YAML list or empty
|
||||
data = yaml.safe_load(text)
|
||||
assert isinstance(data, (list, type(None)))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# get_workflow_tool
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestGetWorkflowTool:
|
||||
def test_get_bundled(self):
|
||||
from skill_seekers.mcp.tools.workflow_tools import get_workflow_tool
|
||||
|
||||
with patch(
|
||||
"skill_seekers.mcp.tools.workflow_tools._read_workflow",
|
||||
return_value=MINIMAL_YAML,
|
||||
):
|
||||
result = get_workflow_tool({"name": "default"})
|
||||
|
||||
assert "stages" in _text(result)
|
||||
|
||||
def test_get_not_found(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import get_workflow_tool
|
||||
|
||||
with _mock_bundled_names([]):
|
||||
result = get_workflow_tool({"name": "ghost"})
|
||||
|
||||
text = _text(result)
|
||||
assert "not found" in text.lower() or "Error" in text
|
||||
|
||||
def test_missing_name_param(self):
|
||||
from skill_seekers.mcp.tools.workflow_tools import get_workflow_tool
|
||||
|
||||
result = get_workflow_tool({})
|
||||
assert "required" in _text(result).lower()
|
||||
|
||||
def test_get_user_workflow(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import get_workflow_tool
|
||||
|
||||
(tmp_user_dir / "custom.yaml").write_text(MINIMAL_YAML, encoding="utf-8")
|
||||
result = get_workflow_tool({"name": "custom"})
|
||||
assert "stages" in _text(result)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# create_workflow_tool
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCreateWorkflowTool:
|
||||
def test_create_new_workflow(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import create_workflow_tool
|
||||
|
||||
result = create_workflow_tool({"name": "new-wf", "content": MINIMAL_YAML})
|
||||
text = _text(result)
|
||||
assert "Created" in text or "created" in text.lower()
|
||||
assert (tmp_user_dir / "new-wf.yaml").exists()
|
||||
|
||||
def test_create_duplicate_fails(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import create_workflow_tool
|
||||
|
||||
(tmp_user_dir / "existing.yaml").write_text(MINIMAL_YAML, encoding="utf-8")
|
||||
result = create_workflow_tool({"name": "existing", "content": MINIMAL_YAML})
|
||||
assert "already exists" in _text(result).lower()
|
||||
|
||||
def test_create_invalid_yaml(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import create_workflow_tool
|
||||
|
||||
result = create_workflow_tool(
|
||||
{"name": "bad", "content": INVALID_YAML_NO_STAGES}
|
||||
)
|
||||
assert "invalid" in _text(result).lower() or "stages" in _text(result).lower()
|
||||
|
||||
def test_create_missing_name(self):
|
||||
from skill_seekers.mcp.tools.workflow_tools import create_workflow_tool
|
||||
|
||||
result = create_workflow_tool({"content": MINIMAL_YAML})
|
||||
assert "required" in _text(result).lower()
|
||||
|
||||
def test_create_missing_content(self):
|
||||
from skill_seekers.mcp.tools.workflow_tools import create_workflow_tool
|
||||
|
||||
result = create_workflow_tool({"name": "test"})
|
||||
assert "required" in _text(result).lower()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# update_workflow_tool
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestUpdateWorkflowTool:
|
||||
def test_update_user_workflow(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import update_workflow_tool
|
||||
|
||||
(tmp_user_dir / "my-wf.yaml").write_text("old content", encoding="utf-8")
|
||||
|
||||
with _mock_bundled_names([]):
|
||||
result = update_workflow_tool(
|
||||
{"name": "my-wf", "content": MINIMAL_YAML}
|
||||
)
|
||||
|
||||
text = _text(result)
|
||||
assert "Updated" in text or "updated" in text.lower()
|
||||
assert (tmp_user_dir / "my-wf.yaml").read_text() == MINIMAL_YAML
|
||||
|
||||
def test_update_bundled_refused(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import update_workflow_tool
|
||||
|
||||
with _mock_bundled_names(["default"]):
|
||||
result = update_workflow_tool(
|
||||
{"name": "default", "content": MINIMAL_YAML}
|
||||
)
|
||||
|
||||
assert "bundled" in _text(result).lower()
|
||||
|
||||
def test_update_invalid_yaml(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import update_workflow_tool
|
||||
|
||||
(tmp_user_dir / "my-wf.yaml").write_text(MINIMAL_YAML, encoding="utf-8")
|
||||
|
||||
with _mock_bundled_names([]):
|
||||
result = update_workflow_tool(
|
||||
{"name": "my-wf", "content": INVALID_YAML_NO_STAGES}
|
||||
)
|
||||
|
||||
assert "invalid" in _text(result).lower() or "stages" in _text(result).lower()
|
||||
|
||||
def test_update_user_override_of_bundled_name(self, tmp_user_dir):
|
||||
"""A user workflow with same name as bundled should be updatable."""
|
||||
from skill_seekers.mcp.tools.workflow_tools import update_workflow_tool
|
||||
|
||||
(tmp_user_dir / "default.yaml").write_text("old", encoding="utf-8")
|
||||
|
||||
with _mock_bundled_names(["default"]):
|
||||
result = update_workflow_tool(
|
||||
{"name": "default", "content": MINIMAL_YAML}
|
||||
)
|
||||
|
||||
text = _text(result)
|
||||
# User has a file named 'default', so it should succeed
|
||||
assert "Updated" in text or "updated" in text.lower()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# delete_workflow_tool
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDeleteWorkflowTool:
|
||||
def test_delete_user_workflow(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import delete_workflow_tool
|
||||
|
||||
wf = tmp_user_dir / "to-delete.yaml"
|
||||
wf.write_text(MINIMAL_YAML, encoding="utf-8")
|
||||
|
||||
with _mock_bundled_names([]):
|
||||
result = delete_workflow_tool({"name": "to-delete"})
|
||||
|
||||
assert "Deleted" in _text(result) or "deleted" in _text(result).lower()
|
||||
assert not wf.exists()
|
||||
|
||||
def test_delete_bundled_refused(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import delete_workflow_tool
|
||||
|
||||
with _mock_bundled_names(["default"]):
|
||||
result = delete_workflow_tool({"name": "default"})
|
||||
|
||||
assert "bundled" in _text(result).lower()
|
||||
|
||||
def test_delete_nonexistent(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import delete_workflow_tool
|
||||
|
||||
with _mock_bundled_names([]):
|
||||
result = delete_workflow_tool({"name": "ghost"})
|
||||
|
||||
assert "not found" in _text(result).lower()
|
||||
|
||||
def test_delete_yml_extension(self, tmp_user_dir):
|
||||
from skill_seekers.mcp.tools.workflow_tools import delete_workflow_tool
|
||||
|
||||
wf = tmp_user_dir / "my-wf.yml"
|
||||
wf.write_text(MINIMAL_YAML, encoding="utf-8")
|
||||
|
||||
with _mock_bundled_names([]):
|
||||
result = delete_workflow_tool({"name": "my-wf"})
|
||||
|
||||
assert not wf.exists()
|
||||
|
||||
def test_delete_missing_name(self):
|
||||
from skill_seekers.mcp.tools.workflow_tools import delete_workflow_tool
|
||||
|
||||
result = delete_workflow_tool({})
|
||||
assert "required" in _text(result).lower()
|
||||
Reference in New Issue
Block a user