cctally 1.6.3 → 1.7.1

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.
@@ -0,0 +1,1571 @@
1
+ """Setup machinery for cctally (install / uninstall / status / dry-run + legacy migration).
2
+
3
+ Lazy I/O sibling: every function that drives `cctally setup` and the
4
+ legacy-bespoke-hook migration lives here. Symlink plumbing, settings.json
5
+ mutation builders (`_settings_merge_install` / `_settings_merge_uninstall`
6
+ / `_settings_merge_unwire_legacy`), detection (status-line snippet +
7
+ legacy bespoke hooks), the prompt / decision helpers, the four
8
+ `_legacy_*` migration primitives, the four `_setup_status` /
9
+ `_setup_uninstall` / `_setup_dry_run` / `_setup_install` mode handlers,
10
+ and the `cmd_setup` entry point.
11
+
12
+ The settings.json I/O primitives (`_load_claude_settings`,
13
+ `_write_claude_settings_atomic`, `_backup_claude_settings`),
14
+ `CLAUDE_SETTINGS_PATH`, `SetupError`, `_is_cctally_hook_command`, and the
15
+ `_LEGACY_*` constants stay in `bin/cctally` per spec §5.6 option A —
16
+ preserves the existing `_e2e_pin_paths` test workaround verbatim (§5.4),
17
+ plus keeps the monkeypatch-sensitive `_LEGACY_BESPOKE_HOOKS_DIR` /
18
+ `_LEGACY_POLLER_PID_FILE` / `_LEGACY_POLLER_COUNT_FILE` constants on
19
+ `cctally` where `monkeypatch.setitem(ns, ...)` lands. Helpers in this
20
+ module reach them via `_cctally().<NAME>` (call-time lookup, monkeypatch
21
+ propagates).
22
+
23
+ bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same as
24
+ `bin/_lib_subscription_weeks.py` and `bin/_lib_aggregators.py`):
25
+ - Path / log constants: `APP_DIR`, `HOOK_TICK_LOG_PATH`,
26
+ `HOOK_TICK_LOG_ROTATED_PATH`, `CLAUDE_SETTINGS_PATH`,
27
+ `LEGACY_STATUSLINE_PATHS`, `LEGACY_STATUSLINE_NEEDLE`,
28
+ `SETUP_HOOK_EVENTS`, `SETUP_SYMLINK_NAMES`.
29
+ - Legacy constants: `_LEGACY_BESPOKE_HOOKS_DIR`, `_LEGACY_BESPOKE_COMMANDS`,
30
+ `_LEGACY_BESPOKE_FILENAMES`, `_LEGACY_POLLER_PID_FILE`,
31
+ `_LEGACY_POLLER_COUNT_FILE`, `_LEGACY_BACKUP_DIR_PREFIX`,
32
+ `_LEGACY_POLLER_SIGTERM_GRACE_S`.
33
+ - Shared helpers: `eprint`, `SetupError`, `_is_cctally_hook_command`,
34
+ `_load_claude_settings`, `_write_claude_settings_atomic`,
35
+ `_backup_claude_settings`, `_resolve_oauth_token`,
36
+ `_hook_tick_throttle_age_seconds`, `_hook_tick_oauth_refresh`,
37
+ `_hook_tick_throttle_touch`, `_command_as_of`, `open_cache_db`,
38
+ `sync_cache`.
39
+
40
+ bin/cctally re-exports every public symbol below so tests that drive
41
+ `cmd_setup` and the legacy-migration helpers via `ns["X"](...)` resolve
42
+ unchanged (eager-load pattern per spec §4.8: tests use direct dict
43
+ access on the cctally namespace, which bypasses PEP 562 `__getattr__`).
44
+
45
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import argparse
50
+ import dataclasses
51
+ import datetime as dt
52
+ import json
53
+ import os
54
+ import pathlib
55
+ import shutil
56
+ import subprocess
57
+ import sys
58
+ import time
59
+
60
+
61
+ def _cctally():
62
+ """Resolve the current `cctally` module at call-time (spec §5.5)."""
63
+ return sys.modules["cctally"]
64
+
65
+
66
+ # ── settings.json hook surgery ─────────────────────────────────────────
67
+
68
+
69
+ def _settings_merge_install(settings: dict, abs_cctally_path: str) -> dict:
70
+ """Append our hook entries idempotently. Returns a (possibly mutated) dict.
71
+
72
+ Raises SetupError if hooks structure has wrong shape.
73
+
74
+ Legacy upgrade: existing matching entries whose `command` field still
75
+ carries the trailing `&` (or any other variant) are rewritten in place
76
+ to the bare form. Writing the same value is a no-op, so this is safe
77
+ on already-current installs. The trailing `&` was dropped because POSIX
78
+ async-list semantics in non-interactive shells redirect stdin to
79
+ /dev/null, which blanked the hook event payload — `cmd_hook_tick`
80
+ forks internally after reading stdin instead.
81
+ """
82
+ import shlex
83
+ c = _cctally()
84
+ hooks_root = settings.setdefault("hooks", {})
85
+ if not isinstance(hooks_root, dict):
86
+ raise c.SetupError("settings.json: `hooks` is not a dict — fix and re-run.")
87
+ quoted = shlex.quote(abs_cctally_path)
88
+ cmd = f"{quoted} hook-tick"
89
+ for event in c.SETUP_HOOK_EVENTS:
90
+ event_list = hooks_root.setdefault(event, [])
91
+ if not isinstance(event_list, list):
92
+ raise c.SetupError(
93
+ f"settings.json: `hooks.{event}` is not a list — fix and re-run."
94
+ )
95
+ already = False
96
+ for grp in event_list:
97
+ if not isinstance(grp, dict):
98
+ continue
99
+ for h in grp.get("hooks", []) or []:
100
+ if not isinstance(h, dict):
101
+ continue
102
+ existing_cmd = h.get("command", "")
103
+ if c._is_cctally_hook_command(existing_cmd):
104
+ already = True
105
+ # Legacy upgrade: rewrite to bare form if it differs.
106
+ # Idempotent — writing the same string is a no-op.
107
+ if existing_cmd != cmd:
108
+ h["command"] = cmd
109
+ if already:
110
+ continue
111
+ new_group = {
112
+ "matcher": "*" if event == "PostToolBatch" else "",
113
+ "hooks": [{"type": "command", "command": cmd}],
114
+ }
115
+ event_list.append(new_group)
116
+ return settings
117
+
118
+
119
+ def _settings_merge_uninstall(settings: dict) -> tuple[dict, int]:
120
+ """Drop our hook entries. Returns (mutated_settings, removed_count)."""
121
+ c = _cctally()
122
+ hooks_root = settings.get("hooks")
123
+ if not isinstance(hooks_root, dict):
124
+ return settings, 0
125
+ removed = 0
126
+ for event in c.SETUP_HOOK_EVENTS:
127
+ event_list = hooks_root.get(event)
128
+ if not isinstance(event_list, list):
129
+ continue
130
+ new_list: list = []
131
+ for grp in event_list:
132
+ if not isinstance(grp, dict):
133
+ new_list.append(grp)
134
+ continue
135
+ inner = grp.get("hooks", [])
136
+ if not isinstance(inner, list):
137
+ new_list.append(grp)
138
+ continue
139
+ kept = [
140
+ h for h in inner
141
+ if not (isinstance(h, dict) and c._is_cctally_hook_command(h.get("command", "")))
142
+ ]
143
+ removed += len(inner) - len(kept)
144
+ if kept:
145
+ grp["hooks"] = kept
146
+ new_list.append(grp)
147
+ # else: matcher group's only entry was ours → drop the group
148
+ if new_list:
149
+ hooks_root[event] = new_list
150
+ else:
151
+ del hooks_root[event]
152
+ return settings, removed
153
+
154
+
155
+ def _settings_merge_unwire_legacy(settings: dict) -> tuple[dict, int]:
156
+ """Remove legacy-bespoke hook entries from ``settings`` in place.
157
+
158
+ Mirrors _settings_merge_uninstall's structure but matches against
159
+ the legacy command set rather than the cctally one. Returns
160
+ (mutated_settings, removed_count). Empty event lists are dropped.
161
+ Trailing '&' is stripped before tokenizing so legacy installs that
162
+ background the daemon-start hook still match.
163
+ """
164
+ import shlex as _shlex
165
+ c = _cctally()
166
+ canonical = {(ev, tuple(_shlex.split(cmd))) for ev, cmd in c._LEGACY_BESPOKE_COMMANDS}
167
+ canonical_raw = {(ev, cmd) for ev, cmd in c._LEGACY_BESPOKE_COMMANDS}
168
+ hooks_root = settings.get("hooks")
169
+ if not isinstance(hooks_root, dict):
170
+ return settings, 0
171
+ removed = 0
172
+ for ev in [k for k in hooks_root.keys()]: # snapshot keys; we may del
173
+ lst = hooks_root.get(ev)
174
+ if not isinstance(lst, list):
175
+ continue
176
+ new_list: list = []
177
+ for grp in lst:
178
+ if not isinstance(grp, dict):
179
+ new_list.append(grp)
180
+ continue
181
+ inner = grp.get("hooks", [])
182
+ if not isinstance(inner, list):
183
+ new_list.append(grp)
184
+ continue
185
+ kept_inner = []
186
+ for h in inner:
187
+ if not isinstance(h, dict):
188
+ kept_inner.append(h)
189
+ continue
190
+ raw = h.get("command", "")
191
+ if not isinstance(raw, str):
192
+ kept_inner.append(h)
193
+ continue
194
+ stripped = raw.strip().rstrip("&").strip()
195
+ try:
196
+ tokens = tuple(_shlex.split(stripped))
197
+ except ValueError:
198
+ kept_inner.append(h)
199
+ continue
200
+ if (ev, tokens) in canonical or (ev, stripped) in canonical_raw:
201
+ removed += 1
202
+ continue
203
+ kept_inner.append(h)
204
+ if kept_inner:
205
+ grp["hooks"] = kept_inner
206
+ new_list.append(grp)
207
+ # else: matcher group's only entry was a legacy one → drop the group
208
+ if new_list:
209
+ hooks_root[ev] = new_list
210
+ else:
211
+ del hooks_root[ev]
212
+ return settings, removed
213
+
214
+
215
+ # ── symlink + path helpers ─────────────────────────────────────────────
216
+
217
+
218
+ def _setup_resolve_repo_root() -> pathlib.Path:
219
+ """Resolve the cctally checkout root from __file__."""
220
+ # __file__ here is bin/_cctally_setup.py; the cctally checkout
221
+ # root is two parents up (same as bin/cctally), so the resolution
222
+ # is identical to the pre-extraction behavior.
223
+ return pathlib.Path(__file__).resolve().parent.parent
224
+
225
+
226
+ def _setup_local_bin_dir() -> pathlib.Path:
227
+ return pathlib.Path.home() / ".local" / "bin"
228
+
229
+
230
+ @dataclasses.dataclass
231
+ class _SetupSymlinkResult:
232
+ name: str
233
+ status: str # "created" | "already" | "replaced" | "failed"
234
+ detail: str = ""
235
+
236
+
237
+ def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib.Path:
238
+ """Resolve the symlink target for a given PATH-name.
239
+
240
+ For `cctally`, prefer `bin/cctally-npm-shim.js` ONLY when the
241
+ package layout indicates an npm install — i.e. ``repo_root`` sits
242
+ somewhere under a ``node_modules/`` directory (npm global at
243
+ ``<prefix>/lib/node_modules/cctally/``, npm local at
244
+ ``<project>/node_modules/cctally/``, plus pnpm/yarn variants that
245
+ all keep the segment). The shim is committed to the source tree so
246
+ the npm-publish layout doesn't need a build step, but file presence
247
+ alone is not a reliable channel signal: source clones and brew
248
+ installs ship the shim too, and Node is not a runtime dependency
249
+ of either path. Falling back to ``bin/cctally`` (the Python script)
250
+ in those cases keeps source/brew installs Python-only as
251
+ documented. All other names map directly to ``bin/<name>``.
252
+ """
253
+ if name == "cctally" and "node_modules" in repo_root.parts:
254
+ shim = repo_root / "bin" / "cctally-npm-shim.js"
255
+ if shim.exists():
256
+ return shim
257
+ return repo_root / "bin" / name
258
+
259
+
260
+ def _setup_resolve_hook_target(repo_root: pathlib.Path) -> pathlib.Path:
261
+ """Resolve the absolute path that goes into Claude Code hook entries.
262
+
263
+ Single chokepoint shared by ``_setup_install``, ``_setup_dry_run``
264
+ (text + JSON envelopes), and any other site that emits a hook
265
+ command. Routes through :func:`_setup_resolve_symlink_source` so
266
+ npm-layout installs point hooks at the Node shim instead of the
267
+ Python script directly — without this, the shim's
268
+ ``CCTALLY_PYTHON`` honoring works for interactive ``cctally`` calls
269
+ but every hook fire bypasses it via ``/usr/bin/env python3`` and
270
+ silently fails when the user's ``python3`` doesn't meet the version
271
+ floor. The returned path is fully resolved (symlinks followed) so
272
+ the recorded hook entry survives later filesystem rearrangements
273
+ of the source clone or the npm install root.
274
+ """
275
+ return _setup_resolve_symlink_source(repo_root, "cctally").resolve()
276
+
277
+
278
+ def _setup_create_symlinks(
279
+ repo_root: pathlib.Path, dst_dir: pathlib.Path, *, names: tuple[str, ...] | None = None,
280
+ ) -> list[_SetupSymlinkResult]:
281
+ if names is None:
282
+ names = _cctally().SETUP_SYMLINK_NAMES
283
+ dst_dir.mkdir(parents=True, exist_ok=True)
284
+ results: list[_SetupSymlinkResult] = []
285
+ for name in names:
286
+ src = _setup_resolve_symlink_source(repo_root, name)
287
+ dst = dst_dir / name
288
+ if not src.exists():
289
+ results.append(_SetupSymlinkResult(name, "failed", f"source not found: {src}"))
290
+ continue
291
+ if dst.is_symlink():
292
+ existing = os.readlink(dst)
293
+ if pathlib.Path(existing) == src:
294
+ results.append(_SetupSymlinkResult(name, "already"))
295
+ continue
296
+ try:
297
+ dst.unlink()
298
+ os.symlink(src, dst)
299
+ results.append(_SetupSymlinkResult(name, "replaced"))
300
+ except OSError as exc:
301
+ results.append(_SetupSymlinkResult(name, "failed", str(exc)))
302
+ continue
303
+ if dst.exists():
304
+ results.append(_SetupSymlinkResult(
305
+ name, "failed",
306
+ f"non-symlink file at {dst} — remove manually then re-run",
307
+ ))
308
+ continue
309
+ try:
310
+ os.symlink(src, dst)
311
+ results.append(_SetupSymlinkResult(name, "created"))
312
+ except OSError as exc:
313
+ results.append(_SetupSymlinkResult(name, "failed", str(exc)))
314
+ return results
315
+
316
+
317
+ def _setup_path_includes_local_bin() -> bool:
318
+ local_bin = str(_setup_local_bin_dir())
319
+ return local_bin in os.environ.get("PATH", "").split(os.pathsep)
320
+
321
+
322
+ def _setup_shell_rc_hint() -> str:
323
+ shell = os.environ.get("SHELL", "")
324
+ if "zsh" in shell:
325
+ return "~/.zshrc"
326
+ if "bash" in shell:
327
+ return "~/.bashrc"
328
+ return "your shell rc"
329
+
330
+
331
+ # ── legacy snippet + bespoke-hook detection ────────────────────────────
332
+
333
+
334
+ def _setup_detect_legacy_snippet() -> tuple[pathlib.Path, list[int]] | None:
335
+ """Return (path, [line_numbers]) of the first file containing the snippet, or None."""
336
+ c = _cctally()
337
+ for path in c.LEGACY_STATUSLINE_PATHS:
338
+ if not path.exists() or not path.is_file():
339
+ continue
340
+ try:
341
+ text = path.read_text(encoding="utf-8", errors="replace")
342
+ except OSError:
343
+ continue
344
+ hits = [i + 1 for i, ln in enumerate(text.splitlines()) if c.LEGACY_STATUSLINE_NEEDLE in ln]
345
+ if hits:
346
+ return (path, hits)
347
+ return None
348
+
349
+
350
+ def _setup_detect_legacy_bespoke_hooks(settings: dict) -> dict:
351
+ """Detect legacy bespoke hook state per spec Section 1.
352
+
353
+ Detection fires when ANY of the 3 canonical settings.json command
354
+ strings matches an installed entry, OR ANY of the 4 canonical
355
+ .py files exists at its canonical path under _LEGACY_BESPOKE_HOOKS_DIR.
356
+
357
+ Returns a dict with keys:
358
+ detected: bool
359
+ settings_entries: list of {"event": str, "command": str}
360
+ files: list of str (rendered with ~/.claude/hooks/ prefix)
361
+ """
362
+ import shlex as _shlex
363
+ c = _cctally()
364
+ canonical_cmds = {(ev, cmd) for ev, cmd in c._LEGACY_BESPOKE_COMMANDS}
365
+ canonical_tokens = {(ev, tuple(_shlex.split(cmd))) for ev, cmd in c._LEGACY_BESPOKE_COMMANDS}
366
+
367
+ found_entries: list[dict] = []
368
+ hooks_root = settings.get("hooks", {}) if isinstance(settings, dict) else {}
369
+ if isinstance(hooks_root, dict):
370
+ for event, lst in hooks_root.items():
371
+ if not isinstance(lst, list):
372
+ continue
373
+ matched_for_this_event = False
374
+ for grp in lst:
375
+ if matched_for_this_event:
376
+ break # already recorded one row for this event; don't double-count
377
+ if not isinstance(grp, dict):
378
+ continue
379
+ inner = grp.get("hooks", [])
380
+ if not isinstance(inner, list):
381
+ # Mirrors the unwire helper's defensive guard: malformed
382
+ # `hooks` value (None / int / dict) must not crash iteration.
383
+ continue
384
+ for h in inner:
385
+ if not isinstance(h, dict):
386
+ continue
387
+ raw = h.get("command", "")
388
+ if not isinstance(raw, str):
389
+ continue
390
+ stripped = raw.strip().rstrip("&").strip()
391
+ try:
392
+ tokens = tuple(_shlex.split(stripped))
393
+ except ValueError:
394
+ continue
395
+ if (event, tokens) in canonical_tokens or (event, stripped) in canonical_cmds:
396
+ # Record the canonical (clean) form for stable JSON output,
397
+ # not the user's possibly-decorated raw command.
398
+ clean_cmd = next(
399
+ cmd for ev, cmd in c._LEGACY_BESPOKE_COMMANDS if ev == event
400
+ )
401
+ found_entries.append({"event": event, "command": clean_cmd})
402
+ matched_for_this_event = True
403
+ break # one entry per matcher group is enough
404
+
405
+ found_files: list[str] = []
406
+ for name in c._LEGACY_BESPOKE_FILENAMES:
407
+ p = c._LEGACY_BESPOKE_HOOKS_DIR / name
408
+ if p.exists():
409
+ # Render with the ~ prefix the spec uses for stable JSON.
410
+ found_files.append(f"~/.claude/hooks/{name}")
411
+
412
+ return {
413
+ "detected": bool(found_entries) or bool(found_files),
414
+ "settings_entries": found_entries,
415
+ "files": found_files,
416
+ }
417
+
418
+
419
+ # ── legacy migration primitives (move / stop / cleanup / backup-dir) ───
420
+
421
+
422
+ def _legacy_resolve_backup_dir() -> pathlib.Path:
423
+ """Return ~/.claude/cctally-legacy-hook-backup-<UTC YYYYMMDD-HHMMSS>/.
424
+
425
+ Honors CCTALLY_AS_OF for fixture stability via _command_as_of(). Created
426
+ on demand. Idempotent within the same wall-second (mkdir(exist_ok=True)).
427
+
428
+ See spec Section 1 ("What gets touched on accept" → step 2) and
429
+ Section 2 ("Sequence position", step 6a). Backup dir is timestamped
430
+ so a re-run never overwrites a prior migration's snapshot.
431
+ """
432
+ c = _cctally()
433
+ now = c._command_as_of()
434
+ stamp = now.strftime("%Y%m%d-%H%M%S")
435
+ base = pathlib.Path.home() / ".claude" / f"{c._LEGACY_BACKUP_DIR_PREFIX}{stamp}"
436
+ base.mkdir(parents=True, exist_ok=True)
437
+ return base
438
+
439
+
440
+ def _legacy_move_files_to_backup(backup_dir: pathlib.Path) -> list[pathlib.Path]:
441
+ """Move present canonical .py files from `_LEGACY_BESPOKE_HOOKS_DIR` into backup_dir.
442
+
443
+ Each canonical filename is moved only if present at its canonical path;
444
+ missing files are silent no-ops (per spec Section 1: "Missing files are
445
+ silent no-ops in the move loop"). Returns the list of destination paths
446
+ actually written, in canonical (`_LEGACY_BESPOKE_FILENAMES`) order.
447
+
448
+ Uses `shutil.move` (canonical Python idiom): same-filesystem renames
449
+ go through `os.rename`, cross-device moves fall through to
450
+ `copy2 + unlink` atomically — and a failure on the unlink leg raises
451
+ `OSError` instead of silently leaving a duplicate at both src and dst
452
+ (which the prior hand-rolled try/except/inner-try did).
453
+ """
454
+ c = _cctally()
455
+ moved: list[pathlib.Path] = []
456
+ for name in c._LEGACY_BESPOKE_FILENAMES:
457
+ src = c._LEGACY_BESPOKE_HOOKS_DIR / name
458
+ if not src.exists():
459
+ continue
460
+ dst = backup_dir / name
461
+ try:
462
+ shutil.move(str(src), str(dst))
463
+ except OSError:
464
+ # Best-effort: a failed move is silent; spec Section 1 step 2
465
+ # treats the move loop's failures as no-ops (the daemon-stop
466
+ # follow-up handles user-facing damage control).
467
+ continue
468
+ moved.append(dst)
469
+ return moved
470
+
471
+
472
+ def _legacy_stop_active_poller() -> str:
473
+ """Best-effort SIGTERM (then SIGKILL) the bespoke daemon if alive.
474
+
475
+ Per spec Section 1 step 3: read /tmp/claude-usage-poller.pid, send
476
+ SIGTERM, wait `_LEGACY_POLLER_SIGTERM_GRACE_S`, send SIGKILL if still
477
+ alive. All steps are best-effort and silent on failure — the daemon
478
+ may already be dead, the PID may be stale, the rlimits may forbid
479
+ signaling, or the file may simply be absent.
480
+
481
+ Returns one of:
482
+ "no-pid-file" — no /tmp/claude-usage-poller.pid present
483
+ "stale-pid" — PID file exists but the PID isn't a live
484
+ process, parse failed, OR the live PID's
485
+ cmdline doesn't reference usage-poller.py
486
+ (collapsed: don't signal an unrelated process)
487
+ "sigterm-took" — SIGTERM landed and the process exited within
488
+ the grace window
489
+ "sigkill-took" — SIGTERM did not stop it; SIGKILL landed
490
+ "permission-denied" — kernel refused to signal the PID (EPERM)
491
+ """
492
+ import signal as _signal
493
+ c = _cctally()
494
+
495
+ if not c._LEGACY_POLLER_PID_FILE.exists():
496
+ return "no-pid-file"
497
+ try:
498
+ raw = c._LEGACY_POLLER_PID_FILE.read_text(encoding="utf-8", errors="replace").strip()
499
+ pid = int(raw)
500
+ except (OSError, ValueError):
501
+ # Unreadable or non-numeric content → treat as stale (a corrupted
502
+ # PID file is functionally indistinguishable from a stale one;
503
+ # the cleanup helper will unlink it next).
504
+ return "stale-pid"
505
+
506
+ # Aliveness probe: signal 0 doesn't deliver but does the permission
507
+ # + existence check. ProcessLookupError → stale; PermissionError →
508
+ # we'd fail the actual signal too, surface that distinctly.
509
+ try:
510
+ os.kill(pid, 0)
511
+ except ProcessLookupError:
512
+ return "stale-pid"
513
+ except PermissionError:
514
+ return "permission-denied"
515
+ except OSError:
516
+ return "stale-pid"
517
+
518
+ # Ownership probe: the PID file is at a predictable /tmp path that
519
+ # outlives the daemon on uncleanly exit, and macOS PIDs cycle in a
520
+ # narrow space — verify the live process is actually our legacy
521
+ # poller before signaling. ps's `-o command=` emits the full cmdline
522
+ # with no header on both macOS BSD ps and Linux util-linux ps.
523
+ try:
524
+ probe = subprocess.run(
525
+ ["ps", "-p", str(pid), "-o", "command="],
526
+ capture_output=True, text=True, timeout=2.0,
527
+ )
528
+ except (OSError, subprocess.TimeoutExpired):
529
+ # Can't verify → don't signal. Treat as stale: a corrupted /tmp
530
+ # sentinel is functionally equivalent to a missing process here.
531
+ return "stale-pid"
532
+ if probe.returncode != 0 or "usage-poller.py" not in probe.stdout:
533
+ return "stale-pid"
534
+
535
+ # Process is alive AND owned by the legacy poller. SIGTERM, then poll
536
+ # for exit within the grace.
537
+ try:
538
+ os.kill(pid, _signal.SIGTERM)
539
+ except ProcessLookupError:
540
+ # Race: process exited between probe and signal — treat as success.
541
+ return "sigterm-took"
542
+ except PermissionError:
543
+ return "permission-denied"
544
+ except OSError:
545
+ # Residual OSError after ProcessLookupError/PermissionError are caught
546
+ # specifically — exotic kernel refusal (ENOMEM during signal queueing,
547
+ # LSM denial, etc.). Map to permission-denied: spec contract forbids
548
+ # raising, and "we couldn't deliver the signal" is the closest existing
549
+ # outcome.
550
+ return "permission-denied"
551
+
552
+ deadline = time.monotonic() + c._LEGACY_POLLER_SIGTERM_GRACE_S
553
+ while time.monotonic() < deadline:
554
+ try:
555
+ os.kill(pid, 0)
556
+ except ProcessLookupError:
557
+ return "sigterm-took"
558
+ except OSError:
559
+ return "sigterm-took"
560
+ time.sleep(0.01)
561
+
562
+ # Still alive after grace → SIGKILL fallback.
563
+ try:
564
+ os.kill(pid, _signal.SIGKILL)
565
+ except ProcessLookupError:
566
+ # Exited just at the grace boundary — count as SIGTERM-took.
567
+ return "sigterm-took"
568
+ except PermissionError:
569
+ return "permission-denied"
570
+ except OSError:
571
+ # Same residual-OSError category as the SIGTERM site above.
572
+ return "permission-denied"
573
+ return "sigkill-took"
574
+
575
+
576
+ def _legacy_cleanup_tmp_sentinels() -> list[str]:
577
+ """Unlink the bespoke poller's PID + count files. Best-effort; missing
578
+ files are silent no-ops (FileNotFoundError) and so are unwritable
579
+ parents (OSError). Returns the paths actually unlinked, as strings,
580
+ in canonical (pid, count) order.
581
+
582
+ Per spec Section 1 step 3 and Section 2 step 6b — runs after the
583
+ SIGTERM/SIGKILL helper so a successful daemon stop also clears the
584
+ sentinels it left on /tmp.
585
+ """
586
+ c = _cctally()
587
+ unlinked: list[str] = []
588
+ for p in (c._LEGACY_POLLER_PID_FILE, c._LEGACY_POLLER_COUNT_FILE):
589
+ try:
590
+ p.unlink()
591
+ except FileNotFoundError:
592
+ continue
593
+ except OSError:
594
+ continue
595
+ unlinked.append(str(p))
596
+ return unlinked
597
+
598
+
599
+ # ── prompt + decision + auxiliary counters ─────────────────────────────
600
+
601
+
602
+ def _setup_read_legacy_prompt_input(stream, reprompt: str | None = None) -> bool:
603
+ """Read a y/N answer from `stream` per spec Section 2 prompt rules.
604
+
605
+ Empty input (just Enter) → True (the documented default).
606
+ 'y'/'yes' (any case) → True.
607
+ 'n'/'no' (any case) → False.
608
+ EOF before any character → False (decline; explicitly NOT default-Y, so
609
+ non-TTY callers can't auto-accept via inherited stdin closure).
610
+ Anything else → re-prompt up to 3 times, then False with a stderr warning.
611
+
612
+ `reprompt`: optional text to emit to stderr before each attempt AFTER the
613
+ first (the caller already printed the original prompt body before calling
614
+ us). When None (test default), no reprompt is emitted — useful for unit
615
+ tests that drive `stream` from io.StringIO.
616
+ """
617
+ eprint = _cctally().eprint
618
+ yes_words = {"y", "yes"}
619
+ no_words = {"n", "no"}
620
+ for attempt in range(3):
621
+ if attempt > 0 and reprompt is not None:
622
+ eprint(reprompt)
623
+ line = stream.readline()
624
+ if line == "":
625
+ return False # EOF → decline
626
+ token = line.strip().lower() # whitespace-only counts as "just Enter" → default-Y
627
+ if token == "":
628
+ return True
629
+ if token in yes_words:
630
+ return True
631
+ if token in no_words:
632
+ return False
633
+ eprint("setup: invalid responses 3 times; skipping migration")
634
+ return False
635
+
636
+
637
+ def _setup_legacy_decide_action(args, detected: bool, stdin_isatty: bool) -> tuple[str, str | None]:
638
+ """Decide migration action without performing prompt I/O.
639
+
640
+ Returns (decision, reason) where decision is one of:
641
+ - "migrate" — proceed with migration
642
+ - "skip" — do not migrate; reason is one of "not_detected" /
643
+ "no_migrate_flag" / "user_declined". This helper never returns
644
+ "user_declined"; that reason is set by the caller after a
645
+ "prompt" decision yields a No answer from
646
+ _setup_read_legacy_prompt_input.
647
+ - "prompt" — caller must read user input via the prompt helper.
648
+
649
+ Spec Section 2 prompt rules: detection short-circuits, explicit flags
650
+ are decisive, --yes implies migrate, --json or non-TTY without a flag
651
+ skips silently (the JSON envelope and unattended runs both need a
652
+ no-blocking-input contract). When none of those hold, the caller is
653
+ in interactive install with detected hooks → prompt.
654
+ """
655
+ if not detected:
656
+ return ("skip", "not_detected")
657
+ if getattr(args, "no_migrate_legacy_hooks", False):
658
+ return ("skip", "no_migrate_flag")
659
+ if getattr(args, "migrate_legacy_hooks", False):
660
+ return ("migrate", None)
661
+ if getattr(args, "yes", False):
662
+ return ("migrate", None)
663
+ if not stdin_isatty:
664
+ return ("skip", "no_migrate_flag")
665
+ if getattr(args, "json", False):
666
+ return ("skip", "no_migrate_flag")
667
+ return ("prompt", None)
668
+
669
+
670
+ def _setup_oauth_token_present() -> bool:
671
+ try:
672
+ return bool(_cctally()._resolve_oauth_token())
673
+ except Exception:
674
+ return False
675
+
676
+
677
+ def _setup_count_hook_entries(settings: dict) -> dict[str, int]:
678
+ """Return {event_name: count_of_our_entries} for the three events."""
679
+ c = _cctally()
680
+ counts = {ev: 0 for ev in c.SETUP_HOOK_EVENTS}
681
+ hooks_root = settings.get("hooks") if isinstance(settings, dict) else None
682
+ if not isinstance(hooks_root, dict):
683
+ return counts
684
+ for ev in c.SETUP_HOOK_EVENTS:
685
+ ev_list = hooks_root.get(ev)
686
+ if not isinstance(ev_list, list):
687
+ continue
688
+ for grp in ev_list:
689
+ if not isinstance(grp, dict):
690
+ continue
691
+ inner = grp.get("hooks", [])
692
+ if not isinstance(inner, list):
693
+ continue
694
+ for h in inner:
695
+ if isinstance(h, dict) and c._is_cctally_hook_command(h.get("command", "")):
696
+ counts[ev] += 1
697
+ return counts
698
+
699
+
700
+ def _setup_data_dir_size_bytes() -> int:
701
+ app_dir = _cctally().APP_DIR
702
+ total = 0
703
+ if not app_dir.exists():
704
+ return 0
705
+ for root, _dirs, files in os.walk(app_dir):
706
+ for f in files:
707
+ try:
708
+ total += (pathlib.Path(root) / f).stat().st_size
709
+ except OSError:
710
+ pass
711
+ return total
712
+
713
+
714
+ def _setup_format_bytes(n: int) -> str:
715
+ for unit in ("B", "KB", "MB", "GB", "TB"):
716
+ if n < 1024 or unit == "TB":
717
+ return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
718
+ n /= 1024
719
+ return f"{n:.1f} TB"
720
+
721
+
722
+ def _setup_recent_log_stats(seconds: float = 24 * 3600) -> dict:
723
+ """Parse hook-tick.log + .log.1; return counts of fires/oauth/errors in window."""
724
+ c = _cctally()
725
+ cutoff = time.time() - seconds
726
+ counts = {"fires": 0, "by_event": {}, "oauth_ok": 0, "throttled": 0,
727
+ "errors": 0, "last_fire_ago_s": None}
728
+ last_ts = 0.0
729
+ for path in (c.HOOK_TICK_LOG_ROTATED_PATH, c.HOOK_TICK_LOG_PATH):
730
+ if not path.exists():
731
+ continue
732
+ try:
733
+ for ln in path.read_text(encoding="utf-8", errors="replace").splitlines():
734
+ if not ln.strip():
735
+ continue
736
+ try:
737
+ ts_iso = ln.split(" ", 1)[0]
738
+ ts = dt.datetime.fromisoformat(ts_iso).timestamp()
739
+ except (ValueError, IndexError):
740
+ continue
741
+ if ts < cutoff:
742
+ continue
743
+ counts["fires"] += 1
744
+ last_ts = max(last_ts, ts)
745
+ # event=NAME
746
+ ev = "unknown"
747
+ for tok in ln.split():
748
+ if tok.startswith("event="):
749
+ ev = tok.split("=", 1)[1]
750
+ break
751
+ counts["by_event"][ev] = counts["by_event"].get(ev, 0) + 1
752
+ if "oauth=ok(" in ln:
753
+ counts["oauth_ok"] += 1
754
+ elif "oauth=throttled" in ln:
755
+ counts["throttled"] += 1
756
+ elif "oauth=err" in ln:
757
+ counts["errors"] += 1
758
+ except OSError:
759
+ continue
760
+ if last_ts:
761
+ counts["last_fire_ago_s"] = max(0, int(time.time() - last_ts))
762
+ return counts
763
+
764
+
765
+ # ── status / uninstall / dry-run / install mode handlers ───────────────
766
+
767
+
768
+ def _setup_compute_symlink_state(
769
+ repo_root: pathlib.Path, dst_dir: pathlib.Path,
770
+ ) -> "list[tuple[str, str]]":
771
+ """Per-symlink (name, state) for `_setup_status` + `doctor_gather_state`.
772
+
773
+ state ∈ {"ok", "wrong", "missing"}:
774
+ - "ok": ``dst_dir/name`` is a symlink whose target is reachable.
775
+ The target is NOT required to match ``repo_root/bin/<name>`` —
776
+ power users routinely have the symlinks installed by one
777
+ cctally channel (npm/brew) while running ``doctor`` from a
778
+ parallel source clone, which would otherwise produce a 0/N
779
+ false negative on a perfectly healthy install. The diagnostic
780
+ question is "is `cctally-X` invokable from PATH?", not "did
781
+ THIS checkout install the symlink?". ``_setup_create_symlinks``
782
+ keeps its own strict equality check for install-management
783
+ (replace-vs-already).
784
+ - "wrong": a non-symlink file occupies the slot, or the symlink
785
+ target is dangling.
786
+ - "missing": nothing at ``dst_dir/name``.
787
+
788
+ ``repo_root`` is unused here — retained on the signature for
789
+ call-site stability across `_setup_status` and `doctor_gather_state`.
790
+ """
791
+ del repo_root # unused; see docstring
792
+ out: list[tuple[str, str]] = []
793
+ for name in _cctally().SETUP_SYMLINK_NAMES:
794
+ dst = dst_dir / name
795
+ if dst.is_symlink():
796
+ try:
797
+ dst.resolve(strict=True)
798
+ out.append((name, "ok"))
799
+ except (FileNotFoundError, OSError):
800
+ out.append((name, "wrong"))
801
+ elif dst.exists():
802
+ out.append((name, "wrong"))
803
+ else:
804
+ out.append((name, "missing"))
805
+ return out
806
+
807
+
808
+ def _setup_status(args: argparse.Namespace) -> int:
809
+ c = _cctally()
810
+ repo_root = _setup_resolve_repo_root()
811
+ dst_dir = _setup_local_bin_dir()
812
+ sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
813
+ sym_ok = sum(1 for _, s in sym_state if s == "ok")
814
+ on_path = _setup_path_includes_local_bin()
815
+ try:
816
+ settings = c._load_claude_settings()
817
+ except c.SetupError as exc:
818
+ c.eprint(f"setup: warning: {exc}")
819
+ settings = {}
820
+ hook_counts = _setup_count_hook_entries(settings)
821
+ oauth = _setup_oauth_token_present()
822
+ throttle_age = c._hook_tick_throttle_age_seconds()
823
+ activity = _setup_recent_log_stats()
824
+ legacy = _setup_detect_legacy_snippet()
825
+ bespoke = _setup_detect_legacy_bespoke_hooks(settings)
826
+ data_bytes = _setup_data_dir_size_bytes()
827
+
828
+ if getattr(args, "json", False):
829
+ envelope = {
830
+ "schema_version": 1,
831
+ "install": {
832
+ "symlinks_present": sym_ok,
833
+ "symlinks_total": len(c.SETUP_SYMLINK_NAMES),
834
+ "path_includes": on_path,
835
+ },
836
+ "hooks": {ev: hook_counts[ev] for ev in c.SETUP_HOOK_EVENTS},
837
+ "auth": {
838
+ "oauth_token_present": oauth,
839
+ "last_fetch_age_s": (
840
+ None if throttle_age == float("inf") else int(throttle_age)
841
+ ),
842
+ },
843
+ "activity_24h": activity,
844
+ "legacy": {
845
+ "statusline_snippet": str(legacy[0]) if legacy else None,
846
+ "bespoke_hooks": {
847
+ "detected": bespoke["detected"],
848
+ "settings_entries": bespoke["settings_entries"],
849
+ "files": bespoke["files"],
850
+ },
851
+ },
852
+ "data": {"path": str(c.APP_DIR), "size_bytes": data_bytes},
853
+ }
854
+ print(json.dumps(envelope, indent=2))
855
+ return 0
856
+
857
+ out: list[str] = []
858
+ out.append("Install")
859
+ sym_marker = "✓" if sym_ok == len(c.SETUP_SYMLINK_NAMES) else "✗"
860
+ out.append(f" Symlinks {sym_ok}/{len(c.SETUP_SYMLINK_NAMES)} present at {dst_dir}/ {sym_marker}")
861
+ out.append(f" PATH includes {'yes' if on_path else 'no'} "
862
+ f"{'✓' if on_path else '⚠'}")
863
+ out.append(f"Hooks ({c.CLAUDE_SETTINGS_PATH})")
864
+ for ev in c.SETUP_HOOK_EVENTS:
865
+ marker = "✓" if hook_counts[ev] >= 1 else "✗"
866
+ word = "installed" if hook_counts[ev] >= 1 else "missing"
867
+ out.append(f" {ev:14s} {word:24s} {marker}")
868
+ out.append("Auth")
869
+ out.append(f" OAuth token {'present' if oauth else 'missing'} "
870
+ f"{'✓' if oauth else '⚠'}")
871
+ if throttle_age == float("inf"):
872
+ out.append(" Last fetch never")
873
+ else:
874
+ out.append(f" Last fetch {int(throttle_age)}s ago")
875
+ out.append("Hook activity (last 24h)")
876
+ by_ev = ", ".join(f"{ev} {activity['by_event'].get(ev, 0)}" for ev in c.SETUP_HOOK_EVENTS)
877
+ out.append(f" Fires {activity['fires']} ({by_ev})")
878
+ out.append(f" OAuth {activity['oauth_ok']} ({activity['throttled']} throttled)")
879
+ out.append(f" Errors {activity['errors']}")
880
+ if activity["last_fire_ago_s"] is None:
881
+ out.append(" Last fire none")
882
+ else:
883
+ out.append(f" Last fire {activity['last_fire_ago_s']}s ago")
884
+ out.append("Legacy")
885
+ if legacy is None:
886
+ out.append(" status-line snippet not detected ✓")
887
+ else:
888
+ out.append(f" status-line snippet detected at {legacy[0]}:{legacy[1][0]} ⚠")
889
+ if not bespoke["detected"]:
890
+ out.append(" bespoke hooks not detected ✓")
891
+ else:
892
+ n_entries = len(bespoke["settings_entries"])
893
+ n_files = len(bespoke["files"])
894
+ out.append(
895
+ f" bespoke hooks detected ({n_entries} entries, {n_files} files) ⚠"
896
+ )
897
+ out.append(" run `cctally setup --migrate-legacy-hooks` to migrate")
898
+ out.append("Data")
899
+ out.append(f" {c.APP_DIR}/ {_setup_format_bytes(data_bytes)}")
900
+ _setup_emit_text(out)
901
+ return 0
902
+
903
+
904
+ def _setup_uninstall(args: argparse.Namespace) -> int:
905
+ c = _cctally()
906
+ purge = bool(getattr(args, "purge", False))
907
+ yes = bool(getattr(args, "yes", False))
908
+ is_json = bool(getattr(args, "json", False))
909
+
910
+ out: list[str] = []
911
+ try:
912
+ settings = c._load_claude_settings()
913
+ except c.SetupError as exc:
914
+ c.eprint(f"setup: {exc}")
915
+ return 1
916
+ settings, removed = _settings_merge_uninstall(settings)
917
+ if removed:
918
+ try:
919
+ c._write_claude_settings_atomic(settings)
920
+ except OSError as exc:
921
+ c.eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
922
+ return 2
923
+ out.append(f"Removed {removed} hook entries from {c.CLAUDE_SETTINGS_PATH}")
924
+
925
+ repo_root = _setup_resolve_repo_root()
926
+ dst_dir = _setup_local_bin_dir()
927
+ sym_removed = 0
928
+ for name in c.SETUP_SYMLINK_NAMES:
929
+ dst = dst_dir / name
930
+ if dst.is_symlink():
931
+ try:
932
+ target = pathlib.Path(os.readlink(dst))
933
+ except OSError:
934
+ target = None
935
+ expected = _setup_resolve_symlink_source(repo_root, name)
936
+ if target == expected:
937
+ try:
938
+ dst.unlink()
939
+ sym_removed += 1
940
+ except OSError as exc:
941
+ c.eprint(f"setup: failed to remove {dst}: {exc}")
942
+ out.append(f"Removed {sym_removed} symlinks from {dst_dir}/")
943
+
944
+ legacy = _setup_detect_legacy_snippet()
945
+ if legacy is not None:
946
+ out.append(
947
+ f"Note: legacy status-line snippet found in {legacy[0]} — leaving untouched."
948
+ )
949
+
950
+ data_bytes = _setup_data_dir_size_bytes()
951
+ if purge:
952
+ if not yes:
953
+ if is_json:
954
+ # Spec: under --json without --yes, auto-decline. Script with --yes instead.
955
+ print(json.dumps({
956
+ "schema_version": 1,
957
+ "mode": "uninstall",
958
+ "result": "purge_declined",
959
+ "reason": "json_without_yes",
960
+ "hooks_removed": removed,
961
+ "symlinks_removed": sym_removed,
962
+ "purged": False,
963
+ "data_path": str(c.APP_DIR),
964
+ "data_size_bytes": data_bytes,
965
+ "legacy": {
966
+ "statusline_snippet_path": str(legacy[0]) if legacy else None,
967
+ },
968
+ "exit_code": 3,
969
+ }, indent=2))
970
+ return 3
971
+ if data_bytes > 0:
972
+ try:
973
+ resp = input(
974
+ f"Wipe {_setup_format_bytes(data_bytes)} of usage history at "
975
+ f"{c.APP_DIR}/? [y/N] "
976
+ )
977
+ except EOFError:
978
+ resp = "n"
979
+ if resp.strip().lower() not in ("y", "yes"):
980
+ out.append("Purge declined.")
981
+ _setup_emit_text(out)
982
+ return 3
983
+ if c.APP_DIR.exists():
984
+ try:
985
+ shutil.rmtree(c.APP_DIR)
986
+ out.append(f"Wiped {c.APP_DIR}/")
987
+ except OSError as exc:
988
+ if is_json:
989
+ print(json.dumps({
990
+ "schema_version": 1,
991
+ "mode": "uninstall",
992
+ "result": "err",
993
+ "reason": "rmtree_failed",
994
+ "error": str(exc),
995
+ "data_path": str(c.APP_DIR),
996
+ "data_size_bytes": data_bytes,
997
+ "legacy": {
998
+ "statusline_snippet_path": str(legacy[0]) if legacy else None,
999
+ },
1000
+ "exit_code": 1,
1001
+ }, indent=2))
1002
+ else:
1003
+ c.eprint(f"setup: failed to wipe {c.APP_DIR}: {exc}")
1004
+ return 1
1005
+ else:
1006
+ out.append(
1007
+ f"Note: usage history kept at {c.APP_DIR}/ "
1008
+ f"({_setup_format_bytes(data_bytes)}). Use --purge to remove."
1009
+ )
1010
+ if is_json:
1011
+ envelope = {
1012
+ "schema_version": 1,
1013
+ "mode": "uninstall",
1014
+ "result": "ok",
1015
+ "hooks_removed": removed,
1016
+ "symlinks_removed": sym_removed,
1017
+ "purged": purge,
1018
+ "data_path": str(c.APP_DIR),
1019
+ "data_size_bytes": data_bytes,
1020
+ "legacy": {
1021
+ "statusline_snippet_path": str(legacy[0]) if legacy else None,
1022
+ },
1023
+ "exit_code": 0,
1024
+ }
1025
+ print(json.dumps(envelope, indent=2))
1026
+ return 0
1027
+ _setup_emit_text(out)
1028
+ return 0
1029
+
1030
+
1031
+ def _setup_dry_run(args: argparse.Namespace) -> int:
1032
+ c = _cctally()
1033
+ repo_root = _setup_resolve_repo_root()
1034
+ dst_dir = _setup_local_bin_dir()
1035
+ try:
1036
+ settings = c._load_claude_settings()
1037
+ except c.SetupError as exc:
1038
+ # Malformed settings.json — preview still proceeds; legacy detection
1039
+ # against an empty dict simply yields detected=False for entries (files
1040
+ # detection is independent of settings). Mirror _setup_status's pattern
1041
+ # so the user sees the same condition that would fail _setup_install.
1042
+ c.eprint(f"setup: warning: {exc}")
1043
+ settings = {}
1044
+ detection = _setup_detect_legacy_bespoke_hooks(settings)
1045
+ sym_results = []
1046
+ for name in c.SETUP_SYMLINK_NAMES:
1047
+ dst = dst_dir / name
1048
+ src = _setup_resolve_symlink_source(repo_root, name)
1049
+ if dst.is_symlink() and pathlib.Path(os.readlink(dst)) == src:
1050
+ sym_results.append((name, "already"))
1051
+ elif dst.exists() and not dst.is_symlink():
1052
+ sym_results.append((name, "blocked"))
1053
+ else:
1054
+ sym_results.append((name, "would-create"))
1055
+ new = sum(1 for _, s in sym_results if s == "would-create")
1056
+ same = sum(1 for _, s in sym_results if s == "already")
1057
+ blocked = [name for name, s in sym_results if s == "blocked"]
1058
+ out: list[str] = []
1059
+ out.append(
1060
+ f"Would symlink {len(c.SETUP_SYMLINK_NAMES)} files to {dst_dir}/ "
1061
+ f"({same} already correct, {new} new)"
1062
+ )
1063
+ if blocked:
1064
+ out.append(f"⚠ Blocked (non-symlink files exist): {', '.join(blocked)}")
1065
+ out.append(" Remove them manually then re-run.")
1066
+
1067
+ out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {c.CLAUDE_SETTINGS_PATH}:")
1068
+ abs_path = str(_setup_resolve_hook_target(repo_root))
1069
+ import shlex
1070
+ quoted = shlex.quote(abs_path)
1071
+ for ev in c.SETUP_HOOK_EVENTS:
1072
+ matcher = '"*"' if ev == "PostToolBatch" else '""'
1073
+ out.append(
1074
+ f" hooks.{ev}[*] += {{ matcher: {matcher}, "
1075
+ f"command: \"{quoted} hook-tick\" }}"
1076
+ )
1077
+ # Spec §2 mode×flag matrix — three distinct dry-run rendering paths
1078
+ # when legacy is detected:
1079
+ # --dry-run --no-migrate-legacy-hooks → migration block omitted entirely
1080
+ # --dry-run --migrate-legacy-hooks (or --yes) → full migration plan
1081
+ # --dry-run (no migrate flag) → full plan prefixed with the
1082
+ # "would prompt; pass --migrate-legacy-hooks…" note
1083
+ # `--yes` is treated as equivalent to `--migrate-legacy-hooks` to
1084
+ # match `_setup_decide_legacy_migration` (bin/cctally:22094-22101).
1085
+ no_migrate_flag = bool(getattr(args, "no_migrate_legacy_hooks", False))
1086
+ migrate_flag = bool(getattr(args, "migrate_legacy_hooks", False))
1087
+ yes_flag = bool(getattr(args, "yes", False))
1088
+ show_full_migration_plan = migrate_flag or yes_flag
1089
+ show_migration_block = detection["detected"] and not no_migrate_flag
1090
+ if show_migration_block:
1091
+ if not show_full_migration_plan:
1092
+ # No-flag dry-run: prefix the block with the would-prompt note.
1093
+ out.append(
1094
+ "Would prompt for migration; pass --migrate-legacy-hooks to "
1095
+ "preview the migration plan."
1096
+ )
1097
+ out.append("Would migrate legacy bespoke hooks:")
1098
+ if detection["settings_entries"]:
1099
+ out.append(
1100
+ f" Would remove {len(detection['settings_entries'])} "
1101
+ f"entries from settings.json:"
1102
+ )
1103
+ for e in detection["settings_entries"]:
1104
+ out.append(f" hooks.{e['event']:13s} ← {e['command']}")
1105
+ files_present = [f.split('/')[-1] for f in detection["files"]]
1106
+ if files_present:
1107
+ out.append(
1108
+ f" Would move {len(files_present)} files to "
1109
+ f"~/.claude/cctally-legacy-hook-backup-<UTC ts>/:"
1110
+ )
1111
+ out.append(f" {', '.join(files_present)}")
1112
+ out.append(" Would attempt cleanup of /tmp/claude-usage-poller.{pid,count}")
1113
+ out.append("Would not modify ~/.claude/statusline-command.sh")
1114
+ out.append("Would not delete any data")
1115
+ out.append("")
1116
+ out.append("Re-run without --dry-run to apply.")
1117
+
1118
+ if getattr(args, "json", False):
1119
+ # Decision label mirrors `_setup_decide_legacy_migration`'s output:
1120
+ # `migrate` (full plan / explicit opt-in or --yes), `skip`
1121
+ # (--no-migrate-legacy-hooks), or `prompt` (no flag — install would
1122
+ # prompt the user). When no legacy is detected the label is
1123
+ # `not_detected` so consumers can distinguish "no-op" from
1124
+ # "explicit skip."
1125
+ if not detection["detected"]:
1126
+ decision = "not_detected"
1127
+ elif no_migrate_flag:
1128
+ decision = "skip"
1129
+ elif show_full_migration_plan:
1130
+ decision = "migrate"
1131
+ else:
1132
+ decision = "prompt"
1133
+ legacy_path = _setup_detect_legacy_snippet()
1134
+ envelope = {
1135
+ "schema_version": 1,
1136
+ "mode": "dry-run",
1137
+ "symlinks": {
1138
+ "would_create": new,
1139
+ "already": same,
1140
+ "blocked": blocked,
1141
+ "destination": str(dst_dir),
1142
+ "total": len(c.SETUP_SYMLINK_NAMES),
1143
+ },
1144
+ "hooks": {
1145
+ "would_add": [
1146
+ {
1147
+ "event": ev,
1148
+ "matcher": "*" if ev == "PostToolBatch" else "",
1149
+ "command": f"{quoted} hook-tick",
1150
+ }
1151
+ for ev in c.SETUP_HOOK_EVENTS
1152
+ ],
1153
+ "settings_path": str(c.CLAUDE_SETTINGS_PATH),
1154
+ },
1155
+ # Sibling parity with `_setup_status` and `_setup_install`
1156
+ # JSON envelopes (`legacy.bespoke_hooks` shape). Lets the same
1157
+ # consumer query bespoke-hook state from any of the three
1158
+ # commands uniformly.
1159
+ "legacy": {
1160
+ "statusline_snippet": str(legacy_path[0]) if legacy_path else None,
1161
+ "bespoke_hooks": {
1162
+ "detected": detection["detected"],
1163
+ "settings_entries": detection["settings_entries"],
1164
+ "files": detection["files"],
1165
+ },
1166
+ },
1167
+ # Flag-aware preview block. `decision` records what the
1168
+ # install path would do; `would_remove_entries` /
1169
+ # `would_move_files` are the rendered plan (empty when
1170
+ # decision == "skip" or "not_detected").
1171
+ "migration_preview": {
1172
+ "detected": detection["detected"],
1173
+ "decision": decision,
1174
+ "would_remove_entries": (
1175
+ []
1176
+ if decision in ("skip", "not_detected")
1177
+ else [
1178
+ {"event": e["event"], "command": e["command"]}
1179
+ for e in detection["settings_entries"]
1180
+ ]
1181
+ ),
1182
+ "would_move_files": (
1183
+ []
1184
+ if decision in ("skip", "not_detected")
1185
+ else list(detection["files"])
1186
+ ),
1187
+ },
1188
+ "exit_code": 0,
1189
+ }
1190
+ print(json.dumps(envelope, indent=2))
1191
+ return 0
1192
+
1193
+ _setup_emit_text(out)
1194
+ return 0
1195
+
1196
+
1197
+ def _setup_emit_text(lines: list[str]) -> None:
1198
+ for ln in lines:
1199
+ print(ln)
1200
+
1201
+
1202
+ def _setup_render_legacy_prompt(detection: dict) -> str:
1203
+ """Return the multi-line prompt body per spec Section 2.
1204
+
1205
+ Renders the ⚠ header, one row per detected (event → file) settings
1206
+ entry, an optional daemon-source line for usage-poller.py, the
1207
+ explanation of the silent failure mode, and the [Y/n] question.
1208
+ Caller is expected to print the body once and then dispatch to
1209
+ `_setup_read_legacy_prompt_input` for the actual answer.
1210
+ """
1211
+ lines = ["⚠ Detected legacy bespoke hooks (predate `cctally setup`):"]
1212
+ by_event = {e["event"]: e["command"] for e in detection["settings_entries"]}
1213
+ for ev in ("Stop", "SubagentStart", "SubagentStop"):
1214
+ cmd = by_event.get(ev, "")
1215
+ if cmd:
1216
+ file_part = cmd.replace("python3 ", "")
1217
+ lines.append(f" {file_part:38s} → hooks.{ev}")
1218
+ if any("usage-poller.py" in f for f in detection["files"]):
1219
+ lines.append(" ~/.claude/hooks/usage-poller.py (daemon spawned by usage-poller-start.py)")
1220
+ lines += [
1221
+ "",
1222
+ " Their delegate binary isn't on PATH on this system — every fire has",
1223
+ " been silently failing.",
1224
+ "",
1225
+ " Migrate now? Will unwire the settings.json entries and move the .py files",
1226
+ " to ~/.claude/cctally-legacy-hook-backup-<UTC ts>/. Reversible.",
1227
+ "",
1228
+ " Migrate? [Y/n]",
1229
+ ]
1230
+ return "\n".join(lines)
1231
+
1232
+
1233
+ def _setup_install(args: argparse.Namespace) -> int:
1234
+ """Install path. Returns exit code per Section 2 of spec."""
1235
+ c = _cctally()
1236
+ out: list[str] = []
1237
+ warnings = 0
1238
+
1239
+ claude_dir = pathlib.Path.home() / ".claude"
1240
+ if not claude_dir.exists():
1241
+ c.eprint(
1242
+ f"~/.claude/ does not exist. If Claude Code isn't installed yet, "
1243
+ f"install it first. If it is installed, run `claude` once to "
1244
+ f"initialize, then re-run cctally setup."
1245
+ )
1246
+ return 1
1247
+
1248
+ out.append(f"✓ Detected Claude Code at {claude_dir}")
1249
+
1250
+ repo_root = _setup_resolve_repo_root()
1251
+ dst_dir = _setup_local_bin_dir()
1252
+ abs_path = str(_setup_resolve_hook_target(repo_root))
1253
+
1254
+ # Validate settings.json BEFORE creating symlinks so a malformed
1255
+ # settings file leaves the filesystem untouched (spec §2.2 — exit
1256
+ # code 1 for "settings.json malformed"). Both calls are pure: load
1257
+ # only reads, merge mutates the in-memory dict only. The actual
1258
+ # backup + atomic write still happen after symlinks succeed.
1259
+ try:
1260
+ settings = c._load_claude_settings()
1261
+ except c.SetupError as exc:
1262
+ c.eprint(f"setup: {exc}")
1263
+ return 1
1264
+
1265
+ # ── Legacy bespoke hook detection + migration decision (spec §1, §2) ──
1266
+ # Detection is read-only on the in-memory settings dict; decision is
1267
+ # pure (no I/O); the prompt fires only when the decision helper
1268
+ # returns "prompt" (TTY + no flag + not --json). All three must run
1269
+ # BEFORE `_settings_merge_install` so the unwire+add land in the same
1270
+ # atomic write at sequence position 6.
1271
+ detection = _setup_detect_legacy_bespoke_hooks(settings)
1272
+ decision, reason = _setup_legacy_decide_action(
1273
+ args,
1274
+ detected=detection["detected"],
1275
+ stdin_isatty=sys.stdin.isatty(),
1276
+ )
1277
+ if decision == "prompt":
1278
+ print(_setup_render_legacy_prompt(detection))
1279
+ accepted = _setup_read_legacy_prompt_input(
1280
+ sys.stdin,
1281
+ reprompt="Please answer y or n. Migrate? [Y/n]",
1282
+ )
1283
+ decision = "migrate" if accepted else "skip"
1284
+ if not accepted:
1285
+ reason = "user_declined"
1286
+
1287
+ migration_summary: dict = {
1288
+ "performed": False,
1289
+ "reason": reason or "not_detected",
1290
+ }
1291
+
1292
+ backup_dir: pathlib.Path | None = None
1293
+ if decision == "migrate":
1294
+ # Resolve the backup dir BEFORE mutating settings.json so a
1295
+ # mkdir failure (parent unwriteable, name collision with a
1296
+ # regular file, ENOSPC, …) doesn't leave the on-disk settings
1297
+ # in a half-applied state — legacy entries gone but .py files
1298
+ # never moved. Pre-resolving also pins the timestamp shared
1299
+ # between the dir name and JSON envelope.
1300
+ try:
1301
+ # Route through `cctally` (call-time lookup) so the existing
1302
+ # `monkeypatch.setitem(ns, "_legacy_resolve_backup_dir", ...)`
1303
+ # in `tests/test_setup_legacy_migrate.py::TestLegacyMigrationE2EBackupDirFail`
1304
+ # still propagates into this code path post-extraction (§5.6 option C).
1305
+ backup_dir = c._legacy_resolve_backup_dir()
1306
+ except OSError as exc:
1307
+ c.eprint(f"setup: cannot create migration backup dir: {exc}")
1308
+ return 1
1309
+ # Unwire BEFORE the merge so the same atomic write removes legacy
1310
+ # entries and adds cctally entries (spec §2 step 6).
1311
+ settings, n_unwired = _settings_merge_unwire_legacy(settings)
1312
+ migration_summary = {
1313
+ "performed": True,
1314
+ "settings_entries_removed": n_unwired,
1315
+ "files_moved": 0,
1316
+ "backup_dir": None,
1317
+ "active_poller_pid_signaled": None,
1318
+ "active_poller_kill_outcome": None,
1319
+ "tmp_files_unlinked": [],
1320
+ }
1321
+
1322
+ try:
1323
+ _settings_merge_install(settings, abs_path)
1324
+ except c.SetupError as exc:
1325
+ c.eprint(f"setup: {exc}")
1326
+ return 1
1327
+
1328
+ sym_results = _setup_create_symlinks(repo_root, dst_dir)
1329
+ failed = [r for r in sym_results if r.status == "failed"]
1330
+ if failed:
1331
+ for r in failed:
1332
+ c.eprint(f"setup: symlink {r.name} failed: {r.detail}")
1333
+ return 1
1334
+ new_count = sum(1 for r in sym_results if r.status == "created")
1335
+ same_count = sum(1 for r in sym_results if r.status == "already")
1336
+ repl_count = sum(1 for r in sym_results if r.status == "replaced")
1337
+ detail_parts = []
1338
+ if new_count:
1339
+ detail_parts.append(f"{new_count} newly created")
1340
+ if same_count:
1341
+ detail_parts.append(f"{same_count} already correct")
1342
+ if repl_count:
1343
+ detail_parts.append(f"{repl_count} re-pointed")
1344
+ detail = ", ".join(detail_parts) or "no changes"
1345
+ out.append(f"✓ Symlinks at {dst_dir}/: {len(sym_results)}/{len(sym_results)} ({detail})")
1346
+
1347
+ if not _setup_path_includes_local_bin():
1348
+ warnings += 1
1349
+ rc = _setup_shell_rc_hint()
1350
+ out.append(f"⚠ {dst_dir} is not on your PATH. Add to {rc}:")
1351
+ out.append(f" export PATH=\"$HOME/.local/bin:$PATH\"")
1352
+ out.append(" Then reload (`source ...`) or open a new terminal.")
1353
+ out.append(" (Hooks still work — we used absolute paths in settings.json.)")
1354
+
1355
+ c._backup_claude_settings()
1356
+ try:
1357
+ c._write_claude_settings_atomic(settings)
1358
+ except OSError as exc:
1359
+ c.eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
1360
+ return 2
1361
+
1362
+ # ── Post-write migration apply (spec §2 steps 6a, 6b) ──
1363
+ # Settings.json is now durable. File moves, poller stop, and tmp
1364
+ # cleanup are best-effort and may emit a partial-move warning, but
1365
+ # do NOT roll back the on-disk settings.json. Per spec §2 exit-code
1366
+ # table, partial-move failures are uniformly exit-0-with-warning.
1367
+ if decision == "migrate":
1368
+ # `backup_dir` was resolved early (pre-write) so the mkdir
1369
+ # failure path can fail fast with no settings.json mutation.
1370
+ assert backup_dir is not None
1371
+ # Snapshot what we expected to move BEFORE the move so we can
1372
+ # detect partial failure cleanly (post-loop, src files are gone).
1373
+ expected_to_move = [
1374
+ n for n in c._LEGACY_BESPOKE_FILENAMES
1375
+ if (c._LEGACY_BESPOKE_HOOKS_DIR / n).exists()
1376
+ ]
1377
+ moved = _legacy_move_files_to_backup(backup_dir)
1378
+ migration_summary["files_moved"] = len(moved)
1379
+ migration_summary["backup_dir"] = str(backup_dir)
1380
+ if len(moved) < len(expected_to_move):
1381
+ orphans = sorted(set(expected_to_move) - {p.name for p in moved})
1382
+ out.append(
1383
+ f"⚠ Partial file move: {len(moved)} of {len(expected_to_move)} expected "
1384
+ f"files moved. Orphans: {', '.join(orphans)}"
1385
+ )
1386
+ warnings += 1
1387
+
1388
+ # Active-poller stop + tmp-sentinel cleanup (best-effort, silent
1389
+ # on failure per spec §2 step 6b). Capture the pre-stop PID for
1390
+ # the JSON envelope since the helper itself returns only the
1391
+ # outcome string.
1392
+ pid_signaled: int | None = None
1393
+ if c._LEGACY_POLLER_PID_FILE.exists():
1394
+ try:
1395
+ pid_signaled = int(
1396
+ c._LEGACY_POLLER_PID_FILE.read_text(encoding="utf-8", errors="replace").strip()
1397
+ )
1398
+ except (OSError, ValueError):
1399
+ pass
1400
+ kill_outcome = _legacy_stop_active_poller()
1401
+ # Per spec §3 (`active_poller_pid_signaled` semantics): record the
1402
+ # PID only when we actually attempted to deliver a signal. Stale-PID
1403
+ # and no-pid-file outcomes are read-only paths, so the JSON envelope
1404
+ # should reflect "no signal sent" with a null PID.
1405
+ if kill_outcome not in {"sigterm-took", "sigkill-took", "permission-denied"}:
1406
+ pid_signaled = None
1407
+ migration_summary["active_poller_pid_signaled"] = pid_signaled
1408
+ migration_summary["active_poller_kill_outcome"] = kill_outcome
1409
+ migration_summary["tmp_files_unlinked"] = _legacy_cleanup_tmp_sentinels()
1410
+
1411
+ out.append(
1412
+ f"✓ Migrated {migration_summary['settings_entries_removed']} legacy hook entries "
1413
+ f"→ moved {len(moved)} files to {backup_dir}/"
1414
+ )
1415
+
1416
+ # The "✓ Wrote …" line follows any migrate-summary line so the
1417
+ # narrative reads "we did the migration, then wrote the new entries"
1418
+ # — matches the spec's success-path sample (Section 2).
1419
+ out.append(f"✓ Wrote {len(c.SETUP_HOOK_EVENTS)} hook entries to {c.CLAUDE_SETTINGS_PATH}")
1420
+
1421
+ if decision == "skip" and reason in {"user_declined", "no_migrate_flag"}:
1422
+ files_str = "{record-usage-stop,usage-poller{,-start,-stop}}.py"
1423
+ out.append(
1424
+ f"⚠ Legacy bespoke hooks detected (predate `cctally setup`; failing "
1425
+ f"silently on this system). Skipped at your request. Re-run "
1426
+ f"`cctally setup --migrate-legacy-hooks` later, or remove them yourself. "
1427
+ f"The four `.py` files are at ~/.claude/hooks/{files_str}."
1428
+ )
1429
+ warnings += 1
1430
+
1431
+ oauth = _setup_oauth_token_present()
1432
+ if oauth:
1433
+ out.append("✓ Detected OAuth token")
1434
+ else:
1435
+ warnings += 1
1436
+ out.append("⚠ No Claude OAuth token detected.")
1437
+ out.append(" Run `claude` once to authenticate. After that, the next assistant")
1438
+ out.append(" message in any Claude Code session will start collecting data")
1439
+ out.append(" automatically — no need to re-run `cctally setup`.")
1440
+
1441
+ legacy = _setup_detect_legacy_snippet()
1442
+ if legacy is not None:
1443
+ warnings += 1
1444
+ path, hits = legacy
1445
+ line_str = ":".join(str(h) for h in hits[:1])
1446
+ out.append(f"⚠ Found legacy status-line snippet at {path}:{line_str}")
1447
+ out.append(" No need for it anymore — hooks now handle this. It's harmless to")
1448
+ out.append(" leave (data is funneled correctly either way), but you can remove")
1449
+ out.append(" it whenever you want. We won't touch the file.")
1450
+
1451
+ # Bootstrap (non-fatal). sync_cache requires a connection arg — mirror
1452
+ # the pattern from cmd_hook_tick (Task 2 fix).
1453
+ bootstrap_rows: int | None = None
1454
+ bootstrap_oauth_status: str | None = None
1455
+ try:
1456
+ cache_conn = c.open_cache_db()
1457
+ try:
1458
+ stats = c.sync_cache(cache_conn)
1459
+ rows = int(stats.rows_inserted)
1460
+ finally:
1461
+ try:
1462
+ cache_conn.close()
1463
+ except Exception:
1464
+ pass
1465
+ bootstrap_rows = rows
1466
+ out.append(f"✓ Synced session cache ({rows} new entries)")
1467
+ except Exception as exc:
1468
+ out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
1469
+ warnings += 1
1470
+ if oauth:
1471
+ try:
1472
+ status, _ = c._hook_tick_oauth_refresh()
1473
+ bootstrap_oauth_status = status
1474
+ if status.startswith("ok"):
1475
+ c._hook_tick_throttle_touch()
1476
+ out.append(f"✓ Bootstrapped weekly usage ({status})")
1477
+ else:
1478
+ out.append(f"⚠ Bootstrap OAuth fetch: {status}")
1479
+ warnings += 1
1480
+ except Exception as exc:
1481
+ bootstrap_oauth_status = f"err({type(exc).__name__})"
1482
+ out.append(f"⚠ Bootstrap OAuth failed: {exc}")
1483
+ warnings += 1
1484
+
1485
+ out.append("")
1486
+ if warnings:
1487
+ out.append(f"cctally is ready (with {warnings} warning(s) above).")
1488
+ else:
1489
+ out.append("cctally is ready.")
1490
+ out.append("")
1491
+ # Settings.json was modified — CC caches it at session start. The
1492
+ # warning fires unconditionally because `_setup_install` always
1493
+ # rewrites settings.json (legacy migration, fresh install, repair).
1494
+ out.append("⚠ Restart Claude Code for the new hooks to take effect in any currently")
1495
+ out.append(" open sessions. New sessions launched after this point pick them up")
1496
+ out.append(" automatically. (settings.json is cached at session start.)")
1497
+ out.append("")
1498
+ out.append(" Try:")
1499
+ out.append(" cctally daily # last 30 days")
1500
+ out.append(" cctally dashboard # live web dashboard")
1501
+ out.append(" cctally tui # terminal dashboard")
1502
+ out.append(" cctally setup --status # verify install state")
1503
+
1504
+ if getattr(args, "json", False):
1505
+ envelope = {
1506
+ "schema_version": 1,
1507
+ "mode": "install",
1508
+ "result": "warn" if warnings else "ok",
1509
+ "symlinks": {
1510
+ "created": new_count,
1511
+ "already": same_count,
1512
+ "replaced": repl_count,
1513
+ "total": len(sym_results),
1514
+ "destination": str(dst_dir),
1515
+ },
1516
+ "hooks": {
1517
+ "events_added": list(c.SETUP_HOOK_EVENTS),
1518
+ "settings_path": str(c.CLAUDE_SETTINGS_PATH),
1519
+ },
1520
+ "auth": {
1521
+ "oauth_token_present": oauth,
1522
+ },
1523
+ "path_includes_local_bin": _setup_path_includes_local_bin(),
1524
+ "legacy": {
1525
+ "statusline_snippet_path": str(legacy[0]) if legacy else None,
1526
+ "bespoke_hooks": {
1527
+ "detected": detection["detected"],
1528
+ "settings_entries": detection["settings_entries"],
1529
+ "files": detection["files"],
1530
+ },
1531
+ },
1532
+ "migration": migration_summary,
1533
+ "bootstrap": {
1534
+ "session_cache_rows": bootstrap_rows,
1535
+ "oauth_status": bootstrap_oauth_status,
1536
+ },
1537
+ "warnings_count": warnings,
1538
+ "exit_code": 0,
1539
+ }
1540
+ print(json.dumps(envelope, indent=2))
1541
+ return 0
1542
+
1543
+ _setup_emit_text(out)
1544
+ return 0
1545
+
1546
+
1547
+ # ── entry point ────────────────────────────────────────────────────────
1548
+
1549
+
1550
+ def cmd_setup(args: argparse.Namespace) -> int:
1551
+ c = _cctally()
1552
+ # Migration flags are install-mode-only. Reject combinations with
1553
+ # --status or --uninstall (per spec Section 2 mode×flag matrix). The
1554
+ # mutex group on the parser already prevents both flags being set
1555
+ # together; here we guard the mode-axis pairing that argparse can't
1556
+ # express in a single mutex group.
1557
+ mig_flag = (
1558
+ "--migrate-legacy-hooks" if getattr(args, "migrate_legacy_hooks", False)
1559
+ else "--no-migrate-legacy-hooks" if getattr(args, "no_migrate_legacy_hooks", False)
1560
+ else None
1561
+ )
1562
+ if mig_flag and (getattr(args, "status", False) or getattr(args, "uninstall", False)):
1563
+ c.eprint(f"setup: {mig_flag} is install-mode only")
1564
+ return 2
1565
+ if getattr(args, "uninstall", False):
1566
+ return _setup_uninstall(args)
1567
+ if getattr(args, "status", False):
1568
+ return _setup_status(args)
1569
+ if getattr(args, "dry_run", False):
1570
+ return _setup_dry_run(args)
1571
+ return _setup_install(args)