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 = "\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()