Phase 1 - OpenCode Integration: - Add OpenCodeAdaptor with directory-based packaging and dual-format YAML frontmatter - Kebab-case name validation matching OpenCode's regex spec Phase 2 - OpenAI-Compatible LLM Platforms: - Extract OpenAICompatibleAdaptor base class from MiniMax (shared format/package/upload/enhance) - Refactor MiniMax to ~20 lines of constants inheriting from base - Add 6 new LLM adaptors: Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - All use OpenAI-compatible API with platform-specific constants Phase 3 - CLI Agent Expansion: - Add 7 new install-agent paths: roo, cline, aider, bolt, kilo, continue, kimi-code - Total agents: 11 -> 18 Phase 4 - Advanced Features: - OpenCode skill splitter (auto-split large docs into focused sub-skills with router) - Bi-directional skill format converter (import/export between OpenCode and any platform) - GitHub Actions template for automated skill updates Totals: 12 --target platforms, 18 --agent paths, 2915 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
281 lines
11 KiB
Python
281 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Tests for OpenCode skill splitter and converter.
|
|
"""
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from skill_seekers.cli.opencode_skill_splitter import (
|
|
OpenCodeSkillConverter,
|
|
OpenCodeSkillSplitter,
|
|
)
|
|
|
|
|
|
class TestOpenCodeSkillSplitter(unittest.TestCase):
|
|
"""Test skill splitting for OpenCode"""
|
|
|
|
def _create_skill(self, temp_dir, name="test-skill", content=None, refs=None):
|
|
"""Helper to create a test skill directory."""
|
|
skill_dir = Path(temp_dir) / name
|
|
skill_dir.mkdir()
|
|
|
|
if content is None:
|
|
content = "# Test Skill\n\n## Section A\n\nContent A\n\n## Section B\n\nContent B\n\n## Section C\n\nContent C"
|
|
(skill_dir / "SKILL.md").write_text(content)
|
|
|
|
if refs:
|
|
refs_dir = skill_dir / "references"
|
|
refs_dir.mkdir()
|
|
for fname, fcontent in refs.items():
|
|
(refs_dir / fname).write_text(fcontent)
|
|
|
|
return skill_dir
|
|
|
|
def test_needs_splitting_small(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = self._create_skill(tmp, content="Small content")
|
|
splitter = OpenCodeSkillSplitter(skill_dir, max_chars=50000)
|
|
self.assertFalse(splitter.needs_splitting())
|
|
|
|
def test_needs_splitting_large(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = self._create_skill(tmp, content="x" * 60000)
|
|
splitter = OpenCodeSkillSplitter(skill_dir, max_chars=50000)
|
|
self.assertTrue(splitter.needs_splitting())
|
|
|
|
def test_extract_sections(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = self._create_skill(tmp)
|
|
splitter = OpenCodeSkillSplitter(skill_dir)
|
|
content = (skill_dir / "SKILL.md").read_text()
|
|
sections = splitter._extract_sections(content)
|
|
# Should have: overview + Section A + Section B + Section C
|
|
self.assertGreaterEqual(len(sections), 3)
|
|
|
|
def test_extract_sections_strips_frontmatter(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
content = "---\nname: test\n---\n\n## Section A\n\nContent A"
|
|
skill_dir = self._create_skill(tmp, content=content)
|
|
splitter = OpenCodeSkillSplitter(skill_dir)
|
|
sections = splitter._extract_sections(content)
|
|
self.assertEqual(len(sections), 1)
|
|
self.assertEqual(sections[0]["title"], "Section A")
|
|
|
|
def test_split_creates_sub_skills(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = self._create_skill(tmp)
|
|
splitter = OpenCodeSkillSplitter(skill_dir, max_chars=10)
|
|
|
|
output_dir = Path(tmp) / "output"
|
|
result = splitter.split(output_dir)
|
|
|
|
# Should create router + sub-skills
|
|
self.assertGreater(len(result), 1)
|
|
|
|
# Each should have SKILL.md
|
|
for d in result:
|
|
self.assertTrue((d / "SKILL.md").exists())
|
|
|
|
def test_split_router_has_frontmatter(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = self._create_skill(tmp)
|
|
splitter = OpenCodeSkillSplitter(skill_dir, max_chars=10)
|
|
|
|
output_dir = Path(tmp) / "output"
|
|
result = splitter.split(output_dir)
|
|
|
|
# Router is first
|
|
router_content = (result[0] / "SKILL.md").read_text()
|
|
self.assertTrue(router_content.startswith("---"))
|
|
self.assertIn("is-router: true", router_content)
|
|
|
|
def test_split_sub_skills_have_frontmatter(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = self._create_skill(tmp)
|
|
splitter = OpenCodeSkillSplitter(skill_dir, max_chars=10)
|
|
|
|
output_dir = Path(tmp) / "output"
|
|
result = splitter.split(output_dir)
|
|
|
|
# Sub-skills (skip router at index 0)
|
|
for d in result[1:]:
|
|
content = (d / "SKILL.md").read_text()
|
|
self.assertTrue(content.startswith("---"))
|
|
self.assertIn("compatibility: opencode", content)
|
|
self.assertIn("parent-skill:", content)
|
|
|
|
def test_split_by_references(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
# Skill with no H2 sections but multiple reference files
|
|
skill_dir = self._create_skill(
|
|
tmp,
|
|
content="# Simple Skill\n\nJust one paragraph.",
|
|
refs={
|
|
"getting-started.md": "# Getting Started\n\nContent here",
|
|
"api-reference.md": "# API Reference\n\nAPI docs",
|
|
"advanced-topics.md": "# Advanced Topics\n\nAdvanced content",
|
|
},
|
|
)
|
|
splitter = OpenCodeSkillSplitter(skill_dir, max_chars=10)
|
|
|
|
output_dir = Path(tmp) / "output"
|
|
result = splitter.split(output_dir)
|
|
|
|
# Should split by references: router + 3 sub-skills
|
|
self.assertEqual(len(result), 4)
|
|
|
|
def test_no_split_needed(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = self._create_skill(tmp, content="# Simple\n\nSmall content")
|
|
splitter = OpenCodeSkillSplitter(skill_dir, max_chars=100000)
|
|
|
|
output_dir = Path(tmp) / "output"
|
|
result = splitter.split(output_dir)
|
|
|
|
# Should return original skill dir (no split)
|
|
self.assertEqual(len(result), 1)
|
|
|
|
def test_group_small_sections(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = self._create_skill(tmp)
|
|
splitter = OpenCodeSkillSplitter(skill_dir, max_chars=100000)
|
|
|
|
sections = [
|
|
{"title": "a", "content": "short"},
|
|
{"title": "b", "content": "also short"},
|
|
{"title": "c", "content": "x" * 50000},
|
|
]
|
|
grouped = splitter._group_small_sections(sections)
|
|
|
|
# a and b should be merged, c stays separate
|
|
self.assertEqual(len(grouped), 2)
|
|
|
|
|
|
class TestOpenCodeSkillConverter(unittest.TestCase):
|
|
"""Test bi-directional skill format converter"""
|
|
|
|
def test_import_opencode_skill(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = Path(tmp) / "my-skill"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\nname: my-skill\ndescription: Test skill\nversion: 2.0.0\n---\n\n# Content\n\nHello"
|
|
)
|
|
refs = skill_dir / "references"
|
|
refs.mkdir()
|
|
(refs / "guide.md").write_text("# Guide")
|
|
|
|
data = OpenCodeSkillConverter.import_opencode_skill(skill_dir)
|
|
|
|
self.assertEqual(data["name"], "my-skill")
|
|
self.assertEqual(data["description"], "Test skill")
|
|
self.assertEqual(data["version"], "2.0.0")
|
|
self.assertIn("# Content", data["content"])
|
|
self.assertIn("guide.md", data["references"])
|
|
self.assertEqual(data["source_format"], "opencode")
|
|
|
|
def test_import_opencode_skill_no_frontmatter(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill_dir = Path(tmp) / "plain-skill"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text("# Plain content\n\nNo frontmatter")
|
|
|
|
data = OpenCodeSkillConverter.import_opencode_skill(skill_dir)
|
|
|
|
self.assertEqual(data["name"], "plain-skill")
|
|
self.assertIn("Plain content", data["content"])
|
|
|
|
def test_import_missing_skill(self):
|
|
with self.assertRaises(FileNotFoundError):
|
|
OpenCodeSkillConverter.import_opencode_skill("/nonexistent/path")
|
|
|
|
def test_export_to_claude(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
# Create source skill
|
|
source = Path(tmp) / "source"
|
|
source.mkdir()
|
|
(source / "SKILL.md").write_text("---\nname: test\ndescription: Test\n---\n\n# Content")
|
|
|
|
# Import and export
|
|
data = OpenCodeSkillConverter.import_opencode_skill(source)
|
|
output = Path(tmp) / "output"
|
|
result = OpenCodeSkillConverter.export_to_target(data, "claude", output)
|
|
|
|
self.assertTrue(result.exists())
|
|
self.assertTrue((result / "SKILL.md").exists())
|
|
|
|
def test_export_to_markdown(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
source = Path(tmp) / "source"
|
|
source.mkdir()
|
|
(source / "SKILL.md").write_text("# Simple content")
|
|
|
|
data = OpenCodeSkillConverter.import_opencode_skill(source)
|
|
output = Path(tmp) / "output"
|
|
result = OpenCodeSkillConverter.export_to_target(data, "markdown", output)
|
|
|
|
self.assertTrue(result.exists())
|
|
self.assertTrue((result / "SKILL.md").exists())
|
|
|
|
def test_roundtrip_opencode(self):
|
|
"""Test import from OpenCode -> export to OpenCode preserves content."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
# Create original
|
|
original = Path(tmp) / "original"
|
|
original.mkdir()
|
|
original_content = "---\nname: roundtrip-test\ndescription: Roundtrip test\n---\n\n# Roundtrip Content\n\nImportant data here."
|
|
(original / "SKILL.md").write_text(original_content)
|
|
refs = original / "references"
|
|
refs.mkdir()
|
|
(refs / "ref.md").write_text("# Reference")
|
|
|
|
# Import
|
|
data = OpenCodeSkillConverter.import_opencode_skill(original)
|
|
|
|
# Export to opencode
|
|
output = Path(tmp) / "output"
|
|
result = OpenCodeSkillConverter.export_to_target(data, "opencode", output)
|
|
|
|
# Verify
|
|
exported = (result / "SKILL.md").read_text()
|
|
self.assertIn("roundtrip-test", exported)
|
|
self.assertIn("compatibility: opencode", exported)
|
|
|
|
|
|
class TestGitHubActionsTemplate(unittest.TestCase):
|
|
"""Test that GitHub Actions template exists and is valid YAML."""
|
|
|
|
def test_template_exists(self):
|
|
template = (
|
|
Path(__file__).parent.parent / "templates" / "github-actions" / "update-skills.yml"
|
|
)
|
|
self.assertTrue(template.exists(), f"Template not found at {template}")
|
|
|
|
def test_template_has_required_keys(self):
|
|
template = (
|
|
Path(__file__).parent.parent / "templates" / "github-actions" / "update-skills.yml"
|
|
)
|
|
content = template.read_text()
|
|
|
|
self.assertIn("name:", content)
|
|
self.assertIn("on:", content)
|
|
self.assertIn("jobs:", content)
|
|
self.assertIn("skill-seekers", content)
|
|
self.assertIn("schedule:", content)
|
|
self.assertIn("workflow_dispatch:", content)
|
|
|
|
def test_template_lists_all_targets(self):
|
|
template = (
|
|
Path(__file__).parent.parent / "templates" / "github-actions" / "update-skills.yml"
|
|
)
|
|
content = template.read_text()
|
|
|
|
for target in ["claude", "opencode", "gemini", "openai", "kimi", "deepseek", "qwen"]:
|
|
self.assertIn(target, content, f"Target '{target}' not found in template")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|