cctally 1.67.1 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +754 -109
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +187 -15
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +366 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +131 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +321 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +238 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +325 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -56,6 +56,7 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
56
56
  from __future__ import annotations
57
57
 
58
58
  import argparse
59
+ import contextlib
59
60
  import dataclasses
60
61
  import datetime as dt
61
62
  import json
@@ -85,6 +86,17 @@ from _cctally_core import (
85
86
  eprint,
86
87
  _command_as_of,
87
88
  )
89
+ from _lib_codex_hooks import (
90
+ CODEX_HOOK_EVENTS,
91
+ CodexHooksError,
92
+ _plan_install as _codex_hooks_plan_install,
93
+ _plan_uninstall as _codex_hooks_plan_uninstall,
94
+ _read_hooks_document as _read_codex_hooks_document,
95
+ _write_hooks_document_atomic as _write_codex_hooks_atomic,
96
+ codex_hook_roots,
97
+ is_canonical_owned_codex_hook_handler,
98
+ is_owned_codex_hook_command,
99
+ )
88
100
 
89
101
 
90
102
  # Dev-instance isolation (§3): refusal message when `cctally setup` is run
@@ -1312,6 +1324,159 @@ def _setup_detect_stale_symlinks(dst_dir: pathlib.Path) -> list[str]:
1312
1324
  return found
1313
1325
 
1314
1326
 
