#!/usr/bin/env python3 """ Git-Based Shared Skill Registry. Manages a git-friendly skill registry for publishing, discovering, and installing cross-platform agent skills. The registry is a directory with a registry.json manifest and a skills/ folder — no servers, no databases, no new dependencies. Usage: python3 scripts/skill_registry.py init [--name NAME] [--registry PATH] python3 scripts/skill_registry.py publish [--registry PATH] [--tags T1,T2] [--force] [--json] python3 scripts/skill_registry.py list [--registry PATH] [--json] python3 scripts/skill_registry.py search [--registry PATH] [--json] python3 scripts/skill_registry.py install [--registry PATH] [--platform PLATFORM] [--project] [--force] [--json] python3 scripts/skill_registry.py info [--registry PATH] [--json] python3 scripts/skill_registry.py remove [--registry PATH] [--force] Exit codes: 0 - Success 1 - Error """ import argparse import json import os import re import shutil import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional # --- Import sibling scripts --- _SCRIPTS_DIR = Path(__file__).resolve().parent if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from validate import validate_skill, _parse_frontmatter, _parse_yaml_field, _parse_subfield_value from security_scan import security_scan # --- Constants --- ALL_PLATFORMS = ["claude-code", "copilot", "cursor", "windsurf", "cline", "codex", "gemini"] PLATFORM_PATHS_USER = { "claude-code": "~/.claude/skills", "copilot": "~/.copilot/skills", "cursor": "~/.cursor/rules", "windsurf": "~/.windsurf/skills", "cline": "~/.cline/rules", "codex": "~/.codex/skills", "gemini": "~/.gemini/skills", } PLATFORM_PATHS_PROJECT = { "claude-code": ".claude/skills", "copilot": ".github/skills", "cursor": ".cursor/rules", "windsurf": ".windsurf/skills", "cline": ".clinerules", "codex": ".codex/skills", "gemini": ".gemini/skills", } # Directories/files to exclude when copying skills COPY_IGNORE_PATTERNS = shutil.ignore_patterns( ".git", "__pycache__", "node_modules", ".venv", "venv", "env", ".pytest_cache", ".mypy_cache", "dist", "build", "*.pyc", "*.pyo", ) # Stop words for auto-tagging STOP_WORDS = { "a", "an", "the", "and", "or", "but", "is", "are", "was", "were", "be", "been", "being", "in", "on", "at", "to", "for", "of", "with", "by", "from", "as", "into", "through", "during", "before", "after", "above", "below", "between", "out", "off", "over", "under", "again", "further", "then", "once", "here", "there", "when", "where", "why", "how", "all", "each", "every", "both", "few", "more", "most", "other", "some", "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "can", "will", "just", "should", "now", "it", "its", "this", "that", "these", "those", "he", "she", "we", "they", "what", "which", "who", "whom", "do", "does", "did", "has", "have", "had", "having", "using", } MIN_TAG_LENGTH = 3 # --- Registry I/O --- def load_registry(registry_path: Path) -> dict: """Read and parse registry.json from the registry directory.""" manifest = registry_path / "registry.json" if not manifest.exists(): print(f"Error: registry.json not found in {registry_path}", file=sys.stderr) print("Run 'skill_registry.py init' first.", file=sys.stderr) sys.exit(1) try: return json.loads(manifest.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: print(f"Error reading registry.json: {exc}", file=sys.stderr) sys.exit(1) def save_registry(registry_path: Path, data: dict) -> None: """Atomic write: write to .tmp then rename.""" manifest = registry_path / "registry.json" tmp = registry_path / "registry.json.tmp" try: tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") tmp.replace(manifest) except OSError as exc: # Clean up tmp on failure if tmp.exists(): tmp.unlink() print(f"Error writing registry.json: {exc}", file=sys.stderr) sys.exit(1) # --- Metadata Extraction --- def extract_skill_metadata(skill_path: Path) -> dict: """ Parse SKILL.md frontmatter into a metadata dict. Returns dict with keys: name, description, version, author, license. Missing fields default to empty string. """ skill_md = skill_path / "SKILL.md" if not skill_md.exists(): return {"name": "", "description": "", "version": "", "author": "", "license": ""} content = skill_md.read_text(encoding="utf-8") frontmatter, _ = _parse_frontmatter(content) if frontmatter is None: return {"name": "", "description": "", "version": "", "author": "", "license": ""} name = _parse_yaml_field(frontmatter, "name") or "" description = _parse_yaml_field(frontmatter, "description") or "" license_val = _parse_yaml_field(frontmatter, "license") or "" # Version: try metadata.version first, then top-level version version = _parse_subfield_value(frontmatter, "metadata", "version") if not version: version = _parse_yaml_field(frontmatter, "version") or "" # Author: try metadata.author first author = _parse_subfield_value(frontmatter, "metadata", "author") or "" return { "name": name.strip(), "description": description.strip(), "version": version.strip(), "author": author.strip(), "license": license_val.strip(), } def auto_extract_tags(description: str) -> list[str]: """ Extract keyword tags from a description string. Splits on non-alphanumeric characters, filters stop words and short words, returns up to 10 unique lowercase tags. """ if not description: return [] words = re.split(r"[^a-zA-Z0-9-]+", description.lower()) seen: set[str] = set() tags: list[str] = [] for word in words: word = word.strip("-") if len(word) < MIN_TAG_LENGTH: continue if word in STOP_WORDS: continue if word not in seen: seen.add(word) tags.append(word) if len(tags) >= 10: break return tags # --- Platform Detection --- def detect_platform() -> str: """ Auto-detect the installed agent platform by checking known directories. Returns the platform name or "claude-code" as default. """ checks = [ ("claude-code", "~/.claude"), ("copilot", "~/.copilot"), ("cursor", "~/.cursor"), ("windsurf", "~/.windsurf"), ("cline", "~/.cline"), ("codex", "~/.codex"), ("gemini", "~/.gemini"), ] for platform, path in checks: if Path(path).expanduser().exists(): return platform return "claude-code" def resolve_install_path(name: str, platform: str, project: bool) -> Path: """ Map platform + scope to the filesystem install path for a skill. Args: name: Skill name (used as subdirectory). platform: Platform identifier. project: If True, use project-level path; otherwise user-level. Returns: Absolute path where the skill should be installed. """ if project: base = PLATFORM_PATHS_PROJECT.get(platform) else: base = PLATFORM_PATHS_USER.get(platform) if base is None: print(f"Error: unknown platform '{platform}'", file=sys.stderr) print(f"Supported: {', '.join(ALL_PLATFORMS)}", file=sys.stderr) sys.exit(1) return Path(base).expanduser().resolve() / name # --- Table Formatting --- def _format_table(entries: list[dict]) -> str: """Format skill entries as an aligned text table.""" if not entries: return "No skills found." headers = ["NAME", "VERSION", "AUTHOR", "TAGS"] rows = [] for entry in entries: tags = ", ".join(entry.get("tags", [])) rows.append([ entry.get("name", ""), entry.get("version", ""), entry.get("author", ""), tags, ]) # Calculate column widths widths = [len(h) for h in headers] for row in rows: for i, cell in enumerate(row): widths[i] = max(widths[i], len(cell)) # Build output lines = [] header_line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers)) lines.append(header_line) for row in rows: lines.append(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))) return "\n".join(lines) # --- Subcommands --- def cmd_init(args: argparse.Namespace) -> None: """Initialize a new skill registry.""" registry_path = Path(args.registry).resolve() manifest = registry_path / "registry.json" if manifest.exists(): print(f"Error: registry already exists at {registry_path}", file=sys.stderr) sys.exit(1) registry_path.mkdir(parents=True, exist_ok=True) (registry_path / "skills").mkdir(exist_ok=True) name = args.name or "Shared Skills" data = { "registry": { "name": name, "created": datetime.now(timezone.utc).isoformat(timespec="seconds"), "schema_version": "1", }, "skills": [], } save_registry(registry_path, data) print(f"Registry initialized: {registry_path}") print(f" Name: {name}") print(f" Manifest: {manifest}") print(f" Skills dir: {registry_path / 'skills'}") def cmd_publish(args: argparse.Namespace) -> None: """Publish a skill to the registry.""" registry_path = Path(args.registry).resolve() skill_path = Path(args.skill_path).resolve() if not skill_path.is_dir(): print(f"Error: skill path is not a directory: {skill_path}", file=sys.stderr) sys.exit(1) # Step 1: Validate validation = validate_skill(str(skill_path)) if not validation["valid"]: print("Validation failed:", file=sys.stderr) for err in validation["errors"]: print(f" [ERROR] {err}", file=sys.stderr) sys.exit(1) # Step 2: Security scan scan = security_scan(str(skill_path)) high_issues = [i for i in scan["issues"] if i["severity"] == "high"] other_issues = [i for i in scan["issues"] if i["severity"] != "high"] if other_issues: for issue in other_issues: location = issue["file"] if issue["line"] > 0: location += f":{issue['line']}" print(f" [WARN] {location}: {issue['description']}") if high_issues and not args.force: print("Security scan found high-severity issues:", file=sys.stderr) for issue in high_issues: location = issue["file"] if issue["line"] > 0: location += f":{issue['line']}" print(f" [HIGH] {location}: {issue['description']}", file=sys.stderr) print("Use --force to publish anyway.", file=sys.stderr) sys.exit(1) # Step 3: Extract metadata metadata = extract_skill_metadata(skill_path) name = metadata["name"] version = metadata["version"] or "0.0.0" if not name: print("Error: could not extract skill name from SKILL.md frontmatter", file=sys.stderr) sys.exit(1) # Step 4: Tags tags = [] if args.tags: tags = [t.strip() for t in args.tags.split(",") if t.strip()] if not tags: tags = auto_extract_tags(metadata["description"]) # Step 5: Check duplicates data = load_registry(registry_path) for existing in data["skills"]: if existing["name"] == name and existing["version"] == version: if not args.force: print( f"Error: skill '{name}' version '{version}' already exists in registry.", file=sys.stderr, ) print("Use --force to overwrite.", file=sys.stderr) sys.exit(1) # Remove old entry if forcing data["skills"] = [s for s in data["skills"] if not (s["name"] == name and s["version"] == version)] # Step 6: Copy skill to registry dest = registry_path / "skills" / name if dest.exists(): shutil.rmtree(dest) shutil.copytree(skill_path, dest, ignore=COPY_IGNORE_PATTERNS) # Step 7: Add entry entry = { "name": name, "description": metadata["description"], "version": version, "author": metadata["author"], "license": metadata["license"], "tags": tags, "platforms": list(ALL_PLATFORMS), "published": datetime.now(timezone.utc).isoformat(timespec="seconds"), "path": f"skills/{name}", "validation": { "valid": validation["valid"], "errors": len(validation["errors"]), "warnings": len(validation["warnings"]), }, "security": { "clean": scan["clean"], "issues": len(scan["issues"]), }, } data["skills"].append(entry) save_registry(registry_path, data) if getattr(args, "json", False): print(json.dumps(entry, indent=2)) else: print(f"Published '{name}' v{version} to registry.") print(f" Path: {dest}") print(f" Tags: {', '.join(tags)}") def cmd_list(args: argparse.Namespace) -> None: """List all skills in the registry.""" registry_path = Path(args.registry).resolve() data = load_registry(registry_path) if getattr(args, "json", False): print(json.dumps(data["skills"], indent=2)) return print(_format_table(data["skills"])) def cmd_search(args: argparse.Namespace) -> None: """Search for skills matching a query.""" registry_path = Path(args.registry).resolve() data = load_registry(registry_path) query = args.query.lower() matches = [] for skill in data["skills"]: searchable = " ".join([ skill.get("name", ""), skill.get("description", ""), skill.get("author", ""), " ".join(skill.get("tags", [])), ]).lower() if query in searchable: matches.append(skill) if getattr(args, "json", False): print(json.dumps(matches, indent=2)) return if not matches: print(f"No skills matching '{args.query}'.") return print(f"Skills matching '{args.query}':\n") print(_format_table(matches)) def cmd_install(args: argparse.Namespace) -> None: """Install a skill from the registry.""" registry_path = Path(args.registry).resolve() data = load_registry(registry_path) # Find skill skill_entry = None for skill in data["skills"]: if skill["name"] == args.skill_name: skill_entry = skill break if skill_entry is None: print(f"Error: skill '{args.skill_name}' not found in registry.", file=sys.stderr) sys.exit(1) # Resolve platform platform = args.platform or detect_platform() if platform not in ALL_PLATFORMS: print(f"Error: unknown platform '{platform}'", file=sys.stderr) print(f"Supported: {', '.join(ALL_PLATFORMS)}", file=sys.stderr) sys.exit(1) # Resolve target path project = getattr(args, "project", False) target = resolve_install_path(args.skill_name, platform, project) # Check if already installed if target.exists() and not args.force: print(f"Error: skill already installed at {target}", file=sys.stderr) print("Use --force to overwrite.", file=sys.stderr) sys.exit(1) # Copy source = registry_path / skill_entry["path"] if not source.exists(): print(f"Error: skill files not found at {source}", file=sys.stderr) sys.exit(1) if target.exists(): shutil.rmtree(target) target.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(source, target, ignore=COPY_IGNORE_PATTERNS) if getattr(args, "json", False): print(json.dumps({ "installed": True, "skill": args.skill_name, "platform": platform, "path": str(target), }, indent=2)) return scope = "project" if project else "user" print(f"Installed '{args.skill_name}' for {platform} ({scope}-level).") print(f" Path: {target}") # Platform-specific activation tips tips = { "claude-code": "Skill is auto-loaded. Start a new conversation to activate.", "copilot": "Skill is auto-loaded by Copilot Chat.", "cursor": "Skill is loaded alongside .mdc rules.", "windsurf": "Skill is auto-loaded by Windsurf.", "cline": "Skill is loaded from .clinerules.", "codex": "Skill is auto-loaded by Codex CLI.", "gemini": "Skill is auto-loaded by Gemini CLI.", } tip = tips.get(platform) if tip: print(f" Tip: {tip}") def cmd_info(args: argparse.Namespace) -> None: """Show detailed info about a skill.""" registry_path = Path(args.registry).resolve() data = load_registry(registry_path) skill_entry = None for skill in data["skills"]: if skill["name"] == args.skill_name: skill_entry = skill break if skill_entry is None: print(f"Error: skill '{args.skill_name}' not found in registry.", file=sys.stderr) sys.exit(1) if getattr(args, "json", False): print(json.dumps(skill_entry, indent=2)) return print(f"Skill: {skill_entry['name']}") print(f"{'=' * 50}") print(f" Version: {skill_entry.get('version', 'N/A')}") print(f" Author: {skill_entry.get('author', 'N/A')}") print(f" License: {skill_entry.get('license', 'N/A')}") print(f" Description: {skill_entry.get('description', 'N/A')}") print(f" Tags: {', '.join(skill_entry.get('tags', []))}") print(f" Platforms: {', '.join(skill_entry.get('platforms', []))}") print(f" Published: {skill_entry.get('published', 'N/A')}") print(f" Path: {skill_entry.get('path', 'N/A')}") validation = skill_entry.get("validation", {}) if validation: status = "valid" if validation.get("valid") else "invalid" print(f" Validation: {status} ({validation.get('errors', 0)} errors, {validation.get('warnings', 0)} warnings)") security = skill_entry.get("security", {}) if security: status = "clean" if security.get("clean") else f"{security.get('issues', 0)} issues" print(f" Security: {status}") print(f"{'=' * 50}") def cmd_remove(args: argparse.Namespace) -> None: """Remove a skill from the registry.""" registry_path = Path(args.registry).resolve() data = load_registry(registry_path) # Find skill skill_entry = None for skill in data["skills"]: if skill["name"] == args.skill_name: skill_entry = skill break if skill_entry is None: print(f"Error: skill '{args.skill_name}' not found in registry.", file=sys.stderr) sys.exit(1) if not args.force: print(f"Remove '{args.skill_name}' from registry? Use --force to confirm.", file=sys.stderr) sys.exit(1) # Remove files skill_dir = registry_path / skill_entry["path"] if skill_dir.exists(): shutil.rmtree(skill_dir) # Remove entry data["skills"] = [s for s in data["skills"] if s["name"] != args.skill_name] save_registry(registry_path, data) print(f"Removed '{args.skill_name}' from registry.") # --- CLI --- def _add_registry_arg(parser: argparse.ArgumentParser) -> None: """Add the --registry argument to a subparser.""" parser.add_argument( "--registry", default="./registry", help="Path to the registry directory (default: ./registry)", ) def build_parser() -> argparse.ArgumentParser: """Build the argument parser with all subcommands.""" parser = argparse.ArgumentParser( prog="skill_registry", description="Git-based shared skill registry for cross-platform agent skills.", ) subparsers = parser.add_subparsers(dest="command", help="Available commands") # init p_init = subparsers.add_parser("init", help="Initialize a new skill registry") _add_registry_arg(p_init) p_init.add_argument("--name", help="Registry name (default: 'Shared Skills')") # publish p_publish = subparsers.add_parser("publish", help="Publish a skill to the registry") p_publish.add_argument("skill_path", help="Path to the skill directory") _add_registry_arg(p_publish) p_publish.add_argument("--tags", help="Comma-separated tags (auto-extracted if omitted)") p_publish.add_argument("--force", action="store_true", help="Overwrite existing or ignore high-severity issues") p_publish.add_argument("--json", action="store_true", help="Output as JSON") # list p_list = subparsers.add_parser("list", help="List all skills in the registry") _add_registry_arg(p_list) p_list.add_argument("--json", action="store_true", help="Output as JSON") # search p_search = subparsers.add_parser("search", help="Search for skills") p_search.add_argument("query", help="Search query (matches name, description, author, tags)") _add_registry_arg(p_search) p_search.add_argument("--json", action="store_true", help="Output as JSON") # install p_install = subparsers.add_parser("install", help="Install a skill from the registry") p_install.add_argument("skill_name", help="Name of the skill to install") _add_registry_arg(p_install) p_install.add_argument("--platform", choices=ALL_PLATFORMS, help="Target platform (auto-detected if omitted)") p_install.add_argument("--project", action="store_true", help="Install at project level instead of user level") p_install.add_argument("--force", action="store_true", help="Overwrite existing installation") p_install.add_argument("--json", action="store_true", help="Output as JSON") # info p_info = subparsers.add_parser("info", help="Show detailed info about a skill") p_info.add_argument("skill_name", help="Name of the skill") _add_registry_arg(p_info) p_info.add_argument("--json", action="store_true", help="Output as JSON") # remove p_remove = subparsers.add_parser("remove", help="Remove a skill from the registry") p_remove.add_argument("skill_name", help="Name of the skill to remove") _add_registry_arg(p_remove) p_remove.add_argument("--force", action="store_true", help="Confirm removal") return parser def main() -> None: """CLI entry point.""" parser = build_parser() args = parser.parse_args() if args.command is None: parser.print_help() sys.exit(1) commands = { "init": cmd_init, "publish": cmd_publish, "list": cmd_list, "search": cmd_search, "install": cmd_install, "info": cmd_info, "remove": cmd_remove, } cmd_func = commands.get(args.command) if cmd_func is None: parser.print_help() sys.exit(1) cmd_func(args) if __name__ == "__main__": main()