infra/scripts/workstation/claude-hooks/wire-memory-hooks.py
Viktor Barzin 2169e0de5f
All checks were successful
ci/woodpecker/push/default Pipeline was successful
workstation: harden memory hooks — prune dead plugin refs + homelab-CLI-only store
wire-memory-hooks.py now PRUNES any settings.json hook still pointing at the
retired claude-memory plugin (plugins/claude-memory/hooks/) before the additive
pass. install_memory() rm -rf's that dir, so those entries are dangling — and a
missing UserPromptSubmit hook exits 2, a BLOCKING error that erases the prompt
and froze emo's sessions (2026-06-22). The plugin shares basenames with the
homelab hooks, so the old additive-only logic saw the dead plugin path as
"already present" and skipped installing the real ~/.claude/hooks/ copy; pruning
first fixes that. Verified against emo's exact original config: yields the
correct 4-hook set, drops the dead PermissionRequest entry, idempotent on rerun.

auto-learn.py now stores via the `homelab memory` CLI only — dropped the direct
HTTP path and the local-SQLite fallback (memory is homelab-CLI-only per Viktor;
never local files). No-ops silently when no API key is in env (e.g. ancamilea).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:24:42 +00:00

90 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""Wire the homelab-memory hooks into a user's ~/.claude/settings.json.
Part of the claude-memory MCP -> homelab CLI migration (all-users rollout).
Two passes, idempotent, never touching `env` (the per-user MEMORY_API_KEY) or any
other setting:
(0) PRUNE any hook command still pointing at the retired claude-memory plugin
(`plugins/claude-memory/hooks/`). install_memory() rm -rf's that dir, so
those entries are dangling — and a missing UserPromptSubmit hook exits 2,
a BLOCKING error that erases the prompt and freezes the session (devvm emo
incident 2026-06-22). Must run BEFORE the additive pass: the plugin shares
basenames with the homelab hooks, so without pruning, the "already present"
check below matches the dead plugin path and skips the real install.
(1) ADD each homelab hook group when no existing command references its script.
Usage: wire-memory-hooks.py <home_dir>
Exit 0 on success (changed or already-present); 1 only on an unreadable settings file.
"""
import json
import os
import sys
home = sys.argv[1]
settings = os.path.join(home, ".claude", "settings.json")
hooks_dir = os.path.join(home, ".claude", "hooks")
# (event, script-basename used for the if-absent check, full command, extra fields)
WANT = [
("PreCompact", "pre-compact-backup.sh", f"{hooks_dir}/pre-compact-backup.sh", {"timeout": 30}),
("UserPromptSubmit", "post-compact-recovery.sh", f"{hooks_dir}/post-compact-recovery.sh", {"timeout": 10}),
("UserPromptSubmit", "homelab-memory-recall.py", f"python3 {hooks_dir}/homelab-memory-recall.py", {"timeout": 8}),
("Stop", "auto-learn.py", f"python3 {hooks_dir}/auto-learn.py", {"async": True}),
]
try:
if os.path.exists(settings) and os.path.getsize(settings) > 0:
with open(settings) as fh:
data = json.load(fh)
else:
data = {}
except (json.JSONDecodeError, OSError) as e:
print(f"ERROR: cannot read {settings}: {e}", file=sys.stderr)
sys.exit(1)
hooks = data.setdefault("hooks", {})
changed = False
# (0) Prune dead claude-memory plugin hooks (see module docstring). Must precede
# the additive pass so shared basenames don't mask a needed install.
DEAD_REF = "plugins/claude-memory/hooks/"
for event in list(hooks.keys()):
new_groups = []
removed_any = False
for g in (hooks.get(event) or []):
original = g.get("hooks") or []
kept = [h for h in original if DEAD_REF not in (h.get("command", "") or "")]
if len(kept) != len(original):
removed_any = True
if kept:
new_groups.append({**g, "hooks": kept})
if removed_any:
changed = True
if new_groups:
hooks[event] = new_groups
else:
del hooks[event]
# (1) Additively wire each homelab hook, if no command already references it.
for event, basename, command, extra in WANT:
groups = hooks.setdefault(event, [])
already = any(
basename in (h.get("command", "") or "")
for g in groups
for h in (g.get("hooks", []) or [])
)
if already:
continue
entry = {"type": "command", "command": command}
entry.update(extra)
groups.append({"hooks": [entry]})
changed = True
if changed:
tmp = settings + ".tmp"
with open(tmp, "w") as fh:
json.dump(data, fh, indent=2)
os.replace(tmp, settings)
print(f"wired memory hooks -> {settings}")
else:
print(f"memory hooks already present -> {settings} (no change)")