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:
@@ -94,6 +94,36 @@ minimax = [
|
|||||||
"openai>=1.0.0",
|
"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 LLM platforms combined
|
||||||
all-llms = [
|
all-llms = [
|
||||||
"google-generativeai>=0.8.0",
|
"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-confluence = "skill_seekers.cli.confluence_scraper:main"
|
||||||
skill-seekers-notion = "skill_seekers.cli.notion_scraper:main"
|
skill-seekers-notion = "skill_seekers.cli.notion_scraper:main"
|
||||||
skill-seekers-chat = "skill_seekers.cli.chat_scraper:main"
|
skill-seekers-chat = "skill_seekers.cli.chat_scraper:main"
|
||||||
|
skill-seekers-opencode-split = "skill_seekers.cli.opencode_skill_splitter:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = {"" = "src"}
|
package-dir = {"" = "src"}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
Multi-LLM Adaptor Registry
|
Multi-LLM Adaptor Registry
|
||||||
|
|
||||||
Provides factory function to get platform-specific adaptors for skill generation.
|
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
|
from .base import SkillAdaptor, SkillMetadata
|
||||||
@@ -74,6 +76,41 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
MiniMaxAdaptor = None
|
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
|
# Registry of available adaptors
|
||||||
ADAPTORS: dict[str, type[SkillAdaptor]] = {}
|
ADAPTORS: dict[str, type[SkillAdaptor]] = {}
|
||||||
@@ -105,6 +142,20 @@ if PineconeAdaptor:
|
|||||||
ADAPTORS["pinecone"] = PineconeAdaptor
|
ADAPTORS["pinecone"] = PineconeAdaptor
|
||||||
if MiniMaxAdaptor:
|
if MiniMaxAdaptor:
|
||||||
ADAPTORS["minimax"] = 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:
|
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.
|
Factory function to get platform-specific adaptor instance.
|
||||||
|
|
||||||
Args:
|
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
|
config: Optional platform-specific configuration
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
19
src/skill_seekers/cli/adaptors/deepseek.py
Normal file
19
src/skill_seekers/cli/adaptors/deepseek.py
Normal 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/"
|
||||||
19
src/skill_seekers/cli/adaptors/fireworks.py
Normal file
19
src/skill_seekers/cli/adaptors/fireworks.py
Normal 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/"
|
||||||
19
src/skill_seekers/cli/adaptors/kimi.py
Normal file
19
src/skill_seekers/cli/adaptors/kimi.py
Normal 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/"
|
||||||
@@ -2,502 +2,19 @@
|
|||||||
"""
|
"""
|
||||||
MiniMax AI Adaptor
|
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.
|
Uses MiniMax's OpenAI-compatible API for AI enhancement with M2.7 model.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
from .openai_compatible import OpenAICompatibleAdaptor
|
||||||
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 MiniMaxAdaptor(SkillAdaptor):
|
class MiniMaxAdaptor(OpenAICompatibleAdaptor):
|
||||||
"""
|
"""MiniMax AI platform adaptor."""
|
||||||
MiniMax AI platform adaptor.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- System instructions format (plain text, no YAML frontmatter)
|
|
||||||
- ZIP packaging with knowledge files
|
|
||||||
- AI enhancement using MiniMax-M2.7
|
|
||||||
"""
|
|
||||||
|
|
||||||
PLATFORM = "minimax"
|
PLATFORM = "minimax"
|
||||||
PLATFORM_NAME = "MiniMax AI"
|
PLATFORM_NAME = "MiniMax AI"
|
||||||
DEFAULT_API_ENDPOINT = "https://api.minimax.io/v1"
|
DEFAULT_API_ENDPOINT = "https://api.minimax.io/v1"
|
||||||
|
DEFAULT_MODEL = "MiniMax-M2.7"
|
||||||
def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str:
|
ENV_VAR_NAME = "MINIMAX_API_KEY"
|
||||||
"""
|
PLATFORM_URL = "https://platform.minimaxi.com/"
|
||||||
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
|
|
||||||
|
|||||||
431
src/skill_seekers/cli/adaptors/openai_compatible.py
Normal file
431
src/skill_seekers/cli/adaptors/openai_compatible.py
Normal 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
|
||||||
188
src/skill_seekers/cli/adaptors/opencode.py
Normal file
188
src/skill_seekers/cli/adaptors/opencode.py
Normal 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
|
||||||
19
src/skill_seekers/cli/adaptors/openrouter.py
Normal file
19
src/skill_seekers/cli/adaptors/openrouter.py
Normal 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/"
|
||||||
19
src/skill_seekers/cli/adaptors/qwen.py
Normal file
19
src/skill_seekers/cli/adaptors/qwen.py
Normal 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/"
|
||||||
19
src/skill_seekers/cli/adaptors/together.py
Normal file
19
src/skill_seekers/cli/adaptors/together.py
Normal 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/"
|
||||||
@@ -44,6 +44,13 @@ AGENT_PATHS = {
|
|||||||
"aide": "~/.aide/skills/", # Global
|
"aide": "~/.aide/skills/", # Global
|
||||||
"windsurf": "~/.windsurf/skills/", # Global
|
"windsurf": "~/.windsurf/skills/", # Global
|
||||||
"neovate": "~/.neovate/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
|
skill-seekers install-agent output/react/ --agent cursor --dry-run
|
||||||
|
|
||||||
Supported agents:
|
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
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
447
src/skill_seekers/cli/opencode_skill_splitter.py
Normal file
447
src/skill_seekers/cli/opencode_skill_splitter.py
Normal 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())
|
||||||
153
templates/github-actions/update-skills.yml
Normal file
153
templates/github-actions/update-skills.yml
Normal 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
|
||||||
51
tests/test_adaptors/test_deepseek_adaptor.py
Normal file
51
tests/test_adaptors/test_deepseek_adaptor.py
Normal 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()
|
||||||
51
tests/test_adaptors/test_fireworks_adaptor.py
Normal file
51
tests/test_adaptors/test_fireworks_adaptor.py
Normal 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()
|
||||||
51
tests/test_adaptors/test_kimi_adaptor.py
Normal file
51
tests/test_adaptors/test_kimi_adaptor.py
Normal 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()
|
||||||
224
tests/test_adaptors/test_openai_compatible_base.py
Normal file
224
tests/test_adaptors/test_openai_compatible_base.py
Normal 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()
|
||||||
210
tests/test_adaptors/test_opencode_adaptor.py
Normal file
210
tests/test_adaptors/test_opencode_adaptor.py
Normal 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()
|
||||||
51
tests/test_adaptors/test_openrouter_adaptor.py
Normal file
51
tests/test_adaptors/test_openrouter_adaptor.py
Normal 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()
|
||||||
51
tests/test_adaptors/test_qwen_adaptor.py
Normal file
51
tests/test_adaptors/test_qwen_adaptor.py
Normal 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()
|
||||||
51
tests/test_adaptors/test_together_adaptor.py
Normal file
51
tests/test_adaptors/test_together_adaptor.py
Normal 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()
|
||||||
@@ -66,17 +66,38 @@ class TestAgentPathMapping:
|
|||||||
get_agent_path("invalid_agent")
|
get_agent_path("invalid_agent")
|
||||||
|
|
||||||
def test_get_available_agents(self):
|
def test_get_available_agents(self):
|
||||||
"""Test that all 11 agents are listed."""
|
"""Test that all 18 agents are listed."""
|
||||||
agents = get_available_agents()
|
agents = get_available_agents()
|
||||||
assert len(agents) == 11
|
assert len(agents) == 18
|
||||||
assert "claude" in agents
|
assert "claude" in agents
|
||||||
assert "cursor" in agents
|
assert "cursor" in agents
|
||||||
assert "vscode" in agents
|
assert "vscode" in agents
|
||||||
assert "amp" in agents
|
assert "amp" in agents
|
||||||
assert "goose" in agents
|
assert "goose" in agents
|
||||||
assert "neovate" 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
|
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):
|
def test_agent_path_case_insensitive(self):
|
||||||
"""Test that agent names are case-insensitive."""
|
"""Test that agent names are case-insensitive."""
|
||||||
path_lower = get_agent_path("claude")
|
path_lower = get_agent_path("claude")
|
||||||
@@ -340,7 +361,7 @@ class TestInstallToAllAgents:
|
|||||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
def test_install_to_all_success(self):
|
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:
|
with tempfile.TemporaryDirectory() as agent_tmpdir:
|
||||||
|
|
||||||
def mock_get_agent_path(agent_name, _project_root=None):
|
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)
|
results = install_to_all_agents(self.skill_dir, force=True)
|
||||||
|
|
||||||
assert len(results) == 11
|
assert len(results) == 18
|
||||||
assert "claude" in results
|
assert "claude" in results
|
||||||
assert "cursor" in results
|
assert "cursor" in results
|
||||||
|
|
||||||
@@ -362,7 +383,7 @@ class TestInstallToAllAgents:
|
|||||||
results = install_to_all_agents(self.skill_dir, dry_run=True)
|
results = install_to_all_agents(self.skill_dir, dry_run=True)
|
||||||
|
|
||||||
# All should succeed in dry-run mode
|
# All should succeed in dry-run mode
|
||||||
assert len(results) == 11
|
assert len(results) == 18
|
||||||
for _agent_name, (success, message) in results.items():
|
for _agent_name, (success, message) in results.items():
|
||||||
assert success is True
|
assert success is True
|
||||||
assert "DRY RUN" in message
|
assert "DRY RUN" in message
|
||||||
@@ -399,7 +420,7 @@ class TestInstallToAllAgents:
|
|||||||
results = install_to_all_agents(self.skill_dir, dry_run=True)
|
results = install_to_all_agents(self.skill_dir, dry_run=True)
|
||||||
|
|
||||||
assert isinstance(results, dict)
|
assert isinstance(results, dict)
|
||||||
assert len(results) == 11
|
assert len(results) == 18
|
||||||
|
|
||||||
for agent_name, (success, message) in results.items():
|
for agent_name, (success, message) in results.items():
|
||||||
assert isinstance(success, bool)
|
assert isinstance(success, bool)
|
||||||
|
|||||||
280
tests/test_opencode_skill_splitter.py
Normal file
280
tests/test_opencode_skill_splitter.py
Normal 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()
|
||||||
Reference in New Issue
Block a user