mirror of
https://github.com/bobmatnyc/claude-mpm-skills.git
synced 2026-09-14 19:59:53 +08:00
ead26503ce
- init_skill.py: Initialize new skills with proper structure (interactive + CLI modes) - package_skill.py: Validate and package skills for deployment - Update scripts/README.md with comprehensive documentation Version: 1.0.0 → 1.0.1 (PATCH) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
489 lines
16 KiB
Python
Executable File
489 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Package a skill for deployment to the Claude skills directory.
|
|
|
|
This script validates and packages individual skills:
|
|
1. Validates SKILL.md exists and has proper frontmatter
|
|
2. Validates metadata.json exists and has required fields
|
|
3. Calculates token counts using tiktoken
|
|
4. Copies skill to .claude/skills with flattened name
|
|
5. Optionally copies reference files
|
|
|
|
Usage:
|
|
# Package a single skill
|
|
python scripts/package_skill.py toolchains/python/frameworks/django
|
|
|
|
# Package with validation only (no copy)
|
|
python scripts/package_skill.py --validate toolchains/python/frameworks/django
|
|
|
|
# Package to custom target
|
|
python scripts/package_skill.py --target ~/.claude/skills toolchains/python/frameworks/django
|
|
|
|
# Package all skills matching pattern
|
|
python scripts/package_skill.py --pattern "toolchains/python/*"
|
|
|
|
# Force overwrite
|
|
python scripts/package_skill.py --force toolchains/python/frameworks/django
|
|
|
|
Examples:
|
|
# Validate skill structure
|
|
python scripts/package_skill.py --validate toolchains/rust/frameworks/tauri
|
|
|
|
# Package with verbose output
|
|
python scripts/package_skill.py --verbose toolchains/typescript/testing/vitest
|
|
|
|
# Dry run to see what would happen
|
|
python scripts/package_skill.py --dry-run toolchains/javascript/frameworks/react
|
|
"""
|
|
|
|
import argparse
|
|
import fnmatch
|
|
import json
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
try:
|
|
import tiktoken
|
|
|
|
HAS_TIKTOKEN = True
|
|
except ImportError:
|
|
HAS_TIKTOKEN = False
|
|
|
|
|
|
class SkillValidator:
|
|
"""Validate skill structure and content."""
|
|
|
|
def __init__(self, verbose: bool = False):
|
|
self.verbose = verbose
|
|
self.errors: list[str] = []
|
|
self.warnings: list[str] = []
|
|
self.encoding = None
|
|
|
|
if HAS_TIKTOKEN:
|
|
try:
|
|
self.encoding = tiktoken.get_encoding("cl100k_base")
|
|
except Exception:
|
|
pass
|
|
|
|
def reset(self):
|
|
"""Reset errors and warnings for new validation."""
|
|
self.errors = []
|
|
self.warnings = []
|
|
|
|
def count_tokens(self, text: str) -> int:
|
|
"""Count tokens in text."""
|
|
if self.encoding:
|
|
try:
|
|
return len(self.encoding.encode(text))
|
|
except Exception:
|
|
pass
|
|
# Fallback: estimate ~4 characters per token
|
|
return len(text) // 4
|
|
|
|
def validate_skill_md(self, skill_path: Path) -> bool:
|
|
"""Validate SKILL.md file."""
|
|
skill_md = skill_path / "SKILL.md"
|
|
|
|
if not skill_md.exists():
|
|
self.errors.append(f"SKILL.md not found in {skill_path}")
|
|
return False
|
|
|
|
try:
|
|
content = skill_md.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
self.errors.append(f"Cannot read SKILL.md: {e}")
|
|
return False
|
|
|
|
# Check frontmatter
|
|
if not content.startswith("---"):
|
|
self.errors.append("SKILL.md missing frontmatter (should start with ---)")
|
|
return False
|
|
|
|
# Extract frontmatter
|
|
match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
|
|
if not match:
|
|
self.errors.append("SKILL.md has malformed frontmatter")
|
|
return False
|
|
|
|
frontmatter = match.group(1)
|
|
|
|
# Check required frontmatter fields
|
|
if "name:" not in frontmatter:
|
|
self.warnings.append("SKILL.md frontmatter missing 'name' field")
|
|
|
|
if "description:" not in frontmatter:
|
|
self.warnings.append("SKILL.md frontmatter missing 'description' field")
|
|
|
|
# Check for progressive_disclosure section
|
|
if "progressive_disclosure:" not in content:
|
|
self.warnings.append("SKILL.md missing progressive_disclosure section")
|
|
|
|
# Check content length
|
|
tokens = self.count_tokens(content)
|
|
if tokens < 100:
|
|
self.warnings.append(f"SKILL.md seems too short ({tokens} tokens)")
|
|
elif tokens > 20000:
|
|
self.warnings.append(
|
|
f"SKILL.md is very large ({tokens} tokens) - consider splitting"
|
|
)
|
|
|
|
if self.verbose:
|
|
print(f" SKILL.md: {tokens} tokens")
|
|
|
|
return True
|
|
|
|
def validate_metadata_json(self, skill_path: Path) -> Optional[dict]:
|
|
"""Validate metadata.json file."""
|
|
metadata_path = skill_path / "metadata.json"
|
|
|
|
if not metadata_path.exists():
|
|
self.warnings.append(f"metadata.json not found in {skill_path}")
|
|
return None
|
|
|
|
try:
|
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as e:
|
|
self.errors.append(f"metadata.json is not valid JSON: {e}")
|
|
return None
|
|
except Exception as e:
|
|
self.errors.append(f"Cannot read metadata.json: {e}")
|
|
return None
|
|
|
|
# Check required fields
|
|
required_fields = ["name", "version", "category"]
|
|
for field in required_fields:
|
|
if field not in metadata:
|
|
self.errors.append(f"metadata.json missing required field: {field}")
|
|
|
|
# Check recommended fields
|
|
recommended_fields = ["toolchain", "tags", "entry_point_tokens", "full_tokens"]
|
|
for field in recommended_fields:
|
|
if field not in metadata:
|
|
self.warnings.append(
|
|
f"metadata.json missing recommended field: {field}"
|
|
)
|
|
|
|
# Validate version format
|
|
version = metadata.get("version", "")
|
|
if version and not re.match(r"^\d+\.\d+\.\d+$", version):
|
|
self.warnings.append(
|
|
f"metadata.json version '{version}' not in semver format"
|
|
)
|
|
|
|
# Validate category
|
|
valid_categories = ["toolchain", "universal", "example"]
|
|
category = metadata.get("category", "")
|
|
if category and category not in valid_categories:
|
|
self.warnings.append(f"metadata.json category '{category}' not recognized")
|
|
|
|
if self.verbose:
|
|
print(f" metadata.json: {json.dumps(metadata, indent=2)[:200]}...")
|
|
|
|
return metadata
|
|
|
|
def validate_references(self, skill_path: Path) -> list[Path]:
|
|
"""Check for reference files."""
|
|
references_dir = skill_path / "references"
|
|
references = []
|
|
|
|
if references_dir.exists():
|
|
for ref in references_dir.glob("*.md"):
|
|
references.append(ref)
|
|
if self.verbose:
|
|
tokens = self.count_tokens(ref.read_text(encoding="utf-8"))
|
|
print(f" references/{ref.name}: {tokens} tokens")
|
|
|
|
return references
|
|
|
|
def validate_skill(self, skill_path: Path) -> bool:
|
|
"""Validate a complete skill."""
|
|
self.reset()
|
|
|
|
if not skill_path.exists():
|
|
self.errors.append(f"Skill path does not exist: {skill_path}")
|
|
return False
|
|
|
|
if not skill_path.is_dir():
|
|
self.errors.append(f"Skill path is not a directory: {skill_path}")
|
|
return False
|
|
|
|
if self.verbose:
|
|
print(f"\nValidating: {skill_path}")
|
|
|
|
# Validate components
|
|
skill_md_valid = self.validate_skill_md(skill_path)
|
|
metadata = self.validate_metadata_json(skill_path)
|
|
references = self.validate_references(skill_path)
|
|
|
|
# Calculate total tokens
|
|
if skill_md_valid:
|
|
skill_content = (skill_path / "SKILL.md").read_text(encoding="utf-8")
|
|
total_tokens = self.count_tokens(skill_content)
|
|
|
|
for ref in references:
|
|
total_tokens += self.count_tokens(ref.read_text(encoding="utf-8"))
|
|
|
|
if self.verbose:
|
|
print(f" Total tokens: {total_tokens}")
|
|
|
|
# Check if metadata token count is accurate
|
|
if metadata and "full_tokens" in metadata:
|
|
declared = metadata["full_tokens"]
|
|
if abs(declared - total_tokens) > total_tokens * 0.2:
|
|
self.warnings.append(
|
|
f"Token count mismatch: declared {declared}, actual {total_tokens}"
|
|
)
|
|
|
|
return len(self.errors) == 0
|
|
|
|
|
|
class SkillPackager:
|
|
"""Package skills for deployment."""
|
|
|
|
def __init__(self, repo_root: Path, target: Path, verbose: bool = False):
|
|
self.repo_root = repo_root
|
|
self.target = target
|
|
self.verbose = verbose
|
|
self.validator = SkillValidator(verbose)
|
|
|
|
def get_flat_name(self, skill_path: Path) -> str:
|
|
"""Convert hierarchical path to flat name."""
|
|
try:
|
|
relative = skill_path.relative_to(self.repo_root)
|
|
except ValueError:
|
|
relative = skill_path
|
|
|
|
# Convert path separators to dashes
|
|
flat_name = str(relative).replace("/", "-").replace("\\", "-")
|
|
|
|
# Remove trailing SKILL.md if present
|
|
flat_name = flat_name.replace("-SKILL.md", "")
|
|
|
|
return flat_name
|
|
|
|
def package_skill(
|
|
self, skill_path: Path, force: bool = False, dry_run: bool = False
|
|
) -> bool:
|
|
"""Package a single skill."""
|
|
# Validate first
|
|
if not self.validator.validate_skill(skill_path):
|
|
print(f"\n❌ Validation failed for {skill_path}")
|
|
for error in self.validator.errors:
|
|
print(f" ERROR: {error}")
|
|
return False
|
|
|
|
# Show warnings
|
|
if self.validator.warnings:
|
|
for warning in self.validator.warnings:
|
|
print(f" ⚠️ {warning}")
|
|
|
|
# Get flat name
|
|
flat_name = self.get_flat_name(skill_path)
|
|
target_dir = self.target / flat_name
|
|
|
|
if self.verbose:
|
|
print(f"\n Packaging as: {flat_name}")
|
|
print(f" Target: {target_dir}")
|
|
|
|
# Check if exists
|
|
if target_dir.exists() and not force:
|
|
print(f" Target exists: {target_dir}")
|
|
print(" Use --force to overwrite")
|
|
return False
|
|
|
|
if dry_run:
|
|
print(f" [DRY RUN] Would package to: {target_dir}")
|
|
return True
|
|
|
|
# Create target directory
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Copy SKILL.md
|
|
shutil.copy2(skill_path / "SKILL.md", target_dir / "SKILL.md")
|
|
|
|
# Copy metadata.json if exists
|
|
metadata_path = skill_path / "metadata.json"
|
|
if metadata_path.exists():
|
|
shutil.copy2(metadata_path, target_dir / "metadata.json")
|
|
|
|
# Copy references directory if exists
|
|
references_src = skill_path / "references"
|
|
if references_src.exists():
|
|
references_dst = target_dir / "references"
|
|
if references_dst.exists():
|
|
shutil.rmtree(references_dst)
|
|
shutil.copytree(references_src, references_dst)
|
|
|
|
print(f" ✅ Packaged: {flat_name}")
|
|
return True
|
|
|
|
def find_skills(self, pattern: str) -> list[Path]:
|
|
"""Find skills matching a pattern."""
|
|
skills = []
|
|
|
|
# Search in toolchains, universal, examples
|
|
search_dirs = ["toolchains", "universal", "examples"]
|
|
|
|
for search_dir in search_dirs:
|
|
base = self.repo_root / search_dir
|
|
if not base.exists():
|
|
continue
|
|
|
|
for skill_md in base.rglob("SKILL.md"):
|
|
skill_path = skill_md.parent
|
|
relative = str(skill_path.relative_to(self.repo_root))
|
|
|
|
if fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(
|
|
relative, f"{pattern}*"
|
|
):
|
|
skills.append(skill_path)
|
|
|
|
return sorted(set(skills))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Package a skill for deployment",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
|
|
parser.add_argument(
|
|
"skill_path", nargs="?", help="Path to skill directory (relative to repo root)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--pattern",
|
|
"-p",
|
|
type=str,
|
|
help='Pattern to match multiple skills (e.g., "toolchains/python/*")',
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--target",
|
|
"-t",
|
|
type=Path,
|
|
help="Target directory for packaged skills (default: .claude/skills)",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--validate", "-V", action="store_true", help="Validate only, do not package"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--force", "-f", action="store_true", help="Overwrite existing packaged skills"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--dry-run", action="store_true", help="Preview without packaging"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--verbose", "-v", action="store_true", help="Enable verbose output"
|
|
)
|
|
|
|
parser.add_argument("--all", "-a", action="store_true", help="Package all skills")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Find repository root
|
|
script_dir = Path(__file__).parent
|
|
repo_root = script_dir.parent
|
|
|
|
# Validate repository
|
|
if (
|
|
not (repo_root / "toolchains").exists()
|
|
and not (repo_root / "universal").exists()
|
|
):
|
|
print(
|
|
f"Error: Not a valid claude-mpm-skills repository: {repo_root}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
# Determine target
|
|
target = args.target or (repo_root / ".claude" / "skills")
|
|
|
|
# Create packager
|
|
packager = SkillPackager(repo_root, target, args.verbose)
|
|
|
|
# Determine skills to process
|
|
skills_to_process: list[Path] = []
|
|
|
|
if args.all:
|
|
skills_to_process = packager.find_skills("*")
|
|
elif args.pattern:
|
|
skills_to_process = packager.find_skills(args.pattern)
|
|
elif args.skill_path:
|
|
skill_path = repo_root / args.skill_path
|
|
if not skill_path.exists():
|
|
# Try without leading slash
|
|
skill_path = repo_root / args.skill_path.lstrip("/")
|
|
if skill_path.exists():
|
|
skills_to_process = [skill_path]
|
|
else:
|
|
print(f"Error: Skill path not found: {args.skill_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
if not skills_to_process:
|
|
print("No skills found to process")
|
|
sys.exit(1)
|
|
|
|
print(f"\n🔧 Processing {len(skills_to_process)} skill(s)...")
|
|
|
|
if args.validate:
|
|
print(" Mode: Validation only\n")
|
|
elif args.dry_run:
|
|
print(" Mode: Dry run\n")
|
|
else:
|
|
print(f" Target: {target}\n")
|
|
|
|
success_count = 0
|
|
error_count = 0
|
|
|
|
for skill_path in skills_to_process:
|
|
relative = skill_path.relative_to(repo_root)
|
|
print(f"\n📦 {relative}")
|
|
|
|
if args.validate:
|
|
# Validation only
|
|
if packager.validator.validate_skill(skill_path):
|
|
print(" ✅ Valid")
|
|
success_count += 1
|
|
else:
|
|
for error in packager.validator.errors:
|
|
print(f" ❌ {error}")
|
|
error_count += 1
|
|
|
|
for warning in packager.validator.warnings:
|
|
print(f" ⚠️ {warning}")
|
|
else:
|
|
# Package
|
|
if packager.package_skill(skill_path, args.force, args.dry_run):
|
|
success_count += 1
|
|
else:
|
|
error_count += 1
|
|
|
|
# Summary
|
|
print(f"\n{'='*50}")
|
|
print(f"Summary: {success_count} successful, {error_count} failed")
|
|
|
|
if error_count > 0:
|
|
sys.exit(1)
|
|
|
|
if not args.validate and not args.dry_run:
|
|
print(f"\n✅ Skills packaged to: {target}")
|
|
print("\nNext steps:")
|
|
print(" - Verify deployment with: ls -la .claude/skills/")
|
|
print(" - Run manifest generator: python scripts/generate_manifest.py")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|