Files
claude-skills-reference/scripts/sync-gemini-skills.py

271 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""
Sync Gemini Skills - Generate symlinks and index for Gemini CLI compatibility.
This script scans the entire repository for SKILL.md files and creates:
1. Symlinks in .gemini/skills/ directory
2. skills-index.json manifest for tooling
Usage:
python scripts/sync-gemini-skills.py [--dry-run] [--verbose]
"""
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
# Domain mapping for categories based on top-level folder
DOMAIN_MAP = {
"marketing-skill": "marketing",
"engineering-team": "engineering",
"engineering": "engineering-advanced",
"product-team": "product",
"c-level-advisor": "c-level",
"project-management": "project-management",
"ra-qm-team": "ra-qm",
"business-growth": "business-growth",
"finance": "finance"
}
def find_skills(repo_root: Path) -> List[Dict]:
"""
Scan repository for all skills (SKILL.md files).
"""
skills = []
seen_names = set()
# 1. Find all SKILL.md files recursively
for skill_md in repo_root.rglob("SKILL.md"):
# Skip internal .gemini directory
if ".gemini" in skill_md.parts:
continue
# Skip evaluation workspaces, assets, and gitignored directories
if "eval-workspace" in skill_md.parts or "assets" in skill_md.parts or "evals" in skill_md.parts:
if "sample-skill" not in skill_md.parts: # Keep sample if it's for testing
continue
# Skip directories not in DOMAIN_MAP (e.g. gitignored local folders)
top_level = skill_md.relative_to(repo_root).parts[0]
if top_level not in DOMAIN_MAP and top_level not in ("agents", "commands"):
continue
skill_dir = skill_md.parent
# Determine skill name
if skill_dir == repo_root:
continue # Root SKILL.md (unlikely)
# For domain-level SKILL.md, name it after the domain
if skill_dir.name in DOMAIN_MAP:
skill_name = f"{skill_dir.name}-bundle"
else:
skill_name = skill_dir.name
# Handle duplicates by appending parent name
if skill_name in seen_names:
skill_name = f"{skill_dir.parent.name}-{skill_name}"
seen_names.add(skill_name)
# Determine category based on top-level folder
category = "general"
for folder, cat in DOMAIN_MAP.items():
if folder in skill_md.parts:
category = cat
break
description = extract_skill_description(skill_md)
# Calculate relative path (3 levels up from .gemini/skills/{name}/SKILL.md)
rel_path = skill_md.relative_to(repo_root)
source_path = "../../../" + str(rel_path)
skills.append({
"name": skill_name,
"source": source_path,
"category": category,
"description": description or f"Skill from {rel_path.parent}"
})
# 2. Agents as Skills
agents_path = repo_root / "agents"
if agents_path.exists():
for agent_file in agents_path.rglob("*.md"):
if agent_file.name == "CLAUDE.md" or ".gemini" in agent_file.parts:
continue
agent_name = agent_file.stem
if agent_name in seen_names:
agent_name = f"agent-{agent_name}"
seen_names.add(agent_name)
description = extract_skill_description(agent_file)
rel_path = agent_file.relative_to(repo_root)
source_path = "../../../" + str(rel_path)
skills.append({
"name": agent_name,
"source": source_path,
"category": "agent",
"description": description or f"Agent from {agent_file.parent.name}"
})
# 3. Commands as Skills
commands_path = repo_root / "commands"
if commands_path.exists():
for cmd_file in commands_path.glob("*.md"):
if cmd_file.name == ".gitkeep":
continue
cmd_name = cmd_file.stem
if cmd_name in seen_names:
cmd_name = f"cmd-{cmd_name}"
seen_names.add(cmd_name)
description = extract_skill_description(cmd_file)
rel_path = cmd_file.relative_to(repo_root)
source_path = "../../../" + str(rel_path)
skills.append({
"name": cmd_name,
"source": source_path,
"category": "command",
"description": description or "Custom slash command"
})
skills.sort(key=lambda s: (s["category"], s["name"]))
return skills
def extract_skill_description(skill_md_path: Path) -> Optional[str]:
"""
Extract description from YAML frontmatter.
"""
try:
content = skill_md_path.read_text(encoding="utf-8")
if not content.startswith("---"):
return None
end_idx = content.find("---", 3)
if end_idx == -1:
return None
frontmatter = content[3:end_idx]
for line in frontmatter.split("\n"):
line = line.strip()
if line.startswith("description:"):
desc = line[len("description:"):].strip()
if (desc.startswith('"') and desc.endswith('"')) or (desc.startswith("'") and desc.endswith("'")):
desc = desc[1:-1]
return desc
return None
except Exception:
return None
def create_symlinks(repo_root: Path, skills: List[Dict], dry_run: bool = False, verbose: bool = False) -> Dict:
"""
Create symlinks in .gemini/skills/ directory.
"""
gemini_skills_dir = repo_root / ".gemini" / "skills"
# Optional: Clean existing skills to remove stale ones
# if not dry_run and gemini_skills_dir.exists():
# import shutil
# shutil.rmtree(gemini_skills_dir)
created, updated, unchanged, errors = [], [], [], []
if not dry_run:
gemini_skills_dir.mkdir(parents=True, exist_ok=True)
for skill in skills:
skill_name = skill["name"]
skill_dest_dir = gemini_skills_dir / skill_name
if not dry_run:
skill_dest_dir.mkdir(exist_ok=True)
symlink_path = skill_dest_dir / "SKILL.md"
target = skill["source"]
try:
if symlink_path.is_symlink():
current_target = os.readlink(symlink_path)
if current_target == target:
unchanged.append(skill_name)
else:
if not dry_run:
symlink_path.unlink()
symlink_path.symlink_to(target)
updated.append(skill_name)
elif symlink_path.exists():
errors.append(f"{skill_name}: path exists but is not a symlink")
else:
if not dry_run:
symlink_path.symlink_to(target)
created.append(skill_name)
except Exception as e:
errors.append(f"{skill_name}: {str(e)}")
return {"created": created, "updated": updated, "unchanged": unchanged, "errors": errors}
def generate_skills_index(repo_root: Path, skills: List[Dict], dry_run: bool = False) -> Dict:
"""
Generate .gemini/skills-index.json manifest.
"""
categories = {}
for skill in skills:
cat = skill["category"]
if cat not in categories:
categories[cat] = {"count": 0, "description": f"{cat.capitalize()} resources"}
categories[cat]["count"] += 1
index = {
"version": "1.0.0",
"name": "gemini-cli-skills",
"total_skills": len(skills),
"skills": [{"name": s["name"], "category": s["category"], "description": s["description"]} for s in skills],
"categories": categories
}
if not dry_run:
index_path = repo_root / ".gemini" / "skills-index.json"
index_path.parent.mkdir(parents=True, exist_ok=True)
index_path.write_text(json.dumps(index, indent=2) + "\n", encoding="utf-8")
return index
def main():
parser = argparse.ArgumentParser(description="Sync Gemini skills")
parser.add_argument("--dry-run", "-n", action="store_true")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
repo_root = Path(__file__).resolve().parent.parent
skills = find_skills(repo_root)
if not skills:
print("No skills found.")
sys.exit(1)
symlink_results = create_symlinks(repo_root, skills, args.dry_run, args.verbose)
generate_skills_index(repo_root, skills, args.dry_run)
print(f"Total items synced for Gemini CLI: {len(skills)}")
print(f"Created: {len(symlink_results['created'])}")
print(f"Updated: {len(symlink_results['updated'])}")
print(f"Unchanged: {len(symlink_results['unchanged'])}")
if symlink_results['errors']:
print(f"Errors: {len(symlink_results['errors'])}")
if __name__ == "__main__":
main()