cctally 1.22.2 → 1.22.3

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.
@@ -10,15 +10,22 @@ legacy bespoke hooks), the prompt / decision helpers, the four
10
10
  and the `cmd_setup` entry point.
11
11
 
12
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).
13
+ `_write_claude_settings_atomic`, `_backup_claude_settings`), `SetupError`,
14
+ `_is_cctally_hook_command`, and `cmd_repair_symlinks` now LIVE HERE
15
+ (#125 Batch E, C10 — relocated from `bin/cctally`). bin/cctally
16
+ eager-re-exports each so doctor's `except c.SetupError`, the parser's
17
+ `set_defaults(func=c.cmd_repair_symlinks)`, and the setup tests'
18
+ `monkeypatch.setitem(ns, "_load_claude_settings", …)` resolve unchanged.
19
+ The `_LEGACY_*` constants and `CLAUDE_SETTINGS_PATH` stay in
20
+ `bin/cctally` / `_cctally_core` preserves the existing `_e2e_pin_paths`
21
+ test workaround verbatim (§5.4), plus keeps the monkeypatch-sensitive
22
+ `_LEGACY_BESPOKE_HOOKS_DIR` / `_LEGACY_POLLER_PID_FILE` /
23
+ `_LEGACY_POLLER_COUNT_FILE` constants on `cctally` where
24
+ `monkeypatch.setitem(ns, ...)` lands. Helpers in this module reach the
25
+ `_LEGACY_*` constants via `_cctally().<NAME>` (call-time lookup,
26
+ monkeypatch propagates); the C10 helpers above are same-module locals but
27
+ THIS module's existing call sites deliberately keep reaching them via
28
+ `c.<NAME>` so ns-level `setitem` patches stay visible (Codex round-1 P1).
22
29
 
23
30
  bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same as
24
31
  `bin/_lib_subscription_weeks.py` and `bin/_lib_aggregators.py`):
@@ -30,12 +37,14 @@ bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same as
30
37
  `_LEGACY_BESPOKE_FILENAMES`, `_LEGACY_POLLER_PID_FILE`,
31
38
  `_LEGACY_POLLER_COUNT_FILE`, `_LEGACY_BACKUP_DIR_PREFIX`,
32
39
  `_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`,
40
+ - Shared helpers: `eprint`, `_resolve_oauth_token`,
36
41
  `_hook_tick_throttle_age_seconds`, `_hook_tick_oauth_refresh`,
37
42
  `_hook_tick_throttle_touch`, `_command_as_of`, `open_cache_db`,
38
- `sync_cache`.
43
+ `sync_cache`. (`SetupError`, `_is_cctally_hook_command`,
44
+ `_load_claude_settings`, `_write_claude_settings_atomic`,
45
+ `_backup_claude_settings` are now LOCAL to this module — C10 — but the
46
+ call sites here still reach them via `c.<NAME>` for the monkeypatch
47
+ contract, so functionally they read like the back-references above.)
39
48
 
40
49
  bin/cctally re-exports every public symbol below so tests that drive
41
50
  `cmd_setup` and the legacy-migration helpers via `ns["X"](...)` resolve
@@ -110,6 +119,129 @@ _DEV_SETUP_FORCE_DEV_OVERRIDE_WARNING = (
110
119
  )
111
120
 
112
121
 
