Add smart auto-upload feature with API key detection
Features: - New upload_skill.py for automatic API-based upload - Smart detection: upload if API key available, helpful message if not - Enhanced package_skill.py with --upload flag - New MCP tool: upload_skill (9 total MCP tools now) - Enhanced MCP tool: package_skill with smart auto-upload - Cross-platform folder opening in utils.py - Graceful error handling throughout Fixes: - Fix missing import os in mcp/server.py - Fix package_skill.py exit code (now 0 when API key missing) - Improve UX with helpful messages instead of errors Tests: 14/14 passed (100%) - CLI tests: 8/8 passed - MCP tests: 6/6 passed Files: +4 new, 5 modified, ~600 lines added
This commit is contained in:
@@ -6,31 +6,52 @@ Packages a skill directory into a .zip file for Claude.
|
||||
Usage:
|
||||
python3 package_skill.py output/steam-inventory/
|
||||
python3 package_skill.py output/react/
|
||||
python3 package_skill.py output/react/ --no-open # Don't open folder
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Import utilities
|
||||
try:
|
||||
from utils import (
|
||||
open_folder,
|
||||
print_upload_instructions,
|
||||
format_file_size,
|
||||
validate_skill_directory
|
||||
)
|
||||
except ImportError:
|
||||
# If running from different directory, add cli to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from utils import (
|
||||
open_folder,
|
||||
print_upload_instructions,
|
||||
format_file_size,
|
||||
validate_skill_directory
|
||||
)
|
||||
|
||||
def package_skill(skill_dir):
|
||||
"""Package a skill directory into a .zip file"""
|
||||
|
||||
def package_skill(skill_dir, open_folder_after=True):
|
||||
"""
|
||||
Package a skill directory into a .zip file
|
||||
|
||||
Args:
|
||||
skill_dir: Path to skill directory
|
||||
open_folder_after: Whether to open the output folder after packaging
|
||||
|
||||
Returns:
|
||||
tuple: (success, zip_path) where success is bool and zip_path is Path or None
|
||||
"""
|
||||
skill_path = Path(skill_dir)
|
||||
|
||||
if not skill_path.exists():
|
||||
print(f"❌ Error: Directory not found: {skill_dir}")
|
||||
return False
|
||||
|
||||
if not skill_path.is_dir():
|
||||
print(f"❌ Error: Not a directory: {skill_dir}")
|
||||
return False
|
||||
|
||||
# Verify SKILL.md exists
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
print(f"❌ Error: SKILL.md not found in {skill_dir}")
|
||||
return False
|
||||
# Validate skill directory
|
||||
is_valid, error_msg = validate_skill_directory(skill_path)
|
||||
if not is_valid:
|
||||
print(f"❌ Error: {error_msg}")
|
||||
return False, None
|
||||
|
||||
# Create zip filename
|
||||
skill_name = skill_path.name
|
||||
@@ -55,23 +76,101 @@ def package_skill(skill_dir):
|
||||
# Get zip size
|
||||
zip_size = zip_path.stat().st_size
|
||||
print(f"\n✅ Package created: {zip_path}")
|
||||
print(f" Size: {zip_size:,} bytes ({zip_size / 1024:.1f} KB)")
|
||||
print(f" Size: {zip_size:,} bytes ({format_file_size(zip_size)})")
|
||||
|
||||
return True
|
||||
# Open folder in file browser
|
||||
if open_folder_after:
|
||||
print(f"\n📂 Opening folder: {zip_path.parent}")
|
||||
open_folder(zip_path.parent)
|
||||
|
||||
# Print upload instructions
|
||||
print_upload_instructions(zip_path)
|
||||
|
||||
return True, zip_path
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 package_skill.py <skill_directory>")
|
||||
print()
|
||||
print("Examples:")
|
||||
print(" python3 package_skill.py output/steam-inventory/")
|
||||
print(" python3 package_skill.py output/react/")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Package a skill directory into a .zip file for Claude",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Package skill and open folder
|
||||
python3 package_skill.py output/react/
|
||||
|
||||
# Package skill without opening folder
|
||||
python3 package_skill.py output/react/ --no-open
|
||||
|
||||
# Get help
|
||||
python3 package_skill.py --help
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'skill_dir',
|
||||
help='Path to skill directory (e.g., output/react/)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--no-open',
|
||||
action='store_true',
|
||||
help='Do not open the output folder after packaging'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--upload',
|
||||
action='store_true',
|
||||
help='Automatically upload to Claude after packaging (requires ANTHROPIC_API_KEY)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
success, zip_path = package_skill(args.skill_dir, open_folder_after=not args.no_open)
|
||||
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
skill_dir = sys.argv[1]
|
||||
success = package_skill(skill_dir)
|
||||
sys.exit(0 if success else 1)
|
||||
# Auto-upload if requested
|
||||
if args.upload:
|
||||
# Check if API key is set BEFORE attempting upload
|
||||
api_key = os.environ.get('ANTHROPIC_API_KEY', '').strip()
|
||||
|
||||
if not api_key:
|
||||
# No API key - show helpful message but DON'T fail
|
||||
print("\n" + "="*60)
|
||||
print("💡 Automatic Upload")
|
||||
print("="*60)
|
||||
print()
|
||||
print("To enable automatic upload:")
|
||||
print(" 1. Get API key from https://console.anthropic.com/")
|
||||
print(" 2. Set: export ANTHROPIC_API_KEY=sk-ant-...")
|
||||
print(" 3. Run package_skill.py with --upload flag")
|
||||
print()
|
||||
print("For now, use manual upload (instructions above) ☝️")
|
||||
print("="*60)
|
||||
# Exit successfully - packaging worked!
|
||||
sys.exit(0)
|
||||
|
||||
# API key exists - try upload
|
||||
try:
|
||||
from upload_skill import upload_skill_api
|
||||
print("\n" + "="*60)
|
||||
upload_success, message = upload_skill_api(zip_path)
|
||||
if not upload_success:
|
||||
print(f"❌ Upload failed: {message}")
|
||||
print()
|
||||
print("💡 Try manual upload instead (instructions above) ☝️")
|
||||
print("="*60)
|
||||
# Exit successfully - packaging worked even if upload failed
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("="*60)
|
||||
sys.exit(0)
|
||||
except ImportError:
|
||||
print("\n❌ Error: upload_skill.py not found")
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
174
cli/upload_skill.py
Executable file
174
cli/upload_skill.py
Executable file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automatic Skill Uploader
|
||||
Uploads a skill .zip file to Claude using the Anthropic API
|
||||
|
||||
Usage:
|
||||
# Set API key (one-time)
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# Upload skill
|
||||
python3 upload_skill.py output/react.zip
|
||||
python3 upload_skill.py output/godot.zip
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Import utilities
|
||||
try:
|
||||
from utils import (
|
||||
get_api_key,
|
||||
get_upload_url,
|
||||
print_upload_instructions,
|
||||
validate_zip_file
|
||||
)
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from utils import (
|
||||
get_api_key,
|
||||
get_upload_url,
|
||||
print_upload_instructions,
|
||||
validate_zip_file
|
||||
)
|
||||
|
||||
|
||||
def upload_skill_api(zip_path):
|
||||
"""
|
||||
Upload skill to Claude via Anthropic API
|
||||
|
||||
Args:
|
||||
zip_path: Path to skill .zip file
|
||||
|
||||
Returns:
|
||||
tuple: (success, message)
|
||||
"""
|
||||
# Check for requests library
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
return False, "requests library not installed. Run: pip install requests"
|
||||
|
||||
# Validate zip file
|
||||
is_valid, error_msg = validate_zip_file(zip_path)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
|
||||
# Get API key
|
||||
api_key = get_api_key()
|
||||
if not api_key:
|
||||
return False, "ANTHROPIC_API_KEY not set. Run: export ANTHROPIC_API_KEY=sk-ant-..."
|
||||
|
||||
zip_path = Path(zip_path)
|
||||
skill_name = zip_path.stem
|
||||
|
||||
print(f"📤 Uploading skill: {skill_name}")
|
||||
print(f" Source: {zip_path}")
|
||||
print(f" Size: {zip_path.stat().st_size:,} bytes")
|
||||
print()
|
||||
|
||||
# Prepare API request
|
||||
api_url = "https://api.anthropic.com/v1/skills"
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01"
|
||||
}
|
||||
|
||||
try:
|
||||
# Read zip file
|
||||
with open(zip_path, 'rb') as f:
|
||||
zip_data = f.read()
|
||||
|
||||
# Upload skill
|
||||
print("⏳ Uploading to Anthropic API...")
|
||||
|
||||
files = {
|
||||
'skill': (zip_path.name, zip_data, 'application/zip')
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
api_url,
|
||||
headers=headers,
|
||||
files=files,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
# Check response
|
||||
if response.status_code == 200:
|
||||
print()
|
||||
print("✅ Skill uploaded successfully!")
|
||||
print()
|
||||
print("Your skill is now available in Claude at:")
|
||||
print(f" {get_upload_url()}")
|
||||
print()
|
||||
return True, "Upload successful"
|
||||
|
||||
elif response.status_code == 401:
|
||||
return False, "Authentication failed. Check your ANTHROPIC_API_KEY"
|
||||
|
||||
elif response.status_code == 400:
|
||||
error_msg = response.json().get('error', {}).get('message', 'Unknown error')
|
||||
return False, f"Invalid skill format: {error_msg}"
|
||||
|
||||
else:
|
||||
error_msg = response.json().get('error', {}).get('message', 'Unknown error')
|
||||
return False, f"Upload failed ({response.status_code}): {error_msg}"
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return False, "Upload timed out. Try again or use manual upload"
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, "Connection error. Check your internet connection"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Unexpected error: {str(e)}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Upload a skill .zip file to Claude via Anthropic API",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Setup:
|
||||
1. Get your Anthropic API key from https://console.anthropic.com/
|
||||
2. Set the API key:
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
Examples:
|
||||
# Upload skill
|
||||
python3 upload_skill.py output/react.zip
|
||||
|
||||
# Upload with explicit path
|
||||
python3 upload_skill.py /path/to/skill.zip
|
||||
|
||||
Requirements:
|
||||
- ANTHROPIC_API_KEY environment variable must be set
|
||||
- requests library (pip install requests)
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'zip_file',
|
||||
help='Path to skill .zip file (e.g., output/react.zip)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Upload skill
|
||||
success, message = upload_skill_api(args.zip_file)
|
||||
|
||||
if success:
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"\n❌ Upload failed: {message}")
|
||||
print()
|
||||
print("📝 Manual upload instructions:")
|
||||
print_upload_instructions(args.zip_file)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
172
cli/utils.py
Executable file
172
cli/utils.py
Executable file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Utility functions for Skill Seeker CLI tools
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def open_folder(folder_path):
|
||||
"""
|
||||
Open a folder in the system file browser
|
||||
|
||||
Args:
|
||||
folder_path: Path to folder to open
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
folder_path = Path(folder_path).resolve()
|
||||
|
||||
if not folder_path.exists():
|
||||
print(f"⚠️ Folder not found: {folder_path}")
|
||||
return False
|
||||
|
||||
system = platform.system()
|
||||
|
||||
try:
|
||||
if system == "Linux":
|
||||
# Try xdg-open first (standard)
|
||||
subprocess.run(["xdg-open", str(folder_path)], check=True)
|
||||
elif system == "Darwin": # macOS
|
||||
subprocess.run(["open", str(folder_path)], check=True)
|
||||
elif system == "Windows":
|
||||
subprocess.run(["explorer", str(folder_path)], check=True)
|
||||
else:
|
||||
print(f"⚠️ Unknown operating system: {system}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"⚠️ Could not open folder automatically")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(f"⚠️ File browser not found on system")
|
||||
return False
|
||||
|
||||
|
||||
def has_api_key():
|
||||
"""
|
||||
Check if ANTHROPIC_API_KEY is set in environment
|
||||
|
||||
Returns:
|
||||
bool: True if API key is set, False otherwise
|
||||
"""
|
||||
api_key = os.environ.get('ANTHROPIC_API_KEY', '').strip()
|
||||
return len(api_key) > 0
|
||||
|
||||
|
||||
def get_api_key():
|
||||
"""
|
||||
Get ANTHROPIC_API_KEY from environment
|
||||
|
||||
Returns:
|
||||
str: API key or None if not set
|
||||
"""
|
||||
api_key = os.environ.get('ANTHROPIC_API_KEY', '').strip()
|
||||
return api_key if api_key else None
|
||||
|
||||
|
||||
def get_upload_url():
|
||||
"""
|
||||
Get the Claude skills upload URL
|
||||
|
||||
Returns:
|
||||
str: Claude skills upload URL
|
||||
"""
|
||||
return "https://claude.ai/skills"
|
||||
|
||||
|
||||
def print_upload_instructions(zip_path):
|
||||
"""
|
||||
Print clear upload instructions for manual upload
|
||||
|
||||
Args:
|
||||
zip_path: Path to the .zip file to upload
|
||||
"""
|
||||
zip_path = Path(zip_path)
|
||||
|
||||
print()
|
||||
print("╔══════════════════════════════════════════════════════════╗")
|
||||
print("║ NEXT STEP ║")
|
||||
print("╚══════════════════════════════════════════════════════════╝")
|
||||
print()
|
||||
print(f"📤 Upload to Claude: {get_upload_url()}")
|
||||
print()
|
||||
print(f"1. Go to {get_upload_url()}")
|
||||
print("2. Click \"Upload Skill\"")
|
||||
print(f"3. Select: {zip_path}")
|
||||
print("4. Done! ✅")
|
||||
print()
|
||||
|
||||
|
||||
def format_file_size(size_bytes):
|
||||
"""
|
||||
Format file size in human-readable format
|
||||
|
||||
Args:
|
||||
size_bytes: Size in bytes
|
||||
|
||||
Returns:
|
||||
str: Formatted size (e.g., "45.3 KB")
|
||||
"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} bytes"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
|
||||
def validate_skill_directory(skill_dir):
|
||||
"""
|
||||
Validate that a directory is a valid skill directory
|
||||
|
||||
Args:
|
||||
skill_dir: Path to skill directory
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, error_message)
|
||||
"""
|
||||
skill_path = Path(skill_dir)
|
||||
|
||||
if not skill_path.exists():
|
||||
return False, f"Directory not found: {skill_dir}"
|
||||
|
||||
if not skill_path.is_dir():
|
||||
return False, f"Not a directory: {skill_dir}"
|
||||
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return False, f"SKILL.md not found in {skill_dir}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_zip_file(zip_path):
|
||||
"""
|
||||
Validate that a file is a valid skill .zip file
|
||||
|
||||
Args:
|
||||
zip_path: Path to .zip file
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, error_message)
|
||||
"""
|
||||
zip_path = Path(zip_path)
|
||||
|
||||
if not zip_path.exists():
|
||||
return False, f"File not found: {zip_path}"
|
||||
|
||||
if not zip_path.is_file():
|
||||
return False, f"Not a file: {zip_path}"
|
||||
|
||||
if not zip_path.suffix == '.zip':
|
||||
return False, f"Not a .zip file: {zip_path}"
|
||||
|
||||
return True, None
|
||||
Reference in New Issue
Block a user