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.
Files changed (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,491 @@
1
+ """
2
+ Git notes for agent-trace (``refs/notes/agent-trace``).
3
+
4
+ Attaches JSON metadata per commit with composable sections
5
+ (core, ledger, summary, prompts, all_session_conversations).
6
+ Stdlib only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import tempfile
16
+ from typing import Any, Iterator
17
+
18
+ from .models import Ledger, Trace
19
+
20
+ NOTE_REF = "agent-trace"
21
+
22
+
23
+ def _git(*args: str, cwd: str | None = None) -> str | None:
24
+ try:
25
+ r = subprocess.run(
26
+ ["git", *args],
27
+ capture_output=True,
28
+ text=True,
29
+ cwd=cwd,
30
+ timeout=120,
31
+ )
32
+ if r.returncode == 0:
33
+ return r.stdout.strip()
34
+ except Exception:
35
+ pass
36
+ return None
37
+
38
+
39
+ def resolve_commit(repo_dir: str, rev: str) -> str | None:
40
+ """Resolve ``rev`` (e.g. ``HEAD``) to a full SHA."""
41
+ return _git("rev-parse", rev, cwd=repo_dir)
42
+
43
+
44
+ def ledger_dict_hash(ledger: dict[str, Any]) -> str:
45
+ """Canonical SHA-256 over the ledger JSON (sorted keys)."""
46
+ body = json.dumps(ledger, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
47
+ h = hashlib.sha256(body.encode("utf-8")).hexdigest()
48
+ return f"sha256:{h}"
49
+
50
+
51
+ def _ledger_to_dict(ledger: Ledger | dict[str, Any]) -> dict[str, Any]:
52
+ if isinstance(ledger, Ledger):
53
+ return ledger.to_dict()
54
+ return dict(ledger)
55
+
56
+
57
+ def _stats_from_ledger(ledger: dict[str, Any]) -> dict[str, int]:
58
+ """Schema 2.0: only AI lines are counted. Anything else is implicitly
59
+ NO_ATTRIBUTION and isn't stored, so there's nothing to tally."""
60
+ ai = 0
61
+ for _fp, fl in ledger.get("files", {}).items():
62
+ if not isinstance(fl, dict):
63
+ continue
64
+ for seg in fl.get("line_attributions", []):
65
+ if not isinstance(seg, dict):
66
+ continue
67
+ if str(seg.get("type", "")).lower() != "ai":
68
+ continue
69
+ start = int(seg.get("start_line", 0))
70
+ end = int(seg.get("end_line", 0))
71
+ n = max(0, end - start + 1) if start and end else 0
72
+ ai += n
73
+ return {"ai_lines": ai}
74
+
75
+
76
+ def _prompts_from_traces(traces: list[Trace], *, max_chars: int = 500) -> list[str]:
77
+ seen: set[str] = set()
78
+ out: list[str] = []
79
+ for tr in traces:
80
+ meta = tr.metadata or {}
81
+ for key in ("prompt", "user_prompt", "instruction"):
82
+ val = meta.get(key)
83
+ if isinstance(val, str) and val.strip():
84
+ s = val.strip()[:max_chars]
85
+ if s not in seen:
86
+ seen.add(s)
87
+ out.append(s)
88
+ break
89
+ return out
90
+
91
+
92
+ def build_note(
93
+ ledger: Ledger | dict[str, Any],
94
+ traces: list[Trace],
95
+ *,
96
+ include_ledger: bool,
97
+ include_summary: bool,
98
+ include_prompts: bool,
99
+ summaries: dict[str, str] | None = None,
100
+ include_all_session_conversations: bool = False,
101
+ all_session_conversations: list[dict[str, Any]] | None = None,
102
+ ) -> dict[str, Any]:
103
+ """Build the note JSON. Core fields always present; optional sections per flags."""
104
+ led = _ledger_to_dict(ledger)
105
+ trace_ids = [str(x) for x in led.get("trace_ids", [])]
106
+ h = ledger_dict_hash(led)
107
+ stats = _stats_from_ledger(led)
108
+ note: dict[str, Any] = {
109
+ "version": "2.0",
110
+ "trace_ids": trace_ids,
111
+ "ledger_hash": h,
112
+ "stats": stats,
113
+ }
114
+ if include_ledger:
115
+ files = led.get("files", {})
116
+ if isinstance(files, dict) and files:
117
+ note["ledger"] = {"files": {k: v for k, v in files.items()}}
118
+ if include_summary and summaries:
119
+ note["summary"] = dict(summaries)
120
+ if include_prompts:
121
+ pr = _prompts_from_traces(traces)
122
+ if pr:
123
+ note["prompts"] = pr
124
+ if include_all_session_conversations and all_session_conversations:
125
+ note["all_session_conversations"] = list(all_session_conversations)
126
+ return note
127
+
128
+
129
+ def attach_note(commit_sha: str, note: dict[str, Any], repo_dir: str) -> bool:
130
+ """Attach JSON note to ``commit_sha`` on ``refs/notes/agent-trace``."""
131
+ if not commit_sha or not repo_dir:
132
+ return False
133
+ try:
134
+ with tempfile.NamedTemporaryFile(
135
+ mode="w",
136
+ encoding="utf-8",
137
+ delete=False,
138
+ suffix=".json",
139
+ ) as tf:
140
+ json.dump(note, tf, ensure_ascii=False)
141
+ path = tf.name
142
+ try:
143
+ r = subprocess.run(
144
+ [
145
+ "git",
146
+ "notes",
147
+ "--ref",
148
+ NOTE_REF,
149
+ "add",
150
+ "-f",
151
+ "-F",
152
+ path,
153
+ commit_sha,
154
+ ],
155
+ capture_output=True,
156
+ text=True,
157
+ cwd=repo_dir,
158
+ timeout=60,
159
+ )
160
+ return r.returncode == 0
161
+ finally:
162
+ try:
163
+ os.unlink(path)
164
+ except OSError:
165
+ pass
166
+ except Exception:
167
+ return False
168
+ return False
169
+
170
+
171
+ def read_note(commit_sha: str, repo_dir: str) -> dict[str, Any] | None:
172
+ """Read and parse the agent-trace note for ``commit_sha``, or None."""
173
+ raw = _git("notes", "--ref", NOTE_REF, "show", commit_sha, cwd=repo_dir)
174
+ if not raw:
175
+ return None
176
+ try:
177
+ d = json.loads(raw)
178
+ return d if isinstance(d, dict) else None
179
+ except json.JSONDecodeError:
180
+ return None
181
+
182
+
183
+ def list_notes(repo_dir: str, range_spec: str | None = None) -> Iterator[tuple[str, dict[str, Any]]]:
184
+ """Yield ``(commit_sha, note_dict)`` for commits in ``range_spec`` that have a note.
185
+
186
+ ``range_spec`` is passed to ``git rev-list`` (e.g. ``HEAD~3..HEAD``). Default ``HEAD``.
187
+ """
188
+ spec = range_spec if range_spec else "HEAD"
189
+ out = _git("rev-list", spec, cwd=repo_dir)
190
+ if not out:
191
+ return iter(())
192
+ shas = [ln.strip() for ln in out.splitlines() if ln.strip()]
193
+
194
+ def _gen() -> Iterator[tuple[str, dict[str, Any]]]:
195
+ for sha in shas:
196
+ n = read_note(sha, repo_dir)
197
+ if n:
198
+ yield (sha, n)
199
+
200
+ return _gen()
201
+
202
+
203
+ def strip_sections(commit_sha: str, sections: list[str], repo_dir: str) -> bool:
204
+ """Remove optional sections (``ledger``, ``summary``, ``prompts``,
205
+ ``all_session_conversations``) from the note."""
206
+ note = read_note(commit_sha, repo_dir)
207
+ if not note:
208
+ return False
209
+ for sec in sections:
210
+ if sec in ("ledger", "summary", "prompts", "all_session_conversations"):
211
+ note.pop(sec, None)
212
+ return attach_note(commit_sha, note, repo_dir)
213
+
214
+
215
+ def _notes_config(project_dir: str) -> dict[str, Any]:
216
+ from .config import get_project_config
217
+
218
+ cfg = get_project_config(project_dir)
219
+ if not cfg:
220
+ return {}
221
+ raw = cfg.get("notes")
222
+ return raw if isinstance(raw, dict) else {}
223
+
224
+
225
+ def project_notes_flags(project_dir: str) -> tuple[bool, bool, bool]:
226
+ """Default ``(include_ledger, include_summary, include_prompts)`` from project config."""
227
+ nc = _notes_config(project_dir)
228
+ return (
229
+ bool(nc.get("include_ledger", False)),
230
+ bool(nc.get("include_summary", False)),
231
+ bool(nc.get("include_prompts", False)),
232
+ )
233
+
234
+
235
+ def all_session_conversations_enabled(project_dir: str) -> bool:
236
+ """Whether to embed ``all_session_conversations`` in notes (default: off)."""
237
+ nc = _notes_config(project_dir)
238
+ return bool(nc.get("all_session_conversations", False))
239
+
240
+
241
+ def _load_local_traces_raw(project_dir: str) -> list[dict[str, Any]]:
242
+ """Same as commit_link._load_local_traces (duplicated to avoid import cycle)."""
243
+ import json
244
+
245
+ from .storage import get_traces_path, resolve_project_id
246
+
247
+ pid = resolve_project_id(project_dir, create=False)
248
+ if not pid:
249
+ return []
250
+ traces_path = get_traces_path(pid)
251
+ if not traces_path.exists():
252
+ return []
253
+ traces: list[dict[str, Any]] = []
254
+ try:
255
+ for line in traces_path.read_text().splitlines():
256
+ line = line.strip()
257
+ if line:
258
+ try:
259
+ traces.append(json.loads(line))
260
+ except json.JSONDecodeError:
261
+ continue
262
+ except OSError:
263
+ pass
264
+ return traces
265
+
266
+
267
+ def load_traces_for_ids(project_dir: str, trace_ids: list[str]) -> list[Trace]:
268
+ if not trace_ids:
269
+ return []
270
+ want = set(trace_ids)
271
+ out: list[Trace] = []
272
+ for row in _load_local_traces_raw(project_dir):
273
+ tid = row.get("id")
274
+ if tid and str(tid) in want:
275
+ try:
276
+ out.append(Trace.from_dict(row))
277
+ except Exception:
278
+ continue
279
+ return out
280
+
281
+
282
+ def attach_note_after_ledger(project_dir: str, ledger: dict[str, Any]) -> bool:
283
+ """After a ledger is stored: build and attach a git note per project ``notes.*`` config.
284
+
285
+ Skips attachment when the ledger has zero AI lines — there's nothing
286
+ worth carrying on the commit, and an empty note just pollutes
287
+ ``git log --show-notes``.
288
+ """
289
+ try:
290
+ if _stats_from_ledger(_ledger_to_dict(ledger)).get("ai_lines", 0) <= 0:
291
+ return False
292
+ nc = _notes_config(project_dir)
293
+ if nc.get("enabled") is False:
294
+ return False
295
+ include_ledger = bool(nc.get("include_ledger", False))
296
+ include_summary = bool(nc.get("include_summary", False))
297
+ include_prompts = bool(nc.get("include_prompts", False))
298
+ include_asc = bool(nc.get("all_session_conversations", False))
299
+ from .summary import all_session_conversations_for_ledger, merge_note_summaries
300
+
301
+ static_s = nc.get("summaries") if isinstance(nc.get("summaries"), dict) else None
302
+ summaries = merge_note_summaries(project_dir, ledger, static_s)
303
+
304
+ tid_list = [str(x) for x in ledger.get("trace_ids", [])]
305
+ traces = load_traces_for_ids(project_dir, tid_list)
306
+ asc = all_session_conversations_for_ledger(project_dir, ledger) if include_asc else None
307
+ note = build_note(
308
+ ledger,
309
+ traces,
310
+ include_ledger=include_ledger,
311
+ include_summary=include_summary,
312
+ include_prompts=include_prompts,
313
+ summaries=summaries,
314
+ include_all_session_conversations=include_asc,
315
+ all_session_conversations=asc,
316
+ )
317
+ sha = str(ledger.get("commit_sha", ""))
318
+ if not sha:
319
+ return False
320
+ return attach_note(sha, note, project_dir)
321
+ except Exception:
322
+ return False
323
+
324
+
325
+ def rebuild_notes_for_range(
326
+ project_dir: str,
327
+ range_spec: str,
328
+ *,
329
+ include_ledger: bool | None = None,
330
+ include_summary: bool | None = None,
331
+ include_prompts: bool | None = None,
332
+ include_all_session_conversations: bool | None = None,
333
+ ) -> int:
334
+ """Rebuild notes from local ledgers for commits in ``range_spec``. Returns count updated."""
335
+ from .ledger import load_local_ledgers
336
+
337
+ nc = _notes_config(project_dir)
338
+ il = include_ledger if include_ledger is not None else bool(nc.get("include_ledger", False))
339
+ isum = include_summary if include_summary is not None else bool(nc.get("include_summary", False))
340
+ ipr = include_prompts if include_prompts is not None else bool(nc.get("include_prompts", False))
341
+ iasc = (
342
+ include_all_session_conversations
343
+ if include_all_session_conversations is not None
344
+ else bool(nc.get("all_session_conversations", False))
345
+ )
346
+ from .summary import all_session_conversations_for_ledger, merge_note_summaries
347
+
348
+ static_s = nc.get("summaries") if isinstance(nc.get("summaries"), dict) else None
349
+
350
+ ledgers = load_local_ledgers(project_dir)
351
+ out = _git("rev-list", range_spec, cwd=project_dir)
352
+ if not out:
353
+ return 0
354
+ shas = [ln.strip() for ln in out.splitlines() if ln.strip()]
355
+ count = 0
356
+ for sha in shas:
357
+ led = ledgers.get(sha)
358
+ if not led:
359
+ continue
360
+ tid_list = [str(x) for x in led.get("trace_ids", [])]
361
+ traces = load_traces_for_ids(project_dir, tid_list)
362
+ summaries = merge_note_summaries(project_dir, led, static_s)
363
+ asc = all_session_conversations_for_ledger(project_dir, led) if iasc else None
364
+ note = build_note(
365
+ led,
366
+ traces,
367
+ include_ledger=il,
368
+ include_summary=isum,
369
+ include_prompts=ipr,
370
+ summaries=summaries,
371
+ include_all_session_conversations=iasc,
372
+ all_session_conversations=asc,
373
+ )
374
+ if attach_note(sha, note, project_dir):
375
+ count += 1
376
+ return count
377
+
378
+
379
+ def backfill_notes(
380
+ project_dir: str,
381
+ *,
382
+ since: str | None = None,
383
+ include_ledger: bool | None = None,
384
+ include_summary: bool | None = None,
385
+ include_prompts: bool | None = None,
386
+ include_all_session_conversations: bool | None = None,
387
+ ) -> int:
388
+ """Rebuild notes for commits touching the repo since a date (``git rev-list --since``)."""
389
+ parts: list[str] = ["rev-list"]
390
+ if since:
391
+ parts.extend(["--since", since])
392
+ parts.append("HEAD")
393
+ out = _git(*parts, cwd=project_dir)
394
+ if not out:
395
+ return 0
396
+ shas = [ln.strip() for ln in out.splitlines() if ln.strip()]
397
+ if not shas:
398
+ return 0
399
+ # rev-list outputs oldest-first; rebuild_range needs a single spec — use chained parents
400
+ # Simplest: rebuild each sha (inefficient but clear)
401
+ from .ledger import load_local_ledgers
402
+
403
+ nc = _notes_config(project_dir)
404
+ il = include_ledger if include_ledger is not None else bool(nc.get("include_ledger", False))
405
+ isum = include_summary if include_summary is not None else bool(nc.get("include_summary", False))
406
+ ipr = include_prompts if include_prompts is not None else bool(nc.get("include_prompts", False))
407
+ iasc = (
408
+ include_all_session_conversations
409
+ if include_all_session_conversations is not None
410
+ else bool(nc.get("all_session_conversations", False))
411
+ )
412
+ from .summary import all_session_conversations_for_ledger, merge_note_summaries
413
+
414
+ static_s = nc.get("summaries") if isinstance(nc.get("summaries"), dict) else None
415
+
416
+ ledgers = load_local_ledgers(project_dir)
417
+ count = 0
418
+ for sha in shas:
419
+ led = ledgers.get(sha)
420
+ if not led:
421
+ continue
422
+ tid_list = [str(x) for x in led.get("trace_ids", [])]
423
+ traces = load_traces_for_ids(project_dir, tid_list)
424
+ summaries = merge_note_summaries(project_dir, led, static_s)
425
+ asc = all_session_conversations_for_ledger(project_dir, led) if iasc else None
426
+ note = build_note(
427
+ led,
428
+ traces,
429
+ include_ledger=il,
430
+ include_summary=isum,
431
+ include_prompts=ipr,
432
+ summaries=summaries,
433
+ include_all_session_conversations=iasc,
434
+ all_session_conversations=asc,
435
+ )
436
+ if attach_note(sha, note, project_dir):
437
+ count += 1
438
+ return count
439
+
440
+
441
+ def git_notes_push(repo_dir: str, remote: str = "origin") -> tuple[bool, str]:
442
+ """Run ``git push`` for the agent-trace notes ref."""
443
+ try:
444
+ r = subprocess.run(
445
+ [
446
+ "git",
447
+ "push",
448
+ remote,
449
+ f"refs/notes/{NOTE_REF}:refs/notes/{NOTE_REF}",
450
+ ],
451
+ capture_output=True,
452
+ text=True,
453
+ cwd=repo_dir,
454
+ timeout=120,
455
+ )
456
+ msg = (r.stderr or r.stdout or "").strip()
457
+ return r.returncode == 0, msg or "ok"
458
+ except Exception as e:
459
+ return False, str(e)
460
+
461
+
462
+ def git_notes_pull(repo_dir: str, remote: str = "origin") -> tuple[bool, str]:
463
+ """Run ``git fetch`` for the agent-trace notes ref."""
464
+ try:
465
+ r = subprocess.run(
466
+ [
467
+ "git",
468
+ "fetch",
469
+ remote,
470
+ f"refs/notes/{NOTE_REF}:refs/notes/{NOTE_REF}",
471
+ ],
472
+ capture_output=True,
473
+ text=True,
474
+ cwd=repo_dir,
475
+ timeout=120,
476
+ )
477
+ msg = (r.stderr or r.stdout or "").strip()
478
+ return r.returncode == 0, msg or "ok"
479
+ except Exception as e:
480
+ return False, str(e)
481
+
482
+
483
+ def ledger_from_note_for_blame(note: dict[str, Any]) -> dict[str, Any] | None:
484
+ """Return a minimal ledger dict for blame if the note embeds ``ledger``."""
485
+ led = note.get("ledger")
486
+ if not isinstance(led, dict):
487
+ return None
488
+ files = led.get("files")
489
+ if not isinstance(files, dict) or not files:
490
+ return None
491
+ return {"files": files}
@@ -0,0 +1,163 @@
1
+ """Coding-agent adapters and the registry that drives them.
2
+
3
+ To add a new agent:
4
+
5
+ 1. Create ``agent_trace/hooks/<agent>.py`` with a class that subclasses
6
+ ``CodingAgentAdapter`` (see ``base.py`` for the contract).
7
+ 2. Implement ``inject`` / ``remove`` / ``global_config_path`` for the
8
+ agent's hook surface.
9
+ 3. Optionally declare ``EVENTS`` (event-name → translator), a
10
+ ``rules_dir``/``rule_extension`` and a ``detect_tool_info``.
11
+ 4. Register the adapter at the bottom of this file with
12
+ ``register_adapter(NewAdapter())``.
13
+
14
+ Nothing else in the codebase should hardcode the agent's name —
15
+ ``cli.py``, ``record.py``, ``rules.py`` and ``trace.get_tool_info``
16
+ all walk the registry.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .base import AGENT_TRACE_CMD, Adapter, CodingAgentAdapter
22
+ from .claude import ClaudeAdapter
23
+ from .codex import CodexAdapter
24
+ from .cursor import CursorAdapter
25
+
26
+ _ADAPTERS: dict[str, CodingAgentAdapter] = {}
27
+
28
+
29
+ def register_adapter(adapter: CodingAgentAdapter) -> None:
30
+ _ADAPTERS[adapter.name] = adapter
31
+
32
+
33
+ def iter_adapters() -> list[CodingAgentAdapter]:
34
+ return list(_ADAPTERS.values())
35
+
36
+
37
+ def get_adapter(name: str) -> CodingAgentAdapter | None:
38
+ return _ADAPTERS.get(name)
39
+
40
+
41
+ def adapter_names() -> list[str]:
42
+ return list(_ADAPTERS)
43
+
44
+
45
+ # Built-in adapters. Order matters for CLI choices and doctor output.
46
+ register_adapter(CursorAdapter())
47
+ register_adapter(ClaudeAdapter())
48
+ register_adapter(CodexAdapter())
49
+
50
+
51
+ # ---------------------------------------------------------------------
52
+ # Back-compat tool-specific shims (cursor / claude). Kept for callers
53
+ # that still import the named functions; new code should iterate the
54
+ # registry via ``iter_adapters`` / ``setup_global_hooks``.
55
+ # ---------------------------------------------------------------------
56
+
57
+
58
+ def configure_cursor_hooks(project_dir: str | None = None, *, global_install: bool = False) -> bool:
59
+ return _ADAPTERS["cursor"].inject(
60
+ AGENT_TRACE_CMD,
61
+ project_dir=project_dir,
62
+ global_install=global_install,
63
+ )
64
+
65
+
66
+ def configure_claude_hooks(project_dir: str | None = None, *, global_install: bool = False) -> bool:
67
+ return _ADAPTERS["claude"].inject(
68
+ AGENT_TRACE_CMD,
69
+ project_dir=project_dir,
70
+ global_install=global_install,
71
+ )
72
+
73
+
74
+ def has_global_cursor_hooks() -> bool:
75
+ return _ADAPTERS["cursor"].is_installed()
76
+
77
+
78
+ def has_global_claude_hooks() -> bool:
79
+ return _ADAPTERS["claude"].is_installed()
80
+
81
+
82
+ def has_global_hooks(tool: str | None = None) -> bool:
83
+ if tool is None:
84
+ return any(adapter.is_installed() for adapter in iter_adapters())
85
+ adapter = _ADAPTERS.get(tool)
86
+ return adapter.is_installed() if adapter else False
87
+
88
+
89
+ def remove_global_cursor_hooks() -> bool:
90
+ return _ADAPTERS["cursor"].remove()
91
+
92
+
93
+ def remove_global_claude_hooks() -> bool:
94
+ return _ADAPTERS["claude"].remove()
95
+
96
+
97
+ def configure_project_hooks(
98
+ project_dir: str | None = None,
99
+ tools: list[str] | None = None,
100
+ ) -> dict[str, bool]:
101
+ """Run ``inject`` (project-level) for every adapter or a subset.
102
+
103
+ Replaces the per-tool ``configure_*_hooks`` calls in ``init`` so new
104
+ adapters automatically participate.
105
+ """
106
+ if tools is None:
107
+ tools = list(_ADAPTERS)
108
+ out: dict[str, bool] = {}
109
+ for tool in tools:
110
+ adapter = _ADAPTERS.get(tool)
111
+ if adapter is None:
112
+ continue
113
+ out[tool] = adapter.inject(
114
+ AGENT_TRACE_CMD,
115
+ project_dir=project_dir,
116
+ global_install=False,
117
+ )
118
+ return out
119
+
120
+
121
+ def setup_global_hooks(tools: list[str] | None = None) -> dict[str, bool]:
122
+ if tools is None:
123
+ tools = list(_ADAPTERS)
124
+ results: dict[str, bool] = {}
125
+ for tool in tools:
126
+ adapter = _ADAPTERS.get(tool)
127
+ if adapter is None:
128
+ continue
129
+ results[tool] = adapter.inject(AGENT_TRACE_CMD, global_install=True)
130
+ return results
131
+
132
+
133
+ def remove_global_hooks(tools: list[str] | None = None) -> dict[str, bool]:
134
+ if tools is None:
135
+ tools = list(_ADAPTERS)
136
+ results: dict[str, bool] = {}
137
+ for tool in tools:
138
+ adapter = _ADAPTERS.get(tool)
139
+ if adapter is None:
140
+ continue
141
+ results[tool] = adapter.remove()
142
+ return results
143
+
144
+
145
+ __all__ = [
146
+ "AGENT_TRACE_CMD",
147
+ "Adapter",
148
+ "CodingAgentAdapter",
149
+ "register_adapter",
150
+ "iter_adapters",
151
+ "get_adapter",
152
+ "adapter_names",
153
+ "setup_global_hooks",
154
+ "remove_global_hooks",
155
+ "configure_project_hooks",
156
+ "configure_cursor_hooks",
157
+ "configure_claude_hooks",
158
+ "has_global_cursor_hooks",
159
+ "has_global_claude_hooks",
160
+ "has_global_hooks",
161
+ "remove_global_cursor_hooks",
162
+ "remove_global_claude_hooks",
163
+ ]