#!/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 [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()