getlogbook 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- getlogbook/__init__.py +2 -0
- getlogbook/__main__.py +11 -0
- getlogbook/capture.py +372 -0
- getlogbook/cli.py +471 -0
- getlogbook/demo.py +205 -0
- getlogbook/entry.py +85 -0
- getlogbook/git.py +144 -0
- getlogbook/hookmain.py +22 -0
- getlogbook/recall.py +138 -0
- getlogbook/safety.py +79 -0
- getlogbook/server.py +126 -0
- getlogbook/store.py +315 -0
- getlogbook/sync.py +115 -0
- getlogbook/tree.py +286 -0
- getlogbook/viewer.html +373 -0
- getlogbook-0.0.1.dist-info/METADATA +91 -0
- getlogbook-0.0.1.dist-info/RECORD +21 -0
- getlogbook-0.0.1.dist-info/WHEEL +5 -0
- getlogbook-0.0.1.dist-info/entry_points.txt +2 -0
- getlogbook-0.0.1.dist-info/licenses/LICENSE +21 -0
- getlogbook-0.0.1.dist-info/top_level.txt +1 -0
getlogbook/__init__.py
ADDED
getlogbook/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("capture", "post-commit"):
|
|
4
|
+
# hook fast path: no argparse, no CLI imports (saves ~100 ms per invocation)
|
|
5
|
+
from .hookmain import run
|
|
6
|
+
|
|
7
|
+
raise SystemExit(run(sys.argv[1:]))
|
|
8
|
+
|
|
9
|
+
from .cli import main
|
|
10
|
+
|
|
11
|
+
raise SystemExit(main())
|
getlogbook/capture.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Capture: hook entry point, spool, reconcile, git post-commit floor, hook installers.
|
|
2
|
+
|
|
3
|
+
Design rules (from the review panels):
|
|
4
|
+
* A hook only writes a small spool file and (maybe) spawns a detached worker. No model, no network, < 100 ms.
|
|
5
|
+
* A hook never fails the agent: everything is caught, logged to errors.log, exit code 0.
|
|
6
|
+
* `reconcile()` is the one sweep: spool -> transcript slices -> drafts; crash recovery; commits with no entry.
|
|
7
|
+
* Drafts built without a model are provenance=inferred and are never injected (no 'why').
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import git as g
|
|
20
|
+
from .entry import BODY_FIELDS, Entry, from_json, new_id
|
|
21
|
+
from .store import Repo, atomic_write
|
|
22
|
+
|
|
23
|
+
MARK_BEGIN, MARK_END = "# >>> logbook >>>", "# <<< logbook <<<"
|
|
24
|
+
CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "PreCompact", "SessionEnd"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def now():
|
|
28
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---- entries made by people/agents ---------------------------------------------------------------------------
|
|
32
|
+
def make_entry(repo, kind, topic, body, paths=None, provenance="agent-written", via="cli", pending=False, supersedes=()):
|
|
33
|
+
from . import tree
|
|
34
|
+
body = {k: str(v)[:2000] for k, v in body.items() if k in BODY_FIELDS and v}
|
|
35
|
+
e = Entry(id=new_id(), kind=kind, topic=topic[:200], body=body, provenance=provenance, captured_via=via,
|
|
36
|
+
supersedes=list(supersedes), created=now(),
|
|
37
|
+
display=repo.cfg().get("display", "") if repo.attribution() == "named" else "")
|
|
38
|
+
if pending: # change entries are written before the commit exists; post-commit attaches the sha
|
|
39
|
+
e.anchor = {"paths": list(paths or [])}
|
|
40
|
+
atomic_write(repo.cdir / "pending" / f"{e.id}.json", e.to_json())
|
|
41
|
+
else:
|
|
42
|
+
e.anchor = tree.anchor_for(repo, paths=paths)
|
|
43
|
+
repo.add_private(e)
|
|
44
|
+
return e
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def post_commit(repo):
|
|
48
|
+
"""Attach pending change entries to the commit that was just made. Infers nothing here (reconcile does)."""
|
|
49
|
+
from . import tree
|
|
50
|
+
n = 0
|
|
51
|
+
pdir = repo.cdir / "pending"
|
|
52
|
+
for p in sorted(pdir.glob("*.json")) if pdir.exists() else []:
|
|
53
|
+
try:
|
|
54
|
+
e = from_json(p.read_text(encoding="utf-8"))
|
|
55
|
+
e.anchor = tree.anchor_for(repo) # paths come from the commit itself
|
|
56
|
+
repo.add_private(e)
|
|
57
|
+
p.unlink()
|
|
58
|
+
n += 1
|
|
59
|
+
except (OSError, ValueError) as ex:
|
|
60
|
+
repo.error(f"post-commit {p.name}: {ex}")
|
|
61
|
+
return n
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---- spool + detached worker ---------------------------------------------------------------------------------
|
|
65
|
+
@contextlib.contextmanager
|
|
66
|
+
def _lock(path, timeout=1.0):
|
|
67
|
+
"""Tiny cross-platform lock (O_EXCL file, stale after 10 s). Yields False if it could not be taken."""
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
end, fd = time.time() + timeout, None
|
|
70
|
+
while time.time() < end:
|
|
71
|
+
try:
|
|
72
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
73
|
+
break
|
|
74
|
+
except FileExistsError:
|
|
75
|
+
try:
|
|
76
|
+
if time.time() - path.stat().st_mtime > 10:
|
|
77
|
+
path.unlink()
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
time.sleep(0.02)
|
|
81
|
+
try:
|
|
82
|
+
yield fd is not None
|
|
83
|
+
finally:
|
|
84
|
+
if fd is not None:
|
|
85
|
+
os.close(fd)
|
|
86
|
+
try:
|
|
87
|
+
path.unlink()
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def spawn_detached(args, cwd):
|
|
93
|
+
if os.environ.get("LOGBOOK_NO_SPAWN"): # tests
|
|
94
|
+
return
|
|
95
|
+
kw = dict(stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, cwd=str(cwd))
|
|
96
|
+
if os.name == "nt":
|
|
97
|
+
flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
|
98
|
+
try: # escape the agent's job object if allowed, otherwise fall back
|
|
99
|
+
subprocess.Popen(args, creationflags=flags | 0x01000000, **kw) # CREATE_BREAKAWAY_FROM_JOB
|
|
100
|
+
except OSError:
|
|
101
|
+
subprocess.Popen(args, creationflags=flags, **kw)
|
|
102
|
+
else:
|
|
103
|
+
subprocess.Popen(args, start_new_session=True, **kw)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def spool_event(repo, event, agent, payload):
|
|
107
|
+
head, branch, paths, _ = g.state(repo.root) # one subprocess
|
|
108
|
+
item = {"event": event, "agent": agent, "t": time.time(), "head": head or "", "branch": branch,
|
|
109
|
+
"paths": paths[:40], "session": str(payload.get("session_id", ""))[:80], "transcript": str(payload.get("transcript_path", "")),
|
|
110
|
+
"prompt": str(payload.get("prompt", ""))[:2000], "last": str(payload.get("last_assistant_message", ""))[:4000]}
|
|
111
|
+
atomic_write(repo.cdir / "spool" / f"{int(time.time() * 1000):013d}-{os.urandom(3).hex()}.json", json.dumps(item))
|
|
112
|
+
if item["session"] and item["transcript"]: # copy the new bytes now: agents prune old transcripts
|
|
113
|
+
checkpoint(repo, item["session"], item["transcript"])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def handle_hook(event, agent, raw):
|
|
117
|
+
"""Entry point for `logbook capture`. Returns text for stdout (may be ''). Never raises."""
|
|
118
|
+
repo = None
|
|
119
|
+
try:
|
|
120
|
+
payload = json.loads(raw) if raw and raw.strip() else {}
|
|
121
|
+
if not isinstance(payload, dict):
|
|
122
|
+
payload = {}
|
|
123
|
+
repo = Repo(payload.get("cwd") or ".")
|
|
124
|
+
cfg = repo.cfg()
|
|
125
|
+
if not cfg.get("user"):
|
|
126
|
+
return ""
|
|
127
|
+
out = ""
|
|
128
|
+
if event in ("SessionStart", "UserPromptSubmit", "PreToolUse"):
|
|
129
|
+
from . import recall # only the prompt-time hooks need retrieval; Stop/SessionEnd stay lean
|
|
130
|
+
files = []
|
|
131
|
+
ti = payload.get("tool_input")
|
|
132
|
+
if event == "PreToolUse" and isinstance(ti, dict) and ti.get("file_path"):
|
|
133
|
+
try:
|
|
134
|
+
files = [Path(ti["file_path"]).resolve().relative_to(repo.root.resolve()).as_posix()]
|
|
135
|
+
except (ValueError, OSError):
|
|
136
|
+
files = []
|
|
137
|
+
out = recall.hook_json(event, recall.digest(repo, event, payload.get("prompt", ""), files, str(payload.get("session_id", ""))))
|
|
138
|
+
if event in ("Stop", "PreCompact", "SessionEnd"): # prompt-time hooks stay read-only and fast
|
|
139
|
+
spool_event(repo, event, agent, payload)
|
|
140
|
+
if event in ("SessionStart", "PreCompact", "SessionEnd"):
|
|
141
|
+
spawn_detached([cfg.get("interpreter") or sys.executable, "-m", "getlogbook", "reconcile", "--quiet"], repo.root)
|
|
142
|
+
return out
|
|
143
|
+
except Exception as ex: # a hook must never break the agent
|
|
144
|
+
try:
|
|
145
|
+
(repo.error if repo else (lambda m: None))(f"capture {event}: {ex!r}")
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---- reconcile -----------------------------------------------------------------------------------------------
|
|
152
|
+
def _msg(obj):
|
|
153
|
+
m = obj.get("message") if isinstance(obj.get("message"), dict) else obj
|
|
154
|
+
role = m.get("role") or obj.get("type") or ""
|
|
155
|
+
c = m.get("content")
|
|
156
|
+
if isinstance(c, list):
|
|
157
|
+
c = " ".join(p.get("text", "") for p in c if isinstance(p, dict) and p.get("type") == "text")
|
|
158
|
+
return role, c.strip() if isinstance(c, str) else ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _sid(sid):
|
|
162
|
+
return "".join(c for c in sid if c.isalnum() or c in "-_")[:80] or "nosession"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _load(d):
|
|
166
|
+
try:
|
|
167
|
+
st = json.loads((d / "state.json").read_text(encoding="utf-8"))
|
|
168
|
+
except (OSError, ValueError):
|
|
169
|
+
st = {}
|
|
170
|
+
st.setdefault("offset", 0)
|
|
171
|
+
st.setdefault("paths", [])
|
|
172
|
+
st.setdefault("summarized", 0)
|
|
173
|
+
return st
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _copy_slice(d, st, transcript):
|
|
177
|
+
if transcript and Path(transcript).is_file():
|
|
178
|
+
size = Path(transcript).stat().st_size
|
|
179
|
+
if size < st["offset"]:
|
|
180
|
+
st["offset"] = 0
|
|
181
|
+
if size > st["offset"]:
|
|
182
|
+
with open(transcript, "rb") as f:
|
|
183
|
+
f.seek(st["offset"])
|
|
184
|
+
data = f.read()
|
|
185
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
(d / f"slice-{st['offset']:012d}.jsonl").write_bytes(data)
|
|
187
|
+
st["offset"] = size
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def checkpoint(repo, sid, transcript, paths=(), meta=None):
|
|
191
|
+
"""Copy the transcript bytes added since the last checkpoint. Cheap; safe to call from a hook."""
|
|
192
|
+
d = repo.cdir / "sessions" / _sid(sid)
|
|
193
|
+
with _lock(d / ".lock") as got:
|
|
194
|
+
if not got:
|
|
195
|
+
return None
|
|
196
|
+
st = _load(d)
|
|
197
|
+
_copy_slice(d, st, transcript)
|
|
198
|
+
st["paths"] = sorted(set(st["paths"]) | set(paths))[:200]
|
|
199
|
+
st["last"] = time.time()
|
|
200
|
+
st.update(meta or {})
|
|
201
|
+
atomic_write(d / "state.json", json.dumps(st))
|
|
202
|
+
return d, st
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _finalize(repo, sid, d, st, agent, head, branch):
|
|
206
|
+
from . import tree
|
|
207
|
+
texts = []
|
|
208
|
+
for sl in sorted(d.glob("slice-*.jsonl")):
|
|
209
|
+
if int(sl.stem.split("-")[1]) < st["summarized"]:
|
|
210
|
+
continue
|
|
211
|
+
for line in sl.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
212
|
+
try:
|
|
213
|
+
role, text = _msg(json.loads(line))
|
|
214
|
+
except ValueError:
|
|
215
|
+
continue
|
|
216
|
+
if text:
|
|
217
|
+
texts.append((role, text))
|
|
218
|
+
assistant = [t for r, t in texts if r == "assistant"]
|
|
219
|
+
user = [t for r, t in texts if r == "user"]
|
|
220
|
+
if not assistant:
|
|
221
|
+
return None
|
|
222
|
+
st["summarized"] = st["offset"]
|
|
223
|
+
first_line = lambda s, n: (s.splitlines() or [""])[0][:n]
|
|
224
|
+
e = Entry(id=new_id(), kind="finding", topic=(first_line(user[0], 80) if user else f"session {sid[:8]}"),
|
|
225
|
+
body={"summary": assistant[-1][:300], "what": "\n".join("- " + first_line(a, 120) for a in assistant[:5])},
|
|
226
|
+
provenance="inferred", captured_via=agent or "agent", created=now())
|
|
227
|
+
a = tree.anchor_for(repo, sha=head or None, paths=st["paths"][:40], dirty=bool(st["paths"]))
|
|
228
|
+
a["branch"] = branch or a["branch"]
|
|
229
|
+
e.anchor = a
|
|
230
|
+
repo.add_private(e)
|
|
231
|
+
return e
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def reconcile(repo, quiet=True):
|
|
235
|
+
"""Process spooled hook events, recover crashed sessions, and record commits that have no entry."""
|
|
236
|
+
done = {"drafts": 0, "floor": 0, "pending": 0}
|
|
237
|
+
sessions = {}
|
|
238
|
+
spool = repo.cdir / "spool"
|
|
239
|
+
for p in sorted(spool.glob("*.json")) if spool.exists() else []:
|
|
240
|
+
try:
|
|
241
|
+
it = json.loads(p.read_text(encoding="utf-8"))
|
|
242
|
+
except (OSError, ValueError):
|
|
243
|
+
p.unlink(missing_ok=True)
|
|
244
|
+
continue
|
|
245
|
+
sid = it.get("session") or "nosession"
|
|
246
|
+
s = sessions.setdefault(sid, {"agent": it.get("agent", ""), "transcript": "", "final": False, "head": "", "branch": "", "paths": set()})
|
|
247
|
+
s["transcript"] = it.get("transcript") or s["transcript"]
|
|
248
|
+
s["head"], s["branch"] = it.get("head") or s["head"], it.get("branch") or s["branch"]
|
|
249
|
+
s["paths"] |= set(it.get("paths", []))
|
|
250
|
+
s["final"] |= it.get("event") in ("PreCompact", "SessionEnd")
|
|
251
|
+
p.unlink(missing_ok=True)
|
|
252
|
+
for sid, s in sessions.items():
|
|
253
|
+
safe = _sid(sid)
|
|
254
|
+
got = checkpoint(repo, safe, s["transcript"], s["paths"], {"agent": s["agent"], "head": s["head"], "branch": s["branch"]})
|
|
255
|
+
if got and s["final"]:
|
|
256
|
+
d = got[0]
|
|
257
|
+
with _lock(d / ".lock") as ok:
|
|
258
|
+
st = _load(d)
|
|
259
|
+
if ok and _finalize(repo, safe, d, st, s["agent"], s["head"], s["branch"]):
|
|
260
|
+
done["drafts"] += 1
|
|
261
|
+
st["paths"] = []
|
|
262
|
+
if ok:
|
|
263
|
+
atomic_write(d / "state.json", json.dumps(st))
|
|
264
|
+
# crash recovery: sessions with unsummarised bytes and no activity for 30 minutes
|
|
265
|
+
sdir = repo.cdir / "sessions"
|
|
266
|
+
for d in sdir.iterdir() if sdir.exists() else []:
|
|
267
|
+
if not d.is_dir():
|
|
268
|
+
continue
|
|
269
|
+
with _lock(d / ".lock") as ok:
|
|
270
|
+
st = _load(d)
|
|
271
|
+
if ok and st["offset"] > st["summarized"] and time.time() - st.get("last", 0) > 1800:
|
|
272
|
+
if _finalize(repo, d.name, d, st, st.get("agent", ""), st.get("head", ""), st.get("branch", "")):
|
|
273
|
+
done["drafts"] += 1
|
|
274
|
+
atomic_write(d / "state.json", json.dumps(st))
|
|
275
|
+
done["pending"] = post_commit(repo) if (repo.cdir / "pending").exists() else 0
|
|
276
|
+
done["floor"] = _commit_floor(repo)
|
|
277
|
+
return done
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _commit_floor(repo):
|
|
281
|
+
"""Inferred entries for my own commits since init that have no entry yet (covers web-UI/hook-less commits)."""
|
|
282
|
+
from . import tree
|
|
283
|
+
base = repo.cfg().get("floor_base")
|
|
284
|
+
if not base or not g.commit_exists(repo.root, base):
|
|
285
|
+
return 0
|
|
286
|
+
email = g.config_get(repo.root, "user.email")
|
|
287
|
+
rc, out, _ = g.try_run(["log", "--first-parent", "-n50", f"--author={email}", "--format=%H", f"{base}..HEAD"], repo.root)
|
|
288
|
+
covered = {l.entry.anchor.get("sha") for l in repo.all_entries()}
|
|
289
|
+
n = 0
|
|
290
|
+
for sha in (out.split() if rc == 0 else []):
|
|
291
|
+
if sha in covered:
|
|
292
|
+
continue
|
|
293
|
+
info = g.commit_info(repo.root, sha) or {}
|
|
294
|
+
paths = g.changed_paths(repo.root, sha)
|
|
295
|
+
body = g.try_run(["show", "-s", "--format=%b", sha], repo.root)[1].strip()
|
|
296
|
+
layers = sorted({l for l in (tree.layer_of(p) for p in paths) if l})
|
|
297
|
+
e = Entry(id=new_id(), kind="change", topic=info.get("subject", "")[:200],
|
|
298
|
+
body={"summary": info.get("subject", ""), "what": (body + f"\nfiles: {len(paths)}" +
|
|
299
|
+
(f" ({', '.join(layers)})" if layers else "")).strip()},
|
|
300
|
+
provenance="inferred", captured_via="reconcile", created=now())
|
|
301
|
+
e.anchor = tree.anchor_for(repo, sha=sha, paths=paths)
|
|
302
|
+
repo.add_private(e)
|
|
303
|
+
n += 1
|
|
304
|
+
return n
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---- installers ---------------------------------------------------------------------------------------------
|
|
308
|
+
def _py(repo):
|
|
309
|
+
return (repo.cfg().get("interpreter") or sys.executable).replace("\\", "/")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def install_git_hook(repo):
|
|
313
|
+
hooks = Path(g.run(["rev-parse", "--path-format=absolute", "--git-path", "hooks"], repo.root).strip())
|
|
314
|
+
hooks.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
f = hooks / "post-commit"
|
|
316
|
+
block = f'{MARK_BEGIN}\n"{_py(repo)}" -m getlogbook post-commit >/dev/null 2>&1 || true\n{MARK_END}\n'
|
|
317
|
+
text = f.read_text(encoding="utf-8") if f.exists() else "#!/bin/sh\n"
|
|
318
|
+
if MARK_BEGIN not in text: # chain, never overwrite
|
|
319
|
+
f.write_text(text.rstrip("\n") + "\n" + block, encoding="utf-8", newline="\n")
|
|
320
|
+
try:
|
|
321
|
+
f.chmod(f.stat().st_mode | 0o111)
|
|
322
|
+
except OSError:
|
|
323
|
+
pass
|
|
324
|
+
c = repo.cfg()
|
|
325
|
+
c.setdefault("floor_base", g.head_sha(repo.root) or "")
|
|
326
|
+
repo.save_cfg(c)
|
|
327
|
+
return f
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def uninstall_git_hook(repo):
|
|
331
|
+
hooks = Path(g.run(["rev-parse", "--path-format=absolute", "--git-path", "hooks"], repo.root).strip())
|
|
332
|
+
f = hooks / "post-commit"
|
|
333
|
+
if f.exists() and MARK_BEGIN in f.read_text(encoding="utf-8"):
|
|
334
|
+
t = f.read_text(encoding="utf-8")
|
|
335
|
+
a, _, rest = t.partition(MARK_BEGIN)
|
|
336
|
+
_, _, b = rest.partition(MARK_END)
|
|
337
|
+
f.write_text((a + b.lstrip("\n")).rstrip("\n") + "\n", encoding="utf-8", newline="\n")
|
|
338
|
+
return f
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def claude_snippet(repo, pretool=False):
|
|
342
|
+
py = _py(repo)
|
|
343
|
+
mk = lambda ev: [{"hooks": [{"type": "command", "command": f'"{py}" -m getlogbook capture --event {ev} --agent claude-code'}]}]
|
|
344
|
+
hooks = {ev: mk(ev) for ev in CLAUDE_EVENTS}
|
|
345
|
+
if pretool:
|
|
346
|
+
hooks["PreToolUse"] = [{"matcher": "Edit|Write|MultiEdit", **mk("PreToolUse")[0]}]
|
|
347
|
+
return {"hooks": hooks}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def install_claude(repo, path=None, pretool=False, write=False, remove=False):
|
|
351
|
+
path = Path(path or repo.root / ".claude" / "settings.local.json") # local: it holds a machine-specific interpreter path
|
|
352
|
+
try:
|
|
353
|
+
cur = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
354
|
+
except ValueError:
|
|
355
|
+
raise SystemExit(f"logbook: {path} is not valid JSON; refusing to touch it")
|
|
356
|
+
hooks = cur.setdefault("hooks", {})
|
|
357
|
+
snippet = claude_snippet(repo, pretool)["hooks"]
|
|
358
|
+
for ev, entries in list(hooks.items()): # always drop our previous entries so install is idempotent
|
|
359
|
+
keep = [e for e in entries if "getlogbook capture" not in json.dumps(e)]
|
|
360
|
+
if keep:
|
|
361
|
+
hooks[ev] = keep
|
|
362
|
+
else:
|
|
363
|
+
del hooks[ev]
|
|
364
|
+
if not remove:
|
|
365
|
+
for ev, entries in snippet.items():
|
|
366
|
+
hooks.setdefault(ev, []).extend(entries)
|
|
367
|
+
if not hooks:
|
|
368
|
+
cur.pop("hooks")
|
|
369
|
+
if write:
|
|
370
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
atomic_write(path, json.dumps(cur, indent=2))
|
|
372
|
+
return path, cur
|