#!/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 shlex import shutil import stat 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). EVENTS = { "SessionStart": (True, ""), "UserPromptSubmit": (True, ""), "Notification": (True, ""), "PostToolUse": (True, ""), "PreCompact": (True, ""), "SubagentStop": (True, ""), "Stop": (False, ""), "SessionEnd": (False, ""), } # Registered once, no longer. PreToolUse existed to count subagents as they # started; the count now comes from the snapshot the events carry themselves # (see running_agents in the hook). Listed rather than forgotten because both # install and uninstall sweep these out: an entry left behind would go on # spawning the hook on every agent launch for nothing, and an uninstall that # leaves our registrations in settings.json is not an uninstall. LEGACY_EVENTS = ("PreToolUse",) def entry(spec): async_, matcher = spec # Claude Code runs the command through a shell, so a repository path # containing a space -- or worse -- has to survive the trip. hook = {"type": "command", "command": shlex.quote(HOOK), "timeout": 5} if async_: hook["async"] = True return {"matcher": matcher, "hooks": [hook]} def is_ours(group): return any( h.get("command", "").rstrip("'\"").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: try: path = sys.argv[sys.argv.index("--settings") + 1] except IndexError: sys.exit("--settings needs a path") # Written through, not over: a settings.json symlinked out of a dotfiles # repository would otherwise be replaced by a regular file, silently # detaching it from the repository that is supposed to manage it. path = os.path.realpath(path) os.makedirs(os.path.dirname(path), exist_ok=True) 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)) # Kept from the first run only. Overwriting it on every run would, on the # second run, replace the pristine copy with the already-modified one -- # which is exactly when someone reaches for a backup. if os.path.exists(path) and not os.path.exists(path + ".bak"): shutil.copyfile(path, path + ".bak") shutil.copymode(path, path + ".bak") hooks = settings.setdefault("hooks", {}) for event in list(EVENTS) + list(LEGACY_EVENTS): groups = [g for g in hooks.get(event, []) if not is_ours(g)] if not uninstall and event in EVENTS: 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") # A replace discards the original's mode. settings.json may hold API keys # and may have been deliberately narrowed to 0600; silently widening it to # the umask default would undo that without a word. try: os.chmod(tmp, stat.S_IMODE(os.stat(path).st_mode)) except OSError: pass os.replace(tmp, path) print("%s %s in %s" % ("removed" if uninstall else "installed", HOOK, path)) print("running sessions pick this up on their own; no restart needed") if __name__ == "__main__": main()