Files
amc/tests/test_skills.py
2026-02-26 16:59:34 -05:00

550 lines
22 KiB
Python

import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from amc_server.mixins.skills import SkillsMixin
class DummySkillsHandler(SkillsMixin):
def __init__(self):
self.sent = []
def _send_json(self, code, payload):
self.sent.append((code, payload))
class TestEnumerateClaudeSkills(unittest.TestCase):
"""Tests for _enumerate_claude_skills."""
def setUp(self):
self.handler = DummySkillsHandler()
def test_empty_directory(self):
"""Returns empty list when ~/.claude/skills doesn't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result, [])
def test_reads_skill_md(self):
"""Reads description from SKILL.md."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/my-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("Does something useful")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "my-skill")
self.assertEqual(result[0]["description"], "Does something useful")
def test_fallback_to_readme(self):
"""Falls back to README.md when SKILL.md is missing."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/readme-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "README.md").write_text("# Header\n\nReadme description here")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["description"], "Readme description here")
def test_fallback_priority_order(self):
"""SKILL.md takes priority over skill.md, prompt.md, README.md."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/priority-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("From SKILL.md")
(skill_dir / "README.md").write_text("From README.md")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "From SKILL.md")
def test_skill_md_lowercase_fallback(self):
"""Falls back to skill.md when SKILL.md is missing."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/lower-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "skill.md").write_text("From lowercase skill.md")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "From lowercase skill.md")
def test_skips_hidden_dirs(self):
"""Ignores directories starting with dot."""
with tempfile.TemporaryDirectory() as tmpdir:
skills_dir = Path(tmpdir) / ".claude/skills"
skills_dir.mkdir(parents=True)
# Visible skill
visible = skills_dir / "visible"
visible.mkdir()
(visible / "SKILL.md").write_text("Visible skill")
# Hidden skill
hidden = skills_dir / ".hidden"
hidden.mkdir()
(hidden / "SKILL.md").write_text("Hidden skill")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
names = [s["name"] for s in result]
self.assertIn("visible", names)
self.assertNotIn(".hidden", names)
def test_skips_files_in_skills_dir(self):
"""Only processes directories, not loose files."""
with tempfile.TemporaryDirectory() as tmpdir:
skills_dir = Path(tmpdir) / ".claude/skills"
skills_dir.mkdir(parents=True)
(skills_dir / "not-a-dir.txt").write_text("stray file")
skill = skills_dir / "real-skill"
skill.mkdir()
(skill / "SKILL.md").write_text("A real skill")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "real-skill")
def test_truncates_description(self):
"""Description truncated to 100 chars."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/long-skill"
skill_dir.mkdir(parents=True)
long_desc = "A" * 200
(skill_dir / "SKILL.md").write_text(long_desc)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result[0]["description"]), 100)
def test_skips_headers(self):
"""First non-header line used as description."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/header-skill"
skill_dir.mkdir(parents=True)
content = "# My Skill\n## Subtitle\n\nActual description"
(skill_dir / "SKILL.md").write_text(content)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "Actual description")
def test_no_description_uses_fallback(self):
"""Empty/header-only skill uses 'Skill: name' fallback."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/empty-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# Only Headers\n## Nothing else")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "Skill: empty-skill")
def test_no_md_files_uses_fallback(self):
"""Skill dir with no markdown files uses fallback description."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/bare-skill"
skill_dir.mkdir(parents=True)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "Skill: bare-skill")
def test_os_error_on_read_continues(self):
"""OSError when reading file doesn't crash enumeration."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/broken-skill"
skill_dir.mkdir(parents=True)
md = skill_dir / "SKILL.md"
md.write_text("content")
with patch.object(Path, "home", return_value=Path(tmpdir)), \
patch.object(Path, "read_text", side_effect=OSError("disk error")):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["description"], "Skill: broken-skill")
class TestExtractDescription(unittest.TestCase):
"""Tests for _extract_description."""
def setUp(self):
self.handler = DummySkillsHandler()
def test_empty_content(self):
self.assertEqual(self.handler._extract_description(""), "")
def test_plain_text(self):
self.assertEqual(
self.handler._extract_description("Simple description"),
"Simple description",
)
def test_yaml_frontmatter_description(self):
content = "---\ndescription: A skill for formatting\n---\nBody text"
self.assertEqual(
self.handler._extract_description(content),
"A skill for formatting",
)
def test_yaml_frontmatter_quoted_description(self):
content = '---\ndescription: "Quoted desc"\n---\n'
self.assertEqual(
self.handler._extract_description(content),
"Quoted desc",
)
def test_yaml_frontmatter_single_quoted_description(self):
content = "---\ndescription: 'Single quoted'\n---\n"
self.assertEqual(
self.handler._extract_description(content),
"Single quoted",
)
def test_yaml_multiline_fold_indicator(self):
"""Handles >- style multi-line YAML."""
content = "---\ndescription: >-\n Multi-line folded text\n---\n"
self.assertEqual(
self.handler._extract_description(content),
"Multi-line folded text",
)
def test_yaml_multiline_literal_indicator(self):
"""Handles |- style multi-line YAML."""
content = "---\ndescription: |-\n Literal block text\n---\n"
self.assertEqual(
self.handler._extract_description(content),
"Literal block text",
)
def test_yaml_multiline_bare_fold(self):
"""Handles > without trailing dash."""
content = "---\ndescription: >\n Bare fold\n---\n"
self.assertEqual(
self.handler._extract_description(content),
"Bare fold",
)
def test_yaml_multiline_bare_literal(self):
"""Handles | without trailing dash."""
content = "---\ndescription: |\n Bare literal\n---\n"
self.assertEqual(
self.handler._extract_description(content),
"Bare literal",
)
def test_yaml_empty_description_falls_back_to_body(self):
"""Empty description in frontmatter falls back to body text."""
content = "---\ndescription:\n---\nFallback body line"
self.assertEqual(
self.handler._extract_description(content),
"Fallback body line",
)
def test_skips_headers_and_empty_lines(self):
content = "# Title\n\n## Section\n\nActual content"
self.assertEqual(
self.handler._extract_description(content),
"Actual content",
)
def test_skips_html_comments(self):
content = "<!-- comment -->\nReal content"
self.assertEqual(
self.handler._extract_description(content),
"Real content",
)
def test_truncates_to_100_chars(self):
long_line = "B" * 150
self.assertEqual(
len(self.handler._extract_description(long_line)),
100,
)
def test_frontmatter_description_truncated(self):
desc = "C" * 150
content = f"---\ndescription: {desc}\n---\n"
self.assertEqual(
len(self.handler._extract_description(content)),
100,
)
def test_no_closing_frontmatter_extracts_description(self):
"""Unclosed frontmatter still extracts description from the loop."""
content = "---\ndescription: Orphaned\ntitle: Test"
# The frontmatter loop finds "description:" and returns early,
# even though there's no closing "---"
result = self.handler._extract_description(content)
self.assertEqual(result, "Orphaned")
def test_body_only_headers_returns_empty(self):
content = "# H1\n## H2\n### H3"
self.assertEqual(self.handler._extract_description(content), "")
class TestEnumerateCodexSkills(unittest.TestCase):
"""Tests for _enumerate_codex_skills."""
def setUp(self):
self.handler = DummySkillsHandler()
def test_reads_cache(self):
"""Reads skills from skills-curated-cache.json."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {
"skills": [
{"id": "lint", "shortDescription": "Lint code"},
{"name": "deploy", "description": "Deploy to prod"},
]
}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["name"], "lint")
self.assertEqual(result[0]["description"], "Lint code")
# Falls back to 'name' when 'id' is absent
self.assertEqual(result[1]["name"], "deploy")
self.assertEqual(result[1]["description"], "Deploy to prod")
def test_id_preferred_over_name(self):
"""Uses 'id' field preferentially over 'name'."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "the-id", "name": "the-name", "shortDescription": "desc"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result[0]["name"], "the-id")
def test_short_description_preferred(self):
"""Uses 'shortDescription' preferentially over 'description'."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "sk", "shortDescription": "short", "description": "long version"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result[0]["description"], "short")
def test_invalid_json(self):
"""Continues without cache if JSON is invalid."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
(cache_dir / "skills-curated-cache.json").write_text("{not valid json")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result, [])
def test_combines_cache_and_user(self):
"""Returns both curated and user-installed skills."""
with tempfile.TemporaryDirectory() as tmpdir:
# Curated
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "curated-skill", "shortDescription": "Curated"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
# User
user_skill = Path(tmpdir) / ".codex/skills/user-skill"
user_skill.mkdir(parents=True)
(user_skill / "SKILL.md").write_text("User installed skill")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
names = [s["name"] for s in result]
self.assertIn("curated-skill", names)
self.assertIn("user-skill", names)
self.assertEqual(len(result), 2)
def test_no_deduplication(self):
"""Duplicate names from cache and user both appear."""
with tempfile.TemporaryDirectory() as tmpdir:
# Curated with name "dupe"
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "dupe", "shortDescription": "From cache"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
# User with same name "dupe"
user_skill = Path(tmpdir) / ".codex/skills/dupe"
user_skill.mkdir(parents=True)
(user_skill / "SKILL.md").write_text("From user dir")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
dupe_entries = [s for s in result if s["name"] == "dupe"]
self.assertEqual(len(dupe_entries), 2)
def test_empty_no_cache_no_user_dir(self):
"""Returns empty list when neither cache nor user dir exists."""
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result, [])
def test_skips_entries_without_name_or_id(self):
"""Cache entries without name or id are skipped."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"shortDescription": "No name"}, {"id": "valid", "shortDescription": "OK"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "valid")
def test_missing_description_uses_fallback(self):
"""Cache entry without description uses 'Skill: name' fallback."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "bare"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result[0]["description"], "Skill: bare")
def test_user_skill_without_skill_md_uses_fallback(self):
"""User skill dir without SKILL.md uses fallback description."""
with tempfile.TemporaryDirectory() as tmpdir:
user_skill = Path(tmpdir) / ".codex/skills/no-md"
user_skill.mkdir(parents=True)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result[0]["description"], "User skill: no-md")
def test_user_skills_skip_hidden_dirs(self):
"""Hidden directories in user skills dir are skipped."""
with tempfile.TemporaryDirectory() as tmpdir:
user_dir = Path(tmpdir) / ".codex/skills"
user_dir.mkdir(parents=True)
(user_dir / "visible").mkdir()
(user_dir / ".hidden").mkdir()
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
names = [s["name"] for s in result]
self.assertIn("visible", names)
self.assertNotIn(".hidden", names)
def test_description_truncated_to_100(self):
"""Codex cache description truncated to 100 chars."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "long", "shortDescription": "D" * 200}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(len(result[0]["description"]), 100)
class TestServeSkills(unittest.TestCase):
"""Tests for _serve_skills."""
def setUp(self):
self.handler = DummySkillsHandler()
def test_claude_trigger(self):
"""Returns / trigger for claude agent."""
with patch.object(self.handler, "_enumerate_claude_skills", return_value=[]):
self.handler._serve_skills("claude")
self.assertEqual(self.handler.sent[0][0], 200)
self.assertEqual(self.handler.sent[0][1]["trigger"], "/")
def test_codex_trigger(self):
"""Returns $ trigger for codex agent."""
with patch.object(self.handler, "_enumerate_codex_skills", return_value=[]):
self.handler._serve_skills("codex")
self.assertEqual(self.handler.sent[0][0], 200)
self.assertEqual(self.handler.sent[0][1]["trigger"], "$")
def test_default_to_claude(self):
"""Unknown agent defaults to claude (/ trigger)."""
with patch.object(self.handler, "_enumerate_claude_skills", return_value=[]):
self.handler._serve_skills("unknown-agent")
self.assertEqual(self.handler.sent[0][1]["trigger"], "/")
def test_alphabetical_sort(self):
"""Skills sorted alphabetically (case-insensitive)."""
skills = [
{"name": "Zebra", "description": "z"},
{"name": "alpha", "description": "a"},
{"name": "Beta", "description": "b"},
]
with patch.object(self.handler, "_enumerate_claude_skills", return_value=skills):
self.handler._serve_skills("claude")
result_names = [s["name"] for s in self.handler.sent[0][1]["skills"]]
self.assertEqual(result_names, ["alpha", "Beta", "Zebra"])
def test_response_format(self):
"""Response has trigger and skills keys."""
skills = [{"name": "test", "description": "A test skill"}]
with patch.object(self.handler, "_enumerate_claude_skills", return_value=skills):
self.handler._serve_skills("claude")
code, payload = self.handler.sent[0]
self.assertEqual(code, 200)
self.assertIn("trigger", payload)
self.assertIn("skills", payload)
self.assertEqual(len(payload["skills"]), 1)
def test_empty_skills_list(self):
"""Empty skill list still returns valid response."""
with patch.object(self.handler, "_enumerate_claude_skills", return_value=[]):
self.handler._serve_skills("claude")
payload = self.handler.sent[0][1]
self.assertEqual(payload["skills"], [])
self.assertEqual(payload["trigger"], "/")
if __name__ == "__main__":
unittest.main()