1327
+ # ── Codex native hooks.json management (#294 S2) ─────────────────────────
1328
+
1329
+
1330
+ def _setup_codex_hook_roots():
1331
+ """Configured, usable Codex homes in sorted opaque-root-key order.
1332
+
1333
+ The existing `$CODEX_HOME` resolver deliberately returns no default root
1334
+ for an explicit all-invalid override; preserve that privacy boundary here.
1335
+ """
1336
+ return codex_hook_roots(_cctally()._codex_home_roots())
1337
+
1338
+
1339
+ def _codex_hooks_feature_enabled() -> bool:
1340
+ return not _cctally_core._truthy_env("CCTALLY_DISABLE_CODEX_HOOKS")
1341
+
1342
+
1343
+ def _setup_claude_available() -> bool:
1344
+ """Whether setup may truthfully plan or mutate Claude-owned surfaces."""
1345
+ return (pathlib.Path.home() / ".claude").is_dir()
1346
+
1347
+
1348
+ def _codex_hooks_count(document: dict, binary: str) -> dict[str, dict[str, int]]:
1349
+ hooks = document.get("hooks", {})
1350
+ counts = {
1351
+ event: {"owned": 0, "canonical": 0}
1352
+ for event in CODEX_HOOK_EVENTS
1353
+ }
1354
+ for event in CODEX_HOOK_EVENTS:
1355
+ for group in hooks.get(event, []):
1356
+ for handler in group["hooks"]:
1357
+ if is_owned_codex_hook_command(handler.get("command"), binary):
1358
+ counts[event]["owned"] += 1
1359
+ if is_canonical_owned_codex_hook_handler(handler, binary):
1360
+ counts[event]["canonical"] += 1
1361
+ return counts
1362
+
1363
+
1364
+ def _codex_hook_remediation(state: str) -> str | None:
1365
+ messages = {
1366
+ "absent": "Run `cctally setup` to install the native Codex handler.",
1367
+ "malformed": "Fix the malformed hooks.json before cctally can manage it.",
1368
+ "feature_disabled": "Unset CCTALLY_DISABLE_CODEX_HOOKS to enable native Codex hooks.",
1369
+ "installed_review_required": "Review and trust the exact cctally handler in Codex /hooks.",
1370
+ "installed_trust_unobservable": "Verify the exact cctally handler in Codex /hooks.",
1371
+ "unavailable": "Ensure this configured Codex home is available, then re-run setup.",
1372
+ }
1373
+ return messages.get(state)
1374
+
1375
+
1376
+ def _codex_hook_row(root, binary: str, *, changed: bool = False) -> dict:
1377
+ base = {
1378
+ "source_root_key": root.source_root_key,
1379
+ "codex_home": str(root.codex_home),
1380
+ "hooks_path": str(root.hooks_path),
1381
+ "stop_count": 0,
1382
+ "subagent_stop_count": 0,
1383
+ "feature_enabled": _codex_hooks_feature_enabled(),
1384
+ "requires_review": False,
1385
+ "remediation": None,
1386
+ "error": None,
1387
+ }
1388
+ if not base["feature_enabled"]:
1389
+ state = "feature_disabled"
1390
+ else:
1391
+ try:
1392
+ document = _read_codex_hooks_document(root.hooks_path)
1393
+ except CodexHooksError as exc:
1394
+ state = "malformed"
1395
+ base["error"] = str(exc)
1396
+ else:
1397
+ counts = _codex_hooks_count(document, binary)
1398
+ base["stop_count"] = counts["Stop"]["owned"]
1399
+ base["subagent_stop_count"] = counts["SubagentStop"]["owned"]
1400
+ if all(
1401
+ counts[event] == {"owned": 1, "canonical": 1}
1402
+ for event in CODEX_HOOK_EVENTS
1403
+ ):
1404
+ state = "installed_review_required" if changed else "installed_trust_unobservable"
1405
+ base["requires_review"] = True if changed else None
1406
+ else:
1407
+ state = "absent"
1408
+ base["state"] = state
1409
+ base["remediation"] = _codex_hook_remediation(state)
1410
+ return base
1411
+
1412
+
1413
+ def _setup_codex_hooks_preflight(
1414
+ binary: str, *, validate_feature_disabled: bool = False,
1415
+ ) -> list[dict]:
1416
+ """Read/validate every managed file before any setup mutation.
1417
+
1418
+ Feature-disabled status and preview normally avoid reading Codex
1419
+ configuration because installation is inactive. Mutating install and
1420
+ uninstall pass ``validate_feature_disabled=True`` so both fail closed on
1421
+ every malformed managed file before changing any provider-owned surface.
1422
+ """
1423
+ rows: list[dict] = []
1424
+ for root in _setup_codex_hook_roots():
1425
+ row = _codex_hook_row(root, binary)
1426
+ if validate_feature_disabled and row["state"] == "feature_disabled":
1427
+ try:
1428
+ _read_codex_hooks_document(root.hooks_path)
1429
+ except CodexHooksError as exc:
1430
+ row["state"] = "malformed"
1431
+ row["error"] = str(exc)
1432
+ row["remediation"] = _codex_hook_remediation("malformed")
1433
+ rows.append(row)
1434
+ return rows
1435
+
1436
+
1437
+ def _setup_manage_codex_hooks(mode: str, binary: str) -> dict:
1438
+ """Apply owned-only install/uninstall surgery to every detected home."""
1439
+ rows: list[dict] = []
1440
+ for root in _setup_codex_hook_roots():
1441
+ # The feature switch prevents new native-hook installation, but must
1442
+ # never strand handlers that an operator explicitly asks setup to
1443
+ # uninstall. Malformed documents still remain fail-closed.
1444
+ if mode == "install" and not _codex_hooks_feature_enabled():
1445
+ rows.append(_codex_hook_row(root, binary))
1446
+ continue
1447
+ try:
1448
+ if mode == "install":
1449
+ planner = lambda document: _codex_hooks_plan_install(document, binary)
1450
+ elif mode == "uninstall":
1451
+ planner = lambda document: _codex_hooks_plan_uninstall(document, binary)
1452
+ else:
1453
+ raise ValueError(f"unsupported Codex hooks mode: {mode}")
1454
+ _planned, changes, changed, _backup = _write_codex_hooks_atomic(
1455
+ root.hooks_path, transform=planner,
1456
+ harden_unchanged=(mode == "install"),
1457
+ )
1458
+ row = _codex_hook_row(root, binary, changed=(mode == "install" and changed))
1459
+ row["changes"] = changes
1460
+ rows.append(row)
1461
+ except CodexHooksError as exc:
1462
+ initial = _codex_hook_row(root, binary)
1463
+ initial["state"] = "malformed"
1464
+ initial["requires_review"] = False
1465
+ initial["remediation"] = _codex_hook_remediation("malformed")
1466
+ initial["error"] = str(exc)
1467
+ rows.append(initial)
1468
+ return _codex_hooks_summary(rows)
1469
+
1470
+
1471
+ def _codex_hooks_summary(rows: list[dict]) -> dict:
1472
+ installed_states = {"installed_review_required", "installed_trust_unobservable"}
1473
+ return {
1474
+ "roots": rows,
1475
+ "installed_count": sum(1 for row in rows if row["state"] in installed_states),
1476
+ "error_count": sum(1 for row in rows if row["state"] in {"malformed", "unavailable"}),
1477
+ }
1478
+
1479
+
1315
1480
  def _setup_status(args: argparse.Namespace) -> int:
