cctally 1.7.0 → 1.7.2
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.
- package/CHANGELOG.md +27 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5403 -0
- package/bin/_cctally_db.py +1837 -0
- package/bin/_cctally_record.py +2305 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4487 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +441 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +137 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11694 -35448
- package/package.json +24 -1
|
@@ -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)
|