-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.py
More file actions
executable file
·258 lines (220 loc) · 10.2 KB
/
Copy pathinstall.py
File metadata and controls
executable file
·258 lines (220 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
#!/usr/bin/env python3
"""
Proctor (goal-contract) installer — wire a fresh clone into a Claude Code config.
Makes "git clone → reinstall → iterate" real: reconstructs every piece of live wiring
that otherwise only exists because it was hand-built — the Stop/PostToolUse/UserPromptSubmit
hooks in settings.json, the two skill symlinks, the /disarm command, and the judge config.
Idempotent (safe to re-run), backs up settings.json before editing, and fully reversible
with --uninstall. The hooks directory is SYMLINKED to this clone, so editing the clone is
editing the live system — that's the iterate loop.
Usage:
./install.py [--config-dir DIR] [--dry-run] install / repair wiring
./install.py --uninstall [--config-dir DIR] remove wiring (keeps witness logs + backups)
./install.py --status [--config-dir DIR] report what's wired, change nothing
Defaults --config-dir to $CLAUDE_CONFIG_DIR or ~/.claude.
Proof-integrity (damage-control patterns) is a defense-in-depth layer on a SEPARATE system;
this installer reports its status and prints the exact block to add, but never structurally
rewrites another tool's config. See README "Proof integrity".
"""
import os, sys, json, shutil, argparse
from datetime import datetime, timezone
REPO = os.path.dirname(os.path.abspath(__file__))
PY = shutil.which("python3") or "/usr/bin/python3"
TS = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
# (event, matcher-or-None, script, timeout-or-None) — the live wiring, reproduced exactly.
HOOKS = [
("PostToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit", "witness.py", 10),
("Stop", None, "disposition-gate.py", 10),
("Stop", None, "goal-gate.py", 10),
("UserPromptSubmit", None, "arm-contract.py", None),
]
SKILLS = [("goal-contract", "skills/goal-contract"),
("goal-contract-opus", "skills/goal-contract-opus"),
("proctor-setup", "skills/proctor-setup")]
COMMANDS = [("disarm.md", "commands/disarm.md"),
("inbox.md", "commands/inbox.md")]
BIN = os.path.expanduser("~/.local/bin/proctor") # operator CLI: `proctor inbox` / `signoff`
DC_MARKER = "goal-contract gate state" # presence check for the proof-integrity patterns
def hooks_link(cfg):
return os.path.join(cfg, "hooks", "goal-contract")
def hook_cmd(cfg, script):
return f"{PY} {os.path.join(hooks_link(cfg), script)}"
# ---- primitives ----------------------------------------------------------------
def link(target, linkname, dry, log, backup_root):
"""Idempotent symlink with backup of any real file/dir in the way. Backups go to
backup_root (OUTSIDE the skills/commands scan path) so a replaced skill can't reappear
as a duplicate in Claude Code's skill list."""
if os.path.islink(linkname):
if os.path.realpath(linkname) == os.path.realpath(target):
log(f" ok {linkname} (already linked)")
return
if not dry:
os.remove(linkname)
elif os.path.exists(linkname):
bak = os.path.join(backup_root, f"{os.path.basename(linkname)}.{TS}")
log(f" bak {linkname} -> {bak}")
if not dry:
os.makedirs(backup_root, exist_ok=True)
shutil.move(linkname, bak)
if not dry:
os.makedirs(os.path.dirname(linkname), exist_ok=True)
os.symlink(target, linkname)
log(f" link {linkname} -> {target}")
def unlink(linkname, dry, log):
if os.path.islink(linkname):
if not dry:
os.remove(linkname)
log(f" rm {linkname}")
def load_settings(cfg):
p = os.path.join(cfg, "settings.json")
try:
return p, json.load(open(p))
except FileNotFoundError:
return p, {}
except Exception as e:
print(f"! settings.json unreadable ({e}); aborting to avoid clobber.", file=sys.stderr)
sys.exit(1)
def _has_hook(groups, script):
return any(script in h.get("command", "")
for g in groups for h in g.get("hooks", []))
def wire_settings(cfg, dry, log):
p, s = load_settings(cfg)
if not dry and os.path.exists(p):
shutil.copy(p, f"{p}.bak-proctor-{TS}")
hooks = s.setdefault("hooks", {})
added = 0
for event, matcher, script, timeout in HOOKS:
groups = hooks.setdefault(event, [])
if _has_hook(groups, f"goal-contract/{script}") or _has_hook(groups, script):
log(f" ok {event} -> {script} (already wired)")
continue
hk = {"type": "command", "command": hook_cmd(cfg, script)}
if timeout:
hk["timeout"] = timeout
grp = {"hooks": [hk]}
if matcher:
grp["matcher"] = matcher
groups.append(grp)
added += 1
log(f" wire {event}[{matcher or '*'}] -> {script}")
if added and not dry:
json.dump(s, open(p, "w"), indent=2)
return added
def unwire_settings(cfg, dry, log):
p, s = load_settings(cfg)
if not s.get("hooks"):
return
if not dry and os.path.exists(p):
shutil.copy(p, f"{p}.bak-proctor-{TS}")
removed = 0
for event, groups in list(s["hooks"].items()):
kept = []
for g in groups:
hs = [h for h in g.get("hooks", []) if "goal-contract/" not in h.get("command", "")]
if not hs:
removed += 1
continue
if len(hs) != len(g.get("hooks", [])):
removed += 1
g["hooks"] = hs
kept.append(g)
if kept:
s["hooks"][event] = kept
else:
del s["hooks"][event]
if removed and not dry:
json.dump(s, open(p, "w"), indent=2)
log(f" rm {removed} goal-contract hook entr(ies) from settings.json")
def damage_control_status(cfg, log):
dc = os.path.join(cfg, "hooks", "damage-control-python", "patterns.yaml")
if not os.path.exists(dc):
log(" note proof-integrity guard SKIPPED — damage-control not installed (core system works without it)")
return
txt = open(dc, errors="replace").read()
if DC_MARKER in txt:
log(" ok proof-integrity patterns present in damage-control")
else:
log(" WARN proof-integrity patterns MISSING — add the block from "
"guards/damage-control-patterns.yaml to your patterns.yaml "
"(readOnlyPaths + the two bashToolPatterns). Not auto-merged (foreign config).")
# ---- top-level ----------------------------------------------------------------
def do_install(cfg, dry):
log = _logger(dry)
backup_root = os.path.join(cfg, ".proctor-backups")
print(f"Proctor install → {cfg} (repo: {REPO})")
link(REPO, hooks_link(cfg), dry, log, backup_root)
for name, rel in SKILLS:
link(os.path.join(REPO, rel), os.path.join(cfg, "skills", name), dry, log, backup_root)
for cmd_name, cmd_rel in COMMANDS:
link(os.path.join(REPO, cmd_rel), os.path.join(cfg, "commands", cmd_name), dry, log, backup_root)
# operator CLI on PATH: `proctor inbox`, `proctor signoff <sid>`
link(os.path.join(REPO, "proctor"), BIN, dry, log, backup_root)
if os.path.dirname(BIN) not in os.environ.get("PATH", "").split(":"):
log(f" note add {os.path.dirname(BIN)} to PATH to use `proctor` directly "
f"(else run python3 {os.path.join(REPO, 'proctor-inbox.py')})")
ex = os.path.join(REPO, "judge.config.example.json")
jc = os.path.join(REPO, "judge.config.json")
if os.path.exists(ex) and not os.path.exists(jc):
log(f" cfg judge.config.json (from example)")
if not dry:
shutil.copy(ex, jc)
# notify config (Slack push rail + per-machine vault board) — machine-local in the data dir
nex = os.path.join(REPO, "notify.config.example.json")
ncfg = os.path.join(cfg, "goal-contract", "notify.config.json")
if os.path.exists(nex) and not os.path.exists(ncfg):
log(" cfg goal-contract/notify.config.json (from example — add a Slack webhook + vault dir to enable)")
if not dry:
os.makedirs(os.path.dirname(ncfg), exist_ok=True)
shutil.copy(nex, ncfg)
n = wire_settings(cfg, dry, log)
damage_control_status(cfg, log)
print(f"\n{'[dry-run] ' if dry else ''}Done. {n} hook(s) newly wired. "
f"Verify: python3 {os.path.join(REPO,'test_gates.py')}")
def do_uninstall(cfg, dry):
log = _logger(dry)
print(f"Proctor uninstall ← {cfg}")
unwire_settings(cfg, dry, log)
for name, _ in SKILLS:
unlink(os.path.join(cfg, "skills", name), dry, log)
for cmd_name, _ in COMMANDS:
unlink(os.path.join(cfg, "commands", cmd_name), dry, log)
unlink(BIN, dry, log)
unlink(hooks_link(cfg), dry, log)
print(f"\n{'[dry-run] ' if dry else ''}Removed. Witness logs, backups, and the clone are untouched.")
def do_status(cfg):
log = _logger(True)
print(f"Proctor status @ {cfg}")
hl = hooks_link(cfg)
print(f" hooks link: {'-> ' + os.path.realpath(hl) if os.path.islink(hl) else 'ABSENT'}")
_, s = load_settings(cfg)
for event, _, script, _ in HOOKS:
present = _has_hook(s.get("hooks", {}).get(event, []), f"goal-contract/{script}")
print(f" {event:16} {script:22} {'wired' if present else 'MISSING'}")
def state(p):
return "linked" if os.path.islink(p) else "present (copy)" if os.path.exists(p) else "MISSING"
for name, _ in SKILLS:
print(f" skill {name:20} {state(os.path.join(cfg, 'skills', name))}")
for cmd_name, _ in COMMANDS:
print(f" command {cmd_name:16} {state(os.path.join(cfg, 'commands', cmd_name))}")
print(f" proctor CLI {state(BIN)}")
damage_control_status(cfg, log)
def _logger(dry):
return lambda m: print(m)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config-dir", default=os.environ.get("CLAUDE_CONFIG_DIR")
or os.path.expanduser("~/.claude"))
ap.add_argument("--uninstall", action="store_true")
ap.add_argument("--status", action="store_true")
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
cfg = os.path.abspath(os.path.expanduser(a.config_dir))
os.makedirs(cfg, exist_ok=True)
if a.status:
do_status(cfg)
elif a.uninstall:
do_uninstall(cfg, a.dry_run)
else:
do_install(cfg, a.dry_run)
if __name__ == "__main__":
main()