1316
1481
  c = _cctally()
1317
1482
  repo_root = _setup_resolve_repo_root()
@@ -1335,6 +1500,9 @@ def _setup_status(args: argparse.Namespace) -> int:
1335
1500
  legacy = _setup_detect_legacy_snippet()
1336
1501
  bespoke = _setup_detect_legacy_bespoke_hooks(settings)
1337
1502
  data_bytes = _setup_data_dir_size_bytes()
1503
+ codex_hooks = _codex_hooks_summary(
1504
+ _setup_codex_hooks_preflight(str(_setup_resolve_hook_target(repo_root)))
1505
+ )
1338
1506
 
1339
1507
  if getattr(args, "json", False):
1340
1508
  envelope = {
@@ -1353,6 +1521,7 @@ def _setup_status(args: argparse.Namespace) -> int:
1353
1521
  ),
1354
1522
  },
1355
1523
  "activity_24h": activity,
1524
+ "codex_hooks": codex_hooks,
1356
1525
  "legacy": {
1357
1526
  "statusline_snippet": str(legacy[0]) if legacy else None,
1358
1527
  "bespoke_hooks": {
@@ -1386,6 +1555,13 @@ def _setup_status(args: argparse.Namespace) -> int:
1386
1555
  marker = "✓" if hook_counts[ev] >= 1 else "✗"
1387
1556
  word = "installed" if hook_counts[ev] >= 1 else "missing"
1388
1557
  out.append(f" {ev:14s} {word:24s} {marker}")
1558
+ if codex_hooks["roots"]:
1559
+ out.append("Codex hooks")
1560
+ for row in codex_hooks["roots"]:
1561
+ out.append(
1562
+ f" {row['source_root_key']} {row['state']} "
1563
+ f"(Stop {row['stop_count']}, SubagentStop {row['subagent_stop_count']})"
1564
+ )
1389
1565
  out.append("Auth")
1390
1566
  out.append(f" OAuth token {'present' if oauth else 'missing'} "
1391
1567
  f"{'✓' if oauth else '⚠'}")
@@ -1429,21 +1605,39 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1429
1605
  is_json = bool(getattr(args, "json", False))
1430
1606
 
1431
1607
  out: list[str] = []
1432
- try:
1433
- settings = c._load_claude_settings()
1434
- except c.SetupError as exc:
1435
- eprint(f"setup: {exc}")
1608
+ repo_root = _setup_resolve_repo_root()
1609
+ codex_binary = str(_setup_resolve_hook_target(repo_root))
1610
+ codex_preflight = _setup_codex_hooks_preflight(
1611
+ codex_binary, validate_feature_disabled=True,
1612
+ )
1613
+ bad_codex = next((row for row in codex_preflight if row["state"] == "malformed"), None)
1614
+ if bad_codex is not None:
1615
+ eprint(f"setup: {bad_codex['error']}")
1436
1616
  return 1
1437
- settings, removed = _settings_merge_uninstall(settings)
1438
- if removed:
1617
+ claude_available = _setup_claude_available()
1618
+ removed = 0
1619
+ if claude_available:
1439
1620
  try:
1440
- c._write_claude_settings_atomic(settings)
1441
- except OSError as exc:
1442
- eprint(f"setup: failed to write {_cctally_core.CLAUDE_SETTINGS_PATH}: {exc}")
1443
- return 2
1444
- out.append(f"Removed {removed} hook entries from {_cctally_core.CLAUDE_SETTINGS_PATH}")
1621
+ settings = c._load_claude_settings()
1622
+ except c.SetupError as exc:
1623
+ eprint(f"setup: {exc}")
1624
+ return 1
1625
+ settings, removed = _settings_merge_uninstall(settings)
1626
+ if removed:
1627
+ try:
1628
+ c._write_claude_settings_atomic(settings)
1629
+ except OSError as exc:
1630
+ eprint(f"setup: failed to write {_cctally_core.CLAUDE_SETTINGS_PATH}: {exc}")
1631
+ return 2
1632
+ out.append(f"Removed {removed} hook entries from {_cctally_core.CLAUDE_SETTINGS_PATH}")
1633
+
1634
+ codex_hooks = _setup_manage_codex_hooks("uninstall", codex_binary)
1635
+ for row in codex_hooks["roots"]:
1636
+ changes = row.get("changes", {})
1637
+ count = sum(changes.values()) if isinstance(changes, dict) else 0
1638
+ if count:
1639
+ out.append(f"Removed {count} Codex hook entries from {row['hooks_path']}")
1445
1640
 
1446
- repo_root = _setup_resolve_repo_root()
1447
1641
  dst_dir = _setup_local_bin_dir()
1448
1642
  sym_removed = 0
1449
1643
  for name in c.SETUP_SYMLINK_NAMES:
@@ -1523,6 +1717,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1523
1717
  "result": "purge_declined",
1524
1718
  "reason": "json_without_yes",
1525
1719
  "hooks_removed": removed,
1720
+ "codex_hooks": codex_hooks,
1526
1721
  "symlinks_removed": sym_removed,
1527
1722
  "purged": False,
1528
1723
  "data_path": str(_cctally_core.APP_DIR),
@@ -1578,6 +1773,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
1578
1773
  "mode": "uninstall",
1579
1774
  "result": "ok",
1580
1775
  "hooks_removed": removed,
1776
+ "codex_hooks": codex_hooks,
1581
1777
  "symlinks_removed": sym_removed,
1582
1778
  "purged": purge,
1583
1779
  "data_path": str(_cctally_core.APP_DIR),
@@ -1597,15 +1793,16 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1597
1793
  c = _cctally()
1598
1794
  repo_root = _setup_resolve_repo_root()
1599
1795
  dst_dir = _setup_local_bin_dir()
1600
- try:
1601
- settings = c._load_claude_settings()
1602
- except c.SetupError as exc:
1603
- # Malformed settings.json — preview still proceeds; legacy detection
1604
- # against an empty dict simply yields detected=False for entries (files
1605
- # detection is independent of settings). Mirror _setup_status's pattern
1606
- # so the user sees the same condition that would fail _setup_install.
1607
- eprint(f"setup: warning: {exc}")
1608
- settings = {}
1796
+ claude_available = _setup_claude_available()
1797
+ settings: dict = {}
1798
+ if claude_available:
1799
+ try:
1800
+ settings = c._load_claude_settings()
1801
+ except c.SetupError as exc:
1802
+ # Malformed settings.json preview still proceeds; legacy detection
1803
+ # against an empty dict simply yields detected=False for entries.
1804
+ # Mirror _setup_status's non-mutating warning behavior.
1805
+ eprint(f"setup: warning: {exc}")
1609
1806
  detection = _setup_detect_legacy_bespoke_hooks(settings)
1610
1807
  sym_results = []
1611
1808
  for name in c.SETUP_SYMLINK_NAMES:
@@ -1635,16 +1832,29 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1635
1832
  out.append(f"⚠ Blocked (non-symlink files exist): {', '.join(blocked)}")
1636
1833
  out.append(" Remove them manually then re-run.")
1637
1834
 
1638
- out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}:")
1639
1835
  abs_path = str(_setup_resolve_hook_target(repo_root))
1836
+ codex_hooks = _codex_hooks_summary(_setup_codex_hooks_preflight(abs_path))
1640
1837
  import shlex
1641
1838
  quoted = shlex.quote(abs_path)
1642
- for ev in c.SETUP_HOOK_EVENTS:
1643
- matcher = '"*"' if ev == "PostToolBatch" else '""'
1644
- out.append(
1645
- f" hooks.{ev}[*] += {{ matcher: {matcher}, "
1646
- f"command: \"{quoted} hook-tick\" }}"
1647
- )
1839
+ if claude_available:
1840
+ out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}:")
1841
+ for ev in c.SETUP_HOOK_EVENTS:
1842
+ matcher = '"*"' if ev == "PostToolBatch" else '""'
1843
+ out.append(
1844
+ f" hooks.{ev}[*] += {{ matcher: {matcher}, "
1845
+ f"command: \"{quoted} hook-tick\" }}"
1846
+ )
1847
+ else:
1848
+ out.append("Claude Code home not present — would skip Claude hooks")
1849
+ for row in codex_hooks["roots"]:
1850
+ if row["state"] == "malformed":
1851
+ out.append(f"Codex hooks malformed at {row['hooks_path']}: {row['error']}")
1852
+ elif row["state"] == "feature_disabled":
1853
+ out.append(f"Codex hooks disabled for {row['codex_home']}")
1854
+ else:
1855
+ out.append(
1856
+ f"Would add native Codex Stop/SubagentStop handlers to {row['hooks_path']}"
1857
+ )
1648
1858
  # Spec §2 mode×flag matrix — three distinct dry-run rendering paths
