#!/usr/bin/env python3 """Register (or remove) the status hooks in ~/.claude/settings.json. Merges into the existing file rather than rewriting it: settings.json holds unrelated user configuration, and entries for other tools must survive both install and uninstall. Ownership is tracked by the command path, so a repo moved to a new location cleanly replaces its old registration. Usage: install.py [--uninstall] [--settings PATH] """ import json import os import shutil import sys HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-hook.py") # Stop and SessionEnd are deliberately synchronous. Both fire as claude is # about to go quiet or exit, and an async child racing that exit gets killed # before it writes -- observed with `claude -p`, which left a session stuck at # "busy" forever. They fire at most once per turn, so ~20 ms is free; the # per-tool-call events stay async so they never sit in the agent's loop. # (async, matcher). PreToolUse is matched to the agent-spawning tools alone: # unmatched it would fire on every tool call in every session, and the only # thing it is here for is to count subagents as they start. The pattern is # anchored because it is a regex -- a bare "Task" also matches TaskCreate, # TaskUpdate and friends, which are not subagents. The hook re-checks the name # anyway, in case a future matcher works differently. EVENTS = { "SessionStart": (True, ""), "UserPromptSubmit": (True, ""), "Notification": (True, ""), "PreToolUse": (True, "^(Agent|Task)$"), "PostToolUse": (True, ""), "PreCompact": (True, ""), "SubagentStop": (True, ""), "Stop": (False, ""), "SessionEnd": (False, ""), } def entry(spec): async_, matcher = spec hook = {"type": "command", "command": HOOK, "timeout": 5} if async_: hook["async"] = True return {"matcher": matcher, "hooks": [hook]} def is_ours(group): return any( h.get("command", "").endswith("claude-status-hook.py") for h in group.get("hooks", []) if isinstance(h, dict) ) def main(): uninstall = "--uninstall" in sys.argv path = os.path.expanduser("~/.claude/settings.json") if "--settings" in sys.argv: path = sys.argv[sys.argv.index("--settings") + 1] try: with open(path) as fh: settings = json.load(fh) except FileNotFoundError: settings = {} except ValueError as exc: sys.exit("refusing to touch malformed %s: %s" % (path, exc)) if os.path.exists(path): shutil.copyfile(path, path + ".bak") hooks = settings.setdefault("hooks", {}) for event in EVENTS: groups = [g for g in hooks.get(event, []) if not is_ours(g)] if not uninstall: groups.append(entry(EVENTS[event])) if groups: hooks[event] = groups else: hooks.pop(event, None) if not hooks: settings.pop("hooks", None) # Replaced atomically rather than truncated in place: claude re-reads # settings.json as it changes, so a running session can be reading this # exact file, and a truncate-then-stream write hands it invalid JSON. tmp = "%s.%d.tmp" % (path, os.getpid()) with open(tmp, "w") as fh: json.dump(settings, fh, indent=2) fh.write("\n") os.replace(tmp, path) print("%s %s in %s" % ("removed" if uninstall else "installed", HOOK, path)) print("restart running claude sessions for the change to take effect") if __name__ == "__main__": main()