cctally 1.65.0 → 1.66.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 (42) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/_cctally_alerts.py +1 -1
  3. package/bin/_cctally_cache.py +203 -22
  4. package/bin/_cctally_cache_report.py +7 -5
  5. package/bin/_cctally_core.py +49 -14
  6. package/bin/_cctally_dashboard.py +481 -4891
  7. package/bin/_cctally_dashboard_cache_report.py +714 -0
  8. package/bin/_cctally_dashboard_conversation.py +771 -0
  9. package/bin/_cctally_dashboard_envelope.py +1520 -0
  10. package/bin/_cctally_dashboard_share.py +2012 -0
  11. package/bin/_cctally_db.py +127 -5
  12. package/bin/_cctally_doctor.py +104 -3
  13. package/bin/_cctally_five_hour.py +2 -1
  14. package/bin/_cctally_forecast.py +78 -135
  15. package/bin/_cctally_parser.py +919 -784
  16. package/bin/_cctally_percent_breakdown.py +1 -1
  17. package/bin/_cctally_pricing_check.py +39 -0
  18. package/bin/_cctally_project.py +8 -5
  19. package/bin/_cctally_record.py +279 -274
  20. package/bin/_cctally_reporting.py +1 -1
  21. package/bin/_cctally_sync_week.py +1 -1
  22. package/bin/_cctally_telemetry.py +3 -5
  23. package/bin/_cctally_tui.py +42 -18
  24. package/bin/_lib_alert_dispatch.py +1 -1
  25. package/bin/_lib_blocks.py +6 -1
  26. package/bin/_lib_credit.py +133 -0
  27. package/bin/_lib_doctor.py +150 -1
  28. package/bin/_lib_five_hour.py +34 -0
  29. package/bin/_lib_forecast.py +144 -0
  30. package/bin/_lib_json_envelope.py +38 -0
  31. package/bin/_lib_jsonl.py +115 -73
  32. package/bin/_lib_log.py +96 -0
  33. package/bin/_lib_pricing.py +47 -1
  34. package/bin/_lib_pricing_check.py +25 -3
  35. package/bin/_lib_record.py +179 -0
  36. package/bin/_lib_render.py +18 -10
  37. package/bin/_lib_snapshot_cache.py +61 -0
  38. package/bin/cctally +51 -7
  39. package/bin/cctally-dashboard +2 -2
  40. package/bin/cctally-statusline +1 -0
  41. package/bin/cctally-tui +2 -2
  42. package/package.json +10 -1
@@ -1,10 +1,14 @@
1
1
  """cctally CLI argument-parser construction (eager sibling).
2
2
 
3
- Holds the full argparse tree: build_parser() + per-command _build_*_parser
4
- helpers, the _add_*_args helpers, _share_validate_args, the _nonneg_int
5
- type validator, and CLIHelpFormatter. Loaded eagerly by bin/cctally; every
6
- symbol is re-exported into the cctally namespace. cmd_* handlers and other
7
- bin/cctally-staying globals are reached via the call-time _cctally() accessor.
3
+ Holds the full argparse tree: build_parser() loops over the ordered
4
+ _REGISTRATION table (`_Reg` rows) of per-command `_build_*_parser` builders
5
+ (#279 S6 W3 table order is registration order is --help order), the
6
+ _add_*_args helpers incl. _add_since_until_args, _share_validate_args, the
7
+ _nonneg_int type validator, and CLIHelpFormatter. Loaded eagerly by bin/cctally;
8
+ every symbol is re-exported into the cctally namespace. cmd_* handlers and other
9
+ bin/cctally-staying globals are reached via the call-time _cctally() accessor;
10
+ the table stores builder callables + literal help_text/xref + the __preview
11
+ predicate lambda — nothing is resolved at import time.
8
12
 
9
13
  Spec: docs/superpowers/specs/2026-05-30-parser-share-extraction-design.md
10
14
  """
@@ -13,6 +17,7 @@ from __future__ import annotations
13
17
  import argparse
14
18
  import sys
15
19
  import textwrap
20
+ from typing import NamedTuple
16
21
 
17
22
  from _cctally_core import WEEKDAY_MAP
18
23
  from _lib_display_tz import _argparse_tz
@@ -91,6 +96,17 @@ def _add_mode_arg(parser, *, noop: bool = False) -> None:
91
96
  )
92
97
 
93
98
 
99
+ def _add_since_until_args(parser, *, metavar_since, metavar_until,
100
+ help_since, help_until) -> None:
101
+ """Shared -s/--since -u/--until pair; metavars/help passed verbatim
102
+ per command (the YYYYMMDD vs YYYY-MM-DD drift is data, not unified —
103
+ #279 S6 W3; --help bytes must not move)."""
104
+ parser.add_argument("-s", "--since", default=None,
105
+ metavar=metavar_since, help=help_since)
106
+ parser.add_argument("-u", "--until", default=None,
107
+ metavar=metavar_until, help=help_until)
108
+
109
+
94
110
  def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
95
111
  """Attach the Session A ccusage alias surface to a Claude-cmd subparser.
96
112
 
@@ -418,18 +434,10 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
418
434
  cctally daily -i --project-aliases repos=Repos
419
435
  """),
420
436
  )
421
- p.add_argument(
422
- "-s", "--since",
423
- default=None,
424
- metavar="YYYYMMDD",
425
- help="Filter from date (inclusive).",
426
- )
427
- p.add_argument(
428
- "-u", "--until",
429
- default=None,
430
- metavar="YYYYMMDD",
431
- help="Filter until date (inclusive).",
432
- )
437
+ _add_since_until_args(
438
+ p, metavar_since="YYYYMMDD", metavar_until="YYYYMMDD",
439
+ help_since="Filter from date (inclusive).",
440
+ help_until="Filter until date (inclusive).")
433
441
  p.add_argument(
434
442
  "-b", "--breakdown",
435
443
  action="store_true",
@@ -500,18 +508,10 @@ def _build_monthly_parser(subparsers, name, *, help_text, xref):
500
508
  cctally monthly --order desc
501
509
  """),
502
510
  )
503
- p.add_argument(
504
- "-s", "--since",
505
- default=None,
506
- metavar="YYYYMMDD",
507
- help="Filter from date (inclusive).",
508
- )
509
- p.add_argument(
510
- "-u", "--until",
511
- default=None,
512
- metavar="YYYYMMDD",
513
- help="Filter until date (inclusive).",
514
- )
511
+ _add_since_until_args(
512
+ p, metavar_since="YYYYMMDD", metavar_until="YYYYMMDD",
513
+ help_since="Filter from date (inclusive).",
514
+ help_until="Filter until date (inclusive).")
515
515
  p.add_argument(
516
516
  "-b", "--breakdown",
517
517
  action="store_true",
@@ -563,10 +563,10 @@ def _build_weekly_parser(subparsers, name, *, help_text, xref):
563
563
  cctally weekly --order desc
564
564
  """),
565
565
  )
566
- p.add_argument("-s", "--since", default=None, metavar="YYYYMMDD",
567
- help="Filter from date (inclusive).")
568
- p.add_argument("-u", "--until", default=None, metavar="YYYYMMDD",
569
- help="Filter until date (inclusive).")
566
+ _add_since_until_args(
567
+ p, metavar_since="YYYYMMDD", metavar_until="YYYYMMDD",
568
+ help_since="Filter from date (inclusive).",
569
+ help_until="Filter until date (inclusive).")
570
570
  p.add_argument("-b", "--breakdown", action="store_true",
571
571
  help="Show per-model cost breakdown sub-rows.")
572
572
  p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
@@ -604,10 +604,10 @@ def _build_session_parser(subparsers, name, *, help_text, xref):
604
604
  cctally session --order desc
605
605
  """),
606
606
  )
607
- p.add_argument("-s", "--since", default=None, metavar="YYYYMMDD",
608
- help="Filter from date (inclusive).")
609
- p.add_argument("-u", "--until", default=None, metavar="YYYYMMDD",
610
- help="Filter until date (inclusive).")
607
+ _add_since_until_args(
608
+ p, metavar_since="YYYYMMDD", metavar_until="YYYYMMDD",
609
+ help_since="Filter from date (inclusive).",
610
+ help_until="Filter until date (inclusive).")
611
611
  p.add_argument("-b", "--breakdown", action="store_true",
612
612
  help="Show per-model cost breakdown sub-rows.")
613
613
  p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
@@ -658,18 +658,10 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
658
658
  cctally blocks --since 20260414 --json
659
659
  """),
660
660
  )
661
- p.add_argument(
662
- "-s", "--since",
663
- default=None,
664
- metavar="YYYYMMDD",
665
- help="Filter from date (inclusive).",
666
- )
667
- p.add_argument(
668
- "-u", "--until",
669
- default=None,
670
- metavar="YYYYMMDD",
671
- help="Filter until date (inclusive).",
672
- )
661
+ _add_since_until_args(
662
+ p, metavar_since="YYYYMMDD", metavar_until="YYYYMMDD",
663
+ help_since="Filter from date (inclusive).",
664
+ help_until="Filter until date (inclusive).")
673
665
  p.add_argument(
674
666
  "-b", "--breakdown",
675
667
  action="store_true",
@@ -926,10 +918,10 @@ def _build_codex_daily_parser(subparsers, name, *, help_text, xref):
926
918
  cctally codex-daily --order desc
927
919
  """),
928
920
  )
929
- p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
930
- help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
931
- p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
932
- help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
921
+ _add_since_until_args(
922
+ p, metavar_since="YYYY-MM-DD", metavar_until="YYYY-MM-DD",
923
+ help_since="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).",
924
+ help_until="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
933
925
  p.add_argument("-b", "--breakdown", action="store_true",
934
926
  help="Show per-model cost breakdown sub-rows.")
935
927
  p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
@@ -957,10 +949,10 @@ def _build_codex_monthly_parser(subparsers, name, *, help_text, xref):
957
949
  cctally codex-monthly --json
958
950
  """),
959
951
  )
960
- p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
961
- help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
962
- p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
963
- help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
952
+ _add_since_until_args(
953
+ p, metavar_since="YYYY-MM-DD", metavar_until="YYYY-MM-DD",
954
+ help_since="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).",
955
+ help_until="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
964
956
  p.add_argument("-b", "--breakdown", action="store_true",
965
957
  help="Show per-model cost breakdown sub-rows.")
966
958
  p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
@@ -992,10 +984,10 @@ def _build_codex_weekly_parser(subparsers, name, *, help_text, xref):
992
984
  cctally codex-weekly --order desc
993
985
  """),
994
986
  )
995
- p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
996
- help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
997
- p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
998
- help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
987
+ _add_since_until_args(
988
+ p, metavar_since="YYYY-MM-DD", metavar_until="YYYY-MM-DD",
989
+ help_since="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).",
990
+ help_until="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
999
991
  p.add_argument("-b", "--breakdown", action="store_true",
1000
992
  help="Show per-model cost breakdown sub-rows.")
1001
993
  p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
@@ -1023,10 +1015,10 @@ def _build_codex_session_parser(subparsers, name, *, help_text, xref):
1023
1015
  cctally codex-session --json
1024
1016
  """),
1025
1017
  )
1026
- p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
1027
- help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
1028
- p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
1029
- help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
1018
+ _add_since_until_args(
1019
+ p, metavar_since="YYYY-MM-DD", metavar_until="YYYY-MM-DD",
1020
+ help_since="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).",
1021
+ help_until="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
1030
1022
  p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
1031
1023
  help="Sort direction by last activity (default: asc — earliest first).")
1032
1024
  p.add_argument("--json", action="store_true", dest="json",
@@ -1036,67 +1028,35 @@ def _build_codex_session_parser(subparsers, name, *, help_text, xref):
1036
1028
  return p
1037
1029
 
1038
1030
 
1039
- def build_parser() -> argparse.ArgumentParser:
1040
- c = _cctally()
1041
- p = argparse.ArgumentParser(
1042
- prog="cctally",
1043
- formatter_class=CLIHelpFormatter,
1044
- description=textwrap.dedent(
1045
- """\
1046
- Track Claude subscription weekly usage percent and weekly cost
1047
- in a local SQLite database.
1031
+ def _build_sync_week_parser(subparsers, name, *, help_text, xref=None):
1032
+ """Build the `sync-week` parser (registered via _REGISTRATION; #279 S6 W3).
1048
1033
 
1049
- Data flow:
1050
- 1) Claude Code status line captures rate limit data after each API call.
1051
- 2) record-usage stores usage snapshots and triggers percent milestones.
1052
- 3) sync-week computes weekly USD cost from Claude Code session data.
1053
- 4) report computes dollars per 1% and shows trend history.
1054
- """
1055
- ),
1056
- epilog=textwrap.dedent(
1057
- """\
1058
- Quick start:
1059
- # Add record-usage call to ~/.claude/statusline-command.sh (see record-usage --help)
1060
- cctally sync-week
1061
- cctally report
1062
- """
1063
- ),
1064
- )
1065
- p.add_argument(
1066
- "-v", "--version",
1067
- action="store_true",
1068
- default=argparse.SUPPRESS,
1069
- help="Print cctally version (from CHANGELOG.md latest release header) and exit",
1070
- )
1071
- sub = p.add_subparsers(
1072
- dest="command",
1073
- required=False,
1074
- title="commands",
1075
- metavar="<command>",
1076
- )
1077
-
1078
- py = sub.add_parser(
1079
- "sync-week",
1080
- help="Compute weekly cost from session data and store in SQLite",
1034
+ Move-only extraction of the former inline build_parser() block;
1035
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1036
+ """
1037
+ c = _cctally()
1038
+ py = subparsers.add_parser(
1039
+ name,
1040
+ help=help_text,
1081
1041
  formatter_class=CLIHelpFormatter,
1082
1042
  description=textwrap.dedent(
1083
- """\
1084
- Compute and store weekly cost (USD) for a selected week window.
1085
-
1086
- Week selection priority:
1087
- 1) Explicit --week-start/--week-end (date based)
1088
- 2) Latest usage snapshot weekStartAt/weekEndAt (hour-accurate)
1089
- 3) Current week from configured week-start rule
1090
- """
1091
- ),
1043
+ """\
1044
+ Compute and store weekly cost (USD) for a selected week window.
1045
+
1046
+ Week selection priority:
1047
+ 1) Explicit --week-start/--week-end (date based)
1048
+ 2) Latest usage snapshot weekStartAt/weekEndAt (hour-accurate)
1049
+ 3) Current week from configured week-start rule
1050
+ """
1051
+ ),
1092
1052
  epilog=textwrap.dedent(
1093
- """\
1094
- Examples:
1095
- cctally sync-week
1096
- cctally sync-week --week-start 2026-02-05 --week-end 2026-02-12
1097
- cctally sync-week --mode calculate --offline --json
1098
- """
1099
- ),
1053
+ """\
1054
+ Examples:
1055
+ cctally sync-week
1056
+ cctally sync-week --week-start 2026-02-05 --week-end 2026-02-12
1057
+ cctally sync-week --mode calculate --offline --json
1058
+ """
1059
+ ),
1100
1060
  )
