feat: expand platform coverage with 8 new adaptors, 7 new CLI agents, and OpenCode skill tools

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>
This commit is contained in:
yusyus
2026-03-21 20:31:51 +03:00
parent 1d3d7389d7
commit cd7b322b5e
24 changed files with 2482 additions and 499 deletions

View File

@@ -94,6 +94,36 @@ minimax = [
"openai>=1.0.0",
]
# Kimi (Moonshot AI) support (uses OpenAI-compatible API)
kimi = [
"openai>=1.0.0",
]
# DeepSeek AI support (uses OpenAI-compatible API)
deepseek = [
"openai>=1.0.0",
]
# Qwen (Alibaba) support (uses OpenAI-compatible API)
qwen = [
"openai>=1.0.0",
]
# OpenRouter support (uses OpenAI-compatible API)
openrouter = [
"openai>=1.0.0",
]
# Together AI support (uses OpenAI-compatible API)
together = [
"openai>=1.0.0",
]
# Fireworks AI support (uses OpenAI-compatible API)
fireworks = [
"openai>=1.0.0",
]
# All LLM platforms combined
all-llms = [
"google-generativeai>=0.8.0",
@@ -306,6 +336,7 @@ skill-seekers-manpage = "skill_seekers.cli.man_scraper:main"
skill-seekers-confluence = "skill_seekers.cli.confluence_scraper:main"
skill-seekers-notion = "skill_seekers.cli.notion_scraper:main"
skill-seekers-chat = "skill_seekers.cli.chat_scraper:main"
skill-seekers-opencode-split = "skill_seekers.cli.opencode_skill_splitter:main"
[tool.setuptools]
package-dir = {"" = "src"}

View File

@@ -3,7 +3,9 @@
Multi-LLM Adaptor Registry
Provides factory function to get platform-specific adaptors for skill generation.
Supports Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, and generic Markdown export.
Supports Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, OpenCode,
Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI,
and generic Markdown export.
"""
from .base import SkillAdaptor, SkillMetadata
@@ -74,6 +76,41 @@ try:
except ImportError:
MiniMaxAdaptor = None
try:
from .opencode import OpenCodeAdaptor
except ImportError:
OpenCodeAdaptor = None
try:
from .kimi import KimiAdaptor
except ImportError:
KimiAdaptor = None
try:
from .deepseek import DeepSeekAdaptor
except ImportError:
DeepSeekAdaptor = None
try:
from .qwen import QwenAdaptor
except ImportError:
QwenAdaptor = None
try:
from .openrouter import OpenRouterAdaptor
except ImportError:
OpenRouterAdaptor = None
try:
from .together import TogetherAdaptor
except ImportError:
TogetherAdaptor = None
try:
from .fireworks import FireworksAdaptor
except ImportError:
FireworksAdaptor = None
# Registry of available adaptors
ADAPTORS: dict[str, type[SkillAdaptor]] = {}
@@ -105,6 +142,20 @@ if PineconeAdaptor:
ADAPTORS["pinecone"] = PineconeAdaptor
if MiniMaxAdaptor:
ADAPTORS["minimax"] = MiniMaxAdaptor
if OpenCodeAdaptor:
ADAPTORS["opencode"] = OpenCodeAdaptor
if KimiAdaptor:
ADAPTORS["kimi"] = KimiAdaptor
if DeepSeekAdaptor:
ADAPTORS["deepseek"] = DeepSeekAdaptor
if QwenAdaptor:
ADAPTORS["qwen"] = QwenAdaptor
if OpenRouterAdaptor:
ADAPTORS["openrouter"] = OpenRouterAdaptor
if TogetherAdaptor:
ADAPTORS["together"] = TogetherAdaptor
if FireworksAdaptor:
ADAPTORS["fireworks"] = FireworksAdaptor
def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor:
@@ -112,7 +163,9 @@ def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor:
Factory function to get platform-specific adaptor instance.
Args:
platform: Platform identifier ('claude', 'gemini', 'openai', 'minimax', 'markdown')
platform: Platform identifier (e.g., 'claude', 'gemini', 'openai', 'minimax',
'opencode', 'kimi', 'deepseek', 'qwen', 'openrouter', 'together',
'fireworks', 'markdown')
config: Optional platform-specific configuration
Returns:

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""
DeepSeek AI Adaptor
OpenAI-compatible LLM platform adaptor for DeepSeek.
"""
from .openai_compatible import OpenAICompatibleAdaptor
class DeepSeekAdaptor(OpenAICompatibleAdaptor):
"""DeepSeek AI platform adaptor."""
PLATFORM = "deepseek"
PLATFORM_NAME = "DeepSeek AI"
DEFAULT_API_ENDPOINT = "https://api.deepseek.com/v1"
DEFAULT_MODEL = "deepseek-chat"
ENV_VAR_NAME = "DEEPSEEK_API_KEY"
PLATFORM_URL = "https://platform.deepseek.com/"

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""
Fireworks AI Adaptor
OpenAI-compatible LLM platform adaptor for Fireworks AI.
"""
from .openai_compatible import OpenAICompatibleAdaptor
class FireworksAdaptor(OpenAICompatibleAdaptor):
"""Fireworks AI platform adaptor."""
PLATFORM = "fireworks"
PLATFORM_NAME = "Fireworks AI"
DEFAULT_API_ENDPOINT = "https://api.fireworks.ai/inference/v1"
DEFAULT_MODEL = "accounts/fireworks/models/llama-v3p1-70b-instruct"
ENV_VAR_NAME = "FIREWORKS_API_KEY"
PLATFORM_URL = "https://fireworks.ai/"

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""
Kimi (Moonshot AI) Adaptor
OpenAI-compatible LLM platform adaptor for Kimi/Moonshot AI.
"""
from .openai_compatible import OpenAICompatibleAdaptor
class KimiAdaptor(OpenAICompatibleAdaptor):
"""Kimi (Moonshot AI) platform adaptor."""
PLATFORM = "kimi"
PLATFORM_NAME = "Kimi (Moonshot AI)"
DEFAULT_API_ENDPOINT = "https://api.moonshot.cn/v1"
DEFAULT_MODEL = "moonshot-v1-128k"
ENV_VAR_NAME = "MOONSHOT_API_KEY"
PLATFORM_URL = "https://platform.moonshot.cn/"

View File

@@ -2,502 +2,19 @@
"""
MiniMax AI Adaptor
Implements platform-specific handling for MiniMax AI skills.
OpenAI-compatible LLM platform adaptor for MiniMax AI.
Uses MiniMax's OpenAI-compatible API for AI enhancement with M2.7 model.
"""
import json
import zipfile
from pathlib import Path
from typing import Any
from .base import SkillAdaptor, SkillMetadata
from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS
from .openai_compatible import OpenAICompatibleAdaptor
class MiniMaxAdaptor(SkillAdaptor):
"""
MiniMax AI platform adaptor.
Handles:
- System instructions format (plain text, no YAML frontmatter)
- ZIP packaging with knowledge files
- AI enhancement using MiniMax-M2.7
"""
class MiniMaxAdaptor(OpenAICompatibleAdaptor):
"""MiniMax AI platform adaptor."""
PLATFORM = "minimax"
PLATFORM_NAME = "MiniMax AI"
DEFAULT_API_ENDPOINT = "https://api.minimax.io/v1"
def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str:
"""
Format SKILL.md as system instructions for MiniMax AI.
MiniMax uses OpenAI-compatible chat completions, so instructions
are formatted as clear system prompts without YAML frontmatter.
Args:
skill_dir: Path to skill directory
metadata: Skill metadata
Returns:
Formatted instructions for MiniMax AI
"""
existing_content = self._read_existing_content(skill_dir)
if existing_content and len(existing_content) > 100:
content_body = f"""You are an expert assistant for {metadata.name}.
{metadata.description}
Use the attached knowledge files to provide accurate, detailed answers about {metadata.name}.
{existing_content}
## How to Assist Users
When users ask questions:
1. Search the knowledge files for relevant information
2. Provide clear, practical answers with code examples
3. Reference specific documentation sections when helpful
4. Be concise but thorough
Always prioritize accuracy by consulting the knowledge base before responding."""
else:
content_body = f"""You are an expert assistant for {metadata.name}.
{metadata.description}
## Your Knowledge Base
You have access to comprehensive documentation files about {metadata.name}. Use these files to provide accurate answers to user questions.
{self._generate_toc(skill_dir)}
## Quick Reference
{self._extract_quick_reference(skill_dir)}
## How to Assist Users
When users ask questions about {metadata.name}:
1. **Search the knowledge files** - Find relevant information in the documentation
2. **Provide code examples** - Include practical, working code snippets
3. **Reference documentation** - Cite specific sections when helpful
4. **Be practical** - Focus on real-world usage and best practices
5. **Stay accurate** - Always verify information against the knowledge base
## Response Guidelines
- Keep answers clear and concise
- Use proper code formatting with language tags
- Provide both simple and detailed explanations as needed
- Suggest related topics when relevant
- Admit when information isn't in the knowledge base
Always prioritize accuracy by consulting the attached documentation files before responding."""
return content_body
def package(
self,
skill_dir: Path,
output_path: Path,
enable_chunking: bool = False,
chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS,
preserve_code_blocks: bool = True,
chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS,
) -> Path:
"""
Package skill into ZIP file for MiniMax AI.
Creates MiniMax-compatible structure:
- system_instructions.txt (main instructions)
- knowledge_files/*.md (reference files)
- minimax_metadata.json (skill metadata)
Args:
skill_dir: Path to skill directory
output_path: Output path/filename for ZIP
Returns:
Path to created ZIP file
"""
skill_dir = Path(skill_dir)
output_path = Path(output_path)
if output_path.is_dir() or str(output_path).endswith("/"):
output_path = Path(output_path) / f"{skill_dir.name}-minimax.zip"
elif not str(output_path).endswith(".zip") and not str(output_path).endswith(
"-minimax.zip"
):
output_str = str(output_path).replace(".zip", "-minimax.zip")
if not output_str.endswith(".zip"):
output_str += ".zip"
output_path = Path(output_str)
output_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
instructions = skill_md.read_text(encoding="utf-8")
zf.writestr("system_instructions.txt", instructions)
refs_dir = skill_dir / "references"
if refs_dir.exists():
for ref_file in refs_dir.rglob("*.md"):
if ref_file.is_file() and not ref_file.name.startswith("."):
arcname = f"knowledge_files/{ref_file.name}"
zf.write(ref_file, arcname)
metadata = {
"platform": "minimax",
"name": skill_dir.name,
"version": "1.0.0",
"created_with": "skill-seekers",
"model": "MiniMax-M2.7",
"api_base": self.DEFAULT_API_ENDPOINT,
}
zf.writestr("minimax_metadata.json", json.dumps(metadata, indent=2))
return output_path
def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]:
"""
Upload packaged skill to MiniMax AI.
MiniMax uses an OpenAI-compatible chat completion API.
This method validates the package and prepares it for use
with the MiniMax API.
Args:
package_path: Path to skill ZIP file
api_key: MiniMax API key
**kwargs: Additional arguments (model, etc.)
Returns:
Dictionary with upload result
"""
package_path = Path(package_path)
if not package_path.exists():
return {
"success": False,
"skill_id": None,
"url": None,
"message": f"File not found: {package_path}",
}
if package_path.suffix != ".zip":
return {
"success": False,
"skill_id": None,
"url": None,
"message": f"Not a ZIP file: {package_path}",
}
try:
from openai import OpenAI, APITimeoutError, APIConnectionError
except ImportError:
return {
"success": False,
"skill_id": None,
"url": None,
"message": "openai library not installed. Run: pip install openai",
}
try:
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
with zipfile.ZipFile(package_path, "r") as zf:
zf.extractall(temp_dir)
temp_path = Path(temp_dir)
instructions_file = temp_path / "system_instructions.txt"
if not instructions_file.exists():
return {
"success": False,
"skill_id": None,
"url": None,
"message": "Invalid package: system_instructions.txt not found",
}
instructions = instructions_file.read_text(encoding="utf-8")
metadata_file = temp_path / "minimax_metadata.json"
skill_name = package_path.stem
model = kwargs.get("model", "MiniMax-M2.7")
if metadata_file.exists():
with open(metadata_file) as f:
metadata = json.load(f)
skill_name = metadata.get("name", skill_name)
model = metadata.get("model", model)
knowledge_dir = temp_path / "knowledge_files"
knowledge_count = 0
if knowledge_dir.exists():
knowledge_count = len(list(knowledge_dir.glob("*.md")))
client = OpenAI(
api_key=api_key,
base_url=self.DEFAULT_API_ENDPOINT,
)
client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": instructions},
{
"role": "user",
"content": f"Confirm you are ready to assist with {skill_name}. Reply briefly.",
},
],
temperature=0.3,
max_tokens=100,
)
return {
"success": True,
"skill_id": None,
"url": "https://platform.minimaxi.com/",
"message": f"Skill '{skill_name}' validated with MiniMax {model} ({knowledge_count} knowledge files)",
}
except APITimeoutError:
return {
"success": False,
"skill_id": None,
"url": None,
"message": "Upload timed out. Try again.",
}
except APIConnectionError:
return {
"success": False,
"skill_id": None,
"url": None,
"message": "Connection error. Check your internet connection.",
}
except Exception as e:
return {
"success": False,
"skill_id": None,
"url": None,
"message": f"Upload failed: {str(e)}",
}
def validate_api_key(self, api_key: str) -> bool:
"""
Validate MiniMax API key format.
MiniMax API keys are opaque strings. We only check for
a non-empty key with a reasonable minimum length.
Args:
api_key: API key to validate
Returns:
True if key format appears valid
"""
key = api_key.strip()
return len(key) > 10
def get_env_var_name(self) -> str:
"""
Get environment variable name for MiniMax API key.
Returns:
'MINIMAX_API_KEY'
"""
return "MINIMAX_API_KEY"
def supports_enhancement(self) -> bool:
"""
MiniMax supports AI enhancement via MiniMax-M2.7.
Returns:
True
"""
return True
def enhance(self, skill_dir: Path, api_key: str) -> bool:
"""
Enhance SKILL.md using MiniMax-M2.7 API.
Uses MiniMax's OpenAI-compatible API endpoint for enhancement.
Args:
skill_dir: Path to skill directory
api_key: MiniMax API key
Returns:
True if enhancement succeeded
"""
try:
from openai import OpenAI
except ImportError:
print("❌ Error: openai package not installed")
print("Install with: pip install openai")
return False
skill_dir = Path(skill_dir)
references_dir = skill_dir / "references"
skill_md_path = skill_dir / "SKILL.md"
print("📖 Reading reference documentation...")
references = self._read_reference_files(references_dir)
if not references:
print("❌ No reference files found to analyze")
return False
print(f" ✓ Read {len(references)} reference files")
total_size = sum(len(c) for c in references.values())
print(f" ✓ Total size: {total_size:,} characters\n")
current_skill_md = None
if skill_md_path.exists():
current_skill_md = skill_md_path.read_text(encoding="utf-8")
print(f" Found existing SKILL.md ({len(current_skill_md)} chars)")
else:
print(" No existing SKILL.md, will create new one")
prompt = self._build_enhancement_prompt(skill_dir.name, references, current_skill_md)
print("\n🤖 Asking MiniMax-M2.7 to enhance SKILL.md...")
print(f" Input: {len(prompt):,} characters")
try:
client = OpenAI(
api_key=api_key,
base_url="https://api.minimax.io/v1",
)
response = client.chat.completions.create(
model="MiniMax-M2.7",
messages=[
{
"role": "system",
"content": "You are an expert technical writer creating system instructions for MiniMax AI.",
},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=4096,
)
enhanced_content = response.choices[0].message.content
print(f" ✓ Generated enhanced SKILL.md ({len(enhanced_content)} chars)\n")
if skill_md_path.exists():
backup_path = skill_md_path.with_suffix(".md.backup")
skill_md_path.rename(backup_path)
print(f" 💾 Backed up original to: {backup_path.name}")
skill_md_path.write_text(enhanced_content, encoding="utf-8")
print(" ✅ Saved enhanced SKILL.md")
return True
except Exception as e:
print(f"❌ Error calling MiniMax API: {e}")
return False
def _read_reference_files(
self, references_dir: Path, max_chars: int = 200000
) -> dict[str, str]:
"""
Read reference markdown files from skill directory.
Args:
references_dir: Path to references directory
max_chars: Maximum total characters to read
Returns:
Dictionary mapping filename to content
"""
if not references_dir.exists():
return {}
references = {}
total_chars = 0
for ref_file in sorted(references_dir.glob("*.md")):
if total_chars >= max_chars:
break
try:
content = ref_file.read_text(encoding="utf-8")
if len(content) > 30000:
content = content[:30000] + "\n\n...(truncated)"
references[ref_file.name] = content
total_chars += len(content)
except Exception as e:
print(f" ⚠️ Could not read {ref_file.name}: {e}")
return references
def _build_enhancement_prompt(
self, skill_name: str, references: dict[str, str], current_skill_md: str = None
) -> str:
"""
Build MiniMax API prompt for enhancement.
Args:
skill_name: Name of the skill
references: Dictionary of reference content
current_skill_md: Existing SKILL.md content (optional)
Returns:
Enhancement prompt for MiniMax-M2.7
"""
prompt = f"""You are creating system instructions for a MiniMax AI assistant about: {skill_name}
I've scraped documentation and organized it into reference files. Your job is to create EXCELLENT system instructions that will help the assistant use this documentation effectively.
CURRENT INSTRUCTIONS:
{"```" if current_skill_md else "(none - create from scratch)"}
{current_skill_md or "No existing instructions"}
{"```" if current_skill_md else ""}
REFERENCE DOCUMENTATION:
"""
for filename, content in references.items():
prompt += f"\n\n## {filename}\n```markdown\n{content[:30000]}\n```\n"
prompt += """
YOUR TASK:
Create enhanced system instructions that include:
1. **Clear role definition** - "You are an expert assistant for [topic]"
2. **Knowledge base description** - What documentation is attached
3. **Excellent Quick Reference** - Extract 5-10 of the BEST, most practical code examples from the reference docs
- Choose SHORT, clear examples that demonstrate common tasks
- Include both simple and intermediate examples
- Annotate examples with clear descriptions
- Use proper language tags (cpp, python, javascript, json, etc.)
4. **Response guidelines** - How the assistant should help users
5. **Search strategy** - How to find information in the knowledge base
6. **DO NOT use YAML frontmatter** - This is plain text instructions
IMPORTANT:
- Extract REAL examples from the reference docs, don't make them up
- Prioritize SHORT, clear examples (5-20 lines max)
- Make it actionable and practical
- Write clear, direct instructions
- Focus on how the assistant should behave and respond
- NO YAML frontmatter (no --- blocks)
OUTPUT:
Return ONLY the complete system instructions as plain text.
"""
return prompt
DEFAULT_MODEL = "MiniMax-M2.7"
ENV_VAR_NAME = "MINIMAX_API_KEY"
PLATFORM_URL = "https://platform.minimaxi.com/"

View File

@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""
OpenAI-Compatible Base Adaptor
Shared base class for all LLM platforms that use OpenAI-compatible APIs.
Subclasses only need to override platform constants (~15 lines each).
"""
import json
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from .base import SkillAdaptor, SkillMetadata
from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS
class OpenAICompatibleAdaptor(SkillAdaptor):
"""
Base class for OpenAI-compatible LLM platform adaptors.
Subclasses override these constants:
- PLATFORM: Registry key (e.g., "kimi")
- PLATFORM_NAME: Display name (e.g., "Kimi (Moonshot AI)")
- DEFAULT_API_ENDPOINT: API base URL
- DEFAULT_MODEL: Default model name
- ENV_VAR_NAME: API key env var name
- PLATFORM_URL: Dashboard/platform URL
"""
PLATFORM = "unknown"
PLATFORM_NAME = "Unknown"
DEFAULT_API_ENDPOINT = ""
DEFAULT_MODEL = ""
ENV_VAR_NAME = ""
PLATFORM_URL = ""
def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str:
"""
Format SKILL.md as system instructions (no YAML frontmatter).
Uses plain text format compatible with OpenAI-compatible chat APIs.
"""
existing_content = self._read_existing_content(skill_dir)
if existing_content and len(existing_content) > 100:
return f"""You are an expert assistant for {metadata.name}.
{metadata.description}
Use the attached knowledge files to provide accurate, detailed answers about {metadata.name}.
{existing_content}
## How to Assist Users
When users ask questions:
1. Search the knowledge files for relevant information
2. Provide clear, practical answers with code examples
3. Reference specific documentation sections when helpful
4. Be concise but thorough
Always prioritize accuracy by consulting the knowledge base before responding."""
return f"""You are an expert assistant for {metadata.name}.
{metadata.description}
## Your Knowledge Base
You have access to comprehensive documentation files about {metadata.name}. Use these files to provide accurate answers to user questions.
{self._generate_toc(skill_dir)}
## Quick Reference
{self._extract_quick_reference(skill_dir)}
## How to Assist Users
When users ask questions about {metadata.name}:
1. **Search the knowledge files** - Find relevant information in the documentation
2. **Provide code examples** - Include practical, working code snippets
3. **Reference documentation** - Cite specific sections when helpful
4. **Be practical** - Focus on real-world usage and best practices
5. **Stay accurate** - Always verify information against the knowledge base
## Response Guidelines
- Keep answers clear and concise
- Use proper code formatting with language tags
- Provide both simple and detailed explanations as needed
- Suggest related topics when relevant
- Admit when information isn't in the knowledge base
Always prioritize accuracy by consulting the attached documentation files before responding."""
def package(
self,
skill_dir: Path,
output_path: Path,
enable_chunking: bool = False,
chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS,
preserve_code_blocks: bool = True,
chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS,
) -> Path:
"""
Package skill into ZIP file for the platform.
Creates platform-compatible structure:
- system_instructions.txt (main instructions)
- knowledge_files/*.md (reference files)
- {platform}_metadata.json (skill metadata)
"""
skill_dir = Path(skill_dir)
output_path = Path(output_path)
suffix = f"-{self.PLATFORM}.zip"
if output_path.is_dir() or str(output_path).endswith("/"):
output_path = Path(output_path) / f"{skill_dir.name}{suffix}"
elif not str(output_path).endswith(suffix):
output_str = str(output_path)
# Strip existing .zip extension if present
if output_str.endswith(".zip"):
output_str = output_str[:-4]
output_path = Path(output_str + suffix)
output_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
instructions = skill_md.read_text(encoding="utf-8")
zf.writestr("system_instructions.txt", instructions)
refs_dir = skill_dir / "references"
if refs_dir.exists():
for ref_file in refs_dir.rglob("*.md"):
if ref_file.is_file() and not ref_file.name.startswith("."):
arcname = f"knowledge_files/{ref_file.name}"
zf.write(ref_file, arcname)
metadata = {
"platform": self.PLATFORM,
"name": skill_dir.name,
"version": "1.0.0",
"created_with": "skill-seekers",
"model": self.DEFAULT_MODEL,
"api_base": self.DEFAULT_API_ENDPOINT,
}
zf.writestr(f"{self.PLATFORM}_metadata.json", json.dumps(metadata, indent=2))
return output_path
def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]:
"""
Upload/validate packaged skill via OpenAI-compatible API.
"""
package_path = Path(package_path)
if not package_path.exists():
return {
"success": False,
"skill_id": None,
"url": None,
"message": f"File not found: {package_path}",
}
if package_path.suffix != ".zip":
return {
"success": False,
"skill_id": None,
"url": None,
"message": f"Not a ZIP file: {package_path}",
}
try:
from openai import OpenAI, APITimeoutError, APIConnectionError
except ImportError:
return {
"success": False,
"skill_id": None,
"url": None,
"message": "openai library not installed. Run: pip install openai",
}
try:
with tempfile.TemporaryDirectory() as temp_dir:
with zipfile.ZipFile(package_path, "r") as zf:
zf.extractall(temp_dir)
temp_path = Path(temp_dir)
instructions_file = temp_path / "system_instructions.txt"
if not instructions_file.exists():
return {
"success": False,
"skill_id": None,
"url": None,
"message": "Invalid package: system_instructions.txt not found",
}
instructions = instructions_file.read_text(encoding="utf-8")
metadata_file = temp_path / f"{self.PLATFORM}_metadata.json"
skill_name = package_path.stem
model = kwargs.get("model", self.DEFAULT_MODEL)
if metadata_file.exists():
with open(metadata_file) as f:
metadata = json.load(f)
skill_name = metadata.get("name", skill_name)
model = metadata.get("model", model)
knowledge_dir = temp_path / "knowledge_files"
knowledge_count = 0
if knowledge_dir.exists():
knowledge_count = len(list(knowledge_dir.glob("*.md")))
client = OpenAI(
api_key=api_key,
base_url=self.DEFAULT_API_ENDPOINT,
)
client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": instructions},
{
"role": "user",
"content": f"Confirm you are ready to assist with {skill_name}. Reply briefly.",
},
],
temperature=0.3,
max_tokens=100,
)
return {
"success": True,
"skill_id": None,
"url": self.PLATFORM_URL,
"message": f"Skill '{skill_name}' validated with {self.PLATFORM_NAME} {model} ({knowledge_count} knowledge files)",
}
except APITimeoutError:
return {
"success": False,
"skill_id": None,
"url": None,
"message": "Upload timed out. Try again.",
}
except APIConnectionError:
return {
"success": False,
"skill_id": None,
"url": None,
"message": "Connection error. Check your internet connection.",
}
except Exception as e:
return {
"success": False,
"skill_id": None,
"url": None,
"message": f"Upload failed: {str(e)}",
}
def validate_api_key(self, api_key: str) -> bool:
"""Validate API key (non-empty, >10 chars)."""
key = api_key.strip()
return len(key) > 10
def get_env_var_name(self) -> str:
"""Get environment variable name for API key."""
return self.ENV_VAR_NAME
def supports_enhancement(self) -> bool:
"""OpenAI-compatible platforms support enhancement."""
return True
def enhance(self, skill_dir: Path, api_key: str) -> bool:
"""
Enhance SKILL.md using the platform's OpenAI-compatible API.
"""
try:
from openai import OpenAI
except ImportError:
print("Error: openai package not installed")
print("Install with: pip install openai")
return False
skill_dir = Path(skill_dir)
references_dir = skill_dir / "references"
skill_md_path = skill_dir / "SKILL.md"
print("Reading reference documentation...")
references = self._read_reference_files(references_dir)
if not references:
print("No reference files found to analyze")
return False
print(f" Read {len(references)} reference files")
total_size = sum(len(c) for c in references.values())
print(f" Total size: {total_size:,} characters\n")
current_skill_md = None
if skill_md_path.exists():
current_skill_md = skill_md_path.read_text(encoding="utf-8")
print(f" Found existing SKILL.md ({len(current_skill_md)} chars)")
else:
print(" No existing SKILL.md, will create new one")
prompt = self._build_enhancement_prompt(skill_dir.name, references, current_skill_md)
print(f"\nAsking {self.PLATFORM_NAME} ({self.DEFAULT_MODEL}) to enhance SKILL.md...")
print(f" Input: {len(prompt):,} characters")
try:
client = OpenAI(
api_key=api_key,
base_url=self.DEFAULT_API_ENDPOINT,
)
response = client.chat.completions.create(
model=self.DEFAULT_MODEL,
messages=[
{
"role": "system",
"content": f"You are an expert technical writer creating system instructions for {self.PLATFORM_NAME}.",
},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=4096,
)
enhanced_content = response.choices[0].message.content
print(f" Generated enhanced SKILL.md ({len(enhanced_content)} chars)\n")
if skill_md_path.exists():
backup_path = skill_md_path.with_suffix(".md.backup")
skill_md_path.rename(backup_path)
print(f" Backed up original to: {backup_path.name}")
skill_md_path.write_text(enhanced_content, encoding="utf-8")
print(" Saved enhanced SKILL.md")
return True
except Exception as e:
print(f"Error calling {self.PLATFORM_NAME} API: {e}")
return False
def _read_reference_files(
self, references_dir: Path, max_chars: int = 200000
) -> dict[str, str]:
"""Read reference markdown files from skill directory."""
if not references_dir.exists():
return {}
references = {}
total_chars = 0
for ref_file in sorted(references_dir.glob("*.md")):
if total_chars >= max_chars:
break
try:
content = ref_file.read_text(encoding="utf-8")
if len(content) > 30000:
content = content[:30000] + "\n\n...(truncated)"
references[ref_file.name] = content
total_chars += len(content)
except Exception as e:
print(f" Could not read {ref_file.name}: {e}")
return references
def _build_enhancement_prompt(
self, skill_name: str, references: dict[str, str], current_skill_md: str = None
) -> str:
"""Build API prompt for enhancement."""
prompt = f"""You are creating system instructions for a {self.PLATFORM_NAME} assistant about: {skill_name}
I've scraped documentation and organized it into reference files. Your job is to create EXCELLENT system instructions that will help the assistant use this documentation effectively.
CURRENT INSTRUCTIONS:
{"```" if current_skill_md else "(none - create from scratch)"}
{current_skill_md or "No existing instructions"}
{"```" if current_skill_md else ""}
REFERENCE DOCUMENTATION:
"""
for filename, content in references.items():
prompt += f"\n\n## {filename}\n```markdown\n{content[:30000]}\n```\n"
prompt += f"""
YOUR TASK:
Create enhanced system instructions that include:
1. **Clear role definition** - "You are an expert assistant for [topic]"
2. **Knowledge base description** - What documentation is attached
3. **Excellent Quick Reference** - Extract 5-10 of the BEST, most practical code examples from the reference docs
- Choose SHORT, clear examples that demonstrate common tasks
- Include both simple and intermediate examples
- Annotate examples with clear descriptions
- Use proper language tags (cpp, python, javascript, json, etc.)
4. **Response guidelines** - How the assistant should help users
5. **Search strategy** - How to find information in the knowledge base
6. **DO NOT use YAML frontmatter** - This is plain text instructions
IMPORTANT:
- Extract REAL examples from the reference docs, don't make them up
- Prioritize SHORT, clear examples (5-20 lines max)
- Make it actionable and practical
- Write clear, direct instructions
- Focus on how the assistant should behave and respond
- NO YAML frontmatter (no --- blocks)
OUTPUT:
Return ONLY the complete system instructions as plain text.
"""
return prompt

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
OpenCode Adaptor
Generates skills in OpenCode-compatible format with YAML frontmatter.
OpenCode searches ~/.opencode/skills/ for SKILL.md files.
"""
import re
import shutil
from pathlib import Path
from typing import Any
from .base import SkillAdaptor, SkillMetadata
from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS
class OpenCodeAdaptor(SkillAdaptor):
"""
OpenCode platform adaptor.
Generates directory-based skill packages with dual-format YAML frontmatter
compatible with both OpenCode and Claude Code.
"""
PLATFORM = "opencode"
PLATFORM_NAME = "OpenCode"
DEFAULT_API_ENDPOINT = None # Local file-based, no API
# OpenCode name validation: kebab-case, 1-64 chars
NAME_REGEX = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
@staticmethod
def _to_kebab_case(name: str) -> str:
"""
Convert any title/name to valid OpenCode kebab-case.
Rules:
- Lowercase
- Replace spaces, underscores, dots with hyphens
- Remove non-alphanumeric chars (except hyphens)
- Collapse multiple hyphens
- Strip leading/trailing hyphens
- Truncate to 64 chars
Args:
name: Input name string
Returns:
Valid kebab-case name (1-64 chars)
"""
result = name.lower()
result = re.sub(r"[_\s.]+", "-", result)
result = re.sub(r"[^a-z0-9-]", "", result)
result = re.sub(r"-+", "-", result)
result = result.strip("-")
result = result[:64]
result = result.rstrip("-")
return result or "skill"
def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str:
"""
Format SKILL.md with OpenCode-compatible YAML frontmatter.
Generates a superset frontmatter that works with both Claude and OpenCode.
OpenCode-required fields: kebab-case name, compatibility, metadata map.
Args:
skill_dir: Path to skill directory
metadata: Skill metadata
Returns:
Formatted SKILL.md content with YAML frontmatter
"""
existing_content = self._read_existing_content(skill_dir)
kebab_name = self._to_kebab_case(metadata.name)
description = metadata.description[:1024] if metadata.description else ""
# Quote description to handle colons and special YAML chars
safe_desc = description.replace('"', '\\"')
safe_source = metadata.name.replace('"', '\\"')
frontmatter = f"""---
name: {kebab_name}
description: "{safe_desc}"
version: {metadata.version}
license: MIT
compatibility: opencode
metadata:
generated-by: skill-seekers
source: "{safe_source}"
version: {metadata.version}
---"""
if existing_content and len(existing_content) > 100:
return f"{frontmatter}\n\n{existing_content}"
toc = self._generate_toc(skill_dir)
quick_ref = self._extract_quick_reference(skill_dir)
body = f"""# {metadata.name}
{metadata.description}
## Documentation
{toc if toc else "See references/ directory for documentation."}
## Quick Reference
{quick_ref}"""
return f"{frontmatter}\n\n{body}"
def package(
self,
skill_dir: Path,
output_path: Path,
enable_chunking: bool = False,
chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS,
preserve_code_blocks: bool = True,
chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS,
) -> Path:
"""
Package skill as a directory (not ZIP) for OpenCode.
Creates: <output>/<name>-opencode/SKILL.md + references/
Args:
skill_dir: Path to skill directory
output_path: Output path for the package directory
Returns:
Path to created directory
"""
skill_dir = Path(skill_dir)
output_path = Path(output_path)
dir_name = f"{skill_dir.name}-opencode"
if output_path.is_dir() or str(output_path).endswith("/"):
target_dir = output_path / dir_name
else:
target_dir = output_path
# Clean and create target
if target_dir.exists():
shutil.rmtree(target_dir)
target_dir.mkdir(parents=True, exist_ok=True)
# Copy SKILL.md
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
shutil.copy2(skill_md, target_dir / "SKILL.md")
# Copy references
refs_dir = skill_dir / "references"
if refs_dir.exists():
target_refs = target_dir / "references"
shutil.copytree(
refs_dir,
target_refs,
ignore=shutil.ignore_patterns("*.backup", ".*"),
)
return target_dir
def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]:
"""
OpenCode uses local files, no upload needed.
Returns local path information.
"""
package_path = Path(package_path)
return {
"success": True,
"skill_id": None,
"url": None,
"message": f"OpenCode skill packaged at: {package_path} (local install only)",
}
def validate_api_key(self, api_key: str) -> bool:
"""No API key needed for OpenCode."""
return True
def supports_enhancement(self) -> bool:
"""OpenCode does not have its own enhancement API."""
return False

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""
OpenRouter Adaptor
OpenAI-compatible LLM platform adaptor for OpenRouter.
"""
from .openai_compatible import OpenAICompatibleAdaptor
class OpenRouterAdaptor(OpenAICompatibleAdaptor):
"""OpenRouter platform adaptor."""
PLATFORM = "openrouter"
PLATFORM_NAME = "OpenRouter"
DEFAULT_API_ENDPOINT = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = "openrouter/auto"
ENV_VAR_NAME = "OPENROUTER_API_KEY"
PLATFORM_URL = "https://openrouter.ai/"

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""
Qwen (Alibaba) Adaptor
OpenAI-compatible LLM platform adaptor for Qwen/DashScope.
"""
from .openai_compatible import OpenAICompatibleAdaptor
class QwenAdaptor(OpenAICompatibleAdaptor):
"""Qwen (Alibaba Cloud) platform adaptor."""
PLATFORM = "qwen"
PLATFORM_NAME = "Qwen (Alibaba)"
DEFAULT_API_ENDPOINT = "https://dashscope.aliyuncs.com/compatible-mode/v1"
DEFAULT_MODEL = "qwen-max"
ENV_VAR_NAME = "DASHSCOPE_API_KEY"
PLATFORM_URL = "https://dashscope.console.aliyun.com/"

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""
Together AI Adaptor
OpenAI-compatible LLM platform adaptor for Together AI.
"""
from .openai_compatible import OpenAICompatibleAdaptor
class TogetherAdaptor(OpenAICompatibleAdaptor):
"""Together AI platform adaptor."""
PLATFORM = "together"
PLATFORM_NAME = "Together AI"
DEFAULT_API_ENDPOINT = "https://api.together.xyz/v1"
DEFAULT_MODEL = "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"
ENV_VAR_NAME = "TOGETHER_API_KEY"
PLATFORM_URL = "https://api.together.xyz/"

View File

@@ -44,6 +44,13 @@ AGENT_PATHS = {
"aide": "~/.aide/skills/", # Global
"windsurf": "~/.windsurf/skills/", # Global
"neovate": "~/.neovate/skills/", # Global
"roo": ".roo/skills/", # Project-relative (Roo Code, Cline fork)
"cline": ".cline/skills/", # Project-relative (Cline AI)
"aider": "~/.aider/skills/", # Global (terminal AI coding)
"bolt": ".bolt/skills/", # Project-relative (Bolt.new/Bolt.diy)
"kilo": ".kilo/skills/", # Project-relative (Kilo Code, Cline fork)
"continue": "~/.continue/skills/", # Global (Continue.dev)
"kimi-code": "~/.kimi/skills/", # Global (Kimi Code)
}
@@ -360,7 +367,8 @@ Examples:
skill-seekers install-agent output/react/ --agent cursor --dry-run
Supported agents:
claude, cursor, vscode, copilot, amp, goose, opencode, letta, aide, windsurf, neovate, all
claude, cursor, vscode, copilot, amp, goose, opencode, letta, aide, windsurf,
neovate, roo, cline, aider, bolt, kilo, continue, kimi-code, all
""",
)

View File

@@ -0,0 +1,447 @@
#!/usr/bin/env python3
"""
OpenCode Skill Splitter
Splits large documentation skills into multiple focused sub-skills for
OpenCode's on-demand loading. Reuses existing split_config + generate_router patterns.
Usage:
skill-seekers opencode-split <skill_directory> [--max-size 50000] [--output-dir output/]
"""
import argparse
import contextlib
import re
import sys
from pathlib import Path
from typing import Any
from skill_seekers.cli.adaptors.opencode import OpenCodeAdaptor
class OpenCodeSkillSplitter:
"""
Splits large skills into multiple focused sub-skills for OpenCode.
Strategy:
1. Read SKILL.md and references
2. Split by H2 sections in SKILL.md (or by reference files if no sections)
3. Generate a router SKILL.md that lists all sub-skills
4. Output each sub-skill with OpenCode-compatible frontmatter
"""
def __init__(self, skill_dir: str | Path, max_chars: int = 50000):
self.skill_dir = Path(skill_dir)
self.max_chars = max_chars
self.adaptor = OpenCodeAdaptor()
def needs_splitting(self) -> bool:
"""Check if the skill exceeds the size threshold."""
total = 0
skill_md = self.skill_dir / "SKILL.md"
if skill_md.exists():
total += skill_md.stat().st_size
refs_dir = self.skill_dir / "references"
if refs_dir.exists():
for f in refs_dir.rglob("*.md"):
total += f.stat().st_size
return total > self.max_chars
def _extract_sections(self, content: str) -> list[dict[str, str]]:
"""
Extract H2 sections from markdown content.
Returns list of {title, content} dicts.
"""
# Strip YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
content = parts[2]
sections = []
# Split on ## headers
pattern = re.compile(r"^## (.+)$", re.MULTILINE)
matches = list(pattern.finditer(content))
if not matches:
return [{"title": "main", "content": content.strip()}]
# Content before first section
preamble = content[: matches[0].start()].strip()
if preamble:
sections.append({"title": "overview", "content": preamble})
for i, match in enumerate(matches):
title = match.group(1).strip()
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
section_content = content[start:end].strip()
if section_content:
sections.append({"title": title, "content": f"## {title}\n\n{section_content}"})
return sections
def _group_small_sections(self, sections: list[dict[str, str]]) -> list[dict[str, str]]:
"""Merge sections that are too small to be standalone skills."""
if not sections:
return sections
grouped = []
current = None
for section in sections:
if current is None:
current = dict(section)
continue
combined_size = len(current["content"]) + len(section["content"])
if combined_size < self.max_chars // 4:
# Merge small sections
current["title"] = f"{current['title']}-and-{section['title']}"
current["content"] += f"\n\n{section['content']}"
else:
grouped.append(current)
current = dict(section)
if current:
grouped.append(current)
return grouped
def split(self, output_dir: str | Path | None = None) -> list[Path]:
"""
Split the skill into multiple sub-skills.
Args:
output_dir: Output directory (default: <skill_dir>-split/)
Returns:
List of paths to created sub-skill directories
"""
if output_dir is None:
output_dir = self.skill_dir.parent / f"{self.skill_dir.name}-opencode-split"
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
skill_name = self.skill_dir.name
base_name = OpenCodeAdaptor._to_kebab_case(skill_name)
# Read SKILL.md
skill_md = self.skill_dir / "SKILL.md"
if not skill_md.exists():
print(f"Error: SKILL.md not found in {self.skill_dir}")
return []
content = skill_md.read_text(encoding="utf-8")
# Extract and group sections
sections = self._extract_sections(content)
sections = self._group_small_sections(sections)
if len(sections) <= 1:
# Try splitting by reference files instead
sections = self._split_by_references()
if len(sections) <= 1:
print(f"Skill {skill_name} has only 1 section, no splitting needed")
return [self.skill_dir]
created_dirs = []
sub_skill_names = []
# Create sub-skills
for section in sections:
section_name = OpenCodeAdaptor._to_kebab_case(section["title"])
sub_name = f"{base_name}-{section_name}"
sub_dir = output_dir / sub_name
sub_dir.mkdir(parents=True, exist_ok=True)
# Write sub-skill SKILL.md with frontmatter (quote values for YAML safety)
safe_title = section["title"].replace('"', '\\"')
safe_skill = skill_name.replace('"', '\\"')
frontmatter = f"""---
name: {sub_name}
description: "{safe_skill} - {safe_title}"
version: 1.0.0
license: MIT
compatibility: opencode
metadata:
generated-by: skill-seekers
source: "{safe_skill}"
parent-skill: {base_name}
section: "{safe_title}"
---"""
sub_content = (
f"{frontmatter}\n\n# {skill_name} - {section['title']}\n\n{section['content']}"
)
(sub_dir / "SKILL.md").write_text(sub_content, encoding="utf-8")
sub_skill_names.append(sub_name)
created_dirs.append(sub_dir)
# Create router skill
router_dir = output_dir / base_name
router_dir.mkdir(parents=True, exist_ok=True)
router_content = self._generate_router(base_name, skill_name, sub_skill_names)
(router_dir / "SKILL.md").write_text(router_content, encoding="utf-8")
created_dirs.insert(0, router_dir)
print(f"Split '{skill_name}' into {len(sub_skill_names)} sub-skills + 1 router:")
print(f" Router: {base_name}/")
for name in sub_skill_names:
print(f" Sub-skill: {name}/")
return created_dirs
def _split_by_references(self) -> list[dict[str, str]]:
"""Split by reference files when SKILL.md doesn't have enough sections."""
refs_dir = self.skill_dir / "references"
if not refs_dir.exists():
return []
sections = []
for ref_file in sorted(refs_dir.glob("*.md")):
if ref_file.name.startswith(".") or ref_file.name == "index.md":
continue
try:
content = ref_file.read_text(encoding="utf-8")
title = ref_file.stem.replace("_", " ").replace("-", " ")
sections.append({"title": title, "content": content})
except Exception:
continue
return sections
def _generate_router(self, base_name: str, skill_name: str, sub_skill_names: list[str]) -> str:
"""Generate a router SKILL.md that lists all sub-skills."""
safe_skill = skill_name.replace('"', '\\"')
frontmatter = f"""---
name: {base_name}
description: "Router for {safe_skill} documentation. Directs to specialized sub-skills."
version: 1.0.0
license: MIT
compatibility: opencode
metadata:
generated-by: skill-seekers
source: "{safe_skill}"
is-router: true
sub-skills: {len(sub_skill_names)}
---"""
sub_list = "\n".join(
f"- `{name}` - {name.replace(base_name + '-', '').replace('-', ' ').title()}"
for name in sub_skill_names
)
body = f"""# {skill_name}
This is a router skill that directs to specialized sub-skills.
## Available Sub-Skills
{sub_list}
## Usage
When answering questions about {skill_name}, load the relevant sub-skill for detailed information.
Each sub-skill covers a specific topic area of the documentation."""
return f"{frontmatter}\n\n{body}"
class OpenCodeSkillConverter:
"""
Bi-directional skill format converter.
Converts between Skill Seekers format and OpenCode ecosystem format.
"""
@staticmethod
def import_opencode_skill(source_dir: str | Path) -> dict[str, Any]:
"""
Import a skill from OpenCode format into Skill Seekers format.
Reads an OpenCode skill directory and returns a normalized dict
suitable for further processing by Skill Seekers adaptors.
Args:
source_dir: Path to OpenCode skill directory
Returns:
Dict with keys: name, description, version, content, references, metadata
"""
source_dir = Path(source_dir)
skill_md = source_dir / "SKILL.md"
if not skill_md.exists():
raise FileNotFoundError(f"SKILL.md not found in {source_dir}")
raw = skill_md.read_text(encoding="utf-8")
# Parse frontmatter
frontmatter = {}
content = raw
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
for line in parts[1].strip().splitlines():
if ":" in line:
key, _, value = line.partition(":")
frontmatter[key.strip()] = value.strip()
content = parts[2].strip()
# Read references
references = {}
refs_dir = source_dir / "references"
if refs_dir.exists():
for ref_file in sorted(refs_dir.glob("*.md")):
if not ref_file.name.startswith("."):
with contextlib.suppress(Exception):
references[ref_file.name] = ref_file.read_text(encoding="utf-8")
return {
"name": frontmatter.get("name", source_dir.name),
"description": frontmatter.get("description", ""),
"version": frontmatter.get("version", "1.0.0"),
"content": content,
"references": references,
"metadata": frontmatter,
"source_format": "opencode",
}
@staticmethod
def export_to_target(
skill_data: dict[str, Any],
target: str,
output_dir: str | Path,
) -> Path:
"""
Export an imported skill to a target platform format.
Args:
skill_data: Normalized skill dict from import_opencode_skill()
target: Target platform ('claude', 'gemini', 'openai', 'markdown', etc.)
output_dir: Output directory
Returns:
Path to the exported skill directory
"""
from skill_seekers.cli.adaptors import get_adaptor
from skill_seekers.cli.adaptors.base import SkillMetadata
output_dir = Path(output_dir)
skill_dir = output_dir / skill_data["name"]
skill_dir.mkdir(parents=True, exist_ok=True)
# Write SKILL.md (raw content without frontmatter for now)
(skill_dir / "SKILL.md").write_text(skill_data["content"], encoding="utf-8")
# Write references
if skill_data.get("references"):
refs_dir = skill_dir / "references"
refs_dir.mkdir(exist_ok=True)
for name, content in skill_data["references"].items():
(refs_dir / name).write_text(content, encoding="utf-8")
# Format using target adaptor
adaptor = get_adaptor(target)
metadata = SkillMetadata(
name=skill_data["name"],
description=skill_data.get("description", ""),
version=skill_data.get("version", "1.0.0"),
)
formatted = adaptor.format_skill_md(skill_dir, metadata)
(skill_dir / "SKILL.md").write_text(formatted, encoding="utf-8")
return skill_dir
def main():
parser = argparse.ArgumentParser(
prog="skill-seekers-opencode-split",
description="Split large skills into OpenCode-compatible sub-skills",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Auto-split a large skill
skill-seekers opencode-split output/react/
# Custom size threshold
skill-seekers opencode-split output/react/ --max-size 30000
# Custom output directory
skill-seekers opencode-split output/react/ --output-dir output/react-split/
# Import an OpenCode skill and convert to Claude format
skill-seekers opencode-convert ~/.opencode/skills/my-skill/ --target claude --output-dir output/
# Check if splitting is needed
skill-seekers opencode-split output/react/ --dry-run
""",
)
subparsers = parser.add_subparsers(dest="command")
# Split command
split_parser = subparsers.add_parser("split", help="Split large skill into sub-skills")
split_parser.add_argument("skill_directory", help="Path to skill directory")
split_parser.add_argument(
"--max-size", type=int, default=50000, help="Max chars before splitting (default: 50000)"
)
split_parser.add_argument("--output-dir", help="Output directory")
split_parser.add_argument(
"--dry-run", action="store_true", help="Check if splitting is needed without making changes"
)
# Convert command
convert_parser = subparsers.add_parser("convert", help="Convert between skill formats")
convert_parser.add_argument("source_directory", help="Path to source skill directory")
convert_parser.add_argument(
"--target", required=True, help="Target platform (claude, gemini, openai, markdown, etc.)"
)
convert_parser.add_argument("--output-dir", required=True, help="Output directory")
args = parser.parse_args()
if args.command == "split" or (not hasattr(args, "command") or args.command is None):
# Default to split if no subcommand but has positional arg
if not hasattr(args, "skill_directory"):
parser.print_help()
return 1
splitter = OpenCodeSkillSplitter(args.skill_directory, args.max_size)
if args.dry_run:
if splitter.needs_splitting():
print(f"Skill needs splitting (exceeds {args.max_size} chars)")
else:
print(f"Skill does not need splitting (under {args.max_size} chars)")
return 0
result = splitter.split(args.output_dir)
return 0 if result else 1
elif args.command == "convert":
try:
skill_data = OpenCodeSkillConverter.import_opencode_skill(args.source_directory)
result = OpenCodeSkillConverter.export_to_target(
skill_data, args.target, args.output_dir
)
print(f"Converted skill to {args.target} format: {result}")
return 0
except Exception as e:
print(f"Error: {e}")
return 1
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,153 @@
# GitHub Actions template for auto-updating Skill Seekers skills
#
# This workflow periodically re-scrapes documentation sources and updates
# the generated skills in your repository.
#
# Usage:
# 1. Copy this file to .github/workflows/update-skills.yml
# 2. Configure the SKILLS matrix below with your documentation sources
# 3. Set ANTHROPIC_API_KEY secret (optional, for AI enhancement)
# 4. Commit and push
#
# The workflow runs weekly by default (configurable via cron schedule).
name: Update AI Skills
on:
schedule:
# Run weekly on Monday at 6:00 AM UTC
- cron: '0 6 * * 1'
workflow_dispatch:
inputs:
skill_name:
description: 'Specific skill to update (leave empty for all)'
required: false
type: string
target:
description: 'Target platform'
required: false
default: 'claude'
type: choice
options:
- claude
- opencode
- gemini
- openai
- markdown
- kimi
- deepseek
- qwen
- openrouter
- together
- fireworks
agent:
description: 'Install to agent (leave empty to skip install)'
required: false
type: choice
options:
- ''
- claude
- cursor
- opencode
- all
env:
PYTHON_VERSION: '3.12'
jobs:
update-skills:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# ============================================================
# CONFIGURE YOUR SKILLS HERE
# Each entry defines a documentation source to scrape.
# ============================================================
skill:
# Example: Web documentation
# - name: react
# source: https://react.dev/reference
# target: claude
# Example: GitHub repository
# - name: fastapi
# source: tiangolo/fastapi
# target: opencode
# Example: PDF documentation
# - name: rfc-http
# source: ./docs/rfc9110.pdf
# target: markdown
# Placeholder - replace with your skills
- name: placeholder
source: https://example.com/docs
target: claude
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Skill Seekers
run: pip install skill-seekers
- name: Check if specific skill requested
id: check
run: |
if [ -n "${{ github.event.inputs.skill_name }}" ]; then
if [ "${{ matrix.skill.name }}" != "${{ github.event.inputs.skill_name }}" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
fi
fi
- name: Generate skill
if: steps.check.outputs.skip != 'true'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TARGET="${{ github.event.inputs.target || matrix.skill.target || 'claude' }}"
skill-seekers create "${{ matrix.skill.source }}" \
--name "${{ matrix.skill.name }}" \
--target "$TARGET" \
--output-dir "output/${{ matrix.skill.name }}"
- name: Install to agent
if: >
steps.check.outputs.skip != 'true' &&
github.event.inputs.agent != ''
run: |
skill-seekers install-agent \
"output/${{ matrix.skill.name }}" \
--agent "${{ github.event.inputs.agent }}" \
--force
- name: Check for changes
if: steps.check.outputs.skip != 'true'
id: changes
run: |
if [ -n "$(git status --porcelain output/)" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Create PR with updated skills
if: steps.changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v6
with:
commit-message: "chore: update ${{ matrix.skill.name }} skill"
title: "Update ${{ matrix.skill.name }} skill"
body: |
Automated skill update for **${{ matrix.skill.name }}**.
Source: `${{ matrix.skill.source }}`
Target: `${{ github.event.inputs.target || matrix.skill.target || 'claude' }}`
Generated by [Skill Seekers](https://github.com/yusufkaraaslan/Skill_Seekers)
branch: "skill-update/${{ matrix.skill.name }}"
delete-branch: true

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Tests for DeepSeek AI adaptor"""
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from skill_seekers.cli.adaptors import get_adaptor, is_platform_available
class TestDeepSeekAdaptor(unittest.TestCase):
def setUp(self):
self.adaptor = get_adaptor("deepseek")
def test_platform_info(self):
self.assertEqual(self.adaptor.PLATFORM, "deepseek")
self.assertEqual(self.adaptor.PLATFORM_NAME, "DeepSeek AI")
self.assertIn("deepseek", self.adaptor.DEFAULT_API_ENDPOINT)
self.assertEqual(self.adaptor.DEFAULT_MODEL, "deepseek-chat")
def test_platform_available(self):
self.assertTrue(is_platform_available("deepseek"))
def test_env_var_name(self):
self.assertEqual(self.adaptor.get_env_var_name(), "DEEPSEEK_API_KEY")
def test_supports_enhancement(self):
self.assertTrue(self.adaptor.supports_enhancement())
def test_package_metadata(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
pkg = self.adaptor.package(skill_dir, output_dir)
self.assertIn("deepseek", pkg.name)
with zipfile.ZipFile(pkg) as zf:
meta = json.loads(zf.read("deepseek_metadata.json"))
self.assertEqual(meta["platform"], "deepseek")
self.assertIn("deepseek", meta["api_base"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Tests for Fireworks AI adaptor"""
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from skill_seekers.cli.adaptors import get_adaptor, is_platform_available
class TestFireworksAdaptor(unittest.TestCase):
def setUp(self):
self.adaptor = get_adaptor("fireworks")
def test_platform_info(self):
self.assertEqual(self.adaptor.PLATFORM, "fireworks")
self.assertEqual(self.adaptor.PLATFORM_NAME, "Fireworks AI")
self.assertIn("fireworks", self.adaptor.DEFAULT_API_ENDPOINT)
self.assertIn("llama", self.adaptor.DEFAULT_MODEL.lower())
def test_platform_available(self):
self.assertTrue(is_platform_available("fireworks"))
def test_env_var_name(self):
self.assertEqual(self.adaptor.get_env_var_name(), "FIREWORKS_API_KEY")
def test_supports_enhancement(self):
self.assertTrue(self.adaptor.supports_enhancement())
def test_package_metadata(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
pkg = self.adaptor.package(skill_dir, output_dir)
self.assertIn("fireworks", pkg.name)
with zipfile.ZipFile(pkg) as zf:
meta = json.loads(zf.read("fireworks_metadata.json"))
self.assertEqual(meta["platform"], "fireworks")
self.assertIn("fireworks", meta["api_base"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Tests for Kimi (Moonshot AI) adaptor"""
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from skill_seekers.cli.adaptors import get_adaptor, is_platform_available
class TestKimiAdaptor(unittest.TestCase):
def setUp(self):
self.adaptor = get_adaptor("kimi")
def test_platform_info(self):
self.assertEqual(self.adaptor.PLATFORM, "kimi")
self.assertEqual(self.adaptor.PLATFORM_NAME, "Kimi (Moonshot AI)")
self.assertIn("moonshot", self.adaptor.DEFAULT_API_ENDPOINT)
self.assertEqual(self.adaptor.DEFAULT_MODEL, "moonshot-v1-128k")
def test_platform_available(self):
self.assertTrue(is_platform_available("kimi"))
def test_env_var_name(self):
self.assertEqual(self.adaptor.get_env_var_name(), "MOONSHOT_API_KEY")
def test_supports_enhancement(self):
self.assertTrue(self.adaptor.supports_enhancement())
def test_package_metadata(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
pkg = self.adaptor.package(skill_dir, output_dir)
self.assertIn("kimi", pkg.name)
with zipfile.ZipFile(pkg) as zf:
meta = json.loads(zf.read("kimi_metadata.json"))
self.assertEqual(meta["platform"], "kimi")
self.assertIn("moonshot", meta["api_base"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
Tests for OpenAI-compatible base adaptor class.
Tests shared behavior across all OpenAI-compatible platforms.
"""
import json
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from skill_seekers.cli.adaptors.openai_compatible import OpenAICompatibleAdaptor
from skill_seekers.cli.adaptors.base import SkillMetadata
class ConcreteTestAdaptor(OpenAICompatibleAdaptor):
"""Concrete subclass for testing the base class."""
PLATFORM = "testplatform"
PLATFORM_NAME = "Test Platform"
DEFAULT_API_ENDPOINT = "https://api.test.example.com/v1"
DEFAULT_MODEL = "test-model-v1"
ENV_VAR_NAME = "TEST_PLATFORM_API_KEY"
PLATFORM_URL = "https://test.example.com/"
class TestOpenAICompatibleBase(unittest.TestCase):
"""Test shared OpenAI-compatible base behavior"""
def setUp(self):
self.adaptor = ConcreteTestAdaptor()
def test_constants_used_in_env_var(self):
self.assertEqual(self.adaptor.get_env_var_name(), "TEST_PLATFORM_API_KEY")
def test_supports_enhancement(self):
self.assertTrue(self.adaptor.supports_enhancement())
def test_validate_api_key_valid(self):
self.assertTrue(self.adaptor.validate_api_key("sk-some-long-api-key-string"))
def test_validate_api_key_invalid(self):
self.assertFalse(self.adaptor.validate_api_key(""))
self.assertFalse(self.adaptor.validate_api_key(" "))
self.assertFalse(self.adaptor.validate_api_key("short"))
def test_format_skill_md_no_frontmatter(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir)
(skill_dir / "references").mkdir()
(skill_dir / "references" / "test.md").write_text("# Test")
metadata = SkillMetadata(name="test-skill", description="Test description")
formatted = self.adaptor.format_skill_md(skill_dir, metadata)
self.assertFalse(formatted.startswith("---"))
self.assertIn("You are an expert assistant", formatted)
self.assertIn("test-skill", formatted)
def test_format_skill_md_with_existing_content(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir)
existing = "# Existing\n\n" + "x" * 200
(skill_dir / "SKILL.md").write_text(existing)
metadata = SkillMetadata(name="test", description="Test")
formatted = self.adaptor.format_skill_md(skill_dir, metadata)
self.assertIn("You are an expert assistant", formatted)
def test_package_creates_zip_with_platform_name(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test instructions")
(skill_dir / "references").mkdir()
(skill_dir / "references" / "guide.md").write_text("# Guide")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
package_path = self.adaptor.package(skill_dir, output_dir)
self.assertTrue(package_path.exists())
self.assertTrue(str(package_path).endswith(".zip"))
self.assertIn("testplatform", package_path.name)
def test_package_metadata_uses_constants(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
(skill_dir / "references").mkdir()
(skill_dir / "references" / "guide.md").write_text("# Guide")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
package_path = self.adaptor.package(skill_dir, output_dir)
with zipfile.ZipFile(package_path, "r") as zf:
metadata_content = zf.read("testplatform_metadata.json").decode("utf-8")
metadata = json.loads(metadata_content)
self.assertEqual(metadata["platform"], "testplatform")
self.assertEqual(metadata["model"], "test-model-v1")
self.assertEqual(metadata["api_base"], "https://api.test.example.com/v1")
def test_package_zip_structure(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
(skill_dir / "references").mkdir()
(skill_dir / "references" / "test.md").write_text("# Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
package_path = self.adaptor.package(skill_dir, output_dir)
with zipfile.ZipFile(package_path, "r") as zf:
names = zf.namelist()
self.assertIn("system_instructions.txt", names)
self.assertIn("testplatform_metadata.json", names)
self.assertTrue(any("knowledge_files" in n for n in names))
def test_upload_missing_file(self):
result = self.adaptor.upload(Path("/nonexistent/file.zip"), "test-key")
self.assertFalse(result["success"])
self.assertIn("not found", result["message"].lower())
def test_upload_wrong_format(self):
with tempfile.NamedTemporaryFile(suffix=".tar.gz") as tmp:
result = self.adaptor.upload(Path(tmp.name), "test-key")
self.assertFalse(result["success"])
self.assertIn("not a zip", result["message"].lower())
def test_upload_missing_library(self):
with tempfile.NamedTemporaryFile(suffix=".zip") as tmp:
with patch.dict(sys.modules, {"openai": None}):
result = self.adaptor.upload(Path(tmp.name), "test-key")
self.assertFalse(result["success"])
self.assertIn("openai", result["message"])
@patch("openai.OpenAI")
def test_upload_success_mocked(self, mock_openai_class):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Ready"
mock_client.chat.completions.create.return_value = mock_response
mock_openai_class.return_value = mock_client
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
(skill_dir / "references").mkdir()
(skill_dir / "references" / "test.md").write_text("# Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
package_path = self.adaptor.package(skill_dir, output_dir)
result = self.adaptor.upload(package_path, "test-long-api-key-string")
self.assertTrue(result["success"])
self.assertEqual(result["url"], "https://test.example.com/")
self.assertIn("validated", result["message"])
def test_read_reference_files(self):
with tempfile.TemporaryDirectory() as temp_dir:
refs_dir = Path(temp_dir)
(refs_dir / "guide.md").write_text("# Guide\nContent")
(refs_dir / "api.md").write_text("# API\nDocs")
refs = self.adaptor._read_reference_files(refs_dir)
self.assertEqual(len(refs), 2)
def test_read_reference_files_truncation(self):
with tempfile.TemporaryDirectory() as temp_dir:
(Path(temp_dir) / "large.md").write_text("x" * 50000)
refs = self.adaptor._read_reference_files(Path(temp_dir))
self.assertIn("truncated", refs["large.md"])
self.assertLessEqual(len(refs["large.md"]), 31000)
def test_build_enhancement_prompt_uses_platform_name(self):
refs = {"test.md": "# Test\nContent"}
prompt = self.adaptor._build_enhancement_prompt("skill", refs, None)
self.assertIn("Test Platform", prompt)
@patch("openai.OpenAI")
def test_enhance_success_mocked(self, mock_openai_class):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Enhanced content"
mock_client.chat.completions.create.return_value = mock_response
mock_openai_class.return_value = mock_client
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir)
refs_dir = skill_dir / "references"
refs_dir.mkdir()
(refs_dir / "test.md").write_text("# Test\nContent")
(skill_dir / "SKILL.md").write_text("Original")
success = self.adaptor.enhance(skill_dir, "test-api-key")
self.assertTrue(success)
self.assertEqual((skill_dir / "SKILL.md").read_text(), "Enhanced content")
self.assertTrue((skill_dir / "SKILL.md.backup").exists())
def test_enhance_missing_references(self):
with tempfile.TemporaryDirectory() as temp_dir:
self.assertFalse(self.adaptor.enhance(Path(temp_dir), "key"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Tests for OpenCode adaptor
"""
import tempfile
import unittest
from pathlib import Path
from skill_seekers.cli.adaptors import get_adaptor, is_platform_available
from skill_seekers.cli.adaptors.base import SkillMetadata
from skill_seekers.cli.adaptors.opencode import OpenCodeAdaptor
class TestOpenCodeAdaptor(unittest.TestCase):
"""Test OpenCode adaptor functionality"""
def setUp(self):
self.adaptor = get_adaptor("opencode")
def test_platform_info(self):
self.assertEqual(self.adaptor.PLATFORM, "opencode")
self.assertEqual(self.adaptor.PLATFORM_NAME, "OpenCode")
self.assertIsNone(self.adaptor.DEFAULT_API_ENDPOINT)
def test_platform_available(self):
self.assertTrue(is_platform_available("opencode"))
def test_validate_api_key_always_true(self):
self.assertTrue(self.adaptor.validate_api_key(""))
self.assertTrue(self.adaptor.validate_api_key("anything"))
def test_no_enhancement_support(self):
self.assertFalse(self.adaptor.supports_enhancement())
def test_upload_returns_local_path(self):
result = self.adaptor.upload(Path("/some/path"), "")
self.assertTrue(result["success"])
self.assertIn("local", result["message"].lower())
# --- Kebab-case conversion ---
def test_kebab_case_spaces(self):
self.assertEqual(OpenCodeAdaptor._to_kebab_case("My Cool Skill"), "my-cool-skill")
def test_kebab_case_underscores(self):
self.assertEqual(OpenCodeAdaptor._to_kebab_case("my_cool_skill"), "my-cool-skill")
def test_kebab_case_special_chars(self):
self.assertEqual(OpenCodeAdaptor._to_kebab_case("My Skill! (v2.0)"), "my-skill-v2-0")
def test_kebab_case_uppercase(self):
self.assertEqual(OpenCodeAdaptor._to_kebab_case("ALLCAPS"), "allcaps")
def test_kebab_case_truncation(self):
long_name = "a" * 100
result = OpenCodeAdaptor._to_kebab_case(long_name)
self.assertLessEqual(len(result), 64)
def test_kebab_case_empty(self):
self.assertEqual(OpenCodeAdaptor._to_kebab_case("!!!"), "skill")
def test_kebab_case_valid_regex(self):
"""All converted names must match OpenCode's regex"""
test_names = [
"My Skill",
"test_skill_v2",
"UPPERCASE NAME",
"special!@#chars",
"dots.and.periods",
"a",
]
for name in test_names:
result = OpenCodeAdaptor._to_kebab_case(name)
self.assertRegex(result, r"^[a-z0-9]+(-[a-z0-9]+)*$", f"Failed for: {name}")
# --- Format ---
def test_format_skill_md_has_frontmatter(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir)
(skill_dir / "references").mkdir()
(skill_dir / "references" / "test.md").write_text("# Test content")
metadata = SkillMetadata(name="test-skill", description="Test description")
formatted = self.adaptor.format_skill_md(skill_dir, metadata)
self.assertTrue(formatted.startswith("---"))
self.assertIn("name: test-skill", formatted)
self.assertIn("compatibility: opencode", formatted)
self.assertIn("generated-by: skill-seekers", formatted)
def test_format_description_truncation(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir)
long_desc = "x" * 2000
metadata = SkillMetadata(name="test", description=long_desc)
formatted = self.adaptor.format_skill_md(skill_dir, metadata)
# The description in frontmatter should be truncated to 1024 chars
# (plus YAML quotes around it)
lines = formatted.split("\n")
for line in lines:
if line.startswith("description:"):
desc_value = line[len("description:") :].strip()
# Strip surrounding quotes for length check
inner = desc_value.strip('"')
self.assertLessEqual(len(inner), 1024)
break
def test_format_with_existing_content(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir)
existing = "# Existing Content\n\n" + "x" * 200
(skill_dir / "SKILL.md").write_text(existing)
metadata = SkillMetadata(name="test", description="Test")
formatted = self.adaptor.format_skill_md(skill_dir, metadata)
self.assertTrue(formatted.startswith("---"))
self.assertIn("Existing Content", formatted)
# --- Package ---
def test_package_creates_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Test")
(skill_dir / "references").mkdir()
(skill_dir / "references" / "guide.md").write_text("# Guide")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
result_path = self.adaptor.package(skill_dir, output_dir)
self.assertTrue(result_path.exists())
self.assertTrue(result_path.is_dir())
self.assertIn("opencode", result_path.name)
def test_package_contains_skill_md(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Test content")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
result_path = self.adaptor.package(skill_dir, output_dir)
self.assertTrue((result_path / "SKILL.md").exists())
content = (result_path / "SKILL.md").read_text()
self.assertEqual(content, "# Test content")
def test_package_copies_references(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Test")
refs = skill_dir / "references"
refs.mkdir()
(refs / "guide.md").write_text("# Guide")
(refs / "api.md").write_text("# API")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
result_path = self.adaptor.package(skill_dir, output_dir)
self.assertTrue((result_path / "references" / "guide.md").exists())
self.assertTrue((result_path / "references" / "api.md").exists())
def test_package_excludes_backup_files(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Test")
refs = skill_dir / "references"
refs.mkdir()
(refs / "guide.md").write_text("# Guide")
(refs / "guide.md.backup").write_text("# Old")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
result_path = self.adaptor.package(skill_dir, output_dir)
self.assertTrue((result_path / "references" / "guide.md").exists())
self.assertFalse((result_path / "references" / "guide.md.backup").exists())
def test_package_without_references(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
result_path = self.adaptor.package(skill_dir, output_dir)
self.assertTrue(result_path.exists())
self.assertTrue((result_path / "SKILL.md").exists())
self.assertFalse((result_path / "references").exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Tests for OpenRouter adaptor"""
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from skill_seekers.cli.adaptors import get_adaptor, is_platform_available
class TestOpenRouterAdaptor(unittest.TestCase):
def setUp(self):
self.adaptor = get_adaptor("openrouter")
def test_platform_info(self):
self.assertEqual(self.adaptor.PLATFORM, "openrouter")
self.assertEqual(self.adaptor.PLATFORM_NAME, "OpenRouter")
self.assertIn("openrouter", self.adaptor.DEFAULT_API_ENDPOINT)
self.assertEqual(self.adaptor.DEFAULT_MODEL, "openrouter/auto")
def test_platform_available(self):
self.assertTrue(is_platform_available("openrouter"))
def test_env_var_name(self):
self.assertEqual(self.adaptor.get_env_var_name(), "OPENROUTER_API_KEY")
def test_supports_enhancement(self):
self.assertTrue(self.adaptor.supports_enhancement())
def test_package_metadata(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
pkg = self.adaptor.package(skill_dir, output_dir)
self.assertIn("openrouter", pkg.name)
with zipfile.ZipFile(pkg) as zf:
meta = json.loads(zf.read("openrouter_metadata.json"))
self.assertEqual(meta["platform"], "openrouter")
self.assertIn("openrouter", meta["api_base"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Tests for Qwen (Alibaba) adaptor"""
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from skill_seekers.cli.adaptors import get_adaptor, is_platform_available
class TestQwenAdaptor(unittest.TestCase):
def setUp(self):
self.adaptor = get_adaptor("qwen")
def test_platform_info(self):
self.assertEqual(self.adaptor.PLATFORM, "qwen")
self.assertEqual(self.adaptor.PLATFORM_NAME, "Qwen (Alibaba)")
self.assertIn("dashscope", self.adaptor.DEFAULT_API_ENDPOINT)
self.assertEqual(self.adaptor.DEFAULT_MODEL, "qwen-max")
def test_platform_available(self):
self.assertTrue(is_platform_available("qwen"))
def test_env_var_name(self):
self.assertEqual(self.adaptor.get_env_var_name(), "DASHSCOPE_API_KEY")
def test_supports_enhancement(self):
self.assertTrue(self.adaptor.supports_enhancement())
def test_package_metadata(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
pkg = self.adaptor.package(skill_dir, output_dir)
self.assertIn("qwen", pkg.name)
with zipfile.ZipFile(pkg) as zf:
meta = json.loads(zf.read("qwen_metadata.json"))
self.assertEqual(meta["platform"], "qwen")
self.assertIn("dashscope", meta["api_base"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Tests for Together AI adaptor"""
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from skill_seekers.cli.adaptors import get_adaptor, is_platform_available
class TestTogetherAdaptor(unittest.TestCase):
def setUp(self):
self.adaptor = get_adaptor("together")
def test_platform_info(self):
self.assertEqual(self.adaptor.PLATFORM, "together")
self.assertEqual(self.adaptor.PLATFORM_NAME, "Together AI")
self.assertIn("together", self.adaptor.DEFAULT_API_ENDPOINT)
self.assertIn("llama", self.adaptor.DEFAULT_MODEL.lower())
def test_platform_available(self):
self.assertTrue(is_platform_available("together"))
def test_env_var_name(self):
self.assertEqual(self.adaptor.get_env_var_name(), "TOGETHER_API_KEY")
def test_supports_enhancement(self):
self.assertTrue(self.adaptor.supports_enhancement())
def test_package_metadata(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Test")
output_dir = Path(temp_dir) / "output"
output_dir.mkdir()
pkg = self.adaptor.package(skill_dir, output_dir)
self.assertIn("together", pkg.name)
with zipfile.ZipFile(pkg) as zf:
meta = json.loads(zf.read("together_metadata.json"))
self.assertEqual(meta["platform"], "together")
self.assertIn("together", meta["api_base"])
if __name__ == "__main__":
unittest.main()

View File

@@ -66,17 +66,38 @@ class TestAgentPathMapping:
get_agent_path("invalid_agent")
def test_get_available_agents(self):
"""Test that all 11 agents are listed."""
"""Test that all 18 agents are listed."""
agents = get_available_agents()
assert len(agents) == 11
assert len(agents) == 18
assert "claude" in agents
assert "cursor" in agents
assert "vscode" in agents
assert "amp" in agents
assert "goose" in agents
assert "neovate" in agents
assert "roo" in agents
assert "cline" in agents
assert "aider" in agents
assert "bolt" in agents
assert "kilo" in agents
assert "continue" in agents
assert "kimi-code" in agents
assert sorted(agents) == agents # Should be sorted
def test_new_agents_project_relative(self):
"""Test that project-relative new agents resolve correctly."""
for agent in ["roo", "cline", "bolt", "kilo"]:
path = get_agent_path(agent)
assert path.is_absolute()
assert str(Path.cwd()) in str(path)
def test_new_agents_global(self):
"""Test that global new agents resolve to home directory."""
for agent in ["aider", "continue", "kimi-code"]:
path = get_agent_path(agent)
assert path.is_absolute()
assert str(path).startswith(str(Path.home()))
def test_agent_path_case_insensitive(self):
"""Test that agent names are case-insensitive."""
path_lower = get_agent_path("claude")
@@ -340,7 +361,7 @@ class TestInstallToAllAgents:
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_install_to_all_success(self):
"""Test that install_to_all_agents attempts all 11 agents."""
"""Test that install_to_all_agents attempts all 18 agents."""
with tempfile.TemporaryDirectory() as agent_tmpdir:
def mock_get_agent_path(agent_name, _project_root=None):
@@ -352,7 +373,7 @@ class TestInstallToAllAgents:
):
results = install_to_all_agents(self.skill_dir, force=True)
assert len(results) == 11
assert len(results) == 18
assert "claude" in results
assert "cursor" in results
@@ -362,7 +383,7 @@ class TestInstallToAllAgents:
results = install_to_all_agents(self.skill_dir, dry_run=True)
# All should succeed in dry-run mode
assert len(results) == 11
assert len(results) == 18
for _agent_name, (success, message) in results.items():
assert success is True
assert "DRY RUN" in message
@@ -399,7 +420,7 @@ class TestInstallToAllAgents:
results = install_to_all_agents(self.skill_dir, dry_run=True)
assert isinstance(results, dict)
assert len(results) == 11
assert len(results) == 18
for agent_name, (success, message) in results.items():
assert isinstance(success, bool)

View File

@@ -0,0 +1,280 @@
#!/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()