fix(agy): replace unreliable plugins.json discovery with a real installer #2

Merged
rootiest merged 1 commits from fix-agy-install-mcp-plugin into main 2026-08-25 00:17:50 +00:00
6 changed files with 301 additions and 19 deletions
+20 -17
View File
@@ -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/<name>/` 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/<name>/`,
`~/.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 <path>` 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/<name>/`, 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
+27
View File
@@ -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}"
}
}
}
}
+29
View File
@@ -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}"
}
}
}
}
+21
View File
@@ -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}"
}
}
}
}
+9 -2
View File
@@ -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 ───────────────────────────────────────────────────────────────────────
+195
View File
@@ -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/<name>/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: <root>/skills/<skill>/ (symlinked from dist/agy)
- MCP servers: <root>/mcp_config.json (merged in, "mcpServers" key)
- lifecycle hooks: <root>/hooks.json (merged in, one top-level key per plugin)
where <root> 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: <repo>/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())