Files
gh-francyjglisboa-agent-ski…/scripts/export_utils.py
2025-11-29 18:27:25 +08:00

767 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Cross-Platform Export Utilities for Agent-Skill-Creator
Packages Claude Code skills for Desktop/Web/API use with versioning and validation.
"""
import os
import sys
import zipfile
import json
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple, Optional
# Directories and files to exclude from exports
EXCLUDE_DIRS = {
'.git', '__pycache__', 'node_modules', '.claude-plugin',
'venv', 'env', '.venv', '.pytest_cache', '.mypy_cache',
'dist', 'build', '*.egg-info'
}
EXCLUDE_FILES = {
'.DS_Store', '.gitignore', 'Thumbs.db', '*.pyc', '*.pyo',
'.env', 'credentials.json', '*.log', '.python-version'
}
# API package size limit (8MB per Claude API requirements)
MAX_API_SIZE_MB = 8
MAX_API_SIZE_BYTES = MAX_API_SIZE_MB * 1024 * 1024
# SKILL.md validation limits
MAX_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024
def get_skill_version(skill_path: str, override_version: str = None) -> str:
"""
Determine skill version from git tags, SKILL.md, or use default.
Args:
skill_path: Path to skill directory
override_version: User-specified version (takes precedence)
Returns:
Version string in format "vX.Y.Z"
"""
if override_version:
return override_version if override_version.startswith('v') else f'v{override_version}'
# Try git tags first
try:
os.chdir(skill_path)
result = subprocess.run(
['git', 'describe', '--tags', '--abbrev=0'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
version = result.stdout.strip()
return version if version.startswith('v') else f'v{version}'
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
pass
# Try SKILL.md frontmatter
skill_md_path = os.path.join(skill_path, 'SKILL.md')
if os.path.exists(skill_md_path):
try:
with open(skill_md_path, 'r', encoding='utf-8') as f:
content = f.read()
# Look for version: in frontmatter
if content.startswith('---'):
frontmatter_end = content.find('---', 3)
if frontmatter_end > 0:
frontmatter = content[3:frontmatter_end]
for line in frontmatter.split('\n'):
if line.strip().startswith('version:'):
version = line.split(':', 1)[1].strip()
return version if version.startswith('v') else f'v{version}'
except Exception:
pass
# Default version
return 'v1.0.0'
def validate_skill_structure(skill_path: str) -> Tuple[bool, List[str]]:
"""
Validate that skill has required structure for export.
Args:
skill_path: Path to skill directory
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
# Check if path exists and is directory
if not os.path.exists(skill_path):
issues.append(f"Path does not exist: {skill_path}")
return False, issues
if not os.path.isdir(skill_path):
issues.append(f"Path is not a directory: {skill_path}")
return False, issues
# Check for SKILL.md
skill_md_path = os.path.join(skill_path, 'SKILL.md')
if not os.path.exists(skill_md_path):
issues.append("SKILL.md not found (required)")
return False, issues
# Validate SKILL.md frontmatter
try:
with open(skill_md_path, 'r', encoding='utf-8') as f:
content = f.read()
if not content.startswith('---'):
issues.append("SKILL.md missing frontmatter (must start with ---)")
else:
# Extract frontmatter
frontmatter_end = content.find('---', 3)
if frontmatter_end == -1:
issues.append("SKILL.md frontmatter not closed (missing second ---)")
else:
frontmatter = content[3:frontmatter_end]
# Check for required fields
has_name = False
has_description = False
name_length = 0
desc_length = 0
for line in frontmatter.split('\n'):
line = line.strip()
if line.startswith('name:'):
has_name = True
name = line.split(':', 1)[1].strip()
name_length = len(name)
if name_length > MAX_NAME_LENGTH:
issues.append(f"name too long: {name_length} chars (max {MAX_NAME_LENGTH})")
elif line.startswith('description:'):
has_description = True
desc = line.split(':', 1)[1].strip()
desc_length = len(desc)
if desc_length > MAX_DESCRIPTION_LENGTH:
issues.append(f"description too long: {desc_length} chars (max {MAX_DESCRIPTION_LENGTH})")
if not has_name:
issues.append("SKILL.md missing 'name:' field in frontmatter")
if not has_description:
issues.append("SKILL.md missing 'description:' field in frontmatter")
except Exception as e:
issues.append(f"Error reading SKILL.md: {str(e)}")
return len(issues) == 0, issues
def should_include_file(file_path: str, filename: str) -> bool:
"""
Determine if a file should be included in export.
Args:
file_path: Full path to file
filename: Just the filename
Returns:
True if file should be included
"""
# Check excluded filenames
if filename in EXCLUDE_FILES:
return False
# Check excluded patterns
for pattern in EXCLUDE_FILES:
if '*' in pattern:
extension = pattern.replace('*', '')
if filename.endswith(extension):
return False
# Check for sensitive files
if filename in {'.env', 'credentials.json', 'secrets.json', 'api_keys.json'}:
return False
return True
def get_directory_size(path: str) -> int:
"""
Calculate total size of directory in bytes.
Args:
path: Directory path
Returns:
Total size in bytes
"""
total = 0
for root, dirs, files in os.walk(path):
# Filter excluded directories
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in files:
if should_include_file(os.path.join(root, file), file):
try:
total += os.path.getsize(os.path.join(root, file))
except OSError:
pass
return total
def create_export_package(
skill_path: str,
output_dir: str,
variant: str = 'desktop',
version: str = 'v1.0.0',
skill_name: str = None
) -> Dict:
"""
Create optimized export package for specified variant.
Args:
skill_path: Path to skill directory
output_dir: Where to save the .zip file
variant: 'desktop' or 'api'
version: Version string (e.g., 'v1.0.0')
skill_name: Override skill name (default: directory name)
Returns:
Dict with 'success', 'zip_path', 'size_mb', 'files_included', 'message'
"""
if skill_name is None:
skill_name = os.path.basename(os.path.abspath(skill_path))
# Create output filename
zip_filename = f"{skill_name}-{variant}-{version}.zip"
zip_path = os.path.join(output_dir, zip_filename)
files_included = []
total_size = 0
try:
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as zipf:
for root, dirs, files in os.walk(skill_path):
# Filter excluded directories
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
# For API variant, exclude .claude-plugin
if variant == 'api' and '.claude-plugin' in dirs:
dirs.remove('.claude-plugin')
for file in files:
if not should_include_file(os.path.join(root, file), file):
continue
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, skill_path)
# For API variant, apply additional filtering
if variant == 'api':
# Skip large documentation files
if file.endswith('.md') and file not in {'SKILL.md', 'README.md'}:
continue
# Skip example files
if 'examples' in arcname.lower():
continue
try:
zipf.write(file_path, arcname)
files_included.append(arcname)
total_size += os.path.getsize(file_path)
except Exception as e:
print(f"Warning: Could not add {arcname}: {e}", file=sys.stderr)
# Check final size
final_size = os.path.getsize(zip_path)
size_mb = final_size / (1024 * 1024)
# Warn if API package is too large
if variant == 'api' and final_size > MAX_API_SIZE_BYTES:
return {
'success': False,
'zip_path': zip_path,
'size_mb': size_mb,
'files_included': files_included,
'message': f"API package too large: {size_mb:.2f} MB (max {MAX_API_SIZE_MB} MB)"
}
return {
'success': True,
'zip_path': zip_path,
'size_mb': size_mb,
'files_included': files_included,
'message': f"Package created successfully: {len(files_included)} files, {size_mb:.2f} MB"
}
except Exception as e:
return {
'success': False,
'zip_path': None,
'size_mb': 0,
'files_included': [],
'message': f"Error creating package: {str(e)}"
}
def generate_installation_guide(
skill_name: str,
version: str,
desktop_package: Dict = None,
api_package: Dict = None,
output_dir: str = None
) -> str:
"""
Generate platform-specific installation guide.
Args:
skill_name: Name of the skill
version: Version string
desktop_package: Desktop package info dict (optional)
api_package: API package info dict (optional)
output_dir: Where to save the guide
Returns:
Path to generated installation guide
"""
guide_filename = f"{skill_name}-{version}_INSTALL.md"
guide_path = os.path.join(output_dir, guide_filename)
# Build guide content
content = f"""# {skill_name} - Installation Guide
**Version:** {version}
**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
---
## 📦 Export Packages
"""
if desktop_package and desktop_package['success']:
content += f"""### Desktop/Web Package
**File:** `{os.path.basename(desktop_package['zip_path'])}`
**Size:** {desktop_package['size_mb']:.2f} MB
**Files:** {len(desktop_package['files_included'])} files included
✅ Optimized for Claude Desktop and claude.ai manual upload
"""
if api_package and api_package['success']:
content += f"""### API Package
**File:** `{os.path.basename(api_package['zip_path'])}`
**Size:** {api_package['size_mb']:.2f} MB
**Files:** {len(api_package['files_included'])} files included
✅ Optimized for programmatic Claude API integration
"""
content += """---
## 🚀 Installation Instructions
### For Claude Desktop
1. **Locate the Desktop package**
- File: `{skill}-desktop-{version}.zip`
2. **Open Claude Desktop**
- Launch the Claude Desktop application
3. **Navigate to Skills settings**
- Go to: **Settings → Capabilities → Skills**
4. **Upload the skill**
- Click: **Upload skill**
- Select the desktop package .zip file
- Wait for upload confirmation
5. **Verify installation**
- The skill should now appear in your Skills list
- Try using it with a relevant query
✅ **Your skill is now available in Claude Desktop!**
---
### For claude.ai (Web Interface)
1. **Locate the Desktop package**
- File: `{skill}-desktop-{version}.zip`
- (Same package as Desktop - optimized for both)
2. **Visit claude.ai**
- Open https://claude.ai in your browser
- Log in to your account
3. **Open Settings**
- Click your profile icon
- Select **Settings**
4. **Navigate to Skills**
- Click on the **Skills** section
5. **Upload the skill**
- Click: **Upload skill**
- Select the desktop package .zip file
- Confirm the upload
6. **Start using**
- Create a new conversation
- The skill will activate automatically when relevant
✅ **Your skill is now available at claude.ai!**
---
### For Claude API (Programmatic Integration)
1. **Locate the API package**
- File: `{skill}-api-{version}.zip`
- Optimized for API use (smaller, execution-focused)
2. **Install required packages**
```bash
pip install anthropic
```
3. **Upload skill programmatically**
```python
import anthropic
client = anthropic.Anthropic(api_key="your-api-key")
# Upload the skill
with open('{skill}-api-{version}.zip', 'rb') as f:
skill = client.skills.create(
file=f,
name="{skill}"
)
print(f"Skill uploaded! ID: {{skill.id}}")
```
4. **Use in API requests**
```python
response = client.messages.create(
model="claude-sonnet-4",
messages=[
{{"role": "user", "content": "Your query here"}}
],
container={{
"type": "custom_skill",
"skill_id": skill.id
}},
betas=[
"code-execution-2025-08-25",
"skills-2025-10-02"
]
)
print(response.content)
```
5. **Important API requirements**
- Must include beta headers: `code-execution-2025-08-25` and `skills-2025-10-02`
- Maximum 8 skills per request
- Skills run in isolated containers (no network access, no pip install)
✅ **Your skill is now integrated with the Claude API!**
---
## 📋 Platform Comparison
| Feature | Claude Code | Desktop/Web | Claude API |
|---------|-------------|-------------|------------|
| **Installation** | Plugin command | Manual upload | Programmatic |
| **Updates** | Git pull | Re-upload .zip | New upload |
| **Version Control** | ✅ Native | ⚠️ Manual | ✅ Versioned |
| **Team Sharing** | ✅ Via plugins | ❌ Individual | ✅ Via API |
| **marketplace.json** | ✅ Used | ❌ Ignored | ❌ Not used |
---
## ⚙️ Technical Details
### What's Included
"""
if desktop_package and desktop_package['success']:
content += f"""**Desktop Package:**
- SKILL.md (core functionality)
- Complete scripts/ directory
- Full references/ documentation
- All assets/ and templates
- README.md and requirements.txt
"""
if api_package and api_package['success']:
content += f"""**API Package:**
- SKILL.md (required)
- Essential scripts only
- Minimal documentation (execution-focused)
- Size-optimized (< 8MB)
"""
content += """### What's Excluded (Security)
For both packages:
- `.git/` (version control history)
- `__pycache__/` (compiled Python)
- `.env` files (environment variables)
- `credentials.json` (API keys/secrets)
- `.DS_Store` (system metadata)
For API package additionally:
- `.claude-plugin/` (Claude Code specific)
- Large documentation files
- Example files (size optimization)
---
## 🔧 Troubleshooting
### Upload fails with "File too large"
**Desktop/Web:**
- Maximum size varies by platform
- Try the API package instead (smaller)
- Contact support if needed
**API:**
- Maximum: 8MB
- The API package is already optimized
- May need to reduce documentation or scripts
### Skill doesn't activate
**Check:**
1. SKILL.md has valid frontmatter
2. `name:` field is present and ≤ 64 characters
3. `description:` field is present and ≤ 1024 characters
4. Description clearly explains when to use the skill
### API errors
**Common issues:**
- Missing beta headers (required!)
- Skill ID incorrect (check `skill.id` after upload)
- Network/pip install attempted (not allowed in API environment)
---
## 📚 Additional Resources
- **Export Guide:** See `references/export-guide.md` in the main repository
- **Cross-Platform Guide:** See `references/cross-platform-guide.md`
- **Main Documentation:** See the main README.md
---
## ✅ Verification Checklist
After installation, verify:
- [ ] Skill appears in Skills list
- [ ] Skill activates with relevant queries
- [ ] Scripts execute correctly
- [ ] Documentation is accessible
- [ ] No error messages on activation
---
**Need help?** Refer to the platform-specific documentation or the main repository guides.
**Generated by:** agent-skill-creator v3.2 cross-platform export system
"""
# Write guide to file
with open(guide_path, 'w', encoding='utf-8') as f:
f.write(content)
return guide_path
def export_skill(
skill_path: str,
variants: List[str] = ['desktop', 'api'],
version_override: str = None,
output_dir: str = None
) -> Dict:
"""
Main export function - validates, packages, and generates guides.
Args:
skill_path: Path to skill directory
variants: List of variants to create ('desktop', 'api', or both)
version_override: User-specified version (optional)
output_dir: Where to save exports (default: exports/ in parent dir)
Returns:
Dict with export results
"""
# Normalize path
skill_path = os.path.abspath(skill_path)
skill_name = os.path.basename(skill_path)
# Determine output directory
if output_dir is None:
# Use exports/ in parent directory
parent_dir = os.path.dirname(skill_path)
output_dir = os.path.join(parent_dir, 'exports')
# Create output directory if needed
os.makedirs(output_dir, exist_ok=True)
# Validate skill structure
print("🔍 Validating skill structure...")
valid, issues = validate_skill_structure(skill_path)
if not valid:
return {
'success': False,
'message': 'Skill validation failed',
'issues': issues
}
print("✅ Skill structure valid")
# Determine version
version = get_skill_version(skill_path, version_override)
print(f"📌 Version: {version}")
# Create packages
results = {
'success': True,
'version': version,
'packages': {}
}
if 'desktop' in variants:
print(f"\n🔨 Creating Desktop/Web package...")
desktop_result = create_export_package(
skill_path, output_dir, 'desktop', version, skill_name
)
results['packages']['desktop'] = desktop_result
if desktop_result['success']:
print(f"✅ Desktop package: {os.path.basename(desktop_result['zip_path'])} ({desktop_result['size_mb']:.2f} MB)")
else:
print(f"❌ Desktop package failed: {desktop_result['message']}")
results['success'] = False
if 'api' in variants:
print(f"\n🔨 Creating API package...")
api_result = create_export_package(
skill_path, output_dir, 'api', version, skill_name
)
results['packages']['api'] = api_result
if api_result['success']:
print(f"✅ API package: {os.path.basename(api_result['zip_path'])} ({api_result['size_mb']:.2f} MB)")
else:
print(f"❌ API package failed: {api_result['message']}")
results['success'] = False
# Generate installation guide
if results['success']:
print(f"\n📄 Generating installation guide...")
guide_path = generate_installation_guide(
skill_name,
version,
desktop_package=results['packages'].get('desktop'),
api_package=results['packages'].get('api'),
output_dir=output_dir
)
results['guide_path'] = guide_path
print(f"✅ Installation guide: {os.path.basename(guide_path)}")
return results
def main():
"""CLI interface for export_utils.py"""
if len(sys.argv) < 2:
print("""
Usage: python export_utils.py <skill-path> [options]
Arguments:
skill-path Path to skill directory
Options:
--variant VARIANT Export variant: desktop, api, or both (default: both)
--version VERSION Override version (default: auto-detect)
--output-dir DIR Output directory (default: exports/)
Examples:
python export_utils.py ./my-skill-cskill
python export_utils.py ./my-skill-cskill --variant desktop
python export_utils.py ./my-skill-cskill --version 2.0.1
python export_utils.py ./my-skill-cskill --variant api --output-dir ./dist
""")
sys.exit(1)
skill_path = sys.argv[1]
# Parse options
variants = ['desktop', 'api'] # default: both
version_override = None
output_dir = None
i = 2
while i < len(sys.argv):
if sys.argv[i] == '--variant':
variant_arg = sys.argv[i + 1]
if variant_arg == 'both':
variants = ['desktop', 'api']
else:
variants = [variant_arg]
i += 2
elif sys.argv[i] == '--version':
version_override = sys.argv[i + 1]
i += 2
elif sys.argv[i] == '--output-dir':
output_dir = sys.argv[i + 1]
i += 2
else:
print(f"Unknown option: {sys.argv[i]}")
sys.exit(1)
# Run export
print(f"\n🚀 Exporting skill: {os.path.basename(skill_path)}\n")
results = export_skill(skill_path, variants, version_override, output_dir)
# Print summary
print(f"\n{'='*60}")
if results['success']:
print("✅ Export completed successfully!")
print(f"\n📦 Packages created:")
for variant, package in results['packages'].items():
if package['success']:
print(f" - {variant.capitalize()}: {os.path.basename(package['zip_path'])}")
if 'guide_path' in results:
print(f"\n📄 Installation guide: {os.path.basename(results['guide_path'])}")
print(f"\n🎯 All files saved to: {output_dir or 'exports/'}")
else:
print("❌ Export failed!")
if 'issues' in results:
print("\nIssues found:")
for issue in results['issues']:
print(f" - {issue}")
print(f"{'='*60}\n")
sys.exit(0 if results['success'] else 1)
if __name__ == '__main__':
main()