550 lines
22 KiB
Python
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()
|