Gyra v0.2 (H17)
Browse files- harness/gyra_install.py +83 -0
harness/gyra_install.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wire the Gyra hook into Claude Code or Codex for one repository (or for the user).
|
| 2 |
+
|
| 3 |
+
python3 gyra_install.py --harness claude [--dir REPO] [--scope project|user] [--env GYRA_MODE=headless ...]
|
| 4 |
+
python3 gyra_install.py --harness codex [--dir REPO] [--scope project|user]
|
| 5 |
+
python3 gyra_install.py --harness claude --remove
|
| 6 |
+
|
| 7 |
+
Claude Code: hooks in <repo>/.claude/settings.local.json (user scope: ~/.claude/settings.json).
|
| 8 |
+
Codex: <repo>/.codex/hooks.json (same hook contract as Claude Code) and `[features] hooks = true` in .codex/config.toml.
|
| 9 |
+
Existing hooks are kept; running it again replaces only Gyra's own entries.
|
| 10 |
+
"""
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
import shlex
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
HOOK = Path(__file__).resolve().parent / "gyra_hook.py"
|
| 19 |
+
MARK = "gyra_hook.py"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def command(env):
|
| 23 |
+
pre = " ".join("%s=%s" % (k, shlex.quote(v)) for k, v in env)
|
| 24 |
+
return ("%s %s %s" % (pre, shlex.quote(sys.executable), shlex.quote(str(HOOK)))).strip()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def spec(harness):
|
| 28 |
+
post = "Bash|Read|WebFetch|WebSearch|mcp__.*" if harness == "claude" else "Bash|mcp__.*" # Codex reads files through Bash
|
| 29 |
+
return [("UserPromptSubmit", None, 5), ("PreToolUse", "Bash", 5), ("PostToolUse", post, 5)]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def merge(hooks, harness, cmd, remove):
|
| 33 |
+
for event, matcher, timeout in spec(harness):
|
| 34 |
+
groups = [g for g in hooks.get(event, []) if not any(MARK in h.get("command", "") for h in g.get("hooks", []))]
|
| 35 |
+
if not remove:
|
| 36 |
+
groups.append(dict({"matcher": matcher} if matcher else {}, hooks=[{"type": "command", "command": cmd, "timeout": timeout}]))
|
| 37 |
+
if groups:
|
| 38 |
+
hooks[event] = groups
|
| 39 |
+
else:
|
| 40 |
+
hooks.pop(event, None)
|
| 41 |
+
return hooks
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def load(p):
|
| 45 |
+
return json.loads(p.read_text()) if p.exists() and p.read_text().strip() else {}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def main():
|
| 49 |
+
ap = argparse.ArgumentParser()
|
| 50 |
+
ap.add_argument("--harness", choices=["claude", "codex"], required=True)
|
| 51 |
+
ap.add_argument("--dir", default=".")
|
| 52 |
+
ap.add_argument("--scope", choices=["project", "user"], default="project")
|
| 53 |
+
ap.add_argument("--env", action="append", default=[], help="KEY=VALUE passed to the hook, e.g. GYRA_MODE=headless")
|
| 54 |
+
ap.add_argument("--remove", action="store_true")
|
| 55 |
+
a = ap.parse_args()
|
| 56 |
+
env = [tuple(e.split("=", 1)) for e in a.env]
|
| 57 |
+
cmd = command(env)
|
| 58 |
+
root = Path.home() if a.scope == "user" else Path(a.dir).resolve()
|
| 59 |
+
if a.harness == "claude":
|
| 60 |
+
path = root / ".claude" / ("settings.json" if a.scope == "user" else "settings.local.json")
|
| 61 |
+
cfg = load(path)
|
| 62 |
+
cfg["hooks"] = merge(cfg.get("hooks", {}), "claude", cmd, a.remove)
|
| 63 |
+
else:
|
| 64 |
+
path = root / ".codex" / "hooks.json"
|
| 65 |
+
cfg = load(path)
|
| 66 |
+
cfg["hooks"] = merge(cfg.get("hooks", {}), "codex", cmd, a.remove)
|
| 67 |
+
toml = root / ".codex" / "config.toml"
|
| 68 |
+
cur = toml.read_text() if toml.exists() else ""
|
| 69 |
+
if not a.remove:
|
| 70 |
+
if re.search(r"^\[features\]", cur, re.M):
|
| 71 |
+
if not re.search(r"^hooks\s*=", cur, re.M):
|
| 72 |
+
cur = re.sub(r"^\[features\]\s*$", "[features]\nhooks = true", cur, count=1, flags=re.M)
|
| 73 |
+
else:
|
| 74 |
+
cur = cur.rstrip() + ("\n\n" if cur.strip() else "") + "[features]\nhooks = true\n"
|
| 75 |
+
toml.parent.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
toml.write_text(cur)
|
| 77 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 78 |
+
path.write_text(json.dumps(cfg, indent=2) + "\n")
|
| 79 |
+
print("%s Gyra hooks %s %s" % ("removed from" if a.remove else "written to", a.harness, path))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
main()
|