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>
400 lines
12 KiB
Python
400 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Sync Codex Skills - Generate symlinks and index for OpenAI Codex compatibility.
|
|
|
|
This script scans all domain folders for SKILL.md files and creates:
|
|
1. Symlinks in .codex/skills/ directory
|
|
2. skills-index.json manifest for tooling
|
|
|
|
Usage:
|
|
python scripts/sync-codex-skills.py [--dry-run] [--verbose]
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
# Skill domain configuration
|
|
SKILL_DOMAINS = {
|
|
"marketing-skill": {
|
|
"category": "marketing",
|
|
"description": "Marketing, content, and demand generation skills"
|
|
},
|
|
"engineering-team": {
|
|
"category": "engineering",
|
|
"description": "Software engineering and technical skills"
|
|
},
|
|
"engineering": {
|
|
"category": "engineering-advanced",
|
|
"description": "Advanced engineering skills - agents, RAG, MCP, CI/CD, databases, observability"
|
|
},
|
|
"product-team": {
|
|
"category": "product",
|
|
"description": "Product management and design skills"
|
|
},
|
|
"c-level-advisor": {
|
|
"category": "c-level",
|
|
"description": "Executive leadership and advisory skills"
|
|
},
|
|
"project-management": {
|
|
"category": "project-management",
|
|
"description": "Project management and Atlassian skills"
|
|
},
|
|
"ra-qm-team": {
|
|
"category": "ra-qm",
|
|
"description": "Regulatory affairs and quality management skills"
|
|
},
|
|
"business-growth": {
|
|
"category": "business-growth",
|
|
"description": "Customer success, sales engineering, and revenue operations skills"
|
|
},
|
|
"finance": {
|
|
"category": "finance",
|
|
"description": "Financial analysis, valuation, and forecasting skills"
|
|
}
|
|
}
|
|
|
|
|
|
def find_skills(repo_root: Path) -> List[Dict]:
|
|
"""
|
|
Scan repository for all skills (folders containing SKILL.md).
|
|
|
|
Returns list of skill dictionaries with metadata.
|
|
"""
|
|
skills = []
|
|
|
|
for domain_dir, domain_info in SKILL_DOMAINS.items():
|
|
domain_path = repo_root / domain_dir
|
|
|
|
if not domain_path.exists():
|
|
continue
|
|
|
|
# Find all subdirectories with SKILL.md
|
|
for skill_path in domain_path.iterdir():
|
|
if not skill_path.is_dir():
|
|
continue
|
|
|
|
skill_md = skill_path / "SKILL.md"
|
|
if not skill_md.exists():
|
|
continue
|
|
|
|
# Extract skill name and description from SKILL.md
|
|
skill_name = skill_path.name
|
|
description = extract_skill_description(skill_md)
|
|
|
|
# Calculate relative path from .codex/skills/ to skill folder
|
|
relative_path = f"../../{domain_dir}/{skill_name}"
|
|
|
|
skills.append({
|
|
"name": skill_name,
|
|
"source": relative_path,
|
|
"source_absolute": str(skill_path.relative_to(repo_root)),
|
|
"category": domain_info["category"],
|
|
"description": description or f"Skill from {domain_dir}"
|
|
})
|
|
|
|
# Sort by category then name for consistent output
|
|
skills.sort(key=lambda s: (s["category"], s["name"]))
|
|
|
|
return skills
|
|
|
|
|
|
def extract_skill_description(skill_md_path: Path) -> Optional[str]:
|
|
"""
|
|
Extract description from SKILL.md YAML frontmatter.
|
|
|
|
Looks for:
|
|
---
|
|
name: ...
|
|
description: ...
|
|
---
|
|
"""
|
|
try:
|
|
content = skill_md_path.read_text(encoding="utf-8")
|
|
|
|
# Check for YAML frontmatter
|
|
if not content.startswith("---"):
|
|
return None
|
|
|
|
# Find end of frontmatter
|
|
end_idx = content.find("---", 3)
|
|
if end_idx == -1:
|
|
return None
|
|
|
|
frontmatter = content[3:end_idx]
|
|
|
|
# Simple extraction without YAML parser dependency
|
|
for line in frontmatter.split("\n"):
|
|
line = line.strip()
|
|
if line.startswith("description:"):
|
|
desc = line[len("description:"):].strip()
|
|
# Remove quotes if present
|
|
if desc.startswith('"') and desc.endswith('"'):
|
|
desc = desc[1:-1]
|
|
elif 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 .codex/skills/ directory.
|
|
|
|
Returns summary of operations.
|
|
"""
|
|
codex_skills_dir = repo_root / ".codex" / "skills"
|
|
|
|
created = []
|
|
updated = []
|
|
unchanged = []
|
|
errors = []
|
|
|
|
if not dry_run:
|
|
codex_skills_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for skill in skills:
|
|
symlink_path = codex_skills_dir / skill["name"]
|
|
target = skill["source"]
|
|
|
|
try:
|
|
if symlink_path.is_symlink():
|
|
current_target = os.readlink(symlink_path)
|
|
if current_target == target:
|
|
unchanged.append(skill["name"])
|
|
if verbose:
|
|
print(f" [UNCHANGED] {skill['name']} -> {target}")
|
|
else:
|
|
if not dry_run:
|
|
symlink_path.unlink()
|
|
symlink_path.symlink_to(target)
|
|
updated.append(skill["name"])
|
|
if verbose:
|
|
print(f" [UPDATED] {skill['name']} -> {target} (was: {current_target})")
|
|
elif symlink_path.exists():
|
|
errors.append(f"{skill['name']}: path exists but is not a symlink")
|
|
if verbose:
|
|
print(f" [ERROR] {skill['name']}: path exists but is not a symlink")
|
|
else:
|
|
if not dry_run:
|
|
symlink_path.symlink_to(target)
|
|
created.append(skill["name"])
|
|
if verbose:
|
|
print(f" [CREATED] {skill['name']} -> {target}")
|
|
|
|
except Exception as e:
|
|
errors.append(f"{skill['name']}: {str(e)}")
|
|
if verbose:
|
|
print(f" [ERROR] {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 .codex/skills-index.json manifest.
|
|
|
|
Returns the index data.
|
|
"""
|
|
# Calculate category counts
|
|
categories = {}
|
|
for skill in skills:
|
|
cat = skill["category"]
|
|
if cat not in categories:
|
|
# Find domain info
|
|
for domain_dir, domain_info in SKILL_DOMAINS.items():
|
|
if domain_info["category"] == cat:
|
|
categories[cat] = {
|
|
"count": 0,
|
|
"source": f"../../{domain_dir}",
|
|
"description": domain_info["description"]
|
|
}
|
|
break
|
|
if cat in categories:
|
|
categories[cat]["count"] += 1
|
|
|
|
# Build index
|
|
index = {
|
|
"version": "1.0.0",
|
|
"name": "claude-code-skills",
|
|
"description": "Production-ready skill packages for AI agents - Marketing, Engineering, Product, C-Level, PM, and RA/QM",
|
|
"repository": "https://github.com/alirezarezvani/claude-skills",
|
|
"total_skills": len(skills),
|
|
"skills": [
|
|
{
|
|
"name": s["name"],
|
|
"source": s["source"],
|
|
"category": s["category"],
|
|
"description": s["description"]
|
|
}
|
|
for s in skills
|
|
],
|
|
"categories": categories
|
|
}
|
|
|
|
if not dry_run:
|
|
index_path = repo_root / ".codex" / "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 validate_symlinks(repo_root: Path, skills: List[Dict]) -> List[str]:
|
|
"""
|
|
Validate that all symlinks resolve to valid SKILL.md files.
|
|
|
|
Returns list of broken symlinks.
|
|
"""
|
|
broken = []
|
|
codex_skills_dir = repo_root / ".codex" / "skills"
|
|
|
|
for skill in skills:
|
|
symlink_path = codex_skills_dir / skill["name"]
|
|
|
|
if not symlink_path.exists():
|
|
broken.append(f"{skill['name']}: symlink does not exist")
|
|
continue
|
|
|
|
skill_md = symlink_path / "SKILL.md"
|
|
if not skill_md.exists():
|
|
broken.append(f"{skill['name']}: SKILL.md not found through symlink")
|
|
|
|
return broken
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Sync Codex skills symlinks and generate index"
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run", "-n",
|
|
action="store_true",
|
|
help="Show what would be done without making changes"
|
|
)
|
|
parser.add_argument(
|
|
"--verbose", "-v",
|
|
action="store_true",
|
|
help="Show detailed output"
|
|
)
|
|
parser.add_argument(
|
|
"--validate",
|
|
action="store_true",
|
|
help="Validate symlinks after sync"
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Output results as JSON"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Find repository root (where this script lives in scripts/)
|
|
script_path = Path(__file__).resolve()
|
|
repo_root = script_path.parent.parent
|
|
|
|
if args.verbose and not args.json:
|
|
print(f"Repository root: {repo_root}")
|
|
print(f"Scanning for skills...")
|
|
|
|
# Find all skills
|
|
skills = find_skills(repo_root)
|
|
|
|
if not skills:
|
|
if args.json:
|
|
print(json.dumps({"error": "No skills found"}, indent=2))
|
|
else:
|
|
print("No skills found in repository")
|
|
sys.exit(1)
|
|
|
|
if args.verbose and not args.json:
|
|
print(f"Found {len(skills)} skills across {len(set(s['category'] for s in skills))} categories")
|
|
print()
|
|
|
|
# Create symlinks
|
|
if not args.json:
|
|
mode = "[DRY RUN] " if args.dry_run else ""
|
|
print(f"{mode}Creating symlinks in .codex/skills/...")
|
|
|
|
symlink_results = create_symlinks(repo_root, skills, args.dry_run, args.verbose)
|
|
|
|
# Generate index
|
|
if not args.json:
|
|
print(f"{mode}Generating .codex/skills-index.json...")
|
|
|
|
index = generate_skills_index(repo_root, skills, args.dry_run)
|
|
|
|
# Validate if requested
|
|
validation_errors = []
|
|
if args.validate and not args.dry_run:
|
|
if not args.json:
|
|
print("Validating symlinks...")
|
|
validation_errors = validate_symlinks(repo_root, skills)
|
|
|
|
# Output results
|
|
if args.json:
|
|
output = {
|
|
"dry_run": args.dry_run,
|
|
"total_skills": len(skills),
|
|
"symlinks": symlink_results,
|
|
"index_generated": not args.dry_run,
|
|
"validation_errors": validation_errors if args.validate else None
|
|
}
|
|
print(json.dumps(output, indent=2))
|
|
else:
|
|
print()
|
|
print("=" * 50)
|
|
print("SUMMARY")
|
|
print("=" * 50)
|
|
print(f"Total skills: {len(skills)}")
|
|
print(f"Symlinks created: {len(symlink_results['created'])}")
|
|
print(f"Symlinks updated: {len(symlink_results['updated'])}")
|
|
print(f"Symlinks unchanged: {len(symlink_results['unchanged'])}")
|
|
|
|
if symlink_results['errors']:
|
|
print(f"Errors: {len(symlink_results['errors'])}")
|
|
for err in symlink_results['errors']:
|
|
print(f" - {err}")
|
|
|
|
if validation_errors:
|
|
print(f"Validation errors: {len(validation_errors)}")
|
|
for err in validation_errors:
|
|
print(f" - {err}")
|
|
|
|
print()
|
|
print("Categories:")
|
|
for cat, info in index["categories"].items():
|
|
print(f" {cat}: {info['count']} skills")
|
|
|
|
if args.dry_run:
|
|
print()
|
|
print("No changes made (dry run mode)")
|
|
else:
|
|
print()
|
|
print(f"Index written to: .codex/skills-index.json")
|
|
print(f"Symlinks created in: .codex/skills/")
|
|
|
|
# Exit with error if there were issues
|
|
if symlink_results['errors'] or validation_errors:
|
|
sys.exit(1)
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|