Gemini CLI: - Add GEMINI.md with activation instructions - Add scripts/gemini-install.sh setup script - Add scripts/sync-gemini-skills.py (194 skills indexed) - Add .gemini/skills/ with symlinks for all skills, agents, commands - Remove phantom medium-content-pro entries from sync script - Add top-level folder filter to prevent gitignored dirs from leaking Codex CLI: - Fix sync-codex-skills.py missing "engineering" domain (25 POWERFUL skills) - Regenerate .codex/skills-index.json: 124 → 149 skills - Add 25 new symlinks in .codex/skills/ OpenClaw: - Add OpenClaw installation section to INSTALLATION.md - Add ClawHub install + manual install + YAML frontmatter docs Documentation: - Update INSTALLATION.md with all 4 platforms + accurate counts - Update README.md: "three platforms" → "four platforms" + Gemini quick start - Update CLAUDE.md with Gemini CLI support in v2.1.1 highlights - Update SKILL-AUTHORING-STANDARD.md + SKILL_PIPELINE.md with Gemini steps - Add OpenClaw + Gemini to installation locations reference table Marketplace: all 18 plugins validated — sources exist, SKILL.md present Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
9.0 KiB
Python
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()
|