1101
1061
  py.add_argument(
1102
1062
  "--week-start",
@@ -1144,28 +1104,35 @@ def build_parser() -> argparse.ArgumentParser:
1144
1104
  )
1145
1105
  py.set_defaults(func=c.cmd_sync_week)
1146
1106
 
1147
- pr = sub.add_parser(
1148
- "report",
1149
- help="Show current and trend dollars-per-1%% statistics",
1107
+ def _build_report_parser(subparsers, name, *, help_text, xref=None):
1108
+ """Build the `report` parser (registered via _REGISTRATION; #279 S6 W3).
1109
+
1110
+ Move-only extraction of the former inline build_parser() block;
1111
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1112
+ """
1113
+ c = _cctally()
1114
+ pr = subparsers.add_parser(
1115
+ name,
1116
+ help=help_text,
1150
1117
  formatter_class=CLIHelpFormatter,
1151
1118
  description=textwrap.dedent(
1152
- """\
1153
- Report current and historical dollars per 1% weekly usage.
1154
-
1155
- For each week, report joins:
1156
- - latest usage snapshot (%)
1157
- - latest cost snapshot (USD)
1158
- then computes USD / percent.
1159
- """
1160
- ),
1119
+ """\
1120
+ Report current and historical dollars per 1% weekly usage.
1121
+
1122
+ For each week, report joins:
1123
+ - latest usage snapshot (%)
1124
+ - latest cost snapshot (USD)
1125
+ then computes USD / percent.
1126
+ """
1127
+ ),
1161
1128
  epilog=textwrap.dedent(
1162
- """\
1163
- Examples:
1164
- cctally report
1165
- cctally report --sync-current
1166
- cctally report --weeks 12 --json
1167
- """
1168
- ),
1129
+ """\
1130
+ Examples:
1131
+ cctally report
1132
+ cctally report --sync-current
1133
+ cctally report --weeks 12 --json
1134
+ """
1135
+ ),
1169
1136
  )
1170
1137
  pr.add_argument(
1171
1138
  "--weeks",
@@ -1220,30 +1187,37 @@ def build_parser() -> argparse.ArgumentParser:
1220
1187
  _add_share_args(pr)
1221
1188
  pr.set_defaults(func=c.cmd_report)
1222
1189
 
1223
- fc = sub.add_parser(
1224
- "forecast",
1225
- help="Project current-week usage to reset; show daily budgets",
1190
+ def _build_forecast_parser(subparsers, name, *, help_text, xref=None):
1191
+ """Build the `forecast` parser (registered via _REGISTRATION; #279 S6 W3).
1192
+
1193
+ Move-only extraction of the former inline build_parser() block;
1194
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1195
+ """
1196
+ c = _cctally()
1197
+ fc = subparsers.add_parser(
1198
+ name,
1199
+ help=help_text,
1226
1200
  formatter_class=CLIHelpFormatter,
1227
1201
  description=textwrap.dedent(
1228
- """\
1229
- Forecast end-of-week usage % and daily $ / % budgets to stay under
1230
- target ceilings (default 100% and 90%). Reads current-week
1231
- `weekly_usage_snapshots` + `session_entries`; never writes.
1232
- """
1233
- ),
1202
+ """\
1203
+ Forecast end-of-week usage % and daily $ / % budgets to stay under
1204
+ target ceilings (default 100% and 90%). Reads current-week
1205
+ `weekly_usage_snapshots` + `session_entries`; never writes.
1206
+ """
1207
+ ),
1234
1208
  epilog=textwrap.dedent(
1235
- """\
1236
- Examples:
1237
- cctally forecast
1238
- cctally forecast --json
1239
- cctally forecast --status-line --no-sync
1240
- cctally forecast --targets 100,95,85
1241
-
1242
- Status-line integration (add to ~/.claude/statusline-command.sh):
1243
- forecast_seg=$(cctally forecast --status-line --no-sync 2>/dev/null)
1244
- # ...then include "$forecast_seg" in your prompt composition.
1245
- """
1246
- ),
1209
+ """\
1210
+ Examples:
1211
+ cctally forecast
1212
+ cctally forecast --json
1213
+ cctally forecast --status-line --no-sync
1214
+ cctally forecast --targets 100,95,85
1215
+
1216
+ Status-line integration (add to ~/.claude/statusline-command.sh):
1217
+ forecast_seg=$(cctally forecast --status-line --no-sync 2>/dev/null)
1218
+ # ...then include "$forecast_seg" in your prompt composition.
1219
+ """
1220
+ ),
1247
1221
  )
1248
1222
  fc.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
1249
1223
  help="In --format output, show real project basenames instead of "
@@ -1259,40 +1233,41 @@ def build_parser() -> argparse.ArgumentParser:
1259
1233
  help="Skip sync_cache(); recommended for status-line use.")
1260
1234
  fc.add_argument("--color", choices=("auto", "always", "never"), default="auto",
1261
1235
  help="Color output control (also honors NO_COLOR).")
1262
- # Dev-only: override "now" for deterministic fixture tests. Hidden from --help.
1263
1236
  fc.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
1264
1237
  _add_share_args(fc, has_status_line=True)
1265
1238
  fc.set_defaults(func=c.cmd_forecast)
1266
1239
 
1267
- # budget — cctally-original (NOT a ccusage drop-in), so flat surface only;
1268
- # no claude/codex subgroup. `--config` is honored read-only on bare status
1269
- # but rejected on set/unset (F4). `--reveal-projects` is accepted for share
1270
- # surface parity but inert (no per-project axis). `--tz` follows the sibling
1271
- # reporting commands' precedence.
1272
- bg = sub.add_parser(
1273
- "budget",
1274
- help="Weekly equivalent-$ budget + pace + spend alerts",
1240
+ def _build_budget_parser(subparsers, name, *, help_text, xref=None):
1241
+ """Build the `budget` parser (registered via _REGISTRATION; #279 S6 W3).
1242
+
1243
+ Move-only extraction of the former inline build_parser() block;
1244
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1245
+ """
1246
+ c = _cctally()
1247
+ bg = subparsers.add_parser(
1248
+ name,
1249
+ help=help_text,
1275
1250
  formatter_class=CLIHelpFormatter,
1276
1251
  description=textwrap.dedent(
1277
- """\
1278
- Track Claude equivalent-$ spend for the current subscription week
1279
- against a weekly budget. Shows spend, pace, projected end-of-week,
1280
- and a verdict (ok / warn / over). `budget set <amount>` and
1281
- `budget unset` manage the budget; spend-crossing alerts fire from
1282
- record-usage (see `cctally alerts`).
1283
- """
1284
- ),
1252
+ """\
1253
+ Track Claude equivalent-$ spend for the current subscription week
1254
+ against a weekly budget. Shows spend, pace, projected end-of-week,
1255
+ and a verdict (ok / warn / over). `budget set <amount>` and
1256
+ `budget unset` manage the budget; spend-crossing alerts fire from
1257
+ record-usage (see `cctally alerts`).
1258
+ """
1259
+ ),
1285
1260
  epilog=textwrap.dedent(
1286
- """\
1287
- Examples:
1288
- cctally budget
1289
- cctally budget set 300
1290
- cctally budget unset
1291
- cctally budget set 25 --project
1292
- cctally budget --json
1293
- cctally budget --format md
1294
- """
1295
- ),
1261
+ """\
1262
+ Examples:
1263
+ cctally budget
1264
+ cctally budget set 300
1265
+ cctally budget unset
1266
+ cctally budget set 25 --project
1267
+ cctally budget --json
1268
+ cctally budget --format md
1269
+ """
1270
+ ),
1296
1271
  )
1297
1272
  bg.add_argument("action", nargs="?", choices=["set", "unset"], default=None,
1298
1273
  help="`set <amount>` to set the weekly budget, `unset` to clear it.")
@@ -1332,25 +1307,32 @@ def build_parser() -> argparse.ArgumentParser:
1332
1307
  _add_share_args(bg)
1333
1308
  bg.set_defaults(func=c.cmd_budget)
1334
1309
 
1335
- pb = sub.add_parser(
1336
- "percent-breakdown",
1337
- help="Show per-percent cost milestones for a week",
1310
+ def _build_percent_breakdown_parser(subparsers, name, *, help_text, xref=None):
1311
+ """Build the `percent-breakdown` parser (registered via _REGISTRATION; #279 S6 W3).
1312
+
1313
+ Move-only extraction of the former inline build_parser() block;
1314
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1315
+ """
1316
+ c = _cctally()
1317
+ pb = subparsers.add_parser(
1318
+ name,
1319
+ help=help_text,
1338
1320
  formatter_class=CLIHelpFormatter,
1339
1321
  description=textwrap.dedent(
1340
- """\
1341
- Show the cumulative and marginal cost at each integer percent threshold
1342
- for a given week. Milestones are recorded automatically when
1343
- record-usage stores a snapshot crossing a new integer percent.
1344
- """
1345
- ),
1322
+ """\
1323
+ Show the cumulative and marginal cost at each integer percent threshold
1324
+ for a given week. Milestones are recorded automatically when
1325
+ record-usage stores a snapshot crossing a new integer percent.
1326
+ """
1327
+ ),
1346
1328
  epilog=textwrap.dedent(
1347
- """\
1348
- Examples:
1349
- cctally percent-breakdown
1350
- cctally percent-breakdown --week-start 2026-03-20
1351
- cctally percent-breakdown --json
1352
- """
1353
- ),
1329
+ """\
1330
+ Examples:
1331
+ cctally percent-breakdown
1332
+ cctally percent-breakdown --week-start 2026-03-20
1333
+ cctally percent-breakdown --json
1334
+ """
1335
+ ),
1354
1336
  )
1355
1337
  pb.add_argument(
1356
1338
  "--week-start",
@@ -1376,25 +1358,32 @@ def build_parser() -> argparse.ArgumentParser:
1376
1358
  )
1377
1359
  pb.set_defaults(func=c.cmd_percent_breakdown)
1378
1360
 
1379
- fhbd = sub.add_parser(
1380
- "five-hour-breakdown",
1381
- help="Per-percent milestones inside one 5h block (mirror of percent-breakdown)",
1361
+ def _build_five_hour_breakdown_parser(subparsers, name, *, help_text, xref=None):
1362
+ """Build the `five-hour-breakdown` parser (registered via _REGISTRATION; #279 S6 W3).
1363
+
1364
+ Move-only extraction of the former inline build_parser() block;
1365
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1366
+ """
1367
+ c = _cctally()
1368
+ fhbd = subparsers.add_parser(
1369
+ name,
1370
+ help=help_text,
1382
1371
  formatter_class=CLIHelpFormatter,
1383
1372
  description=textwrap.dedent(
1384
- """\
1385
- Show cumulative + marginal cost at each integer percent threshold
1386
- inside one 5h block. Mirrors percent-breakdown for the 5h axis.
1387
- """
1388
- ),
1373
+ """\
1374
+ Show cumulative + marginal cost at each integer percent threshold
1375
+ inside one 5h block. Mirrors percent-breakdown for the 5h axis.
1376
+ """
1377
+ ),
1389
1378
  epilog=textwrap.dedent(
1390
- """\
1391
- Examples:
1392
- cctally five-hour-breakdown
1393
- cctally five-hour-breakdown --block-start 2026-04-30T19:30
1394
- cctally five-hour-breakdown --ago 1
1395
- cctally five-hour-breakdown --json
1396
- """
1397
- ),
1379
+ """\
1380
+ Examples:
1381
+ cctally five-hour-breakdown
1382
+ cctally five-hour-breakdown --block-start 2026-04-30T19:30
1383
+ cctally five-hour-breakdown --ago 1
1384
+ cctally five-hour-breakdown --json
1385
+ """
1386
+ ),
1398
1387
  )
1399
1388
  fhbd.add_argument(
1400
1389
  "--block-start",
@@ -1430,33 +1419,40 @@ def build_parser() -> argparse.ArgumentParser:
1430
1419
  )
1431
1420
  fhbd.set_defaults(func=c.cmd_five_hour_breakdown)
1432
1421
 
1433
- tp = sub.add_parser(
1434
- "tui",
1435
- help="Live refreshing dashboard (current week, forecast, trend, sessions)",
1422
+ def _build_tui_parser(subparsers, name, *, help_text, xref=None):
1423
+ """Build the `tui` parser (registered via _REGISTRATION; #279 S6 W3).
1424
+
1425
+ Move-only extraction of the former inline build_parser() block;
1426
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1427
+ """
1428
+ c = _cctally()
1429
+ tp = subparsers.add_parser(
1430
+ name,
1431
+ help=help_text,
1436
1432
  formatter_class=CLIHelpFormatter,
1437
1433
  description=textwrap.dedent(
1438
- """\
1439
- Live terminal dashboard with four refreshing panels:
1440
- - Current week % and 5-hour window
1441
- - Forecast verdict + projections + daily $ budgets
1442
- - $/1% trend over the last 8 weeks (with sparkline)
1443
- - Recent Claude sessions (last 100, scrollable)
1444
-
1445
- Two visual variants — conventional 2x2 grid and expressive
1446
- hero layout — toggleable at runtime with `v`.
1447
-
1448
- Requires the `rich` Python package.
1449
- """
1450
- ),
1434
+ """\
1435
+ Live terminal dashboard with four refreshing panels:
1436
+ - Current week % and 5-hour window
1437
+ - Forecast verdict + projections + daily $ budgets
1438
+ - $/1% trend over the last 8 weeks (with sparkline)
1439
+ - Recent Claude sessions (last 100, scrollable)
1440
+
1441
+ Two visual variants — conventional 2x2 grid and expressive
1442
+ hero layout — toggleable at runtime with `v`.
1443
+
1444
+ Requires the `rich` Python package.
1445
+ """
1446
+ ),
1451
1447
  epilog=textwrap.dedent(
1452
- """\
1453
- Examples:
1454
- cctally tui
1455
- cctally tui --expressive
1456
- cctally tui --refresh 2 --sync-interval 30
1457
- cctally tui --no-sync
1458
- """
1459
- ),
1448
+ """\
1449
+ Examples:
1450
+ cctally tui
1451
+ cctally tui --expressive
1452
+ cctally tui --refresh 2 --sync-interval 30
1453
+ cctally tui --no-sync
1454
+ """
1455
+ ),
1460
1456
  )
1461
1457
  tp.add_argument(
1462
1458
  "--variant",
@@ -1506,34 +1502,33 @@ def build_parser() -> argparse.ArgumentParser:
1506
1502
  help="Display timezone: local, utc, or IANA name. "
1507
1503
  "Overrides config display.tz for this call.",
1508
1504
  )
1509
- # Dev-only: pin "now" for deterministic fixture tests.
1510
1505
  tp.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
1511
- # Dev-only: fixture injection — render one frame from a Python module that
1512
- # exposes `SNAPSHOT` (DataSnapshot) and exits.
1513
1506
  tp.add_argument(
1514
1507
  "--snapshot-module", dest="snapshot_module", default=None, help=argparse.SUPPRESS
1515
1508
  )
1516
- # Dev-only: one-shot render for golden capture.
1517
1509
  tp.add_argument(
1518
1510
  "--render-once", action="store_true", dest="render_once", help=argparse.SUPPRESS
1519
1511
  )
1520
- # Dev-only: force terminal size for --render-once.
1521
1512
  tp.add_argument(
1522
1513
  "--force-size", dest="force_size", default=None, metavar="WxH",
1523
1514
  help=argparse.SUPPRESS
1524
1515
  )
1525
1516
  tp.set_defaults(func=c.cmd_tui)
1526
1517
 
1527
- # ---- dashboard subcommand --------------------------------------------
1528
- dp = sub.add_parser(
1529
- "dashboard",
1530
- help="Launch the live web dashboard on http://localhost:8789",
1531
- description=(
1532
- "Start a local web server rendering a live dashboard of your "
1533
- "subscription usage, weekly cost trend, and recent sessions. "
1534
- "Press Ctrl-C to stop. Two variants are served by the companion "
1535
- "'tui' subcommand for terminal-only use."
1536
- ),
1518
+ def _build_dashboard_parser(subparsers, name, *, help_text, xref=None):
1519
+ """Build the `dashboard` parser (registered via _REGISTRATION; #279 S6 W3).
1520
+
1521
+ Move-only extraction of the former inline build_parser() block;
1522
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1523
+ """
1524
+ c = _cctally()
1525
+ dp = subparsers.add_parser(
1526
+ name,
1527
+ help=help_text,
1528
+ description="Start a local web server rendering a live dashboard of your "
1529
+ "subscription usage, weekly cost trend, and recent sessions. "
1530
+ "Press Ctrl-C to stop. Two variants are served by the companion "
1531
+ "'tui' subcommand for terminal-only use.",
1537
1532
  )
1538
1533
  dp.add_argument(
1539
1534
  "--port",
@@ -1573,33 +1568,40 @@ def build_parser() -> argparse.ArgumentParser:
1573
1568
  )
1574
1569
  dp.set_defaults(func=c.cmd_dashboard)
1575
1570
 
1576
- ru = sub.add_parser(
1577
- "record-usage",
1578
- help="Record usage data from Claude Code status line",
1571
+ def _build_record_usage_parser(subparsers, name, *, help_text, xref=None):
1572
+ """Build the `record-usage` parser (registered via _REGISTRATION; #279 S6 W3).
1573
+
1574
+ Move-only extraction of the former inline build_parser() block;
1575
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1576
+ """
1577
+ c = _cctally()
1578
+ ru = subparsers.add_parser(
1579
+ name,
1580
+ help=help_text,
1579
1581
  formatter_class=CLIHelpFormatter,
1580
1582
  description=textwrap.dedent(
1581
- """\
1582
- Record usage percentage from Claude Code status line rate_limits data.
1583
- Called automatically by the status line script after each assistant message.
1584
- """
1585
- ),
1583
+ """\
1584
+ Record usage percentage from Claude Code status line rate_limits data.
1585
+ Called automatically by the status line script after each assistant message.
1586
+ """
1587
+ ),
1586
1588
  epilog=textwrap.dedent(
1587
- """\
1588
- Examples:
1589
- cctally record-usage --percent 14.2 --resets-at 1744531200
1590
- cctally record-usage --percent 14.2 --resets-at 1744531200 \\
1591
- --five-hour-percent 38.5 --five-hour-resets-at 1744502400
1592
-
1593
- Status line integration (add to ~/.claude/statusline-command.sh):
1594
- if [ -n "$week_pct" ] && [ -n "$week_resets" ]; then
1595
- record_args="--percent $week_pct --resets-at ${week_resets%.*}"
1596
- if [ -n "$five_pct" ] && [ -n "$five_resets" ]; then
1597
- record_args="$record_args --five-hour-percent $five_pct --five-hour-resets-at ${five_resets%.*}"
1598
- fi
1599
- cctally record-usage $record_args &
1600
- fi
1601
- """
1602
- ),
1589
+ """\
1590
+ Examples:
1591
+ cctally record-usage --percent 14.2 --resets-at 1744531200
1592
+ cctally record-usage --percent 14.2 --resets-at 1744531200 \\
1593
+ --five-hour-percent 38.5 --five-hour-resets-at 1744502400
1594
+
1595
+ Status line integration (add to ~/.claude/statusline-command.sh):
1596
+ if [ -n "$week_pct" ] && [ -n "$week_resets" ]; then
1597
+ record_args="--percent $week_pct --resets-at ${week_resets%.*}"
1598
+ if [ -n "$five_pct" ] && [ -n "$five_resets" ]; then
1599
+ record_args="$record_args --five-hour-percent $five_pct --five-hour-resets-at ${five_resets%.*}"
1600
+ fi
1601
+ cctally record-usage $record_args &
1602
+ fi
1603
+ """
1604
+ ),
1603
1605
  )
1604
1606
  ru.add_argument(
1605
1607
  "--percent",
@@ -1625,32 +1627,39 @@ def build_parser() -> argparse.ArgumentParser:
1625
1627
  )
1626
1628
  ru.set_defaults(func=c.cmd_record_usage)
1627
1629
 
1628
- rc = sub.add_parser(
1629
- "record-credit",
1630
- help="Record an in-place weekly credit the auto-detector misses",
1630
+ def _build_record_credit_parser(subparsers, name, *, help_text, xref=None):
1631
+ """Build the `record-credit` parser (registered via _REGISTRATION; #279 S6 W3).
1632
+
1633
+ Move-only extraction of the former inline build_parser() block;
1634
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1635
+ """
1636
+ c = _cctally()
1637
+ rc = subparsers.add_parser(
1638
+ name,
1639
+ help=help_text,
1631
1640
  formatter_class=CLIHelpFormatter,
1632
1641
  description=textwrap.dedent(
1633
- """\
1634
- Record an in-place weekly (7d) credit that the auto-detector
1635
- misses (a sub-25pp, non-zero drop — e.g. Anthropic lowered your
1636
- 7d % from 46 to 31 without a clean reset). Writes a
1637
- weekly_credit_floors clamp row (no week re-anchor — the same week
1638
- continues), lowers hwm-7d, and inserts a post-credit snapshot so
1639
- reports and the statusline read the credited value.
1640
- Preview + confirm by default.
1641
- """
1642
- ),
1642
+ """\
1643
+ Record an in-place weekly (7d) credit that the auto-detector
1644
+ misses (a sub-25pp, non-zero drop — e.g. Anthropic lowered your
1645
+ 7d % from 46 to 31 without a clean reset). Writes a
1646
+ weekly_credit_floors clamp row (no week re-anchor — the same week
1647
+ continues), lowers hwm-7d, and inserts a post-credit snapshot so
1648
+ reports and the statusline read the credited value.
1649
+ Preview + confirm by default.
1650
+ """
1651
+ ),
1643
1652
  epilog=textwrap.dedent(
1644
- """\
1645
- Examples:
1646
- cctally record-credit --to 31 # baseline auto-read from HWM
1647
- cctally record-credit --to 31 --dry-run # preview, write nothing
1648
- cctally record-credit --to 31 --yes # apply without prompting
1653
+ """\
1654
+ Examples:
1655
+ cctally record-credit --to 31 # baseline auto-read from HWM
1656
+ cctally record-credit --to 31 --dry-run # preview, write nothing
1657
+ cctally record-credit --to 31 --yes # apply without prompting
1649
1658
 
1650
- Exit codes: 0 success (incl. --dry-run and an interactive decline),
1651
- 2 validation/refuse, 3 on a database error.
1652
- """
1653
- ),
1659
+ Exit codes: 0 success (incl. --dry-run and an interactive decline),
1660
+ 2 validation/refuse, 3 on a database error.
1661
+ """
1662
+ ),
1654
1663
  )
1655
1664
  rc.add_argument("--to", required=True, type=float,
1656
1665
  help="New post-credit weekly %% (0-100).")
@@ -1670,36 +1679,43 @@ def build_parser() -> argparse.ArgumentParser:
1670
1679
  help="Machine output (schemaVersion 1).")
1671
1680
  rc.set_defaults(func=c.cmd_record_credit)
1672
1681
 
1673
- rfu = sub.add_parser(
1674
- "refresh-usage",
1675
- help="Force-fetch 7d/5h percent from OAuth API and record it",
1682
+ def _build_refresh_usage_parser(subparsers, name, *, help_text, xref=None):
1683
+ """Build the `refresh-usage` parser (registered via _REGISTRATION; #279 S6 W3).
1684
+
1685
+ Move-only extraction of the former inline build_parser() block;
1686
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1687
+ """
1688
+ c = _cctally()
1689
+ rfu = subparsers.add_parser(
1690
+ name,
1691
+ help=help_text,
1676
1692
  formatter_class=CLIHelpFormatter,
1677
1693
  description=textwrap.dedent(
1678
- """\
1679
- Force a fresh fetch of seven_day.utilization and five_hour.utilization
1680
- from Anthropic's OAuth usage API, persist it via the same path
1681
- record-usage uses (HWM, percent_milestones, weekly_usage_snapshots),
1682
- and bust the statusline OAuth cache file at
1683
- /tmp/claude-statusline-usage-cache.json so the next status-line tick
1684
- also gets fresh data.
1685
-
1686
- Use this when the displayed 7d percent is stale (e.g., you've
1687
- been away from Claude Code and the status-line hasn't fired
1688
- recently). Otherwise the status-line script handles refresh
1689
- automatically every minute.
1690
- """
1691
- ),
1694
+ """\
1695
+ Force a fresh fetch of seven_day.utilization and five_hour.utilization
1696
+ from Anthropic's OAuth usage API, persist it via the same path
1697
+ record-usage uses (HWM, percent_milestones, weekly_usage_snapshots),
1698
+ and bust the statusline OAuth cache file at
1699
+ /tmp/claude-statusline-usage-cache.json so the next status-line tick
1700
+ also gets fresh data.
1701
+
1702
+ Use this when the displayed 7d percent is stale (e.g., you've
1703
+ been away from Claude Code and the status-line hasn't fired
1704
+ recently). Otherwise the status-line script handles refresh
1705
+ automatically every minute.
1706
+ """
1707
+ ),
1692
1708
  epilog=textwrap.dedent(
1693
- """\
1694
- Examples:
1695
- ccusage-refresh-usage # one-liner output
1696
- ccusage-refresh-usage --json | jq . # scriptable
1697
- ccusage-refresh-usage --quiet # silent (exit code only)
1709
+ """\
1710
+ Examples:
1711
+ ccusage-refresh-usage # one-liner output
1712
+ ccusage-refresh-usage --json | jq . # scriptable
1713
+ ccusage-refresh-usage --quiet # silent (exit code only)
1698
1714
 
1699
- Exit codes: 0 success / 2 no OAuth token / 3 network failure
1700
- / 4 malformed API response / 5 record-usage internal failure.
1701
- """
1702
- ),
1715
+ Exit codes: 0 success / 2 no OAuth token / 3 network failure
1716
+ / 4 malformed API response / 5 record-usage internal failure.
1717
+ """
1718
+ ),
1703
1719
  )
1704
1720
  rfu.add_argument("--json", action="store_true",
1705
1721
  help="Emit schema_version=1 JSON to stdout instead of one-liner.")
@@ -1711,30 +1727,37 @@ def build_parser() -> argparse.ArgumentParser:
1711
1727
  help="HTTP timeout in seconds (default: 5.0).")
1712
1728
  rfu.set_defaults(func=c.cmd_refresh_usage)
1713
1729
 
1714
- pc = sub.add_parser(
1715
- "cache-report",
1716
- help="Show daily cache hit rates per model from ccusage data",
1730
+ def _build_cache_report_parser(subparsers, name, *, help_text, xref=None):
1731
+ """Build the `cache-report` parser (registered via _REGISTRATION; #279 S6 W3).
1732
+
1733
+ Move-only extraction of the former inline build_parser() block;
1734
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1735
+ """
1736
+ c = _cctally()
1737
+ pc = subparsers.add_parser(
1738
+ name,
1739
+ help=help_text,
1717
1740
  formatter_class=CLIHelpFormatter,
1718
1741
  description=textwrap.dedent(
1719
- """\
1720
- Query ccusage for daily token breakdown and display cache hit
1721
- percentages per model. Useful for spotting caching regressions
1722
- after Claude Code updates.
1723
-
1724
- Cache hit % = cacheReadTokens / (input + cacheCreate + cacheRead)
1725
- """
1726
- ),
1742
+ """\
1743
+ Query ccusage for daily token breakdown and display cache hit
1744
+ percentages per model. Useful for spotting caching regressions
1745
+ after Claude Code updates.
1746
+
1747
+ Cache hit % = cacheReadTokens / (input + cacheCreate + cacheRead)
1748
+ """
1749
+ ),
1727
1750
  epilog=textwrap.dedent(
1728
- """\
1729
- Examples:
1730
- cctally cache-report
1731
- cctally cache-report --days 14
1732
- cctally cache-report --since 2026-04-10 --until 2026-04-18
1733
- cctally cache-report --by-session --days 14
1734
- cctally cache-report --by-session --sort cache
1735
- cctally cache-report --json
1736
- """
1737
- ),
1751
+ """\
1752
+ Examples:
1753
+ cctally cache-report
1754
+ cctally cache-report --days 14
1755
+ cctally cache-report --since 2026-04-10 --until 2026-04-18
1756
+ cctally cache-report --by-session --days 14
1757
+ cctally cache-report --by-session --sort cache
1758
+ cctally cache-report --json
1759
+ """
1760
+ ),
1738
1761
  )
1739
1762
  pc.add_argument(
1740
1763
  "--days",
@@ -1815,25 +1838,28 @@ def build_parser() -> argparse.ArgumentParser:
1815
1838
  help="Display timezone: local, utc, or IANA name. "
1816
1839
  "Overrides config display.tz for this call.",
1817
1840
  )
1818
- # Session A (spec §7.6): ansi_emit=False; existing `--offline` is
1819
- # skipped by the helper's `_argparse_has_arg` guard (the collision
1820
- # case spec §7.1.2 calls out explicitly).
1821
1841
  _add_ccusage_alias_args(pc, ansi_emit=False)
1822
1842
  pc.set_defaults(func=c.cmd_cache_report)
1823
1843
 
1824
- # -- range-cost --
1825
- rc = sub.add_parser(
1826
- "range-cost",
1827
- help="Compute USD cost for a time range from session data",
1844
+ def _build_range_cost_parser(subparsers, name, *, help_text, xref=None):
1845
+ """Build the `range-cost` parser (registered via _REGISTRATION; #279 S6 W3).
1846
+
1847
+ Move-only extraction of the former inline build_parser() block;
1848
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1849
+ """
1850
+ c = _cctally()
1851
+ rc = subparsers.add_parser(
1852
+ name,
1853
+ help=help_text,
1828
1854
  formatter_class=CLIHelpFormatter,
1829
1855
  description="Compute USD cost for Claude Code usage between start/end timestamps.",
1830
1856
  epilog=textwrap.dedent("""\
1831
- Examples:
1832
- cctally range-cost -s "2026-04-10T10:00:00+03:00"
1833
- cctally range-cost -s "2026-04-10T10:00:00Z" -e "2026-04-12T10:00:00Z" --breakdown
1834
- cctally range-cost -s "2026-04-10T10:00:00Z" --json
1835
- cctally range-cost -s "2026-04-10T10:00:00Z" --total-only
1836
- """),
1857
+ Examples:
1858
+ cctally range-cost -s "2026-04-10T10:00:00+03:00"
1859
+ cctally range-cost -s "2026-04-10T10:00:00Z" -e "2026-04-12T10:00:00Z" --breakdown
1860
+ cctally range-cost -s "2026-04-10T10:00:00Z" --json
1861
+ cctally range-cost -s "2026-04-10T10:00:00Z" --total-only
1862
+ """),
1837
1863
  )
1838
1864
  rc.add_argument(
1839
1865
  "-s", "--start",
@@ -1873,55 +1899,34 @@ def build_parser() -> argparse.ArgumentParser:
1873
1899
  dest="total_only",
1874
1900
  help="Print numeric USD total only.",
1875
1901
  )
1876
- # Session A (spec §7.6): ansi_emit=False. range-cost has no --tz of
1877
- # its own (ISO timestamps carry zone info), but the helper-added
1878
- # -z/--timezone still lands on the namespace; the bridge promotes
1879
- # it onto args.tz where the rest of the pipeline treats it as a
1880
- # documented no-op (cmd_range_cost does not consume args.tz).
1881
1902
  _add_ccusage_alias_args(rc, ansi_emit=False)
1882
1903
  rc.set_defaults(func=c.cmd_range_cost)
1883
1904
 
1884
- # -- blocks --
1885
- _build_blocks_parser(
1886
- sub, "blocks",
1887
- help_text="Show usage report grouped by 5-hour session blocks",
1888
- xref="Alias of `cctally claude blocks` (the canonical form).")
1905
+ def _build_five_hour_blocks_parser(subparsers, name, *, help_text, xref=None):
1906
+ """Build the `five-hour-blocks` parser (registered via _REGISTRATION; #279 S6 W3).
1889
1907
 
1890
- # -- statusline --
1891
- _build_statusline_parser(
1892
- sub, "statusline",
1893
- help_text="Compact one-line status for Claude Code hooks",
1894
- xref="Alias of `cctally claude statusline` (the canonical form).")
1895
-
1896
- # -- five-hour-blocks --
1897
- fhb = sub.add_parser(
1898
- "five-hour-blocks",
1899
- help="List API-anchored 5h blocks with rollup totals + 7d-drift columns",
1908
+ Move-only extraction of the former inline build_parser() block;
1909
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1910
+ """
1911
+ c = _cctally()
1912
+ fhb = subparsers.add_parser(
1913
+ name,
1914
+ help=help_text,
1900
1915
  formatter_class=CLIHelpFormatter,
1901
- description=(
1902
- "Show usage grouped by API-anchored 5-hour blocks (analytics view, "
1903
- "distinct from `cctally blocks` upstream-parity drop-in)."
1904
- ),
1916
+ description="Show usage grouped by API-anchored 5-hour blocks (analytics view, "
1917
+ "distinct from `cctally blocks` upstream-parity drop-in).",
1905
1918
  epilog=textwrap.dedent("""\
1906
- Examples:
1907
- cctally five-hour-blocks
1908
- cctally five-hour-blocks --since 20260420
1909
- cctally five-hour-blocks --breakdown model
1910
- cctally five-hour-blocks --breakdown project --json
1911
- """),
1912
- )
1913
- fhb.add_argument(
1914
- "-s", "--since",
1915
- default=None,
1916
- metavar="YYYYMMDD",
1917
- help="Filter from date (inclusive).",
1918
- )
1919
- fhb.add_argument(
1920
- "-u", "--until",
1921
- default=None,
1922
- metavar="YYYYMMDD",
1923
- help="Filter until date (inclusive).",
1924
- )
1919
+ Examples:
1920
+ cctally five-hour-blocks
1921
+ cctally five-hour-blocks --since 20260420
1922
+ cctally five-hour-blocks --breakdown model
1923
+ cctally five-hour-blocks --breakdown project --json
1924
+ """),
1925
+ )
1926
+ _add_since_until_args(
1927
+ fhb, metavar_since="YYYYMMDD", metavar_until="YYYYMMDD",
1928
+ help_since="Filter from date (inclusive).",
1929
+ help_until="Filter until date (inclusive).")
1925
1930
  fhb.add_argument(
1926
1931
  "--breakdown",
1927
1932
  choices=("model", "project"),
@@ -1949,20 +1954,21 @@ def build_parser() -> argparse.ArgumentParser:
1949
1954
  help="Display timezone: local, utc, or IANA name. "
1950
1955
  "Overrides config display.tz for this call.",
1951
1956
  )
1952
- # Session A (spec §7.6 / §7.6.3): ansi_emit=False. fhb already
1953
- # declares --no-color (refreshed to no-op text in spec §7.6.3); the
1954
- # helper's --no-color add is short-circuited by the existing-arg
1955
- # guard. The helper's --color add lands as a parsed-and-ignored
1956
- # no-op (the renderer emits plain text).
1957
1957
  _add_ccusage_alias_args(fhb, ansi_emit=False)
1958
1958
  _add_mode_arg(fhb, noop=True)
1959
1959
  _add_share_args(fhb)
1960
1960
  fhb.set_defaults(func=c.cmd_five_hour_blocks)
1961
1961
 
1962
- # -- cache-sync --
1963
- p_cache_sync = sub.add_parser(
1964
- "cache-sync",
1965
- help="Sync (or rebuild) the session-entry cache",
1962
+ def _build_cache_sync_parser(subparsers, name, *, help_text, xref=None):
1963
+ """Build the `cache-sync` parser (registered via _REGISTRATION; #279 S6 W3).
1964
+
1965
+ Move-only extraction of the former inline build_parser() block;
1966
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1967
+ """
1968
+ c = _cctally()
1969
+ p_cache_sync = subparsers.add_parser(
1970
+ name,
1971
+ help=help_text,
1966
1972
  )
1967
1973
  p_cache_sync.add_argument(
1968
1974
  "--rebuild",
@@ -1983,71 +1989,33 @@ def build_parser() -> argparse.ArgumentParser:
1983
1989
  )
1984
1990
  p_cache_sync.set_defaults(func=c.cmd_cache_sync)
1985
1991
 
1986
- # -- daily --
1987
- _build_daily_parser(
1988
- sub, "daily",
1989
- help_text="Show usage report grouped by date",
1990
- xref="Alias of `cctally claude daily` (the canonical form).")
1991
-
1992
- # -- monthly --
1993
- _build_monthly_parser(
1994
- sub, "monthly",
1995
- help_text="Show usage report grouped by month",
1996
- xref="Alias of `cctally claude monthly` (the canonical form).")
1997
-
1998
- # -- weekly --
1999
- _build_weekly_parser(
2000
- sub, "weekly",
2001
- help_text="Show usage grouped by subscription week (with Used %% and $/1%%)",
2002
- xref="Alias of `cctally claude weekly` (the canonical form).")
2003
-
2004
- # -- codex-daily --
2005
- _build_codex_daily_parser(
2006
- sub, "codex-daily",
2007
- help_text="Show Codex usage report grouped by date (drop-in for `ccusage-codex daily`)",
2008
- xref="Alias of `cctally codex daily` (the canonical form).")
2009
-
2010
- # -- codex-monthly --
2011
- _build_codex_monthly_parser(
2012
- sub, "codex-monthly",
2013
- help_text="Show Codex usage grouped by month (drop-in for `ccusage-codex monthly`)",
2014
- xref="Alias of `cctally codex monthly` (the canonical form).")
2015
-
2016
- # -- codex-weekly --
2017
- _build_codex_weekly_parser(
2018
- sub, "codex-weekly",
2019
- help_text="Show Codex usage grouped by week (week-start from config.json)",
2020
- xref="Alias of `cctally codex weekly` (the canonical form).")
2021
-
2022
- # -- codex-session --
2023
- _build_codex_session_parser(
2024
- sub, "codex-session",
2025
- help_text="Show Codex usage grouped by session (drop-in for `ccusage-codex session`)",
2026
- xref="Alias of `cctally codex session` (the canonical form).")
2027
-
2028
- # -- project --
2029
- p_project = sub.add_parser(
2030
- "project",
2031
- help="Roll usage up by project (git-root), with per-project Used %% attribution",
1992
+ def _build_project_parser(subparsers, name, *, help_text, xref=None):
1993
+ """Build the `project` parser (registered via _REGISTRATION; #279 S6 W3).
1994
+
1995
+ Move-only extraction of the former inline build_parser() block;
1996
+ call-time `c = _cctally()` binding, --help bytes unchanged.
1997
+ """
1998
+ c = _cctally()
1999
+ p_project = subparsers.add_parser(
2000
+ name,
2001
+ help=help_text,
2032
2002
  formatter_class=CLIHelpFormatter,
2033
- description=(
2034
- "Aggregate Claude usage by project (git-root resolved). Default range is "
2035
- "the current subscription week; use --since/--until or --weeks N to extend."
2036
- ),
2003
+ description="Aggregate Claude usage by project (git-root resolved). Default range is "
2004
+ "the current subscription week; use --since/--until or --weeks N to extend.",
2037
2005
  epilog=textwrap.dedent("""\
2038
- Examples:
2039
- cctally project
2040
- cctally project --weeks 4
2041
- cctally project --since 20260401 --until 20260414
2042
- cctally project --project ccusage --model sonnet
2043
- cctally project --breakdown --sort used --order desc
2044
- cctally project --group full-path --json
2045
- """),
2046
- )
2047
- p_project.add_argument("-s", "--since", default=None, metavar="YYYYMMDD",
2048
- help="Inclusive start date (YYYY-MM-DD or YYYYMMDD).")
2049
- p_project.add_argument("-u", "--until", default=None, metavar="YYYYMMDD",
2050
- help="Inclusive end date (YYYY-MM-DD or YYYYMMDD).")
2006
+ Examples:
2007
+ cctally project
2008
+ cctally project --weeks 4
2009
+ cctally project --since 20260401 --until 20260414
2010
+ cctally project --project ccusage --model sonnet
2011
+ cctally project --breakdown --sort used --order desc
2012
+ cctally project --group full-path --json
2013
+ """),
2014
+ )
2015
+ _add_since_until_args(
2016
+ p_project, metavar_since="YYYYMMDD", metavar_until="YYYYMMDD",
2017
+ help_since="Inclusive start date (YYYY-MM-DD or YYYYMMDD).",
2018
+ help_until="Inclusive end date (YYYY-MM-DD or YYYYMMDD).")
2051
2019
  p_project.add_argument("--weeks", type=int, default=None,
2052
2020
  help="Last N subscription weeks ending now.")
2053
2021
  p_project.add_argument("--project", action="append", default=[], metavar="PATTERN",
@@ -2071,18 +2039,20 @@ def build_parser() -> argparse.ArgumentParser:
2071
2039
  p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
2072
2040
  help="Display timezone: local, utc, or IANA name. "
2073
2041
  "Overrides config display.tz for this call.")
2074
- # Session A (spec §7.6): ansi_emit=True. project is one of the two
2075
- # real ANSI emitters. The helper skips its --no-color add (already
2076
- # declared at p_project above) and adds the new bool --color flag
2077
- # whose precedence flows through _resolve_color_enabled (§7.3).
2078
2042
  _add_ccusage_alias_args(p_project, ansi_emit=True)
2079
2043
  _add_share_args(p_project)
2080
2044
  p_project.set_defaults(func=c.cmd_project)
2081
2045
 
2082
- # -- diff --
2083
- diff_p = sub.add_parser(
2084
- "diff",
2085
- help="Compare Claude usage between two windows.",
2046
+ def _build_diff_parser(subparsers, name, *, help_text, xref=None):
2047
+ """Build the `diff` parser (registered via _REGISTRATION; #279 S6 W3).
2048
+
2049
+ Move-only extraction of the former inline build_parser() block;
2050
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2051
+ """
2052
+ c = _cctally()
2053
+ diff_p = subparsers.add_parser(
2054
+ name,
2055
+ help=help_text,
2086
2056
  )
2087
2057
  diff_p.add_argument("--a", required=True,
2088
2058
  help="Window A token (this-week | last-week | Nw-ago | this-month | last-month | Nm-ago | last-Nd | prev-Nd | YYYY-MM-DD..YYYY-MM-DD)")
@@ -2110,34 +2080,34 @@ def build_parser() -> argparse.ArgumentParser:
2110
2080
  diff_p.add_argument("--json", dest="emit_json", action="store_true")
2111
2081
  diff_p.add_argument("--width", type=int, help=argparse.SUPPRESS)
2112
2082
  diff_p.add_argument("--debug-now", action="store_true", help=argparse.SUPPRESS)
2113
- # Session A (spec §7.6): ansi_emit=True. diff is the other real
2114
- # ANSI emitter. The helper skips its --no-color add (declared
2115
- # above) and adds the new bool --color flag wired through
2116
- # _resolve_color_enabled (§7.3). Note: --debug here collides with
2117
- # diff's existing `--debug-now` (SUPPRESS'd internal flag) but
2118
- # `--debug-now` is a different option string; the helper still
2119
- # adds plain `--debug` cleanly.
2120
2083
  _add_ccusage_alias_args(diff_p, ansi_emit=True)
2121
2084
  diff_p.set_defaults(func=c.cmd_diff)
2122
2085
 
2123
- # -- session --
2124
- _build_session_parser(
2125
- sub, "session",
2126
- help_text="Show Claude usage grouped by sessionId (merges resumed-across-files sessions)",
2127
- xref="Alias of `cctally claude session` (the canonical form).")
2128
-
2129
- # --- `claude` subgroup (drop-in for `ccusage claude …`); issue #86 Session B ---
2130
- # Build-once, register-twice: these reuse the same nine builders as the flat
2131
- # forms above. Nested subparsers reuse dest="command" so args.command resolves
2132
- # to the leaf name (e.g. "blocks"), keeping banner suppression byte-identical
2133
- # to the flat form with zero hook-path changes.
2134
- claude_p = sub.add_parser(
2135
- "claude",
2136
- help="Claude-source reports (drop-in for `ccusage claude …`)",
2086
+ def _build_claude_parser(subparsers, name, *, help_text, xref=None):
2087
+ """Build the `claude` parser (registered via _REGISTRATION; #279 S6 W3).
2088
+
2089
+ Move-only extraction of the former inline build_parser() block;
2090
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2091
+
2092
+ Build-once, register-twice: reuses the same leaf builders as the flat
2093
+ forms. The nested subparsers deliberately reuse dest="command" so
2094
+ args.command resolves to the LEAF name (e.g. "blocks") this
2095
+ leaf-collapse keeps `_recompute_banner_should_emit` (_cctally_db.py,
2096
+ raw sys.argv routing that hardcodes claude/codex as the only
2097
+ subgroups) and `_post_command_update_hooks` (bin/cctally)
2098
+ byte-identical between flat and nested forms. Pinned by
2099
+ tests/test_subgroup_routing.py; adding a new subgroup means updating
2100
+ that raw-argv routing too.
2101
+ """
2102
+ c = _cctally()
2103
+ claude_p = subparsers.add_parser(
2104
+ name,
2105
+ help=help_text,
2137
2106
  formatter_class=CLIHelpFormatter,
2138
2107
  description="Claude-source usage reports. Each subcommand is a drop-in for the "
2139
- "matching `ccusage claude <cmd>` and shares its engine with the "
2140
- "top-level `cctally <cmd>` alias.")
2108
+ "matching `ccusage claude <cmd>` and shares its engine with the "
2109
+ "top-level `cctally <cmd>` alias.",
2110
+ )
2141
2111
  claude_sub = claude_p.add_subparsers(dest="command", required=True, metavar="<command>")
2142
2112
  _build_daily_parser(claude_sub, "daily",
2143
2113
  help_text="Show usage grouped by date",
@@ -2159,14 +2129,26 @@ def build_parser() -> argparse.ArgumentParser:
2159
2129
  xref="Canonical `cctally claude statusline` (flat alias: `cctally statusline`). "
2160
2130
  "Drop-in for `ccusage statusline` plus cctally extension segments.")
2161
2131
 
2162
- # --- `codex` subgroup (drop-in for `ccusage codex …`); issue #86 Session B ---
2163
- codex_p = sub.add_parser(
2164
- "codex",
2165
- help="Codex-source reports (drop-in for `ccusage codex …`)",
2132
+ def _build_codex_parser(subparsers, name, *, help_text, xref=None):
2133
+ """Build the `codex` parser (registered via _REGISTRATION; #279 S6 W3).
2134
+
2135
+ Move-only extraction of the former inline build_parser() block;
2136
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2137
+
2138
+ Same build-once/register-twice + dest="command" leaf-collapse coupling
2139
+ as `_build_claude_parser` — see its docstring for the
2140
+ `_recompute_banner_should_emit` / `_post_command_update_hooks`
2141
+ cross-reference.
2142
+ """
2143
+ c = _cctally()
2144
+ codex_p = subparsers.add_parser(
2145
+ name,
2146
+ help=help_text,
2166
2147
  formatter_class=CLIHelpFormatter,
2167
2148
  description="Codex-source usage reports. daily/monthly/session are drop-ins for "
2168
- "`ccusage codex <cmd>`; weekly is a cctally extension. Each shares its "
2169
- "engine with the matching `cctally codex-<cmd>` alias.")
2149
+ "`ccusage codex <cmd>`; weekly is a cctally extension. Each shares its "
2150
+ "engine with the matching `cctally codex-<cmd>` alias.",
2151
+ )
2170
2152
  codex_sub = codex_p.add_subparsers(dest="command", required=True, metavar="<command>")
2171
2153
  _build_codex_daily_parser(codex_sub, "daily",
2172
2154
  help_text="Show Codex usage grouped by date",
@@ -2182,32 +2164,38 @@ def build_parser() -> argparse.ArgumentParser:
2182
2164
  xref="cctally extension (no upstream `ccusage codex weekly`). Same engine as "
2183
2165
  "`cctally codex-weekly`.")
2184
2166
 
2185
- # ---- config (persisted user preferences) ----
2186
- cfg_p = sub.add_parser(
2187
- "config",
2188
- help="Get / set / unset persisted user preferences",
2167
+ def _build_config_parser(subparsers, name, *, help_text, xref=None):
2168
+ """Build the `config` parser (registered via _REGISTRATION; #279 S6 W3).
2169
+
2170
+ Move-only extraction of the former inline build_parser() block;
2171
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2172
+ """
2173
+ c = _cctally()
2174
+ cfg_p = subparsers.add_parser(
2175
+ name,
2176
+ help=help_text,
2189
2177
  formatter_class=CLIHelpFormatter,
2190
2178
  description=textwrap.dedent("""\
2191
- Manage cctally user preferences in ~/.local/share/cctally/config.json.
2192
-
2193
- Currently supported keys:
2194
- display.tz Display timezone. Values: 'local' (default; host
2195
- zone via the OS locale), 'utc', or any IANA name
2196
- like 'America/New_York'. Per-call --tz flag on
2197
- any subcommand still wins over the persisted value.
2198
- alerts.enabled Enable/disable threshold alerts (true/false).
2199
- dashboard.bind Host the `dashboard` subcommand binds. Values:
2200
- 'loopback' (default; binds 127.0.0.1 —
2201
- loopback-only), 'lan' (binds 0.0.0.0 —
2202
- LAN-accessible), or any literal IP / hostname.
2203
-
2204
- Examples:
2205
- cctally config get
2206
- cctally config get display.tz
2207
- cctally config set display.tz America/New_York
2208
- cctally config set dashboard.bind lan
2209
- cctally config unset dashboard.bind
2210
- """),
2179
+ Manage cctally user preferences in ~/.local/share/cctally/config.json.
2180
+
2181
+ Currently supported keys:
2182
+ display.tz Display timezone. Values: 'local' (default; host
2183
+ zone via the OS locale), 'utc', or any IANA name
2184
+ like 'America/New_York'. Per-call --tz flag on
2185
+ any subcommand still wins over the persisted value.
2186
+ alerts.enabled Enable/disable threshold alerts (true/false).
2187
+ dashboard.bind Host the `dashboard` subcommand binds. Values:
2188
+ 'loopback' (default; binds 127.0.0.1 —
2189
+ loopback-only), 'lan' (binds 0.0.0.0 —
2190
+ LAN-accessible), or any literal IP / hostname.
2191
+
2192
+ Examples:
2193
+ cctally config get
2194
+ cctally config get display.tz
2195
+ cctally config set display.tz America/New_York
2196
+ cctally config set dashboard.bind lan
2197
+ cctally config unset dashboard.bind
2198
+ """),
2211
2199
  )
2212
2200
  cfg_sub = cfg_p.add_subparsers(dest="action", required=True)
2213
2201
  cfg_get = cfg_sub.add_parser("get", help="Print current value(s)")
@@ -2225,36 +2213,42 @@ def build_parser() -> argparse.ArgumentParser:
2225
2213
  cfg_unset.add_argument("key", help="Config key")
2226
2214
  cfg_unset.set_defaults(func=c.cmd_config)
2227
2215
 
2228
- # ---- telemetry (anonymous install-count opt-out, spec 2026-07-07) ----
2229
- tele_p = sub.add_parser(
2230
- "telemetry",
2231
- help="Show or change anonymous install-count telemetry",
2216
+ def _build_telemetry_parser(subparsers, name, *, help_text, xref=None):
2217
+ """Build the `telemetry` parser (registered via _REGISTRATION; #279 S6 W3).
2218
+
2219
+ Move-only extraction of the former inline build_parser() block;
2220
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2221
+ """
2222
+ c = _cctally()
2223
+ tele_p = subparsers.add_parser(
2224
+ name,
2225
+ help=help_text,
2232
2226
  formatter_class=CLIHelpFormatter,
2233
2227
  description=textwrap.dedent("""\
2234
- Anonymous install-count telemetry: what it sends, and how to opt out.
2235
-
2236
- cctally sends, at most once a day, a minimal beat: a one-way
2237
- month-rotating token (never your install id), the client version,
2238
- and a coarse OS family (macos/linux/windows/other). No IP, no
2239
- username, no paths, no session content ever leaves the machine.
2240
-
2241
- Actions:
2242
- (none) Show the current state, resolved reason, what gets sent,
2243
- and the token that would be used this month.
2244
- on Enable telemetry (sets telemetry.enabled = true).
2245
- off Disable telemetry (sets telemetry.enabled = false).
2246
- reset Discard the local install id and mint a fresh one.
2247
-
2248
- It is also disabled by CCTALLY_DISABLE_TELEMETRY=1, the DO_NOT_TRACK
2249
- convention, and in dev checkouts.
2250
-
2251
- Examples:
2252
- cctally telemetry
2253
- cctally telemetry --json
2254
- cctally telemetry off
2255
- cctally telemetry on
2256
- cctally telemetry reset
2257
- """),
2228
+ Anonymous install-count telemetry: what it sends, and how to opt out.
2229
+
2230
+ cctally sends, at most once a day, a minimal beat: a one-way
2231
+ month-rotating token (never your install id), the client version,
2232
+ and a coarse OS family (macos/linux/windows/other). No IP, no
2233
+ username, no paths, no session content ever leaves the machine.
2234
+
2235
+ Actions:
2236
+ (none) Show the current state, resolved reason, what gets sent,
2237
+ and the token that would be used this month.
2238
+ on Enable telemetry (sets telemetry.enabled = true).
2239
+ off Disable telemetry (sets telemetry.enabled = false).
2240
+ reset Discard the local install id and mint a fresh one.
2241
+
2242
+ It is also disabled by CCTALLY_DISABLE_TELEMETRY=1, the DO_NOT_TRACK
2243
+ convention, and in dev checkouts.
2244
+
2245
+ Examples:
2246
+ cctally telemetry
2247
+ cctally telemetry --json
2248
+ cctally telemetry off
2249
+ cctally telemetry on
2250
+ cctally telemetry reset
2251
+ """),
2258
2252
  )
2259
2253
  tele_p.add_argument(
2260
2254
  "action", nargs="?", choices=("on", "off", "reset"),
@@ -2266,24 +2260,30 @@ def build_parser() -> argparse.ArgumentParser:
2266
2260
  )
2267
2261
  tele_p.set_defaults(func=c.cmd_telemetry)
2268
2262
 
2269
- # ---- alerts (threshold-actions Task 6) ----
2270
- p_alerts = sub.add_parser(
2271
- "alerts",
2272
- help="Manage threshold alerts",
2263
+ def _build_alerts_parser(subparsers, name, *, help_text, xref=None):
2264
+ """Build the `alerts` parser (registered via _REGISTRATION; #279 S6 W3).
2265
+
2266
+ Move-only extraction of the former inline build_parser() block;
2267
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2268
+ """
2269
+ c = _cctally()
2270
+ p_alerts = subparsers.add_parser(
2271
+ name,
2272
+ help=help_text,
2273
2273
  formatter_class=CLIHelpFormatter,
2274
2274
  description=textwrap.dedent("""\
2275
- Manage cctally threshold alerts.
2275
+ Manage cctally threshold alerts.
2276
2276
 
2277
- Subcommands:
2278
- test Send a synthetic test alert through the dispatch
2279
- pipeline (osascript spawn + alerts.log line). Logs
2280
- with mode=test so it doesn't pollute real-alert
2281
- history.
2277
+ Subcommands:
2278
+ test Send a synthetic test alert through the dispatch
2279
+ pipeline (osascript spawn + alerts.log line). Logs
2280
+ with mode=test so it doesn't pollute real-alert
2281
+ history.
2282
2282
 
2283
- Examples:
2284
- cctally alerts test
2285
- cctally alerts test --axis five-hour --threshold 95
2286
- """),
2283
+ Examples:
2284
+ cctally alerts test
2285
+ cctally alerts test --axis five-hour --threshold 95
2286
+ """),
2287
2287
  )
2288
2288
  alerts_sub = p_alerts.add_subparsers(dest="alerts_command", required=True)
2289
2289
  p_alerts_test = alerts_sub.add_parser(
@@ -2348,25 +2348,31 @@ def build_parser() -> argparse.ArgumentParser:
2348
2348
  )
2349
2349
  p_alerts_test.set_defaults(func=c.cmd_alerts_test)
2350
2350
 
2351
- # ---- setup (onboarding spec §2) ----
2352
- sp = sub.add_parser(
2353
- "setup",
2354
- help="Install cctally into Claude Code (hooks + symlinks)",
2351
+ def _build_setup_parser(subparsers, name, *, help_text, xref=None):
2352
+ """Build the `setup` parser (registered via _REGISTRATION; #279 S6 W3).
2353
+
2354
+ Move-only extraction of the former inline build_parser() block;
2355
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2356
+ """
2357
+ c = _cctally()
2358
+ sp = subparsers.add_parser(
2359
+ name,
2360
+ help=help_text,
2355
2361
  formatter_class=CLIHelpFormatter,
2356
2362
  description=textwrap.dedent(
2357
- """\
2358
- Install cctally into Claude Code by adding hook entries to
2359
- ~/.claude/settings.json (additive, idempotent) and creating
2360
- user-facing symlinks under ~/.local/bin/.
2361
-
2362
- Modes (mutually exclusive):
2363
- cctally setup # install (default)
2364
- cctally setup --dry-run # show planned changes, change nothing
2365
- cctally setup --status # report current install state
2366
- cctally setup --uninstall # remove hooks + symlinks (keep data)
2367
- cctally setup --uninstall --purge # also wipe ~/.local/share/cctally/
2368
- """
2369
- ),
2363
+ """\
2364
+ Install cctally into Claude Code by adding hook entries to
2365
+ ~/.claude/settings.json (additive, idempotent) and creating
2366
+ user-facing symlinks under ~/.local/bin/.
2367
+
2368
+ Modes (mutually exclusive):
2369
+ cctally setup # install (default)
2370
+ cctally setup --dry-run # show planned changes, change nothing
2371
+ cctally setup --status # report current install state
2372
+ cctally setup --uninstall # remove hooks + symlinks (keep data)
2373
+ cctally setup --uninstall --purge # also wipe ~/.local/share/cctally/
2374
+ """
2375
+ ),
2370
2376
  )
2371
2377
  mode = sp.add_mutually_exclusive_group()
2372
2378
  mode.add_argument("--status", action="store_true", help="Report current install state")
@@ -2383,8 +2389,6 @@ def build_parser() -> argparse.ArgumentParser:
2383
2389
  sp.add_argument("--force-dev", action="store_true", dest="force_dev",
2384
2390
  help="Allow setup to run from a dev checkout (writes "
2385
2391
  "dev-pointing hooks into ~/.claude/settings.json)")
2386
- # Legacy bespoke-hook migration flags (install-mode only — see cmd_setup
2387
- # post-parse validation). Spec Section 2 mode×flag matrix.
2388
2392
  mig_group = sp.add_mutually_exclusive_group()
2389
2393
  mig_group.add_argument(
2390
2394
  "--migrate-legacy-hooks", action="store_true", dest="migrate_legacy_hooks",
@@ -2396,39 +2400,44 @@ def build_parser() -> argparse.ArgumentParser:
2396
2400
  )
2397
2401
  sp.set_defaults(func=c.cmd_setup)
2398
2402
 
2399
- # ---- db (migration framework spec §4) ----
2400
- db_parser = sub.add_parser(
2401
- "db",
2402
- help="Migration / DB management (status, skip, unskip)",
2403
+ def _build_db_parser(subparsers, name, *, help_text, xref=None):
2404
+ """Build the `db` parser (registered via _REGISTRATION; #279 S6 W3).
2405
+
2406
+ Move-only extraction of the former inline build_parser() block;
2407
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2408
+ """
2409
+ c = _cctally()
2410
+ db_parser = subparsers.add_parser(
2411
+ name,
2412
+ help=help_text,
2403
2413
  formatter_class=CLIHelpFormatter,
2404
2414
  description=textwrap.dedent(
2405
- """\
2406
- Inspect and manage cctally's SQLite migration state.
2407
-
2408
- Subcommands:
2409
- status List migrations + applied/pending/failed/skipped
2410
- state across stats.db and cache.db. Glyphs:
2411
- ✓ applied ✗ failed · pending ~ skipped
2412
- skip Mark a migration as skipped (manual poison-pill
2413
- escape — bypass an offending migration).
2414
- unskip Remove a skip mark; the migration runs on next
2415
- open.
2416
-
2417
- Migration names accept either bare ("003_…") or qualified
2418
- ("stats.db:003_…" / "cache.db:003_…") forms. Bare names are
2419
- rejected with exit 2 if the same NNN_… exists in both
2420
- registries.
2421
-
2422
- Examples:
2423
- cctally db status
2424
- cctally db status --json
2425
- cctally db skip 003_merge_5h_block_duplicates_v1 --reason "perf hot"
2426
- cctally db unskip stats.db:003_merge_5h_block_duplicates_v1
2427
- """
2428
- ),
2415
+ """\
2416
+ Inspect and manage cctally's SQLite migration state.
2417
+
2418
+ Subcommands:
2419
+ status List migrations + applied/pending/failed/skipped
2420
+ state across stats.db and cache.db. Glyphs:
2421
+ ✓ applied ✗ failed · pending ~ skipped
2422
+ skip Mark a migration as skipped (manual poison-pill
2423
+ escape — bypass an offending migration).
2424
+ unskip Remove a skip mark; the migration runs on next
2425
+ open.
2426
+
2427
+ Migration names accept either bare ("003_…") or qualified
2428
+ ("stats.db:003_…" / "cache.db:003_…") forms. Bare names are
2429
+ rejected with exit 2 if the same NNN_… exists in both
2430
+ registries.
2431
+
2432
+ Examples:
2433
+ cctally db status
2434
+ cctally db status --json
2435
+ cctally db skip 003_merge_5h_block_duplicates_v1 --reason "perf hot"
2436
+ cctally db unskip stats.db:003_merge_5h_block_duplicates_v1
2437
+ """
2438
+ ),
2429
2439
  )
2430
2440
  db_sub = db_parser.add_subparsers(dest="db_action", required=True)
2431
-
2432
2441
  db_status = db_sub.add_parser(
2433
2442
  "status",
2434
2443
  help="List migrations + applied/pending/failed/skipped state",
@@ -2439,7 +2448,6 @@ def build_parser() -> argparse.ArgumentParser:
2439
2448
  help="Emit JSON to stdout",
2440
2449
  )
2441
2450
  db_status.set_defaults(func=c.cmd_db_status)
2442
-
2443
2451
  db_skip = db_sub.add_parser(
2444
2452
  "skip",
2445
2453
  help="Mark a migration as skipped",
@@ -2453,7 +2461,6 @@ def build_parser() -> argparse.ArgumentParser:
2453
2461
  help="Free-text reason (shown in db status)",
2454
2462
  )
2455
2463
  db_skip.set_defaults(func=c.cmd_db_skip)
2456
-
2457
2464
  db_unskip = db_sub.add_parser(
2458
2465
  "unskip",
2459
2466
  help="Remove a skip mark; migration runs on next open",
@@ -2463,7 +2470,6 @@ def build_parser() -> argparse.ArgumentParser:
2463
2470
  help="Migration name (NNN_… or qualified)",
2464
2471
  )
2465
2472
  db_unskip.set_defaults(func=c.cmd_db_unskip)
2466
-
2467
2473
  db_recover = db_sub.add_parser(
2468
2474
  "recover",
2469
2475
  help="Revert a version-ahead DB to the known schema head (#145)",
@@ -2481,27 +2487,33 @@ def build_parser() -> argparse.ArgumentParser:
2481
2487
  )
2482
2488
  db_recover.set_defaults(func=c.cmd_db_recover)
2483
2489
 
2484
- # ─── doctor (Diagnostics) ───────────────────────────────────────────
2485
- doctor_p = sub.add_parser(
2486
- "doctor",
2487
- help="Diagnose data freshness and install state",
2490
+ def _build_doctor_parser(subparsers, name, *, help_text, xref=None):
2491
+ """Build the `doctor` parser (registered via _REGISTRATION; #279 S6 W3).
2492
+
2493
+ Move-only extraction of the former inline build_parser() block;
2494
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2495
+ """
2496
+ c = _cctally()
2497
+ doctor_p = subparsers.add_parser(
2498
+ name,
2499
+ help=help_text,
2488
2500
  formatter_class=CLIHelpFormatter,
2489
2501
  description=textwrap.dedent(
2490
- """\
2491
- Run all read-only diagnostic checks and emit a report.
2502
+ """\
2503
+ Run all read-only diagnostic checks and emit a report.
2492
2504
 
2493
- Categories: install, hooks, auth, db, data, safety. Each
2494
- category renders a severity (✓ ok / ⚠ warn / ✗ fail) and
2495
- actionable remediation guidance for non-OK rows.
2505
+ Categories: install, hooks, auth, db, data, safety. Each
2506
+ category renders a severity (✓ ok / ⚠ warn / ✗ fail) and
2507
+ actionable remediation guidance for non-OK rows.
2496
2508
 
2497
- Exit code: 0 unless any check is FAIL (then exit 2). WARN
2498
- rows do not change the exit code — doctor is a read-only
2499
- diagnostic and warn-class findings are advisories.
2509
+ Exit code: 0 unless any check is FAIL (then exit 2). WARN
2510
+ rows do not change the exit code — doctor is a read-only
2511
+ diagnostic and warn-class findings are advisories.
2500
2512
 
2501
- See docs/commands/doctor.md for the full check inventory
2502
- and JSON schema reference.
2503
- """
2504
- ),
2513
+ See docs/commands/doctor.md for the full check inventory
2514
+ and JSON schema reference.
2515
+ """
2516
+ ),
2505
2517
  )
2506
2518
  doctor_p.add_argument(
2507
2519
  "--json", action="store_true",
@@ -2518,40 +2530,46 @@ def build_parser() -> argparse.ArgumentParser:
2518
2530
  )
2519
2531
  doctor_p.set_defaults(func=c.cmd_doctor)
2520
2532
 
2521
- # ---- pricing-check (standalone diagnostic NOT under claude/codex) ----
2522
- pc_p = sub.add_parser(
2523
- "pricing-check",
2524
- help="Detect stale or missing embedded model pricing",
2533
+ def _build_pricing_check_parser(subparsers, name, *, help_text, xref=None):
2534
+ """Build the `pricing-check` parser (registered via _REGISTRATION; #279 S6 W3).
2535
+
2536
+ Move-only extraction of the former inline build_parser() block;
2537
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2538
+ """
2539
+ c = _cctally()
2540
+ pc_p = subparsers.add_parser(
2541
+ name,
2542
+ help=help_text,
2525
2543
  formatter_class=CLIHelpFormatter,
2526
2544
  description=textwrap.dedent(
2527
- """\
2528
- Check whether cctally's embedded model pricing is stale or
2529
- missing, across three independently-degrading legs:
2530
-
2531
- • coverage (offline, all-history) — models in your cached
2532
- session data that cctally cannot price (Claude $0) or only
2533
- approximates (Codex gpt-5 fallback).
2534
- • drift (network, LiteLLM) — embedded price values vs the
2535
- LiteLLM snapshot (direction-aware; allowlist-suppressed).
2536
- • existence (network, Anthropic /v1/models) — vendor models the
2537
- API offers that our table lacks. Maintainer-local (needs
2538
- OAuth); degrades to skipped/degraded otherwise.
2539
-
2540
- Exit codes:
2541
- 0 — no actionable findings (fully clean, OR partially/fully
2542
- network-degraded but nothing actionable; --json still
2543
- carries "status":"degraded").
2544
- 1 — any actionable finding (a coverage gap, value drift,
2545
- missing-from-us, or an existence gap) — EVEN IF a network
2546
- leg degraded. Findings always win over degradation.
2547
- 2 — argument/usage error.
2548
-
2549
- "status" (ok|degraded) reports check completeness; the exit code
2550
- reports whether you must act. They are orthogonal.
2551
-
2552
- See docs/commands/pricing-check.md for the JSON schema.
2553
- """
2554
- ),
2545
+ """\
2546
+ Check whether cctally's embedded model pricing is stale or
2547
+ missing, across three independently-degrading legs:
2548
+
2549
+ • coverage (offline, all-history) — models in your cached
2550
+ session data that cctally cannot price (Claude $0) or only
2551
+ approximates (Codex gpt-5 fallback).
2552
+ • drift (network, LiteLLM) — embedded price values vs the
2553
+ LiteLLM snapshot (direction-aware; allowlist-suppressed).
2554
+ • existence (network, Anthropic /v1/models) — vendor models the
2555
+ API offers that our table lacks. Maintainer-local (needs
2556
+ OAuth); degrades to skipped/degraded otherwise.
2557
+
2558
+ Exit codes:
2559
+ 0 — no actionable findings (fully clean, OR partially/fully
2560
+ network-degraded but nothing actionable; --json still
2561
+ carries "status":"degraded").
2562
+ 1 — any actionable finding (a coverage gap, value drift,
2563
+ missing-from-us, or an existence gap) — EVEN IF a network
2564
+ leg degraded. Findings always win over degradation.
2565
+ 2 — argument/usage error.
2566
+
2567
+ "status" (ok|degraded) reports check completeness; the exit code
2568
+ reports whether you must act. They are orthogonal.
2569
+
2570
+ See docs/commands/pricing-check.md for the JSON schema.
2571
+ """
2572
+ ),
2555
2573
  )
2556
2574
  pc_p.add_argument(
2557
2575
  "--json", action="store_true",
@@ -2563,24 +2581,26 @@ def build_parser() -> argparse.ArgumentParser:
2563
2581
  )
2564
2582
  pc_p.set_defaults(func=c.cmd_pricing_check)
2565
2583
 
2566
- # `release` is its own standalone entry-point (bin/cctally-release);
2567
- # no `release` subparser is registered on the main `cctally` CLI.
2568
- # See docs/RELEASE.md.
2584
+ def _build_hook_tick_parser(subparsers, name, *, help_text, xref=None):
2585
+ """Build the `hook-tick` parser (registered via _REGISTRATION; #279 S6 W3).
2569
2586
 
2570
- # ---- hook-tick (internal hidden from --help, see onboarding spec §3) ----
2571
- ht = sub.add_parser(
2572
- "hook-tick",
2573
- help=argparse.SUPPRESS,
2587
+ Move-only extraction of the former inline build_parser() block;
2588
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2589
+ """
2590
+ c = _cctally()
2591
+ ht = subparsers.add_parser(
2592
+ name,
2593
+ help=help_text,
2574
2594
  formatter_class=CLIHelpFormatter,
2575
2595
  description=textwrap.dedent(
2576
- """\
2577
- Internal subcommand invoked by Claude Code hooks.
2596
+ """\
2597
+ Internal subcommand invoked by Claude Code hooks.
2578
2598
 
2579
- Reads CC's hook payload from stdin, runs sync_cache, and
2580
- conditionally refreshes the OAuth usage cache (throttled).
2581
- Returns 0 unconditionally in normal mode.
2582
- """
2583
- ),
2599
+ Reads CC's hook payload from stdin, runs sync_cache, and
2600
+ conditionally refreshes the OAuth usage cache (throttled).
2601
+ Returns 0 unconditionally in normal mode.
2602
+ """
2603
+ ),
2584
2604
  )
2585
2605
  ht.add_argument("--explain", action="store_true",
2586
2606
  help="Run synchronously, print decision tree, exit informative code")
@@ -2595,42 +2615,52 @@ def build_parser() -> argparse.ArgumentParser:
2595
2615
  help=argparse.SUPPRESS) # JSON string fed to mock fetch (tests only)
2596
2616
  ht.set_defaults(func=c.cmd_hook_tick)
2597
2617
 
2598
- # ---- __preview (internal maintainer-local preview channel) ----
2599
- # The preview modules are maintainer-only (excluded from the public
2600
- # mirror). On a public build `c.cmd_preview` is None — its load in
2601
- # bin/cctally is wrapped in a tolerant try/except — so skip registering
2602
- # the hidden subcommand entirely rather than wiring a None handler.
2603
- if getattr(c, "cmd_preview", None) is not None:
2604
- pv = sub.add_parser("__preview", help=argparse.SUPPRESS,
2605
- formatter_class=CLIHelpFormatter,
2606
- description="Internal: provision/manage the preview-channel data dir.")
2607
- pv_sub = pv.add_subparsers(dest="action", required=True)
2608
- pv_ensure = pv_sub.add_parser("ensure", help=argparse.SUPPRESS)
2609
- pv_ensure.add_argument("--no-refresh", action="store_true")
2610
- pv_ensure.add_argument("--reseed", action="store_true")
2611
- pv_ensure.set_defaults(func=c.cmd_preview)
2612
- pv_clean = pv_sub.add_parser("clean", help=argparse.SUPPRESS)
2613
- pv_clean.add_argument("--dry-run", action="store_true")
2614
- pv_clean.set_defaults(func=c.cmd_preview)
2615
- pv_status = pv_sub.add_parser("status", help=argparse.SUPPRESS)
2616
- pv_status.set_defaults(func=c.cmd_preview)
2617
-
2618
- # ---- update (user-facing self-update subcommand; spec §4) ----
2619
- sub_update = sub.add_parser(
2620
- "update",
2621
- help="Update cctally to the latest version",
2618
+ def _build_preview_parser(subparsers, name, *, help_text, xref=None):
2619
+ """Build the `__preview` parser (registered via _REGISTRATION; #279 S6 W3).
2620
+
2621
+ Move-only extraction of the former inline build_parser() block;
2622
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2623
+ """
2624
+ c = _cctally()
2625
+ pv = subparsers.add_parser(
2626
+ name,
2627
+ help=help_text,
2628
+ formatter_class=CLIHelpFormatter,
2629
+ description="Internal: provision/manage the preview-channel data dir.",
2630
+ )
2631
+ pv_sub = pv.add_subparsers(dest="action", required=True)
2632
+ pv_ensure = pv_sub.add_parser("ensure", help=argparse.SUPPRESS)
2633
+ pv_ensure.add_argument("--no-refresh", action="store_true")
2634
+ pv_ensure.add_argument("--reseed", action="store_true")
2635
+ pv_ensure.set_defaults(func=c.cmd_preview)
2636
+ pv_clean = pv_sub.add_parser("clean", help=argparse.SUPPRESS)
2637
+ pv_clean.add_argument("--dry-run", action="store_true")
2638
+ pv_clean.set_defaults(func=c.cmd_preview)
2639
+ pv_status = pv_sub.add_parser("status", help=argparse.SUPPRESS)
2640
+ pv_status.set_defaults(func=c.cmd_preview)
2641
+
2642
+ def _build_update_parser(subparsers, name, *, help_text, xref=None):
2643
+ """Build the `update` parser (registered via _REGISTRATION; #279 S6 W3).
2644
+
2645
+ Move-only extraction of the former inline build_parser() block;
2646
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2647
+ """
2648
+ c = _cctally()
2649
+ sub_update = subparsers.add_parser(
2650
+ name,
2651
+ help=help_text,
2622
2652
  formatter_class=CLIHelpFormatter,
2623
2653
  description=textwrap.dedent(
2624
- """\
2625
- Update cctally to the latest version (npm/brew installs only).
2654
+ """\
2655
+ Update cctally to the latest version (npm/brew installs only).
2626
2656
 
2627
- Modes:
2628
- cctally update install the latest version
2629
- cctally update --check show update info without installing
2630
- cctally update --skip [VER] don't remind about VER (default: latest)
2631
- cctally update --remind-later [DAYS] defer the banner (default: 7)
2632
- """
2633
- ),
2657
+ Modes:
2658
+ cctally update install the latest version
2659
+ cctally update --check show update info without installing
2660
+ cctally update --skip [VER] don't remind about VER (default: latest)
2661
+ cctally update --remind-later [DAYS] defer the banner (default: 7)
2662
+ """
2663
+ ),
2634
2664
  )
2635
2665
  update_modes = sub_update.add_mutually_exclusive_group()
2636
2666
  update_modes.add_argument(
@@ -2647,14 +2677,6 @@ def build_parser() -> argparse.ArgumentParser:
2647
2677
  default=None,
2648
2678
  help="Defer reminders by N days (default: 7)",
2649
2679
  )
2650
- # `--version` here is local to the update subparser. The subparser's
2651
- # value is bound to `args.install_version` (NOT `args.version`) to
2652
- # avoid a namespace collision with the top-level `--version`
2653
- # (store_true) flag handled in `main()` before subcommand dispatch:
2654
- # if both used `dest="version"`, `cctally update --version 1.2.3`
2655
- # would set `args.version="1.2.3"`, which `main()`'s truthy check
2656
- # would treat as "global --version requested" and short-circuit to
2657
- # print the version banner before `cmd_update` ever ran.
2658
2680
  sub_update.add_argument(
2659
2681
  "--version", metavar="X.Y.Z", default=None, dest="install_version",
2660
2682
  help="Install a specific version (npm only; brew has no versioned formulae)",
@@ -2673,71 +2695,184 @@ def build_parser() -> argparse.ArgumentParser:
2673
2695
  )
2674
2696
  sub_update.set_defaults(func=c.cmd_update)
2675
2697
 
2676
- # ---- _update-check (internal — hidden, detached-refresh worker for `cctally update`) ----
2677
- uc = sub.add_parser(
2678
- "_update-check",
2679
- help=argparse.SUPPRESS,
2698
+ def _build_update_check_parser(subparsers, name, *, help_text, xref=None):
2699
+ """Build the `_update-check` parser (registered via _REGISTRATION; #279 S6 W3).
2700
+
2701
+ Move-only extraction of the former inline build_parser() block;
2702
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2703
+ """
2704
+ c = _cctally()
2705
+ uc = subparsers.add_parser(
2706
+ name,
2707
+ help=help_text,
2680
2708
  formatter_class=CLIHelpFormatter,
2681
2709
  description=textwrap.dedent(
2682
- """\
2683
- Internal subcommand: detached version-check worker spawned
2684
- by `cctally update` (spec §3.6). Touches the throttle
2685
- marker, fetches the latest version from npm or homebrew
2686
- depending on install method, and writes update-state.json.
2687
- Always returns 0; failures are logged to update.log.
2688
- """
2689
- ),
2710
+ """\
2711
+ Internal subcommand: detached version-check worker spawned
2712
+ by `cctally update` (spec §3.6). Touches the throttle
2713
+ marker, fetches the latest version from npm or homebrew
2714
+ depending on install method, and writes update-state.json.
2715
+ Always returns 0; failures are logged to update.log.
2716
+ """
2717
+ ),
2690
2718
  )
2691
2719
  uc.set_defaults(func=c.cmd_update_check_internal)
2692
2720
 
2693
- # ---- _telemetry-beat (internal — hidden, detached anonymous install-count worker) ----
2694
- tb = sub.add_parser(
2695
- "_telemetry-beat",
2696
- help=argparse.SUPPRESS,
2721
+ def _build_telemetry_beat_parser(subparsers, name, *, help_text, xref=None):
2722
+ """Build the `_telemetry-beat` parser (registered via _REGISTRATION; #279 S6 W3).
2723
+
2724
+ Move-only extraction of the former inline build_parser() block;
2725
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2726
+ """
2727
+ c = _cctally()
2728
+ tb = subparsers.add_parser(
2729
+ name,
2730
+ help=help_text,
2697
2731
  formatter_class=CLIHelpFormatter,
2698
2732
  description=textwrap.dedent(
2699
- """\
2700
- Internal subcommand: detached anonymous install-count beat
2701
- worker, spawned broad-but-throttled from
2702
- `_post_command_update_hooks` (spec 2026-07-07). A dedicated
2703
- worker, decoupled from `_update-check` — it touches only the
2704
- telemetry markers, never update-check state. Honours every
2705
- opt-out (CCTALLY_DISABLE_TELEMETRY / DO_NOT_TRACK / config /
2706
- dev checkout) via `resolve_telemetry_state`. Always returns 0;
2707
- failures are swallowed.
2708
- """
2709
- ),
2733
+ """\
2734
+ Internal subcommand: detached anonymous install-count beat
2735
+ worker, spawned broad-but-throttled from
2736
+ `_post_command_update_hooks` (spec 2026-07-07). A dedicated
2737
+ worker, decoupled from `_update-check` — it touches only the
2738
+ telemetry markers, never update-check state. Honours every
2739
+ opt-out (CCTALLY_DISABLE_TELEMETRY / DO_NOT_TRACK / config /
2740
+ dev checkout) via `resolve_telemetry_state`. Always returns 0;
2741
+ failures are swallowed.
2742
+ """
2743
+ ),
2710
2744
  )
2711
2745
  tb.set_defaults(func=c.cmd_telemetry_beat_internal)
2712
2746
 
2713
- # ---- repair-symlinks (internal hidden; npm-postinstall self-heal, issue #114) ----
2714
- rs = sub.add_parser(
2715
- "repair-symlinks",
2716
- help=argparse.SUPPRESS,
2747
+ def _build_repair_symlinks_parser(subparsers, name, *, help_text, xref=None):
2748
+ """Build the `repair-symlinks` parser (registered via _REGISTRATION; #279 S6 W3).
2749
+
2750
+ Move-only extraction of the former inline build_parser() block;
2751
+ call-time `c = _cctally()` binding, --help bytes unchanged.
2752
+ """
2753
+ c = _cctally()
2754
+ rs = subparsers.add_parser(
2755
+ name,
2756
+ help=help_text,
2757
+ formatter_class=CLIHelpFormatter,
2758
+ description=textwrap.dedent(
2759
+ """\
2760
+ Internal subcommand: additively create any missing
2761
+ ~/.local/bin/ symlinks for cctally subcommands (issue #114).
2762
+
2763
+ Invoked best-effort by the npm postinstall on upgrade so new
2764
+ cctally-* binaries become reachable without re-running
2765
+ `cctally setup`. Gated to existing installs (>=1 symlink
2766
+ already present); a fresh install is a silent no-op. Touches
2767
+ only symlinks — no hooks, settings.json, or cache. Refuses
2768
+ from a dev checkout.
2769
+ """
2770
+ ),
2771
+ )
2772
+ rs.set_defaults(func=c.cmd_repair_symlinks)
2773
+
2774
+
2775
+ class _Reg(NamedTuple):
2776
+ name: str
2777
+ builder: object
2778
+ help_text: object
2779
+ xref: object
2780
+ predicate: object
2781
+
2782
+
2783
+ _REGISTRATION = (
2784
+ _Reg('sync-week', _build_sync_week_parser, "Compute weekly cost from session data and store in SQLite", None, None),
2785
+ _Reg('report', _build_report_parser, "Show current and trend dollars-per-1%% statistics", None, None),
2786
+ _Reg('forecast', _build_forecast_parser, "Project current-week usage to reset; show daily budgets", None, None),
2787
+ _Reg('budget', _build_budget_parser, "Weekly equivalent-$ budget + pace + spend alerts", None, None),
2788
+ _Reg('percent-breakdown', _build_percent_breakdown_parser, "Show per-percent cost milestones for a week", None, None),
2789
+ _Reg('five-hour-breakdown', _build_five_hour_breakdown_parser, "Per-percent milestones inside one 5h block (mirror of percent-breakdown)", None, None),
2790
+ _Reg('tui', _build_tui_parser, "Live refreshing dashboard (current week, forecast, trend, sessions)", None, None),
2791
+ _Reg('dashboard', _build_dashboard_parser, "Launch the live web dashboard on http://localhost:8789", None, None),
2792
+ _Reg('record-usage', _build_record_usage_parser, "Record usage data from Claude Code status line", None, None),
2793
+ _Reg('record-credit', _build_record_credit_parser, "Record an in-place weekly credit the auto-detector misses", None, None),
2794
+ _Reg('refresh-usage', _build_refresh_usage_parser, "Force-fetch 7d/5h percent from OAuth API and record it", None, None),
2795
+ _Reg('cache-report', _build_cache_report_parser, "Show daily cache hit rates per model from ccusage data", None, None),
2796
+ _Reg('range-cost', _build_range_cost_parser, "Compute USD cost for a time range from session data", None, None),
2797
+ _Reg('blocks', _build_blocks_parser, "Show usage report grouped by 5-hour session blocks", "Alias of `cctally claude blocks` (the canonical form).", None),
2798
+ _Reg('statusline', _build_statusline_parser, "Compact one-line status for Claude Code hooks", "Alias of `cctally claude statusline` (the canonical form).", None),
2799
+ _Reg('five-hour-blocks', _build_five_hour_blocks_parser, "List API-anchored 5h blocks with rollup totals + 7d-drift columns", None, None),
2800
+ _Reg('cache-sync', _build_cache_sync_parser, "Sync (or rebuild) the session-entry cache", None, None),
2801
+ _Reg('daily', _build_daily_parser, "Show usage report grouped by date", "Alias of `cctally claude daily` (the canonical form).", None),
2802
+ _Reg('monthly', _build_monthly_parser, "Show usage report grouped by month", "Alias of `cctally claude monthly` (the canonical form).", None),
2803
+ _Reg('weekly', _build_weekly_parser, "Show usage grouped by subscription week (with Used %% and $/1%%)", "Alias of `cctally claude weekly` (the canonical form).", None),
2804
+ _Reg('codex-daily', _build_codex_daily_parser, "Show Codex usage report grouped by date (drop-in for `ccusage-codex daily`)", "Alias of `cctally codex daily` (the canonical form).", None),
2805
+ _Reg('codex-monthly', _build_codex_monthly_parser, "Show Codex usage grouped by month (drop-in for `ccusage-codex monthly`)", "Alias of `cctally codex monthly` (the canonical form).", None),
2806
+ _Reg('codex-weekly', _build_codex_weekly_parser, "Show Codex usage grouped by week (week-start from config.json)", "Alias of `cctally codex weekly` (the canonical form).", None),
2807
+ _Reg('codex-session', _build_codex_session_parser, "Show Codex usage grouped by session (drop-in for `ccusage-codex session`)", "Alias of `cctally codex session` (the canonical form).", None),
2808
+ _Reg('project', _build_project_parser, "Roll usage up by project (git-root), with per-project Used %% attribution", None, None),
2809
+ _Reg('diff', _build_diff_parser, "Compare Claude usage between two windows.", None, None),
2810
+ _Reg('session', _build_session_parser, "Show Claude usage grouped by sessionId (merges resumed-across-files sessions)", "Alias of `cctally claude session` (the canonical form).", None),
2811
+ _Reg('claude', _build_claude_parser, "Claude-source reports (drop-in for `ccusage claude …`)", None, None),
2812
+ _Reg('codex', _build_codex_parser, "Codex-source reports (drop-in for `ccusage codex …`)", None, None),
2813
+ _Reg('config', _build_config_parser, "Get / set / unset persisted user preferences", None, None),
2814
+ _Reg('telemetry', _build_telemetry_parser, "Show or change anonymous install-count telemetry", None, None),
2815
+ _Reg('alerts', _build_alerts_parser, "Manage threshold alerts", None, None),
2816
+ _Reg('setup', _build_setup_parser, "Install cctally into Claude Code (hooks + symlinks)", None, None),
2817
+ _Reg('db', _build_db_parser, "Migration / DB management (status, skip, unskip)", None, None),
2818
+ _Reg('doctor', _build_doctor_parser, "Diagnose data freshness and install state", None, None),
2819
+ _Reg('pricing-check', _build_pricing_check_parser, "Detect stale or missing embedded model pricing", None, None),
2820
+ _Reg('hook-tick', _build_hook_tick_parser, argparse.SUPPRESS, None, None),
2821
+ _Reg('__preview', _build_preview_parser, argparse.SUPPRESS, None, lambda c: getattr(c, "cmd_preview", None) is not None),
2822
+ _Reg('update', _build_update_parser, "Update cctally to the latest version", None, None),
2823
+ _Reg('_update-check', _build_update_check_parser, argparse.SUPPRESS, None, None),
2824
+ _Reg('_telemetry-beat', _build_telemetry_beat_parser, argparse.SUPPRESS, None, None),
2825
+ _Reg('repair-symlinks', _build_repair_symlinks_parser, argparse.SUPPRESS, None, None),
2826
+ )
2827
+
2828
+
2829
+ def build_parser() -> argparse.ArgumentParser:
2830
+ c = _cctally()
2831
+ p = argparse.ArgumentParser(
2832
+ prog="cctally",
2717
2833
  formatter_class=CLIHelpFormatter,
2718
2834
  description=textwrap.dedent(
2719
2835
  """\
2720
- Internal subcommand: additively create any missing
2721
- ~/.local/bin/ symlinks for cctally subcommands (issue #114).
2722
-
2723
- Invoked best-effort by the npm postinstall on upgrade so new
2724
- cctally-* binaries become reachable without re-running
2725
- `cctally setup`. Gated to existing installs (>=1 symlink
2726
- already present); a fresh install is a silent no-op. Touches
2727
- only symlinks no hooks, settings.json, or cache. Refuses
2728
- from a dev checkout.
2836
+ Track Claude subscription weekly usage percent and weekly cost
2837
+ in a local SQLite database.
2838
+
2839
+ Data flow:
2840
+ 1) Claude Code status line captures rate limit data after each API call.
2841
+ 2) record-usage stores usage snapshots and triggers percent milestones.
2842
+ 3) sync-week computes weekly USD cost from Claude Code session data.
2843
+ 4) report computes dollars per 1% and shows trend history.
2844
+ """
2845
+ ),
2846
+ epilog=textwrap.dedent(
2847
+ """\
2848
+ Quick start:
2849
+ # Add record-usage call to ~/.claude/statusline-command.sh (see record-usage --help)
2850
+ cctally sync-week
2851
+ cctally report
2729
2852
  """
2730
2853
  ),
2731
2854
  )
2732
- rs.set_defaults(func=c.cmd_repair_symlinks)
2855
+ p.add_argument(
2856
+ "-v", "--version",
2857
+ action="store_true",
2858
+ default=argparse.SUPPRESS,
2859
+ help="Print cctally version (from CHANGELOG.md latest release header) and exit",
2860
+ )
2861
+ sub = p.add_subparsers(
2862
+ dest="command",
2863
+ required=False,
2864
+ title="commands",
2865
+ metavar="<command>",
2866
+ )
2867
+
2868
+ for _reg in _REGISTRATION:
2869
+ if _reg.predicate is not None and not _reg.predicate(c):
2870
+ continue
2871
+ _reg.builder(sub, _reg.name, help_text=_reg.help_text, xref=_reg.xref)
2733
2872
 
2734
- # Python 3.14 leaks `==SUPPRESS==` for hidden subparsers in --help; strip
2735
- # the pseudo-action so the row disappears entirely. (The choice still
2736
- # appears in the `{...}` choices header — there's no clean way to hide
2737
- # that without a custom formatter, and it's harmless.)
2738
2873
  sub._choices_actions = [
2739
2874
  a for a in getattr(sub, "_choices_actions", [])
2740
2875
  if getattr(a, "help", None) is not argparse.SUPPRESS
2741
2876
  ]
2742
-
2743
2877
  return p
2878
+