cctally 1.22.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2541 @@
1
+ """cctally CLI argument-parser construction (eager sibling).
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.
8
+
9
+ Spec: docs/superpowers/specs/2026-05-30-parser-share-extraction-design.md
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ import textwrap
16
+
17
+ from _cctally_core import WEEKDAY_MAP
18
+ from _lib_display_tz import _argparse_tz
19
+
20
+
21
+ def _cctally():
22
+ """Resolve the current `cctally` module at call-time (spec §5.5)."""
23
+ return sys.modules["cctally"]
24
+
25
+
26
+ def _nonneg_int(raw: str) -> int:
27
+ """argparse `type=` validator for non-negative integer flags (issue #89).
28
+
29
+ Used by ``--debug-samples`` so a negative N is rejected at parse time
30
+ rather than silently coerced inside the helper. Raises
31
+ ``argparse.ArgumentTypeError`` so argparse surfaces the message under
32
+ the standard ``argument <flag>:`` prefix.
33
+ """
34
+ try:
35
+ n = int(raw)
36
+ except ValueError:
37
+ raise argparse.ArgumentTypeError(
38
+ f"must be a non-negative integer, got '{raw}'"
39
+ )
40
+ if n < 0:
41
+ raise argparse.ArgumentTypeError(f"must be >= 0, got {n}")
42
+ return n
43
+
44
+
45
+ class CLIHelpFormatter(
46
+ argparse.ArgumentDefaultsHelpFormatter,
47
+ argparse.RawDescriptionHelpFormatter,
48
+ ):
49
+ """Human-friendly formatter for multi-line help and default values."""
50
+
51
+ def __init__(self, prog: str, **kwargs: object) -> None:
52
+ kwargs.setdefault("max_help_position", 30)
53
+ super().__init__(prog, **kwargs) # type: ignore[arg-type]
54
+
55
+
56
+ def _argparse_has_arg(parser, option_string: str) -> bool:
57
+ """Return True if ``parser`` already registered ``option_string``."""
58
+ for action in parser._actions:
59
+ if option_string in (action.option_strings or ()):
60
+ return True
61
+ return False
62
+
63
+
64
+ def _add_mode_arg(parser, *, noop: bool = False) -> None:
65
+ """Add ccusage's -m/--mode {auto,calculate,display} cost-source flag.
66
+
67
+ Standalone (not folded into _add_ccusage_alias_args) so it lands only
68
+ on the six Session-C reporting commands and never collides with
69
+ range-cost, which defines its own -m/--mode.
70
+
71
+ noop=True (five-hour-blocks only): the flag is accepted for surface
72
+ parity with `blocks` but does not alter numbers — that command's cost
73
+ is the authoritative materialized five_hour_blocks.total_cost_usd
74
+ computed at record-time (always auto semantics).
75
+ """
76
+ help_real = (
77
+ "Cost source: auto (recorded costUSD when present, else computed), "
78
+ "calculate (always compute from embedded pricing), display "
79
+ "(recorded costUSD only; $0 when absent). Default: auto."
80
+ )
81
+ help_noop = (
82
+ "Accepted for ccusage drop-in compat; no-op here — five-hour-blocks "
83
+ "cost is the authoritative materialized per-block value computed at "
84
+ "record-time. Default: auto."
85
+ )
86
+ parser.add_argument(
87
+ "-m", "--mode",
88
+ default="auto",
89
+ choices=["auto", "calculate", "display"],
90
+ help=help_noop if noop else help_real,
91
+ )
92
+
93
+
94
+ def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
95
+ """Attach the Session A ccusage alias surface to a Claude-cmd subparser.
96
+
97
+ Sibling to ``_add_codex_shared_args`` (declared inside ``build_parser``)
98
+ but tailored for Claude commands. Every flag is guarded with
99
+ ``_argparse_has_arg`` so existing per-parser declarations
100
+ (cache-report's ``--offline``, project / five-hour-blocks / diff's
101
+ ``--no-color``) do NOT cause ``argparse.ArgumentError`` — the helper
102
+ just skips the duplicate. This makes future collisions self-healing
103
+ when a contributor adds a Session A-managed flag directly on a
104
+ subparser.
105
+
106
+ Args:
107
+ parser: the subparser to mutate.
108
+ ansi_emit: ``True`` for project + diff (the 2 real ANSI emitters).
109
+ ``False`` for the other 8 in-scope cmds. Controls only
110
+ the ``--color`` help text and whether ``--no-color`` is
111
+ attempted as a fresh add (when ``ansi_emit=True`` we
112
+ skip ``--no-color`` entirely — those parsers already
113
+ declared it themselves).
114
+
115
+ Spec §7.1.2 / issue #86 Session A.
116
+ """
117
+
118
+ def _maybe_add(opt: str, *args, **kwargs):
119
+ if _argparse_has_arg(parser, opt):
120
+ return
121
+ parser.add_argument(opt, *args, **kwargs)
122
+
123
+ def _maybe_add2(opt1: str, opt2: str, *args, **kwargs):
124
+ # Two-form add (short + long) — skip if EITHER is present.
125
+ if _argparse_has_arg(parser, opt1) or _argparse_has_arg(parser, opt2):
126
+ return
127
+ parser.add_argument(opt1, opt2, *args, **kwargs)
128
+
129
+ _maybe_add2(
130
+ "-z", "--timezone", default=None, metavar="TZ",
131
+ help="Alias for --tz (drop-in compat with ccusage). When both "
132
+ "are supplied, --tz wins.",
133
+ )
134
+ _maybe_add2(
135
+ "-O", "--offline",
136
+ action=argparse.BooleanOptionalAction, default=False,
137
+ help="Accepted for ccusage drop-in compat; cctally is always offline.",
138
+ )
139
+ _maybe_add(
140
+ "--compact", action="store_true",
141
+ help="Force compact table layout regardless of terminal width.",
142
+ )
143
+ _maybe_add(
144
+ "--config", default=None, metavar="PATH",
145
+ help="Read config from PATH for this invocation only (no "
146
+ "mutation of the default config at "
147
+ "~/.local/share/cctally/config.json). Missing or invalid "
148
+ "PATH errors out with a clear message.",
149
+ )
150
+ _maybe_add2(
151
+ "-d", "--debug", action="store_true",
152
+ help="Emit a stderr 'Pricing Mismatch Debug Report' "
153
+ "(totals + per-model stats + sample discrepancies, "
154
+ "matching ccusage's --debug shape).",
155
+ )
156
+ _maybe_add(
157
+ "--debug-samples", type=_nonneg_int, default=5, metavar="N",
158
+ help="Cap on sample-discrepancy rows in the --debug report "
159
+ "(default 5; N=0 suppresses the sample block; "
160
+ "negatives rejected at parse time).",
161
+ )
162
+ _maybe_add(
163
+ "--single-thread", action="store_true",
164
+ help="Accepted for ccusage drop-in compat; cctally ingestion "
165
+ "is already single-threaded via the session-entry cache.",
166
+ )
167
+ if ansi_emit:
168
+ _maybe_add(
169
+ "--color", action="store_true", default=False,
170
+ help="Force ANSI color output (overrides NO_COLOR env). When "
171
+ "neither --color nor --no-color is set, color is auto-"
172
+ "detected from isatty() and NO_COLOR/FORCE_COLOR env.",
173
+ )
174
+ # --no-color already declared on these parsers; do nothing here.
175
+ else:
176
+ # No-op-for-compat surface (spec §7.3): these flags parse but do
177
+ # NOT flow through the color resolver on this command. Color (where
178
+ # the renderer emits any) follows the auto-detect — isatty() plus
179
+ # NO_COLOR / FORCE_COLOR env — so the help must NOT claim "no ANSI
180
+ # is emitted" (daily/monthly/weekly/blocks/session/cache-report DO
181
+ # emit auto-detected ANSI on a TTY; only the no-color env vars
182
+ # suppress it). Force/suppress color on the 2 real ANSI commands
183
+ # (project, diff) instead, or use NO_COLOR=1 / FORCE_COLOR=1.
184
+ _maybe_add(
185
+ "--color", action="store_true", default=False,
186
+ help="Accepted for ccusage drop-in compat; does not control "
187
+ "this command's color. Color auto-detects from isatty() "
188
+ "and honors NO_COLOR / FORCE_COLOR env.",
189
+ )
190
+ _maybe_add(
191
+ "--no-color", action="store_true", default=False,
192
+ help="Accepted for ccusage drop-in compat; does not suppress "
193
+ "this command's color. Use NO_COLOR=1 env (or pipe stdout) "
194
+ "to disable auto-detected ANSI.",
195
+ )
196
+
197
+
198
+ def _add_codex_shared_args(parser: argparse.ArgumentParser) -> None:
199
+ """Register upstream `ccusage-codex sharedArgs` on a codex subparser.
200
+
201
+ Upstream sharedArgs (node_modules/@ccusage/codex/dist/index.js):
202
+ --timezone/-z, --locale/-l, --compact, --color, --noColor,
203
+ --offline/--no-offline.
204
+
205
+ Honored here: --timezone (dates + aggregation buckets) and
206
+ --compact (table layout). Accepted-but-no-op (stored on the
207
+ namespace for drop-in parity with upstream scripts): --locale
208
+ (we don't locale-format dates), --color / --noColor (we don't
209
+ emit ANSI codes today). --offline is accepted as a no-op too
210
+ (we are always offline); it uses BooleanOptionalAction so
211
+ `--no-offline` also parses cleanly. `-O` is kept as the short
212
+ form for offline for backward compat with earlier builds.
213
+ """
214
+ parser.add_argument(
215
+ "-z", "--timezone", default=None, metavar="TZ",
216
+ help="IANA timezone for date bucketing and Date/Last Activity cells.",
217
+ )
218
+ parser.add_argument(
219
+ "-l", "--locale", default=None, metavar="LOCALE",
220
+ help="Accepted for drop-in compat; no-op (dates are not locale-formatted).",
221
+ )
222
+ parser.add_argument(
223
+ "--compact", action="store_true",
224
+ help="Force compact table layout regardless of terminal width.",
225
+ )
226
+ parser.add_argument(
227
+ "--color", action="store_true",
228
+ help="Accepted for drop-in compat; no-op today (no ANSI escapes are emitted).",
229
+ )
230
+ parser.add_argument(
231
+ "--noColor", action="store_true", dest="no_color",
232
+ help="Accepted for drop-in compat; no-op today (no ANSI escapes are emitted).",
233
+ )
234
+ parser.add_argument(
235
+ "-O", "--offline", action=argparse.BooleanOptionalAction, default=False,
236
+ help="Accepted for drop-in compat with ccusage-codex; we are always offline.",
237
+ )
238
+ parser.add_argument(
239
+ "--speed", choices=("auto", "standard", "fast"), default="auto",
240
+ help="Codex pricing tier. auto (default) reads service_tier from "
241
+ "~/.codex/config.toml (fast|priority -> fast pricing); fast "
242
+ "forces the fast-tier multiplier; standard forces base pricing.",
243
+ )
244
+ parser.add_argument(
245
+ "--tz", default=None, type=_argparse_tz, metavar="TZ",
246
+ help="Display timezone: local, utc, or IANA name. Overrides "
247
+ "config display.tz for this call. Takes precedence over "
248
+ "upstream's --timezone for drop-in parity.",
249
+ )
250
+ # Issue #92: codex parity for the #89 --debug surface. Codex JSONL
251
+ # has no recorded costUSD to diff against, so the report is the
252
+ # codex variant ("Codex Pricing Debug Report": totals + top-N
253
+ # highest computed-cost entries), wired via
254
+ # _emit_codex_debug_samples_if_set in each cmd_codex_* body.
255
+ parser.add_argument(
256
+ "-d", "--debug", action="store_true",
257
+ help="Emit a stderr 'Codex Pricing Debug Report' (totals + "
258
+ "the N highest computed-cost sample entries).",
259
+ )
260
+ parser.add_argument(
261
+ "--debug-samples", type=_nonneg_int, default=5, metavar="N",
262
+ help="Cap on top-entry sample rows in the --debug report "
263
+ "(default 5; N=0 suppresses the sample block; "
264
+ "negatives rejected at parse time).",
265
+ )
266
+
267
+
268
+ def _add_share_args(parser, *, has_status_line: bool = False) -> None:
269
+ """Attach shareable-reports flags + format/json mutex to a subparser.
270
+
271
+ Idempotent — call exactly once per subparser. Caller MUST remove any
272
+ pre-existing ``--json`` (and ``--status-line`` for forecast) from the
273
+ subparser before invoking this helper, so the mutex group owns those
274
+ flags. Raises ``RuntimeError`` on contract violation — surfaces at
275
+ parser-build time (i.e., on every CLI invocation, including ``--help``)
276
+ instead of at the user invocation that hits the unguarded
277
+ ``--format --json`` combo. The prior shape silently skipped re-adding,
278
+ leaving the mutex unenforced for any future 9th share-enabled subparser
279
+ whose existing ``--json`` was accidentally left in place.
280
+ """
281
+ if _argparse_has_arg(parser, "--json"):
282
+ raise RuntimeError(
283
+ f"_add_share_args: parser {parser.prog!r} already has --json; "
284
+ "remove it before calling _add_share_args so mutex applies"
285
+ )
286
+ if has_status_line and _argparse_has_arg(parser, "--status-line"):
287
+ raise RuntimeError(
288
+ f"_add_share_args: parser {parser.prog!r} already has --status-line; "
289
+ "remove it before calling _add_share_args(has_status_line=True)"
290
+ )
291
+ output_group = parser.add_mutually_exclusive_group()
292
+ output_group.add_argument(
293
+ "--format", choices=("md", "html", "svg"),
294
+ help="Render output as shareable markdown, self-contained HTML, or SVG. "
295
+ "Default destination: md->stdout, html/svg->~/Downloads file.")
296
+ output_group.add_argument(
297
+ "--json", action="store_true",
298
+ help="Emit machine-readable JSON; suppresses terminal render.")
299
+ if has_status_line:
300
+ output_group.add_argument(
301
+ "--status-line", action="store_true", dest="status_line",
302
+ help="Emit one-line compact string for status-line injection.")
303
+
304
+ parser.add_argument(
305
+ "--theme", choices=("light", "dark"), default="light",
306
+ help="Color theme for HTML/SVG (default: light). No-op for markdown.")
307
+ parser.add_argument(
308
+ "--no-branding", action="store_true", dest="no_branding",
309
+ help="Strip the 'Generated by cctally' footer from --format output.")
310
+ parser.add_argument(
311
+ "--output", metavar="PATH",
312
+ help="Write --format output to PATH instead of the default destination "
313
+ "(stdout for md; ~/Downloads/cctally-<cmd>-<utcdate>.<ext> for html/svg). "
314
+ "Use '-' for stdout.")
315
+ parser.add_argument(
316
+ "--copy", action="store_true",
317
+ help="Pipe --format md output to clipboard (pbcopy/xclip/clip). "
318
+ "Rejected for html/svg.")
319
+ parser.add_argument(
320
+ "--open", action="store_true", dest="open_after_write",
321
+ help="After writing --format html/svg to a file, open it in the default app. "
322
+ "Rejected for md.")
323
+
324
+
325
+ def _share_validate_args(args) -> None:
326
+ """Reject share flag combinations BEFORE any DB / sync / render work.
327
+
328
+ Two layers of validation:
329
+
330
+ 1. Share-only flags (``--output``, ``--copy``, ``--open``) require
331
+ ``--format``. Silent dropping trains users to assume the file
332
+ was written.
333
+
334
+ 2. Destination-shape combinations (``--copy`` + ``--output``,
335
+ ``--copy`` + non-md, ``--open`` + md, ``--open`` + ``--output -``).
336
+ These were previously caught only inside ``_resolve_destination``
337
+ / ``_share_render_and_emit`` — i.e. AFTER ``--sync-current`` had
338
+ already mutated the DB and the snapshot had been built. Surfacing
339
+ them at validation time means an exit-2 flag-shape error never
340
+ triggers side effects.
341
+
342
+ Exit 2 with a stderr message naming the offending combo so the
343
+ failure is loud and scriptable. Idempotent; safe to call from every
344
+ share-enabled subcommand before the ``--format`` gate. Existing
345
+ late checks in ``_resolve_destination`` / ``_share_render_and_emit``
346
+ are kept as defense-in-depth for any future caller that bypasses
347
+ this helper.
348
+ """
349
+ if not getattr(args, "format", None):
350
+ offenders = []
351
+ if getattr(args, "output", None):
352
+ offenders.append("--output")
353
+ if getattr(args, "copy", False):
354
+ offenders.append("--copy")
355
+ if getattr(args, "open_after_write", False):
356
+ offenders.append("--open")
357
+ if not offenders:
358
+ return
359
+ verb = "requires" if len(offenders) == 1 else "require"
360
+ sys.stderr.write(
361
+ f"cctally: {', '.join(offenders)} {verb} --format\n"
362
+ )
363
+ sys.exit(2)
364
+
365
+ # --format is set — validate destination-shape combos.
366
+ fmt = args.format
367
+ copy = getattr(args, "copy", False)
368
+ output = getattr(args, "output", None)
369
+ open_after_write = getattr(args, "open_after_write", False)
370
+
371
+ if copy and output is not None:
372
+ # Mutex: a clipboard destination by definition has no path.
373
+ sys.stderr.write(
374
+ "cctally: --copy is mutually exclusive with --output\n"
375
+ )
376
+ sys.exit(2)
377
+ if copy and fmt != "md":
378
+ sys.stderr.write(
379
+ "cctally: --copy is only valid with --format md\n"
380
+ )
381
+ sys.exit(2)
382
+ if open_after_write and fmt == "md":
383
+ sys.stderr.write(
384
+ "cctally: --open is only valid with --format html or --format svg\n"
385
+ )
386
+ sys.exit(2)
387
+ if open_after_write and output == "-":
388
+ # Open-after-write to stdout has no file to launch — was a silent
389
+ # no-op pre-fix; now an explicit exit 2 so users notice.
390
+ sys.stderr.write(
391
+ "cctally: --open is incompatible with --output - (no file to open)\n"
392
+ )
393
+ sys.exit(2)
394
+
395
+
396
+ def _build_daily_parser(subparsers, name, *, help_text, xref):
397
+ """Build the `daily` leaf parser (issue #86 Session B; routes to cmd_daily).
398
+
399
+ Build-once, register-twice: this body is the verbatim former inline `daily`
400
+ construction, parameterized only by `name`, the parent-list `help_text`, and
401
+ the `xref` appended to `description` (renders on `cctally <name> --help`).
402
+ """
403
+ c = _cctally()
404
+ p = subparsers.add_parser(
405
+ name,
406
+ help=help_text,
407
+ formatter_class=CLIHelpFormatter,
408
+ description="Show usage grouped by date, matching upstream ccusage daily output."
409
+ "\n\n" + xref,
410
+ epilog=textwrap.dedent("""\
411
+ Examples:
412
+ cctally daily --since 20260414
413
+ cctally daily --since 20260410 --until 20260416
414
+ cctally daily --since 20260414 --breakdown
415
+ cctally daily --since 20260414 --json
416
+ cctally daily --order desc
417
+ cctally daily --instances
418
+ cctally daily -i --project-aliases repos=Repos
419
+ """),
420
+ )
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
+ )
433
+ p.add_argument(
434
+ "-b", "--breakdown",
435
+ action="store_true",
436
+ help="Show per-model cost breakdown sub-rows.",
437
+ )
438
+ p.add_argument(
439
+ "-o", "--order",
440
+ choices=("asc", "desc"),
441
+ default="asc",
442
+ help="Sort direction by date (default: asc).",
443
+ )
444
+ p.add_argument(
445
+ "--reveal-projects",
446
+ action="store_true",
447
+ dest="reveal_projects",
448
+ help="In --format output, show real project basenames instead of "
449
+ "the default project-1, project-2, ... anonymization.",
450
+ )
451
+ p.add_argument(
452
+ "--tz", default=None, type=_argparse_tz, metavar="TZ",
453
+ help="Display timezone: local, utc, or IANA name. "
454
+ "Overrides config display.tz for this call.",
455
+ )
456
+ p.add_argument(
457
+ "-i", "--instances",
458
+ action="store_true",
459
+ default=False,
460
+ help="Group the report by project (git-root).",
461
+ )
462
+ p.add_argument(
463
+ "-p", "--project",
464
+ action="append",
465
+ default=None,
466
+ metavar="PATTERN",
467
+ help="Filter to projects matching PATTERN (substring of the project "
468
+ "label or path; repeatable, OR semantics).",
469
+ )
470
+ p.add_argument(
471
+ "--project-aliases",
472
+ dest="project_aliases",
473
+ default=None,
474
+ metavar="PAIRS",
475
+ help="Comma-separated key=Label pairs overriding project display "
476
+ "labels (e.g. cctally-dev=Tracker). Display-only.",
477
+ )
478
+ _add_ccusage_alias_args(p, ansi_emit=False)
479
+ _add_mode_arg(p)
480
+ _add_share_args(p)
481
+ p.set_defaults(func=c.cmd_daily)
482
+ return p
483
+
484
+
485
+ def _build_monthly_parser(subparsers, name, *, help_text, xref):
486
+ """Build the `monthly` leaf parser (issue #86 Session B; routes to cmd_monthly)."""
487
+ c = _cctally()
488
+ p = subparsers.add_parser(
489
+ name,
490
+ help=help_text,
491
+ formatter_class=CLIHelpFormatter,
492
+ description="Show usage grouped by calendar month, matching upstream ccusage monthly output."
493
+ "\n\n" + xref,
494
+ epilog=textwrap.dedent("""\
495
+ Examples:
496
+ cctally monthly --since 20260101
497
+ cctally monthly --since 20260101 --until 20260331
498
+ cctally monthly --since 20260101 --breakdown
499
+ cctally monthly --since 20260101 --json
500
+ cctally monthly --order desc
501
+ """),
502
+ )
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
+ )
515
+ p.add_argument(
516
+ "-b", "--breakdown",
517
+ action="store_true",
518
+ help="Show per-model cost breakdown sub-rows.",
519
+ )
520
+ p.add_argument(
521
+ "-o", "--order",
522
+ choices=("asc", "desc"),
523
+ default="asc",
524
+ help="Sort direction by month (default: asc).",
525
+ )
526
+ p.add_argument(
527
+ "--reveal-projects",
528
+ action="store_true",
529
+ dest="reveal_projects",
530
+ help="In --format output, show real project basenames instead of "
531
+ "the default project-1, project-2, ... anonymization.",
532
+ )
533
+ p.add_argument(
534
+ "--tz", default=None, type=_argparse_tz, metavar="TZ",
535
+ help="Display timezone: local, utc, or IANA name. "
536
+ "Overrides config display.tz for this call.",
537
+ )
538
+ _add_ccusage_alias_args(p, ansi_emit=False)
539
+ _add_mode_arg(p)
540
+ _add_share_args(p)
541
+ p.set_defaults(func=c.cmd_monthly)
542
+ return p
543
+
544
+
545
+ def _build_weekly_parser(subparsers, name, *, help_text, xref):
546
+ """Build the `weekly` leaf parser (issue #86 Session B; routes to cmd_weekly)."""
547
+ c = _cctally()
548
+ p = subparsers.add_parser(
549
+ name,
550
+ help=help_text,
551
+ formatter_class=CLIHelpFormatter,
552
+ description="Show Claude usage grouped by subscription week. Boundaries are anchored "
553
+ "to weekly_usage_snapshots.week_start_at with 7-day-cadence extrapolation "
554
+ "for pre-snapshot history. Columns extend daily/monthly's set with Used % "
555
+ "and $/1%."
556
+ "\n\n" + xref,
557
+ epilog=textwrap.dedent("""\
558
+ Examples:
559
+ cctally weekly
560
+ cctally weekly --since 20260101
561
+ cctally weekly --breakdown
562
+ cctally weekly --json
563
+ cctally weekly --order desc
564
+ """),
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).")
570
+ p.add_argument("-b", "--breakdown", action="store_true",
571
+ help="Show per-model cost breakdown sub-rows.")
572
+ p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
573
+ help="Sort direction by week (default: asc).")
574
+ p.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
575
+ help="In --format output, show real project basenames instead of "
576
+ "the default project-1, project-2, ... anonymization.")
577
+ p.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
578
+ help="Display timezone: local, utc, or IANA name. "
579
+ "Overrides config display.tz for this call.")
580
+ _add_ccusage_alias_args(p, ansi_emit=False)
581
+ _add_mode_arg(p)
582
+ _add_share_args(p)
583
+ p.set_defaults(func=c.cmd_weekly)
584
+ return p
585
+
586
+
587
+ def _build_session_parser(subparsers, name, *, help_text, xref):
588
+ """Build the `session` leaf parser (issue #86 Session B; routes to cmd_session)."""
589
+ c = _cctally()
590
+ p = subparsers.add_parser(
591
+ name,
592
+ help=help_text,
593
+ formatter_class=CLIHelpFormatter,
594
+ description="Show Claude usage grouped by JSONL sessionId. Resumed sessions (same "
595
+ "sessionId across multiple files) collapse into one row. 11-column "
596
+ "layout paralleling codex-session."
597
+ "\n\n" + xref,
598
+ epilog=textwrap.dedent("""\
599
+ Examples:
600
+ cctally session
601
+ cctally session --since 20260401
602
+ cctally session --since 20260401 --breakdown
603
+ cctally session --json
604
+ cctally session --order desc
605
+ """),
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).")
611
+ p.add_argument("-b", "--breakdown", action="store_true",
612
+ help="Show per-model cost breakdown sub-rows.")
613
+ p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
614
+ help="Sort direction by last activity (default: asc — earliest first).")
615
+ p.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
616
+ help="In --format output, show real project basenames instead of "
617
+ "the default project-1, project-2, ... anonymization.")
618
+ p.add_argument("--top-n", type=int, default=15, dest="top_n",
619
+ metavar="N",
620
+ help="In --format output, cap rows to top N by cost (default: 15). "
621
+ "Must be >= 1; values above 50 emit a readability warning. "
622
+ "Has no effect on terminal/JSON output.")
623
+ p.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
624
+ help="Display timezone: local, utc, or IANA name. "
625
+ "Overrides config display.tz for this call.")
626
+ p.add_argument(
627
+ "-i", "--id", default=None, metavar="SESSION_ID", dest="id",
628
+ help="Filter to a single session by exact-string sessionId. "
629
+ "Match is against the post-resume-merge id (sessions "
630
+ "resumed across multiple JSONL files collapse to one id). "
631
+ "Unknown id → exit 0 with the empty-render branch.",
632
+ )
633
+ _add_ccusage_alias_args(p, ansi_emit=False)
634
+ _add_mode_arg(p)
635
+ _add_share_args(p)
636
+ p.set_defaults(func=c.cmd_session)
637
+ return p
638
+
639
+
640
+ def _build_blocks_parser(subparsers, name, *, help_text, xref):
641
+ """Build the `blocks` leaf parser (issue #86 Session B; routes to cmd_blocks).
642
+
643
+ Note: `blocks` intentionally has NO `_add_share_args` (matches the former
644
+ inline block — it is not part of the shareable-output flag surface).
645
+ """
646
+ c = _cctally()
647
+ p = subparsers.add_parser(
648
+ name,
649
+ help=help_text,
650
+ formatter_class=CLIHelpFormatter,
651
+ description="Show usage grouped by 5-hour session blocks, matching upstream ccusage blocks output."
652
+ "\n\n" + xref,
653
+ epilog=textwrap.dedent("""\
654
+ Examples:
655
+ cctally blocks --since 20260414
656
+ cctally blocks --since 20260410 --until 20260416
657
+ cctally blocks --since 20260414 --breakdown
658
+ cctally blocks --since 20260414 --json
659
+ """),
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
+ )
673
+ p.add_argument(
674
+ "-b", "--breakdown",
675
+ action="store_true",
676
+ help="Show per-model cost breakdown.",
677
+ )
678
+ p.add_argument(
679
+ "--json",
680
+ action="store_true",
681
+ dest="json",
682
+ help="Output JSON matching upstream ccusage blocks format.",
683
+ )
684
+ p.add_argument(
685
+ "--tz", default=None, type=_argparse_tz, metavar="TZ",
686
+ help="Display timezone: local, utc, or IANA name. "
687
+ "Overrides config display.tz for this call.",
688
+ )
689
+ p.add_argument(
690
+ "-a", "--active", action="store_true",
691
+ help="Show only the active block, with burn-rate + projection "
692
+ "(ccusage drop-in).",
693
+ )
694
+ p.add_argument(
695
+ "-r", "--recent", action="store_true",
696
+ help="Show only blocks from the last 3 days (plus the active block).",
697
+ )
698
+ p.add_argument(
699
+ "-t", "--token-limit", dest="token_limit", default=None,
700
+ metavar="N|max",
701
+ help="Token limit for the quota %% column / projection warnings. "
702
+ "An integer, or 'max' (default) to derive from the largest "
703
+ "completed block.",
704
+ )
705
+ p.add_argument(
706
+ "-n", "--session-length", dest="session_length", type=float,
707
+ default=5.0, metavar="N",
708
+ help="Accepted for ccusage drop-in compat; no-op — cctally blocks "
709
+ "follow Anthropic's real 5-hour resets and are not re-sizable. "
710
+ "A value <= 0 is rejected.",
711
+ )
712
+ _add_ccusage_alias_args(p, ansi_emit=False)
713
+ _add_mode_arg(p)
714
+ p.set_defaults(func=c.cmd_blocks)
715
+ return p
716
+
717
+
718
+ def _build_statusline_parser(subparsers, name, *, help_text, xref):
719
+ """Build the `statusline` (or `claude statusline`) leaf parser.
720
+
721
+ Registered TWICE per the Session B build-once register-twice pattern:
722
+ once on the flat ``cctally statusline`` subparser, once under the
723
+ nested ``cctally claude statusline`` subgroup. Output is byte-identical
724
+ between the two forms; only ``--help`` text differs (the ``xref``
725
+ paragraph appended to ``description``).
726
+ """
727
+ c = _cctally()
728
+ p = subparsers.add_parser(
729
+ name,
730
+ help=help_text,
731
+ formatter_class=CLIHelpFormatter,
732
+ description=(
733
+ "Display a compact one-line status for Claude Code hooks "
734
+ "(ccusage drop-in + cctally extensions).\n\n" + xref
735
+ ),
736
+ )
737
+ # ccusage-shape flags
738
+ p.add_argument(
739
+ "-B", "--visual-burn-rate",
740
+ dest="visual_burn_rate",
741
+ default=None,
742
+ choices=["off", "emoji", "text", "emoji-text"],
743
+ help="Burn-rate visualization (default: off; config key "
744
+ "statusline.visual_burn_rate).",
745
+ )
746
+ # NOTE: `ccusage` is intentionally NOT in `choices=` so it doesn't
747
+ # appear in `--help` advertised options. Argparse runs the `choices`
748
+ # check BEFORE the action's `__call__`, so we cannot list `ccusage`
749
+ # in `choices=` AND catch it in the action. Instead we omit `choices=`
750
+ # entirely, manually validate inside the action, and re-raise the
751
+ # spec's rename hint via `parser.error` for `ccusage` specifically.
752
+ # Help text below hardcodes the legal set so users still see it in
753
+ # `--help`. A typo like `ccussage` falls through to a standard
754
+ # argparse-style "invalid choice" error from `parser.error`.
755
+ class _CostSourceAction(argparse.Action):
756
+ _ACCEPTED = ("auto", "cctally", "cc", "both")
757
+ _RENAMED = "ccusage"
758
+
759
+ def __call__(self, parser, namespace, values, option_string=None):
760
+ if values == self._RENAMED:
761
+ parser.error(
762
+ f"argument {option_string}: invalid choice: "
763
+ f"{values!r} — cctally renamed it; try "
764
+ f"--cost-source cctally"
765
+ )
766
+ if values not in self._ACCEPTED:
767
+ parser.error(
768
+ f"argument {option_string}: invalid choice: "
769
+ f"{values!r} (choose from "
770
+ + ", ".join(repr(c) for c in self._ACCEPTED)
771
+ + ")"
772
+ )
773
+ setattr(namespace, self.dest, values)
774
+
775
+ p.add_argument(
776
+ "--cost-source",
777
+ dest="cost_source",
778
+ default=None,
779
+ action=_CostSourceAction,
780
+ metavar="{auto,cctally,cc,both}",
781
+ help="Session cost source (default: auto; config key "
782
+ "statusline.cost_source). Note: 'ccusage' errors with a "
783
+ "rename hint — use 'cctally' instead.",
784
+ )
785
+ p.add_argument(
786
+ "--cache",
787
+ dest="cache",
788
+ action="store_true",
789
+ default=None,
790
+ help="Accepted for ccusage drop-in compat; cctally renders from "
791
+ "cache.db directly without an extra output cache.",
792
+ )
793
+ p.add_argument(
794
+ "--no-cache",
795
+ dest="cache",
796
+ action="store_false",
797
+ help="(no-op alias)",
798
+ )
799
+ p.add_argument(
800
+ "--refresh-interval",
801
+ dest="refresh_interval",
802
+ default=1,
803
+ type=int,
804
+ metavar="N",
805
+ help="(no-op alias) Accepted for ccusage drop-in compat.",
806
+ )
807
+ p.add_argument(
808
+ "--context-low-threshold",
809
+ dest="context_low_threshold",
810
+ default=50,
811
+ type=int,
812
+ metavar="N",
813
+ help="Below this %% → segment 4 green (default: 50, 0-100).",
814
+ )
815
+ p.add_argument(
816
+ "--context-medium-threshold",
817
+ dest="context_medium_threshold",
818
+ default=80,
819
+ type=int,
820
+ metavar="N",
821
+ help="Below this %% → segment 4 yellow; else red (default: 80, 0-100).",
822
+ )
823
+ p.add_argument(
824
+ "-z", "--timezone",
825
+ dest="timezone",
826
+ default=None,
827
+ metavar="TZ",
828
+ help="Display tz (IANA) for `today` calendar day. Overrides "
829
+ "display.tz config.",
830
+ )
831
+ p.add_argument(
832
+ "-O", "--offline",
833
+ dest="offline",
834
+ action="store_true",
835
+ default=True,
836
+ help="(no-op alias) cctally is always offline.",
837
+ )
838
+ p.add_argument(
839
+ "--no-offline",
840
+ dest="offline",
841
+ action="store_false",
842
+ help="(no-op alias)",
843
+ )
844
+ p.add_argument(
845
+ "--color",
846
+ dest="color",
847
+ action="store_true",
848
+ default=None,
849
+ help="Force ANSI colors on (default: auto via NO_COLOR + TTY).",
850
+ )
851
+ p.add_argument(
852
+ "--no-color",
853
+ dest="color",
854
+ action="store_false",
855
+ help="Force ANSI colors off.",
856
+ )
857
+ p.add_argument(
858
+ "--cctally-extensions",
859
+ dest="cctally_extensions",
860
+ action="store_true",
861
+ default=None,
862
+ help="Append cctally 5h%%/7d%% segment (default: on; config key "
863
+ "statusline.cctally_extensions).",
864
+ )
865
+ p.add_argument(
866
+ "--no-cctally-extensions",
867
+ dest="cctally_extensions",
868
+ action="store_false",
869
+ help="Suppress cctally 5h%%/7d%% segment.",
870
+ )
871
+ p.add_argument(
872
+ "--config",
873
+ dest="config",
874
+ default=None,
875
+ metavar="PATH",
876
+ help="Read config from PATH for this invocation only (no "
877
+ "mutation of the default config). Missing/invalid PATH "
878
+ "exits 2.",
879
+ )
880
+ p.add_argument(
881
+ "--single-thread",
882
+ dest="single_thread",
883
+ action="store_true",
884
+ help="(no-op alias) cctally is always single-threaded via the "
885
+ "session-entry cache.",
886
+ )
887
+ p.add_argument(
888
+ "-d", "--debug",
889
+ dest="debug",
890
+ action="store_true",
891
+ help="Emit pricing-mismatch / config diagnostics on stderr.",
892
+ )
893
+ p.set_defaults(func=c.cmd_statusline, command=name)
894
+ return p
895
+
896
+
897
+ def _build_codex_daily_parser(subparsers, name, *, help_text, xref):
898
+ """Build the `codex-daily` leaf parser (issue #86 Session B; routes to cmd_codex_daily)."""
899
+ c = _cctally()
900
+ p = subparsers.add_parser(
901
+ name,
902
+ help=help_text,
903
+ formatter_class=CLIHelpFormatter,
904
+ description="Show Codex usage grouped by date, matching upstream ccusage-codex daily output."
905
+ "\n\n" + xref,
906
+ epilog=textwrap.dedent("""\
907
+ Examples:
908
+ cctally codex-daily --since 20260401
909
+ cctally codex-daily --since 20260401 --breakdown
910
+ cctally codex-daily --since 20260401 --json
911
+ cctally codex-daily --order desc
912
+ """),
913
+ )
914
+ p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
915
+ help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
916
+ p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
917
+ help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
918
+ p.add_argument("-b", "--breakdown", action="store_true",
919
+ help="Show per-model cost breakdown sub-rows.")
920
+ p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
921
+ help="Sort direction by date (default: asc).")
922
+ p.add_argument("--json", action="store_true", dest="json",
923
+ help="Output JSON matching upstream ccusage-codex daily format.")
924
+ _add_codex_shared_args(p)
925
+ p.set_defaults(func=c.cmd_codex_daily)
926
+ return p
927
+
928
+
929
+ def _build_codex_monthly_parser(subparsers, name, *, help_text, xref):
930
+ """Build the `codex-monthly` leaf parser (issue #86 Session B; routes to cmd_codex_monthly)."""
931
+ c = _cctally()
932
+ p = subparsers.add_parser(
933
+ name,
934
+ help=help_text,
935
+ formatter_class=CLIHelpFormatter,
936
+ description="Show Codex usage grouped by calendar month, matching upstream ccusage-codex monthly output."
937
+ "\n\n" + xref,
938
+ epilog=textwrap.dedent("""\
939
+ Examples:
940
+ cctally codex-monthly --since 20260101
941
+ cctally codex-monthly --breakdown
942
+ cctally codex-monthly --json
943
+ """),
944
+ )
945
+ p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
946
+ help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
947
+ p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
948
+ help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
949
+ p.add_argument("-b", "--breakdown", action="store_true",
950
+ help="Show per-model cost breakdown sub-rows.")
951
+ p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
952
+ help="Sort direction by month (default: asc).")
953
+ p.add_argument("--json", action="store_true", dest="json",
954
+ help="Output JSON matching upstream ccusage-codex monthly format.")
955
+ _add_codex_shared_args(p)
956
+ p.set_defaults(func=c.cmd_codex_monthly)
957
+ return p
958
+
959
+
960
+ def _build_codex_weekly_parser(subparsers, name, *, help_text, xref):
961
+ """Build the `codex-weekly` leaf parser (issue #86 Session B; routes to cmd_codex_weekly)."""
962
+ c = _cctally()
963
+ p = subparsers.add_parser(
964
+ name,
965
+ help=help_text,
966
+ formatter_class=CLIHelpFormatter,
967
+ description="Show Codex usage grouped by week. Week-start day is read from config.json "
968
+ "(collector.week_start, Monday default). Not a ccusage-codex drop-in — "
969
+ "upstream has no `codex weekly` command."
970
+ "\n\n" + xref,
971
+ epilog=textwrap.dedent("""\
972
+ Examples:
973
+ cctally codex-weekly
974
+ cctally codex-weekly --since 20260301
975
+ cctally codex-weekly --breakdown
976
+ cctally codex-weekly --json
977
+ cctally codex-weekly --order desc
978
+ """),
979
+ )
980
+ p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
981
+ help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
982
+ p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
983
+ help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
984
+ p.add_argument("-b", "--breakdown", action="store_true",
985
+ help="Show per-model cost breakdown sub-rows.")
986
+ p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
987
+ help="Sort direction by week (default: asc).")
988
+ p.add_argument("--json", action="store_true", dest="json",
989
+ help="Output JSON.")
990
+ _add_codex_shared_args(p)
991
+ p.set_defaults(func=c.cmd_codex_weekly)
992
+ return p
993
+
994
+
995
+ def _build_codex_session_parser(subparsers, name, *, help_text, xref):
996
+ """Build the `codex-session` leaf parser (issue #86 Session B; routes to cmd_codex_session)."""
997
+ c = _cctally()
998
+ p = subparsers.add_parser(
999
+ name,
1000
+ help=help_text,
1001
+ formatter_class=CLIHelpFormatter,
1002
+ description="Show Codex usage grouped by session, matching upstream ccusage-codex session output."
1003
+ "\n\n" + xref,
1004
+ epilog=textwrap.dedent("""\
1005
+ Examples:
1006
+ cctally codex-session
1007
+ cctally codex-session --since 20260401
1008
+ cctally codex-session --json
1009
+ """),
1010
+ )
1011
+ p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
1012
+ help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
1013
+ p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
1014
+ help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
1015
+ p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
1016
+ help="Sort direction by last activity (default: asc — earliest first).")
1017
+ p.add_argument("--json", action="store_true", dest="json",
1018
+ help="Output JSON matching upstream ccusage-codex session format.")
1019
+ _add_codex_shared_args(p)
1020
+ p.set_defaults(func=c.cmd_codex_session)
1021
+ return p
1022
+
1023
+
1024
+ def build_parser() -> argparse.ArgumentParser:
1025
+ c = _cctally()
1026
+ p = argparse.ArgumentParser(
1027
+ prog="cctally",
1028
+ formatter_class=CLIHelpFormatter,
1029
+ description=textwrap.dedent(
1030
+ """\
1031
+ Track Claude subscription weekly usage percent and weekly cost
1032
+ in a local SQLite database.
1033
+
1034
+ Data flow:
1035
+ 1) Claude Code status line captures rate limit data after each API call.
1036
+ 2) record-usage stores usage snapshots and triggers percent milestones.
1037
+ 3) sync-week computes weekly USD cost from Claude Code session data.
1038
+ 4) report computes dollars per 1% and shows trend history.
1039
+ """
1040
+ ),
1041
+ epilog=textwrap.dedent(
1042
+ """\
1043
+ Quick start:
1044
+ # Add record-usage call to ~/.claude/statusline-command.sh (see record-usage --help)
1045
+ cctally sync-week
1046
+ cctally report
1047
+ """
1048
+ ),
1049
+ )
1050
+ p.add_argument(
1051
+ "-v", "--version",
1052
+ action="store_true",
1053
+ default=argparse.SUPPRESS,
1054
+ help="Print cctally version (from CHANGELOG.md latest release header) and exit",
1055
+ )
1056
+ sub = p.add_subparsers(
1057
+ dest="command",
1058
+ required=False,
1059
+ title="commands",
1060
+ metavar="<command>",
1061
+ )
1062
+
1063
+ py = sub.add_parser(
1064
+ "sync-week",
1065
+ help="Compute weekly cost from session data and store in SQLite",
1066
+ formatter_class=CLIHelpFormatter,
1067
+ description=textwrap.dedent(
1068
+ """\
1069
+ Compute and store weekly cost (USD) for a selected week window.
1070
+
1071
+ Week selection priority:
1072
+ 1) Explicit --week-start/--week-end (date based)
1073
+ 2) Latest usage snapshot weekStartAt/weekEndAt (hour-accurate)
1074
+ 3) Current week from configured week-start rule
1075
+ """
1076
+ ),
1077
+ epilog=textwrap.dedent(
1078
+ """\
1079
+ Examples:
1080
+ cctally sync-week
1081
+ cctally sync-week --week-start 2026-02-05 --week-end 2026-02-12
1082
+ cctally sync-week --mode calculate --offline --json
1083
+ """
1084
+ ),
1085
+ )
1086
+ py.add_argument(
1087
+ "--week-start",
1088
+ default=None,
1089
+ metavar="YYYY-MM-DD",
1090
+ help="Explicit week start date. If --week-end is omitted, uses start + 6 days.",
1091
+ )
1092
+ py.add_argument(
1093
+ "--week-end",
1094
+ default=None,
1095
+ metavar="YYYY-MM-DD",
1096
+ help="Explicit week end date (inclusive date for custom windows).",
1097
+ )
1098
+ py.add_argument(
1099
+ "--week-start-name",
1100
+ default=None,
1101
+ choices=list(WEEKDAY_MAP.keys()),
1102
+ help="Week-start day used when explicit/custom boundaries are not available.",
1103
+ )
1104
+ py.add_argument(
1105
+ "--mode",
1106
+ default="auto",
1107
+ choices=["auto", "calculate", "display"],
1108
+ help="Cost calculation mode: auto, calculate, or display.",
1109
+ )
1110
+ py.add_argument(
1111
+ "--offline",
1112
+ action="store_true",
1113
+ help="Use embedded pricing data (no-op, always used).",
1114
+ )
1115
+ py.add_argument(
1116
+ "--project",
1117
+ default=None,
1118
+ help="Optional project filter for cost calculation.",
1119
+ )
1120
+ py.add_argument(
1121
+ "--json",
1122
+ action="store_true",
1123
+ help="Emit machine-readable JSON output.",
1124
+ )
1125
+ py.add_argument(
1126
+ "--quiet",
1127
+ action="store_true",
1128
+ help="Suppress human-readable output (no effect with --json).",
1129
+ )
1130
+ py.set_defaults(func=c.cmd_sync_week)
1131
+
1132
+ pr = sub.add_parser(
1133
+ "report",
1134
+ help="Show current and trend dollars-per-1%% statistics",
1135
+ formatter_class=CLIHelpFormatter,
1136
+ description=textwrap.dedent(
1137
+ """\
1138
+ Report current and historical dollars per 1% weekly usage.
1139
+
1140
+ For each week, report joins:
1141
+ - latest usage snapshot (%)
1142
+ - latest cost snapshot (USD)
1143
+ then computes USD / percent.
1144
+ """
1145
+ ),
1146
+ epilog=textwrap.dedent(
1147
+ """\
1148
+ Examples:
1149
+ cctally report
1150
+ cctally report --sync-current
1151
+ cctally report --weeks 12 --json
1152
+ """
1153
+ ),
1154
+ )
1155
+ pr.add_argument(
1156
+ "--weeks",
1157
+ type=int,
1158
+ default=8,
1159
+ help="How many recent week windows to include in the trend.",
1160
+ )
1161
+ pr.add_argument(
1162
+ "--sync-current",
1163
+ action="store_true",
1164
+ help="Run sync-week first, then generate the report.",
1165
+ )
1166
+ pr.add_argument(
1167
+ "--week-start-name",
1168
+ default=None,
1169
+ choices=list(WEEKDAY_MAP.keys()),
1170
+ help="Week-start day used if report falls back to date-only week logic.",
1171
+ )
1172
+ pr.add_argument(
1173
+ "--mode",
1174
+ default="auto",
1175
+ choices=["auto", "calculate", "display"],
1176
+ help="Mode passed to sync-week when --sync-current is used.",
1177
+ )
1178
+ pr.add_argument(
1179
+ "--offline",
1180
+ action="store_true",
1181
+ help="Pass --offline to sync-week when --sync-current is used.",
1182
+ )
1183
+ pr.add_argument(
1184
+ "--project",
1185
+ default=None,
1186
+ help="Project filter passed to sync-week when --sync-current is used.",
1187
+ )
1188
+ pr.add_argument(
1189
+ "--reveal-projects",
1190
+ action="store_true",
1191
+ dest="reveal_projects",
1192
+ help="In --format output, show real project basenames instead of "
1193
+ "the default project-1, project-2, ... anonymization.",
1194
+ )
1195
+ pr.add_argument(
1196
+ "--detail",
1197
+ action="store_true",
1198
+ help="Include per-percent cost milestones for the current week.",
1199
+ )
1200
+ pr.add_argument(
1201
+ "--tz", default=None, type=_argparse_tz, metavar="TZ",
1202
+ help="Display timezone: local, utc, or IANA name. "
1203
+ "Overrides config display.tz for this call.",
1204
+ )
1205
+ _add_share_args(pr)
1206
+ pr.set_defaults(func=c.cmd_report)
1207
+
1208
+ fc = sub.add_parser(
1209
+ "forecast",
1210
+ help="Project current-week usage to reset; show daily budgets",
1211
+ formatter_class=CLIHelpFormatter,
1212
+ description=textwrap.dedent(
1213
+ """\
1214
+ Forecast end-of-week usage % and daily $ / % budgets to stay under
1215
+ target ceilings (default 100% and 90%). Reads current-week
1216
+ `weekly_usage_snapshots` + `session_entries`; never writes.
1217
+ """
1218
+ ),
1219
+ epilog=textwrap.dedent(
1220
+ """\
1221
+ Examples:
1222
+ cctally forecast
1223
+ cctally forecast --json
1224
+ cctally forecast --status-line --no-sync
1225
+ cctally forecast --targets 100,95,85
1226
+
1227
+ Status-line integration (add to ~/.claude/statusline-command.sh):
1228
+ forecast_seg=$(cctally forecast --status-line --no-sync 2>/dev/null)
1229
+ # ...then include "$forecast_seg" in your prompt composition.
1230
+ """
1231
+ ),
1232
+ )
1233
+ fc.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
1234
+ help="In --format output, show real project basenames instead of "
1235
+ "the default project-1, project-2, ... anonymization.")
1236
+ fc.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
1237
+ help="Display timezone: local, utc, or IANA name. "
1238
+ "Overrides config display.tz for this call.")
1239
+ fc.add_argument("--targets", default="100,90",
1240
+ help="Comma-separated integer ceilings (default: 100,90).")
1241
+ fc.add_argument("--explain", action="store_true",
1242
+ help="Append rationale footer with rate values and source captions.")
1243
+ fc.add_argument("--no-sync", action="store_true", dest="no_sync",
1244
+ help="Skip sync_cache(); recommended for status-line use.")
1245
+ fc.add_argument("--color", choices=("auto", "always", "never"), default="auto",
1246
+ help="Color output control (also honors NO_COLOR).")
1247
+ # Dev-only: override "now" for deterministic fixture tests. Hidden from --help.
1248
+ fc.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
1249
+ _add_share_args(fc, has_status_line=True)
1250
+ fc.set_defaults(func=c.cmd_forecast)
1251
+
1252
+ # budget — cctally-original (NOT a ccusage drop-in), so flat surface only;
1253
+ # no claude/codex subgroup. `--config` is honored read-only on bare status
1254
+ # but rejected on set/unset (F4). `--reveal-projects` is accepted for share
1255
+ # surface parity but inert (no per-project axis). `--tz` follows the sibling
1256
+ # reporting commands' precedence.
1257
+ bg = sub.add_parser(
1258
+ "budget",
1259
+ help="Weekly equivalent-$ budget + pace + spend alerts",
1260
+ formatter_class=CLIHelpFormatter,
1261
+ description=textwrap.dedent(
1262
+ """\
1263
+ Track Claude equivalent-$ spend for the current subscription week
1264
+ against a weekly budget. Shows spend, pace, projected end-of-week,
1265
+ and a verdict (ok / warn / over). `budget set <amount>` and
1266
+ `budget unset` manage the budget; spend-crossing alerts fire from
1267
+ record-usage (see `cctally alerts`).
1268
+ """
1269
+ ),
1270
+ epilog=textwrap.dedent(
1271
+ """\
1272
+ Examples:
1273
+ cctally budget
1274
+ cctally budget set 300
1275
+ cctally budget unset
1276
+ cctally budget --json
1277
+ cctally budget --format md
1278
+ """
1279
+ ),
1280
+ )
1281
+ bg.add_argument("action", nargs="?", choices=["set", "unset"], default=None,
1282
+ help="`set <amount>` to set the weekly budget, `unset` to clear it.")
1283
+ bg.add_argument("amount", nargs="?", default=None,
1284
+ help="Target USD for `budget set` (e.g. 300).")
1285
+ bg.add_argument("--config", default=None,
1286
+ help="Read status from this config file (read-only; "
1287
+ "rejected on set/unset).")
1288
+ bg.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
1289
+ help="Accepted for --format surface parity; inert for budget "
1290
+ "(no per-project axis).")
1291
+ bg.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
1292
+ help="Display timezone: local, utc, or IANA name. "
1293
+ "Overrides config display.tz for this call.")
1294
+ _add_share_args(bg)
1295
+ bg.set_defaults(func=c.cmd_budget)
1296
+
1297
+ pb = sub.add_parser(
1298
+ "percent-breakdown",
1299
+ help="Show per-percent cost milestones for a week",
1300
+ formatter_class=CLIHelpFormatter,
1301
+ description=textwrap.dedent(
1302
+ """\
1303
+ Show the cumulative and marginal cost at each integer percent threshold
1304
+ for a given week. Milestones are recorded automatically when
1305
+ record-usage stores a snapshot crossing a new integer percent.
1306
+ """
1307
+ ),
1308
+ epilog=textwrap.dedent(
1309
+ """\
1310
+ Examples:
1311
+ cctally percent-breakdown
1312
+ cctally percent-breakdown --week-start 2026-03-20
1313
+ cctally percent-breakdown --json
1314
+ """
1315
+ ),
1316
+ )
1317
+ pb.add_argument(
1318
+ "--week-start",
1319
+ default=None,
1320
+ metavar="YYYY-MM-DD",
1321
+ help="Week start date. Defaults to the current week.",
1322
+ )
1323
+ pb.add_argument(
1324
+ "--week-start-name",
1325
+ default=None,
1326
+ choices=list(WEEKDAY_MAP.keys()),
1327
+ help="Week-start day used when no explicit date or usage data is available.",
1328
+ )
1329
+ pb.add_argument(
1330
+ "--json",
1331
+ action="store_true",
1332
+ help="Emit machine-readable JSON output.",
1333
+ )
1334
+ pb.add_argument(
1335
+ "--tz", default=None, type=_argparse_tz, metavar="TZ",
1336
+ help="Display timezone: local, utc, or IANA name. "
1337
+ "Overrides config display.tz for this call.",
1338
+ )
1339
+ pb.set_defaults(func=c.cmd_percent_breakdown)
1340
+
1341
+ fhbd = sub.add_parser(
1342
+ "five-hour-breakdown",
1343
+ help="Per-percent milestones inside one 5h block (mirror of percent-breakdown)",
1344
+ formatter_class=CLIHelpFormatter,
1345
+ description=textwrap.dedent(
1346
+ """\
1347
+ Show cumulative + marginal cost at each integer percent threshold
1348
+ inside one 5h block. Mirrors percent-breakdown for the 5h axis.
1349
+ """
1350
+ ),
1351
+ epilog=textwrap.dedent(
1352
+ """\
1353
+ Examples:
1354
+ cctally five-hour-breakdown
1355
+ cctally five-hour-breakdown --block-start 2026-04-30T19:30
1356
+ cctally five-hour-breakdown --ago 1
1357
+ cctally five-hour-breakdown --json
1358
+ """
1359
+ ),
1360
+ )
1361
+ fhbd.add_argument(
1362
+ "--block-start",
1363
+ default=None,
1364
+ metavar="ISO8601",
1365
+ dest="block_start",
1366
+ help="Block start (e.g. 2026-04-30T19:30, naive=UTC).",
1367
+ )
1368
+ fhbd.add_argument(
1369
+ "--ago",
1370
+ default=None,
1371
+ type=int,
1372
+ metavar="N",
1373
+ help="Relative selector: 0=current, 1=previous, etc.",
1374
+ )
1375
+ fhbd.add_argument(
1376
+ "--json",
1377
+ action="store_true",
1378
+ help="Emit camelCase JSON (schemaVersion 1).",
1379
+ )
1380
+ fhbd.add_argument(
1381
+ "--no-color",
1382
+ action="store_true",
1383
+ help="Disable ANSI color output (currently a no-op — table is plain text).",
1384
+ )
1385
+ fhbd.add_argument(
1386
+ "--tz",
1387
+ default=None,
1388
+ type=_argparse_tz,
1389
+ metavar="TZ",
1390
+ help="Display timezone: local, utc, or IANA name. "
1391
+ "Overrides config display.tz for this call.",
1392
+ )
1393
+ fhbd.set_defaults(func=c.cmd_five_hour_breakdown)
1394
+
1395
+ tp = sub.add_parser(
1396
+ "tui",
1397
+ help="Live refreshing dashboard (current week, forecast, trend, sessions)",
1398
+ formatter_class=CLIHelpFormatter,
1399
+ description=textwrap.dedent(
1400
+ """\
1401
+ Live terminal dashboard with four refreshing panels:
1402
+ - Current week % and 5-hour window
1403
+ - Forecast verdict + projections + daily $ budgets
1404
+ - $/1% trend over the last 8 weeks (with sparkline)
1405
+ - Recent Claude sessions (last 100, scrollable)
1406
+
1407
+ Two visual variants — conventional 2x2 grid and expressive
1408
+ hero layout — toggleable at runtime with `v`.
1409
+
1410
+ Requires the `rich` Python package.
1411
+ """
1412
+ ),
1413
+ epilog=textwrap.dedent(
1414
+ """\
1415
+ Examples:
1416
+ cctally tui
1417
+ cctally tui --expressive
1418
+ cctally tui --refresh 2 --sync-interval 30
1419
+ cctally tui --no-sync
1420
+ """
1421
+ ),
1422
+ )
1423
+ tp.add_argument(
1424
+ "--variant",
1425
+ choices=("conventional", "expressive"),
1426
+ default="conventional",
1427
+ help="Initial layout variant (press 'v' at runtime to toggle).",
1428
+ )
1429
+ tp.add_argument(
1430
+ "--expressive",
1431
+ action="store_const",
1432
+ dest="variant",
1433
+ const="expressive",
1434
+ help="Shortcut for --variant expressive.",
1435
+ )
1436
+ tp.add_argument(
1437
+ "--refresh",
1438
+ type=c._tui_refresh_interval_type,
1439
+ default=1.0,
1440
+ metavar="SECONDS",
1441
+ help="UI redraw cadence (default: 1.0).",
1442
+ )
1443
+ tp.add_argument(
1444
+ "--sync-interval",
1445
+ type=c._tui_sync_interval_type,
1446
+ default=10.0,
1447
+ metavar="SECONDS",
1448
+ dest="sync_interval",
1449
+ help="Background JSONL sync cadence (default: 10).",
1450
+ )
1451
+ tp.add_argument(
1452
+ "--no-sync",
1453
+ action="store_true",
1454
+ dest="no_sync",
1455
+ help="Disable background sync; render from cache only.",
1456
+ )
1457
+ tp.add_argument(
1458
+ "--no-color",
1459
+ action="store_true",
1460
+ dest="no_color",
1461
+ help="Disable ANSI color (NO_COLOR env var also respected).",
1462
+ )
1463
+ tp.add_argument(
1464
+ "--tz",
1465
+ default=None,
1466
+ type=_argparse_tz,
1467
+ metavar="TZ",
1468
+ help="Display timezone: local, utc, or IANA name. "
1469
+ "Overrides config display.tz for this call.",
1470
+ )
1471
+ # Dev-only: pin "now" for deterministic fixture tests.
1472
+ tp.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
1473
+ # Dev-only: fixture injection — render one frame from a Python module that
1474
+ # exposes `SNAPSHOT` (DataSnapshot) and exits.
1475
+ tp.add_argument(
1476
+ "--snapshot-module", dest="snapshot_module", default=None, help=argparse.SUPPRESS
1477
+ )
1478
+ # Dev-only: one-shot render for golden capture.
1479
+ tp.add_argument(
1480
+ "--render-once", action="store_true", dest="render_once", help=argparse.SUPPRESS
1481
+ )
1482
+ # Dev-only: force terminal size for --render-once.
1483
+ tp.add_argument(
1484
+ "--force-size", dest="force_size", default=None, metavar="WxH",
1485
+ help=argparse.SUPPRESS
1486
+ )
1487
+ tp.set_defaults(func=c.cmd_tui)
1488
+
1489
+ # ---- dashboard subcommand --------------------------------------------
1490
+ dp = sub.add_parser(
1491
+ "dashboard",
1492
+ help="Launch the live web dashboard on http://localhost:8789",
1493
+ description=(
1494
+ "Start a local web server rendering a live dashboard of your "
1495
+ "subscription usage, weekly cost trend, and recent sessions. "
1496
+ "Press Ctrl-C to stop. Two variants are served by the companion "
1497
+ "'tui' subcommand for terminal-only use."
1498
+ ),
1499
+ )
1500
+ dp.add_argument(
1501
+ "--port",
1502
+ type=int,
1503
+ default=8789,
1504
+ help="TCP port to bind (default: 8789).",
1505
+ )
1506
+ dp.add_argument(
1507
+ "--host",
1508
+ default=None,
1509
+ help=("Bind host (default: from config dashboard.bind, fallback "
1510
+ "127.0.0.1 — loopback-only). Use --host 0.0.0.0 to opt in "
1511
+ "to LAN exposure (no auth on /api/* — trusted networks only)."),
1512
+ )
1513
+ dp.add_argument(
1514
+ "--no-browser",
1515
+ action="store_true",
1516
+ help="Skip auto-opening the browser to the dashboard URL.",
1517
+ )
1518
+ dp.add_argument(
1519
+ "--sync-interval",
1520
+ type=c._tui_sync_interval_type, # reuse the TUI validator
1521
+ default=5.0,
1522
+ dest="sync_interval",
1523
+ help="Background snapshot-rebuild cadence in seconds (default: 5).",
1524
+ )
1525
+ dp.add_argument(
1526
+ "--no-sync",
1527
+ action="store_true",
1528
+ dest="no_sync",
1529
+ help="Freeze the snapshot at startup; skip background rebuilds.",
1530
+ )
1531
+ dp.add_argument(
1532
+ "--tz", default=None, type=_argparse_tz, metavar="TZ",
1533
+ help="Display timezone: local, utc, or IANA name. "
1534
+ "Overrides config display.tz for this call.",
1535
+ )
1536
+ dp.set_defaults(func=c.cmd_dashboard)
1537
+
1538
+ ru = sub.add_parser(
1539
+ "record-usage",
1540
+ help="Record usage data from Claude Code status line",
1541
+ formatter_class=CLIHelpFormatter,
1542
+ description=textwrap.dedent(
1543
+ """\
1544
+ Record usage percentage from Claude Code status line rate_limits data.
1545
+ Called automatically by the status line script after each assistant message.
1546
+ """
1547
+ ),
1548
+ epilog=textwrap.dedent(
1549
+ """\
1550
+ Examples:
1551
+ cctally record-usage --percent 14.2 --resets-at 1744531200
1552
+ cctally record-usage --percent 14.2 --resets-at 1744531200 \\
1553
+ --five-hour-percent 38.5 --five-hour-resets-at 1744502400
1554
+
1555
+ Status line integration (add to ~/.claude/statusline-command.sh):
1556
+ if [ -n "$week_pct" ] && [ -n "$week_resets" ]; then
1557
+ record_args="--percent $week_pct --resets-at ${week_resets%.*}"
1558
+ if [ -n "$five_pct" ] && [ -n "$five_resets" ]; then
1559
+ record_args="$record_args --five-hour-percent $five_pct --five-hour-resets-at ${five_resets%.*}"
1560
+ fi
1561
+ cctally record-usage $record_args &
1562
+ fi
1563
+ """
1564
+ ),
1565
+ )
1566
+ ru.add_argument(
1567
+ "--percent",
1568
+ required=True,
1569
+ type=float,
1570
+ help="7-day utilization percentage (0-100).",
1571
+ )
1572
+ ru.add_argument(
1573
+ "--resets-at",
1574
+ required=True,
1575
+ help="7-day window reset timestamp (Unix epoch seconds).",
1576
+ )
1577
+ ru.add_argument(
1578
+ "--five-hour-percent",
1579
+ type=float,
1580
+ default=None,
1581
+ help="5-hour utilization percentage (0-100).",
1582
+ )
1583
+ ru.add_argument(
1584
+ "--five-hour-resets-at",
1585
+ default=None,
1586
+ help="5-hour window reset timestamp (Unix epoch seconds).",
1587
+ )
1588
+ ru.set_defaults(func=c.cmd_record_usage)
1589
+
1590
+ rfu = sub.add_parser(
1591
+ "refresh-usage",
1592
+ help="Force-fetch 7d/5h percent from OAuth API and record it",
1593
+ formatter_class=CLIHelpFormatter,
1594
+ description=textwrap.dedent(
1595
+ """\
1596
+ Force a fresh fetch of seven_day.utilization and five_hour.utilization
1597
+ from Anthropic's OAuth usage API, persist it via the same path
1598
+ record-usage uses (HWM, percent_milestones, weekly_usage_snapshots),
1599
+ and bust the statusline OAuth cache file at
1600
+ /tmp/claude-statusline-usage-cache.json so the next status-line tick
1601
+ also gets fresh data.
1602
+
1603
+ Use this when the displayed 7d percent is stale (e.g., you've
1604
+ been away from Claude Code and the status-line hasn't fired
1605
+ recently). Otherwise the status-line script handles refresh
1606
+ automatically every minute.
1607
+ """
1608
+ ),
1609
+ epilog=textwrap.dedent(
1610
+ """\
1611
+ Examples:
1612
+ ccusage-refresh-usage # one-liner output
1613
+ ccusage-refresh-usage --json | jq . # scriptable
1614
+ ccusage-refresh-usage --quiet # silent (exit code only)
1615
+
1616
+ Exit codes: 0 success / 2 no OAuth token / 3 network failure
1617
+ / 4 malformed API response / 5 record-usage internal failure.
1618
+ """
1619
+ ),
1620
+ )
1621
+ rfu.add_argument("--json", action="store_true",
1622
+ help="Emit schema_version=1 JSON to stdout instead of one-liner.")
1623
+ rfu.add_argument("--quiet", action="store_true",
1624
+ help="Suppress stdout; exit code is the only success signal.")
1625
+ rfu.add_argument("--color", choices=("auto", "always", "never"), default="auto",
1626
+ help="Color output control (also honors NO_COLOR).")
1627
+ rfu.add_argument("--timeout", type=float, default=5.0,
1628
+ help="HTTP timeout in seconds (default: 5.0).")
1629
+ rfu.set_defaults(func=c.cmd_refresh_usage)
1630
+
1631
+ pc = sub.add_parser(
1632
+ "cache-report",
1633
+ help="Show daily cache hit rates per model from ccusage data",
1634
+ formatter_class=CLIHelpFormatter,
1635
+ description=textwrap.dedent(
1636
+ """\
1637
+ Query ccusage for daily token breakdown and display cache hit
1638
+ percentages per model. Useful for spotting caching regressions
1639
+ after Claude Code updates.
1640
+
1641
+ Cache hit % = cacheReadTokens / (input + cacheCreate + cacheRead)
1642
+ """
1643
+ ),
1644
+ epilog=textwrap.dedent(
1645
+ """\
1646
+ Examples:
1647
+ cctally cache-report
1648
+ cctally cache-report --days 14
1649
+ cctally cache-report --since 2026-04-10 --until 2026-04-18
1650
+ cctally cache-report --by-session --days 14
1651
+ cctally cache-report --by-session --sort cache
1652
+ cctally cache-report --json
1653
+ """
1654
+ ),
1655
+ )
1656
+ pc.add_argument(
1657
+ "--days",
1658
+ type=int,
1659
+ default=7,
1660
+ help="Number of recent days to include.",
1661
+ )
1662
+ pc.add_argument(
1663
+ "--since",
1664
+ default=None,
1665
+ help="Lower window bound (ISO 8601, e.g., '2026-04-10' or "
1666
+ "'2026-04-10T10:00:00Z'). If omitted, falls back to --days.",
1667
+ )
1668
+ pc.add_argument(
1669
+ "--until",
1670
+ default=None,
1671
+ help="Upper window bound (ISO 8601). If omitted, defaults to now.",
1672
+ )
1673
+ pc.add_argument(
1674
+ "--by-session",
1675
+ action="store_true",
1676
+ dest="by_session",
1677
+ help="Group by Claude sessionId (resumed-merged) instead of by date. "
1678
+ "Adds SessionId, Last Activity, and Project identity columns.",
1679
+ )
1680
+ pc.add_argument(
1681
+ "-O", "--offline",
1682
+ action=argparse.BooleanOptionalAction, default=False,
1683
+ help="Use cached pricing data in ccusage. Session A (spec §7.1.2)"
1684
+ " promotes the existing flag to BooleanOptionalAction + -O"
1685
+ " short form so the ccusage drop-in alias surface (-O,"
1686
+ " --offline, --no-offline) all work on cache-report; the"
1687
+ " behavior under each is unchanged (cctally is always"
1688
+ " offline — args.offline still lands as a bool).",
1689
+ )
1690
+ pc.add_argument(
1691
+ "--project",
1692
+ default=None,
1693
+ help="Filter to a specific project.",
1694
+ )
1695
+ pc.add_argument(
1696
+ "--json",
1697
+ action="store_true",
1698
+ help="Emit machine-readable JSON output.",
1699
+ )
1700
+ pc.add_argument(
1701
+ "--anomaly-threshold-pp",
1702
+ type=int,
1703
+ default=15,
1704
+ dest="anomaly_threshold_pp",
1705
+ help="Cache%% drop threshold (percentage points) vs. trailing-median "
1706
+ "baseline for the cache_drop anomaly trigger. Default: 15.",
1707
+ )
1708
+ pc.add_argument(
1709
+ "--anomaly-window-days",
1710
+ type=int,
1711
+ default=14,
1712
+ dest="anomaly_window_days",
1713
+ help="Trailing window (days) for baseline median computation. "
1714
+ "Default: 14.",
1715
+ )
1716
+ pc.add_argument(
1717
+ "--no-anomaly",
1718
+ action="store_true",
1719
+ dest="no_anomaly",
1720
+ help="Disable all anomaly triggers (both cache_drop and net_negative).",
1721
+ )
1722
+ pc.add_argument(
1723
+ "--sort",
1724
+ choices=["date", "net", "cache", "recent", "cost", "anomaly"],
1725
+ default=None,
1726
+ dest="sort",
1727
+ help="Override sort order. Defaults: 'date' in daily mode, 'net' in "
1728
+ "--by-session mode.",
1729
+ )
1730
+ pc.add_argument(
1731
+ "--tz", default=None, type=_argparse_tz, metavar="TZ",
1732
+ help="Display timezone: local, utc, or IANA name. "
1733
+ "Overrides config display.tz for this call.",
1734
+ )
1735
+ # Session A (spec §7.6): ansi_emit=False; existing `--offline` is
1736
+ # skipped by the helper's `_argparse_has_arg` guard (the collision
1737
+ # case spec §7.1.2 calls out explicitly).
1738
+ _add_ccusage_alias_args(pc, ansi_emit=False)
1739
+ pc.set_defaults(func=c.cmd_cache_report)
1740
+
1741
+ # -- range-cost --
1742
+ rc = sub.add_parser(
1743
+ "range-cost",
1744
+ help="Compute USD cost for a time range from session data",
1745
+ formatter_class=CLIHelpFormatter,
1746
+ description="Compute USD cost for Claude Code usage between start/end timestamps.",
1747
+ epilog=textwrap.dedent("""\
1748
+ Examples:
1749
+ cctally range-cost -s "2026-04-10T10:00:00+03:00"
1750
+ cctally range-cost -s "2026-04-10T10:00:00Z" -e "2026-04-12T10:00:00Z" --breakdown
1751
+ cctally range-cost -s "2026-04-10T10:00:00Z" --json
1752
+ cctally range-cost -s "2026-04-10T10:00:00Z" --total-only
1753
+ """),
1754
+ )
1755
+ rc.add_argument(
1756
+ "-s", "--start",
1757
+ required=True,
1758
+ help="Start timestamp (ISO 8601)",
1759
+ )
1760
+ rc.add_argument(
1761
+ "-e", "--end",
1762
+ default=None,
1763
+ help="End timestamp (ISO 8601, default: now)",
1764
+ )
1765
+ rc.add_argument(
1766
+ "-m", "--mode",
1767
+ default="auto",
1768
+ choices=["auto", "calculate", "display"],
1769
+ help="Cost calculation mode.",
1770
+ )
1771
+ rc.add_argument(
1772
+ "-p", "--project",
1773
+ default=None,
1774
+ help="Filter to a specific project.",
1775
+ )
1776
+ rc.add_argument(
1777
+ "-b", "--breakdown",
1778
+ action="store_true",
1779
+ help="Show per-model usage and cost breakdown.",
1780
+ )
1781
+ rc.add_argument(
1782
+ "--json",
1783
+ action="store_true",
1784
+ dest="json",
1785
+ help="Output JSON.",
1786
+ )
1787
+ rc.add_argument(
1788
+ "--total-only",
1789
+ action="store_true",
1790
+ dest="total_only",
1791
+ help="Print numeric USD total only.",
1792
+ )
1793
+ # Session A (spec §7.6): ansi_emit=False. range-cost has no --tz of
1794
+ # its own (ISO timestamps carry zone info), but the helper-added
1795
+ # -z/--timezone still lands on the namespace; the bridge promotes
1796
+ # it onto args.tz where the rest of the pipeline treats it as a
1797
+ # documented no-op (cmd_range_cost does not consume args.tz).
1798
+ _add_ccusage_alias_args(rc, ansi_emit=False)
1799
+ rc.set_defaults(func=c.cmd_range_cost)
1800
+
1801
+ # -- blocks --
1802
+ _build_blocks_parser(
1803
+ sub, "blocks",
1804
+ help_text="Show usage report grouped by 5-hour session blocks",
1805
+ xref="Alias of `cctally claude blocks` (the canonical form).")
1806
+
1807
+ # -- statusline --
1808
+ _build_statusline_parser(
1809
+ sub, "statusline",
1810
+ help_text="Compact one-line status for Claude Code hooks",
1811
+ xref="Alias of `cctally claude statusline` (the canonical form).")
1812
+
1813
+ # -- five-hour-blocks --
1814
+ fhb = sub.add_parser(
1815
+ "five-hour-blocks",
1816
+ help="List API-anchored 5h blocks with rollup totals + 7d-drift columns",
1817
+ formatter_class=CLIHelpFormatter,
1818
+ description=(
1819
+ "Show usage grouped by API-anchored 5-hour blocks (analytics view, "
1820
+ "distinct from `cctally blocks` upstream-parity drop-in)."
1821
+ ),
1822
+ epilog=textwrap.dedent("""\
1823
+ Examples:
1824
+ cctally five-hour-blocks
1825
+ cctally five-hour-blocks --since 20260420
1826
+ cctally five-hour-blocks --breakdown model
1827
+ cctally five-hour-blocks --breakdown project --json
1828
+ """),
1829
+ )
1830
+ fhb.add_argument(
1831
+ "-s", "--since",
1832
+ default=None,
1833
+ metavar="YYYYMMDD",
1834
+ help="Filter from date (inclusive).",
1835
+ )
1836
+ fhb.add_argument(
1837
+ "-u", "--until",
1838
+ default=None,
1839
+ metavar="YYYYMMDD",
1840
+ help="Filter until date (inclusive).",
1841
+ )
1842
+ fhb.add_argument(
1843
+ "--breakdown",
1844
+ choices=("model", "project"),
1845
+ default=None,
1846
+ help="Add per-axis rollup-child rows under each block.",
1847
+ )
1848
+ fhb.add_argument(
1849
+ "--reveal-projects",
1850
+ action="store_true",
1851
+ dest="reveal_projects",
1852
+ help="In --format output, show real project basenames instead of "
1853
+ "the default project-1, project-2, ... anonymization.",
1854
+ )
1855
+ fhb.add_argument(
1856
+ "--no-color",
1857
+ action="store_true",
1858
+ help="Accepted for ccusage drop-in compat; this command emits "
1859
+ "plain-text output and no ANSI is suppressed.",
1860
+ )
1861
+ fhb.add_argument(
1862
+ "--tz",
1863
+ default=None,
1864
+ type=_argparse_tz,
1865
+ metavar="TZ",
1866
+ help="Display timezone: local, utc, or IANA name. "
1867
+ "Overrides config display.tz for this call.",
1868
+ )
1869
+ # Session A (spec §7.6 / §7.6.3): ansi_emit=False. fhb already
1870
+ # declares --no-color (refreshed to no-op text in spec §7.6.3); the
1871
+ # helper's --no-color add is short-circuited by the existing-arg
1872
+ # guard. The helper's --color add lands as a parsed-and-ignored
1873
+ # no-op (the renderer emits plain text).
1874
+ _add_ccusage_alias_args(fhb, ansi_emit=False)
1875
+ _add_mode_arg(fhb, noop=True)
1876
+ _add_share_args(fhb)
1877
+ fhb.set_defaults(func=c.cmd_five_hour_blocks)
1878
+
1879
+ # -- cache-sync --
1880
+ p_cache_sync = sub.add_parser(
1881
+ "cache-sync",
1882
+ help="Sync (or rebuild) the session-entry cache",
1883
+ )
1884
+ p_cache_sync.add_argument(
1885
+ "--rebuild",
1886
+ action="store_true",
1887
+ help="Drop all cached entries and reingest from scratch",
1888
+ )
1889
+ p_cache_sync.add_argument(
1890
+ "--source",
1891
+ choices=("claude", "codex", "all"),
1892
+ default="all",
1893
+ help="Which ingest half to sync/rebuild (default: all).",
1894
+ )
1895
+ p_cache_sync.set_defaults(func=c.cmd_cache_sync)
1896
+
1897
+ # -- daily --
1898
+ _build_daily_parser(
1899
+ sub, "daily",
1900
+ help_text="Show usage report grouped by date",
1901
+ xref="Alias of `cctally claude daily` (the canonical form).")
1902
+
1903
+ # -- monthly --
1904
+ _build_monthly_parser(
1905
+ sub, "monthly",
1906
+ help_text="Show usage report grouped by month",
1907
+ xref="Alias of `cctally claude monthly` (the canonical form).")
1908
+
1909
+ # -- weekly --
1910
+ _build_weekly_parser(
1911
+ sub, "weekly",
1912
+ help_text="Show usage grouped by subscription week (with Used %% and $/1%%)",
1913
+ xref="Alias of `cctally claude weekly` (the canonical form).")
1914
+
1915
+ # -- codex-daily --
1916
+ _build_codex_daily_parser(
1917
+ sub, "codex-daily",
1918
+ help_text="Show Codex usage report grouped by date (drop-in for `ccusage-codex daily`)",
1919
+ xref="Alias of `cctally codex daily` (the canonical form).")
1920
+
1921
+ # -- codex-monthly --
1922
+ _build_codex_monthly_parser(
1923
+ sub, "codex-monthly",
1924
+ help_text="Show Codex usage grouped by month (drop-in for `ccusage-codex monthly`)",
1925
+ xref="Alias of `cctally codex monthly` (the canonical form).")
1926
+
1927
+ # -- codex-weekly --
1928
+ _build_codex_weekly_parser(
1929
+ sub, "codex-weekly",
1930
+ help_text="Show Codex usage grouped by week (week-start from config.json)",
1931
+ xref="Alias of `cctally codex weekly` (the canonical form).")
1932
+
1933
+ # -- codex-session --
1934
+ _build_codex_session_parser(
1935
+ sub, "codex-session",
1936
+ help_text="Show Codex usage grouped by session (drop-in for `ccusage-codex session`)",
1937
+ xref="Alias of `cctally codex session` (the canonical form).")
1938
+
1939
+ # -- project --
1940
+ p_project = sub.add_parser(
1941
+ "project",
1942
+ help="Roll usage up by project (git-root), with per-project Used %% attribution",
1943
+ formatter_class=CLIHelpFormatter,
1944
+ description=(
1945
+ "Aggregate Claude usage by project (git-root resolved). Default range is "
1946
+ "the current subscription week; use --since/--until or --weeks N to extend."
1947
+ ),
1948
+ epilog=textwrap.dedent("""\
1949
+ Examples:
1950
+ cctally project
1951
+ cctally project --weeks 4
1952
+ cctally project --since 20260401 --until 20260414
1953
+ cctally project --project ccusage --model sonnet
1954
+ cctally project --breakdown --sort used --order desc
1955
+ cctally project --group full-path --json
1956
+ """),
1957
+ )
1958
+ p_project.add_argument("-s", "--since", default=None, metavar="YYYYMMDD",
1959
+ help="Inclusive start date (YYYY-MM-DD or YYYYMMDD).")
1960
+ p_project.add_argument("-u", "--until", default=None, metavar="YYYYMMDD",
1961
+ help="Inclusive end date (YYYY-MM-DD or YYYYMMDD).")
1962
+ p_project.add_argument("--weeks", type=int, default=None,
1963
+ help="Last N subscription weeks ending now.")
1964
+ p_project.add_argument("--project", action="append", default=[], metavar="PATTERN",
1965
+ help="Substring filter on project display key (repeatable, OR).")
1966
+ p_project.add_argument("--model", action="append", default=[], metavar="PATTERN",
1967
+ help="Substring filter on model name (repeatable, OR).")
1968
+ p_project.add_argument("-b", "--breakdown", action="store_true",
1969
+ help="Add per-model child rows under each project.")
1970
+ p_project.add_argument("-o", "--order", choices=("asc", "desc"), default="desc",
1971
+ help="Sort direction (default: desc).")
1972
+ p_project.add_argument("--sort", choices=("cost", "used", "name", "last-seen"),
1973
+ default="cost",
1974
+ help="Sort key (default: cost).")
1975
+ p_project.add_argument("--group", choices=("git-root", "full-path"), default="git-root",
1976
+ help="Bucket by resolved git-root (default) or raw project_path.")
1977
+ p_project.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
1978
+ help="In --format output, show real project basenames instead of "
1979
+ "the default project-1, project-2, ... anonymization.")
1980
+ p_project.add_argument("--no-color", action="store_true", dest="no_color",
1981
+ help="Disable ANSI color.")
1982
+ p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
1983
+ help="Display timezone: local, utc, or IANA name. "
1984
+ "Overrides config display.tz for this call.")
1985
+ # Session A (spec §7.6): ansi_emit=True. project is one of the two
1986
+ # real ANSI emitters. The helper skips its --no-color add (already
1987
+ # declared at p_project above) and adds the new bool --color flag
1988
+ # whose precedence flows through _resolve_color_enabled (§7.3).
1989
+ _add_ccusage_alias_args(p_project, ansi_emit=True)
1990
+ _add_share_args(p_project)
1991
+ p_project.set_defaults(func=c.cmd_project)
1992
+
1993
+ # -- diff --
1994
+ diff_p = sub.add_parser(
1995
+ "diff",
1996
+ help="Compare Claude usage between two windows.",
1997
+ )
1998
+ diff_p.add_argument("--a", required=True,
1999
+ 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)")
2000
+ diff_p.add_argument("--b", required=True, help="Window B token (same grammar as --a)")
2001
+ diff_p.add_argument("--allow-mismatch", action="store_true",
2002
+ help="Permit mismatched window lengths (deltas normalized per-day)")
2003
+ diff_p.add_argument("--only", help="Comma-separated section list (overall,models,projects,cache)")
2004
+ diff_p.add_argument("--with", dest="with_extra",
2005
+ help="Comma-separated opt-in sections (trend,time)")
2006
+ diff_p.add_argument("--all", dest="show_all", action="store_true",
2007
+ help="Show all rows (bypass noise filter)")
2008
+ diff_p.add_argument("--min-delta", type=float, dest="min_delta_usd",
2009
+ help="Override |Δ$| noise threshold (default 0.10)")
2010
+ diff_p.add_argument("--min-delta-pct", type=float,
2011
+ help="Override |Δ%%| noise threshold (default 1.0)")
2012
+ diff_p.add_argument("--sort",
2013
+ choices=["delta", "cost-a", "cost-b", "name", "status"], default="delta")
2014
+ diff_p.add_argument("--top", type=int, help="Cap rows per section after filter+sort")
2015
+ diff_p.add_argument("--sync", action="store_true",
2016
+ help="Run sync_cache + sync-week before computing")
2017
+ diff_p.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
2018
+ help="Display timezone: local, utc, or IANA name. "
2019
+ "Overrides config display.tz for this call.")
2020
+ diff_p.add_argument("--no-color", action="store_true")
2021
+ diff_p.add_argument("--json", dest="emit_json", action="store_true")
2022
+ diff_p.add_argument("--width", type=int, help=argparse.SUPPRESS)
2023
+ diff_p.add_argument("--debug-now", action="store_true", help=argparse.SUPPRESS)
2024
+ # Session A (spec §7.6): ansi_emit=True. diff is the other real
2025
+ # ANSI emitter. The helper skips its --no-color add (declared
2026
+ # above) and adds the new bool --color flag wired through
2027
+ # _resolve_color_enabled (§7.3). Note: --debug here collides with
2028
+ # diff's existing `--debug-now` (SUPPRESS'd internal flag) but
2029
+ # `--debug-now` is a different option string; the helper still
2030
+ # adds plain `--debug` cleanly.
2031
+ _add_ccusage_alias_args(diff_p, ansi_emit=True)
2032
+ diff_p.set_defaults(func=c.cmd_diff)
2033
+
2034
+ # -- session --
2035
+ _build_session_parser(
2036
+ sub, "session",
2037
+ help_text="Show Claude usage grouped by sessionId (merges resumed-across-files sessions)",
2038
+ xref="Alias of `cctally claude session` (the canonical form).")
2039
+
2040
+ # --- `claude` subgroup (drop-in for `ccusage claude …`); issue #86 Session B ---
2041
+ # Build-once, register-twice: these reuse the same nine builders as the flat
2042
+ # forms above. Nested subparsers reuse dest="command" so args.command resolves
2043
+ # to the leaf name (e.g. "blocks"), keeping banner suppression byte-identical
2044
+ # to the flat form with zero hook-path changes.
2045
+ claude_p = sub.add_parser(
2046
+ "claude",
2047
+ help="Claude-source reports (drop-in for `ccusage claude …`)",
2048
+ formatter_class=CLIHelpFormatter,
2049
+ description="Claude-source usage reports. Each subcommand is a drop-in for the "
2050
+ "matching `ccusage claude <cmd>` and shares its engine with the "
2051
+ "top-level `cctally <cmd>` alias.")
2052
+ claude_sub = claude_p.add_subparsers(dest="command", required=True, metavar="<command>")
2053
+ _build_daily_parser(claude_sub, "daily",
2054
+ help_text="Show usage grouped by date",
2055
+ xref="Drop-in for `ccusage claude daily`. Same engine as `cctally daily`.")
2056
+ _build_monthly_parser(claude_sub, "monthly",
2057
+ help_text="Show usage grouped by month",
2058
+ xref="Drop-in for `ccusage claude monthly`. Same engine as `cctally monthly`.")
2059
+ _build_weekly_parser(claude_sub, "weekly",
2060
+ help_text="Show usage grouped by subscription week",
2061
+ xref="Drop-in for `ccusage claude weekly`. Same engine as `cctally weekly`.")
2062
+ _build_session_parser(claude_sub, "session",
2063
+ help_text="Show usage grouped by session",
2064
+ xref="Drop-in for `ccusage claude session`. Same engine as `cctally session`.")
2065
+ _build_blocks_parser(claude_sub, "blocks",
2066
+ help_text="Show usage grouped by 5-hour session blocks",
2067
+ xref="Drop-in for `ccusage claude blocks`. Same engine as `cctally blocks`.")
2068
+ _build_statusline_parser(claude_sub, "statusline",
2069
+ help_text="Compact one-line status for Claude Code hooks",
2070
+ xref="Canonical `cctally claude statusline` (flat alias: `cctally statusline`). "
2071
+ "Drop-in for `ccusage statusline` plus cctally extension segments.")
2072
+
2073
+ # --- `codex` subgroup (drop-in for `ccusage codex …`); issue #86 Session B ---
2074
+ codex_p = sub.add_parser(
2075
+ "codex",
2076
+ help="Codex-source reports (drop-in for `ccusage codex …`)",
2077
+ formatter_class=CLIHelpFormatter,
2078
+ description="Codex-source usage reports. daily/monthly/session are drop-ins for "
2079
+ "`ccusage codex <cmd>`; weekly is a cctally extension. Each shares its "
2080
+ "engine with the matching `cctally codex-<cmd>` alias.")
2081
+ codex_sub = codex_p.add_subparsers(dest="command", required=True, metavar="<command>")
2082
+ _build_codex_daily_parser(codex_sub, "daily",
2083
+ help_text="Show Codex usage grouped by date",
2084
+ xref="Drop-in for `ccusage codex daily`. Same engine as `cctally codex-daily`.")
2085
+ _build_codex_monthly_parser(codex_sub, "monthly",
2086
+ help_text="Show Codex usage grouped by month",
2087
+ xref="Drop-in for `ccusage codex monthly`. Same engine as `cctally codex-monthly`.")
2088
+ _build_codex_session_parser(codex_sub, "session",
2089
+ help_text="Show Codex usage grouped by session",
2090
+ xref="Drop-in for `ccusage codex session`. Same engine as `cctally codex-session`.")
2091
+ _build_codex_weekly_parser(codex_sub, "weekly",
2092
+ help_text="Show Codex usage grouped by week",
2093
+ xref="cctally extension (no upstream `ccusage codex weekly`). Same engine as "
2094
+ "`cctally codex-weekly`.")
2095
+
2096
+ # ---- config (persisted user preferences) ----
2097
+ cfg_p = sub.add_parser(
2098
+ "config",
2099
+ help="Get / set / unset persisted user preferences",
2100
+ formatter_class=CLIHelpFormatter,
2101
+ description=textwrap.dedent("""\
2102
+ Manage cctally user preferences in ~/.local/share/cctally/config.json.
2103
+
2104
+ Currently supported keys:
2105
+ display.tz Display timezone. Values: 'local' (default; host
2106
+ zone via the OS locale), 'utc', or any IANA name
2107
+ like 'America/New_York'. Per-call --tz flag on
2108
+ any subcommand still wins over the persisted value.
2109
+ alerts.enabled Enable/disable threshold alerts (true/false).
2110
+ dashboard.bind Host the `dashboard` subcommand binds. Values:
2111
+ 'loopback' (default; binds 127.0.0.1 —
2112
+ loopback-only), 'lan' (binds 0.0.0.0 —
2113
+ LAN-accessible), or any literal IP / hostname.
2114
+
2115
+ Examples:
2116
+ cctally config get
2117
+ cctally config get display.tz
2118
+ cctally config set display.tz America/New_York
2119
+ cctally config set dashboard.bind lan
2120
+ cctally config unset dashboard.bind
2121
+ """),
2122
+ )
2123
+ cfg_sub = cfg_p.add_subparsers(dest="action", required=True)
2124
+ cfg_get = cfg_sub.add_parser("get", help="Print current value(s)")
2125
+ cfg_get.add_argument("key", nargs="?", help="Config key (omit to list all)")
2126
+ cfg_get.add_argument("--json", dest="emit_json", action="store_true",
2127
+ help="Emit JSON instead of key=value lines.")
2128
+ cfg_get.set_defaults(func=c.cmd_config)
2129
+ cfg_set = cfg_sub.add_parser("set", help="Set a config value")
2130
+ cfg_set.add_argument("key", help="Config key")
2131
+ cfg_set.add_argument("value", help="New value")
2132
+ cfg_set.add_argument("--json", dest="emit_json", action="store_true",
2133
+ help="Emit JSON instead of key=value confirmation.")
2134
+ cfg_set.set_defaults(func=c.cmd_config)
2135
+ cfg_unset = cfg_sub.add_parser("unset", help="Remove a config override")
2136
+ cfg_unset.add_argument("key", help="Config key")
2137
+ cfg_unset.set_defaults(func=c.cmd_config)
2138
+
2139
+ # ---- alerts (threshold-actions Task 6) ----
2140
+ p_alerts = sub.add_parser(
2141
+ "alerts",
2142
+ help="Manage threshold alerts",
2143
+ formatter_class=CLIHelpFormatter,
2144
+ description=textwrap.dedent("""\
2145
+ Manage cctally threshold alerts.
2146
+
2147
+ Subcommands:
2148
+ test Send a synthetic test alert through the dispatch
2149
+ pipeline (osascript spawn + alerts.log line). Logs
2150
+ with mode=test so it doesn't pollute real-alert
2151
+ history.
2152
+
2153
+ Examples:
2154
+ cctally alerts test
2155
+ cctally alerts test --axis five-hour --threshold 95
2156
+ """),
2157
+ )
2158
+ alerts_sub = p_alerts.add_subparsers(dest="alerts_command", required=True)
2159
+ p_alerts_test = alerts_sub.add_parser(
2160
+ "test",
2161
+ help="Send a synthetic test alert through the dispatch pipeline",
2162
+ formatter_class=CLIHelpFormatter,
2163
+ description=textwrap.dedent("""\
2164
+ Send a synthetic test alert end-to-end through the alert
2165
+ dispatch pipeline.
2166
+
2167
+ Builds a fake payload using the same content builders the
2168
+ real-alert path uses, then routes through the same osascript
2169
+ spawn and alerts.log writer as production. Distinguishes
2170
+ itself from real threshold-crossing alerts by writing the
2171
+ alerts.log line with mode=test (5th tab-delimited field) —
2172
+ no DB writes, no envelope mutation, so it cannot pollute
2173
+ real-alert history.
2174
+
2175
+ Use this to verify Notification Center delivery is working
2176
+ (osascript present, notifications enabled, no Do Not Disturb)
2177
+ without waiting for a real percent crossing.
2178
+
2179
+ Exit codes:
2180
+ 0 alert was queued (osascript spawned successfully)
2181
+ 1 osascript missing on this host (not macOS, or binary unavailable)
2182
+ 2 --threshold out of [1, 100] range
2183
+ 3 other spawn error (PermissionError, OSError, etc.)
2184
+
2185
+ Examples:
2186
+ cctally alerts test
2187
+ cctally alerts test --axis five-hour --threshold 95
2188
+ cctally alerts test --axis budget --threshold 100
2189
+ """),
2190
+ )
2191
+ p_alerts_test.add_argument(
2192
+ "--axis",
2193
+ choices=["weekly", "five-hour", "budget"],
2194
+ default="weekly",
2195
+ help="Alert axis to simulate: weekly subscription window, 5h block, "
2196
+ "or equiv-$ budget (default: weekly).",
2197
+ )
2198
+ p_alerts_test.add_argument(
2199
+ "--threshold",
2200
+ type=int,
2201
+ default=90,
2202
+ help="Threshold percent (1-100, default: 90).",
2203
+ )
2204
+ p_alerts_test.set_defaults(func=c.cmd_alerts_test)
2205
+
2206
+ # ---- setup (onboarding spec §2) ----
2207
+ sp = sub.add_parser(
2208
+ "setup",
2209
+ help="Install cctally into Claude Code (hooks + symlinks)",
2210
+ formatter_class=CLIHelpFormatter,
2211
+ description=textwrap.dedent(
2212
+ """\
2213
+ Install cctally into Claude Code by adding hook entries to
2214
+ ~/.claude/settings.json (additive, idempotent) and creating
2215
+ user-facing symlinks under ~/.local/bin/.
2216
+
2217
+ Modes (mutually exclusive):
2218
+ cctally setup # install (default)
2219
+ cctally setup --dry-run # show planned changes, change nothing
2220
+ cctally setup --status # report current install state
2221
+ cctally setup --uninstall # remove hooks + symlinks (keep data)
2222
+ cctally setup --uninstall --purge # also wipe ~/.local/share/cctally/
2223
+ """
2224
+ ),
2225
+ )
2226
+ mode = sp.add_mutually_exclusive_group()
2227
+ mode.add_argument("--status", action="store_true", help="Report current install state")
2228
+ mode.add_argument("--uninstall", action="store_true",
2229
+ help="Remove hooks + symlinks (keep data unless --purge)")
2230
+ mode.add_argument("--dry-run", action="store_true", dest="dry_run",
2231
+ help="Show planned changes without modifying anything")
2232
+ sp.add_argument("--purge", action="store_true",
2233
+ help="With --uninstall: also wipe ~/.local/share/cctally/")
2234
+ sp.add_argument("--yes", "-y", action="store_true",
2235
+ help="Skip confirmations")
2236
+ sp.add_argument("--json", action="store_true",
2237
+ help="Emit machine-readable output")
2238
+ sp.add_argument("--force-dev", action="store_true", dest="force_dev",
2239
+ help="Allow setup to run from a dev checkout (writes "
2240
+ "dev-pointing hooks into ~/.claude/settings.json)")
2241
+ # Legacy bespoke-hook migration flags (install-mode only — see cmd_setup
2242
+ # post-parse validation). Spec Section 2 mode×flag matrix.
2243
+ mig_group = sp.add_mutually_exclusive_group()
2244
+ mig_group.add_argument(
2245
+ "--migrate-legacy-hooks", action="store_true", dest="migrate_legacy_hooks",
2246
+ help="Auto-accept the legacy-bespoke-hook migration prompt (install only).",
2247
+ )
2248
+ mig_group.add_argument(
2249
+ "--no-migrate-legacy-hooks", action="store_true", dest="no_migrate_legacy_hooks",
2250
+ help="Auto-skip the legacy-bespoke-hook migration prompt (install only).",
2251
+ )
2252
+ sp.set_defaults(func=c.cmd_setup)
2253
+
2254
+ # ---- db (migration framework — spec §4) ----
2255
+ db_parser = sub.add_parser(
2256
+ "db",
2257
+ help="Migration / DB management (status, skip, unskip)",
2258
+ formatter_class=CLIHelpFormatter,
2259
+ description=textwrap.dedent(
2260
+ """\
2261
+ Inspect and manage cctally's SQLite migration state.
2262
+
2263
+ Subcommands:
2264
+ status List migrations + applied/pending/failed/skipped
2265
+ state across stats.db and cache.db. Glyphs:
2266
+ ✓ applied ✗ failed · pending ~ skipped
2267
+ skip Mark a migration as skipped (manual poison-pill
2268
+ escape — bypass an offending migration).
2269
+ unskip Remove a skip mark; the migration runs on next
2270
+ open.
2271
+
2272
+ Migration names accept either bare ("003_…") or qualified
2273
+ ("stats.db:003_…" / "cache.db:003_…") forms. Bare names are
2274
+ rejected with exit 2 if the same NNN_… exists in both
2275
+ registries.
2276
+
2277
+ Examples:
2278
+ cctally db status
2279
+ cctally db status --json
2280
+ cctally db skip 003_merge_5h_block_duplicates_v1 --reason "perf hot"
2281
+ cctally db unskip stats.db:003_merge_5h_block_duplicates_v1
2282
+ """
2283
+ ),
2284
+ )
2285
+ db_sub = db_parser.add_subparsers(dest="db_action", required=True)
2286
+
2287
+ db_status = db_sub.add_parser(
2288
+ "status",
2289
+ help="List migrations + applied/pending/failed/skipped state",
2290
+ )
2291
+ db_status.add_argument(
2292
+ "--json",
2293
+ action="store_true",
2294
+ help="Emit JSON to stdout",
2295
+ )
2296
+ db_status.set_defaults(func=c.cmd_db_status)
2297
+
2298
+ db_skip = db_sub.add_parser(
2299
+ "skip",
2300
+ help="Mark a migration as skipped",
2301
+ )
2302
+ db_skip.add_argument(
2303
+ "name",
2304
+ help="Migration name (NNN_… or stats.db:NNN_… / cache.db:NNN_…)",
2305
+ )
2306
+ db_skip.add_argument(
2307
+ "--reason",
2308
+ help="Free-text reason (shown in db status)",
2309
+ )
2310
+ db_skip.set_defaults(func=c.cmd_db_skip)
2311
+
2312
+ db_unskip = db_sub.add_parser(
2313
+ "unskip",
2314
+ help="Remove a skip mark; migration runs on next open",
2315
+ )
2316
+ db_unskip.add_argument(
2317
+ "name",
2318
+ help="Migration name (NNN_… or qualified)",
2319
+ )
2320
+ db_unskip.set_defaults(func=c.cmd_db_unskip)
2321
+
2322
+ # ─── doctor (Diagnostics) ───────────────────────────────────────────
2323
+ doctor_p = sub.add_parser(
2324
+ "doctor",
2325
+ help="Diagnose data freshness and install state",
2326
+ formatter_class=CLIHelpFormatter,
2327
+ description=textwrap.dedent(
2328
+ """\
2329
+ Run all read-only diagnostic checks and emit a report.
2330
+
2331
+ Categories: install, hooks, auth, db, data, safety. Each
2332
+ category renders a severity (✓ ok / ⚠ warn / ✗ fail) and
2333
+ actionable remediation guidance for non-OK rows.
2334
+
2335
+ Exit code: 0 unless any check is FAIL (then exit 2). WARN
2336
+ rows do not change the exit code — doctor is a read-only
2337
+ diagnostic and warn-class findings are advisories.
2338
+
2339
+ See docs/commands/doctor.md for the full check inventory
2340
+ and JSON schema reference.
2341
+ """
2342
+ ),
2343
+ )
2344
+ doctor_p.add_argument(
2345
+ "--json", action="store_true",
2346
+ help="Emit machine-readable JSON to stdout (schema_version: 1)",
2347
+ )
2348
+ doctor_mutex = doctor_p.add_mutually_exclusive_group()
2349
+ doctor_mutex.add_argument(
2350
+ "--quiet", "-q", action="store_true",
2351
+ help="Hide OK rows (human mode only)",
2352
+ )
2353
+ doctor_mutex.add_argument(
2354
+ "--verbose", "-v", action="store_true",
2355
+ help="Include each check's details block (human mode only)",
2356
+ )
2357
+ doctor_p.set_defaults(func=c.cmd_doctor)
2358
+
2359
+ # ---- pricing-check (standalone diagnostic — NOT under claude/codex) ----
2360
+ pc_p = sub.add_parser(
2361
+ "pricing-check",
2362
+ help="Detect stale or missing embedded model pricing",
2363
+ formatter_class=CLIHelpFormatter,
2364
+ description=textwrap.dedent(
2365
+ """\
2366
+ Check whether cctally's embedded model pricing is stale or
2367
+ missing, across three independently-degrading legs:
2368
+
2369
+ • coverage (offline, all-history) — models in your cached
2370
+ session data that cctally cannot price (Claude $0) or only
2371
+ approximates (Codex gpt-5 fallback).
2372
+ • drift (network, LiteLLM) — embedded price values vs the
2373
+ LiteLLM snapshot (direction-aware; allowlist-suppressed).
2374
+ • existence (network, Anthropic /v1/models) — vendor models the
2375
+ API offers that our table lacks. Maintainer-local (needs
2376
+ OAuth); degrades to skipped/degraded otherwise.
2377
+
2378
+ Exit codes:
2379
+ 0 — no actionable findings (fully clean, OR partially/fully
2380
+ network-degraded but nothing actionable; --json still
2381
+ carries "status":"degraded").
2382
+ 1 — any actionable finding (a coverage gap, value drift,
2383
+ missing-from-us, or an existence gap) — EVEN IF a network
2384
+ leg degraded. Findings always win over degradation.
2385
+ 2 — argument/usage error.
2386
+
2387
+ "status" (ok|degraded) reports check completeness; the exit code
2388
+ reports whether you must act. They are orthogonal.
2389
+
2390
+ See docs/commands/pricing-check.md for the JSON schema.
2391
+ """
2392
+ ),
2393
+ )
2394
+ pc_p.add_argument(
2395
+ "--json", action="store_true",
2396
+ help="Emit machine-readable JSON to stdout (schemaVersion: 1)",
2397
+ )
2398
+ pc_p.add_argument(
2399
+ "--offline", action="store_true",
2400
+ help="Coverage only — skip both network legs (LiteLLM + /v1/models)",
2401
+ )
2402
+ pc_p.set_defaults(func=c.cmd_pricing_check)
2403
+
2404
+ # `release` is its own standalone entry-point (bin/cctally-release);
2405
+ # no `release` subparser is registered on the main `cctally` CLI.
2406
+ # See docs/RELEASE.md.
2407
+
2408
+ # ---- hook-tick (internal — hidden from --help, see onboarding spec §3) ----
2409
+ ht = sub.add_parser(
2410
+ "hook-tick",
2411
+ help=argparse.SUPPRESS,
2412
+ formatter_class=CLIHelpFormatter,
2413
+ description=textwrap.dedent(
2414
+ """\
2415
+ Internal subcommand invoked by Claude Code hooks.
2416
+
2417
+ Reads CC's hook payload from stdin, runs sync_cache, and
2418
+ conditionally refreshes the OAuth usage cache (throttled).
2419
+ Returns 0 unconditionally in normal mode.
2420
+ """
2421
+ ),
2422
+ )
2423
+ ht.add_argument("--explain", action="store_true",
2424
+ help="Run synchronously, print decision tree, exit informative code")
2425
+ ht.add_argument("--no-oauth", action="store_true",
2426
+ help="Skip the OAuth refresh entirely (local sync only)")
2427
+ ht.add_argument("--throttle-seconds", type=float, default=None,
2428
+ help=f"Override throttle (default {int(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)}s)")
2429
+ ht.add_argument("--event", type=str, default=None,
2430
+ help="Override the event name written to the log line "
2431
+ "(used by --explain and tests)")
2432
+ ht.add_argument("--mock-oauth-response", type=str, default=None,
2433
+ help=argparse.SUPPRESS) # JSON string fed to mock fetch (tests only)
2434
+ ht.set_defaults(func=c.cmd_hook_tick)
2435
+
2436
+ # ---- update (user-facing self-update subcommand; spec §4) ----
2437
+ sub_update = sub.add_parser(
2438
+ "update",
2439
+ help="Update cctally to the latest version",
2440
+ formatter_class=CLIHelpFormatter,
2441
+ description=textwrap.dedent(
2442
+ """\
2443
+ Update cctally to the latest version (npm/brew installs only).
2444
+
2445
+ Modes:
2446
+ cctally update install the latest version
2447
+ cctally update --check show update info without installing
2448
+ cctally update --skip [VER] don't remind about VER (default: latest)
2449
+ cctally update --remind-later [DAYS] defer the banner (default: 7)
2450
+ """
2451
+ ),
2452
+ )
2453
+ update_modes = sub_update.add_mutually_exclusive_group()
2454
+ update_modes.add_argument(
2455
+ "--check", action="store_true",
2456
+ help="Show update info without installing",
2457
+ )
2458
+ update_modes.add_argument(
2459
+ "--skip", nargs="?", const=c.SKIP_USE_STATE_LATEST, metavar="VERSION",
2460
+ default=None,
2461
+ help="Skip a specific version (default: latest in cache)",
2462
+ )
2463
+ update_modes.add_argument(
2464
+ "--remind-later", nargs="?", type=int, const=7, metavar="DAYS",
2465
+ default=None,
2466
+ help="Defer reminders by N days (default: 7)",
2467
+ )
2468
+ # `--version` here is local to the update subparser. The subparser's
2469
+ # value is bound to `args.install_version` (NOT `args.version`) to
2470
+ # avoid a namespace collision with the top-level `--version`
2471
+ # (store_true) flag handled in `main()` before subcommand dispatch:
2472
+ # if both used `dest="version"`, `cctally update --version 1.2.3`
2473
+ # would set `args.version="1.2.3"`, which `main()`'s truthy check
2474
+ # would treat as "global --version requested" and short-circuit to
2475
+ # print the version banner before `cmd_update` ever ran.
2476
+ sub_update.add_argument(
2477
+ "--version", metavar="X.Y.Z", default=None, dest="install_version",
2478
+ help="Install a specific version (npm only; brew has no versioned formulae)",
2479
+ )
2480
+ sub_update.add_argument(
2481
+ "--dry-run", action="store_true",
2482
+ help="Show what would happen, don't install",
2483
+ )
2484
+ sub_update.add_argument(
2485
+ "--force", action="store_true",
2486
+ help="Bypass TTL on --check (force a fresh remote fetch)",
2487
+ )
2488
+ sub_update.add_argument(
2489
+ "--json", action="store_true",
2490
+ help="Emit JSON output (mostly with --check)",
2491
+ )
2492
+ sub_update.set_defaults(func=c.cmd_update)
2493
+
2494
+ # ---- _update-check (internal — hidden, detached-refresh worker for `cctally update`) ----
2495
+ uc = sub.add_parser(
2496
+ "_update-check",
2497
+ help=argparse.SUPPRESS,
2498
+ formatter_class=CLIHelpFormatter,
2499
+ description=textwrap.dedent(
2500
+ """\
2501
+ Internal subcommand: detached version-check worker spawned
2502
+ by `cctally update` (spec §3.6). Touches the throttle
2503
+ marker, fetches the latest version from npm or homebrew
2504
+ depending on install method, and writes update-state.json.
2505
+ Always returns 0; failures are logged to update.log.
2506
+ """
2507
+ ),
2508
+ )
2509
+ uc.set_defaults(func=c.cmd_update_check_internal)
2510
+
2511
+ # ---- repair-symlinks (internal — hidden; npm-postinstall self-heal, issue #114) ----
2512
+ rs = sub.add_parser(
2513
+ "repair-symlinks",
2514
+ help=argparse.SUPPRESS,
2515
+ formatter_class=CLIHelpFormatter,
2516
+ description=textwrap.dedent(
2517
+ """\
2518
+ Internal subcommand: additively create any missing
2519
+ ~/.local/bin/ symlinks for cctally subcommands (issue #114).
2520
+
2521
+ Invoked best-effort by the npm postinstall on upgrade so new
2522
+ cctally-* binaries become reachable without re-running
2523
+ `cctally setup`. Gated to existing installs (>=1 symlink
2524
+ already present); a fresh install is a silent no-op. Touches
2525
+ only symlinks — no hooks, settings.json, or cache. Refuses
2526
+ from a dev checkout.
2527
+ """
2528
+ ),
2529
+ )
2530
+ rs.set_defaults(func=c.cmd_repair_symlinks)
2531
+
2532
+ # Python 3.14 leaks `==SUPPRESS==` for hidden subparsers in --help; strip
2533
+ # the pseudo-action so the row disappears entirely. (The choice still
2534
+ # appears in the `{...}` choices header — there's no clean way to hide
2535
+ # that without a custom formatter, and it's harmless.)
2536
+ sub._choices_actions = [
2537
+ a for a in getattr(sub, "_choices_actions", [])
2538
+ if getattr(a, "help", None) is not argparse.SUPPRESS
2539
+ ]
2540
+
2541
+ return p