From cd7b322b5e8b4bb81c41fbb682da716c3466f37f Mon Sep 17 00:00:00 2001 From: yusyus Date: Sat, 21 Mar 2026 20:31:51 +0300 Subject: [PATCH] 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) --- pyproject.toml | 31 ++ src/skill_seekers/cli/adaptors/__init__.py | 57 +- src/skill_seekers/cli/adaptors/deepseek.py | 19 + src/skill_seekers/cli/adaptors/fireworks.py | 19 + src/skill_seekers/cli/adaptors/kimi.py | 19 + src/skill_seekers/cli/adaptors/minimax.py | 497 +----------------- .../cli/adaptors/openai_compatible.py | 431 +++++++++++++++ src/skill_seekers/cli/adaptors/opencode.py | 188 +++++++ src/skill_seekers/cli/adaptors/openrouter.py | 19 + src/skill_seekers/cli/adaptors/qwen.py | 19 + src/skill_seekers/cli/adaptors/together.py | 19 + src/skill_seekers/cli/install_agent.py | 10 +- .../cli/opencode_skill_splitter.py | 447 ++++++++++++++++ templates/github-actions/update-skills.yml | 153 ++++++ tests/test_adaptors/test_deepseek_adaptor.py | 51 ++ tests/test_adaptors/test_fireworks_adaptor.py | 51 ++ tests/test_adaptors/test_kimi_adaptor.py | 51 ++ .../test_openai_compatible_base.py | 224 ++++++++ tests/test_adaptors/test_opencode_adaptor.py | 210 ++++++++ .../test_adaptors/test_openrouter_adaptor.py | 51 ++ tests/test_adaptors/test_qwen_adaptor.py | 51 ++ tests/test_adaptors/test_together_adaptor.py | 51 ++ tests/test_install_agent.py | 33 +- tests/test_opencode_skill_splitter.py | 280 ++++++++++ 24 files changed, 2482 insertions(+), 499 deletions(-) create mode 100644 src/skill_seekers/cli/adaptors/deepseek.py create mode 100644 src/skill_seekers/cli/adaptors/fireworks.py create mode 100644 src/skill_seekers/cli/adaptors/kimi.py create mode 100644 src/skill_seekers/cli/adaptors/openai_compatible.py create mode 100644 src/skill_seekers/cli/adaptors/opencode.py create mode 100644 src/skill_seekers/cli/adaptors/openrouter.py create mode 100644 src/skill_seekers/cli/adaptors/qwen.py create mode 100644 src/skill_seekers/cli/adaptors/together.py create mode 100644 src/skill_seekers/cli/opencode_skill_splitter.py create mode 100644 templates/github-actions/update-skills.yml create mode 100644 tests/test_adaptors/test_deepseek_adaptor.py create mode 100644 tests/test_adaptors/test_fireworks_adaptor.py create mode 100644 tests/test_adaptors/test_kimi_adaptor.py create mode 100644 tests/test_adaptors/test_openai_compatible_base.py create mode 100644 tests/test_adaptors/test_opencode_adaptor.py create mode 100644 tests/test_adaptors/test_openrouter_adaptor.py create mode 100644 tests/test_adaptors/test_qwen_adaptor.py create mode 100644 tests/test_adaptors/test_together_adaptor.py create mode 100644 tests/test_opencode_skill_splitter.py diff --git a/pyproject.toml b/pyproject.toml index 2bb1b8b..8aa51b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,36 @@ minimax = [ "openai>=1.0.0", ] +# Kimi (Moonshot AI) support (uses OpenAI-compatible API) +kimi = [ + "openai>=1.0.0", +] + +# DeepSeek AI support (uses OpenAI-compatible API) +deepseek = [ + "openai>=1.0.0", +] + +# Qwen (Alibaba) support (uses OpenAI-compatible API) +qwen = [ + "openai>=1.0.0", +] + +# OpenRouter support (uses OpenAI-compatible API) +openrouter = [ + "openai>=1.0.0", +] + +# Together AI support (uses OpenAI-compatible API) +together = [ + "openai>=1.0.0", +] + +# Fireworks AI support (uses OpenAI-compatible API) +fireworks = [ + "openai>=1.0.0", +] + # All LLM platforms combined all-llms = [ "google-generativeai>=0.8.0", @@ -306,6 +336,7 @@ skill-seekers-manpage = "skill_seekers.cli.man_scraper:main" skill-seekers-confluence = "skill_seekers.cli.confluence_scraper:main" skill-seekers-notion = "skill_seekers.cli.notion_scraper:main" skill-seekers-chat = "skill_seekers.cli.chat_scraper:main" +skill-seekers-opencode-split = "skill_seekers.cli.opencode_skill_splitter:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/skill_seekers/cli/adaptors/__init__.py b/src/skill_seekers/cli/adaptors/__init__.py index 2350858..494ee50 100644 --- a/src/skill_seekers/cli/adaptors/__init__.py +++ b/src/skill_seekers/cli/adaptors/__init__.py @@ -3,7 +3,9 @@ Multi-LLM Adaptor Registry Provides factory function to get platform-specific adaptors for skill generation. -Supports Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, and generic Markdown export. +Supports Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, OpenCode, +Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI, +and generic Markdown export. """ from .base import SkillAdaptor, SkillMetadata @@ -74,6 +76,41 @@ try: except ImportError: MiniMaxAdaptor = None +try: + from .opencode import OpenCodeAdaptor +except ImportError: + OpenCodeAdaptor = None + +try: + from .kimi import KimiAdaptor +except ImportError: + KimiAdaptor = None + +try: + from .deepseek import DeepSeekAdaptor +except ImportError: + DeepSeekAdaptor = None + +try: + from .qwen import QwenAdaptor +except ImportError: + QwenAdaptor = None + +try: + from .openrouter import OpenRouterAdaptor +except ImportError: + OpenRouterAdaptor = None + +try: + from .together import TogetherAdaptor +except ImportError: + TogetherAdaptor = None + +try: + from .fireworks import FireworksAdaptor +except ImportError: + FireworksAdaptor = None + # Registry of available adaptors ADAPTORS: dict[str, type[SkillAdaptor]] = {} @@ -105,6 +142,20 @@ if PineconeAdaptor: ADAPTORS["pinecone"] = PineconeAdaptor if MiniMaxAdaptor: ADAPTORS["minimax"] = MiniMaxAdaptor +if OpenCodeAdaptor: + ADAPTORS["opencode"] = OpenCodeAdaptor +if KimiAdaptor: + ADAPTORS["kimi"] = KimiAdaptor +if DeepSeekAdaptor: + ADAPTORS["deepseek"] = DeepSeekAdaptor +if QwenAdaptor: + ADAPTORS["qwen"] = QwenAdaptor +if OpenRouterAdaptor: + ADAPTORS["openrouter"] = OpenRouterAdaptor +if TogetherAdaptor: + ADAPTORS["together"] = TogetherAdaptor +if FireworksAdaptor: + ADAPTORS["fireworks"] = FireworksAdaptor def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor: @@ -112,7 +163,9 @@ def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor: Factory function to get platform-specific adaptor instance. Args: - platform: Platform identifier ('claude', 'gemini', 'openai', 'minimax', 'markdown') + platform: Platform identifier (e.g., 'claude', 'gemini', 'openai', 'minimax', + 'opencode', 'kimi', 'deepseek', 'qwen', 'openrouter', 'together', + 'fireworks', 'markdown') config: Optional platform-specific configuration Returns: diff --git a/src/skill_seekers/cli/adaptors/deepseek.py b/src/skill_seekers/cli/adaptors/deepseek.py new file mode 100644 index 0000000..513537d --- /dev/null +++ b/src/skill_seekers/cli/adaptors/deepseek.py @@ -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/" diff --git a/src/skill_seekers/cli/adaptors/fireworks.py b/src/skill_seekers/cli/adaptors/fireworks.py new file mode 100644 index 0000000..7f4bae9 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/fireworks.py @@ -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/" diff --git a/src/skill_seekers/cli/adaptors/kimi.py b/src/skill_seekers/cli/adaptors/kimi.py new file mode 100644 index 0000000..4a38389 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/kimi.py @@ -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/" diff --git a/src/skill_seekers/cli/adaptors/minimax.py b/src/skill_seekers/cli/adaptors/minimax.py index ca9a272..8d73b66 100644 --- a/src/skill_seekers/cli/adaptors/minimax.py +++ b/src/skill_seekers/cli/adaptors/minimax.py @@ -2,502 +2,19 @@ """ MiniMax AI Adaptor -Implements platform-specific handling for MiniMax AI skills. +OpenAI-compatible LLM platform adaptor for MiniMax AI. Uses MiniMax's OpenAI-compatible API for AI enhancement with M2.7 model. """ -import json -import zipfile -from pathlib import Path -from typing import Any - -from .base import SkillAdaptor, SkillMetadata -from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS +from .openai_compatible import OpenAICompatibleAdaptor -class MiniMaxAdaptor(SkillAdaptor): - """ - MiniMax AI platform adaptor. - - Handles: - - System instructions format (plain text, no YAML frontmatter) - - ZIP packaging with knowledge files - - AI enhancement using MiniMax-M2.7 - """ +class MiniMaxAdaptor(OpenAICompatibleAdaptor): + """MiniMax AI platform adaptor.""" PLATFORM = "minimax" PLATFORM_NAME = "MiniMax AI" DEFAULT_API_ENDPOINT = "https://api.minimax.io/v1" - - def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str: - """ - Format SKILL.md as system instructions for MiniMax AI. - - MiniMax uses OpenAI-compatible chat completions, so instructions - are formatted as clear system prompts without YAML frontmatter. - - Args: - skill_dir: Path to skill directory - metadata: Skill metadata - - Returns: - Formatted instructions for MiniMax AI - """ - existing_content = self._read_existing_content(skill_dir) - - if existing_content and len(existing_content) > 100: - content_body = f"""You are an expert assistant for {metadata.name}. - -{metadata.description} - -Use the attached knowledge files to provide accurate, detailed answers about {metadata.name}. - -{existing_content} - -## How to Assist Users - -When users ask questions: -1. Search the knowledge files for relevant information -2. Provide clear, practical answers with code examples -3. Reference specific documentation sections when helpful -4. Be concise but thorough - -Always prioritize accuracy by consulting the knowledge base before responding.""" - else: - content_body = f"""You are an expert assistant for {metadata.name}. - -{metadata.description} - -## Your Knowledge Base - -You have access to comprehensive documentation files about {metadata.name}. Use these files to provide accurate answers to user questions. - -{self._generate_toc(skill_dir)} - -## Quick Reference - -{self._extract_quick_reference(skill_dir)} - -## How to Assist Users - -When users ask questions about {metadata.name}: - -1. **Search the knowledge files** - Find relevant information in the documentation -2. **Provide code examples** - Include practical, working code snippets -3. **Reference documentation** - Cite specific sections when helpful -4. **Be practical** - Focus on real-world usage and best practices -5. **Stay accurate** - Always verify information against the knowledge base - -## Response Guidelines - -- Keep answers clear and concise -- Use proper code formatting with language tags -- Provide both simple and detailed explanations as needed -- Suggest related topics when relevant -- Admit when information isn't in the knowledge base - -Always prioritize accuracy by consulting the attached documentation files before responding.""" - - return content_body - - def package( - self, - skill_dir: Path, - output_path: Path, - enable_chunking: bool = False, - chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS, - preserve_code_blocks: bool = True, - chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS, - ) -> Path: - """ - Package skill into ZIP file for MiniMax AI. - - Creates MiniMax-compatible structure: - - system_instructions.txt (main instructions) - - knowledge_files/*.md (reference files) - - minimax_metadata.json (skill metadata) - - Args: - skill_dir: Path to skill directory - output_path: Output path/filename for ZIP - - Returns: - Path to created ZIP file - """ - skill_dir = Path(skill_dir) - output_path = Path(output_path) - - if output_path.is_dir() or str(output_path).endswith("/"): - output_path = Path(output_path) / f"{skill_dir.name}-minimax.zip" - elif not str(output_path).endswith(".zip") and not str(output_path).endswith( - "-minimax.zip" - ): - output_str = str(output_path).replace(".zip", "-minimax.zip") - if not output_str.endswith(".zip"): - output_str += ".zip" - output_path = Path(output_str) - - output_path.parent.mkdir(parents=True, exist_ok=True) - - with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: - skill_md = skill_dir / "SKILL.md" - if skill_md.exists(): - instructions = skill_md.read_text(encoding="utf-8") - zf.writestr("system_instructions.txt", instructions) - - refs_dir = skill_dir / "references" - if refs_dir.exists(): - for ref_file in refs_dir.rglob("*.md"): - if ref_file.is_file() and not ref_file.name.startswith("."): - arcname = f"knowledge_files/{ref_file.name}" - zf.write(ref_file, arcname) - - metadata = { - "platform": "minimax", - "name": skill_dir.name, - "version": "1.0.0", - "created_with": "skill-seekers", - "model": "MiniMax-M2.7", - "api_base": self.DEFAULT_API_ENDPOINT, - } - - zf.writestr("minimax_metadata.json", json.dumps(metadata, indent=2)) - - return output_path - - def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]: - """ - Upload packaged skill to MiniMax AI. - - MiniMax uses an OpenAI-compatible chat completion API. - This method validates the package and prepares it for use - with the MiniMax API. - - Args: - package_path: Path to skill ZIP file - api_key: MiniMax API key - **kwargs: Additional arguments (model, etc.) - - Returns: - Dictionary with upload result - """ - package_path = Path(package_path) - if not package_path.exists(): - return { - "success": False, - "skill_id": None, - "url": None, - "message": f"File not found: {package_path}", - } - - if package_path.suffix != ".zip": - return { - "success": False, - "skill_id": None, - "url": None, - "message": f"Not a ZIP file: {package_path}", - } - - try: - from openai import OpenAI, APITimeoutError, APIConnectionError - except ImportError: - return { - "success": False, - "skill_id": None, - "url": None, - "message": "openai library not installed. Run: pip install openai", - } - - try: - import tempfile - - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(package_path, "r") as zf: - zf.extractall(temp_dir) - - temp_path = Path(temp_dir) - - instructions_file = temp_path / "system_instructions.txt" - if not instructions_file.exists(): - return { - "success": False, - "skill_id": None, - "url": None, - "message": "Invalid package: system_instructions.txt not found", - } - - instructions = instructions_file.read_text(encoding="utf-8") - - metadata_file = temp_path / "minimax_metadata.json" - skill_name = package_path.stem - model = kwargs.get("model", "MiniMax-M2.7") - - if metadata_file.exists(): - with open(metadata_file) as f: - metadata = json.load(f) - skill_name = metadata.get("name", skill_name) - model = metadata.get("model", model) - - knowledge_dir = temp_path / "knowledge_files" - knowledge_count = 0 - if knowledge_dir.exists(): - knowledge_count = len(list(knowledge_dir.glob("*.md"))) - - client = OpenAI( - api_key=api_key, - base_url=self.DEFAULT_API_ENDPOINT, - ) - - client.chat.completions.create( - model=model, - messages=[ - {"role": "system", "content": instructions}, - { - "role": "user", - "content": f"Confirm you are ready to assist with {skill_name}. Reply briefly.", - }, - ], - temperature=0.3, - max_tokens=100, - ) - - return { - "success": True, - "skill_id": None, - "url": "https://platform.minimaxi.com/", - "message": f"Skill '{skill_name}' validated with MiniMax {model} ({knowledge_count} knowledge files)", - } - - except APITimeoutError: - return { - "success": False, - "skill_id": None, - "url": None, - "message": "Upload timed out. Try again.", - } - except APIConnectionError: - return { - "success": False, - "skill_id": None, - "url": None, - "message": "Connection error. Check your internet connection.", - } - except Exception as e: - return { - "success": False, - "skill_id": None, - "url": None, - "message": f"Upload failed: {str(e)}", - } - - def validate_api_key(self, api_key: str) -> bool: - """ - Validate MiniMax API key format. - - MiniMax API keys are opaque strings. We only check for - a non-empty key with a reasonable minimum length. - - Args: - api_key: API key to validate - - Returns: - True if key format appears valid - """ - key = api_key.strip() - return len(key) > 10 - - def get_env_var_name(self) -> str: - """ - Get environment variable name for MiniMax API key. - - Returns: - 'MINIMAX_API_KEY' - """ - return "MINIMAX_API_KEY" - - def supports_enhancement(self) -> bool: - """ - MiniMax supports AI enhancement via MiniMax-M2.7. - - Returns: - True - """ - return True - - def enhance(self, skill_dir: Path, api_key: str) -> bool: - """ - Enhance SKILL.md using MiniMax-M2.7 API. - - Uses MiniMax's OpenAI-compatible API endpoint for enhancement. - - Args: - skill_dir: Path to skill directory - api_key: MiniMax API key - - Returns: - True if enhancement succeeded - """ - try: - from openai import OpenAI - except ImportError: - print("āŒ Error: openai package not installed") - print("Install with: pip install openai") - return False - - skill_dir = Path(skill_dir) - references_dir = skill_dir / "references" - skill_md_path = skill_dir / "SKILL.md" - - print("šŸ“– Reading reference documentation...") - references = self._read_reference_files(references_dir) - - if not references: - print("āŒ No reference files found to analyze") - return False - - print(f" āœ“ Read {len(references)} reference files") - total_size = sum(len(c) for c in references.values()) - print(f" āœ“ Total size: {total_size:,} characters\n") - - current_skill_md = None - if skill_md_path.exists(): - current_skill_md = skill_md_path.read_text(encoding="utf-8") - print(f" ℹ Found existing SKILL.md ({len(current_skill_md)} chars)") - else: - print(" ℹ No existing SKILL.md, will create new one") - - prompt = self._build_enhancement_prompt(skill_dir.name, references, current_skill_md) - - print("\nšŸ¤– Asking MiniMax-M2.7 to enhance SKILL.md...") - print(f" Input: {len(prompt):,} characters") - - try: - client = OpenAI( - api_key=api_key, - base_url="https://api.minimax.io/v1", - ) - - response = client.chat.completions.create( - model="MiniMax-M2.7", - messages=[ - { - "role": "system", - "content": "You are an expert technical writer creating system instructions for MiniMax AI.", - }, - {"role": "user", "content": prompt}, - ], - temperature=0.3, - max_tokens=4096, - ) - - enhanced_content = response.choices[0].message.content - print(f" āœ“ Generated enhanced SKILL.md ({len(enhanced_content)} chars)\n") - - if skill_md_path.exists(): - backup_path = skill_md_path.with_suffix(".md.backup") - skill_md_path.rename(backup_path) - print(f" šŸ’¾ Backed up original to: {backup_path.name}") - - skill_md_path.write_text(enhanced_content, encoding="utf-8") - print(" āœ… Saved enhanced SKILL.md") - - return True - - except Exception as e: - print(f"āŒ Error calling MiniMax API: {e}") - return False - - def _read_reference_files( - self, references_dir: Path, max_chars: int = 200000 - ) -> dict[str, str]: - """ - Read reference markdown files from skill directory. - - Args: - references_dir: Path to references directory - max_chars: Maximum total characters to read - - Returns: - Dictionary mapping filename to content - """ - if not references_dir.exists(): - return {} - - references = {} - total_chars = 0 - - for ref_file in sorted(references_dir.glob("*.md")): - if total_chars >= max_chars: - break - - try: - content = ref_file.read_text(encoding="utf-8") - if len(content) > 30000: - content = content[:30000] + "\n\n...(truncated)" - - references[ref_file.name] = content - total_chars += len(content) - - except Exception as e: - print(f" āš ļø Could not read {ref_file.name}: {e}") - - return references - - def _build_enhancement_prompt( - self, skill_name: str, references: dict[str, str], current_skill_md: str = None - ) -> str: - """ - Build MiniMax API prompt for enhancement. - - Args: - skill_name: Name of the skill - references: Dictionary of reference content - current_skill_md: Existing SKILL.md content (optional) - - Returns: - Enhancement prompt for MiniMax-M2.7 - """ - prompt = f"""You are creating system instructions for a MiniMax AI assistant about: {skill_name} - -I've scraped documentation and organized it into reference files. Your job is to create EXCELLENT system instructions that will help the assistant use this documentation effectively. - -CURRENT INSTRUCTIONS: -{"```" if current_skill_md else "(none - create from scratch)"} -{current_skill_md or "No existing instructions"} -{"```" if current_skill_md else ""} - -REFERENCE DOCUMENTATION: -""" - - for filename, content in references.items(): - prompt += f"\n\n## {filename}\n```markdown\n{content[:30000]}\n```\n" - - prompt += """ - -YOUR TASK: -Create enhanced system instructions that include: - -1. **Clear role definition** - "You are an expert assistant for [topic]" -2. **Knowledge base description** - What documentation is attached -3. **Excellent Quick Reference** - Extract 5-10 of the BEST, most practical code examples from the reference docs - - Choose SHORT, clear examples that demonstrate common tasks - - Include both simple and intermediate examples - - Annotate examples with clear descriptions - - Use proper language tags (cpp, python, javascript, json, etc.) -4. **Response guidelines** - How the assistant should help users -5. **Search strategy** - How to find information in the knowledge base -6. **DO NOT use YAML frontmatter** - This is plain text instructions - -IMPORTANT: -- Extract REAL examples from the reference docs, don't make them up -- Prioritize SHORT, clear examples (5-20 lines max) -- Make it actionable and practical -- Write clear, direct instructions -- Focus on how the assistant should behave and respond -- NO YAML frontmatter (no --- blocks) - -OUTPUT: -Return ONLY the complete system instructions as plain text. -""" - - return prompt + DEFAULT_MODEL = "MiniMax-M2.7" + ENV_VAR_NAME = "MINIMAX_API_KEY" + PLATFORM_URL = "https://platform.minimaxi.com/" diff --git a/src/skill_seekers/cli/adaptors/openai_compatible.py b/src/skill_seekers/cli/adaptors/openai_compatible.py new file mode 100644 index 0000000..8f5ab3e --- /dev/null +++ b/src/skill_seekers/cli/adaptors/openai_compatible.py @@ -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 diff --git a/src/skill_seekers/cli/adaptors/opencode.py b/src/skill_seekers/cli/adaptors/opencode.py new file mode 100644 index 0000000..6785880 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/opencode.py @@ -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: /-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 diff --git a/src/skill_seekers/cli/adaptors/openrouter.py b/src/skill_seekers/cli/adaptors/openrouter.py new file mode 100644 index 0000000..8a33b6e --- /dev/null +++ b/src/skill_seekers/cli/adaptors/openrouter.py @@ -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/" diff --git a/src/skill_seekers/cli/adaptors/qwen.py b/src/skill_seekers/cli/adaptors/qwen.py new file mode 100644 index 0000000..a7faa26 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/qwen.py @@ -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/" diff --git a/src/skill_seekers/cli/adaptors/together.py b/src/skill_seekers/cli/adaptors/together.py new file mode 100644 index 0000000..3617b48 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/together.py @@ -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/" diff --git a/src/skill_seekers/cli/install_agent.py b/src/skill_seekers/cli/install_agent.py index 1c59204..47f187f 100644 --- a/src/skill_seekers/cli/install_agent.py +++ b/src/skill_seekers/cli/install_agent.py @@ -44,6 +44,13 @@ AGENT_PATHS = { "aide": "~/.aide/skills/", # Global "windsurf": "~/.windsurf/skills/", # Global "neovate": "~/.neovate/skills/", # Global + "roo": ".roo/skills/", # Project-relative (Roo Code, Cline fork) + "cline": ".cline/skills/", # Project-relative (Cline AI) + "aider": "~/.aider/skills/", # Global (terminal AI coding) + "bolt": ".bolt/skills/", # Project-relative (Bolt.new/Bolt.diy) + "kilo": ".kilo/skills/", # Project-relative (Kilo Code, Cline fork) + "continue": "~/.continue/skills/", # Global (Continue.dev) + "kimi-code": "~/.kimi/skills/", # Global (Kimi Code) } @@ -360,7 +367,8 @@ Examples: skill-seekers install-agent output/react/ --agent cursor --dry-run Supported agents: - claude, cursor, vscode, copilot, amp, goose, opencode, letta, aide, windsurf, neovate, all + claude, cursor, vscode, copilot, amp, goose, opencode, letta, aide, windsurf, + neovate, roo, cline, aider, bolt, kilo, continue, kimi-code, all """, ) diff --git a/src/skill_seekers/cli/opencode_skill_splitter.py b/src/skill_seekers/cli/opencode_skill_splitter.py new file mode 100644 index 0000000..4cd86ae --- /dev/null +++ b/src/skill_seekers/cli/opencode_skill_splitter.py @@ -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 [--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: -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()) diff --git a/templates/github-actions/update-skills.yml b/templates/github-actions/update-skills.yml new file mode 100644 index 0000000..c798f9a --- /dev/null +++ b/templates/github-actions/update-skills.yml @@ -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 diff --git a/tests/test_adaptors/test_deepseek_adaptor.py b/tests/test_adaptors/test_deepseek_adaptor.py new file mode 100644 index 0000000..851f544 --- /dev/null +++ b/tests/test_adaptors/test_deepseek_adaptor.py @@ -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() diff --git a/tests/test_adaptors/test_fireworks_adaptor.py b/tests/test_adaptors/test_fireworks_adaptor.py new file mode 100644 index 0000000..9c70e5c --- /dev/null +++ b/tests/test_adaptors/test_fireworks_adaptor.py @@ -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() diff --git a/tests/test_adaptors/test_kimi_adaptor.py b/tests/test_adaptors/test_kimi_adaptor.py new file mode 100644 index 0000000..2986ded --- /dev/null +++ b/tests/test_adaptors/test_kimi_adaptor.py @@ -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() diff --git a/tests/test_adaptors/test_openai_compatible_base.py b/tests/test_adaptors/test_openai_compatible_base.py new file mode 100644 index 0000000..611ccb4 --- /dev/null +++ b/tests/test_adaptors/test_openai_compatible_base.py @@ -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() diff --git a/tests/test_adaptors/test_opencode_adaptor.py b/tests/test_adaptors/test_opencode_adaptor.py new file mode 100644 index 0000000..4dce595 --- /dev/null +++ b/tests/test_adaptors/test_opencode_adaptor.py @@ -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() diff --git a/tests/test_adaptors/test_openrouter_adaptor.py b/tests/test_adaptors/test_openrouter_adaptor.py new file mode 100644 index 0000000..c9ed87b --- /dev/null +++ b/tests/test_adaptors/test_openrouter_adaptor.py @@ -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() diff --git a/tests/test_adaptors/test_qwen_adaptor.py b/tests/test_adaptors/test_qwen_adaptor.py new file mode 100644 index 0000000..c7924bb --- /dev/null +++ b/tests/test_adaptors/test_qwen_adaptor.py @@ -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() diff --git a/tests/test_adaptors/test_together_adaptor.py b/tests/test_adaptors/test_together_adaptor.py new file mode 100644 index 0000000..8185c29 --- /dev/null +++ b/tests/test_adaptors/test_together_adaptor.py @@ -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() diff --git a/tests/test_install_agent.py b/tests/test_install_agent.py index 49f80d4..fe8c7c1 100644 --- a/tests/test_install_agent.py +++ b/tests/test_install_agent.py @@ -66,17 +66,38 @@ class TestAgentPathMapping: get_agent_path("invalid_agent") def test_get_available_agents(self): - """Test that all 11 agents are listed.""" + """Test that all 18 agents are listed.""" agents = get_available_agents() - assert len(agents) == 11 + assert len(agents) == 18 assert "claude" in agents assert "cursor" in agents assert "vscode" in agents assert "amp" in agents assert "goose" in agents assert "neovate" in agents + assert "roo" in agents + assert "cline" in agents + assert "aider" in agents + assert "bolt" in agents + assert "kilo" in agents + assert "continue" in agents + assert "kimi-code" in agents assert sorted(agents) == agents # Should be sorted + def test_new_agents_project_relative(self): + """Test that project-relative new agents resolve correctly.""" + for agent in ["roo", "cline", "bolt", "kilo"]: + path = get_agent_path(agent) + assert path.is_absolute() + assert str(Path.cwd()) in str(path) + + def test_new_agents_global(self): + """Test that global new agents resolve to home directory.""" + for agent in ["aider", "continue", "kimi-code"]: + path = get_agent_path(agent) + assert path.is_absolute() + assert str(path).startswith(str(Path.home())) + def test_agent_path_case_insensitive(self): """Test that agent names are case-insensitive.""" path_lower = get_agent_path("claude") @@ -340,7 +361,7 @@ class TestInstallToAllAgents: shutil.rmtree(self.tmpdir, ignore_errors=True) def test_install_to_all_success(self): - """Test that install_to_all_agents attempts all 11 agents.""" + """Test that install_to_all_agents attempts all 18 agents.""" with tempfile.TemporaryDirectory() as agent_tmpdir: def mock_get_agent_path(agent_name, _project_root=None): @@ -352,7 +373,7 @@ class TestInstallToAllAgents: ): results = install_to_all_agents(self.skill_dir, force=True) - assert len(results) == 11 + assert len(results) == 18 assert "claude" in results assert "cursor" in results @@ -362,7 +383,7 @@ class TestInstallToAllAgents: results = install_to_all_agents(self.skill_dir, dry_run=True) # All should succeed in dry-run mode - assert len(results) == 11 + assert len(results) == 18 for _agent_name, (success, message) in results.items(): assert success is True assert "DRY RUN" in message @@ -399,7 +420,7 @@ class TestInstallToAllAgents: results = install_to_all_agents(self.skill_dir, dry_run=True) assert isinstance(results, dict) - assert len(results) == 11 + assert len(results) == 18 for agent_name, (success, message) in results.items(): assert isinstance(success, bool) diff --git a/tests/test_opencode_skill_splitter.py b/tests/test_opencode_skill_splitter.py new file mode 100644 index 0000000..2256834 --- /dev/null +++ b/tests/test_opencode_skill_splitter.py @@ -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()