Merge pull request 'fix(agy): replace unreliable plugins.json discovery with a real installer' (#2) from fix-agy-install-mcp-plugin into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -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
@@ -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
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user