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
agent_trace/cli.py ADDED
@@ -0,0 +1,2680 @@
1
+ """
2
+ agent-trace CLI — terminal commands for AI code tracing.
3
+
4
+ Zero external dependencies — uses only the Python standard library.
5
+
6
+ Commands:
7
+ agent-trace init Initialize tracing for the current project
8
+ agent-trace status Show tracing status
9
+ agent-trace reset Reconfigure tracing settings
10
+ agent-trace config show Show persisted configuration
11
+ agent-trace config set Set one configuration field
12
+ agent-trace config reset Reset one configuration field/group
13
+ agent-trace hooks setup-global Install global hooks for all tools
14
+ agent-trace hooks remove-global Remove global hooks
15
+ agent-trace hooks status Show global hook status
16
+ agent-trace record Record a trace from stdin (used by hooks)
17
+ agent-trace commit-link Link current commit to traces (called by git hook)
18
+ agent-trace blame <file> Show AI attribution for a file
19
+ agent-trace context <file> Get conversation context for AI-attributed code
20
+ agent-trace rule add <name> Add a prebuilt rule for a coding agent
21
+ agent-trace rule remove <name> Remove a rule
22
+ agent-trace rule show Show which rules are configured
23
+ agent-trace rule list List available prebuilt rules
24
+ agent-trace viewer Open the file viewer (browse files, git + agent-trace blame)
25
+ agent-trace remote add/list/show/set-url/set-token/remove/rename/default
26
+ agent-trace push Push local traces to remote
27
+ agent-trace pull Pull remote traces to local
28
+ agent-trace sync Push + pull in one go
29
+ agent-trace set globaluser Set a global auth token
30
+ agent-trace remove globaluser Remove the global auth token
31
+ agent-trace projects List registered project IDs (or: projects show <id>)
32
+ agent-trace adopt [path] Register a git repo and print its stable project_id
33
+ agent-trace notes ... Git notes (attach, rebuild, show, push, pull, …)
34
+ agent-trace summary ... Pluggable session summaries (enable, generate, show)
35
+ agent-trace doctor Check hooks, config, remotes, and tooling
36
+
37
+ Global flags:
38
+ --telemetry on|off|status Opt-in anonymous usage telemetry (default off)
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import dataclasses
45
+ import json
46
+ import os
47
+ from pathlib import Path
48
+ import shlex
49
+ import shutil
50
+ import stat
51
+ import subprocess
52
+ import sys
53
+ import time
54
+ import urllib.error
55
+ import urllib.request
56
+
57
+ from .config import (
58
+ get_global_config,
59
+ get_global_config_file,
60
+ get_project_config,
61
+ save_global_config,
62
+ save_project_config,
63
+ )
64
+ from .blame import blame_file
65
+ from .commit_link import create_commit_link
66
+ from .context import context_command
67
+ from .registry import list_projects, lookup_or_create_project_id
68
+ from .trace import (
69
+ cli_resolve_project_root,
70
+ discover_ambiguous_repo_roots,
71
+ get_vcs_info,
72
+ git_repo_root_for_path,
73
+ )
74
+ from .hooks import (
75
+ AGENT_TRACE_CMD,
76
+ adapter_names,
77
+ configure_project_hooks,
78
+ iter_adapters,
79
+ remove_global_hooks,
80
+ setup_global_hooks,
81
+ )
82
+ from .hooks.git import (
83
+ configure_git_hooks,
84
+ configure_git_notes_refspecs,
85
+ configure_git_post_rewrite_hook,
86
+ )
87
+ from .rules import add_rule, remove_rule, show_rules, list_available_rules, TOOL_CHOICES
88
+ from .record import record_from_stdin
89
+ from .rewrite import rewrite_ledgers
90
+ from .remote import (
91
+ ProjectRegistrationError,
92
+ RemoteUrlError,
93
+ TokenScopeError,
94
+ WhoamiUnsupportedError,
95
+ add_remote as remote_add,
96
+ assert_token_matches_url,
97
+ get_default_remote,
98
+ list_remotes as remote_list,
99
+ parse_remote_url,
100
+ register_project_via_remote,
101
+ remove_remote as remote_remove,
102
+ rename_remote as remote_rename,
103
+ set_default_remote,
104
+ set_remote_token,
105
+ set_remote_url,
106
+ show_remote as remote_show,
107
+ whoami as remote_whoami,
108
+ )
109
+ from .sync import pull as sync_pull, push as sync_push, status as sync_status
110
+ from .telemetry import (
111
+ maybe_report_cli_run,
112
+ set_telemetry_enabled,
113
+ telemetry_status_lines,
114
+ )
115
+ from .summary_presets import (
116
+ DEFAULT_OLLAMA_MODEL,
117
+ PRESET_ALIASES,
118
+ build_preset_command,
119
+ list_summary_presets,
120
+ run_summary_preset,
121
+ )
122
+
123
+ VERSION = "0.1.0"
124
+
125
+ VIEWER_BIN = os.path.expanduser("~/.agent-trace/bin/agent-trace-viewer")
126
+
127
+
128
+ def _project_hook_paths_for(adapter) -> list[str]:
129
+ """Return project-scoped config paths declared by the adapter.
130
+
131
+ Adapters expose ``project_config_paths()`` to list every relative
132
+ path that signals "agent-trace hooks installed at project scope".
133
+ The CLI just iterates — no per-tool branches.
134
+ """
135
+ paths_fn = getattr(adapter, "project_config_paths", None)
136
+ if callable(paths_fn):
137
+ try:
138
+ return list(paths_fn())
139
+ except Exception:
140
+ return []
141
+ return []
142
+
143
+
144
+ # -------------------------------------------------------------------
145
+ # Interactive helpers (replaces click.prompt / click.confirm)
146
+ # -------------------------------------------------------------------
147
+
148
+ def _prompt(message, default=None, choices=None):
149
+ """Interactive text prompt."""
150
+ hint = ""
151
+ if choices:
152
+ hint += f" ({'/'.join(choices)})"
153
+ if default is not None:
154
+ hint += f" [{default}]"
155
+
156
+ while True:
157
+ try:
158
+ value = input(f"{message}{hint}: ").strip()
159
+ except (EOFError, KeyboardInterrupt):
160
+ print()
161
+ sys.exit(1)
162
+
163
+ if not value:
164
+ if default is not None:
165
+ return default
166
+ continue
167
+
168
+ if choices and value.lower() not in [c.lower() for c in choices]:
169
+ print(f" Please choose from: {', '.join(choices)}")
170
+ continue
171
+
172
+ return value
173
+
174
+
175
+ def _confirm(message, default=True):
176
+ """Interactive yes / no prompt."""
177
+ hint = " [Y/n]" if default else " [y/N]"
178
+ try:
179
+ value = input(f"{message}{hint}: ").strip().lower()
180
+ except (EOFError, KeyboardInterrupt):
181
+ print()
182
+ sys.exit(1)
183
+
184
+ if not value:
185
+ return default
186
+ return value in ("y", "yes")
187
+
188
+
189
+ # ===================================================================
190
+ # init
191
+ # ===================================================================
192
+
193
+ def cmd_init(_args):
194
+ """Zero-prompt init — applies sensible local defaults and wires up hooks.
195
+
196
+ Like ``git init``: creates local-only state. Remote sharing is opt-in and
197
+ set up later via ``agent-trace remote add`` (and ``agent-trace notes push``
198
+ / ``agent-trace push``).
199
+ """
200
+ config = get_project_config()
201
+ if config is not None:
202
+ print("agent-trace is already initialized for this project.")
203
+ print("Use 'agent-trace reset' to change configuration.")
204
+ return
205
+
206
+ cwd = os.getcwd()
207
+ if not git_repo_root_for_path(cwd):
208
+ print("agent-trace init: not a git repository.", file=sys.stderr)
209
+ print("Create or enter a git repo, then run init again.", file=sys.stderr)
210
+ sys.exit(1)
211
+
212
+ if get_vcs_info(cwd) is None:
213
+ print("agent-trace init: repository has no commits yet.", file=sys.stderr)
214
+ print("Create at least one commit, then run 'agent-trace init' again.", file=sys.stderr)
215
+ sys.exit(1)
216
+
217
+ project_config: dict = {
218
+ "notes": {
219
+ "enabled": True,
220
+ "include_ledger": False,
221
+ "include_summary": True,
222
+ "include_prompts": True,
223
+ "all_session_conversations": False,
224
+ },
225
+ }
226
+ save_project_config(project_config)
227
+
228
+ from .storage import get_project_config_path, resolve_project_id
229
+
230
+ pid = resolve_project_id(cwd, create=True)
231
+
232
+ print("Initializing agent-trace...\n")
233
+ print(f" Project id: {pid}")
234
+ print(f" Data dir: {get_project_config_path(pid).parent if pid else '?'}")
235
+
236
+ # Git notes — defaults on; per-line ledger off unless configured
237
+ print(" Git notes: enabled (summary + prompts; per-line ledger off by default)")
238
+
239
+ notes_remote = "origin"
240
+ notes_refspec_ok = False
241
+ if os.path.isdir(".git"):
242
+ notes_refspec_ok = configure_git_notes_refspecs(remote_name=notes_remote)
243
+ if notes_refspec_ok:
244
+ print(f" Note refspec: configured on remote \"{notes_remote}\"")
245
+ else:
246
+ print(f" Note refspec: skipped (no git remote \"{notes_remote}\" yet — "
247
+ "rerun 'agent-trace reset' after adding one)")
248
+
249
+ # Hooks — install everything by default, skip if global already handles it.
250
+ # Driven by the adapter registry: new agents are picked up automatically.
251
+ for adapter in iter_adapters():
252
+ label = adapter.display_name or adapter.name
253
+ global_path = adapter.global_config_path()
254
+ if adapter.is_installed():
255
+ print(f" {label} hooks: global ({global_path}) — already set")
256
+ else:
257
+ adapter.inject(AGENT_TRACE_CMD, global_install=False)
258
+ print(f" {label} hooks: configured (project-level)")
259
+
260
+ if os.path.isdir(".git"):
261
+ configure_git_hooks()
262
+ print(" Git hooks: post-commit + post-rewrite installed")
263
+
264
+ print()
265
+ print("agent-trace initialized. Everything runs locally.")
266
+ print()
267
+ print(" Change config: agent-trace reset")
268
+ print(" Add a sync remote: agent-trace remote add origin <url>")
269
+ print(" Push to remote: agent-trace push")
270
+ print(" Ship notes with commits: notes travel with 'git push' once a remote is set.")
271
+
272
+
273
+ def _probe_remote_health(base_url: str) -> tuple[bool, str]:
274
+ """Return (ok, detail) for an agent-trace HTTP service (tries /health, /api/health)."""
275
+ base = base_url.rstrip("/")
276
+ last_err = "unreachable"
277
+ for path in ("/health", "/api/health"):
278
+ try:
279
+ with urllib.request.urlopen(base + path, timeout=8) as resp:
280
+ if 200 <= getattr(resp, "status", 200) < 400:
281
+ return True, f"{path} -> OK"
282
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
283
+ last_err = str(e) or repr(e)
284
+ return False, last_err
285
+
286
+
287
+ @dataclasses.dataclass
288
+ class DoctorFixHints:
289
+ """Auto-fix opportunities detected during doctor (never includes unreachable remotes)."""
290
+
291
+ chmod_home: bool = False
292
+ chmod_global_config: Path | None = None
293
+ init_needed: bool = False
294
+ global_hooks_needed: bool = False
295
+ git_hooks_needed: bool = False
296
+ git_rewrite_only: bool = False
297
+ notes_refspec_needed: bool = False
298
+
299
+
300
+ def _doctor_git_has_origin(cwd: str) -> bool:
301
+ try:
302
+ r = subprocess.run(
303
+ ["git", "-C", cwd, "remote", "get-url", "origin"],
304
+ capture_output=True,
305
+ text=True,
306
+ timeout=12,
307
+ )
308
+ return r.returncode == 0
309
+ except (OSError, subprocess.TimeoutExpired):
310
+ return False
311
+
312
+
313
+ def _doctor_diagnose(cwd: str) -> tuple[list[str], list[str], list[str], DoctorFixHints]:
314
+ """Collect doctor messages and fix hints for ``cwd``."""
315
+ from .storage import (
316
+ get_agent_trace_home,
317
+ get_project_config_path,
318
+ resolve_project_id,
319
+ )
320
+
321
+ ok: list[str] = []
322
+ warn: list[str] = []
323
+ err: list[str] = []
324
+ hints = DoctorFixHints()
325
+
326
+ def _git_out(args: list[str]) -> str:
327
+ try:
328
+ r = subprocess.run(
329
+ ["git", "-C", cwd, *args],
330
+ capture_output=True,
331
+ text=True,
332
+ timeout=12,
333
+ )
334
+ return r.stdout if r.returncode == 0 else ""
335
+ except (OSError, subprocess.TimeoutExpired):
336
+ return ""
337
+
338
+ home = get_agent_trace_home()
339
+ try:
340
+ home.mkdir(parents=True, exist_ok=True)
341
+ probe = home / ".doctor_write_probe"
342
+ probe.write_text("")
343
+ try:
344
+ probe.unlink()
345
+ except OSError:
346
+ pass
347
+ ok.append(f"Global storage is writable ({home})")
348
+ except OSError as e:
349
+ err.append(f"Global storage not writable ({home}): {e}")
350
+ if home.exists():
351
+ hints.chmod_home = True
352
+
353
+ gf = get_global_config_file()
354
+ if gf.is_file():
355
+ mode = gf.stat().st_mode & 0o777
356
+ if mode & 0o077:
357
+ warn.append(f"Global config should be mode 600 (currently {oct(mode)})")
358
+ hints.chmod_global_config = gf
359
+ else:
360
+ ok.append("Global config permissions look good (600)")
361
+
362
+ cfg = get_project_config()
363
+ pid = resolve_project_id(cwd, create=False)
364
+ initialised = cfg is not None
365
+
366
+ if initialised:
367
+ if pid:
368
+ ok.append(f"Project config readable ({get_project_config_path(pid)})")
369
+ else:
370
+ ok.append("Project config readable")
371
+ else:
372
+ warn.append("Project not initialised (run agent-trace init)")
373
+ hints.init_needed = True
374
+
375
+ for adapter in iter_adapters():
376
+ label = adapter.display_name or adapter.name
377
+ global_path = adapter.global_config_path()
378
+ if adapter.is_installed():
379
+ ok.append(f"{label} global hooks configured ({global_path})")
380
+ continue
381
+ project_paths = _project_hook_paths_for(adapter)
382
+ any_present = False
383
+ for project_path in project_paths:
384
+ if os.path.exists(project_path):
385
+ any_present = True
386
+ try:
387
+ raw = open(project_path, encoding="utf-8").read()
388
+ except OSError:
389
+ warn.append(f"Could not read {project_path}")
390
+ continue
391
+ if AGENT_TRACE_CMD in raw:
392
+ ok.append(f"{label} project hooks mention {AGENT_TRACE_CMD} ({project_path})")
393
+ else:
394
+ warn.append(
395
+ f"{label} {project_path} present but no {AGENT_TRACE_CMD} hook found",
396
+ )
397
+ break
398
+ if not any_present and initialised:
399
+ warn.append(f"{label} hooks not configured (no global or project hooks)")
400
+ hints.global_hooks_needed = True
401
+
402
+ git_hook_ok = False
403
+ git_rewrite_ok = False
404
+ try:
405
+ if os.path.exists(".git/hooks/post-commit"):
406
+ with open(".git/hooks/post-commit", encoding="utf-8", errors="replace") as f:
407
+ git_hook_ok = "agent-trace commit-link" in f.read()
408
+ except OSError:
409
+ pass
410
+ try:
411
+ if os.path.exists(".git/hooks/post-rewrite"):
412
+ with open(".git/hooks/post-rewrite", encoding="utf-8", errors="replace") as f:
413
+ git_rewrite_ok = "agent-trace rewrite-ledger" in f.read()
414
+ except OSError:
415
+ pass
416
+ if git_hook_ok:
417
+ ok.append("Git post-commit hook calls agent-trace commit-link")
418
+ elif initialised and os.path.isdir(".git"):
419
+ warn.append("Git post-commit hook missing or does not call agent-trace commit-link")
420
+ hints.git_hooks_needed = True
421
+ if git_rewrite_ok:
422
+ ok.append("Git post-rewrite hook calls agent-trace rewrite-ledger")
423
+ elif initialised and os.path.isdir(".git"):
424
+ warn.append("Git post-rewrite hook missing or does not call agent-trace rewrite-ledger")
425
+ if git_hook_ok:
426
+ hints.git_rewrite_only = True
427
+
428
+ if os.path.isdir(".git"):
429
+ fetch_all = _git_out(["config", "--get-all", "remote.origin.fetch"])
430
+ push_all = _git_out(["config", "--get-all", "remote.origin.push"])
431
+ blob = f"{fetch_all}\n{push_all}"
432
+ if "refs/notes/agent-trace" in blob:
433
+ ok.append("Git notes refspec present for origin (refs/notes/agent-trace)")
434
+ else:
435
+ warn.append(
436
+ "Git notes refspec not configured for origin "
437
+ "(optional; run init or git config --add remote.origin.fetch +refs/notes/agent-trace:...)",
438
+ )
439
+ if _doctor_git_has_origin(cwd):
440
+ hints.notes_refspec_needed = True
441
+
442
+ if pid:
443
+ from .remote import (
444
+ RemoteUrlError,
445
+ TokenScopeError,
446
+ WhoamiUnsupportedError,
447
+ assert_token_matches_url,
448
+ get_remote as _doctor_get_remote,
449
+ get_remote_base_url,
450
+ get_remote_org_slug,
451
+ get_remote_project_slug,
452
+ get_remote_token,
453
+ parse_remote_url,
454
+ )
455
+
456
+ remotes = remote_list(pid)
457
+ if not remotes:
458
+ ok.append("No sync remotes configured (optional: agent-trace remote add)")
459
+ for r in remotes:
460
+ url = r.get("url") or ""
461
+ name = r.get("name", "?")
462
+ if not url:
463
+ warn.append(f"Remote '{name}' has no URL")
464
+ continue
465
+ try:
466
+ parse_remote_url(url)
467
+ except RemoteUrlError as e:
468
+ err.append(
469
+ f"Remote '{name}' URL is malformed: {e}. "
470
+ f"Run `agent-trace remote set-url {name} <scheme>://<host>/<org>/<project>`."
471
+ )
472
+ continue
473
+ conf = _doctor_get_remote(pid, name) or {}
474
+ base_url = get_remote_base_url(conf)
475
+ org = get_remote_org_slug(conf) or "?"
476
+ proj = get_remote_project_slug(conf) or "?"
477
+ alive, detail = _probe_remote_health(base_url)
478
+ if alive:
479
+ ok.append(f"Remote '{name}' responds at {base_url} (org={org}, project={proj}) ({detail})")
480
+ else:
481
+ warn.append(f"Remote '{name}' not reachable at {base_url} ({detail})")
482
+ continue
483
+
484
+ # Token / org / project scope check. Skip silently if no token
485
+ # or if the URL didn't carry the expected slugs (the malformed
486
+ # URL branch above already reported it). On a working scope we
487
+ # add a positive line so the user can see the binding is sound.
488
+ token = get_remote_token(conf)
489
+ if not token:
490
+ warn.append(f"Remote '{name}' has no auth token configured (scope check skipped)")
491
+ continue
492
+ url_org = get_remote_org_slug(conf)
493
+ url_project = get_remote_project_slug(conf)
494
+ if not url_org or not url_project:
495
+ continue
496
+ try:
497
+ info = assert_token_matches_url(
498
+ base_url, token,
499
+ expected_org_slug=url_org,
500
+ expected_project_slug=url_project,
501
+ allow_unsupported=True,
502
+ )
503
+ except TokenScopeError as e:
504
+ err.append(
505
+ f"Remote '{name}' token/scope mismatch ({e.code}): {e} "
506
+ f"Run `agent-trace remote set-token {name} ...` with a token "
507
+ f"for org '{url_org}', or `agent-trace remote set-url {name} ...` "
508
+ f"to point at the org the token belongs to."
509
+ )
510
+ continue
511
+ if info.get("_unsupported"):
512
+ warn.append(
513
+ f"Remote '{name}' service does not implement /auth/whoami "
514
+ "(upgrade the service to enable strict scope checks)"
515
+ )
516
+ else:
517
+ token_org = info.get("org_slug") or "?"
518
+ token_proj_scope = info.get("project_id_scope")
519
+ scope_desc = (
520
+ f"project-scoped to '{token_proj_scope}'"
521
+ if token_proj_scope else "org-scoped"
522
+ )
523
+ ok.append(
524
+ f"Remote '{name}' token matches URL "
525
+ f"(token org='{token_org}', {scope_desc})"
526
+ )
527
+
528
+ summ = (cfg or {}).get("summary") if cfg else None
529
+ if isinstance(summ, dict) and summ.get("enabled"):
530
+ cmd = summ.get("command") or ""
531
+ parts = shlex.split(cmd) if cmd.strip() else []
532
+ if not parts:
533
+ err.append("summary.enabled but summary.command is empty")
534
+ else:
535
+ exe = parts[0]
536
+ if os.path.isfile(exe) or shutil.which(exe):
537
+ ok.append("Summary command first token is executable or on PATH")
538
+ else:
539
+ warn.append(f"Summary command may not run (not found: {exe})")
540
+
541
+ return ok, warn, err, hints
542
+
543
+
544
+ def cmd_doctor(args):
545
+ """Report hook installation, config validity, remotes, and optional tools."""
546
+ cwd = os.getcwd()
547
+ fix = getattr(args, "fix", False)
548
+ dry_run = getattr(args, "dry_run", False)
549
+ yes = getattr(args, "yes", False)
550
+
551
+ def _print_report(ok: list[str], warn: list[str], err: list[str]) -> None:
552
+ print("agent-trace doctor\n")
553
+ for line in ok:
554
+ print(f" OK {line}")
555
+ for line in warn:
556
+ print(f" !! {line}")
557
+ for line in err:
558
+ print(f" XX {line}")
559
+ print()
560
+
561
+ def _apply_fixes(hints: DoctorFixHints) -> list[str]:
562
+ """Return human-readable lines describing actions taken (no-op if dry_run)."""
563
+ from .storage import get_agent_trace_home
564
+
565
+ lines: list[str] = []
566
+ home = get_agent_trace_home()
567
+
568
+ if hints.chmod_home and home.exists():
569
+ msg = f"chmod u+rwx {home}"
570
+ if dry_run:
571
+ lines.append(f"[dry-run] would: {msg}")
572
+ else:
573
+ try:
574
+ os.chmod(home, stat.S_IRWXU)
575
+ lines.append(f"Applied: {msg}")
576
+ except OSError as e:
577
+ lines.append(f"Skipped chmod on {home}: {e}")
578
+
579
+ if hints.chmod_global_config is not None:
580
+ p = hints.chmod_global_config
581
+ msg = f"chmod 600 {p}"
582
+ if dry_run:
583
+ lines.append(f"[dry-run] would: {msg}")
584
+ else:
585
+ try:
586
+ os.chmod(p, 0o600)
587
+ lines.append(f"Applied: {msg}")
588
+ except OSError as e:
589
+ lines.append(f"Skipped chmod on {p}: {e}")
590
+
591
+ if hints.init_needed:
592
+ if dry_run:
593
+ lines.append("[dry-run] would: agent-trace init")
594
+ elif yes:
595
+ cmd_init(args)
596
+ lines.append("Applied: agent-trace init (--yes)")
597
+ elif sys.stdin.isatty():
598
+ if _confirm("Initialize agent-trace for this project?", default=True):
599
+ cmd_init(args)
600
+ lines.append("Applied: agent-trace init (confirmed)")
601
+ else:
602
+ lines.append("Skipped: agent-trace init (not confirmed)")
603
+ else:
604
+ lines.append("Skipped: agent-trace init (non-interactive; use --yes to apply)")
605
+
606
+ if hints.global_hooks_needed:
607
+ msg = "hooks setup-global (all registered adapters)"
608
+ if dry_run:
609
+ lines.append(f"[dry-run] would: agent-trace {msg}")
610
+ else:
611
+ setup_global_hooks()
612
+ lines.append(f"Applied: agent-trace {msg}")
613
+
614
+ if hints.git_hooks_needed:
615
+ msg = "install git post-commit + post-rewrite hooks"
616
+ if dry_run:
617
+ lines.append(f"[dry-run] would: {msg}")
618
+ else:
619
+ configure_git_hooks(cwd)
620
+ lines.append(f"Applied: {msg}")
621
+ elif hints.git_rewrite_only:
622
+ msg = "install git post-rewrite hook"
623
+ if dry_run:
624
+ lines.append(f"[dry-run] would: {msg}")
625
+ else:
626
+ configure_git_post_rewrite_hook(cwd)
627
+ lines.append(f"Applied: {msg}")
628
+
629
+ if hints.notes_refspec_needed:
630
+ msg = "configure git notes refspec for remote.origin"
631
+ if dry_run:
632
+ lines.append(f"[dry-run] would: {msg}")
633
+ else:
634
+ if configure_git_notes_refspecs(project_dir=cwd, remote_name="origin"):
635
+ lines.append(f"Applied: {msg}")
636
+ else:
637
+ lines.append(f"Skipped: {msg} (git refused or no origin)")
638
+
639
+ return lines
640
+
641
+ ok, warn, err, hints = _doctor_diagnose(cwd)
642
+
643
+ if fix:
644
+ applied_any = any(
645
+ (
646
+ hints.chmod_home,
647
+ hints.chmod_global_config is not None,
648
+ hints.init_needed,
649
+ hints.global_hooks_needed,
650
+ hints.git_hooks_needed,
651
+ hints.git_rewrite_only,
652
+ hints.notes_refspec_needed,
653
+ ),
654
+ )
655
+ if applied_any:
656
+ print("agent-trace doctor --fix\n")
657
+ action_lines = _apply_fixes(hints)
658
+ for line in sorted(action_lines):
659
+ print(f" {line}")
660
+ print()
661
+ if not dry_run:
662
+ ok, warn, err, _hints = _doctor_diagnose(cwd)
663
+
664
+ _print_report(ok, warn, err)
665
+ if err:
666
+ sys.exit(1)
667
+
668
+
669
+ # ===================================================================
670
+ # status
671
+ # ===================================================================
672
+
673
+ def cmd_status(_args):
674
+ config = get_project_config()
675
+ if config is None:
676
+ print("agent-trace is not set up for this project.")
677
+ print("Run 'agent-trace init' to get started.")
678
+ return
679
+
680
+ from .storage import (
681
+ get_commit_links_path,
682
+ get_ledgers_path,
683
+ get_project_dir,
684
+ get_traces_path,
685
+ resolve_project_id,
686
+ )
687
+ pid = resolve_project_id(os.getcwd(), create=False)
688
+
689
+ print("agent-trace status\n")
690
+ if pid:
691
+ print(f" Project: {pid}")
692
+ print(f" Data dir: {get_project_dir(pid)}")
693
+
694
+ def _count(path):
695
+ if not path.exists():
696
+ return 0
697
+ with open(path) as f:
698
+ return sum(1 for _ in f)
699
+
700
+ print(f" Traces: {_count(get_traces_path(pid))} recorded")
701
+ print(f" Commit links: {_count(get_commit_links_path(pid))} recorded")
702
+ print(f" Ledgers: {_count(get_ledgers_path(pid))} recorded")
703
+
704
+ # Show remote sync info
705
+ try:
706
+ report = sync_status(pid)
707
+ if report.remote_name:
708
+ print(f"\n Remote '{report.remote_name}' ({report.remote_url}):")
709
+ print(f" Unpushed: {report.unpushed_traces} traces "
710
+ f"({report.unattributed_traces} unattributed held back)")
711
+ print(f" {report.unpushed_ledgers} ledgers")
712
+ print(f" {report.unpushed_commit_links} commit-links")
713
+ if report.traces_cursor:
714
+ print(f" Traces cursor: {report.traces_cursor}")
715
+ if report.ledgers_cursor:
716
+ print(f" Ledgers cursor: {report.ledgers_cursor}")
717
+ if report.commit_links_cursor:
718
+ print(f" Commit-links cursor: {report.commit_links_cursor}")
719
+ if report.conversations_cursor:
720
+ print(f" Conversations cursor: {report.conversations_cursor}")
721
+ print()
722
+ print(" Run 'agent-trace push' to share attributed work.")
723
+ print(" Run 'agent-trace pull' to fetch teammates' changes.")
724
+ except Exception:
725
+ pass
726
+
727
+ git_hook_ok = False
728
+ git_rewrite_ok = False
729
+ try:
730
+ if os.path.exists(".git/hooks/post-commit"):
731
+ with open(".git/hooks/post-commit") as f:
732
+ git_hook_ok = "agent-trace commit-link" in f.read()
733
+ except OSError:
734
+ pass
735
+ try:
736
+ if os.path.exists(".git/hooks/post-rewrite"):
737
+ with open(".git/hooks/post-rewrite") as f:
738
+ git_rewrite_ok = "agent-trace rewrite-ledger" in f.read()
739
+ except OSError:
740
+ pass
741
+
742
+ def _hook_label(has_global, has_project):
743
+ if has_global:
744
+ return "global"
745
+ if has_project:
746
+ return "project"
747
+ return "not configured"
748
+
749
+ print()
750
+ for adapter in iter_adapters():
751
+ label = adapter.display_name or adapter.name
752
+ is_global = adapter.is_installed()
753
+ is_project = any(
754
+ os.path.exists(p) for p in _project_hook_paths_for(adapter)
755
+ )
756
+ print(f" {label} hook:".ljust(22) + _hook_label(is_global, is_project))
757
+ print(f" Git post-commit: {'configured' if git_hook_ok else 'not configured'}")
758
+ print(f" Git post-rewrite: {'configured' if git_rewrite_ok else 'not configured'}")
759
+
760
+
761
+ # ===================================================================
762
+ # reset
763
+ # ===================================================================
764
+
765
+ def cmd_reset(_args):
766
+ """Reconfigure notes sections, hooks, and git-notes refspecs."""
767
+ config = get_project_config()
768
+ if config is None:
769
+ print("agent-trace is not set up for this project.")
770
+ print("Run 'agent-trace init' to get started.")
771
+ return
772
+
773
+ print("Resetting agent-trace configuration...\n")
774
+
775
+ notes_cfg = config.get("notes") or {}
776
+ notes_enabled = _confirm(
777
+ "Enable git notes (JSON on refs/notes/agent-trace)?",
778
+ default=bool(notes_cfg.get("enabled", True)),
779
+ )
780
+ new_notes = {"enabled": notes_enabled}
781
+ if notes_enabled:
782
+ new_notes["include_ledger"] = _confirm(
783
+ "Include per-line ledger in notes?",
784
+ default=bool(notes_cfg.get("include_ledger", False)),
785
+ )
786
+ new_notes["include_summary"] = _confirm(
787
+ "Include summaries in notes?",
788
+ default=bool(notes_cfg.get("include_summary", True)),
789
+ )
790
+ new_notes["include_prompts"] = _confirm(
791
+ "Include prompt previews in notes?",
792
+ default=bool(notes_cfg.get("include_prompts", True)),
793
+ )
794
+ new_notes["all_session_conversations"] = _confirm(
795
+ "Include all session conversations in notes (staging window, not only attributed lines)?",
796
+ default=bool(notes_cfg.get("all_session_conversations", False)),
797
+ )
798
+
799
+ new_config = dict(config)
800
+ new_config.pop("label", None)
801
+ new_config["notes"] = new_notes
802
+ save_project_config(new_config)
803
+ print("\nConfiguration updated.\n")
804
+
805
+ if os.path.isdir(".git"):
806
+ rn = _prompt("Git remote for note refspecs (blank to skip)", default="origin").strip()
807
+ if rn:
808
+ if configure_git_notes_refspecs(remote_name=rn):
809
+ print(f" -> Git notes refspecs configured for remote \"{rn}\"")
810
+ else:
811
+ print(f" -> Skipped (no remote \"{rn}\")")
812
+
813
+ for adapter in iter_adapters():
814
+ label = adapter.display_name or adapter.name
815
+ if _confirm(f"Reconfigure {label} hook?", default=False):
816
+ adapter.inject(AGENT_TRACE_CMD, global_install=False)
817
+ print(f" -> {label} hooks configured.")
818
+ if os.path.isdir(".git") and _confirm("Reinstall git hooks?", default=False):
819
+ configure_git_hooks()
820
+ print(" -> Git hooks reinstalled.")
821
+
822
+
823
+ # ===================================================================
824
+ # config
825
+ # ===================================================================
826
+
827
+ _DEFAULT_NOTES_CONFIG = {
828
+ "enabled": True,
829
+ "include_ledger": False,
830
+ "include_summary": True,
831
+ "include_prompts": True,
832
+ "all_session_conversations": False,
833
+ }
834
+
835
+ _CONFIG_FIELDS = {
836
+ "notes.enabled",
837
+ "notes.include-ledger",
838
+ "notes.include-summary",
839
+ "notes.include-prompts",
840
+ "notes.all-session-conversations",
841
+ "summary.enabled",
842
+ "summary.command",
843
+ "summary.timeout-seconds",
844
+ "remote.default",
845
+ "global.auth-token",
846
+ "global.capture-detached-edits",
847
+ }
848
+
849
+ _CONFIG_RESET_TARGETS = _CONFIG_FIELDS | {"notes", "summary"}
850
+
851
+
852
+ def _parse_config_bool(value: str) -> bool:
853
+ v = str(value).strip().lower()
854
+ if v in {"1", "true", "yes", "y", "on", "enable", "enabled"}:
855
+ return True
856
+ if v in {"0", "false", "no", "n", "off", "disable", "disabled"}:
857
+ return False
858
+ raise ValueError("expected a boolean value (true/false)")
859
+
860
+
861
+ def _redact_config_value(value):
862
+ if isinstance(value, dict):
863
+ redacted = {}
864
+ for key, val in value.items():
865
+ lk = str(key).lower()
866
+ if lk == "install_id" and val:
867
+ redacted[key] = "(anonymous id)"
868
+ elif lk in {"auth_token", "token"} or "secret" in lk:
869
+ redacted[key] = "(set)" if val else val
870
+ elif lk == "tokens" and isinstance(val, dict):
871
+ redacted[key] = {name: "(set)" for name in val}
872
+ else:
873
+ redacted[key] = _redact_config_value(val)
874
+ return redacted
875
+ if isinstance(value, list):
876
+ return [_redact_config_value(item) for item in value]
877
+ return value
878
+
879
+
880
+ def _project_config_or_exit() -> tuple[str, dict]:
881
+ from .storage import resolve_project_id
882
+
883
+ cfg = get_project_config()
884
+ if cfg is None:
885
+ print("agent-trace is not set up for this project.", file=sys.stderr)
886
+ print("Run 'agent-trace init' to get started.", file=sys.stderr)
887
+ sys.exit(1)
888
+ pid = resolve_project_id(os.getcwd(), create=False)
889
+ if not pid:
890
+ print("agent-trace config: no project id", file=sys.stderr)
891
+ sys.exit(1)
892
+ return pid, cfg
893
+
894
+
895
+ def _load_safe_remotes(project_id: str) -> dict:
896
+ try:
897
+ from .remote import _load_remotes
898
+ except ImportError:
899
+ return {}
900
+ return _redact_config_value(_load_remotes(project_id))
901
+
902
+
903
+ def _hook_config_status() -> dict:
904
+ git_post_commit = False
905
+ git_post_rewrite = False
906
+ try:
907
+ if os.path.exists(".git/hooks/post-commit"):
908
+ with open(".git/hooks/post-commit") as f:
909
+ git_post_commit = "agent-trace commit-link" in f.read()
910
+ except OSError:
911
+ pass
912
+ try:
913
+ if os.path.exists(".git/hooks/post-rewrite"):
914
+ with open(".git/hooks/post-rewrite") as f:
915
+ git_post_rewrite = "agent-trace rewrite-ledger" in f.read()
916
+ except OSError:
917
+ pass
918
+ out: dict = {}
919
+ for adapter in iter_adapters():
920
+ out[adapter.name] = {
921
+ "global": adapter.is_installed(),
922
+ "project": any(
923
+ os.path.exists(p) for p in _project_hook_paths_for(adapter)
924
+ ),
925
+ }
926
+ out["git"] = {
927
+ "post_commit": git_post_commit,
928
+ "post_rewrite": git_post_rewrite,
929
+ }
930
+ return out
931
+
932
+
933
+ def _full_config_snapshot() -> dict:
934
+ from .storage import get_project_dir, resolve_project_id
935
+
936
+ cfg = get_project_config()
937
+ pid = resolve_project_id(os.getcwd(), create=False)
938
+ snapshot = {
939
+ "global": {
940
+ "config": _redact_config_value(get_global_config()),
941
+ },
942
+ "project": None,
943
+ "hooks": _hook_config_status(),
944
+ }
945
+ if pid and cfg is not None:
946
+ snapshot["project"] = {
947
+ "id": pid,
948
+ "data_dir": str(get_project_dir(pid)),
949
+ "config": cfg,
950
+ "remotes": _load_safe_remotes(pid),
951
+ }
952
+ return snapshot
953
+
954
+
955
+ def _print_config_snapshot(snapshot: dict) -> None:
956
+ print("agent-trace config\n")
957
+ project = snapshot.get("project")
958
+ if project:
959
+ print(f" Project: {project['id']}")
960
+ print(f" Data dir: {project['data_dir']}")
961
+ print("\n Project config:")
962
+ print(_indent_json(project.get("config") or {}))
963
+ print("\n Remotes:")
964
+ print(_indent_json(project.get("remotes") or {}))
965
+ else:
966
+ print(" Project: not initialized")
967
+
968
+ print("\n Global config:")
969
+ print(_indent_json(snapshot.get("global", {}).get("config") or {}))
970
+ print("\n Hooks:")
971
+ print(_indent_json(snapshot.get("hooks") or {}))
972
+
973
+
974
+ def _indent_json(value: dict) -> str:
975
+ text = json.dumps(value, indent=2, sort_keys=True)
976
+ return "\n".join(f" {line}" for line in text.splitlines())
977
+
978
+
979
+ def _cleanup_empty_mapping(config: dict, key: str) -> None:
980
+ if isinstance(config.get(key), dict) and not config[key]:
981
+ del config[key]
982
+
983
+
984
+ def _bool_to_text(value: bool) -> str:
985
+ return "true" if value else "false"
986
+
987
+
988
+ def _prompt_reset_bool(field: str, default_value: bool) -> bool:
989
+ raw = _prompt(
990
+ f"Reset {field} (press Enter for default)",
991
+ default=_bool_to_text(default_value),
992
+ )
993
+ return _parse_config_bool(raw)
994
+
995
+
996
+ def _interactive_reset_field(field: str) -> None:
997
+ """Interactive reset flow: Enter accepts reset defaults."""
998
+ bool_defaults = {
999
+ "notes.enabled": True,
1000
+ "notes.include-ledger": False,
1001
+ "notes.include-summary": True,
1002
+ "notes.include-prompts": True,
1003
+ "notes.all-session-conversations": False,
1004
+ "summary.enabled": False,
1005
+ "global.capture-detached-edits": False,
1006
+ }
1007
+ clear_targets = {
1008
+ "summary",
1009
+ "summary.command",
1010
+ "summary.timeout-seconds",
1011
+ "remote.default",
1012
+ "global.auth-token",
1013
+ }
1014
+
1015
+ if field == "notes":
1016
+ _pid, cfg = _project_config_or_exit()
1017
+ cfg["notes"] = {
1018
+ "enabled": _prompt_reset_bool("notes.enabled", True),
1019
+ "include_ledger": _prompt_reset_bool("notes.include-ledger", False),
1020
+ "include_summary": _prompt_reset_bool("notes.include-summary", True),
1021
+ "include_prompts": _prompt_reset_bool("notes.include-prompts", True),
1022
+ "all_session_conversations": _prompt_reset_bool("notes.all-session-conversations", False),
1023
+ }
1024
+ save_project_config(cfg)
1025
+ return
1026
+
1027
+ if field in bool_defaults:
1028
+ chosen = _prompt_reset_bool(field, bool_defaults[field])
1029
+ _set_config_field(field, _bool_to_text(chosen))
1030
+ return
1031
+
1032
+ if field in clear_targets:
1033
+ if not _confirm(f"Reset {field} to default (clear current value)?", default=True):
1034
+ print("No changes made.")
1035
+ return
1036
+ _reset_config_field(field)
1037
+ return
1038
+
1039
+ # Fallback: if new reset targets are added in future.
1040
+ _reset_config_field(field)
1041
+
1042
+
1043
+ def _set_config_field(field: str, value: str) -> None:
1044
+ if field not in _CONFIG_FIELDS:
1045
+ raise ValueError(f"unknown field '{field}'")
1046
+
1047
+ if field.startswith("global."):
1048
+ cfg = get_global_config()
1049
+ if field == "global.auth-token":
1050
+ cfg["auth_token"] = value
1051
+ elif field == "global.capture-detached-edits":
1052
+ cfg["capture_detached_edits"] = _parse_config_bool(value)
1053
+ save_global_config(cfg)
1054
+ return
1055
+
1056
+ pid, cfg = _project_config_or_exit()
1057
+ if field.startswith("notes."):
1058
+ notes = cfg.setdefault("notes", {})
1059
+ key = field.split(".", 1)[1].replace("-", "_")
1060
+ notes[key] = _parse_config_bool(value)
1061
+ elif field == "summary.enabled":
1062
+ sm = cfg.setdefault("summary", {})
1063
+ sm["enabled"] = _parse_config_bool(value)
1064
+ elif field == "summary.command":
1065
+ if not value.strip():
1066
+ raise ValueError("summary.command cannot be blank")
1067
+ sm = cfg.setdefault("summary", {})
1068
+ sm["enabled"] = True
1069
+ sm["command"] = value
1070
+ elif field == "summary.timeout-seconds":
1071
+ timeout = int(value)
1072
+ if timeout <= 0:
1073
+ raise ValueError("summary.timeout-seconds must be positive")
1074
+ cfg.setdefault("summary", {})["timeout_seconds"] = timeout
1075
+ elif field == "remote.default":
1076
+ from .remote import set_default_remote
1077
+
1078
+ set_default_remote(pid, value)
1079
+ return
1080
+ save_project_config(cfg)
1081
+
1082
+
1083
+ def _reset_config_field(field: str) -> None:
1084
+ if field not in _CONFIG_RESET_TARGETS:
1085
+ raise ValueError(f"unknown reset target '{field}'")
1086
+
1087
+ if field.startswith("global."):
1088
+ cfg = get_global_config()
1089
+ if field == "global.auth-token":
1090
+ cfg.pop("auth_token", None)
1091
+ elif field == "global.capture-detached-edits":
1092
+ cfg.pop("capture_detached_edits", None)
1093
+ save_global_config(cfg)
1094
+ return
1095
+
1096
+ _pid, cfg = _project_config_or_exit()
1097
+ if field == "notes":
1098
+ cfg["notes"] = dict(_DEFAULT_NOTES_CONFIG)
1099
+ elif field.startswith("notes."):
1100
+ notes = cfg.setdefault("notes", {})
1101
+ key = field.split(".", 1)[1].replace("-", "_")
1102
+ notes[key] = _DEFAULT_NOTES_CONFIG[key]
1103
+ elif field == "summary":
1104
+ cfg.pop("summary", None)
1105
+ elif field == "summary.enabled":
1106
+ cfg.setdefault("summary", {})["enabled"] = False
1107
+ elif field == "summary.command":
1108
+ if isinstance(cfg.get("summary"), dict):
1109
+ cfg["summary"].pop("command", None)
1110
+ _cleanup_empty_mapping(cfg, "summary")
1111
+ elif field == "summary.timeout-seconds":
1112
+ if isinstance(cfg.get("summary"), dict):
1113
+ cfg["summary"].pop("timeout_seconds", None)
1114
+ _cleanup_empty_mapping(cfg, "summary")
1115
+ elif field == "remote.default":
1116
+ if isinstance(cfg.get("remote"), dict):
1117
+ cfg["remote"].pop("default", None)
1118
+ _cleanup_empty_mapping(cfg, "remote")
1119
+ save_project_config(cfg)
1120
+
1121
+
1122
+ def cmd_config(args):
1123
+ """Show and mutate persisted configuration."""
1124
+ action = getattr(args, "config_action", None)
1125
+ if action == "show":
1126
+ snapshot = _full_config_snapshot()
1127
+ if getattr(args, "json", False):
1128
+ print(json.dumps(snapshot, indent=2, sort_keys=True))
1129
+ else:
1130
+ _print_config_snapshot(snapshot)
1131
+ return
1132
+
1133
+ if action == "set":
1134
+ try:
1135
+ _set_config_field(args.field, args.value)
1136
+ except (ValueError, TypeError) as e:
1137
+ print(f"agent-trace config set: {e}", file=sys.stderr)
1138
+ sys.exit(1)
1139
+ print(f"Config field '{args.field}' updated.")
1140
+ return
1141
+
1142
+ if action == "reset":
1143
+ try:
1144
+ if getattr(args, "yes", False):
1145
+ _reset_config_field(args.field)
1146
+ else:
1147
+ _interactive_reset_field(args.field)
1148
+ except (ValueError, TypeError) as e:
1149
+ print(f"agent-trace config reset: {e}", file=sys.stderr)
1150
+ sys.exit(1)
1151
+ print(f"Config field '{args.field}' reset.")
1152
+ return
1153
+
1154
+ print("Usage: agent-trace config {show,set,reset}")
1155
+
1156
+
1157
+ # ===================================================================
1158
+ # hooks (global hook management)
1159
+ # ===================================================================
1160
+
1161
+ # Computed lazily from the adapter registry so newly registered tools
1162
+ # are picked up automatically by the ``--tool`` flag and by status output.
1163
+ def _hook_tool_choices() -> list[str]:
1164
+ return adapter_names()
1165
+
1166
+
1167
+ def cmd_hooks(args):
1168
+ """Manage global hooks for coding tools."""
1169
+ action = getattr(args, "hooks_action", None)
1170
+
1171
+ if action == "setup-global":
1172
+ tools = getattr(args, "tools", None) or _hook_tool_choices()
1173
+ results = setup_global_hooks(tools)
1174
+ for tool, ok in results.items():
1175
+ if ok:
1176
+ print(f" -> Global {tool} hooks configured")
1177
+ else:
1178
+ print(f" !! Failed to configure global {tool} hooks")
1179
+
1180
+ elif action == "remove-global":
1181
+ tools = getattr(args, "tools", None) or _hook_tool_choices()
1182
+ results = remove_global_hooks(tools)
1183
+ for tool, removed in results.items():
1184
+ if removed:
1185
+ print(f" -> Global {tool} hooks removed")
1186
+ else:
1187
+ print(f" -- Global {tool} hooks were not present")
1188
+
1189
+ elif action == "status":
1190
+ print("Global hooks:")
1191
+ for adapter in iter_adapters():
1192
+ label = adapter.display_name or adapter.name
1193
+ state = "configured" if adapter.is_installed() else "not configured"
1194
+ print(f" {label:<12} {state:<16} ({adapter.global_config_path()})")
1195
+
1196
+ else:
1197
+ print("Usage: agent-trace hooks {setup-global,remove-global,status}")
1198
+ print("Run 'agent-trace hooks --help' for details.")
1199
+
1200
+
1201
+ # ===================================================================
1202
+ # record (called by hooks — reads stdin)
1203
+ # ===================================================================
1204
+
1205
+ def cmd_record(_args):
1206
+ try:
1207
+ record_from_stdin()
1208
+ except Exception:
1209
+ # Never crash the coding agent
1210
+ pass
1211
+
1212
+
1213
+ # ===================================================================
1214
+ # commit-link (called by git post-commit hook)
1215
+ # ===================================================================
1216
+
1217
+ def cmd_commit_link(_args):
1218
+ """Create a commit-trace link for the current HEAD commit."""
1219
+ try:
1220
+ link = create_commit_link()
1221
+ if link:
1222
+ n = len(link.get("trace_ids", []))
1223
+ print(f"agent-trace: linked commit {link['commit_sha'][:8]} to {n} trace(s)")
1224
+ except Exception:
1225
+ # Never crash — this runs inside a git hook
1226
+ pass
1227
+
1228
+
1229
+ # ===================================================================
1230
+ # rewrite-ledger (called by git post-rewrite hook)
1231
+ # ===================================================================
1232
+
1233
+ def cmd_rewrite_ledger(_args):
1234
+ """Remap ledgers after rebase/amend (called by git post-rewrite hook)."""
1235
+ try:
1236
+ count = rewrite_ledgers()
1237
+ if count:
1238
+ print(f"agent-trace: remapped {count} ledger(s)")
1239
+ except Exception:
1240
+ # Never crash — this runs inside a git hook
1241
+ pass
1242
+
1243
+
1244
+ # ===================================================================
1245
+ # viewer
1246
+ # ===================================================================
1247
+
1248
+ def cmd_viewer(args):
1249
+ """Launch the file viewer, or print install instructions if not installed."""
1250
+ project_path = getattr(args, "project", None) or os.getcwd()
1251
+ if not os.path.isdir(project_path):
1252
+ print(f"agent-trace viewer: project path is not a directory: {project_path}", file=sys.stderr)
1253
+ sys.exit(1)
1254
+
1255
+ if not os.path.isfile(VIEWER_BIN) or not os.access(VIEWER_BIN, os.X_OK):
1256
+ print("Viewer is not installed.", file=sys.stderr)
1257
+ print("", file=sys.stderr)
1258
+ print("Re-run install.sh from the agent-trace-cli repo to install the viewer:", file=sys.stderr)
1259
+ print(" cd agent-trace-cli && ./install.sh", file=sys.stderr)
1260
+ sys.exit(1)
1261
+
1262
+ # Exec the viewer with project path as first argument
1263
+ os.execv(VIEWER_BIN, [VIEWER_BIN, project_path])
1264
+
1265
+
1266
+ # ===================================================================
1267
+ # blame
1268
+ # ===================================================================
1269
+
1270
+ def cmd_blame(args):
1271
+ """Show AI attribution for a file."""
1272
+ # Parse --range if provided (e.g. "10-25")
1273
+ start_line = None
1274
+ end_line = None
1275
+ if getattr(args, "range", None):
1276
+ parts = args.range.split("-", 1)
1277
+ try:
1278
+ start_line = int(parts[0])
1279
+ end_line = int(parts[1]) if len(parts) > 1 else start_line
1280
+ except (ValueError, IndexError):
1281
+ print(f"Invalid range: {args.range} (expected format: START-END)")
1282
+ sys.exit(1)
1283
+
1284
+ project_dir = None
1285
+ if getattr(args, "project", None):
1286
+ project_dir = cli_resolve_project_root(args.project)
1287
+ else:
1288
+ amb = discover_ambiguous_repo_roots()
1289
+ if len(amb) > 1:
1290
+ print(
1291
+ "agent-trace: current directory spans multiple git repositories; "
1292
+ "pass --project <path|id> to choose one:",
1293
+ file=sys.stderr,
1294
+ )
1295
+ for r in amb:
1296
+ print(f" {r}", file=sys.stderr)
1297
+ sys.exit(1)
1298
+
1299
+ result = blame_file(
1300
+ args.file,
1301
+ line=getattr(args, "line", None),
1302
+ start_line=start_line,
1303
+ end_line=end_line,
1304
+ show_no_attribution=getattr(args, "show_no_attribution", False),
1305
+ require_attribution=getattr(args, "require_attribution", False),
1306
+ json_output=getattr(args, "json", False),
1307
+ project_dir=project_dir,
1308
+ )
1309
+ if result is not None:
1310
+ print(result)
1311
+
1312
+
1313
+ # ===================================================================
1314
+ # context
1315
+ # ===================================================================
1316
+
1317
+ def cmd_context(args):
1318
+ """Get conversation context for AI-attributed code."""
1319
+ project_dir = None
1320
+ if getattr(args, "project", None):
1321
+ project_dir = cli_resolve_project_root(args.project)
1322
+ else:
1323
+ amb = discover_ambiguous_repo_roots()
1324
+ if len(amb) > 1:
1325
+ print(
1326
+ "agent-trace: current directory spans multiple git repositories; "
1327
+ "pass --project <path|id>:",
1328
+ file=sys.stderr,
1329
+ )
1330
+ for r in amb:
1331
+ print(f" {r}", file=sys.stderr)
1332
+ sys.exit(1)
1333
+
1334
+ context_command(
1335
+ args.file,
1336
+ lines_range=getattr(args, "lines", None),
1337
+ full=getattr(args, "full", False),
1338
+ json_output=getattr(args, "json", False),
1339
+ query=getattr(args, "query", None),
1340
+ project_dir=project_dir,
1341
+ )
1342
+
1343
+
1344
+ # ===================================================================
1345
+ # rule
1346
+ # ===================================================================
1347
+
1348
+ def cmd_rule(args):
1349
+ """Manage agent rules."""
1350
+ rule_action = getattr(args, "rule_action", None)
1351
+
1352
+ rule_tools_help = " | ".join(list(TOOL_CHOICES))
1353
+ if rule_action == "add":
1354
+ tool = getattr(args, "tool", None)
1355
+ if not tool:
1356
+ print(f"--tool is required. Use --tool <{rule_tools_help}>", file=sys.stderr)
1357
+ sys.exit(1)
1358
+ path = add_rule(args.rule_name, tool)
1359
+ print(f"Rule '{args.rule_name}' added for {tool}: {path}")
1360
+
1361
+ elif rule_action == "remove":
1362
+ tool = getattr(args, "tool", None)
1363
+ if not tool:
1364
+ print(f"--tool is required. Use --tool <{rule_tools_help}>", file=sys.stderr)
1365
+ sys.exit(1)
1366
+ path = remove_rule(args.rule_name, tool)
1367
+ if path:
1368
+ print(f"Rule '{args.rule_name}' removed for {tool}: {path}")
1369
+ else:
1370
+ print(f"Rule '{args.rule_name}' is not configured for {tool}.")
1371
+
1372
+ elif rule_action == "show":
1373
+ active = show_rules()
1374
+ if not active:
1375
+ print("No agent-trace rules are configured.")
1376
+ print("Use 'agent-trace rule list' to see available rules.")
1377
+ return
1378
+ print("Configured agent-trace rules:\n")
1379
+ for entry in active:
1380
+ print(f" {entry['name']:<25} tool: {entry['tool']:<10} {entry['path']}")
1381
+ print()
1382
+
1383
+ elif rule_action == "list":
1384
+ available = list_available_rules()
1385
+ if not available:
1386
+ print("No prebuilt rules available.")
1387
+ return
1388
+ print("Available agent-trace rules:\n")
1389
+ for entry in available:
1390
+ print(f" {entry['name']:<25} {entry['description']}")
1391
+ print()
1392
+ print(f"Add a rule with: agent-trace rule add <name> --tool <{'|'.join(list(TOOL_CHOICES))}>")
1393
+
1394
+ else:
1395
+ print("Usage: agent-trace rule {add,remove,show,list}")
1396
+ print("Run 'agent-trace rule --help' for details.")
1397
+
1398
+
1399
+ # ===================================================================
1400
+ # set globaluser
1401
+ # ===================================================================
1402
+
1403
+ def cmd_set_globaluser(args):
1404
+ config = get_global_config()
1405
+ config["auth_token"] = args.token
1406
+ save_global_config(config)
1407
+ from .storage import get_global_config_file
1408
+ print(f"Global auth token saved to {get_global_config_file()}")
1409
+
1410
+
1411
+ # ===================================================================
1412
+ # remove globaluser
1413
+ # ===================================================================
1414
+
1415
+ def cmd_remove_globaluser(_args):
1416
+ config = get_global_config()
1417
+ if "auth_token" in config:
1418
+ del config["auth_token"]
1419
+ save_global_config(config)
1420
+ print("Global auth token removed.")
1421
+ else:
1422
+ print("No global auth token is currently configured.")
1423
+
1424
+
1425
+ # ===================================================================
1426
+ # projects / adopt (Phase 1b registry)
1427
+ # ===================================================================
1428
+
1429
+ def cmd_projects(args):
1430
+ """List registered projects or show one."""
1431
+ import json
1432
+
1433
+ from .registry import get_project_record
1434
+
1435
+ rest = list(getattr(args, "projects_args", None) or [])
1436
+ if len(rest) >= 2 and rest[0] == "show":
1437
+ rec = get_project_record(rest[1])
1438
+ if not rec:
1439
+ print(f"agent-trace: unknown project id: {rest[1]}", file=sys.stderr)
1440
+ sys.exit(1)
1441
+ print(json.dumps({"project_id": rest[1], **rec}, indent=2))
1442
+ return
1443
+ if rest:
1444
+ print("Usage: agent-trace projects | agent-trace projects show <project_id>", file=sys.stderr)
1445
+ sys.exit(1)
1446
+
1447
+ rows = list_projects()
1448
+ if not rows:
1449
+ print("No registered projects.")
1450
+ return
1451
+ for row in rows:
1452
+ pid = row.get("project_id", "?")
1453
+ root = row.get("canonical_root", "")
1454
+ print(f" {pid} {root}")
1455
+ print()
1456
+
1457
+
1458
+ def cmd_adopt(args):
1459
+ """Register a repository and print its stable project_id."""
1460
+ path = os.path.abspath(os.path.expanduser(getattr(args, "adopt_path", ".") or "."))
1461
+ gr = git_repo_root_for_path(path)
1462
+ if not gr:
1463
+ print("agent-trace adopt: not a git repository", file=sys.stderr)
1464
+ sys.exit(1)
1465
+ pid = lookup_or_create_project_id(gr)
1466
+ print(pid)
1467
+
1468
+
1469
+ # ===================================================================
1470
+ # project create — register a project on a remote service
1471
+ # ===================================================================
1472
+
1473
+ def cmd_project(args):
1474
+ """Server-side project administration (currently: create)."""
1475
+ action = getattr(args, "project_action", None)
1476
+ if action == "create":
1477
+ return _cmd_project_create(args)
1478
+ print(
1479
+ "Usage: agent-trace project create <remote-url> [--token TOKEN | --token-env VAR] "
1480
+ "[--name NAME] [--description TEXT]",
1481
+ file=sys.stderr,
1482
+ )
1483
+ sys.exit(2)
1484
+
1485
+
1486
+ def _cmd_project_create(args):
1487
+ url = args.url
1488
+ try:
1489
+ base_url, org_slug, project_slug = parse_remote_url(url)
1490
+ except RemoteUrlError as e:
1491
+ print(f"agent-trace project create: {e}", file=sys.stderr)
1492
+ sys.exit(1)
1493
+
1494
+ token_value = getattr(args, "token", None)
1495
+ token_env = getattr(args, "token_env", None)
1496
+ if token_value is None and token_env:
1497
+ token_value = os.environ.get(token_env)
1498
+ admin_secret = os.environ.get("AGENT_TRACE_ADMIN_SECRET")
1499
+
1500
+ if not token_value and not admin_secret:
1501
+ print(
1502
+ "agent-trace project create: a token is required. Pass --token / --token-env, "
1503
+ "or set AGENT_TRACE_ADMIN_SECRET to use the admin path.",
1504
+ file=sys.stderr,
1505
+ )
1506
+ sys.exit(1)
1507
+
1508
+ # Pre-flight scope check: refuse to create the project if the URL's
1509
+ # ``<org_slug>`` doesn't match the token's actual org. Without this,
1510
+ # the server would (silently) create the row under the token's org and
1511
+ # the local remote would record the wrong org slug — drift the user
1512
+ # only notices when traces vanish from "their" org.
1513
+ if token_value:
1514
+ try:
1515
+ assert_token_matches_url(
1516
+ base_url, token_value,
1517
+ expected_org_slug=org_slug,
1518
+ expected_project_slug=project_slug,
1519
+ )
1520
+ except TokenScopeError as e:
1521
+ print(f"agent-trace project create: {e}", file=sys.stderr)
1522
+ sys.exit(1)
1523
+
1524
+ try:
1525
+ proj = register_project_via_remote(
1526
+ base_url, project_slug,
1527
+ org_slug=org_slug,
1528
+ token=token_value, admin_secret=admin_secret,
1529
+ name=getattr(args, "name", None),
1530
+ description=getattr(args, "description", None),
1531
+ )
1532
+ except ProjectRegistrationError as e:
1533
+ print(f"agent-trace project create: {e}", file=sys.stderr)
1534
+ sys.exit(1 if e.status != 409 else 2)
1535
+
1536
+ print(json.dumps(proj, indent=2))
1537
+
1538
+
1539
+ # ===================================================================
1540
+ # remote
1541
+ # ===================================================================
1542
+
1543
+ def _resolve_pid_for_remote():
1544
+ from .storage import resolve_project_id
1545
+ pid = resolve_project_id(os.getcwd(), create=False)
1546
+ if not pid:
1547
+ print("agent-trace: not in an initialised project. Run 'agent-trace init' first.", file=sys.stderr)
1548
+ sys.exit(1)
1549
+ return pid
1550
+
1551
+
1552
+ def cmd_remote(args):
1553
+ """Manage named remotes (git remote-like)."""
1554
+ action = getattr(args, "remote_action", None)
1555
+
1556
+ if action == "add":
1557
+ pid = _resolve_pid_for_remote()
1558
+ url = args.url
1559
+ token_value = getattr(args, "token", None)
1560
+ token_env = getattr(args, "token_env", None)
1561
+
1562
+ # Parse + scope-check up front, regardless of --create. We refuse to
1563
+ # bind a remote whose URL points at one org while the token belongs
1564
+ # to another — local config and server state would silently diverge.
1565
+ try:
1566
+ base_url, url_org_slug, url_project_slug = parse_remote_url(url)
1567
+ except RemoteUrlError as e:
1568
+ print(f"agent-trace remote add: {e}", file=sys.stderr)
1569
+ sys.exit(1)
1570
+
1571
+ resolved_token = token_value
1572
+ if resolved_token is None and token_env:
1573
+ resolved_token = os.environ.get(token_env)
1574
+
1575
+ if resolved_token:
1576
+ try:
1577
+ assert_token_matches_url(
1578
+ base_url, resolved_token,
1579
+ expected_org_slug=url_org_slug,
1580
+ expected_project_slug=url_project_slug,
1581
+ )
1582
+ except TokenScopeError as e:
1583
+ print(f"agent-trace remote add: {e}", file=sys.stderr)
1584
+ sys.exit(1)
1585
+
1586
+ if getattr(args, "create", False):
1587
+ admin_secret = os.environ.get("AGENT_TRACE_ADMIN_SECRET")
1588
+ if not resolved_token and not admin_secret:
1589
+ print(
1590
+ "agent-trace remote add --create: a token is required to register the "
1591
+ "project. Pass --token, --token-env, or set AGENT_TRACE_ADMIN_SECRET.",
1592
+ file=sys.stderr,
1593
+ )
1594
+ sys.exit(1)
1595
+ try:
1596
+ proj = register_project_via_remote(
1597
+ base_url, url_project_slug,
1598
+ org_slug=url_org_slug,
1599
+ token=resolved_token, admin_secret=admin_secret,
1600
+ )
1601
+ print(f"Registered project '{proj['project_id']}' on {base_url}")
1602
+ except ProjectRegistrationError as e:
1603
+ if e.status == 409:
1604
+ print(f"Project '{url_project_slug}' already exists; binding the remote anyway.")
1605
+ else:
1606
+ print(f"agent-trace remote add --create: {e}", file=sys.stderr)
1607
+ sys.exit(1)
1608
+
1609
+ try:
1610
+ entry = remote_add(
1611
+ pid, args.name, url,
1612
+ token=token_value, token_env=token_env,
1613
+ )
1614
+ print(f"Remote '{args.name}' added: {entry['url']}")
1615
+ except ValueError as e:
1616
+ print(f"agent-trace remote add: {e}", file=sys.stderr)
1617
+ sys.exit(1)
1618
+
1619
+ elif action == "list":
1620
+ pid = _resolve_pid_for_remote()
1621
+ remotes = remote_list(pid)
1622
+ if not remotes:
1623
+ print("No remotes configured. Run 'agent-trace remote add <name> <url>'.")
1624
+ return
1625
+ for r in remotes:
1626
+ ref = r["token_ref"] or "(no auth)"
1627
+ print(f" {r['name']:<15} {r['url']} (token: {ref})")
1628
+
1629
+ elif action == "show":
1630
+ pid = _resolve_pid_for_remote()
1631
+ info = remote_show(pid, args.name)
1632
+ if not info:
1633
+ print(f"Remote '{args.name}' not found.", file=sys.stderr)
1634
+ sys.exit(1)
1635
+ print(f" Name: {info['name']}")
1636
+ print(f" URL: {info['url']}")
1637
+ print(f" Host: {info['base_url']}")
1638
+ print(f" Org: {info['org_slug']}")
1639
+ print(f" Project: {info['project_slug']}")
1640
+ print(f" Auth: {info['auth_type']}")
1641
+ print(f" Token: {info['token_masked']} (ref: {info['token_ref']})")
1642
+
1643
+ elif action == "set-url":
1644
+ pid = _resolve_pid_for_remote()
1645
+ # Validate the new URL's slugs against whatever token the remote
1646
+ # already holds. Same rationale as `remote add`: a URL change must
1647
+ # not leave the local remote pointing at one org while the token
1648
+ # belongs to another.
1649
+ try:
1650
+ base_url, url_org_slug, url_project_slug = parse_remote_url(args.url)
1651
+ except RemoteUrlError as e:
1652
+ print(f"agent-trace remote set-url: {e}", file=sys.stderr)
1653
+ sys.exit(1)
1654
+ from .remote import get_remote as _get_remote, get_remote_token as _get_token
1655
+ existing = _get_remote(pid, args.name)
1656
+ if existing is None:
1657
+ print(f"agent-trace remote set-url: Remote '{args.name}' does not exist.", file=sys.stderr)
1658
+ sys.exit(1)
1659
+ token_value = _get_token(existing)
1660
+ if token_value:
1661
+ try:
1662
+ assert_token_matches_url(
1663
+ base_url, token_value,
1664
+ expected_org_slug=url_org_slug,
1665
+ expected_project_slug=url_project_slug,
1666
+ )
1667
+ except TokenScopeError as e:
1668
+ print(f"agent-trace remote set-url: {e}", file=sys.stderr)
1669
+ sys.exit(1)
1670
+ try:
1671
+ set_remote_url(pid, args.name, args.url)
1672
+ print(f"Remote '{args.name}' URL updated.")
1673
+ except ValueError as e:
1674
+ print(f"agent-trace remote set-url: {e}", file=sys.stderr)
1675
+ sys.exit(1)
1676
+
1677
+ elif action == "set-token":
1678
+ pid = _resolve_pid_for_remote()
1679
+ # Validate the new token against the remote's current URL before we
1680
+ # persist it, so the user catches an org/project mismatch up front
1681
+ # rather than at the next sync.
1682
+ from .remote import (
1683
+ get_remote as _get_remote,
1684
+ get_remote_base_url as _get_base,
1685
+ get_remote_org_slug as _get_org,
1686
+ get_remote_project_slug as _get_proj,
1687
+ )
1688
+ existing = _get_remote(pid, args.name)
1689
+ if existing is None:
1690
+ print(f"agent-trace remote set-token: Remote '{args.name}' does not exist.", file=sys.stderr)
1691
+ sys.exit(1)
1692
+ new_token = getattr(args, "token", None)
1693
+ new_token_env = getattr(args, "token_env", None)
1694
+ resolved_new_token = new_token
1695
+ if resolved_new_token is None and new_token_env:
1696
+ resolved_new_token = os.environ.get(new_token_env)
1697
+ base_url = _get_base(existing)
1698
+ url_org_slug = _get_org(existing)
1699
+ url_project_slug = _get_proj(existing)
1700
+ if resolved_new_token and base_url and url_org_slug:
1701
+ try:
1702
+ assert_token_matches_url(
1703
+ base_url, resolved_new_token,
1704
+ expected_org_slug=url_org_slug,
1705
+ expected_project_slug=url_project_slug,
1706
+ )
1707
+ except TokenScopeError as e:
1708
+ print(f"agent-trace remote set-token: {e}", file=sys.stderr)
1709
+ sys.exit(1)
1710
+ try:
1711
+ set_remote_token(
1712
+ pid, args.name,
1713
+ token=new_token,
1714
+ token_env=new_token_env,
1715
+ )
1716
+ print(f"Remote '{args.name}' token updated.")
1717
+ except ValueError as e:
1718
+ print(f"agent-trace remote set-token: {e}", file=sys.stderr)
1719
+ sys.exit(1)
1720
+
1721
+ elif action == "remove":
1722
+ pid = _resolve_pid_for_remote()
1723
+ if remote_remove(pid, args.name):
1724
+ print(f"Remote '{args.name}' removed.")
1725
+ else:
1726
+ print(f"Remote '{args.name}' not found.", file=sys.stderr)
1727
+ sys.exit(1)
1728
+
1729
+ elif action == "rename":
1730
+ pid = _resolve_pid_for_remote()
1731
+ try:
1732
+ remote_rename(pid, args.old_name, args.new_name)
1733
+ print(f"Remote '{args.old_name}' renamed to '{args.new_name}'.")
1734
+ except ValueError as e:
1735
+ print(f"agent-trace remote rename: {e}", file=sys.stderr)
1736
+ sys.exit(1)
1737
+
1738
+ elif action == "default":
1739
+ pid = _resolve_pid_for_remote()
1740
+ try:
1741
+ set_default_remote(pid, args.name)
1742
+ print(f"Default remote set to '{args.name}'.")
1743
+ except ValueError as e:
1744
+ print(f"agent-trace remote default: {e}", file=sys.stderr)
1745
+ sys.exit(1)
1746
+ else:
1747
+ print("Usage: agent-trace remote {add,list,show,set-url,set-token,remove,rename,default}")
1748
+
1749
+
1750
+ # ===================================================================
1751
+ # push / pull / sync
1752
+ # ===================================================================
1753
+
1754
+ def cmd_push(args):
1755
+ """Push local data to a remote service."""
1756
+ pid = _resolve_pid_for_remote()
1757
+ try:
1758
+ result = sync_push(
1759
+ pid,
1760
+ remote_name=getattr(args, "remote", None),
1761
+ full=getattr(args, "full", False),
1762
+ only=getattr(args, "only", None),
1763
+ since=getattr(args, "since", None),
1764
+ dry_run=getattr(args, "dry_run", False),
1765
+ )
1766
+ except ValueError as e:
1767
+ print(f"agent-trace push: {e}", file=sys.stderr)
1768
+ sys.exit(1)
1769
+
1770
+ prefix = "[dry-run] " if result.dry_run else ""
1771
+ print(f"{prefix}Pushed {result.traces_pushed} trace(s), "
1772
+ f"{result.ledgers_pushed} ledger(s), "
1773
+ f"{result.commit_links_pushed} commit-link(s), "
1774
+ f"{result.conversations_pushed} conversation(s), "
1775
+ f"{result.summaries_pushed} summary(ies)")
1776
+ if result.traces_held_back:
1777
+ print(f" ({result.traces_held_back} unattributed trace(s) held back; "
1778
+ "use --full to push)")
1779
+ for err in result.errors:
1780
+ print(f" Error: {err}", file=sys.stderr)
1781
+
1782
+
1783
+ def cmd_pull(args):
1784
+ """Pull remote data into local storage."""
1785
+ pid = _resolve_pid_for_remote()
1786
+ try:
1787
+ result = sync_pull(
1788
+ pid,
1789
+ remote_name=getattr(args, "remote", None),
1790
+ since=getattr(args, "since", None),
1791
+ dry_run=getattr(args, "dry_run", False),
1792
+ )
1793
+ except ValueError as e:
1794
+ print(f"agent-trace pull: {e}", file=sys.stderr)
1795
+ sys.exit(1)
1796
+
1797
+ prefix = "[dry-run] " if result.dry_run else ""
1798
+ print(f"{prefix}Pulled {result.traces_pulled} trace(s), "
1799
+ f"{result.ledgers_pulled} ledger(s), "
1800
+ f"{result.commit_links_pulled} commit-link(s), "
1801
+ f"{result.conversations_pulled} conversation(s), "
1802
+ f"{result.summaries_pulled} summary(ies)")
1803
+ for err in result.errors:
1804
+ print(f" Error: {err}", file=sys.stderr)
1805
+
1806
+
1807
+ def cmd_sync(args):
1808
+ """Push + pull in one go."""
1809
+ pid = _resolve_pid_for_remote()
1810
+ rn = getattr(args, "remote", None)
1811
+ try:
1812
+ push_r = sync_push(pid, remote_name=rn)
1813
+ except ValueError as e:
1814
+ print(f"agent-trace sync (push): {e}", file=sys.stderr)
1815
+ push_r = None
1816
+ try:
1817
+ pull_r = sync_pull(pid, remote_name=rn)
1818
+ except ValueError as e:
1819
+ print(f"agent-trace sync (pull): {e}", file=sys.stderr)
1820
+ pull_r = None
1821
+
1822
+ if push_r:
1823
+ print(f"Pushed {push_r.traces_pushed} trace(s), "
1824
+ f"{push_r.ledgers_pushed} ledger(s), "
1825
+ f"{push_r.commit_links_pushed} commit-link(s), "
1826
+ f"{push_r.conversations_pushed} conversation(s), "
1827
+ f"{push_r.summaries_pushed} summary(ies)")
1828
+ if pull_r:
1829
+ print(f"Pulled {pull_r.traces_pulled} trace(s), "
1830
+ f"{pull_r.ledgers_pulled} ledger(s), "
1831
+ f"{pull_r.commit_links_pulled} commit-link(s), "
1832
+ f"{pull_r.conversations_pulled} conversation(s), "
1833
+ f"{pull_r.summaries_pulled} summary(ies)")
1834
+
1835
+
1836
+ # ===================================================================
1837
+ # notes (git refs/notes/agent-trace)
1838
+ # ===================================================================
1839
+
1840
+
1841
+ def _resolve_note_section_flags(
1842
+ args,
1843
+ cwd: str,
1844
+ ) -> tuple[bool, bool, bool, bool]:
1845
+ """Merge ``--include-*`` / ``--no-include-*`` with project ``notes.*`` defaults.
1846
+
1847
+ Returns ``(include_ledger, include_summary, include_prompts,
1848
+ include_all_session_conversations)``.
1849
+ """
1850
+ from .git_notes import all_session_conversations_enabled, project_notes_flags
1851
+
1852
+ d_l, d_s, d_p = project_notes_flags(cwd)
1853
+ d_asc = all_session_conversations_enabled(cwd)
1854
+
1855
+ def one(name: str, default: bool) -> bool:
1856
+ t = getattr(args, f"include_{name}", False)
1857
+ f = getattr(args, f"no_include_{name}", False)
1858
+ if t and f:
1859
+ print(
1860
+ f"agent-trace notes: specify only one of --include-{name} or --no-include-{name}",
1861
+ file=sys.stderr,
1862
+ )
1863
+ sys.exit(1)
1864
+ if t:
1865
+ return True
1866
+ if f:
1867
+ return False
1868
+ return default
1869
+
1870
+ return (
1871
+ one("ledger", d_l),
1872
+ one("summary", d_s),
1873
+ one("prompts", d_p),
1874
+ one("all_session_conversations", d_asc),
1875
+ )
1876
+
1877
+
1878
+ def cmd_summary(args):
1879
+ """Enable/disable/configure pluggable session summaries."""
1880
+ from .storage import resolve_project_id
1881
+ from .summary import get_summary_for_commit, run_summary_generate
1882
+ from .git_notes import resolve_commit
1883
+
1884
+ cwd = os.getcwd()
1885
+ action = getattr(args, "summary_action", None)
1886
+
1887
+ if action == "enable":
1888
+ cfg = get_project_config(cwd)
1889
+ if cfg is None:
1890
+ print("agent-trace: project not initialised (run agent-trace init)", file=sys.stderr)
1891
+ sys.exit(1)
1892
+ # Use ``summary_command`` dest so we do not overwrite the top-level ``command`` (subcommand name).
1893
+ cmd = getattr(args, "summary_command", None) or ""
1894
+ if not cmd.strip():
1895
+ print("agent-trace summary enable: --command is required", file=sys.stderr)
1896
+ sys.exit(1)
1897
+ cfg.setdefault("summary", {})
1898
+ cfg["summary"]["enabled"] = True
1899
+ cfg["summary"]["command"] = cmd
1900
+ to = getattr(args, "summary_timeout", None)
1901
+ if to is not None:
1902
+ cfg["summary"]["timeout_seconds"] = int(to)
1903
+ save_project_config(cfg, cwd)
1904
+ print("Session summaries enabled.")
1905
+ return
1906
+
1907
+ if action == "presets":
1908
+ rows = list_summary_presets()
1909
+ print("Built-in summary presets:\n")
1910
+ for row in rows:
1911
+ alias = row["alias"]
1912
+ desc = row["description"]
1913
+ if row.get("needs_model"):
1914
+ dm = row.get("default_model") or DEFAULT_OLLAMA_MODEL
1915
+ print(f" {alias:<16} {desc} (model required; default: {dm})")
1916
+ else:
1917
+ print(f" {alias:<16} {desc}")
1918
+ print()
1919
+ print("Configure one with:")
1920
+ print(" agent-trace summary use <preset> [--model <name>] [--timeout <seconds>]")
1921
+ return
1922
+
1923
+ if action == "use":
1924
+ cfg = get_project_config(cwd)
1925
+ if cfg is None:
1926
+ print("agent-trace: project not initialised (run agent-trace init)", file=sys.stderr)
1927
+ sys.exit(1)
1928
+ alias = (getattr(args, "preset_alias", None) or "").strip()
1929
+ if alias not in PRESET_ALIASES:
1930
+ print(
1931
+ f"agent-trace summary use: unknown preset '{alias}'. "
1932
+ "Run 'agent-trace summary presets'.",
1933
+ file=sys.stderr,
1934
+ )
1935
+ sys.exit(1)
1936
+ model = getattr(args, "model", None)
1937
+ if alias != "ollama-summary" and model:
1938
+ print(
1939
+ "agent-trace summary use: --model is only valid with ollama-summary",
1940
+ file=sys.stderr,
1941
+ )
1942
+ sys.exit(1)
1943
+ command = build_preset_command(alias, model=model)
1944
+ cfg.setdefault("summary", {})
1945
+ cfg["summary"]["enabled"] = True
1946
+ cfg["summary"]["command"] = command
1947
+ to = getattr(args, "summary_timeout", None)
1948
+ if to is not None:
1949
+ cfg["summary"]["timeout_seconds"] = int(to)
1950
+ save_project_config(cfg, cwd)
1951
+ print(f"Session summaries enabled using preset '{alias}'.")
1952
+ print(f"summary.command = {command}")
1953
+ return
1954
+
1955
+ if action == "preset-run":
1956
+ alias = (getattr(args, "preset_alias", None) or "").strip()
1957
+ model = getattr(args, "model", None)
1958
+ transcript_text = sys.stdin.read()
1959
+ code = run_summary_preset(alias, transcript_text, model=model)
1960
+ if code != 0:
1961
+ print(
1962
+ "agent-trace summary preset-run: failed "
1963
+ f"(preset={alias}, ensure required tool is installed and authenticated)",
1964
+ file=sys.stderr,
1965
+ )
1966
+ sys.exit(code)
1967
+ return
1968
+
1969
+ if action == "disable":
1970
+ cfg = get_project_config(cwd)
1971
+ if cfg is None:
1972
+ print("agent-trace: project not initialised", file=sys.stderr)
1973
+ sys.exit(1)
1974
+ cfg.setdefault("summary", {})
1975
+ cfg["summary"]["enabled"] = False
1976
+ save_project_config(cfg, cwd)
1977
+ print("Session summaries disabled.")
1978
+ return
1979
+
1980
+ if action == "generate":
1981
+ cid = (getattr(args, "conversation_id", None) or "").strip()
1982
+ sid = (getattr(args, "session_id", None) or "").strip()
1983
+ if not (cid or sid):
1984
+ print(
1985
+ "agent-trace summary generate: --conversation ID or --session-id SID required",
1986
+ file=sys.stderr,
1987
+ )
1988
+ sys.exit(1)
1989
+ out = run_summary_generate(
1990
+ cwd,
1991
+ conversation_id=cid or None,
1992
+ session_id=sid or None,
1993
+ )
1994
+ if out is None:
1995
+ print("agent-trace summary generate: failed or no transcript", file=sys.stderr)
1996
+ sys.exit(1)
1997
+ print(json.dumps(out, indent=2))
1998
+ return
1999
+
2000
+ if action == "show":
2001
+ rev = getattr(args, "commit", None) or "HEAD"
2002
+ sha = resolve_commit(cwd, rev)
2003
+ if not sha:
2004
+ print(f"agent-trace summary show: bad revision: {rev}", file=sys.stderr)
2005
+ sys.exit(1)
2006
+ pid = resolve_project_id(cwd, create=False)
2007
+ if not pid:
2008
+ print("agent-trace summary show: no project id", file=sys.stderr)
2009
+ sys.exit(1)
2010
+ s = get_summary_for_commit(pid, sha)
2011
+ if not s:
2012
+ print(f"No summaries stored for {sha}", file=sys.stderr)
2013
+ sys.exit(1)
2014
+ print(json.dumps(s, indent=2))
2015
+ return
2016
+
2017
+
2018
+ def cmd_notes(args):
2019
+ """Manage per-commit JSON notes on ``refs/notes/agent-trace``."""
2020
+ from .git_notes import (
2021
+ attach_note,
2022
+ backfill_notes,
2023
+ build_note,
2024
+ git_notes_pull,
2025
+ git_notes_push,
2026
+ read_note,
2027
+ rebuild_notes_for_range,
2028
+ resolve_commit,
2029
+ strip_sections,
2030
+ )
2031
+ from .ledger import load_local_ledgers
2032
+
2033
+ cwd = os.getcwd()
2034
+ action = getattr(args, "notes_action", None)
2035
+
2036
+ if action == "show":
2037
+ rev = getattr(args, "commit", None) or "HEAD"
2038
+ sha = resolve_commit(cwd, rev)
2039
+ if not sha:
2040
+ print(f"agent-trace notes show: bad revision: {rev}", file=sys.stderr)
2041
+ sys.exit(1)
2042
+ note = read_note(sha, cwd)
2043
+ if not note:
2044
+ print(f"No agent-trace note for {sha}", file=sys.stderr)
2045
+ sys.exit(1)
2046
+ print(json.dumps(note, indent=2))
2047
+ return
2048
+
2049
+ if action == "attach":
2050
+ rev = getattr(args, "commit", None) or "HEAD"
2051
+ sha = resolve_commit(cwd, rev)
2052
+ if not sha:
2053
+ print(f"agent-trace notes attach: bad revision: {rev}", file=sys.stderr)
2054
+ sys.exit(1)
2055
+ ledgers = load_local_ledgers(cwd)
2056
+ led = ledgers.get(sha)
2057
+ if not led:
2058
+ print(
2059
+ "agent-trace notes attach: no local ledger for this commit "
2060
+ "(build a ledger first, e.g. make a commit with agent-trace hooks).",
2061
+ file=sys.stderr,
2062
+ )
2063
+ sys.exit(1)
2064
+ il, isum, ipr, iasc = _resolve_note_section_flags(args, cwd)
2065
+ from .git_notes import load_traces_for_ids
2066
+
2067
+ tid_list = [str(x) for x in led.get("trace_ids", [])]
2068
+ traces = load_traces_for_ids(cwd, tid_list)
2069
+ summaries = None
2070
+ if isum:
2071
+ from .summary import merge_note_summaries
2072
+
2073
+ cfg = get_project_config(cwd) or {}
2074
+ nc = cfg.get("notes") or {}
2075
+ static_s = nc.get("summaries") if isinstance(nc.get("summaries"), dict) else None
2076
+ summaries = merge_note_summaries(cwd, led, static_s)
2077
+ asc = None
2078
+ if iasc:
2079
+ from .summary import all_session_conversations_for_ledger
2080
+
2081
+ asc = all_session_conversations_for_ledger(cwd, led)
2082
+ note = build_note(
2083
+ led,
2084
+ traces,
2085
+ include_ledger=il,
2086
+ include_summary=isum,
2087
+ include_prompts=ipr,
2088
+ summaries=summaries,
2089
+ include_all_session_conversations=iasc,
2090
+ all_session_conversations=asc,
2091
+ )
2092
+ if attach_note(sha, note, cwd):
2093
+ print(f"Attached note to {sha}")
2094
+ else:
2095
+ print("agent-trace notes attach: failed", file=sys.stderr)
2096
+ sys.exit(1)
2097
+ return
2098
+
2099
+ if action == "rebuild":
2100
+ range_spec = getattr(args, "range_spec", None)
2101
+ if not range_spec:
2102
+ print("agent-trace notes rebuild: <range> required (e.g. HEAD~10..HEAD)", file=sys.stderr)
2103
+ sys.exit(1)
2104
+ il, isum, ipr, iasc = _resolve_note_section_flags(args, cwd)
2105
+ n = rebuild_notes_for_range(
2106
+ cwd,
2107
+ range_spec,
2108
+ include_ledger=il,
2109
+ include_summary=isum,
2110
+ include_prompts=ipr,
2111
+ include_all_session_conversations=iasc,
2112
+ )
2113
+ print(f"Rebuilt notes for {n} commit(s)")
2114
+ return
2115
+
2116
+ if action == "backfill":
2117
+ il, isum, ipr, iasc = _resolve_note_section_flags(args, cwd)
2118
+ since = getattr(args, "since", None)
2119
+ n = backfill_notes(
2120
+ cwd,
2121
+ since=since,
2122
+ include_ledger=il,
2123
+ include_summary=isum,
2124
+ include_prompts=ipr,
2125
+ include_all_session_conversations=iasc,
2126
+ )
2127
+ print(f"Backfilled notes for {n} commit(s)")
2128
+ return
2129
+
2130
+ if action == "strip":
2131
+ rev = getattr(args, "commit", None) or "HEAD"
2132
+ sha = resolve_commit(cwd, rev)
2133
+ if not sha:
2134
+ print(f"agent-trace notes strip: bad revision: {rev}", file=sys.stderr)
2135
+ sys.exit(1)
2136
+ sections: list[str] = []
2137
+ if getattr(args, "strip_ledger", False):
2138
+ sections.append("ledger")
2139
+ if getattr(args, "strip_summary", False):
2140
+ sections.append("summary")
2141
+ if getattr(args, "strip_prompts", False):
2142
+ sections.append("prompts")
2143
+ if getattr(args, "strip_all_session_conversations", False):
2144
+ sections.append("all_session_conversations")
2145
+ if not sections:
2146
+ print(
2147
+ "agent-trace notes strip: specify at least one of "
2148
+ "--ledger --summary --prompts --all-session-conversations",
2149
+ file=sys.stderr,
2150
+ )
2151
+ sys.exit(1)
2152
+ if strip_sections(sha, sections, cwd):
2153
+ print(f"Stripped {', '.join(sections)} from note on {sha}")
2154
+ else:
2155
+ print("agent-trace notes strip: no note or failed", file=sys.stderr)
2156
+ sys.exit(1)
2157
+ return
2158
+
2159
+ if action == "push":
2160
+ remote = getattr(args, "remote", None) or "origin"
2161
+ ok, msg = git_notes_push(cwd, remote=remote)
2162
+ if ok:
2163
+ print(msg or "push ok")
2164
+ else:
2165
+ print(f"agent-trace notes push: {msg}", file=sys.stderr)
2166
+ sys.exit(1)
2167
+ return
2168
+
2169
+ if action == "pull":
2170
+ remote = getattr(args, "remote", None) or "origin"
2171
+ ok, msg = git_notes_pull(cwd, remote=remote)
2172
+ if ok:
2173
+ print(msg or "fetch ok")
2174
+ else:
2175
+ print(f"agent-trace notes pull: {msg}", file=sys.stderr)
2176
+ sys.exit(1)
2177
+ return
2178
+
2179
+
2180
+ # ===================================================================
2181
+ # telemetry (global flag)
2182
+ # ===================================================================
2183
+
2184
+
2185
+ def cmd_telemetry_control(args) -> None:
2186
+ """Handle ``agent-trace --telemetry on|off|status`` (no subcommand)."""
2187
+ mode = args.telemetry
2188
+ if mode == "on":
2189
+ set_telemetry_enabled(True)
2190
+ print("Anonymous telemetry is now enabled (see docs/concepts/telemetry.md).")
2191
+ return
2192
+ if mode == "off":
2193
+ set_telemetry_enabled(False)
2194
+ print("Telemetry is now disabled.")
2195
+ return
2196
+ for line in telemetry_status_lines():
2197
+ print(line)
2198
+
2199
+
2200
+ # ===================================================================
2201
+ # Entry point
2202
+ # ===================================================================
2203
+
2204
+ def _run_subcommand(args, set_p, rm_p) -> None:
2205
+ """Run one subcommand; may raise ``SystemExit``."""
2206
+ dispatch = {
2207
+ "init": cmd_init,
2208
+ "doctor": cmd_doctor,
2209
+ "status": cmd_status,
2210
+ "reset": cmd_reset,
2211
+ "config": cmd_config,
2212
+ "hooks": cmd_hooks,
2213
+ "record": cmd_record,
2214
+ "commit-link": cmd_commit_link,
2215
+ "rewrite-ledger": cmd_rewrite_ledger,
2216
+ "viewer": cmd_viewer,
2217
+ "blame": cmd_blame,
2218
+ "context": cmd_context,
2219
+ "projects": cmd_projects,
2220
+ "adopt": cmd_adopt,
2221
+ "project": cmd_project,
2222
+ "push": cmd_push,
2223
+ "pull": cmd_pull,
2224
+ "sync": cmd_sync,
2225
+ "notes": cmd_notes,
2226
+ }
2227
+
2228
+ if args.command in dispatch:
2229
+ dispatch[args.command](args)
2230
+ elif args.command == "remote":
2231
+ cmd_remote(args)
2232
+ elif args.command == "rule":
2233
+ cmd_rule(args)
2234
+ elif args.command == "set":
2235
+ if getattr(args, "set_command", None) == "globaluser":
2236
+ cmd_set_globaluser(args)
2237
+ else:
2238
+ set_p.print_help()
2239
+ elif args.command == "remove":
2240
+ if getattr(args, "remove_command", None) == "globaluser":
2241
+ cmd_remove_globaluser(args)
2242
+ else:
2243
+ rm_p.print_help()
2244
+ elif args.command == "summary":
2245
+ cmd_summary(args)
2246
+
2247
+
2248
+ def main():
2249
+ parser = argparse.ArgumentParser(
2250
+ prog="agent-trace",
2251
+ description="agent-trace — AI code tracing tool",
2252
+ )
2253
+ parser.add_argument(
2254
+ "--version", action="version", version=f"agent-trace {VERSION}",
2255
+ )
2256
+ parser.add_argument(
2257
+ "--telemetry",
2258
+ choices=["on", "off", "status"],
2259
+ default=None,
2260
+ metavar="MODE",
2261
+ help="opt-in anonymous telemetry: on, off, or status (no subcommand; default off)",
2262
+ )
2263
+
2264
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
2265
+
2266
+ sub.add_parser("init", help="Initialize agent-trace for the current project")
2267
+ doc_p = sub.add_parser("doctor", help="Check hooks, config, remotes, and optional tools")
2268
+ doc_p.add_argument(
2269
+ "--fix",
2270
+ action="store_true",
2271
+ help="Apply safe automatic fixes (permissions, init, hooks, git notes refspec)",
2272
+ )
2273
+ doc_p.add_argument(
2274
+ "--dry-run",
2275
+ dest="dry_run",
2276
+ action="store_true",
2277
+ help="With --fix, show what would change without modifying the system",
2278
+ )
2279
+ doc_p.add_argument(
2280
+ "--yes",
2281
+ "-y",
2282
+ action="store_true",
2283
+ help="With --fix, apply fixes non-interactively (e.g. run init without prompting)",
2284
+ )
2285
+ sub.add_parser("status", help="Show agent-trace status")
2286
+ sub.add_parser("reset", help="Reset agent-trace configuration")
2287
+ sub_config = sub.add_parser("config", help="Show or update persisted configuration")
2288
+ config_sub = sub_config.add_subparsers(dest="config_action", metavar="ACTION", required=True)
2289
+ c_show = config_sub.add_parser("show", help="Show full persisted configuration")
2290
+ c_show.add_argument("--json", action="store_true", default=False, help="Output as JSON")
2291
+ c_set = config_sub.add_parser("set", help="Set one configuration field")
2292
+ c_set.add_argument("field", choices=sorted(_CONFIG_FIELDS), help="Config field to update")
2293
+ c_set.add_argument("value", help="New value")
2294
+ c_reset = config_sub.add_parser("reset", help="Reset one configuration field or group")
2295
+ c_reset.add_argument("field", choices=sorted(_CONFIG_RESET_TARGETS), help="Config field/group to reset")
2296
+ c_reset.add_argument(
2297
+ "--yes",
2298
+ action="store_true",
2299
+ default=False,
2300
+ help="Reset directly without interactive prompt",
2301
+ )
2302
+
2303
+ # hooks {setup-global, remove-global, status}
2304
+ hook_choices = _hook_tool_choices()
2305
+ hook_choices_help = ", ".join(hook_choices)
2306
+ sub_hooks = sub.add_parser(
2307
+ "hooks",
2308
+ help=f"Manage global hooks for coding tools ({hook_choices_help})",
2309
+ )
2310
+ hooks_sub = sub_hooks.add_subparsers(dest="hooks_action", metavar="ACTION")
2311
+ h_setup = hooks_sub.add_parser(
2312
+ "setup-global",
2313
+ help="Install global hooks for one or more registered coding-agent adapters",
2314
+ )
2315
+ h_setup.add_argument("--tool", "-t", dest="tools", action="append", choices=hook_choices,
2316
+ help="Tool(s) to configure (default: all). Can be repeated.")
2317
+ h_remove = hooks_sub.add_parser("remove-global", help="Remove global hooks")
2318
+ h_remove.add_argument("--tool", "-t", dest="tools", action="append", choices=hook_choices,
2319
+ help="Tool(s) to remove (default: all). Can be repeated.")
2320
+ hooks_sub.add_parser("status", help="Show global hook status")
2321
+
2322
+ sub.add_parser("record", help="Record a trace from stdin (used by hooks)")
2323
+ sub.add_parser("commit-link", help="Link current commit to traces (called by git hook)")
2324
+ sub.add_parser("rewrite-ledger", help="Remap ledgers after rebase/amend (called by git hook)")
2325
+
2326
+ # viewer [--project /path]
2327
+ sub_viewer = sub.add_parser("viewer", help="Open the file viewer (browse files, git + agent-trace blame)")
2328
+ sub_viewer.add_argument("--project", "-p", default=None, help="Project directory (default: current directory)")
2329
+
2330
+ # blame <file>
2331
+ sub_blame = sub.add_parser("blame", help="Show AI attribution for a file")
2332
+ sub_blame.add_argument("file", help="File path to blame")
2333
+ sub_blame.add_argument("--line", "-l", type=int, default=None,
2334
+ help="Specific line number")
2335
+ sub_blame.add_argument("--range", "-r", default=None,
2336
+ help="Line range (e.g. 10-25)")
2337
+ sub_blame.add_argument(
2338
+ "--project", "-p", default=None,
2339
+ help="Git repo root path or registry project_id (disambiguate multi-repo cwd)",
2340
+ )
2341
+ sub_blame.add_argument("--json", action="store_true", default=False,
2342
+ help="Output as JSON")
2343
+ sub_blame.add_argument(
2344
+ "--show-no-attribution",
2345
+ action="store_true",
2346
+ default=False,
2347
+ help="Include lines not attributed to AI (NO_ATTRIBUTION); default is to omit them",
2348
+ )
2349
+ sub_blame.add_argument(
2350
+ "--require-attribution",
2351
+ action="store_true",
2352
+ default=False,
2353
+ help="Exit with non-zero status if any line is NO_ATTRIBUTION (for CI)",
2354
+ )
2355
+
2356
+ # context <file>
2357
+ sub_context = sub.add_parser("context", help="Get conversation context for AI-attributed code")
2358
+ sub_context.add_argument("file", help="File path to get context for")
2359
+ sub_context.add_argument("--lines", "-l", default=None,
2360
+ help="Line range (e.g. 10-25)")
2361
+ sub_context.add_argument(
2362
+ "--project", "-p", default=None,
2363
+ help="Git repo root path or registry project_id (disambiguate multi-repo cwd)",
2364
+ )
2365
+ sub_context.add_argument("--full", action="store_true", default=False,
2366
+ help="Include full conversation transcript")
2367
+ sub_context.add_argument("--json", action="store_true", default=False,
2368
+ help="Output as JSON (for machine consumption)")
2369
+ sub_context.add_argument("--query", "-q", default=None,
2370
+ help="Query to pass through for subagent instruction")
2371
+
2372
+ # rule {add,remove,show,list}
2373
+ sub_rule = sub.add_parser("rule", help="Manage agent rules for coding agents")
2374
+ rule_sub = sub_rule.add_subparsers(dest="rule_action", metavar="ACTION")
2375
+
2376
+ rule_tool_choices = list(TOOL_CHOICES)
2377
+ rule_tool_help = " | ".join(rule_tool_choices) or "(no tools registered)"
2378
+
2379
+ # rule add <name> --tool <...>
2380
+ rule_add = rule_sub.add_parser("add", help="Add a prebuilt rule")
2381
+ rule_add.add_argument("rule_name", help="Rule name (e.g. context-for-agents)")
2382
+ rule_add.add_argument("--tool", "-t", required=True, choices=rule_tool_choices,
2383
+ help=f"Tool to add the rule for ({rule_tool_help})")
2384
+
2385
+ # rule remove <name> --tool <...>
2386
+ rule_rm = rule_sub.add_parser("remove", help="Remove a rule")
2387
+ rule_rm.add_argument("rule_name", help="Rule name (e.g. context-for-agents)")
2388
+ rule_rm.add_argument("--tool", "-t", required=True, choices=rule_tool_choices,
2389
+ help=f"Tool to remove the rule from ({rule_tool_help})")
2390
+
2391
+ # rule show
2392
+ rule_sub.add_parser("show", help="Show which rules are configured")
2393
+
2394
+ # rule list
2395
+ rule_sub.add_parser("list", help="List available prebuilt rules")
2396
+
2397
+ # set globaluser <token>
2398
+ set_p = sub.add_parser("set", help="Set global configuration")
2399
+ set_sub = set_p.add_subparsers(dest="set_command", metavar="KEY")
2400
+ gu = set_sub.add_parser("globaluser", help="Set global auth token")
2401
+ gu.add_argument("token", help="The auth token to store globally")
2402
+
2403
+ # remove globaluser
2404
+ rm_p = sub.add_parser("remove", help="Remove global configuration")
2405
+ rm_sub = rm_p.add_subparsers(dest="remove_command", metavar="KEY")
2406
+ rm_sub.add_parser("globaluser", help="Remove global auth token")
2407
+
2408
+ # remote {add,list,show,set-url,set-token,remove,rename,default}
2409
+ sub_remote = sub.add_parser("remote", help="Manage named remotes (git remote-like)")
2410
+ remote_sub = sub_remote.add_subparsers(dest="remote_action", metavar="ACTION")
2411
+
2412
+ r_add = remote_sub.add_parser("add", help="Add a remote")
2413
+ r_add.add_argument("name", help="Remote name (e.g. origin)")
2414
+ r_add.add_argument("url", help="Remote URL: <scheme>://<host>/<org>/<project>")
2415
+ r_add.add_argument("--token", default=None, help="Auth token (stored globally)")
2416
+ r_add.add_argument("--token-env", default=None, help="Environment variable holding the token")
2417
+ r_add.add_argument(
2418
+ "--create", action="store_true", default=False,
2419
+ help="Register the project on the remote service before storing the remote",
2420
+ )
2421
+
2422
+ r_list = remote_sub.add_parser("list", help="List remotes")
2423
+
2424
+ r_show = remote_sub.add_parser("show", help="Show remote details (token masked)")
2425
+ r_show.add_argument("name", help="Remote name")
2426
+
2427
+ r_seturl = remote_sub.add_parser("set-url", help="Change remote URL")
2428
+ r_seturl.add_argument("name", help="Remote name")
2429
+ r_seturl.add_argument("url", help="New URL")
2430
+
2431
+ r_settok = remote_sub.add_parser("set-token", help="Update remote auth token")
2432
+ r_settok.add_argument("name", help="Remote name")
2433
+ r_settok.add_argument("--token", default=None, help="New auth token")
2434
+ r_settok.add_argument("--token-env", default=None, help="Environment variable holding the token")
2435
+
2436
+ r_rm = remote_sub.add_parser("remove", help="Remove a remote")
2437
+ r_rm.add_argument("name", help="Remote name")
2438
+
2439
+ r_ren = remote_sub.add_parser("rename", help="Rename a remote")
2440
+ r_ren.add_argument("old_name", help="Current name")
2441
+ r_ren.add_argument("new_name", help="New name")
2442
+
2443
+ r_def = remote_sub.add_parser("default", help="Set default remote")
2444
+ r_def.add_argument("name", help="Remote name to set as default")
2445
+
2446
+ # push
2447
+ sub_push = sub.add_parser("push", help="Push local data to a remote")
2448
+ sub_push.add_argument("--remote", default=None, help="Remote name (default: auto)")
2449
+ sub_push.add_argument("--full", action="store_true", default=False,
2450
+ help="Include unattributed traces (default: attributed only)")
2451
+ sub_push.add_argument("--only", default=None, choices=["traces", "ledgers", "commit-links"],
2452
+ help="Push only one artifact type")
2453
+ sub_push.add_argument("--since", default=None, help="Push only since this timestamp/commit")
2454
+ sub_push.add_argument("--dry-run", action="store_true", default=False,
2455
+ help="Show what would be pushed without sending")
2456
+
2457
+ # pull
2458
+ sub_pull = sub.add_parser("pull", help="Pull remote data into local storage")
2459
+ sub_pull.add_argument("--remote", default=None, help="Remote name (default: auto)")
2460
+ sub_pull.add_argument("--since", default=None, help="Pull only since this timestamp")
2461
+ sub_pull.add_argument("--dry-run", action="store_true", default=False,
2462
+ help="Show what would be pulled without writing")
2463
+
2464
+ # sync
2465
+ sub_sync = sub.add_parser("sync", help="Push + pull in one go")
2466
+ sub_sync.add_argument("--remote", default=None, help="Remote name (default: auto)")
2467
+
2468
+ p_projects = sub.add_parser("projects", help="List registered projects (or: projects show <id>)")
2469
+ p_projects.add_argument("projects_args", nargs="*", default=[], help="show <project_id>")
2470
+
2471
+ p_adopt = sub.add_parser("adopt", help="Register a repo and print its project_id")
2472
+ p_adopt.add_argument(
2473
+ "adopt_path",
2474
+ nargs="?",
2475
+ default=".",
2476
+ help="Path to repository (default: current directory)",
2477
+ )
2478
+
2479
+ # project {create}
2480
+ p_project = sub.add_parser("project", help="Server-side project administration")
2481
+ project_sub = p_project.add_subparsers(dest="project_action", metavar="ACTION")
2482
+
2483
+ p_proj_create = project_sub.add_parser(
2484
+ "create",
2485
+ help="Register a project on a remote service (POST /api/v1/projects)",
2486
+ )
2487
+ p_proj_create.add_argument(
2488
+ "url",
2489
+ help="Remote URL: <scheme>://<host>/<org>/<project>",
2490
+ )
2491
+ p_proj_create.add_argument("--token", default=None, help="Org-scoped token (with projects:write)")
2492
+ p_proj_create.add_argument(
2493
+ "--token-env", default=None,
2494
+ help="Environment variable holding the token",
2495
+ )
2496
+ p_proj_create.add_argument("--name", default=None, help="Optional human-readable display name")
2497
+ p_proj_create.add_argument("--description", default=None, help="Optional description")
2498
+
2499
+ # notes {show, attach, rebuild, backfill, strip, push, pull}
2500
+ sub_notes = sub.add_parser("notes", help="Git notes (refs/notes/agent-trace)")
2501
+ notes_sub = sub_notes.add_subparsers(dest="notes_action", metavar="ACTION", required=True)
2502
+
2503
+ ns_show = notes_sub.add_parser("show", help="Print JSON note for a commit")
2504
+ ns_show.add_argument("commit", nargs="?", default="HEAD", help="Commit (default: HEAD)")
2505
+
2506
+ ns_attach = notes_sub.add_parser("attach", help="Build note from local ledger and attach")
2507
+ ns_attach.add_argument("commit", nargs="?", default="HEAD", help="Commit (default: HEAD)")
2508
+ ns_attach.add_argument("--include-ledger", action="store_true", help="Include ledger section")
2509
+ ns_attach.add_argument("--no-include-ledger", action="store_true", help="Omit ledger section")
2510
+ ns_attach.add_argument("--include-summary", action="store_true")
2511
+ ns_attach.add_argument("--no-include-summary", action="store_true")
2512
+ ns_attach.add_argument("--include-prompts", action="store_true")
2513
+ ns_attach.add_argument("--no-include-prompts", action="store_true")
2514
+ ns_attach.add_argument(
2515
+ "--include-all-session-conversations",
2516
+ action="store_true",
2517
+ help="Include all_session_conversations section (staging window)",
2518
+ )
2519
+ ns_attach.add_argument(
2520
+ "--no-include-all-session-conversations",
2521
+ action="store_true",
2522
+ help="Omit all_session_conversations section",
2523
+ )
2524
+
2525
+ ns_rebuild = notes_sub.add_parser("rebuild", help="Rebuild notes from local ledgers for a commit range")
2526
+ ns_rebuild.add_argument("range_spec", help="Range for git rev-list (e.g. HEAD~10..HEAD)")
2527
+ ns_rebuild.add_argument("--include-ledger", action="store_true")
2528
+ ns_rebuild.add_argument("--no-include-ledger", action="store_true")
2529
+ ns_rebuild.add_argument("--include-summary", action="store_true")
2530
+ ns_rebuild.add_argument("--no-include-summary", action="store_true")
2531
+ ns_rebuild.add_argument("--include-prompts", action="store_true")
2532
+ ns_rebuild.add_argument("--no-include-prompts", action="store_true")
2533
+ ns_rebuild.add_argument("--include-all-session-conversations", action="store_true")
2534
+ ns_rebuild.add_argument("--no-include-all-session-conversations", action="store_true")
2535
+
2536
+ ns_backfill = notes_sub.add_parser("backfill", help="Rebuild notes for commits (optional --since)")
2537
+ ns_backfill.add_argument("--since", default=None, help="git rev-list --since (e.g. 2026-01-01)")
2538
+ ns_backfill.add_argument("--include-ledger", action="store_true")
2539
+ ns_backfill.add_argument("--no-include-ledger", action="store_true")
2540
+ ns_backfill.add_argument("--include-summary", action="store_true")
2541
+ ns_backfill.add_argument("--no-include-summary", action="store_true")
2542
+ ns_backfill.add_argument("--include-prompts", action="store_true")
2543
+ ns_backfill.add_argument("--no-include-prompts", action="store_true")
2544
+ ns_backfill.add_argument("--include-all-session-conversations", action="store_true")
2545
+ ns_backfill.add_argument("--no-include-all-session-conversations", action="store_true")
2546
+
2547
+ ns_strip = notes_sub.add_parser("strip", help="Remove optional sections from a note")
2548
+ ns_strip.add_argument("commit", nargs="?", default="HEAD")
2549
+ ns_strip.add_argument("--ledger", dest="strip_ledger", action="store_true")
2550
+ ns_strip.add_argument("--summary", dest="strip_summary", action="store_true")
2551
+ ns_strip.add_argument("--prompts", dest="strip_prompts", action="store_true")
2552
+ ns_strip.add_argument(
2553
+ "--all-session-conversations",
2554
+ dest="strip_all_session_conversations",
2555
+ action="store_true",
2556
+ )
2557
+
2558
+ ns_npush = notes_sub.add_parser("push", help="Push refs/notes/agent-trace to a remote")
2559
+ ns_npush.add_argument("--remote", default="origin")
2560
+
2561
+ ns_npull = notes_sub.add_parser("pull", help="Fetch refs/notes/agent-trace from a remote")
2562
+ ns_npull.add_argument("--remote", default="origin")
2563
+
2564
+ sub_summary = sub.add_parser(
2565
+ "summary",
2566
+ help="Pluggable transcript summaries (command reads raw transcript on stdin, prints summary on stdout)",
2567
+ )
2568
+ sum_sub = sub_summary.add_subparsers(dest="summary_action", metavar="ACTION", required=True)
2569
+ s_en = sum_sub.add_parser("enable", help="Enable and set the summary command")
2570
+ s_en.add_argument(
2571
+ "--command",
2572
+ dest="summary_command",
2573
+ required=True,
2574
+ help="Executable: raw transcript on stdin, summary text on stdout",
2575
+ )
2576
+ s_en.add_argument(
2577
+ "--timeout",
2578
+ dest="summary_timeout",
2579
+ type=int,
2580
+ default=None,
2581
+ help="Timeout seconds (default 30)",
2582
+ )
2583
+ sum_sub.add_parser("presets", help="List built-in summary presets")
2584
+ s_use = sum_sub.add_parser("use", help="Enable a built-in summary preset")
2585
+ s_use.add_argument("preset_alias", choices=PRESET_ALIASES, help="Preset alias")
2586
+ s_use.add_argument(
2587
+ "--model",
2588
+ default=None,
2589
+ help="Model name for ollama-summary (ignored by other presets)",
2590
+ )
2591
+ s_use.add_argument(
2592
+ "--timeout",
2593
+ dest="summary_timeout",
2594
+ type=int,
2595
+ default=None,
2596
+ help="Timeout seconds (default 30)",
2597
+ )
2598
+ s_pr = sum_sub.add_parser(
2599
+ "preset-run",
2600
+ help=argparse.SUPPRESS, # internal: summary.command target for built-in presets
2601
+ )
2602
+ s_pr.add_argument("preset_alias", choices=PRESET_ALIASES)
2603
+ s_pr.add_argument("--model", default=None)
2604
+ sum_sub.add_parser("disable", help="Disable session-end summaries")
2605
+ s_gen = sum_sub.add_parser(
2606
+ "generate",
2607
+ help="Re-run summary for a conversation id or every id in a session",
2608
+ )
2609
+ s_gen.add_argument(
2610
+ "--conversation",
2611
+ dest="conversation_id",
2612
+ default=None,
2613
+ help="A specific conversation_id (64-hex sha256 over the original transcript URL)",
2614
+ )
2615
+ s_gen.add_argument(
2616
+ "--session-id",
2617
+ dest="session_id",
2618
+ default=None,
2619
+ help="session_id; regenerates every conversation id referenced by traces in that session",
2620
+ )
2621
+ s_show = sum_sub.add_parser(
2622
+ "show",
2623
+ help="Show {conversation_id: summary} for a commit",
2624
+ )
2625
+ s_show.add_argument("commit", nargs="?", default="HEAD", help="Commit (default HEAD)")
2626
+
2627
+ args = parser.parse_args()
2628
+
2629
+ if getattr(args, "telemetry", None) is not None:
2630
+ if args.command is not None:
2631
+ parser.error(
2632
+ "--telemetry cannot be combined with a subcommand "
2633
+ "(example: agent-trace --telemetry status)",
2634
+ )
2635
+ cmd_telemetry_control(args)
2636
+ return
2637
+
2638
+ if args.command is None:
2639
+ parser.print_help()
2640
+ sys.exit(0)
2641
+
2642
+ start = time.monotonic()
2643
+ try:
2644
+ _run_subcommand(args, set_p, rm_p)
2645
+ except SystemExit as exc:
2646
+ code = exc.code
2647
+ exit_code = (
2648
+ 0
2649
+ if code is None
2650
+ else (code if isinstance(code, int) else (1 if code else 0))
2651
+ )
2652
+ duration_ms = int((time.monotonic() - start) * 1000)
2653
+ maybe_report_cli_run(
2654
+ version=VERSION,
2655
+ command=args.command,
2656
+ exit_code=exit_code,
2657
+ duration_ms=duration_ms,
2658
+ )
2659
+ raise
2660
+ except BaseException:
2661
+ duration_ms = int((time.monotonic() - start) * 1000)
2662
+ maybe_report_cli_run(
2663
+ version=VERSION,
2664
+ command=args.command,
2665
+ exit_code=1,
2666
+ duration_ms=duration_ms,
2667
+ )
2668
+ raise
2669
+ else:
2670
+ duration_ms = int((time.monotonic() - start) * 1000)
2671
+ maybe_report_cli_run(
2672
+ version=VERSION,
2673
+ command=args.command,
2674
+ exit_code=0,
2675
+ duration_ms=duration_ms,
2676
+ )
2677
+
2678
+
2679
+ if __name__ == "__main__":
2680
+ main()