1649
1859
  # when legacy is detected:
1650
1860
  # --dry-run --no-migrate-legacy-hooks → migration block omitted entirely
@@ -1724,10 +1934,11 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1724
1934
  "matcher": "*" if ev == "PostToolBatch" else "",
1725
1935
  "command": f"{quoted} hook-tick",
1726
1936
  }
1727
- for ev in c.SETUP_HOOK_EVENTS
1937
+ for ev in (c.SETUP_HOOK_EVENTS if claude_available else ())
1728
1938
  ],
1729
1939
  "settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
1730
1940
  },
1941
+ "codex_hooks": codex_hooks,
1731
1942
  # Sibling parity with `_setup_status` and `_setup_install`
1732
1943
  # JSON envelopes (`legacy.bespoke_hooks` shape). Lets the same
1733
1944
  # consumer query bespoke-hook state from any of the three
@@ -1891,29 +2102,42 @@ def _setup_install(args: argparse.Namespace) -> int:
1891
2102
  warnings = 0
1892
2103
 
1893
2104
  claude_dir = pathlib.Path.home() / ".claude"
1894
- if not claude_dir.exists():
2105
+ claude_available = _setup_claude_available()
2106
+ repo_root = _setup_resolve_repo_root()
2107
+ dst_dir = _setup_local_bin_dir()
2108
+ abs_path = str(_setup_resolve_hook_target(repo_root))
2109
+ codex_roots = _setup_codex_hook_roots()
2110
+ if not claude_available and not codex_roots:
1895
2111
  eprint(
1896
- f"~/.claude/ does not exist. If Claude Code isn't installed yet, "
1897
- f"install it first. If it is installed, run `claude` once to "
1898
- f"initialize, then re-run cctally setup."
2112
+ "Neither an initialized Claude Code home nor a usable Codex home "
2113
+ "was found. Run the provider once, then re-run cctally setup."
1899
2114
  )
