diff --git a/README.md b/README.md index 3987fe9..c9f84ac 100644 --- a/README.md +++ b/README.md @@ -157,26 +157,29 @@ Run `/plugin marketplace update` to pick up newly published plugins. ### Antigravity CLI (`agy`) -`agy` discovers plugins from a `plugins/` folder pointed at by an entry in -`plugins.json` — either globally (`~/.gemini/config/plugins.json`) or per -project (`.agents/plugins.json`). Clone this repo, then add an entry -pointing at the generated `dist/agy` directory: +agy documents a `plugins.json` "entries" mechanism for pointing it at an +external directory of plugins, but empirically it doesn't reliably load +one as a bundle — hooks and MCP servers inside a `plugins//` folder +are never picked up that way, only a stray skill occasionally surfaces via +agy's generic skill-walk. What does reliably work is placing things +directly where agy actually looks: `~/.gemini/config/skills//`, +`~/.gemini/config/mcp_config.json`, `~/.gemini/config/hooks.json`. +`scripts/install_agy.py` does exactly that from a generated `dist/agy`: ```bash git clone https://git.rootiest.dev/rootiest/rootiest-ai.git ~/rootiest-ai +cd ~/rootiest-ai +python3 scripts/generate_plugins.py # produces dist/agy/** +python3 scripts/install_agy.py --all # symlinks skills, merges MCP/hooks into ~/.gemini/config ``` -```json -// ~/.gemini/config/plugins.json -{ - "entries": [ - { "path": "~/rootiest-ai/dist/agy" } - ] -} -``` - -`git pull` in the clone to pick up updates; `agy plugin list` / `agy plugin -validate ` to inspect what's loaded. +It symlinks each skill (so `git pull` + re-running the script — or just +`git pull` alone, since skills are live symlinks — keeps them current) and +merges each plugin's `mcpServers`/hooks into the shared global config +files without touching unrelated entries already there. Install specific +plugins by name instead of `--all`, add `--project` to target a project's +`.agents/` instead of the global config, or `--uninstall` to cleanly +remove exactly what was added. --- @@ -224,9 +227,9 @@ bundle id, target list) and every `plugins//`, then regenerates: |---|---| | `.claude-plugin/marketplace.json` | Claude Code plugin marketplace | | `dist/claude-code/**` | Claude Code plugin directories (linked from the marketplace) | -| `dist/agy/**` | Antigravity CLI (`agy`) plugin directories | +| `dist/agy/**` | Antigravity CLI (`agy`) plugin directories — an intermediate; run `scripts/install_agy.py` to actually get them into agy | -CI (`.gitea/workflows/plugins.yml`) runs the generator on every push to +CI (`.github/workflows/plugins.yml`) runs the generator on every push to `main` that touches `plugins/`, `manifest.yaml`, or the generator itself, and commits the regenerated output; it can also be re-run on demand from the Actions tab (`workflow_dispatch`). Pull requests run the same generator diff --git a/dist/agy/core-essentials/mcp_config.json b/dist/agy/core-essentials/mcp_config.json new file mode 100644 index 0000000..b7398fa --- /dev/null +++ b/dist/agy/core-essentials/mcp_config.json @@ -0,0 +1,27 @@ +{ + "mcpServers": { + "gitea": { + "command": "go", + "args": [ + "run", + "gitea.com/gitea/gitea-mcp@latest", + "-t", + "stdio" + ], + "env": { + "GITEA_HOST": "${GITEA_HOST}", + "GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}" + } + }, + "github": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/dist/claude-code/core-essentials/.mcp.json b/dist/claude-code/core-essentials/.mcp.json new file mode 100644 index 0000000..bc8db1c --- /dev/null +++ b/dist/claude-code/core-essentials/.mcp.json @@ -0,0 +1,29 @@ +{ + "mcpServers": { + "gitea": { + "type": "stdio", + "command": "go", + "args": [ + "run", + "gitea.com/gitea/gitea-mcp@latest", + "-t", + "stdio" + ], + "env": { + "GITEA_HOST": "${GITEA_HOST}", + "GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}" + } + }, + "github": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/plugins/core-essentials/mcp.json b/plugins/core-essentials/mcp.json new file mode 100644 index 0000000..099d04a --- /dev/null +++ b/plugins/core-essentials/mcp.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "gitea": { + "type": "stdio", + "command": "go", + "args": ["run", "gitea.com/gitea/gitea-mcp@latest", "-t", "stdio"], + "env": { + "GITEA_HOST": "${GITEA_HOST}", + "GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}" + } + }, + "github": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/scripts/generate_plugins.py b/scripts/generate_plugins.py index 2a79a30..9625d21 100755 --- a/scripts/generate_plugins.py +++ b/scripts/generate_plugins.py @@ -413,8 +413,15 @@ def install_local(manifest: dict, out_dir: Path) -> None: agy_src = out_dir / "dist" / "agy" if agy_src.exists(): - print("For agy, add this entry to ~/.gemini/config/plugins.json:") - print(json.dumps({"entries": [{"path": str(agy_src)}]}, indent=2)) + install_agy_script = ROOT / "scripts" / "install_agy.py" + result = subprocess.run( + [sys.executable, str(install_agy_script), "--all", "--dist-dir", str(agy_src)], + capture_output=True, + text=True, + ) + print(result.stdout, end="") + if result.returncode != 0: + print(result.stderr, end="", file=sys.stderr) # ── CLI ─────────────────────────────────────────────────────────────────────── diff --git a/scripts/install_agy.py b/scripts/install_agy.py new file mode 100644 index 0000000..ec470df --- /dev/null +++ b/scripts/install_agy.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Install generated agy plugins into agy's real, working config locations. + +agy documents a `plugins.json` "entries" indirection for discovering plugin +bundles from an arbitrary directory, but empirically (tested against a live +agy session) it does not reliably surface a `plugins//plugin.json` +bundle as a plugin — hooks and MCP servers inside it are never loaded, and +only a stray skill occasionally surfaces via agy's generic skill-walk. The +mechanism that IS confirmed working is placing things directly where agy +actually looks: + + - skills: /skills// (symlinked from dist/agy) + - MCP servers: /mcp_config.json (merged in, "mcpServers" key) + - lifecycle hooks: /hooks.json (merged in, one top-level key per plugin) + +where is `~/.gemini/config` (global, default) or `.agents` at the +project's git root (--project). + +Run `scripts/generate_plugins.py` first to produce `dist/agy/**`; this +script only consumes that output, it doesn't generate anything itself. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +class InstallError(Exception): + pass + + +def load_json(path: Path) -> dict: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def resolve_target_root(project: bool) -> Path: + if not project: + return Path.home() / ".gemini" / "config" + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True + ) + project_root = Path(result.stdout.strip()) if result.returncode == 0 else Path.cwd() + return project_root / ".agents" + + +def discover_plugin_names(dist_agy: Path, bundle_id: "str | None") -> list[str]: + if not dist_agy.is_dir(): + raise InstallError( + f"{dist_agy} does not exist. Run `python3 scripts/generate_plugins.py` first." + ) + names = [p.name for p in sorted(dist_agy.iterdir()) if p.is_dir()] + if bundle_id and bundle_id in names: + names.remove(bundle_id) # would just re-symlink every skill a second time + return names + + +def install_plugin(name: str, dist_agy: Path, target_root: Path) -> None: + plugin_dir = dist_agy / name + if not plugin_dir.is_dir(): + raise InstallError(f"no such generated plugin: {name} (looked in {plugin_dir})") + + skills_dir = plugin_dir / "skills" + if skills_dir.is_dir(): + target_skills = target_root / "skills" + target_skills.mkdir(parents=True, exist_ok=True) + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): + continue + link = target_skills / skill_dir.name + if link.is_symlink() or link.exists(): + if link.is_symlink(): + link.unlink() + else: + raise InstallError( + f"{link} already exists and is not a symlink managed by this script " + "— remove it manually if you want to replace it" + ) + link.symlink_to(skill_dir.resolve()) + print(f" skill: {skill_dir.name} -> {link}") + + mcp_path = plugin_dir / "mcp_config.json" + if mcp_path.exists(): + source = load_json(mcp_path) + target_path = target_root / "mcp_config.json" + target = load_json(target_path) + target.setdefault("mcpServers", {}) + for server_name, cfg in source.get("mcpServers", {}).items(): + target["mcpServers"][server_name] = cfg + print(f" mcp: {server_name} -> {target_path}") + write_json(target_path, target) + + hooks_path = plugin_dir / "hooks.json" + if hooks_path.exists(): + source = load_json(hooks_path) + target_path = target_root / "hooks.json" + target = load_json(target_path) + for hook_name, spec in source.items(): + target[hook_name] = spec + print(f" hook: {hook_name} -> {target_path}") + write_json(target_path, target) + + if not any((skills_dir.is_dir(), mcp_path.exists(), hooks_path.exists())): + print(f" (nothing to install for '{name}')") + + +def uninstall_plugin(name: str, dist_agy: Path, target_root: Path) -> None: + plugin_dir = dist_agy / name + if not plugin_dir.is_dir(): + raise InstallError(f"no such generated plugin: {name} (looked in {plugin_dir})") + + skills_dir = plugin_dir / "skills" + if skills_dir.is_dir(): + for skill_dir in sorted(skills_dir.iterdir()): + link = target_root / "skills" / skill_dir.name + if link.is_symlink() and link.resolve() == skill_dir.resolve(): + link.unlink() + print(f" removed skill symlink: {link}") + + mcp_path = plugin_dir / "mcp_config.json" + if mcp_path.exists(): + source = load_json(mcp_path) + target_path = target_root / "mcp_config.json" + target = load_json(target_path) + for server_name in source.get("mcpServers", {}): + if target.get("mcpServers", {}).pop(server_name, None) is not None: + print(f" removed mcp server: {server_name}") + if target_path.exists(): + write_json(target_path, target) + + hooks_path = plugin_dir / "hooks.json" + if hooks_path.exists(): + source = load_json(hooks_path) + target_path = target_root / "hooks.json" + target = load_json(target_path) + for hook_name in source: + if target.pop(hook_name, None) is not None: + print(f" removed hook group: {hook_name}") + if target_path.exists(): + write_json(target_path, target) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("plugins", nargs="*", help="plugin name(s) to install/uninstall") + parser.add_argument("--all", action="store_true", help="install/uninstall every generated plugin") + parser.add_argument("--uninstall", action="store_true", help="remove instead of install") + parser.add_argument("--project", action="store_true", help="target .agents/ at the project's git root instead of ~/.gemini/config") + parser.add_argument("--dist-dir", help="path to a generated dist/agy directory (default: /dist/agy)") + args = parser.parse_args(sys.argv[1:]) + + try: + dist_agy = Path(args.dist_dir).expanduser().resolve() if args.dist_dir else ROOT / "dist" / "agy" + target_root = resolve_target_root(args.project) + + bundle_id = None + manifest_path = ROOT / "manifest.yaml" + if manifest_path.exists(): + import yaml + + bundle_id = yaml.safe_load(manifest_path.read_text(encoding="utf-8")).get("bundle", {}).get("id") + + if args.all: + names = discover_plugin_names(dist_agy, bundle_id) + elif args.plugins: + names = args.plugins + else: + parser.error("specify plugin name(s) or --all") + + action = uninstall_plugin if args.uninstall else install_plugin + verb = "Uninstalling" if args.uninstall else "Installing" + print(f"{verb} {len(names)} plugin(s) -> {target_root}") + for name in names: + print(f"{name}:") + action(name, dist_agy, target_root) + + return 0 + except InstallError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())