Files
Francy Lisboa Charuto 80f60c5380 feat: Universal cross-tool skill installation (14 platforms, format adapters, .agents/skills/)
- Fix install-template.sh POSIX compliance (set -eu, no bashisms, dash-tested)
- Fix incorrect paths: Windsurf → .windsurf/rules/, Codex → ~/.agents/skills/
- Add 7 new platforms: universal, kiro, trae, goose, opencode, roo-code, antigravity
- Add format adapters: auto-generate .mdc (Cursor), .md rules (Windsurf), plain .md (Cline/Roo/Trae)
- Add ~/.agents/skills/ universal secondary symlink after every install
- Add --all flag to install to every detected tool at once
- Add 3-tier platform support in cross-platform-guide.md (Native/Adapter/Manual)
- Update all 6 files with consistent paths across 14 platforms
- Fix set -e safety: replace ||/&& chaining with if/then/fi in detect_all_platforms
- Fix awk idempotency: exact marker match prevents substring collision

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:51:11 -03:00

846 lines
25 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 subprocess
from datetime import datetime
from typing import Dict, List, Tuple
# 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 += f"""---
## Cross-Platform Installation
This skill works on all platforms supporting the Agent Skills Open Standard.
### Universal Path (works with 6+ tools)
```bash
cp -r {skill_name}/ ~/.agents/skills/{skill_name}/
```
Works with Codex CLI, Gemini CLI, Kiro, Antigravity, and other tools that read `~/.agents/skills/`.
### Using install.sh (Recommended)
If the skill includes an `install.sh` script:
```bash
# Auto-detect platform and install
./install.sh
# Install to specific platform
./install.sh --platform claude-code
./install.sh --platform copilot
./install.sh --platform cursor
# Install to ALL detected platforms
./install.sh --all
# Project-level install
./install.sh --project
# Preview without installing
./install.sh --dry-run
```
### Alternative: npx
```bash
npx skills add ./{skill_name}
```
### Manual Installation by Platform
#### Claude Code
```bash
# User-level
cp -r {skill_name}/ ~/.claude/skills/{skill_name}/
# Project-level
cp -r {skill_name}/ .claude/skills/{skill_name}/
```
#### GitHub Copilot
```bash
cp -r {skill_name}/ .github/skills/{skill_name}/
```
#### Cursor
```bash
cp -r {skill_name}/ .cursor/rules/{skill_name}/
```
#### Windsurf
```bash
# Project-level
cp -r {skill_name}/ .windsurf/rules/{skill_name}/
```
#### Cline
```bash
cp -r {skill_name}/ .clinerules/{skill_name}/
```
#### OpenAI Codex CLI
```bash
cp -r {skill_name}/ ~/.agents/skills/{skill_name}/
```
#### Gemini CLI
```bash
cp -r {skill_name}/ ~/.gemini/skills/{skill_name}/
```
#### Kiro
```bash
cp -r {skill_name}/ .kiro/skills/{skill_name}/
```
#### Trae
```bash
cp -r {skill_name}/ .trae/rules/{skill_name}/
```
#### Goose
```bash
cp -r {skill_name}/ ~/.config/goose/skills/{skill_name}/
```
#### OpenCode
```bash
cp -r {skill_name}/ ~/.config/opencode/skills/{skill_name}/
```
#### Roo Code
```bash
cp -r {skill_name}/ .roo/rules/{skill_name}/
```
#### Antigravity
```bash
cp -r {skill_name}/ .agents/skills/{skill_name}/
```
### Claude Desktop / claude.ai (Web)
1. Locate the Desktop package: `{skill_name}-desktop-{{version}}.zip`
2. Open Claude Desktop or claude.ai
3. Go to **Settings > Skills > Upload skill**
4. Select the .zip file and confirm
### Claude API
1. Locate the API package: `{skill_name}-api-{{version}}.zip`
2. Upload programmatically:
```python
import anthropic
client = anthropic.Anthropic()
with open('{skill_name}-api-{{version}}.zip', 'rb') as f:
skill = client.skills.create(file=f, name="{skill_name}")
```
---
## Platform Comparison
| Platform | Install Method | Updates | marketplace.json |
|----------|---------------|---------|-----------------|
| **Universal** | install.sh / copy | git pull | Not used |
| **Claude Code** | install.sh / copy | git pull | Optional |
| **GitHub Copilot** | install.sh / copy | git pull | Not used |
| **Cursor** | install.sh / copy (+ .mdc) | git pull | Not used |
| **Windsurf** | install.sh / copy | git pull | Not used |
| **Cline** | install.sh / copy | git pull | Not used |
| **Codex CLI** | install.sh / copy | git pull | Not used |
| **Gemini CLI** | install.sh / copy | git pull | Not used |
| **Kiro** | install.sh / copy | git pull | Not used |
| **Trae** | install.sh / copy | git pull | Not used |
| **Goose** | install.sh / copy | git pull | Not used |
| **OpenCode** | install.sh / copy | git pull | Not used |
| **Roo Code** | install.sh / copy | git pull | Not used |
| **Desktop/Web** | .zip upload | Re-upload | Not used |
| **Claude API** | API upload | New upload | Not used |
---
## Technical Details
### What's Included
"""
if desktop_package and desktop_package['success']:
content += """**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 += """**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 v4.0 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'],
platform: str = None,
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)
platform: Target platform for platform-specific output (optional)
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")
# Run spec validation if validate.py is available
validate_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'validate.py')
if os.path.exists(validate_script):
print("🔍 Running spec validation...")
try:
result = subprocess.run(
[sys.executable, validate_script, skill_path, '--json'],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
import json as _json
try:
val_result = _json.loads(result.stdout)
if val_result.get('errors'):
print(f"⚠️ Spec validation warnings: {len(val_result['errors'])} errors")
for err in val_result['errors']:
print(f" - {err}")
except (ValueError, KeyError):
pass
else:
print("✅ Spec validation passed")
except (subprocess.TimeoutExpired, Exception):
print("⚠️ Spec validation skipped (script error)")
# Run security scan if security_scan.py is available
security_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'security_scan.py')
if os.path.exists(security_script):
print("🔍 Running security scan...")
try:
result = subprocess.run(
[sys.executable, security_script, skill_path, '--json'],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
import json as _json
try:
sec_result = _json.loads(result.stdout)
if sec_result.get('issues'):
print(f"⚠️ Security issues found: {len(sec_result['issues'])}")
for issue in sec_result['issues'][:5]:
print(f" - [{issue.get('severity', 'unknown')}] {issue.get('description', '')}")
except (ValueError, KeyError):
pass
else:
print("✅ Security scan passed")
except (subprocess.TimeoutExpired, Exception):
print("⚠️ Security scan skipped (script error)")
# 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("\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("\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("\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
python export_utils.py ./my-skill --variant desktop
python export_utils.py ./my-skill --version 2.0.1
python export_utils.py ./my-skill --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("\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()