1900
2115
  return 1
1901
2116
 
1902
- out.append(f"✓ Detected Claude Code at {claude_dir}")
1903
-
1904
- repo_root = _setup_resolve_repo_root()
1905
- dst_dir = _setup_local_bin_dir()
1906
- abs_path = str(_setup_resolve_hook_target(repo_root))
2117
+ if claude_available:
2118
+ out.append(f"✓ Detected Claude Code at {claude_dir}")
2119
+ else:
2120
+ out.append("✓ Claude Code home not present — skipping Claude hooks")
1907
2121
 
1908
2122
  # Validate settings.json BEFORE creating symlinks so a malformed
1909
2123
  # settings file leaves the filesystem untouched (spec §2.2 — exit
1910
2124
  # code 1 for "settings.json malformed"). Both calls are pure: load
1911
2125
  # only reads, merge mutates the in-memory dict only. The actual
1912
2126
  # backup + atomic write still happen after symlinks succeed.
1913
- try:
1914
- settings = c._load_claude_settings()
1915
- except c.SetupError as exc:
1916
- eprint(f"setup: {exc}")
2127
+ settings: dict = {}
2128
+ if claude_available:
2129
+ try:
2130
+ settings = c._load_claude_settings()
2131
+ except c.SetupError as exc:
2132
+ eprint(f"setup: {exc}")
2133
+ return 1
2134
+
2135
+ codex_preflight = _setup_codex_hooks_preflight(
2136
+ abs_path, validate_feature_disabled=True,
2137
+ )
2138
+ bad_codex = next((row for row in codex_preflight if row["state"] == "malformed"), None)
2139
+ if bad_codex is not None:
2140
+ eprint(f"setup: {bad_codex['error']}")
1917
2141
  return 1
1918
2142
 
1919
2143
  # ── Legacy bespoke hook detection + migration decision (spec §1, §2) ──
@@ -1973,11 +2197,12 @@ def _setup_install(args: argparse.Namespace) -> int:
1973
2197
  "tmp_files_unlinked": [],
1974
2198
  }
1975
2199
 
1976
- try:
1977
- _settings_merge_install(settings, abs_path)
1978
- except c.SetupError as exc:
1979
- eprint(f"setup: {exc}")
1980
- return 1
2200
+ if claude_available:
2201
+ try:
2202
+ _settings_merge_install(settings, abs_path)
2203
+ except c.SetupError as exc:
2204
+ eprint(f"setup: {exc}")
2205
+ return 1
1981
2206
 
1982
2207
  # Clean up symlinks left behind by prior cctally versions whose
1983
2208
  # subcommand surface has changed (e.g. v1.9.0 retired
@@ -2058,12 +2283,26 @@ def _setup_install(args: argparse.Namespace) -> int:
2058
2283
  )
2059
2284
  warnings += 1
2060
2285
 
2061
- c._backup_claude_settings()
2062
- try:
2063
- c._write_claude_settings_atomic(settings)
2064
- except OSError as exc:
2065
- eprint(f"setup: failed to write {_cctally_core.CLAUDE_SETTINGS_PATH}: {exc}")
2066
- return 2
2286
+ if claude_available:
2287
+ c._backup_claude_settings()
2288
+ try:
2289
+ c._write_claude_settings_atomic(settings)
2290
+ except OSError as exc:
2291
+ eprint(f"setup: failed to write {_cctally_core.CLAUDE_SETTINGS_PATH}: {exc}")
2292
+ return 2
2293
+
2294
+ codex_hooks = _setup_manage_codex_hooks("install", abs_path)
2295
+ for row in codex_hooks["roots"]:
2296
+ changes = row.get("changes", {})
2297
+ count = sum(changes.values()) if isinstance(changes, dict) else 0
2298
+ if count:
2299
+ out.append(f"✓ Wrote {count} Codex hook entries to {row['hooks_path']}")
2300
+ if row["state"] == "installed_review_required":
2301
+ out.append("⚠ Codex hook trust needs review in Codex /hooks.")
2302
+ warnings += 1
2303
+ elif row["state"] == "malformed":
2304
+ out.append(f"⚠ Codex hooks unavailable: {row['error']}")
2305
+ warnings += 1
2067
2306
 
2068
2307
  # ── Post-write migration apply (spec §2 steps 6a, 6b) ──
2069
2308
  # Settings.json is now durable. File moves, poller stop, and tmp
@@ -2122,7 +2361,8 @@ def _setup_install(args: argparse.Namespace) -> int:
2122
2361
  # The "✓ Wrote …" line follows any migrate-summary line so the
2123
2362
  # narrative reads "we did the migration, then wrote the new entries"
2124
2363
  # — matches the spec's success-path sample (Section 2).
2125
- out.append(f"✓ Wrote {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}")
2364
+ if claude_available:
2365
+ out.append(f"✓ Wrote {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}")
2126
2366
 
2127
2367
  if decision == "skip" and reason in {"user_declined", "no_migrate_flag"}:
2128
2368
  files_str = "{record-usage-stop,usage-poller{,-start,-stop}}.py"
@@ -2134,17 +2374,17 @@ def _setup_install(args: argparse.Namespace) -> int:
2134
2374
  )
2135
2375
  warnings += 1
2136
2376
 
2137
- oauth = _setup_oauth_token_present()
2138
- if oauth:
2377
+ oauth = claude_available and _setup_oauth_token_present()
2378
+ if claude_available and oauth:
2139
2379
  out.append("✓ Detected OAuth token")
2140
- else:
2380
+ elif claude_available:
2141
2381
  warnings += 1
2142
2382
  out.append("⚠ No Claude OAuth token detected.")
2143
2383
  out.append(" Run `claude` once to authenticate. After that, the next assistant")
2144
2384
  out.append(" message in any Claude Code session will start collecting data")
2145
2385
  out.append(" automatically — no need to re-run `cctally setup`.")
2146
2386
 
2147
- legacy = _setup_detect_legacy_snippet()
2387
+ legacy = _setup_detect_legacy_snippet() if claude_available else None
2148
2388
  if legacy is not None:
2149
2389
  warnings += 1
2150
2390
  path, hits = legacy
@@ -2165,61 +2405,49 @@ def _setup_install(args: argparse.Namespace) -> int:
2165
2405
  progress = _SetupProgressReporter(
2166
2406
  _setup_progress_enabled(json_mode=getattr(args, "json", False))
2167
2407
  )
2168
- with progress:
2169
- progress.emit(
2170
- "⏳ Syncing session history (first run can take a moment on a large history)…",
2171
- force=True,
2172
- )
2173
- try:
2174
- cache_conn = c.open_cache_db()
2408
+ with progress if claude_available else contextlib.nullcontext():
2409
+ if not claude_available:
2410
+ bootstrap_rows = None
2411
+ else:
2412
+ progress.emit(
2413
+ "⏳ Syncing session history (first run can take a moment on a large history)…",
2414
+ force=True,
2415
+ )
2175
2416
  try:
2176
- stats = c.sync_cache(cache_conn, progress=progress.sync_callback)
2177
- finally:
2417
+ cache_conn = c.open_cache_db()
2178
2418
  try:
2179
- cache_conn.close()
2180
- except Exception:
2181
- pass
2182
- if stats.lock_contended:
2183
- # Another process holds the cache flock — nothing was
2184
- # ingested (sync_cache short-circuits with rows_changed=0).
2185
- # Report honestly rather than a false "Synced (0 new
2186
- # entries)"; leave bootstrap_rows None for the envelope.
2187
- out.append("⚠ Another cache sync is in progress; using existing cache.")
2188
- warnings += 1
2189
- bootstrap_rows = None
2190
- else:
2191
- rows = int(stats.rows_changed)
2192
- bootstrap_rows = rows
2193
- # `rows` counts both genuine INSERTs and ccusage-parity DO
2194
- # UPDATE replacements (see IngestStats.rows_changed). On
2195
- # first install this is always 0-vs-N pure inserts (cache is
2196
- # empty), so "N new entries" is exactly accurate. On a
2197
- # re-install / upgrade path with active sessions, `rows` also
2198
- # counts UPSERT replacements (streaming-vs-final tiebreaker
2199
- # swaps), so the count is more accurately "ingest activity"
2200
- # than "rows newly added" — but we keep "new entries" because
2201
- # (a) it's still a useful signal to the operator that the
2202
- # cache is alive, and (b) the dominant case (first install)
2203
- # reads literally.
2204
- out.append(f"✓ Synced session cache ({rows} new entries)")
2205
- except Exception as exc:
2206
- out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
2207
- warnings += 1
2208
- if oauth:
2209
- progress.emit("⏳ Fetching current usage…", force=True)
2210
- try:
2211
- status, _ = c._hook_tick_oauth_refresh()
2212
- bootstrap_oauth_status = status
2213
- if status.startswith("ok"):
2214
- c._hook_tick_throttle_touch()
2215
- out.append(f"✓ Bootstrapped weekly usage ({status})")
2216
- else:
2217
- out.append(f"⚠ Bootstrap OAuth fetch: {status}")
2419
+ stats = c.sync_cache(cache_conn, progress=progress.sync_callback)
2420
+ finally:
2421
+ try:
2422
+ cache_conn.close()
2423
+ except Exception:
2424
+ pass
2425
+ if stats.lock_contended:
2426
+ out.append(" Another cache sync is in progress; using existing cache.")
2218
2427
  warnings += 1
