diff --git a/scripts/convert_to_gemini.py b/scripts/convert_to_gemini.py new file mode 100644 index 000000000..f50b7f97b --- /dev/null +++ b/scripts/convert_to_gemini.py @@ -0,0 +1,692 @@ +#!/usr/bin/env python3 +"""Convert Claude Code plugins to a single Gemini CLI extension. + +Reads all plugins under plugins/ and generates a Gemini CLI extension at the +repo root. Directory-based namespacing preserves the /plugin:command pattern +(e.g. commands/ci/analyze-payload.toml -> /ci:analyze-payload). + +Generated files at repo root: + gemini-extension.json # Extension manifest (version auto-bumped) + GEMINI.md # Aggregated context file + commands/ # Converted commands (TOML) + {plugin}/ + {command}.toml + skills/ # Copied skills (nested by plugin) + {plugin}/ + {skill}/ + SKILL.md + +Conversion details: + - commands/*.md frontmatter -> TOML description field (via yaml.safe_load) + - commands/*.md body -> TOML prompt field (triple-quoted ''') + - "Claude" references replaced with "Gemini" in commands and skills + - Skills nested under plugin name to preserve path structure + +Version management: + The patch version in gemini-extension.json is auto-bumped whenever any + content (commands, skills, GEMINI.md) changes compared to the existing + output. The version starts at 1.0.0 on first generation. +""" + +import filecmp +import json +import os +import re +import shutil +import sys +import tempfile + +import yaml + +try: + import tomllib +except ImportError: + import tomli as tomllib + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUGINS_DIR = os.path.join(REPO_ROOT, "plugins") + +# Files/directories managed by this script at the repo root +MANAGED_PATHS = ["gemini-extension.json", "GEMINI.md", "commands", "skills"] + +# Text replacement mapping: Claude-specific references -> Gemini equivalents. +# Uses single-pass regex to avoid recursive replacement issues where an earlier +# replacement's output could match a later replacement's input. +# "Claude agent text" is a real pattern used in plugin output descriptions +# (e.g. git/branch-cleanup.md, yaml/docs.md). +_REPLACEMENTS = { + "Claude agent text": "Gemini agent text", + "Claude Code plugin": "Gemini CLI extension", + "Claude Code": "Gemini CLI", + "Claude plugin": "Gemini extension", + "Claude": "Gemini", + "https://claude.com/claude-code": "https://github.com/google-gemini/gemini-cli", + "$HOME/.config/claude-code/": "$HOME/.gemini/", + "~/.config/claude-code/": "~/.gemini/", + "CLAUDE.md": "GEMINI.md", + "claude-code": "gemini-cli", + ".claude/": ".gemini/", + "claude": "gemini", +} + +# Build a single regex that matches any key (longest first to prefer specific +# matches over generic ones). Each matched segment is replaced exactly once. +_REPLACEMENTS_RE = re.compile( + "|".join(re.escape(k) for k in sorted(_REPLACEMENTS, key=len, reverse=True)) +) + + +def parse_md_frontmatter(content): + """Extract YAML frontmatter and body from a markdown file.""" + frontmatter = {} + body = content + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + parsed = yaml.safe_load(parts[1]) + if isinstance(parsed, dict): + frontmatter = { + k: str(v) if v is not None else "" + for k, v in parsed.items() + } + body = parts[2].lstrip("\n") + return frontmatter, body + + +def adapt_text(text, plugin_name=None): + """Replace Claude-specific references with Gemini equivalents. + + Uses single-pass regex so each matched segment is replaced exactly once, + avoiding recursive replacement issues. + """ + if plugin_name: + text = text.replace( + "${CLAUDE_PLUGIN_ROOT}/skills/", + f"${{extensionPath}}/skills/{plugin_name}/", + ) + text = text.replace("${CLAUDE_PLUGIN_ROOT}", "${extensionPath}") + return _REPLACEMENTS_RE.sub(lambda m: _REPLACEMENTS[m.group()], text) + + +def convert_command_to_toml(md_path, plugin_name, plugin_version): + """Convert a Claude command .md file to a Gemini command .toml file.""" + with open(md_path, "r") as f: + content = f.read() + + frontmatter, body = parse_md_frontmatter(content) + description = adapt_text(frontmatter.get("description", ""), plugin_name) + + body = adapt_text(body, plugin_name) + + # Replace Claude-style positional arguments ($1, $2, etc.) with + # Gemini CLI's {{args}} placeholder. Gemini CLI provides the entire + # argument string as {{args}} — the model parses individual args. + body = re.sub(r'\$\{(\d+)\}', '{{args}}', body) + body = re.sub(r'(? v{new_version}") + + # Report + cmd_count = 0 + cmds_dir = os.path.join(REPO_ROOT, "commands", plugin_name) + if os.path.isdir(cmds_dir): + toml_files = sorted(os.listdir(cmds_dir)) + cmd_count = len(toml_files) + print(f"Commands ({cmd_count}):") + for f in toml_files: + print(f" commands/{plugin_name}/{f}") + + skill_count = 0 + skills_dir = os.path.join(REPO_ROOT, "skills", plugin_name) + if os.path.isdir(skills_dir): + skill_dirs = sorted(os.listdir(skills_dir)) + skill_count = len(skill_dirs) + if skill_dirs: + print(f"Skills ({skill_count}):") + for d in skill_dirs: + print(f" skills/{plugin_name}/{d}/") + + print(f"\nConverted plugin '{plugin_name}': {cmd_count} commands, {skill_count} skills.") + + +def main(): + args = parse_args() + + if not os.path.isdir(PLUGINS_DIR): + print(f"Error: plugins directory not found: {PLUGINS_DIR}", file=sys.stderr) + sys.exit(1) + + if args.plugin: + convert_single_plugin(args.plugin) + return + + if args.check: + temp_dir = tempfile.mkdtemp(prefix="gemini-check-") + try: + converted = generate_content(temp_dir) + existing_version = read_existing_version() + write_manifest(temp_dir, existing_version) + + changed_plugins = get_changed_plugins(temp_dir) + if changed_plugins: + print( + "\nFAIL: Gemini extension files are out of sync with " + "plugins/. Run 'make convert-to-gemini' to regenerate " + "(version will be bumped automatically).", + file=sys.stderr, + ) + print("Changed plugins:", file=sys.stderr) + for p in changed_plugins: + print(f" - {p}", file=sys.stderr) + sys.exit(1) + + diffs_found = report_content_diffs(temp_dir) + if diffs_found: + print( + "\nFAIL: Gemini extension files are out of sync with " + "plugins/. Run 'make convert-to-gemini' to regenerate.", + file=sys.stderr, + ) + sys.exit(1) + else: + print( + f"OK: Gemini extension is in sync " + f"(v{existing_version}, {converted} plugins verified)." + ) + finally: + shutil.rmtree(temp_dir) + else: + temp_dir = tempfile.mkdtemp(prefix="gemini-gen-") + try: + converted = generate_content(temp_dir) + + current_version = read_existing_version() + changed_plugins = get_changed_plugins(temp_dir) + if changed_plugins: + new_version = bump_patch(current_version) + else: + new_version = current_version + + write_manifest(temp_dir, new_version) + sync_to_root(temp_dir) + + if new_version != current_version: + print( + f"Converted {converted} plugins " + f"(v{current_version} -> v{new_version})" + ) + print(f"Changed plugins:") + for p in changed_plugins: + print(f" - {p}") + else: + print( + f"Converted {converted} plugins " + f"(v{new_version}, no changes)" + ) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + main()