agent-trace-cli 0.1.0__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.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code adapter — hooks injection, event translators, summary
|
|
3
|
+
lifecycle, and the Claude-specific session-model tracking helpers.
|
|
4
|
+
|
|
5
|
+
Claude's ``PostToolUse`` hook payload doesn't include a model id, so we
|
|
6
|
+
keep a per-session model cache:
|
|
7
|
+
- ``SessionStart`` writes the model into the cache
|
|
8
|
+
- ``Stop`` / ``stop`` runs the pre-summary hook to refresh the cache
|
|
9
|
+
from the transcript tail (handles mid-session ``/model`` changes)
|
|
10
|
+
- ``PostToolUse`` reads the cached model
|
|
11
|
+
|
|
12
|
+
All of this lives here, not in ``record.py``, so adding a new agent
|
|
13
|
+
never requires touching Claude code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .base import AGENT_TRACE_CMD, CodingAgentAdapter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CLAUDE_SETTINGS_FILE = ".claude/settings.json"
|
|
26
|
+
CLAUDE_GLOBAL_SETTINGS_FILE = Path.home() / ".claude" / "settings.json"
|
|
27
|
+
|
|
28
|
+
# Claude session JSONL can be large; only scan the tail for the latest
|
|
29
|
+
# ``message.model`` field.
|
|
30
|
+
_TRANSCRIPT_TAIL_BYTES = 512 * 1024
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------
|
|
34
|
+
# Claude session-model cache (persisted under
|
|
35
|
+
# ``<AGENT_TRACE_HOME>/projects/<id>/session-state.json`` as
|
|
36
|
+
# ``model:<session_id>``).
|
|
37
|
+
# ---------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _remember_session_model(session_id: str, model: str | None, project_dir: str | None = None) -> None:
|
|
40
|
+
from ..storage import (
|
|
41
|
+
ensure_project_dir,
|
|
42
|
+
get_session_state_path,
|
|
43
|
+
resolve_project_id,
|
|
44
|
+
)
|
|
45
|
+
from ..trace import get_workspace_root
|
|
46
|
+
|
|
47
|
+
if not session_id or not model:
|
|
48
|
+
return
|
|
49
|
+
if project_dir is None:
|
|
50
|
+
project_dir = get_workspace_root()
|
|
51
|
+
pid = resolve_project_id(project_dir, create=True)
|
|
52
|
+
if not pid:
|
|
53
|
+
return
|
|
54
|
+
ensure_project_dir(pid)
|
|
55
|
+
state_path = get_session_state_path(pid)
|
|
56
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
state: dict = {}
|
|
58
|
+
if state_path.exists():
|
|
59
|
+
try:
|
|
60
|
+
state = json.loads(state_path.read_text())
|
|
61
|
+
except (json.JSONDecodeError, OSError):
|
|
62
|
+
state = {}
|
|
63
|
+
state[f"model:{session_id}"] = model
|
|
64
|
+
try:
|
|
65
|
+
state_path.write_text(json.dumps(state))
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_transcript_tail_text(path: Path, max_bytes: int) -> str:
|
|
71
|
+
try:
|
|
72
|
+
size = path.stat().st_size
|
|
73
|
+
if size <= max_bytes:
|
|
74
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
75
|
+
with open(path, "rb") as f:
|
|
76
|
+
f.seek(max(0, size - max_bytes))
|
|
77
|
+
f.readline() # discard incomplete first line
|
|
78
|
+
return f.read().decode("utf-8", errors="replace")
|
|
79
|
+
except OSError:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _extract_model_from_transcript_obj(obj: object, depth: int = 0) -> str | None:
|
|
84
|
+
if depth > 8 or not isinstance(obj, dict):
|
|
85
|
+
return None
|
|
86
|
+
msg = obj.get("message")
|
|
87
|
+
if isinstance(msg, dict):
|
|
88
|
+
for key in ("model", "modelId", "model_id"):
|
|
89
|
+
v = msg.get(key)
|
|
90
|
+
if isinstance(v, str) and v.strip():
|
|
91
|
+
return v.strip()
|
|
92
|
+
inner = _extract_model_from_transcript_obj(msg, depth + 1)
|
|
93
|
+
if inner:
|
|
94
|
+
return inner
|
|
95
|
+
for key in ("model", "modelId", "model_id"):
|
|
96
|
+
v = obj.get(key)
|
|
97
|
+
if isinstance(v, str) and v.strip():
|
|
98
|
+
return v.strip()
|
|
99
|
+
for nest in ("session", "metadata", "config"):
|
|
100
|
+
inner = obj.get(nest)
|
|
101
|
+
if isinstance(inner, dict):
|
|
102
|
+
got = _extract_model_from_transcript_obj(inner, depth + 1)
|
|
103
|
+
if got:
|
|
104
|
+
return got
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _model_from_transcript_tail(
|
|
109
|
+
transcript_path: str | None,
|
|
110
|
+
*,
|
|
111
|
+
max_bytes: int = _TRANSCRIPT_TAIL_BYTES,
|
|
112
|
+
) -> str | None:
|
|
113
|
+
"""Latest model id seen in a Claude transcript JSONL file."""
|
|
114
|
+
if not transcript_path:
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
p = Path(transcript_path)
|
|
118
|
+
if not p.is_file():
|
|
119
|
+
return None
|
|
120
|
+
text = _read_transcript_tail_text(p, max_bytes)
|
|
121
|
+
if not text:
|
|
122
|
+
return None
|
|
123
|
+
for line in reversed(text.splitlines()):
|
|
124
|
+
line = line.strip()
|
|
125
|
+
if not line.startswith("{"):
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
obj = json.loads(line)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
continue
|
|
131
|
+
m = _extract_model_from_transcript_obj(obj)
|
|
132
|
+
if m:
|
|
133
|
+
return m
|
|
134
|
+
return None
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _session_model_from_state(session_id: str, project_dir: str | None = None) -> str | None:
|
|
140
|
+
from ..storage import get_session_state_path, resolve_project_id
|
|
141
|
+
from ..trace import get_workspace_root
|
|
142
|
+
|
|
143
|
+
if not session_id:
|
|
144
|
+
return None
|
|
145
|
+
if project_dir is None:
|
|
146
|
+
project_dir = get_workspace_root()
|
|
147
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
148
|
+
if not pid:
|
|
149
|
+
return None
|
|
150
|
+
state_path = get_session_state_path(pid)
|
|
151
|
+
if not state_path.exists():
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
state = json.loads(state_path.read_text())
|
|
155
|
+
except (json.JSONDecodeError, OSError):
|
|
156
|
+
return None
|
|
157
|
+
v = state.get(f"model:{session_id}")
|
|
158
|
+
return str(v) if v else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _claude_model(d: dict, project_dir: str | None) -> str | None:
|
|
162
|
+
"""Resolve model: hook fields, then transcript, then cached SessionStart."""
|
|
163
|
+
from ..record import transcript_path_from_hook
|
|
164
|
+
|
|
165
|
+
m = d.get("model") or d.get("model_id")
|
|
166
|
+
if m:
|
|
167
|
+
return str(m)
|
|
168
|
+
sid = d.get("session_id") or ""
|
|
169
|
+
m = _model_from_transcript_tail(transcript_path_from_hook(d))
|
|
170
|
+
if m:
|
|
171
|
+
if sid and project_dir:
|
|
172
|
+
prev = _session_model_from_state(sid, project_dir)
|
|
173
|
+
if prev != m:
|
|
174
|
+
_remember_session_model(sid, m, project_dir)
|
|
175
|
+
return m
|
|
176
|
+
return _session_model_from_state(sid, project_dir)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------
|
|
180
|
+
# Event translators (hook payload → trace record)
|
|
181
|
+
# ---------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _PostToolUse(d):
|
|
184
|
+
from ..record import (
|
|
185
|
+
_file_existed_before,
|
|
186
|
+
_get_next_sequence,
|
|
187
|
+
_ranges_from_edit,
|
|
188
|
+
_ranges_from_multiedit,
|
|
189
|
+
_ranges_from_notebook,
|
|
190
|
+
_ranges_from_write,
|
|
191
|
+
transcript_path_from_hook,
|
|
192
|
+
)
|
|
193
|
+
from ..trace import create_trace, resolve_file_project
|
|
194
|
+
|
|
195
|
+
tn = d.get("tool_name", "")
|
|
196
|
+
if tn == "Bash":
|
|
197
|
+
ti = d.get("tool_input", {})
|
|
198
|
+
session_id = d.get("session_id") or ""
|
|
199
|
+
anchor = ti.get("cwd") or d.get("cwd") or os.getcwd()
|
|
200
|
+
res = resolve_file_project(".shell-history", anchor_path=anchor)
|
|
201
|
+
seq = _get_next_sequence(session_id, res.repo_root if res else None) if session_id else None
|
|
202
|
+
return create_trace(
|
|
203
|
+
"ai", ".shell-history",
|
|
204
|
+
model=_claude_model(d, res.repo_root if res else None),
|
|
205
|
+
transcript=transcript_path_from_hook(d),
|
|
206
|
+
metadata={
|
|
207
|
+
"session_id": d.get("session_id"),
|
|
208
|
+
"tool_name": tn,
|
|
209
|
+
"tool_use_id": d.get("tool_use_id"),
|
|
210
|
+
"command": ti.get("command"),
|
|
211
|
+
},
|
|
212
|
+
edit_sequence=seq,
|
|
213
|
+
anchor_path=anchor,
|
|
214
|
+
resolution=res,
|
|
215
|
+
), "PostToolUse"
|
|
216
|
+
|
|
217
|
+
ti = d.get("tool_input", {})
|
|
218
|
+
fp = ti.get("file_path") or ti.get("notebook_path") or ".unknown"
|
|
219
|
+
anchor = d.get("cwd") or os.getcwd()
|
|
220
|
+
|
|
221
|
+
if tn == "Write":
|
|
222
|
+
existed = _file_existed_before(fp)
|
|
223
|
+
rp, rc = _ranges_from_write(fp, ti.get("content", ""))
|
|
224
|
+
meta_extra: dict = {"is_creation": not existed}
|
|
225
|
+
elif tn == "Edit":
|
|
226
|
+
rp, rc = _ranges_from_edit(fp, ti.get("old_string", ""), ti.get("new_string", ""))
|
|
227
|
+
meta_extra = {}
|
|
228
|
+
elif tn == "MultiEdit":
|
|
229
|
+
rp, rc = _ranges_from_multiedit(fp, ti.get("edits", []))
|
|
230
|
+
meta_extra = {}
|
|
231
|
+
elif tn == "NotebookEdit":
|
|
232
|
+
fp = ti.get("notebook_path", fp)
|
|
233
|
+
rp, rc = _ranges_from_notebook(fp, ti)
|
|
234
|
+
cell_id = ti.get("cell_id")
|
|
235
|
+
meta_extra = {"cell_id": cell_id} if cell_id is not None else {}
|
|
236
|
+
else:
|
|
237
|
+
return None, "PostToolUse"
|
|
238
|
+
|
|
239
|
+
if rp is None or rc is None:
|
|
240
|
+
return None, "PostToolUse"
|
|
241
|
+
|
|
242
|
+
session_id = d.get("session_id") or ""
|
|
243
|
+
res = resolve_file_project(fp, anchor_path=anchor)
|
|
244
|
+
seq = _get_next_sequence(session_id, res.repo_root if res else None) if session_id else None
|
|
245
|
+
|
|
246
|
+
metadata: dict = {
|
|
247
|
+
"session_id": d.get("session_id"),
|
|
248
|
+
"tool_name": tn,
|
|
249
|
+
"tool_use_id": d.get("tool_use_id"),
|
|
250
|
+
}
|
|
251
|
+
metadata = {k: v for k, v in metadata.items() if v is not None}
|
|
252
|
+
metadata.update(meta_extra)
|
|
253
|
+
|
|
254
|
+
return create_trace(
|
|
255
|
+
"ai", fp,
|
|
256
|
+
model=_claude_model(d, res.repo_root if res else None),
|
|
257
|
+
range_positions=rp,
|
|
258
|
+
range_contents=rc,
|
|
259
|
+
transcript=transcript_path_from_hook(d),
|
|
260
|
+
metadata=metadata,
|
|
261
|
+
edit_sequence=seq,
|
|
262
|
+
anchor_path=anchor,
|
|
263
|
+
resolution=res,
|
|
264
|
+
), "PostToolUse"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _SessionStart(d):
|
|
268
|
+
from ..trace import create_trace, resolve_file_project
|
|
269
|
+
|
|
270
|
+
anchor = d.get("cwd") or os.getcwd()
|
|
271
|
+
res = resolve_file_project(".sessions", anchor_path=anchor)
|
|
272
|
+
repo = res.repo_root if res else None
|
|
273
|
+
m = d.get("model") or d.get("model_id")
|
|
274
|
+
if d.get("session_id") and m and repo:
|
|
275
|
+
_remember_session_model(str(d["session_id"]), str(m), repo)
|
|
276
|
+
return create_trace(
|
|
277
|
+
"ai", ".sessions",
|
|
278
|
+
model=_claude_model(d, repo),
|
|
279
|
+
metadata={
|
|
280
|
+
"event": "session_start",
|
|
281
|
+
"session_id": d.get("session_id"),
|
|
282
|
+
"source": d.get("source"),
|
|
283
|
+
},
|
|
284
|
+
anchor_path=anchor,
|
|
285
|
+
resolution=res,
|
|
286
|
+
), "SessionStart"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _SessionEnd(d):
|
|
290
|
+
from ..trace import create_trace, resolve_file_project
|
|
291
|
+
|
|
292
|
+
anchor = d.get("cwd") or os.getcwd()
|
|
293
|
+
res = resolve_file_project(".sessions", anchor_path=anchor)
|
|
294
|
+
repo = res.repo_root if res else None
|
|
295
|
+
return create_trace(
|
|
296
|
+
"ai", ".sessions",
|
|
297
|
+
model=_claude_model(d, repo),
|
|
298
|
+
metadata={
|
|
299
|
+
"event": "session_end",
|
|
300
|
+
"session_id": d.get("session_id"),
|
|
301
|
+
"reason": d.get("reason"),
|
|
302
|
+
},
|
|
303
|
+
anchor_path=anchor,
|
|
304
|
+
resolution=res,
|
|
305
|
+
), "SessionEnd"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------
|
|
309
|
+
# Adapter
|
|
310
|
+
# ---------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
class ClaudeAdapter(CodingAgentAdapter):
|
|
313
|
+
name = "claude"
|
|
314
|
+
display_name = "Claude Code"
|
|
315
|
+
rules_dir = ".claude/rules"
|
|
316
|
+
rule_extension = ".md"
|
|
317
|
+
|
|
318
|
+
EVENTS = {
|
|
319
|
+
"PostToolUse": _PostToolUse,
|
|
320
|
+
"SessionStart": _SessionStart,
|
|
321
|
+
"SessionEnd": _SessionEnd,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# Claude's ``Stop`` (and lowercase ``stop``) is "the agent finished
|
|
325
|
+
# responding". No trace, but fires the summary command — and we use
|
|
326
|
+
# ``pre_summary_hook`` to refresh the model cache from the transcript
|
|
327
|
+
# in case ``/model`` changed mid-session.
|
|
328
|
+
summary_only_events = ("Stop", "stop")
|
|
329
|
+
|
|
330
|
+
project_dir_env_vars = ("CLAUDE_PROJECT_DIR",)
|
|
331
|
+
|
|
332
|
+
def global_config_path(self) -> Path:
|
|
333
|
+
return CLAUDE_GLOBAL_SETTINGS_FILE
|
|
334
|
+
|
|
335
|
+
def project_config_paths(self) -> tuple[str, ...]:
|
|
336
|
+
return (CLAUDE_SETTINGS_FILE,)
|
|
337
|
+
|
|
338
|
+
def detect_tool_info(self) -> dict | None:
|
|
339
|
+
if os.environ.get("CLAUDE_PROJECT_DIR"):
|
|
340
|
+
return {"name": "claude-code"}
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
def pre_summary_hook(self, data: dict) -> None:
|
|
344
|
+
"""Refresh the per-session model cache from the transcript tail.
|
|
345
|
+
|
|
346
|
+
Runs before summary on ``Stop`` / ``stop`` so ``/model`` changes
|
|
347
|
+
that happened mid-session are reflected on subsequent traces.
|
|
348
|
+
"""
|
|
349
|
+
from ..record import project_dir_from_hook, transcript_path_from_hook
|
|
350
|
+
from ..trace import resolve_file_project
|
|
351
|
+
|
|
352
|
+
sid = data.get("session_id")
|
|
353
|
+
tp = transcript_path_from_hook(data)
|
|
354
|
+
if not sid or not tp:
|
|
355
|
+
return
|
|
356
|
+
anchor = project_dir_from_hook(data)
|
|
357
|
+
res = resolve_file_project(".sessions", anchor_path=anchor)
|
|
358
|
+
repo = res.repo_root if res else None
|
|
359
|
+
if not repo:
|
|
360
|
+
return
|
|
361
|
+
m = _model_from_transcript_tail(tp)
|
|
362
|
+
if not m:
|
|
363
|
+
return
|
|
364
|
+
prev = _session_model_from_state(str(sid), repo)
|
|
365
|
+
if prev != m:
|
|
366
|
+
_remember_session_model(str(sid), m, repo)
|
|
367
|
+
|
|
368
|
+
def inject(
|
|
369
|
+
self,
|
|
370
|
+
record_invocation: str,
|
|
371
|
+
project_dir: str | None = None,
|
|
372
|
+
*,
|
|
373
|
+
global_install: bool = False,
|
|
374
|
+
) -> bool:
|
|
375
|
+
if global_install:
|
|
376
|
+
settings_path = CLAUDE_GLOBAL_SETTINGS_FILE
|
|
377
|
+
else:
|
|
378
|
+
if project_dir is None:
|
|
379
|
+
project_dir = os.getcwd()
|
|
380
|
+
settings_path = Path(project_dir) / CLAUDE_SETTINGS_FILE
|
|
381
|
+
|
|
382
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
|
|
384
|
+
if settings_path.exists():
|
|
385
|
+
try:
|
|
386
|
+
config = json.loads(settings_path.read_text())
|
|
387
|
+
except (json.JSONDecodeError, OSError):
|
|
388
|
+
config = {}
|
|
389
|
+
else:
|
|
390
|
+
config = {}
|
|
391
|
+
|
|
392
|
+
config.setdefault("hooks", {})
|
|
393
|
+
|
|
394
|
+
hook_entry = {"type": "command", "command": record_invocation}
|
|
395
|
+
|
|
396
|
+
for event in ("SessionStart", "SessionEnd"):
|
|
397
|
+
existing = config["hooks"].get(event, [])
|
|
398
|
+
already = any(
|
|
399
|
+
any(record_invocation in h.get("command", "") for h in entry.get("hooks", []))
|
|
400
|
+
for entry in existing
|
|
401
|
+
if isinstance(entry, dict)
|
|
402
|
+
)
|
|
403
|
+
if not already:
|
|
404
|
+
existing.append({"hooks": [hook_entry]})
|
|
405
|
+
config["hooks"][event] = existing
|
|
406
|
+
|
|
407
|
+
post = config["hooks"].get("PostToolUse", [])
|
|
408
|
+
already = any(
|
|
409
|
+
any(record_invocation in h.get("command", "") for h in entry.get("hooks", []))
|
|
410
|
+
for entry in post
|
|
411
|
+
if isinstance(entry, dict)
|
|
412
|
+
)
|
|
413
|
+
if not already:
|
|
414
|
+
config["hooks"]["PostToolUse"] = [
|
|
415
|
+
{"matcher": "Write|Edit", "hooks": [hook_entry]},
|
|
416
|
+
{"matcher": "Bash", "hooks": [hook_entry]},
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
stop = config["hooks"].get("Stop", [])
|
|
420
|
+
already = any(
|
|
421
|
+
any(record_invocation in h.get("command", "") for h in entry.get("hooks", []))
|
|
422
|
+
for entry in stop
|
|
423
|
+
if isinstance(entry, dict)
|
|
424
|
+
)
|
|
425
|
+
if not already:
|
|
426
|
+
stop.append({"hooks": [hook_entry]})
|
|
427
|
+
config["hooks"]["Stop"] = stop
|
|
428
|
+
|
|
429
|
+
settings_path.write_text(json.dumps(config, indent=2) + "\n")
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
def remove(self) -> bool:
|
|
433
|
+
return _remove_agent_trace_from_claude(CLAUDE_GLOBAL_SETTINGS_FILE)
|
|
434
|
+
|
|
435
|
+
def is_installed(self, *, global_only: bool = True) -> bool:
|
|
436
|
+
try:
|
|
437
|
+
raw = CLAUDE_GLOBAL_SETTINGS_FILE.read_text()
|
|
438
|
+
except (OSError, FileNotFoundError):
|
|
439
|
+
return False
|
|
440
|
+
return AGENT_TRACE_CMD in raw
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _remove_agent_trace_from_claude(settings_path: Path) -> bool:
|
|
444
|
+
if not settings_path.is_file():
|
|
445
|
+
return False
|
|
446
|
+
try:
|
|
447
|
+
config = json.loads(settings_path.read_text())
|
|
448
|
+
except (json.JSONDecodeError, OSError):
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
hooks = config.get("hooks")
|
|
452
|
+
if not isinstance(hooks, dict):
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
changed = False
|
|
456
|
+
for event, entries in list(hooks.items()):
|
|
457
|
+
if not isinstance(entries, list):
|
|
458
|
+
continue
|
|
459
|
+
filtered = []
|
|
460
|
+
for entry in entries:
|
|
461
|
+
if not isinstance(entry, dict):
|
|
462
|
+
filtered.append(entry)
|
|
463
|
+
continue
|
|
464
|
+
inner = entry.get("hooks", [])
|
|
465
|
+
if isinstance(inner, list) and any(
|
|
466
|
+
AGENT_TRACE_CMD in h.get("command", "")
|
|
467
|
+
for h in inner if isinstance(h, dict)
|
|
468
|
+
):
|
|
469
|
+
changed = True
|
|
470
|
+
continue
|
|
471
|
+
filtered.append(entry)
|
|
472
|
+
if len(filtered) != len(entries):
|
|
473
|
+
changed = True
|
|
474
|
+
if filtered:
|
|
475
|
+
hooks[event] = filtered
|
|
476
|
+
else:
|
|
477
|
+
del hooks[event]
|
|
478
|
+
|
|
479
|
+
if changed:
|
|
480
|
+
if not hooks:
|
|
481
|
+
del config["hooks"]
|
|
482
|
+
settings_path.write_text(json.dumps(config, indent=2) + "\n")
|
|
483
|
+
return changed
|