122
+ # ── settings/hook glue (#125 Batch E, C10) ─────────────────────────────
123
+ # Moved here from bin/cctally. bin/cctally re-exports each via the
124
+ # _cctally_setup load site so doctor's `except c.SetupError`, the parser's
125
+ # `set_defaults(func=c.cmd_repair_symlinks)`, and the setup tests'
126
+ # `monkeypatch.setitem(ns, "_load_claude_settings", …)` all resolve to
127
+ # these objects. The sibling's OWN reaches to these helpers deliberately
128
+ # STAY `c.<name>` (NOT local) so ns-level monkeypatches propagate.
129
+
130
+
131
+ def _is_cctally_hook_command(cmd: str) -> bool:
132
+ """Return True if `cmd` is one of OUR hook entries (Section 4 of spec).
133
+
134
+ Identification is shlex-aware so quoted absolute paths (with spaces)
135
+ and bare names both match. The discriminator is the LAST TWO tokens
136
+ after stripping any trailing `&` and surrounding whitespace:
137
+
138
+ tokens[-2] = a path whose basename is ``cctally`` or
139
+ the npm shim (``_CCTALLY_NPM_SHIM_BASENAME``; npm install
140
+ layout — the hook command points at the Node shim so
141
+ ``CCTALLY_PYTHON`` propagates from the user's shell env
142
+ into hook fires)
143
+ tokens[-1] = "hook-tick"
144
+
145
+ Examples that match:
146
+ cctally hook-tick
147
+ cctally hook-tick &
148
+ /Users/me/.local/bin/cctally hook-tick &
149
+ '/Users/My Name/.local/bin/cctally' hook-tick &
150
+ /usr/local/lib/node_modules/cctally/bin/<npm-shim> hook-tick
151
+ """
152
+ import shlex
153
+ if not isinstance(cmd, str) or not cmd.strip():
154
+ return False
155
+ stripped = cmd.strip()
156
+ # Strip trailing &; allow whitespace before it.
157
+ while stripped.endswith("&"):
158
+ stripped = stripped[:-1].rstrip()
159
+ if not stripped:
160
+ return False
161
+ try:
162
+ tokens = shlex.split(stripped)
163
+ except ValueError:
164
+ return False
165
+ if len(tokens) < 2:
166
+ return False
167
+ last = tokens[-1]
168
+ prev = tokens[-2]
169
+ if last != "hook-tick":
170
+ return False
171
+ return pathlib.PurePosixPath(prev).name in (
172
+ "cctally", _CCTALLY_NPM_SHIM_BASENAME,
173
+ )
174
+
175
+
176
+ class SetupError(RuntimeError):
177
+ """Raised when setup hits a hard prerequisite failure (Section 2 of spec)."""
178
+
179
+
180
+ def _load_claude_settings(path: pathlib.Path | None = None) -> dict:
181
+ """Read ~/.claude/settings.json. Empty/missing → {}. Malformed → SetupError.
182
+
183
+ ``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
184
+ TIME when omitted, so ``monkeypatch.setattr(_cctally_core,
185
+ "CLAUDE_SETTINGS_PATH", tmp)`` propagates without needing to swap
186
+ out this callable. Capturing the default at def-time would silently
187
+ pin the maintainer's real ``~/.claude/settings.json``.
188
+ """
189
+ if path is None:
190
+ path = _cctally_core.CLAUDE_SETTINGS_PATH
191
+ if not path.exists():
192
+ return {}
193
+ raw = path.read_text(encoding="utf-8")
194
+ if not raw.strip():
195
+ return {}
196
+ try:
197
+ data = json.loads(raw)
198
+ except json.JSONDecodeError as exc:
199
+ raise SetupError(
200
+ f"settings.json at {path} is not valid JSON: {exc}. Fix it and re-run."
201
+ ) from exc
202
+ if not isinstance(data, dict):
203
+ raise SetupError(f"settings.json at {path} is not a JSON object — fix and re-run.")
204
+ return data
205
+
206
+
207
+ def _backup_claude_settings(path: pathlib.Path | None = None) -> pathlib.Path | None:
208
+ """Best-effort daily backup; return backup path or None.
209
+
210
+ ``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
211
+ TIME when omitted (see ``_load_claude_settings`` for rationale).
212
+ """
213
+ if path is None:
214
+ path = _cctally_core.CLAUDE_SETTINGS_PATH
215
+ if not path.exists():
216
+ return None
217
+ today = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d")
218
+ backup = path.with_name(path.name + f".cctally-backup-{today}")
219
+ if backup.exists():
220
+ return backup
221
+ try:
222
+ shutil.copy2(path, backup)
223
+ except OSError as exc:
224
+ eprint(f"[setup] backup failed (continuing): {exc}")
225
+ return None
226
+ return backup
227
+
228
+
229
+ def _write_claude_settings_atomic(
230
+ settings: dict, path: pathlib.Path | None = None
231
+ ) -> None:
232
+ """Atomic write with 2-space indent, trailing newline.
233
+
234
+ ``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
235
+ TIME when omitted (see ``_load_claude_settings`` for rationale).
236
+ """
237
+ if path is None:
238
+ path = _cctally_core.CLAUDE_SETTINGS_PATH
239
+ path.parent.mkdir(parents=True, exist_ok=True)
240
+ tmp = path.with_suffix(path.suffix + ".tmp")
241
+ tmp.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
242
+ os.replace(tmp, path)
243
+
244
+
113
245
  # ── settings.json hook surgery ─────────────────────────────────────────
114
246
 
115
247
 
@@ -417,6 +549,33 @@ def _setup_repair_symlinks(
417
549
  return _RepairResult(gated=False, created=created, failed=failed)
418
550
 
419
551
 
552
+ def cmd_repair_symlinks(args: argparse.Namespace) -> int:
553
+ """Hidden: additively create missing ~/.local/bin/ symlinks on upgrade.
554
+
555
+ Invoked best-effort by the npm postinstall (issue #114). Refuses from
556
+ a dev checkout (would point ~/.local/bin at the dev tree). Touches
557
+ only symlinks — see _setup_repair_symlinks. Exempted from main()'s
558
+ post-command update hooks (see _post_command_update_hooks).
559
+ """
560
+ if _cctally_core._is_dev_checkout():
561
+ eprint(
562
+ "repair-symlinks: refusing to run from a dev checkout "
563
+ "(would point ~/.local/bin at the dev tree)"
564
+ )
565
+ return 2
566
+ repo_root = _setup_resolve_repo_root()
567
+ dst_dir = _setup_local_bin_dir()
568
+ result = _setup_repair_symlinks(repo_root, dst_dir)
569
+ if result.created:
570
+ print(
571
+ f"cctally: linked {len(result.created)} new command symlink(s): "
572
+ + ", ".join(result.created)
573
+ )
574
+ for name, detail in result.failed:
575
+ eprint(f"repair-symlinks: {name}: {detail}")
576
+ return 1 if result.failed else 0
577
+
578
+
420
579
  def _setup_cleanup_stale_symlinks(
421
580
  dst_dir: pathlib.Path,
422
581
  ) -> list[_SetupSymlinkResult]: