cctally 1.68.0 → 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.
- package/CHANGELOG.md +56 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_setup.py
CHANGED
|
@@ -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
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
1617
|
+
claude_available = _setup_claude_available()
|
|
1618
|
+
removed = 0
|
|
1619
|
+
if claude_available:
|
|
1439
1620
|
try:
|
|
1440
|
-
c.
|
|
1441
|
-
except
|
|
1442
|
-
eprint(f"setup:
|
|
1443
|
-
return
|
|
1444
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
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
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
2177
|
-
finally:
|
|
2417
|
+
cache_conn = c.open_cache_db()
|
|
2178
2418
|
try:
|
|
2179
|
-
cache_conn.
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
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
|
},
|