2428
+ bootstrap_rows = None
2429
+ else:
2430
+ rows = int(stats.rows_changed)
2431
+ bootstrap_rows = rows
2432
+ out.append(f"✓ Synced session cache ({rows} new entries)")
2219
2433
  except Exception as exc:
2220
- bootstrap_oauth_status = f"err({type(exc).__name__})"
2221
- out.append(f"⚠ Bootstrap OAuth failed: {exc}")
2434
+ out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
2222
2435
  warnings += 1
2436
+ if oauth:
2437
+ progress.emit("⏳ Fetching current usage…", force=True)
2438
+ try:
2439
+ status, _ = c._hook_tick_oauth_refresh()
2440
+ bootstrap_oauth_status = status
2441
+ if status.startswith("ok"):
2442
+ c._hook_tick_throttle_touch()
2443
+ out.append(f"✓ Bootstrapped weekly usage ({status})")
2444
+ else:
2445
+ out.append(f"⚠ Bootstrap OAuth fetch: {status}")
2446
+ warnings += 1
2447
+ except Exception as exc:
2448
+ bootstrap_oauth_status = f"err({type(exc).__name__})"
2449
+ out.append(f"⚠ Bootstrap OAuth failed: {exc}")
2450
+ warnings += 1
2223
2451
 
2224
2452
  out.append("")
2225
2453
  if warnings:
@@ -2253,10 +2481,11 @@ def _setup_install(args: argparse.Namespace) -> int:
2253
2481
  # `warnings` count. settings.json is cached at session start, so open
2254
2482
  # sessions need a restart; `_setup_install` always rewrites settings.json
2255
2483
  # (legacy migration, fresh install, repair), so this always applies.
2256
- out.append("")
2257
- out.append("▶ Next step: restart Claude Code to activate the new hooks.")
2258
- out.append(" Open sessions won't record until you restart (settings.json is cached at")
2259
- out.append(" session start). New sessions started after this pick them up automatically.")
2484
+ if claude_available:
2485
+ out.append("")
2486
+ out.append(" Next step: restart Claude Code to activate the new hooks.")
2487
+ out.append(" Open sessions won't record until you restart (settings.json is cached at")
2488
+ out.append(" session start). New sessions started after this pick them up automatically.")
2260
2489
 
2261
2490
  if getattr(args, "json", False):
2262
2491
  # JSON-safe telemetry disclosure (spec 2026-07-07 §4): a structured
@@ -2279,9 +2508,10 @@ def _setup_install(args: argparse.Namespace) -> int:
2279
2508
  if is_brew else {}),
2280
2509
  },
2281
2510
  "hooks": {
2282
- "events_added": list(c.SETUP_HOOK_EVENTS),
2511
+ "events_added": list(c.SETUP_HOOK_EVENTS) if claude_available else [],
2283
2512
  "settings_path": str(_cctally_core.CLAUDE_SETTINGS_PATH),
2284
2513
  },
2514
+ "codex_hooks": codex_hooks,
2285
2515
  "auth": {
2286
2516
  "oauth_token_present": oauth,
2287
2517
  },