feat(multi-llm): Phase 1 - Foundation adaptor architecture

Implement base adaptor pattern for multi-LLM support (Issue #179)

**Architecture:**
- Created adaptors/ package with base SkillAdaptor class
- Implemented factory pattern with get_adaptor() registry
- Refactored Claude-specific code into ClaudeAdaptor

**Changes:**
- New: src/skill_seekers/cli/adaptors/base.py (SkillAdaptor + SkillMetadata)
- New: src/skill_seekers/cli/adaptors/__init__.py (registry + factory)
- New: src/skill_seekers/cli/adaptors/claude.py (refactored upload + enhance logic)
- Modified: package_skill.py (added --target flag, uses adaptor.package())
- Modified: upload_skill.py (added --target flag, uses adaptor.upload())
- Modified: enhance_skill.py (added --target flag, uses adaptor.enhance())

**Tests:**
- New: tests/test_adaptors/test_base.py (10 tests passing)
- All existing tests still pass (backward compatible)

**Backward Compatibility:**
- Default --target=claude maintains existing behavior
- All CLI tools work exactly as before without --target flag
- No breaking changes

**Next:** Phase 2 - Implement Gemini, OpenAI, Markdown adaptors
This commit is contained in:
yusyus
2025-12-28 20:17:31 +03:00
parent 74bae4b49f
commit d0bc042a43
8 changed files with 1211 additions and 158 deletions

View File

@@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""
Claude AI Adaptor
Implements platform-specific handling for Claude AI (Anthropic) skills.
Refactored from upload_skill.py and enhance_skill.py.
"""
import os
import zipfile
from pathlib import Path
from typing import Dict, Any
from .base import SkillAdaptor, SkillMetadata
class ClaudeAdaptor(SkillAdaptor):
"""
Claude AI platform adaptor.
Handles:
- YAML frontmatter format for SKILL.md
- ZIP packaging with standard Claude skill structure
- Upload to Anthropic Skills API
- AI enhancement using Claude API
"""
PLATFORM = "claude"
PLATFORM_NAME = "Claude AI (Anthropic)"
DEFAULT_API_ENDPOINT = "https://api.anthropic.com/v1/skills"
def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str:
"""
Format SKILL.md with Claude's YAML frontmatter.
Args:
skill_dir: Path to skill directory
metadata: Skill metadata
Returns:
Formatted SKILL.md content with YAML frontmatter
"""
# Read existing content (if any)
existing_content = self._read_existing_content(skill_dir)
# If existing content already has proper structure, use it
if existing_content and len(existing_content) > 100:
content_body = existing_content
else:
# Generate default content
content_body = f"""# {metadata.name.title()} Documentation Skill
{metadata.description}
## When to use this skill
Use this skill when the user asks about {metadata.name} documentation, including API references, tutorials, examples, and best practices.
## What's included
This skill contains comprehensive documentation organized into categorized reference files.
{self._generate_toc(skill_dir)}
## Quick Reference
{self._extract_quick_reference(skill_dir)}
## Navigation
See `references/index.md` for complete documentation structure.
"""
# Format with YAML frontmatter
return f"""---
name: {metadata.name}
description: {metadata.description}
version: {metadata.version}
---
{content_body}
"""
def package(self, skill_dir: Path, output_path: Path) -> Path:
"""
Package skill into ZIP file for Claude.
Creates standard Claude skill structure:
- SKILL.md
- references/*.md
- scripts/ (optional)
- assets/ (optional)
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)
# Determine output filename
if output_path.is_dir() or str(output_path).endswith('/'):
output_path = Path(output_path) / f"{skill_dir.name}.zip"
elif not str(output_path).endswith('.zip'):
output_path = Path(str(output_path) + '.zip')
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Create ZIP file
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
# Add SKILL.md (required)
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
zf.write(skill_md, "SKILL.md")
# Add references directory (if exists)
refs_dir = skill_dir / "references"
if refs_dir.exists():
for ref_file in refs_dir.rglob("*"):
if ref_file.is_file() and not ref_file.name.startswith('.'):
arcname = ref_file.relative_to(skill_dir)
zf.write(ref_file, str(arcname))
# Add scripts directory (if exists)
scripts_dir = skill_dir / "scripts"
if scripts_dir.exists():
for script_file in scripts_dir.rglob("*"):
if script_file.is_file() and not script_file.name.startswith('.'):
arcname = script_file.relative_to(skill_dir)
zf.write(script_file, str(arcname))
# Add assets directory (if exists)
assets_dir = skill_dir / "assets"
if assets_dir.exists():
for asset_file in assets_dir.rglob("*"):
if asset_file.is_file() and not asset_file.name.startswith('.'):
arcname = asset_file.relative_to(skill_dir)
zf.write(asset_file, str(arcname))
return output_path
def upload(self, package_path: Path, api_key: str, **kwargs) -> Dict[str, Any]:
"""
Upload skill ZIP to Anthropic Skills API.
Args:
package_path: Path to skill ZIP file
api_key: Anthropic API key
**kwargs: Additional arguments (timeout, etc.)
Returns:
Dictionary with upload result
"""
# Check for requests library
try:
import requests
except ImportError:
return {
'success': False,
'skill_id': None,
'url': None,
'message': 'requests library not installed. Run: pip install requests'
}
# Validate ZIP file
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 not package_path.suffix == '.zip':
return {
'success': False,
'skill_id': None,
'url': None,
'message': f'Not a ZIP file: {package_path}'
}
# Prepare API request
api_url = self.DEFAULT_API_ENDPOINT
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"anthropic-beta": "skills-2025-10-02"
}
timeout = kwargs.get('timeout', 60)
try:
# Read ZIP file
with open(package_path, 'rb') as f:
zip_data = f.read()
# Upload skill
files = {
'files[]': (package_path.name, zip_data, 'application/zip')
}
response = requests.post(
api_url,
headers=headers,
files=files,
timeout=timeout
)
# Check response
if response.status_code == 200:
# Extract skill ID if available
try:
response_data = response.json()
skill_id = response_data.get('id')
except:
skill_id = None
return {
'success': True,
'skill_id': skill_id,
'url': 'https://claude.ai/skills',
'message': 'Skill uploaded successfully to Claude AI'
}
elif response.status_code == 401:
return {
'success': False,
'skill_id': None,
'url': None,
'message': 'Authentication failed. Check your ANTHROPIC_API_KEY'
}
elif response.status_code == 400:
try:
error_msg = response.json().get('error', {}).get('message', 'Unknown error')
except:
error_msg = 'Invalid skill format'
return {
'success': False,
'skill_id': None,
'url': None,
'message': f'Invalid skill format: {error_msg}'
}
else:
try:
error_msg = response.json().get('error', {}).get('message', 'Unknown error')
except:
error_msg = f'HTTP {response.status_code}'
return {
'success': False,
'skill_id': None,
'url': None,
'message': f'Upload failed: {error_msg}'
}
except requests.exceptions.Timeout:
return {
'success': False,
'skill_id': None,
'url': None,
'message': 'Upload timed out. Try again or use manual upload'
}
except requests.exceptions.ConnectionError:
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'Unexpected error: {str(e)}'
}
def validate_api_key(self, api_key: str) -> bool:
"""
Validate Anthropic API key format.
Args:
api_key: API key to validate
Returns:
True if key starts with 'sk-ant-'
"""
return api_key.strip().startswith('sk-ant-')
def get_env_var_name(self) -> str:
"""
Get environment variable name for Anthropic API key.
Returns:
'ANTHROPIC_API_KEY'
"""
return "ANTHROPIC_API_KEY"
def supports_enhancement(self) -> bool:
"""
Claude supports AI enhancement via Anthropic API.
Returns:
True
"""
return True
def enhance(self, skill_dir: Path, api_key: str) -> bool:
"""
Enhance SKILL.md using Claude API.
Reads reference files, sends them to Claude, and generates
an improved SKILL.md with real examples and better organization.
Args:
skill_dir: Path to skill directory
api_key: Anthropic API key
Returns:
True if enhancement succeeded
"""
# Check for anthropic library
try:
import anthropic
except ImportError:
print("❌ Error: anthropic package not installed")
print("Install with: pip install anthropic")
return False
skill_dir = Path(skill_dir)
references_dir = skill_dir / "references"
skill_md_path = skill_dir / "SKILL.md"
# Read reference files
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")
# Read current SKILL.md
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(f" No existing SKILL.md, will create new one")
# Build enhancement prompt
prompt = self._build_enhancement_prompt(
skill_dir.name,
references,
current_skill_md
)
print("\n🤖 Asking Claude to enhance SKILL.md...")
print(f" Input: {len(prompt):,} characters")
try:
client = anthropic.Anthropic(api_key=api_key)
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
temperature=0.3,
messages=[{
"role": "user",
"content": prompt
}]
)
enhanced_content = message.content[0].text
print(f" ✓ Generated enhanced SKILL.md ({len(enhanced_content)} chars)\n")
# Backup original
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}")
# Save enhanced version
skill_md_path.write_text(enhanced_content, encoding='utf-8')
print(f" ✅ Saved enhanced SKILL.md")
return True
except Exception as e:
print(f"❌ Error calling Claude 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
# Read all .md files
for ref_file in sorted(references_dir.glob("*.md")):
if total_chars >= max_chars:
break
try:
content = ref_file.read_text(encoding='utf-8')
# Limit individual file size
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 Claude 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 Claude
"""
prompt = f"""You are enhancing a Claude skill's SKILL.md file. This skill is about: {skill_name}
I've scraped documentation and organized it into reference files. Your job is to create an EXCELLENT SKILL.md that will help Claude use this documentation effectively.
CURRENT SKILL.MD:
{'```markdown' if current_skill_md else '(none - create from scratch)'}
{current_skill_md or 'No existing SKILL.md'}
{'```' 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 an enhanced SKILL.md that includes:
1. **Clear "When to Use This Skill" section** - Be specific about trigger conditions
2. **Excellent Quick Reference section** - 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.)
3. **Detailed Reference Files description** - Explain what's in each reference file
4. **Practical "Working with This Skill" section** - Give users clear guidance on how to navigate the skill
5. **Key Concepts section** (if applicable) - Explain core concepts
6. **Keep the frontmatter** (---\nname: ...\n---) intact
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
- Don't be too verbose - be concise but useful
- Maintain the markdown structure for Claude skills
- Keep code examples properly formatted with language tags
OUTPUT:
Return ONLY the complete SKILL.md content, starting with the frontmatter (---).
"""
return prompt