cctally 1.22.0 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cctally CHANGED
@@ -349,23 +349,26 @@ format_display_dt = _lib_display_tz.format_display_dt
349
349
  _argparse_tz = _lib_display_tz._argparse_tz
350
350
 
351
351
 
352
- def _nonneg_int(raw: str) -> int:
353
- """argparse `type=` validator for non-negative integer flags (issue #89).
354
-
355
- Used by ``--debug-samples`` so a negative N is rejected at parse time
356
- rather than silently coerced inside the helper. Raises
357
- ``argparse.ArgumentTypeError`` so argparse surfaces the message under
358
- the standard ``argument <flag>:`` prefix.
359
- """
360
- try:
361
- n = int(raw)
362
- except ValueError:
363
- raise argparse.ArgumentTypeError(
364
- f"must be a non-negative integer, got '{raw}'"
365
- )
366
- if n < 0:
367
- raise argparse.ArgumentTypeError(f"must be >= 0, got {n}")
368
- return n
352
+ _cctally_parser = _load_sibling("_cctally_parser")
353
+ build_parser = _cctally_parser.build_parser
354
+ _nonneg_int = _cctally_parser._nonneg_int
355
+ CLIHelpFormatter = _cctally_parser.CLIHelpFormatter
356
+ _argparse_has_arg = _cctally_parser._argparse_has_arg
357
+ _add_mode_arg = _cctally_parser._add_mode_arg
358
+ _add_ccusage_alias_args = _cctally_parser._add_ccusage_alias_args
359
+ _add_codex_shared_args = _cctally_parser._add_codex_shared_args
360
+ _add_share_args = _cctally_parser._add_share_args
361
+ _share_validate_args = _cctally_parser._share_validate_args
362
+ _build_daily_parser = _cctally_parser._build_daily_parser
363
+ _build_monthly_parser = _cctally_parser._build_monthly_parser
364
+ _build_weekly_parser = _cctally_parser._build_weekly_parser
365
+ _build_session_parser = _cctally_parser._build_session_parser
366
+ _build_blocks_parser = _cctally_parser._build_blocks_parser
367
+ _build_statusline_parser = _cctally_parser._build_statusline_parser
368
+ _build_codex_daily_parser = _cctally_parser._build_codex_daily_parser
369
+ _build_codex_monthly_parser = _cctally_parser._build_codex_monthly_parser
370
+ _build_codex_weekly_parser = _cctally_parser._build_codex_weekly_parser
371
+ _build_codex_session_parser = _cctally_parser._build_codex_session_parser
369
372
 
370
373
 
371
374
  _lib_alerts_payload = _load_sibling("_lib_alerts_payload")
@@ -498,6 +501,42 @@ _render_project_table = _lib_render._render_project_table
498
501
  _five_hour_blocks_to_json = _lib_render._five_hour_blocks_to_json
499
502
  _render_five_hour_blocks_table = _lib_render._render_five_hour_blocks_table
500
503
 
504
+ # Eager re-export of bin/_cctally_share.py (eager sibling holding the
505
+ # share destination/emit path + the _build_*_snapshot builders + the
506
+ # _share_* helpers). Loaded here, after _lib_render / _lib_display_tz /
507
+ # _lib_changelog (its three sibling deps), so its module-top imports
508
+ # resolve. Every moved symbol is re-bound onto cctally's __dict__ so the
509
+ # cmd_* handlers' bare `_share_render_and_emit(...)` / `_build_*_snapshot(...)`
510
+ # calls, the staying _build_budget_snapshot dispatch, and the dashboard's
511
+ # `sys.modules["cctally"].X` share thunks all resolve to the same objects.
512
+ _cctally_share = _load_sibling("_cctally_share")
513
+ _DOWNLOADS_HOME_HINT_EMITTED = _cctally_share._DOWNLOADS_HOME_HINT_EMITTED
514
+ _share_resolve_download_dir = _cctally_share._share_resolve_download_dir
515
+ _share_unique_path = _cctally_share._share_unique_path
516
+ _resolve_destination = _cctally_share._resolve_destination
517
+ _emit = _cctally_share._emit
518
+ _share_load_lib = _cctally_share._share_load_lib
519
+ _share_now_utc = _cctally_share._share_now_utc
520
+ _share_now_utc_iso = _cctally_share._share_now_utc_iso
521
+ _SHARE_HISTORY_RING_CAP = _cctally_share._SHARE_HISTORY_RING_CAP
522
+ _share_history_recipe_id = _cctally_share._share_history_recipe_id
523
+ _share_resolve_version = _cctally_share._share_resolve_version
524
+ _share_period_label = _cctally_share._share_period_label
525
+ _share_parse_date_to_dt = _cctally_share._share_parse_date_to_dt
526
+ _share_display_tz_label = _cctally_share._share_display_tz_label
527
+ _build_report_snapshot = _cctally_share._build_report_snapshot
528
+ _build_daily_snapshot = _cctally_share._build_daily_snapshot
529
+ _build_monthly_snapshot = _cctally_share._build_monthly_snapshot
530
+ _build_weekly_snapshot = _cctally_share._build_weekly_snapshot
531
+ _build_forecast_snapshot = _cctally_share._build_forecast_snapshot
532
+ _build_project_snapshot = _cctally_share._build_project_snapshot
533
+ _build_five_hour_blocks_snapshot = _cctally_share._build_five_hour_blocks_snapshot
534
+ _session_disambiguate_labels = _cctally_share._session_disambiguate_labels
535
+ _build_session_snapshot = _cctally_share._build_session_snapshot
536
+ _share_iso = _cctally_share._share_iso
537
+ _share_render_and_emit = _cctally_share._share_render_and_emit
538
+ _share_open_file = _cctally_share._share_open_file
539
+
501
540
  # Eager re-export of bin/_cctally_setup.py (lazy I/O sibling that loads
502
541
  # at startup to keep `ns["cmd_setup"](...)` / `ns["_setup_X"](...)`
503
542
  # direct-dict test patterns working — dict-key access on `mod.__dict__`
@@ -11013,17 +11052,6 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
11013
11052
  return 0
11014
11053
 
11015
11054
 
11016
- class CLIHelpFormatter(
11017
- argparse.ArgumentDefaultsHelpFormatter,
11018
- argparse.RawDescriptionHelpFormatter,
11019
- ):
11020
- """Human-friendly formatter for multi-line help and default values."""
11021
-
11022
- def __init__(self, prog: str, **kwargs: object) -> None:
11023
- kwargs.setdefault("max_help_position", 30)
11024
- super().__init__(prog, **kwargs) # type: ignore[arg-type]
11025
-
11026
-
11027
11055
  # Legacy bespoke hook set — see docs/superpowers/specs/2026-05-09-auto-migrate-legacy-hooks-design.md
11028
11056
  _LEGACY_BESPOKE_HOOKS_DIR = pathlib.Path.home() / ".claude" / "hooks"
11029
11057
  _LEGACY_BESPOKE_COMMANDS: tuple[tuple[str, str], ...] = (
@@ -11828,4168 +11856,6 @@ def cmd_doctor(args: argparse.Namespace) -> int:
11828
11856
  return 2 if report.overall_severity == "fail" else 0
11829
11857
 
11830
11858
 
11831
- def _argparse_has_arg(parser, option_string: str) -> bool:
11832
- """Return True if ``parser`` already registered ``option_string``."""
11833
- for action in parser._actions:
11834
- if option_string in (action.option_strings or ()):
11835
- return True
11836
- return False
11837
-
11838
-
11839
- def _add_mode_arg(parser, *, noop: bool = False) -> None:
11840
- """Add ccusage's -m/--mode {auto,calculate,display} cost-source flag.
11841
-
11842
- Standalone (not folded into _add_ccusage_alias_args) so it lands only
11843
- on the six Session-C reporting commands and never collides with
11844
- range-cost, which defines its own -m/--mode.
11845
-
11846
- noop=True (five-hour-blocks only): the flag is accepted for surface
11847
- parity with `blocks` but does not alter numbers — that command's cost
11848
- is the authoritative materialized five_hour_blocks.total_cost_usd
11849
- computed at record-time (always auto semantics).
11850
- """
11851
- help_real = (
11852
- "Cost source: auto (recorded costUSD when present, else computed), "
11853
- "calculate (always compute from embedded pricing), display "
11854
- "(recorded costUSD only; $0 when absent). Default: auto."
11855
- )
11856
- help_noop = (
11857
- "Accepted for ccusage drop-in compat; no-op here — five-hour-blocks "
11858
- "cost is the authoritative materialized per-block value computed at "
11859
- "record-time. Default: auto."
11860
- )
11861
- parser.add_argument(
11862
- "-m", "--mode",
11863
- default="auto",
11864
- choices=["auto", "calculate", "display"],
11865
- help=help_noop if noop else help_real,
11866
- )
11867
-
11868
-
11869
- def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
11870
- """Attach the Session A ccusage alias surface to a Claude-cmd subparser.
11871
-
11872
- Sibling to ``_add_codex_shared_args`` (declared inside ``build_parser``)
11873
- but tailored for Claude commands. Every flag is guarded with
11874
- ``_argparse_has_arg`` so existing per-parser declarations
11875
- (cache-report's ``--offline``, project / five-hour-blocks / diff's
11876
- ``--no-color``) do NOT cause ``argparse.ArgumentError`` — the helper
11877
- just skips the duplicate. This makes future collisions self-healing
11878
- when a contributor adds a Session A-managed flag directly on a
11879
- subparser.
11880
-
11881
- Args:
11882
- parser: the subparser to mutate.
11883
- ansi_emit: ``True`` for project + diff (the 2 real ANSI emitters).
11884
- ``False`` for the other 8 in-scope cmds. Controls only
11885
- the ``--color`` help text and whether ``--no-color`` is
11886
- attempted as a fresh add (when ``ansi_emit=True`` we
11887
- skip ``--no-color`` entirely — those parsers already
11888
- declared it themselves).
11889
-
11890
- Spec §7.1.2 / issue #86 Session A.
11891
- """
11892
-
11893
- def _maybe_add(opt: str, *args, **kwargs):
11894
- if _argparse_has_arg(parser, opt):
11895
- return
11896
- parser.add_argument(opt, *args, **kwargs)
11897
-
11898
- def _maybe_add2(opt1: str, opt2: str, *args, **kwargs):
11899
- # Two-form add (short + long) — skip if EITHER is present.
11900
- if _argparse_has_arg(parser, opt1) or _argparse_has_arg(parser, opt2):
11901
- return
11902
- parser.add_argument(opt1, opt2, *args, **kwargs)
11903
-
11904
- _maybe_add2(
11905
- "-z", "--timezone", default=None, metavar="TZ",
11906
- help="Alias for --tz (drop-in compat with ccusage). When both "
11907
- "are supplied, --tz wins.",
11908
- )
11909
- _maybe_add2(
11910
- "-O", "--offline",
11911
- action=argparse.BooleanOptionalAction, default=False,
11912
- help="Accepted for ccusage drop-in compat; cctally is always offline.",
11913
- )
11914
- _maybe_add(
11915
- "--compact", action="store_true",
11916
- help="Force compact table layout regardless of terminal width.",
11917
- )
11918
- _maybe_add(
11919
- "--config", default=None, metavar="PATH",
11920
- help="Read config from PATH for this invocation only (no "
11921
- "mutation of the default config at "
11922
- "~/.local/share/cctally/config.json). Missing or invalid "
11923
- "PATH errors out with a clear message.",
11924
- )
11925
- _maybe_add2(
11926
- "-d", "--debug", action="store_true",
11927
- help="Emit a stderr 'Pricing Mismatch Debug Report' "
11928
- "(totals + per-model stats + sample discrepancies, "
11929
- "matching ccusage's --debug shape).",
11930
- )
11931
- _maybe_add(
11932
- "--debug-samples", type=_nonneg_int, default=5, metavar="N",
11933
- help="Cap on sample-discrepancy rows in the --debug report "
11934
- "(default 5; N=0 suppresses the sample block; "
11935
- "negatives rejected at parse time).",
11936
- )
11937
- _maybe_add(
11938
- "--single-thread", action="store_true",
11939
- help="Accepted for ccusage drop-in compat; cctally ingestion "
11940
- "is already single-threaded via the session-entry cache.",
11941
- )
11942
- if ansi_emit:
11943
- _maybe_add(
11944
- "--color", action="store_true", default=False,
11945
- help="Force ANSI color output (overrides NO_COLOR env). When "
11946
- "neither --color nor --no-color is set, color is auto-"
11947
- "detected from isatty() and NO_COLOR/FORCE_COLOR env.",
11948
- )
11949
- # --no-color already declared on these parsers; do nothing here.
11950
- else:
11951
- # No-op-for-compat surface (spec §7.3): these flags parse but do
11952
- # NOT flow through the color resolver on this command. Color (where
11953
- # the renderer emits any) follows the auto-detect — isatty() plus
11954
- # NO_COLOR / FORCE_COLOR env — so the help must NOT claim "no ANSI
11955
- # is emitted" (daily/monthly/weekly/blocks/session/cache-report DO
11956
- # emit auto-detected ANSI on a TTY; only the no-color env vars
11957
- # suppress it). Force/suppress color on the 2 real ANSI commands
11958
- # (project, diff) instead, or use NO_COLOR=1 / FORCE_COLOR=1.
11959
- _maybe_add(
11960
- "--color", action="store_true", default=False,
11961
- help="Accepted for ccusage drop-in compat; does not control "
11962
- "this command's color. Color auto-detects from isatty() "
11963
- "and honors NO_COLOR / FORCE_COLOR env.",
11964
- )
11965
- _maybe_add(
11966
- "--no-color", action="store_true", default=False,
11967
- help="Accepted for ccusage drop-in compat; does not suppress "
11968
- "this command's color. Use NO_COLOR=1 env (or pipe stdout) "
11969
- "to disable auto-detected ANSI.",
11970
- )
11971
-
11972
-
11973
- def _add_codex_shared_args(parser: argparse.ArgumentParser) -> None:
11974
- """Register upstream `ccusage-codex sharedArgs` on a codex subparser.
11975
-
11976
- Upstream sharedArgs (node_modules/@ccusage/codex/dist/index.js):
11977
- --timezone/-z, --locale/-l, --compact, --color, --noColor,
11978
- --offline/--no-offline.
11979
-
11980
- Honored here: --timezone (dates + aggregation buckets) and
11981
- --compact (table layout). Accepted-but-no-op (stored on the
11982
- namespace for drop-in parity with upstream scripts): --locale
11983
- (we don't locale-format dates), --color / --noColor (we don't
11984
- emit ANSI codes today). --offline is accepted as a no-op too
11985
- (we are always offline); it uses BooleanOptionalAction so
11986
- `--no-offline` also parses cleanly. `-O` is kept as the short
11987
- form for offline for backward compat with earlier builds.
11988
- """
11989
- parser.add_argument(
11990
- "-z", "--timezone", default=None, metavar="TZ",
11991
- help="IANA timezone for date bucketing and Date/Last Activity cells.",
11992
- )
11993
- parser.add_argument(
11994
- "-l", "--locale", default=None, metavar="LOCALE",
11995
- help="Accepted for drop-in compat; no-op (dates are not locale-formatted).",
11996
- )
11997
- parser.add_argument(
11998
- "--compact", action="store_true",
11999
- help="Force compact table layout regardless of terminal width.",
12000
- )
12001
- parser.add_argument(
12002
- "--color", action="store_true",
12003
- help="Accepted for drop-in compat; no-op today (no ANSI escapes are emitted).",
12004
- )
12005
- parser.add_argument(
12006
- "--noColor", action="store_true", dest="no_color",
12007
- help="Accepted for drop-in compat; no-op today (no ANSI escapes are emitted).",
12008
- )
12009
- parser.add_argument(
12010
- "-O", "--offline", action=argparse.BooleanOptionalAction, default=False,
12011
- help="Accepted for drop-in compat with ccusage-codex; we are always offline.",
12012
- )
12013
- parser.add_argument(
12014
- "--speed", choices=("auto", "standard", "fast"), default="auto",
12015
- help="Codex pricing tier. auto (default) reads service_tier from "
12016
- "~/.codex/config.toml (fast|priority -> fast pricing); fast "
12017
- "forces the fast-tier multiplier; standard forces base pricing.",
12018
- )
12019
- parser.add_argument(
12020
- "--tz", default=None, type=_argparse_tz, metavar="TZ",
12021
- help="Display timezone: local, utc, or IANA name. Overrides "
12022
- "config display.tz for this call. Takes precedence over "
12023
- "upstream's --timezone for drop-in parity.",
12024
- )
12025
- # Issue #92: codex parity for the #89 --debug surface. Codex JSONL
12026
- # has no recorded costUSD to diff against, so the report is the
12027
- # codex variant ("Codex Pricing Debug Report": totals + top-N
12028
- # highest computed-cost entries), wired via
12029
- # _emit_codex_debug_samples_if_set in each cmd_codex_* body.
12030
- parser.add_argument(
12031
- "-d", "--debug", action="store_true",
12032
- help="Emit a stderr 'Codex Pricing Debug Report' (totals + "
12033
- "the N highest computed-cost sample entries).",
12034
- )
12035
- parser.add_argument(
12036
- "--debug-samples", type=_nonneg_int, default=5, metavar="N",
12037
- help="Cap on top-entry sample rows in the --debug report "
12038
- "(default 5; N=0 suppresses the sample block; "
12039
- "negatives rejected at parse time).",
12040
- )
12041
-
12042
-
12043
- def _add_share_args(parser, *, has_status_line: bool = False) -> None:
12044
- """Attach shareable-reports flags + format/json mutex to a subparser.
12045
-
12046
- Idempotent — call exactly once per subparser. Caller MUST remove any
12047
- pre-existing ``--json`` (and ``--status-line`` for forecast) from the
12048
- subparser before invoking this helper, so the mutex group owns those
12049
- flags. Raises ``RuntimeError`` on contract violation — surfaces at
12050
- parser-build time (i.e., on every CLI invocation, including ``--help``)
12051
- instead of at the user invocation that hits the unguarded
12052
- ``--format --json`` combo. The prior shape silently skipped re-adding,
12053
- leaving the mutex unenforced for any future 9th share-enabled subparser
12054
- whose existing ``--json`` was accidentally left in place.
12055
- """
12056
- if _argparse_has_arg(parser, "--json"):
12057
- raise RuntimeError(
12058
- f"_add_share_args: parser {parser.prog!r} already has --json; "
12059
- "remove it before calling _add_share_args so mutex applies"
12060
- )
12061
- if has_status_line and _argparse_has_arg(parser, "--status-line"):
12062
- raise RuntimeError(
12063
- f"_add_share_args: parser {parser.prog!r} already has --status-line; "
12064
- "remove it before calling _add_share_args(has_status_line=True)"
12065
- )
12066
- output_group = parser.add_mutually_exclusive_group()
12067
- output_group.add_argument(
12068
- "--format", choices=("md", "html", "svg"),
12069
- help="Render output as shareable markdown, self-contained HTML, or SVG. "
12070
- "Default destination: md->stdout, html/svg->~/Downloads file.")
12071
- output_group.add_argument(
12072
- "--json", action="store_true",
12073
- help="Emit machine-readable JSON; suppresses terminal render.")
12074
- if has_status_line:
12075
- output_group.add_argument(
12076
- "--status-line", action="store_true", dest="status_line",
12077
- help="Emit one-line compact string for status-line injection.")
12078
-
12079
- parser.add_argument(
12080
- "--theme", choices=("light", "dark"), default="light",
12081
- help="Color theme for HTML/SVG (default: light). No-op for markdown.")
12082
- parser.add_argument(
12083
- "--no-branding", action="store_true", dest="no_branding",
12084
- help="Strip the 'Generated by cctally' footer from --format output.")
12085
- parser.add_argument(
12086
- "--output", metavar="PATH",
12087
- help="Write --format output to PATH instead of the default destination "
12088
- "(stdout for md; ~/Downloads/cctally-<cmd>-<utcdate>.<ext> for html/svg). "
12089
- "Use '-' for stdout.")
12090
- parser.add_argument(
12091
- "--copy", action="store_true",
12092
- help="Pipe --format md output to clipboard (pbcopy/xclip/clip). "
12093
- "Rejected for html/svg.")
12094
- parser.add_argument(
12095
- "--open", action="store_true", dest="open_after_write",
12096
- help="After writing --format html/svg to a file, open it in the default app. "
12097
- "Rejected for md.")
12098
-
12099
-
12100
- def _share_validate_args(args) -> None:
12101
- """Reject share flag combinations BEFORE any DB / sync / render work.
12102
-
12103
- Two layers of validation:
12104
-
12105
- 1. Share-only flags (``--output``, ``--copy``, ``--open``) require
12106
- ``--format``. Silent dropping trains users to assume the file
12107
- was written.
12108
-
12109
- 2. Destination-shape combinations (``--copy`` + ``--output``,
12110
- ``--copy`` + non-md, ``--open`` + md, ``--open`` + ``--output -``).
12111
- These were previously caught only inside ``_resolve_destination``
12112
- / ``_share_render_and_emit`` — i.e. AFTER ``--sync-current`` had
12113
- already mutated the DB and the snapshot had been built. Surfacing
12114
- them at validation time means an exit-2 flag-shape error never
12115
- triggers side effects.
12116
-
12117
- Exit 2 with a stderr message naming the offending combo so the
12118
- failure is loud and scriptable. Idempotent; safe to call from every
12119
- share-enabled subcommand before the ``--format`` gate. Existing
12120
- late checks in ``_resolve_destination`` / ``_share_render_and_emit``
12121
- are kept as defense-in-depth for any future caller that bypasses
12122
- this helper.
12123
- """
12124
- if not getattr(args, "format", None):
12125
- offenders = []
12126
- if getattr(args, "output", None):
12127
- offenders.append("--output")
12128
- if getattr(args, "copy", False):
12129
- offenders.append("--copy")
12130
- if getattr(args, "open_after_write", False):
12131
- offenders.append("--open")
12132
- if not offenders:
12133
- return
12134
- verb = "requires" if len(offenders) == 1 else "require"
12135
- sys.stderr.write(
12136
- f"cctally: {', '.join(offenders)} {verb} --format\n"
12137
- )
12138
- sys.exit(2)
12139
-
12140
- # --format is set — validate destination-shape combos.
12141
- fmt = args.format
12142
- copy = getattr(args, "copy", False)
12143
- output = getattr(args, "output", None)
12144
- open_after_write = getattr(args, "open_after_write", False)
12145
-
12146
- if copy and output is not None:
12147
- # Mutex: a clipboard destination by definition has no path.
12148
- sys.stderr.write(
12149
- "cctally: --copy is mutually exclusive with --output\n"
12150
- )
12151
- sys.exit(2)
12152
- if copy and fmt != "md":
12153
- sys.stderr.write(
12154
- "cctally: --copy is only valid with --format md\n"
12155
- )
12156
- sys.exit(2)
12157
- if open_after_write and fmt == "md":
12158
- sys.stderr.write(
12159
- "cctally: --open is only valid with --format html or --format svg\n"
12160
- )
12161
- sys.exit(2)
12162
- if open_after_write and output == "-":
12163
- # Open-after-write to stdout has no file to launch — was a silent
12164
- # no-op pre-fix; now an explicit exit 2 so users notice.
12165
- sys.stderr.write(
12166
- "cctally: --open is incompatible with --output - (no file to open)\n"
12167
- )
12168
- sys.exit(2)
12169
-
12170
-
12171
- def _build_daily_parser(subparsers, name, *, help_text, xref):
12172
- """Build the `daily` leaf parser (issue #86 Session B; routes to cmd_daily).
12173
-
12174
- Build-once, register-twice: this body is the verbatim former inline `daily`
12175
- construction, parameterized only by `name`, the parent-list `help_text`, and
12176
- the `xref` appended to `description` (renders on `cctally <name> --help`).
12177
- """
12178
- p = subparsers.add_parser(
12179
- name,
12180
- help=help_text,
12181
- formatter_class=CLIHelpFormatter,
12182
- description="Show usage grouped by date, matching upstream ccusage daily output."
12183
- "\n\n" + xref,
12184
- epilog=textwrap.dedent("""\
12185
- Examples:
12186
- cctally daily --since 20260414
12187
- cctally daily --since 20260410 --until 20260416
12188
- cctally daily --since 20260414 --breakdown
12189
- cctally daily --since 20260414 --json
12190
- cctally daily --order desc
12191
- cctally daily --instances
12192
- cctally daily -i --project-aliases repos=Repos
12193
- """),
12194
- )
12195
- p.add_argument(
12196
- "-s", "--since",
12197
- default=None,
12198
- metavar="YYYYMMDD",
12199
- help="Filter from date (inclusive).",
12200
- )
12201
- p.add_argument(
12202
- "-u", "--until",
12203
- default=None,
12204
- metavar="YYYYMMDD",
12205
- help="Filter until date (inclusive).",
12206
- )
12207
- p.add_argument(
12208
- "-b", "--breakdown",
12209
- action="store_true",
12210
- help="Show per-model cost breakdown sub-rows.",
12211
- )
12212
- p.add_argument(
12213
- "-o", "--order",
12214
- choices=("asc", "desc"),
12215
- default="asc",
12216
- help="Sort direction by date (default: asc).",
12217
- )
12218
- p.add_argument(
12219
- "--reveal-projects",
12220
- action="store_true",
12221
- dest="reveal_projects",
12222
- help="In --format output, show real project basenames instead of "
12223
- "the default project-1, project-2, ... anonymization.",
12224
- )
12225
- p.add_argument(
12226
- "--tz", default=None, type=_argparse_tz, metavar="TZ",
12227
- help="Display timezone: local, utc, or IANA name. "
12228
- "Overrides config display.tz for this call.",
12229
- )
12230
- p.add_argument(
12231
- "-i", "--instances",
12232
- action="store_true",
12233
- default=False,
12234
- help="Group the report by project (git-root).",
12235
- )
12236
- p.add_argument(
12237
- "-p", "--project",
12238
- action="append",
12239
- default=None,
12240
- metavar="PATTERN",
12241
- help="Filter to projects matching PATTERN (substring of the project "
12242
- "label or path; repeatable, OR semantics).",
12243
- )
12244
- p.add_argument(
12245
- "--project-aliases",
12246
- dest="project_aliases",
12247
- default=None,
12248
- metavar="PAIRS",
12249
- help="Comma-separated key=Label pairs overriding project display "
12250
- "labels (e.g. cctally-dev=Tracker). Display-only.",
12251
- )
12252
- _add_ccusage_alias_args(p, ansi_emit=False)
12253
- _add_mode_arg(p)
12254
- _add_share_args(p)
12255
- p.set_defaults(func=cmd_daily)
12256
- return p
12257
-
12258
-
12259
- def _build_monthly_parser(subparsers, name, *, help_text, xref):
12260
- """Build the `monthly` leaf parser (issue #86 Session B; routes to cmd_monthly)."""
12261
- p = subparsers.add_parser(
12262
- name,
12263
- help=help_text,
12264
- formatter_class=CLIHelpFormatter,
12265
- description="Show usage grouped by calendar month, matching upstream ccusage monthly output."
12266
- "\n\n" + xref,
12267
- epilog=textwrap.dedent("""\
12268
- Examples:
12269
- cctally monthly --since 20260101
12270
- cctally monthly --since 20260101 --until 20260331
12271
- cctally monthly --since 20260101 --breakdown
12272
- cctally monthly --since 20260101 --json
12273
- cctally monthly --order desc
12274
- """),
12275
- )
12276
- p.add_argument(
12277
- "-s", "--since",
12278
- default=None,
12279
- metavar="YYYYMMDD",
12280
- help="Filter from date (inclusive).",
12281
- )
12282
- p.add_argument(
12283
- "-u", "--until",
12284
- default=None,
12285
- metavar="YYYYMMDD",
12286
- help="Filter until date (inclusive).",
12287
- )
12288
- p.add_argument(
12289
- "-b", "--breakdown",
12290
- action="store_true",
12291
- help="Show per-model cost breakdown sub-rows.",
12292
- )
12293
- p.add_argument(
12294
- "-o", "--order",
12295
- choices=("asc", "desc"),
12296
- default="asc",
12297
- help="Sort direction by month (default: asc).",
12298
- )
12299
- p.add_argument(
12300
- "--reveal-projects",
12301
- action="store_true",
12302
- dest="reveal_projects",
12303
- help="In --format output, show real project basenames instead of "
12304
- "the default project-1, project-2, ... anonymization.",
12305
- )
12306
- p.add_argument(
12307
- "--tz", default=None, type=_argparse_tz, metavar="TZ",
12308
- help="Display timezone: local, utc, or IANA name. "
12309
- "Overrides config display.tz for this call.",
12310
- )
12311
- _add_ccusage_alias_args(p, ansi_emit=False)
12312
- _add_mode_arg(p)
12313
- _add_share_args(p)
12314
- p.set_defaults(func=cmd_monthly)
12315
- return p
12316
-
12317
-
12318
- def _build_weekly_parser(subparsers, name, *, help_text, xref):
12319
- """Build the `weekly` leaf parser (issue #86 Session B; routes to cmd_weekly)."""
12320
- p = subparsers.add_parser(
12321
- name,
12322
- help=help_text,
12323
- formatter_class=CLIHelpFormatter,
12324
- description="Show Claude usage grouped by subscription week. Boundaries are anchored "
12325
- "to weekly_usage_snapshots.week_start_at with 7-day-cadence extrapolation "
12326
- "for pre-snapshot history. Columns extend daily/monthly's set with Used % "
12327
- "and $/1%."
12328
- "\n\n" + xref,
12329
- epilog=textwrap.dedent("""\
12330
- Examples:
12331
- cctally weekly
12332
- cctally weekly --since 20260101
12333
- cctally weekly --breakdown
12334
- cctally weekly --json
12335
- cctally weekly --order desc
12336
- """),
12337
- )
12338
- p.add_argument("-s", "--since", default=None, metavar="YYYYMMDD",
12339
- help="Filter from date (inclusive).")
12340
- p.add_argument("-u", "--until", default=None, metavar="YYYYMMDD",
12341
- help="Filter until date (inclusive).")
12342
- p.add_argument("-b", "--breakdown", action="store_true",
12343
- help="Show per-model cost breakdown sub-rows.")
12344
- p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
12345
- help="Sort direction by week (default: asc).")
12346
- p.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
12347
- help="In --format output, show real project basenames instead of "
12348
- "the default project-1, project-2, ... anonymization.")
12349
- p.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
12350
- help="Display timezone: local, utc, or IANA name. "
12351
- "Overrides config display.tz for this call.")
12352
- _add_ccusage_alias_args(p, ansi_emit=False)
12353
- _add_mode_arg(p)
12354
- _add_share_args(p)
12355
- p.set_defaults(func=cmd_weekly)
12356
- return p
12357
-
12358
-
12359
- def _build_session_parser(subparsers, name, *, help_text, xref):
12360
- """Build the `session` leaf parser (issue #86 Session B; routes to cmd_session)."""
12361
- p = subparsers.add_parser(
12362
- name,
12363
- help=help_text,
12364
- formatter_class=CLIHelpFormatter,
12365
- description="Show Claude usage grouped by JSONL sessionId. Resumed sessions (same "
12366
- "sessionId across multiple files) collapse into one row. 11-column "
12367
- "layout paralleling codex-session."
12368
- "\n\n" + xref,
12369
- epilog=textwrap.dedent("""\
12370
- Examples:
12371
- cctally session
12372
- cctally session --since 20260401
12373
- cctally session --since 20260401 --breakdown
12374
- cctally session --json
12375
- cctally session --order desc
12376
- """),
12377
- )
12378
- p.add_argument("-s", "--since", default=None, metavar="YYYYMMDD",
12379
- help="Filter from date (inclusive).")
12380
- p.add_argument("-u", "--until", default=None, metavar="YYYYMMDD",
12381
- help="Filter until date (inclusive).")
12382
- p.add_argument("-b", "--breakdown", action="store_true",
12383
- help="Show per-model cost breakdown sub-rows.")
12384
- p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
12385
- help="Sort direction by last activity (default: asc — earliest first).")
12386
- p.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
12387
- help="In --format output, show real project basenames instead of "
12388
- "the default project-1, project-2, ... anonymization.")
12389
- p.add_argument("--top-n", type=int, default=15, dest="top_n",
12390
- metavar="N",
12391
- help="In --format output, cap rows to top N by cost (default: 15). "
12392
- "Must be >= 1; values above 50 emit a readability warning. "
12393
- "Has no effect on terminal/JSON output.")
12394
- p.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
12395
- help="Display timezone: local, utc, or IANA name. "
12396
- "Overrides config display.tz for this call.")
12397
- p.add_argument(
12398
- "-i", "--id", default=None, metavar="SESSION_ID", dest="id",
12399
- help="Filter to a single session by exact-string sessionId. "
12400
- "Match is against the post-resume-merge id (sessions "
12401
- "resumed across multiple JSONL files collapse to one id). "
12402
- "Unknown id → exit 0 with the empty-render branch.",
12403
- )
12404
- _add_ccusage_alias_args(p, ansi_emit=False)
12405
- _add_mode_arg(p)
12406
- _add_share_args(p)
12407
- p.set_defaults(func=cmd_session)
12408
- return p
12409
-
12410
-
12411
- def _build_blocks_parser(subparsers, name, *, help_text, xref):
12412
- """Build the `blocks` leaf parser (issue #86 Session B; routes to cmd_blocks).
12413
-
12414
- Note: `blocks` intentionally has NO `_add_share_args` (matches the former
12415
- inline block — it is not part of the shareable-output flag surface).
12416
- """
12417
- p = subparsers.add_parser(
12418
- name,
12419
- help=help_text,
12420
- formatter_class=CLIHelpFormatter,
12421
- description="Show usage grouped by 5-hour session blocks, matching upstream ccusage blocks output."
12422
- "\n\n" + xref,
12423
- epilog=textwrap.dedent("""\
12424
- Examples:
12425
- cctally blocks --since 20260414
12426
- cctally blocks --since 20260410 --until 20260416
12427
- cctally blocks --since 20260414 --breakdown
12428
- cctally blocks --since 20260414 --json
12429
- """),
12430
- )
12431
- p.add_argument(
12432
- "-s", "--since",
12433
- default=None,
12434
- metavar="YYYYMMDD",
12435
- help="Filter from date (inclusive).",
12436
- )
12437
- p.add_argument(
12438
- "-u", "--until",
12439
- default=None,
12440
- metavar="YYYYMMDD",
12441
- help="Filter until date (inclusive).",
12442
- )
12443
- p.add_argument(
12444
- "-b", "--breakdown",
12445
- action="store_true",
12446
- help="Show per-model cost breakdown.",
12447
- )
12448
- p.add_argument(
12449
- "--json",
12450
- action="store_true",
12451
- dest="json",
12452
- help="Output JSON matching upstream ccusage blocks format.",
12453
- )
12454
- p.add_argument(
12455
- "--tz", default=None, type=_argparse_tz, metavar="TZ",
12456
- help="Display timezone: local, utc, or IANA name. "
12457
- "Overrides config display.tz for this call.",
12458
- )
12459
- p.add_argument(
12460
- "-a", "--active", action="store_true",
12461
- help="Show only the active block, with burn-rate + projection "
12462
- "(ccusage drop-in).",
12463
- )
12464
- p.add_argument(
12465
- "-r", "--recent", action="store_true",
12466
- help="Show only blocks from the last 3 days (plus the active block).",
12467
- )
12468
- p.add_argument(
12469
- "-t", "--token-limit", dest="token_limit", default=None,
12470
- metavar="N|max",
12471
- help="Token limit for the quota %% column / projection warnings. "
12472
- "An integer, or 'max' (default) to derive from the largest "
12473
- "completed block.",
12474
- )
12475
- p.add_argument(
12476
- "-n", "--session-length", dest="session_length", type=float,
12477
- default=5.0, metavar="N",
12478
- help="Accepted for ccusage drop-in compat; no-op — cctally blocks "
12479
- "follow Anthropic's real 5-hour resets and are not re-sizable. "
12480
- "A value <= 0 is rejected.",
12481
- )
12482
- _add_ccusage_alias_args(p, ansi_emit=False)
12483
- _add_mode_arg(p)
12484
- p.set_defaults(func=cmd_blocks)
12485
- return p
12486
-
12487
-
12488
- def _build_statusline_parser(subparsers, name, *, help_text, xref):
12489
- """Build the `statusline` (or `claude statusline`) leaf parser.
12490
-
12491
- Registered TWICE per the Session B build-once register-twice pattern:
12492
- once on the flat ``cctally statusline`` subparser, once under the
12493
- nested ``cctally claude statusline`` subgroup. Output is byte-identical
12494
- between the two forms; only ``--help`` text differs (the ``xref``
12495
- paragraph appended to ``description``).
12496
- """
12497
- p = subparsers.add_parser(
12498
- name,
12499
- help=help_text,
12500
- formatter_class=CLIHelpFormatter,
12501
- description=(
12502
- "Display a compact one-line status for Claude Code hooks "
12503
- "(ccusage drop-in + cctally extensions).\n\n" + xref
12504
- ),
12505
- )
12506
- # ccusage-shape flags
12507
- p.add_argument(
12508
- "-B", "--visual-burn-rate",
12509
- dest="visual_burn_rate",
12510
- default=None,
12511
- choices=["off", "emoji", "text", "emoji-text"],
12512
- help="Burn-rate visualization (default: off; config key "
12513
- "statusline.visual_burn_rate).",
12514
- )
12515
- # NOTE: `ccusage` is intentionally NOT in `choices=` so it doesn't
12516
- # appear in `--help` advertised options. Argparse runs the `choices`
12517
- # check BEFORE the action's `__call__`, so we cannot list `ccusage`
12518
- # in `choices=` AND catch it in the action. Instead we omit `choices=`
12519
- # entirely, manually validate inside the action, and re-raise the
12520
- # spec's rename hint via `parser.error` for `ccusage` specifically.
12521
- # Help text below hardcodes the legal set so users still see it in
12522
- # `--help`. A typo like `ccussage` falls through to a standard
12523
- # argparse-style "invalid choice" error from `parser.error`.
12524
- class _CostSourceAction(argparse.Action):
12525
- _ACCEPTED = ("auto", "cctally", "cc", "both")
12526
- _RENAMED = "ccusage"
12527
-
12528
- def __call__(self, parser, namespace, values, option_string=None):
12529
- if values == self._RENAMED:
12530
- parser.error(
12531
- f"argument {option_string}: invalid choice: "
12532
- f"{values!r} — cctally renamed it; try "
12533
- f"--cost-source cctally"
12534
- )
12535
- if values not in self._ACCEPTED:
12536
- parser.error(
12537
- f"argument {option_string}: invalid choice: "
12538
- f"{values!r} (choose from "
12539
- + ", ".join(repr(c) for c in self._ACCEPTED)
12540
- + ")"
12541
- )
12542
- setattr(namespace, self.dest, values)
12543
-
12544
- p.add_argument(
12545
- "--cost-source",
12546
- dest="cost_source",
12547
- default=None,
12548
- action=_CostSourceAction,
12549
- metavar="{auto,cctally,cc,both}",
12550
- help="Session cost source (default: auto; config key "
12551
- "statusline.cost_source). Note: 'ccusage' errors with a "
12552
- "rename hint — use 'cctally' instead.",
12553
- )
12554
- p.add_argument(
12555
- "--cache",
12556
- dest="cache",
12557
- action="store_true",
12558
- default=None,
12559
- help="Accepted for ccusage drop-in compat; cctally renders from "
12560
- "cache.db directly without an extra output cache.",
12561
- )
12562
- p.add_argument(
12563
- "--no-cache",
12564
- dest="cache",
12565
- action="store_false",
12566
- help="(no-op alias)",
12567
- )
12568
- p.add_argument(
12569
- "--refresh-interval",
12570
- dest="refresh_interval",
12571
- default=1,
12572
- type=int,
12573
- metavar="N",
12574
- help="(no-op alias) Accepted for ccusage drop-in compat.",
12575
- )
12576
- p.add_argument(
12577
- "--context-low-threshold",
12578
- dest="context_low_threshold",
12579
- default=50,
12580
- type=int,
12581
- metavar="N",
12582
- help="Below this %% → segment 4 green (default: 50, 0-100).",
12583
- )
12584
- p.add_argument(
12585
- "--context-medium-threshold",
12586
- dest="context_medium_threshold",
12587
- default=80,
12588
- type=int,
12589
- metavar="N",
12590
- help="Below this %% → segment 4 yellow; else red (default: 80, 0-100).",
12591
- )
12592
- p.add_argument(
12593
- "-z", "--timezone",
12594
- dest="timezone",
12595
- default=None,
12596
- metavar="TZ",
12597
- help="Display tz (IANA) for `today` calendar day. Overrides "
12598
- "display.tz config.",
12599
- )
12600
- p.add_argument(
12601
- "-O", "--offline",
12602
- dest="offline",
12603
- action="store_true",
12604
- default=True,
12605
- help="(no-op alias) cctally is always offline.",
12606
- )
12607
- p.add_argument(
12608
- "--no-offline",
12609
- dest="offline",
12610
- action="store_false",
12611
- help="(no-op alias)",
12612
- )
12613
- p.add_argument(
12614
- "--color",
12615
- dest="color",
12616
- action="store_true",
12617
- default=None,
12618
- help="Force ANSI colors on (default: auto via NO_COLOR + TTY).",
12619
- )
12620
- p.add_argument(
12621
- "--no-color",
12622
- dest="color",
12623
- action="store_false",
12624
- help="Force ANSI colors off.",
12625
- )
12626
- p.add_argument(
12627
- "--cctally-extensions",
12628
- dest="cctally_extensions",
12629
- action="store_true",
12630
- default=None,
12631
- help="Append cctally 5h%%/7d%% segment (default: on; config key "
12632
- "statusline.cctally_extensions).",
12633
- )
12634
- p.add_argument(
12635
- "--no-cctally-extensions",
12636
- dest="cctally_extensions",
12637
- action="store_false",
12638
- help="Suppress cctally 5h%%/7d%% segment.",
12639
- )
12640
- p.add_argument(
12641
- "--config",
12642
- dest="config",
12643
- default=None,
12644
- metavar="PATH",
12645
- help="Read config from PATH for this invocation only (no "
12646
- "mutation of the default config). Missing/invalid PATH "
12647
- "exits 2.",
12648
- )
12649
- p.add_argument(
12650
- "--single-thread",
12651
- dest="single_thread",
12652
- action="store_true",
12653
- help="(no-op alias) cctally is always single-threaded via the "
12654
- "session-entry cache.",
12655
- )
12656
- p.add_argument(
12657
- "-d", "--debug",
12658
- dest="debug",
12659
- action="store_true",
12660
- help="Emit pricing-mismatch / config diagnostics on stderr.",
12661
- )
12662
- p.set_defaults(func=cmd_statusline, command=name)
12663
- return p
12664
-
12665
-
12666
- def _build_codex_daily_parser(subparsers, name, *, help_text, xref):
12667
- """Build the `codex-daily` leaf parser (issue #86 Session B; routes to cmd_codex_daily)."""
12668
- p = subparsers.add_parser(
12669
- name,
12670
- help=help_text,
12671
- formatter_class=CLIHelpFormatter,
12672
- description="Show Codex usage grouped by date, matching upstream ccusage-codex daily output."
12673
- "\n\n" + xref,
12674
- epilog=textwrap.dedent("""\
12675
- Examples:
12676
- cctally codex-daily --since 20260401
12677
- cctally codex-daily --since 20260401 --breakdown
12678
- cctally codex-daily --since 20260401 --json
12679
- cctally codex-daily --order desc
12680
- """),
12681
- )
12682
- p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
12683
- help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
12684
- p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
12685
- help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
12686
- p.add_argument("-b", "--breakdown", action="store_true",
12687
- help="Show per-model cost breakdown sub-rows.")
12688
- p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
12689
- help="Sort direction by date (default: asc).")
12690
- p.add_argument("--json", action="store_true", dest="json",
12691
- help="Output JSON matching upstream ccusage-codex daily format.")
12692
- _add_codex_shared_args(p)
12693
- p.set_defaults(func=cmd_codex_daily)
12694
- return p
12695
-
12696
-
12697
- def _build_codex_monthly_parser(subparsers, name, *, help_text, xref):
12698
- """Build the `codex-monthly` leaf parser (issue #86 Session B; routes to cmd_codex_monthly)."""
12699
- p = subparsers.add_parser(
12700
- name,
12701
- help=help_text,
12702
- formatter_class=CLIHelpFormatter,
12703
- description="Show Codex usage grouped by calendar month, matching upstream ccusage-codex monthly output."
12704
- "\n\n" + xref,
12705
- epilog=textwrap.dedent("""\
12706
- Examples:
12707
- cctally codex-monthly --since 20260101
12708
- cctally codex-monthly --breakdown
12709
- cctally codex-monthly --json
12710
- """),
12711
- )
12712
- p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
12713
- help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
12714
- p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
12715
- help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
12716
- p.add_argument("-b", "--breakdown", action="store_true",
12717
- help="Show per-model cost breakdown sub-rows.")
12718
- p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
12719
- help="Sort direction by month (default: asc).")
12720
- p.add_argument("--json", action="store_true", dest="json",
12721
- help="Output JSON matching upstream ccusage-codex monthly format.")
12722
- _add_codex_shared_args(p)
12723
- p.set_defaults(func=cmd_codex_monthly)
12724
- return p
12725
-
12726
-
12727
- def _build_codex_weekly_parser(subparsers, name, *, help_text, xref):
12728
- """Build the `codex-weekly` leaf parser (issue #86 Session B; routes to cmd_codex_weekly)."""
12729
- p = subparsers.add_parser(
12730
- name,
12731
- help=help_text,
12732
- formatter_class=CLIHelpFormatter,
12733
- description="Show Codex usage grouped by week. Week-start day is read from config.json "
12734
- "(collector.week_start, Monday default). Not a ccusage-codex drop-in — "
12735
- "upstream has no `codex weekly` command."
12736
- "\n\n" + xref,
12737
- epilog=textwrap.dedent("""\
12738
- Examples:
12739
- cctally codex-weekly
12740
- cctally codex-weekly --since 20260301
12741
- cctally codex-weekly --breakdown
12742
- cctally codex-weekly --json
12743
- cctally codex-weekly --order desc
12744
- """),
12745
- )
12746
- p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
12747
- help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
12748
- p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
12749
- help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
12750
- p.add_argument("-b", "--breakdown", action="store_true",
12751
- help="Show per-model cost breakdown sub-rows.")
12752
- p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
12753
- help="Sort direction by week (default: asc).")
12754
- p.add_argument("--json", action="store_true", dest="json",
12755
- help="Output JSON.")
12756
- _add_codex_shared_args(p)
12757
- p.set_defaults(func=cmd_codex_weekly)
12758
- return p
12759
-
12760
-
12761
- def _build_codex_session_parser(subparsers, name, *, help_text, xref):
12762
- """Build the `codex-session` leaf parser (issue #86 Session B; routes to cmd_codex_session)."""
12763
- p = subparsers.add_parser(
12764
- name,
12765
- help=help_text,
12766
- formatter_class=CLIHelpFormatter,
12767
- description="Show Codex usage grouped by session, matching upstream ccusage-codex session output."
12768
- "\n\n" + xref,
12769
- epilog=textwrap.dedent("""\
12770
- Examples:
12771
- cctally codex-session
12772
- cctally codex-session --since 20260401
12773
- cctally codex-session --json
12774
- """),
12775
- )
12776
- p.add_argument("-s", "--since", default=None, metavar="YYYY-MM-DD",
12777
- help="Filter from date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
12778
- p.add_argument("-u", "--until", default=None, metavar="YYYY-MM-DD",
12779
- help="Filter until date (inclusive; accepts YYYY-MM-DD or YYYYMMDD).")
12780
- p.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
12781
- help="Sort direction by last activity (default: asc — earliest first).")
12782
- p.add_argument("--json", action="store_true", dest="json",
12783
- help="Output JSON matching upstream ccusage-codex session format.")
12784
- _add_codex_shared_args(p)
12785
- p.set_defaults(func=cmd_codex_session)
12786
- return p
12787
-
12788
-
12789
- def build_parser() -> argparse.ArgumentParser:
12790
- p = argparse.ArgumentParser(
12791
- prog="cctally",
12792
- formatter_class=CLIHelpFormatter,
12793
- description=textwrap.dedent(
12794
- """\
12795
- Track Claude subscription weekly usage percent and weekly cost
12796
- in a local SQLite database.
12797
-
12798
- Data flow:
12799
- 1) Claude Code status line captures rate limit data after each API call.
12800
- 2) record-usage stores usage snapshots and triggers percent milestones.
12801
- 3) sync-week computes weekly USD cost from Claude Code session data.
12802
- 4) report computes dollars per 1% and shows trend history.
12803
- """
12804
- ),
12805
- epilog=textwrap.dedent(
12806
- """\
12807
- Quick start:
12808
- # Add record-usage call to ~/.claude/statusline-command.sh (see record-usage --help)
12809
- cctally sync-week
12810
- cctally report
12811
- """
12812
- ),
12813
- )
12814
- p.add_argument(
12815
- "-v", "--version",
12816
- action="store_true",
12817
- default=argparse.SUPPRESS,
12818
- help="Print cctally version (from CHANGELOG.md latest release header) and exit",
12819
- )
12820
- sub = p.add_subparsers(
12821
- dest="command",
12822
- required=False,
12823
- title="commands",
12824
- metavar="<command>",
12825
- )
12826
-
12827
- py = sub.add_parser(
12828
- "sync-week",
12829
- help="Compute weekly cost from session data and store in SQLite",
12830
- formatter_class=CLIHelpFormatter,
12831
- description=textwrap.dedent(
12832
- """\
12833
- Compute and store weekly cost (USD) for a selected week window.
12834
-
12835
- Week selection priority:
12836
- 1) Explicit --week-start/--week-end (date based)
12837
- 2) Latest usage snapshot weekStartAt/weekEndAt (hour-accurate)
12838
- 3) Current week from configured week-start rule
12839
- """
12840
- ),
12841
- epilog=textwrap.dedent(
12842
- """\
12843
- Examples:
12844
- cctally sync-week
12845
- cctally sync-week --week-start 2026-02-05 --week-end 2026-02-12
12846
- cctally sync-week --mode calculate --offline --json
12847
- """
12848
- ),
12849
- )
12850
- py.add_argument(
12851
- "--week-start",
12852
- default=None,
12853
- metavar="YYYY-MM-DD",
12854
- help="Explicit week start date. If --week-end is omitted, uses start + 6 days.",
12855
- )
12856
- py.add_argument(
12857
- "--week-end",
12858
- default=None,
12859
- metavar="YYYY-MM-DD",
12860
- help="Explicit week end date (inclusive date for custom windows).",
12861
- )
12862
- py.add_argument(
12863
- "--week-start-name",
12864
- default=None,
12865
- choices=list(WEEKDAY_MAP.keys()),
12866
- help="Week-start day used when explicit/custom boundaries are not available.",
12867
- )
12868
- py.add_argument(
12869
- "--mode",
12870
- default="auto",
12871
- choices=["auto", "calculate", "display"],
12872
- help="Cost calculation mode: auto, calculate, or display.",
12873
- )
12874
- py.add_argument(
12875
- "--offline",
12876
- action="store_true",
12877
- help="Use embedded pricing data (no-op, always used).",
12878
- )
12879
- py.add_argument(
12880
- "--project",
12881
- default=None,
12882
- help="Optional project filter for cost calculation.",
12883
- )
12884
- py.add_argument(
12885
- "--json",
12886
- action="store_true",
12887
- help="Emit machine-readable JSON output.",
12888
- )
12889
- py.add_argument(
12890
- "--quiet",
12891
- action="store_true",
12892
- help="Suppress human-readable output (no effect with --json).",
12893
- )
12894
- py.set_defaults(func=cmd_sync_week)
12895
-
12896
- pr = sub.add_parser(
12897
- "report",
12898
- help="Show current and trend dollars-per-1%% statistics",
12899
- formatter_class=CLIHelpFormatter,
12900
- description=textwrap.dedent(
12901
- """\
12902
- Report current and historical dollars per 1% weekly usage.
12903
-
12904
- For each week, report joins:
12905
- - latest usage snapshot (%)
12906
- - latest cost snapshot (USD)
12907
- then computes USD / percent.
12908
- """
12909
- ),
12910
- epilog=textwrap.dedent(
12911
- """\
12912
- Examples:
12913
- cctally report
12914
- cctally report --sync-current
12915
- cctally report --weeks 12 --json
12916
- """
12917
- ),
12918
- )
12919
- pr.add_argument(
12920
- "--weeks",
12921
- type=int,
12922
- default=8,
12923
- help="How many recent week windows to include in the trend.",
12924
- )
12925
- pr.add_argument(
12926
- "--sync-current",
12927
- action="store_true",
12928
- help="Run sync-week first, then generate the report.",
12929
- )
12930
- pr.add_argument(
12931
- "--week-start-name",
12932
- default=None,
12933
- choices=list(WEEKDAY_MAP.keys()),
12934
- help="Week-start day used if report falls back to date-only week logic.",
12935
- )
12936
- pr.add_argument(
12937
- "--mode",
12938
- default="auto",
12939
- choices=["auto", "calculate", "display"],
12940
- help="Mode passed to sync-week when --sync-current is used.",
12941
- )
12942
- pr.add_argument(
12943
- "--offline",
12944
- action="store_true",
12945
- help="Pass --offline to sync-week when --sync-current is used.",
12946
- )
12947
- pr.add_argument(
12948
- "--project",
12949
- default=None,
12950
- help="Project filter passed to sync-week when --sync-current is used.",
12951
- )
12952
- pr.add_argument(
12953
- "--reveal-projects",
12954
- action="store_true",
12955
- dest="reveal_projects",
12956
- help="In --format output, show real project basenames instead of "
12957
- "the default project-1, project-2, ... anonymization.",
12958
- )
12959
- pr.add_argument(
12960
- "--detail",
12961
- action="store_true",
12962
- help="Include per-percent cost milestones for the current week.",
12963
- )
12964
- pr.add_argument(
12965
- "--tz", default=None, type=_argparse_tz, metavar="TZ",
12966
- help="Display timezone: local, utc, or IANA name. "
12967
- "Overrides config display.tz for this call.",
12968
- )
12969
- _add_share_args(pr)
12970
- pr.set_defaults(func=cmd_report)
12971
-
12972
- fc = sub.add_parser(
12973
- "forecast",
12974
- help="Project current-week usage to reset; show daily budgets",
12975
- formatter_class=CLIHelpFormatter,
12976
- description=textwrap.dedent(
12977
- """\
12978
- Forecast end-of-week usage % and daily $ / % budgets to stay under
12979
- target ceilings (default 100% and 90%). Reads current-week
12980
- `weekly_usage_snapshots` + `session_entries`; never writes.
12981
- """
12982
- ),
12983
- epilog=textwrap.dedent(
12984
- """\
12985
- Examples:
12986
- cctally forecast
12987
- cctally forecast --json
12988
- cctally forecast --status-line --no-sync
12989
- cctally forecast --targets 100,95,85
12990
-
12991
- Status-line integration (add to ~/.claude/statusline-command.sh):
12992
- forecast_seg=$(cctally forecast --status-line --no-sync 2>/dev/null)
12993
- # ...then include "$forecast_seg" in your prompt composition.
12994
- """
12995
- ),
12996
- )
12997
- fc.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
12998
- help="In --format output, show real project basenames instead of "
12999
- "the default project-1, project-2, ... anonymization.")
13000
- fc.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
13001
- help="Display timezone: local, utc, or IANA name. "
13002
- "Overrides config display.tz for this call.")
13003
- fc.add_argument("--targets", default="100,90",
13004
- help="Comma-separated integer ceilings (default: 100,90).")
13005
- fc.add_argument("--explain", action="store_true",
13006
- help="Append rationale footer with rate values and source captions.")
13007
- fc.add_argument("--no-sync", action="store_true", dest="no_sync",
13008
- help="Skip sync_cache(); recommended for status-line use.")
13009
- fc.add_argument("--color", choices=("auto", "always", "never"), default="auto",
13010
- help="Color output control (also honors NO_COLOR).")
13011
- # Dev-only: override "now" for deterministic fixture tests. Hidden from --help.
13012
- fc.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
13013
- _add_share_args(fc, has_status_line=True)
13014
- fc.set_defaults(func=cmd_forecast)
13015
-
13016
- # budget — cctally-original (NOT a ccusage drop-in), so flat surface only;
13017
- # no claude/codex subgroup. `--config` is honored read-only on bare status
13018
- # but rejected on set/unset (F4). `--reveal-projects` is accepted for share
13019
- # surface parity but inert (no per-project axis). `--tz` follows the sibling
13020
- # reporting commands' precedence.
13021
- bg = sub.add_parser(
13022
- "budget",
13023
- help="Weekly equivalent-$ budget + pace + spend alerts",
13024
- formatter_class=CLIHelpFormatter,
13025
- description=textwrap.dedent(
13026
- """\
13027
- Track Claude equivalent-$ spend for the current subscription week
13028
- against a weekly budget. Shows spend, pace, projected end-of-week,
13029
- and a verdict (ok / warn / over). `budget set <amount>` and
13030
- `budget unset` manage the budget; spend-crossing alerts fire from
13031
- record-usage (see `cctally alerts`).
13032
- """
13033
- ),
13034
- epilog=textwrap.dedent(
13035
- """\
13036
- Examples:
13037
- cctally budget
13038
- cctally budget set 300
13039
- cctally budget unset
13040
- cctally budget --json
13041
- cctally budget --format md
13042
- """
13043
- ),
13044
- )
13045
- bg.add_argument("action", nargs="?", choices=["set", "unset"], default=None,
13046
- help="`set <amount>` to set the weekly budget, `unset` to clear it.")
13047
- bg.add_argument("amount", nargs="?", default=None,
13048
- help="Target USD for `budget set` (e.g. 300).")
13049
- bg.add_argument("--config", default=None,
13050
- help="Read status from this config file (read-only; "
13051
- "rejected on set/unset).")
13052
- bg.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
13053
- help="Accepted for --format surface parity; inert for budget "
13054
- "(no per-project axis).")
13055
- bg.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
13056
- help="Display timezone: local, utc, or IANA name. "
13057
- "Overrides config display.tz for this call.")
13058
- _add_share_args(bg)
13059
- bg.set_defaults(func=cmd_budget)
13060
-
13061
- pb = sub.add_parser(
13062
- "percent-breakdown",
13063
- help="Show per-percent cost milestones for a week",
13064
- formatter_class=CLIHelpFormatter,
13065
- description=textwrap.dedent(
13066
- """\
13067
- Show the cumulative and marginal cost at each integer percent threshold
13068
- for a given week. Milestones are recorded automatically when
13069
- record-usage stores a snapshot crossing a new integer percent.
13070
- """
13071
- ),
13072
- epilog=textwrap.dedent(
13073
- """\
13074
- Examples:
13075
- cctally percent-breakdown
13076
- cctally percent-breakdown --week-start 2026-03-20
13077
- cctally percent-breakdown --json
13078
- """
13079
- ),
13080
- )
13081
- pb.add_argument(
13082
- "--week-start",
13083
- default=None,
13084
- metavar="YYYY-MM-DD",
13085
- help="Week start date. Defaults to the current week.",
13086
- )
13087
- pb.add_argument(
13088
- "--week-start-name",
13089
- default=None,
13090
- choices=list(WEEKDAY_MAP.keys()),
13091
- help="Week-start day used when no explicit date or usage data is available.",
13092
- )
13093
- pb.add_argument(
13094
- "--json",
13095
- action="store_true",
13096
- help="Emit machine-readable JSON output.",
13097
- )
13098
- pb.add_argument(
13099
- "--tz", default=None, type=_argparse_tz, metavar="TZ",
13100
- help="Display timezone: local, utc, or IANA name. "
13101
- "Overrides config display.tz for this call.",
13102
- )
13103
- pb.set_defaults(func=cmd_percent_breakdown)
13104
-
13105
- fhbd = sub.add_parser(
13106
- "five-hour-breakdown",
13107
- help="Per-percent milestones inside one 5h block (mirror of percent-breakdown)",
13108
- formatter_class=CLIHelpFormatter,
13109
- description=textwrap.dedent(
13110
- """\
13111
- Show cumulative + marginal cost at each integer percent threshold
13112
- inside one 5h block. Mirrors percent-breakdown for the 5h axis.
13113
- """
13114
- ),
13115
- epilog=textwrap.dedent(
13116
- """\
13117
- Examples:
13118
- cctally five-hour-breakdown
13119
- cctally five-hour-breakdown --block-start 2026-04-30T19:30
13120
- cctally five-hour-breakdown --ago 1
13121
- cctally five-hour-breakdown --json
13122
- """
13123
- ),
13124
- )
13125
- fhbd.add_argument(
13126
- "--block-start",
13127
- default=None,
13128
- metavar="ISO8601",
13129
- dest="block_start",
13130
- help="Block start (e.g. 2026-04-30T19:30, naive=UTC).",
13131
- )
13132
- fhbd.add_argument(
13133
- "--ago",
13134
- default=None,
13135
- type=int,
13136
- metavar="N",
13137
- help="Relative selector: 0=current, 1=previous, etc.",
13138
- )
13139
- fhbd.add_argument(
13140
- "--json",
13141
- action="store_true",
13142
- help="Emit camelCase JSON (schemaVersion 1).",
13143
- )
13144
- fhbd.add_argument(
13145
- "--no-color",
13146
- action="store_true",
13147
- help="Disable ANSI color output (currently a no-op — table is plain text).",
13148
- )
13149
- fhbd.add_argument(
13150
- "--tz",
13151
- default=None,
13152
- type=_argparse_tz,
13153
- metavar="TZ",
13154
- help="Display timezone: local, utc, or IANA name. "
13155
- "Overrides config display.tz for this call.",
13156
- )
13157
- fhbd.set_defaults(func=cmd_five_hour_breakdown)
13158
-
13159
- tp = sub.add_parser(
13160
- "tui",
13161
- help="Live refreshing dashboard (current week, forecast, trend, sessions)",
13162
- formatter_class=CLIHelpFormatter,
13163
- description=textwrap.dedent(
13164
- """\
13165
- Live terminal dashboard with four refreshing panels:
13166
- - Current week % and 5-hour window
13167
- - Forecast verdict + projections + daily $ budgets
13168
- - $/1% trend over the last 8 weeks (with sparkline)
13169
- - Recent Claude sessions (last 100, scrollable)
13170
-
13171
- Two visual variants — conventional 2x2 grid and expressive
13172
- hero layout — toggleable at runtime with `v`.
13173
-
13174
- Requires the `rich` Python package.
13175
- """
13176
- ),
13177
- epilog=textwrap.dedent(
13178
- """\
13179
- Examples:
13180
- cctally tui
13181
- cctally tui --expressive
13182
- cctally tui --refresh 2 --sync-interval 30
13183
- cctally tui --no-sync
13184
- """
13185
- ),
13186
- )
13187
- tp.add_argument(
13188
- "--variant",
13189
- choices=("conventional", "expressive"),
13190
- default="conventional",
13191
- help="Initial layout variant (press 'v' at runtime to toggle).",
13192
- )
13193
- tp.add_argument(
13194
- "--expressive",
13195
- action="store_const",
13196
- dest="variant",
13197
- const="expressive",
13198
- help="Shortcut for --variant expressive.",
13199
- )
13200
- tp.add_argument(
13201
- "--refresh",
13202
- type=_tui_refresh_interval_type,
13203
- default=1.0,
13204
- metavar="SECONDS",
13205
- help="UI redraw cadence (default: 1.0).",
13206
- )
13207
- tp.add_argument(
13208
- "--sync-interval",
13209
- type=_tui_sync_interval_type,
13210
- default=10.0,
13211
- metavar="SECONDS",
13212
- dest="sync_interval",
13213
- help="Background JSONL sync cadence (default: 10).",
13214
- )
13215
- tp.add_argument(
13216
- "--no-sync",
13217
- action="store_true",
13218
- dest="no_sync",
13219
- help="Disable background sync; render from cache only.",
13220
- )
13221
- tp.add_argument(
13222
- "--no-color",
13223
- action="store_true",
13224
- dest="no_color",
13225
- help="Disable ANSI color (NO_COLOR env var also respected).",
13226
- )
13227
- tp.add_argument(
13228
- "--tz",
13229
- default=None,
13230
- type=_argparse_tz,
13231
- metavar="TZ",
13232
- help="Display timezone: local, utc, or IANA name. "
13233
- "Overrides config display.tz for this call.",
13234
- )
13235
- # Dev-only: pin "now" for deterministic fixture tests.
13236
- tp.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
13237
- # Dev-only: fixture injection — render one frame from a Python module that
13238
- # exposes `SNAPSHOT` (DataSnapshot) and exits.
13239
- tp.add_argument(
13240
- "--snapshot-module", dest="snapshot_module", default=None, help=argparse.SUPPRESS
13241
- )
13242
- # Dev-only: one-shot render for golden capture.
13243
- tp.add_argument(
13244
- "--render-once", action="store_true", dest="render_once", help=argparse.SUPPRESS
13245
- )
13246
- # Dev-only: force terminal size for --render-once.
13247
- tp.add_argument(
13248
- "--force-size", dest="force_size", default=None, metavar="WxH",
13249
- help=argparse.SUPPRESS
13250
- )
13251
- tp.set_defaults(func=cmd_tui)
13252
-
13253
- # ---- dashboard subcommand --------------------------------------------
13254
- dp = sub.add_parser(
13255
- "dashboard",
13256
- help="Launch the live web dashboard on http://localhost:8789",
13257
- description=(
13258
- "Start a local web server rendering a live dashboard of your "
13259
- "subscription usage, weekly cost trend, and recent sessions. "
13260
- "Press Ctrl-C to stop. Two variants are served by the companion "
13261
- "'tui' subcommand for terminal-only use."
13262
- ),
13263
- )
13264
- dp.add_argument(
13265
- "--port",
13266
- type=int,
13267
- default=8789,
13268
- help="TCP port to bind (default: 8789).",
13269
- )
13270
- dp.add_argument(
13271
- "--host",
13272
- default=None,
13273
- help=("Bind host (default: from config dashboard.bind, fallback "
13274
- "127.0.0.1 — loopback-only). Use --host 0.0.0.0 to opt in "
13275
- "to LAN exposure (no auth on /api/* — trusted networks only)."),
13276
- )
13277
- dp.add_argument(
13278
- "--no-browser",
13279
- action="store_true",
13280
- help="Skip auto-opening the browser to the dashboard URL.",
13281
- )
13282
- dp.add_argument(
13283
- "--sync-interval",
13284
- type=_tui_sync_interval_type, # reuse the TUI validator
13285
- default=5.0,
13286
- dest="sync_interval",
13287
- help="Background snapshot-rebuild cadence in seconds (default: 5).",
13288
- )
13289
- dp.add_argument(
13290
- "--no-sync",
13291
- action="store_true",
13292
- dest="no_sync",
13293
- help="Freeze the snapshot at startup; skip background rebuilds.",
13294
- )
13295
- dp.add_argument(
13296
- "--tz", default=None, type=_argparse_tz, metavar="TZ",
13297
- help="Display timezone: local, utc, or IANA name. "
13298
- "Overrides config display.tz for this call.",
13299
- )
13300
- dp.set_defaults(func=cmd_dashboard)
13301
-
13302
- ru = sub.add_parser(
13303
- "record-usage",
13304
- help="Record usage data from Claude Code status line",
13305
- formatter_class=CLIHelpFormatter,
13306
- description=textwrap.dedent(
13307
- """\
13308
- Record usage percentage from Claude Code status line rate_limits data.
13309
- Called automatically by the status line script after each assistant message.
13310
- """
13311
- ),
13312
- epilog=textwrap.dedent(
13313
- """\
13314
- Examples:
13315
- cctally record-usage --percent 14.2 --resets-at 1744531200
13316
- cctally record-usage --percent 14.2 --resets-at 1744531200 \\
13317
- --five-hour-percent 38.5 --five-hour-resets-at 1744502400
13318
-
13319
- Status line integration (add to ~/.claude/statusline-command.sh):
13320
- if [ -n "$week_pct" ] && [ -n "$week_resets" ]; then
13321
- record_args="--percent $week_pct --resets-at ${week_resets%.*}"
13322
- if [ -n "$five_pct" ] && [ -n "$five_resets" ]; then
13323
- record_args="$record_args --five-hour-percent $five_pct --five-hour-resets-at ${five_resets%.*}"
13324
- fi
13325
- cctally record-usage $record_args &
13326
- fi
13327
- """
13328
- ),
13329
- )
13330
- ru.add_argument(
13331
- "--percent",
13332
- required=True,
13333
- type=float,
13334
- help="7-day utilization percentage (0-100).",
13335
- )
13336
- ru.add_argument(
13337
- "--resets-at",
13338
- required=True,
13339
- help="7-day window reset timestamp (Unix epoch seconds).",
13340
- )
13341
- ru.add_argument(
13342
- "--five-hour-percent",
13343
- type=float,
13344
- default=None,
13345
- help="5-hour utilization percentage (0-100).",
13346
- )
13347
- ru.add_argument(
13348
- "--five-hour-resets-at",
13349
- default=None,
13350
- help="5-hour window reset timestamp (Unix epoch seconds).",
13351
- )
13352
- ru.set_defaults(func=cmd_record_usage)
13353
-
13354
- rfu = sub.add_parser(
13355
- "refresh-usage",
13356
- help="Force-fetch 7d/5h percent from OAuth API and record it",
13357
- formatter_class=CLIHelpFormatter,
13358
- description=textwrap.dedent(
13359
- """\
13360
- Force a fresh fetch of seven_day.utilization and five_hour.utilization
13361
- from Anthropic's OAuth usage API, persist it via the same path
13362
- record-usage uses (HWM, percent_milestones, weekly_usage_snapshots),
13363
- and bust the statusline OAuth cache file at
13364
- /tmp/claude-statusline-usage-cache.json so the next status-line tick
13365
- also gets fresh data.
13366
-
13367
- Use this when the displayed 7d percent is stale (e.g., you've
13368
- been away from Claude Code and the status-line hasn't fired
13369
- recently). Otherwise the status-line script handles refresh
13370
- automatically every minute.
13371
- """
13372
- ),
13373
- epilog=textwrap.dedent(
13374
- """\
13375
- Examples:
13376
- ccusage-refresh-usage # one-liner output
13377
- ccusage-refresh-usage --json | jq . # scriptable
13378
- ccusage-refresh-usage --quiet # silent (exit code only)
13379
-
13380
- Exit codes: 0 success / 2 no OAuth token / 3 network failure
13381
- / 4 malformed API response / 5 record-usage internal failure.
13382
- """
13383
- ),
13384
- )
13385
- rfu.add_argument("--json", action="store_true",
13386
- help="Emit schema_version=1 JSON to stdout instead of one-liner.")
13387
- rfu.add_argument("--quiet", action="store_true",
13388
- help="Suppress stdout; exit code is the only success signal.")
13389
- rfu.add_argument("--color", choices=("auto", "always", "never"), default="auto",
13390
- help="Color output control (also honors NO_COLOR).")
13391
- rfu.add_argument("--timeout", type=float, default=5.0,
13392
- help="HTTP timeout in seconds (default: 5.0).")
13393
- rfu.set_defaults(func=cmd_refresh_usage)
13394
-
13395
- pc = sub.add_parser(
13396
- "cache-report",
13397
- help="Show daily cache hit rates per model from ccusage data",
13398
- formatter_class=CLIHelpFormatter,
13399
- description=textwrap.dedent(
13400
- """\
13401
- Query ccusage for daily token breakdown and display cache hit
13402
- percentages per model. Useful for spotting caching regressions
13403
- after Claude Code updates.
13404
-
13405
- Cache hit % = cacheReadTokens / (input + cacheCreate + cacheRead)
13406
- """
13407
- ),
13408
- epilog=textwrap.dedent(
13409
- """\
13410
- Examples:
13411
- cctally cache-report
13412
- cctally cache-report --days 14
13413
- cctally cache-report --since 2026-04-10 --until 2026-04-18
13414
- cctally cache-report --by-session --days 14
13415
- cctally cache-report --by-session --sort cache
13416
- cctally cache-report --json
13417
- """
13418
- ),
13419
- )
13420
- pc.add_argument(
13421
- "--days",
13422
- type=int,
13423
- default=7,
13424
- help="Number of recent days to include.",
13425
- )
13426
- pc.add_argument(
13427
- "--since",
13428
- default=None,
13429
- help="Lower window bound (ISO 8601, e.g., '2026-04-10' or "
13430
- "'2026-04-10T10:00:00Z'). If omitted, falls back to --days.",
13431
- )
13432
- pc.add_argument(
13433
- "--until",
13434
- default=None,
13435
- help="Upper window bound (ISO 8601). If omitted, defaults to now.",
13436
- )
13437
- pc.add_argument(
13438
- "--by-session",
13439
- action="store_true",
13440
- dest="by_session",
13441
- help="Group by Claude sessionId (resumed-merged) instead of by date. "
13442
- "Adds SessionId, Last Activity, and Project identity columns.",
13443
- )
13444
- pc.add_argument(
13445
- "-O", "--offline",
13446
- action=argparse.BooleanOptionalAction, default=False,
13447
- help="Use cached pricing data in ccusage. Session A (spec §7.1.2)"
13448
- " promotes the existing flag to BooleanOptionalAction + -O"
13449
- " short form so the ccusage drop-in alias surface (-O,"
13450
- " --offline, --no-offline) all work on cache-report; the"
13451
- " behavior under each is unchanged (cctally is always"
13452
- " offline — args.offline still lands as a bool).",
13453
- )
13454
- pc.add_argument(
13455
- "--project",
13456
- default=None,
13457
- help="Filter to a specific project.",
13458
- )
13459
- pc.add_argument(
13460
- "--json",
13461
- action="store_true",
13462
- help="Emit machine-readable JSON output.",
13463
- )
13464
- pc.add_argument(
13465
- "--anomaly-threshold-pp",
13466
- type=int,
13467
- default=15,
13468
- dest="anomaly_threshold_pp",
13469
- help="Cache%% drop threshold (percentage points) vs. trailing-median "
13470
- "baseline for the cache_drop anomaly trigger. Default: 15.",
13471
- )
13472
- pc.add_argument(
13473
- "--anomaly-window-days",
13474
- type=int,
13475
- default=14,
13476
- dest="anomaly_window_days",
13477
- help="Trailing window (days) for baseline median computation. "
13478
- "Default: 14.",
13479
- )
13480
- pc.add_argument(
13481
- "--no-anomaly",
13482
- action="store_true",
13483
- dest="no_anomaly",
13484
- help="Disable all anomaly triggers (both cache_drop and net_negative).",
13485
- )
13486
- pc.add_argument(
13487
- "--sort",
13488
- choices=["date", "net", "cache", "recent", "cost", "anomaly"],
13489
- default=None,
13490
- dest="sort",
13491
- help="Override sort order. Defaults: 'date' in daily mode, 'net' in "
13492
- "--by-session mode.",
13493
- )
13494
- pc.add_argument(
13495
- "--tz", default=None, type=_argparse_tz, metavar="TZ",
13496
- help="Display timezone: local, utc, or IANA name. "
13497
- "Overrides config display.tz for this call.",
13498
- )
13499
- # Session A (spec §7.6): ansi_emit=False; existing `--offline` is
13500
- # skipped by the helper's `_argparse_has_arg` guard (the collision
13501
- # case spec §7.1.2 calls out explicitly).
13502
- _add_ccusage_alias_args(pc, ansi_emit=False)
13503
- pc.set_defaults(func=cmd_cache_report)
13504
-
13505
- # -- range-cost --
13506
- rc = sub.add_parser(
13507
- "range-cost",
13508
- help="Compute USD cost for a time range from session data",
13509
- formatter_class=CLIHelpFormatter,
13510
- description="Compute USD cost for Claude Code usage between start/end timestamps.",
13511
- epilog=textwrap.dedent("""\
13512
- Examples:
13513
- cctally range-cost -s "2026-04-10T10:00:00+03:00"
13514
- cctally range-cost -s "2026-04-10T10:00:00Z" -e "2026-04-12T10:00:00Z" --breakdown
13515
- cctally range-cost -s "2026-04-10T10:00:00Z" --json
13516
- cctally range-cost -s "2026-04-10T10:00:00Z" --total-only
13517
- """),
13518
- )
13519
- rc.add_argument(
13520
- "-s", "--start",
13521
- required=True,
13522
- help="Start timestamp (ISO 8601)",
13523
- )
13524
- rc.add_argument(
13525
- "-e", "--end",
13526
- default=None,
13527
- help="End timestamp (ISO 8601, default: now)",
13528
- )
13529
- rc.add_argument(
13530
- "-m", "--mode",
13531
- default="auto",
13532
- choices=["auto", "calculate", "display"],
13533
- help="Cost calculation mode.",
13534
- )
13535
- rc.add_argument(
13536
- "-p", "--project",
13537
- default=None,
13538
- help="Filter to a specific project.",
13539
- )
13540
- rc.add_argument(
13541
- "-b", "--breakdown",
13542
- action="store_true",
13543
- help="Show per-model usage and cost breakdown.",
13544
- )
13545
- rc.add_argument(
13546
- "--json",
13547
- action="store_true",
13548
- dest="json",
13549
- help="Output JSON.",
13550
- )
13551
- rc.add_argument(
13552
- "--total-only",
13553
- action="store_true",
13554
- dest="total_only",
13555
- help="Print numeric USD total only.",
13556
- )
13557
- # Session A (spec §7.6): ansi_emit=False. range-cost has no --tz of
13558
- # its own (ISO timestamps carry zone info), but the helper-added
13559
- # -z/--timezone still lands on the namespace; the bridge promotes
13560
- # it onto args.tz where the rest of the pipeline treats it as a
13561
- # documented no-op (cmd_range_cost does not consume args.tz).
13562
- _add_ccusage_alias_args(rc, ansi_emit=False)
13563
- rc.set_defaults(func=cmd_range_cost)
13564
-
13565
- # -- blocks --
13566
- _build_blocks_parser(
13567
- sub, "blocks",
13568
- help_text="Show usage report grouped by 5-hour session blocks",
13569
- xref="Alias of `cctally claude blocks` (the canonical form).")
13570
-
13571
- # -- statusline --
13572
- _build_statusline_parser(
13573
- sub, "statusline",
13574
- help_text="Compact one-line status for Claude Code hooks",
13575
- xref="Alias of `cctally claude statusline` (the canonical form).")
13576
-
13577
- # -- five-hour-blocks --
13578
- fhb = sub.add_parser(
13579
- "five-hour-blocks",
13580
- help="List API-anchored 5h blocks with rollup totals + 7d-drift columns",
13581
- formatter_class=CLIHelpFormatter,
13582
- description=(
13583
- "Show usage grouped by API-anchored 5-hour blocks (analytics view, "
13584
- "distinct from `cctally blocks` upstream-parity drop-in)."
13585
- ),
13586
- epilog=textwrap.dedent("""\
13587
- Examples:
13588
- cctally five-hour-blocks
13589
- cctally five-hour-blocks --since 20260420
13590
- cctally five-hour-blocks --breakdown model
13591
- cctally five-hour-blocks --breakdown project --json
13592
- """),
13593
- )
13594
- fhb.add_argument(
13595
- "-s", "--since",
13596
- default=None,
13597
- metavar="YYYYMMDD",
13598
- help="Filter from date (inclusive).",
13599
- )
13600
- fhb.add_argument(
13601
- "-u", "--until",
13602
- default=None,
13603
- metavar="YYYYMMDD",
13604
- help="Filter until date (inclusive).",
13605
- )
13606
- fhb.add_argument(
13607
- "--breakdown",
13608
- choices=("model", "project"),
13609
- default=None,
13610
- help="Add per-axis rollup-child rows under each block.",
13611
- )
13612
- fhb.add_argument(
13613
- "--reveal-projects",
13614
- action="store_true",
13615
- dest="reveal_projects",
13616
- help="In --format output, show real project basenames instead of "
13617
- "the default project-1, project-2, ... anonymization.",
13618
- )
13619
- fhb.add_argument(
13620
- "--no-color",
13621
- action="store_true",
13622
- help="Accepted for ccusage drop-in compat; this command emits "
13623
- "plain-text output and no ANSI is suppressed.",
13624
- )
13625
- fhb.add_argument(
13626
- "--tz",
13627
- default=None,
13628
- type=_argparse_tz,
13629
- metavar="TZ",
13630
- help="Display timezone: local, utc, or IANA name. "
13631
- "Overrides config display.tz for this call.",
13632
- )
13633
- # Session A (spec §7.6 / §7.6.3): ansi_emit=False. fhb already
13634
- # declares --no-color (refreshed to no-op text in spec §7.6.3); the
13635
- # helper's --no-color add is short-circuited by the existing-arg
13636
- # guard. The helper's --color add lands as a parsed-and-ignored
13637
- # no-op (the renderer emits plain text).
13638
- _add_ccusage_alias_args(fhb, ansi_emit=False)
13639
- _add_mode_arg(fhb, noop=True)
13640
- _add_share_args(fhb)
13641
- fhb.set_defaults(func=cmd_five_hour_blocks)
13642
-
13643
- # -- cache-sync --
13644
- p_cache_sync = sub.add_parser(
13645
- "cache-sync",
13646
- help="Sync (or rebuild) the session-entry cache",
13647
- )
13648
- p_cache_sync.add_argument(
13649
- "--rebuild",
13650
- action="store_true",
13651
- help="Drop all cached entries and reingest from scratch",
13652
- )
13653
- p_cache_sync.add_argument(
13654
- "--source",
13655
- choices=("claude", "codex", "all"),
13656
- default="all",
13657
- help="Which ingest half to sync/rebuild (default: all).",
13658
- )
13659
- p_cache_sync.set_defaults(func=cmd_cache_sync)
13660
-
13661
- # -- daily --
13662
- _build_daily_parser(
13663
- sub, "daily",
13664
- help_text="Show usage report grouped by date",
13665
- xref="Alias of `cctally claude daily` (the canonical form).")
13666
-
13667
- # -- monthly --
13668
- _build_monthly_parser(
13669
- sub, "monthly",
13670
- help_text="Show usage report grouped by month",
13671
- xref="Alias of `cctally claude monthly` (the canonical form).")
13672
-
13673
- # -- weekly --
13674
- _build_weekly_parser(
13675
- sub, "weekly",
13676
- help_text="Show usage grouped by subscription week (with Used %% and $/1%%)",
13677
- xref="Alias of `cctally claude weekly` (the canonical form).")
13678
-
13679
- # -- codex-daily --
13680
- _build_codex_daily_parser(
13681
- sub, "codex-daily",
13682
- help_text="Show Codex usage report grouped by date (drop-in for `ccusage-codex daily`)",
13683
- xref="Alias of `cctally codex daily` (the canonical form).")
13684
-
13685
- # -- codex-monthly --
13686
- _build_codex_monthly_parser(
13687
- sub, "codex-monthly",
13688
- help_text="Show Codex usage grouped by month (drop-in for `ccusage-codex monthly`)",
13689
- xref="Alias of `cctally codex monthly` (the canonical form).")
13690
-
13691
- # -- codex-weekly --
13692
- _build_codex_weekly_parser(
13693
- sub, "codex-weekly",
13694
- help_text="Show Codex usage grouped by week (week-start from config.json)",
13695
- xref="Alias of `cctally codex weekly` (the canonical form).")
13696
-
13697
- # -- codex-session --
13698
- _build_codex_session_parser(
13699
- sub, "codex-session",
13700
- help_text="Show Codex usage grouped by session (drop-in for `ccusage-codex session`)",
13701
- xref="Alias of `cctally codex session` (the canonical form).")
13702
-
13703
- # -- project --
13704
- p_project = sub.add_parser(
13705
- "project",
13706
- help="Roll usage up by project (git-root), with per-project Used %% attribution",
13707
- formatter_class=CLIHelpFormatter,
13708
- description=(
13709
- "Aggregate Claude usage by project (git-root resolved). Default range is "
13710
- "the current subscription week; use --since/--until or --weeks N to extend."
13711
- ),
13712
- epilog=textwrap.dedent("""\
13713
- Examples:
13714
- cctally project
13715
- cctally project --weeks 4
13716
- cctally project --since 20260401 --until 20260414
13717
- cctally project --project ccusage --model sonnet
13718
- cctally project --breakdown --sort used --order desc
13719
- cctally project --group full-path --json
13720
- """),
13721
- )
13722
- p_project.add_argument("-s", "--since", default=None, metavar="YYYYMMDD",
13723
- help="Inclusive start date (YYYY-MM-DD or YYYYMMDD).")
13724
- p_project.add_argument("-u", "--until", default=None, metavar="YYYYMMDD",
13725
- help="Inclusive end date (YYYY-MM-DD or YYYYMMDD).")
13726
- p_project.add_argument("--weeks", type=int, default=None,
13727
- help="Last N subscription weeks ending now.")
13728
- p_project.add_argument("--project", action="append", default=[], metavar="PATTERN",
13729
- help="Substring filter on project display key (repeatable, OR).")
13730
- p_project.add_argument("--model", action="append", default=[], metavar="PATTERN",
13731
- help="Substring filter on model name (repeatable, OR).")
13732
- p_project.add_argument("-b", "--breakdown", action="store_true",
13733
- help="Add per-model child rows under each project.")
13734
- p_project.add_argument("-o", "--order", choices=("asc", "desc"), default="desc",
13735
- help="Sort direction (default: desc).")
13736
- p_project.add_argument("--sort", choices=("cost", "used", "name", "last-seen"),
13737
- default="cost",
13738
- help="Sort key (default: cost).")
13739
- p_project.add_argument("--group", choices=("git-root", "full-path"), default="git-root",
13740
- help="Bucket by resolved git-root (default) or raw project_path.")
13741
- p_project.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
13742
- help="In --format output, show real project basenames instead of "
13743
- "the default project-1, project-2, ... anonymization.")
13744
- p_project.add_argument("--no-color", action="store_true", dest="no_color",
13745
- help="Disable ANSI color.")
13746
- p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
13747
- help="Display timezone: local, utc, or IANA name. "
13748
- "Overrides config display.tz for this call.")
13749
- # Session A (spec §7.6): ansi_emit=True. project is one of the two
13750
- # real ANSI emitters. The helper skips its --no-color add (already
13751
- # declared at p_project above) and adds the new bool --color flag
13752
- # whose precedence flows through _resolve_color_enabled (§7.3).
13753
- _add_ccusage_alias_args(p_project, ansi_emit=True)
13754
- _add_share_args(p_project)
13755
- p_project.set_defaults(func=cmd_project)
13756
-
13757
- # -- diff --
13758
- diff_p = sub.add_parser(
13759
- "diff",
13760
- help="Compare Claude usage between two windows.",
13761
- )
13762
- diff_p.add_argument("--a", required=True,
13763
- 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)")
13764
- diff_p.add_argument("--b", required=True, help="Window B token (same grammar as --a)")
13765
- diff_p.add_argument("--allow-mismatch", action="store_true",
13766
- help="Permit mismatched window lengths (deltas normalized per-day)")
13767
- diff_p.add_argument("--only", help="Comma-separated section list (overall,models,projects,cache)")
13768
- diff_p.add_argument("--with", dest="with_extra",
13769
- help="Comma-separated opt-in sections (trend,time)")
13770
- diff_p.add_argument("--all", dest="show_all", action="store_true",
13771
- help="Show all rows (bypass noise filter)")
13772
- diff_p.add_argument("--min-delta", type=float, dest="min_delta_usd",
13773
- help="Override |Δ$| noise threshold (default 0.10)")
13774
- diff_p.add_argument("--min-delta-pct", type=float,
13775
- help="Override |Δ%%| noise threshold (default 1.0)")
13776
- diff_p.add_argument("--sort",
13777
- choices=["delta", "cost-a", "cost-b", "name", "status"], default="delta")
13778
- diff_p.add_argument("--top", type=int, help="Cap rows per section after filter+sort")
13779
- diff_p.add_argument("--sync", action="store_true",
13780
- help="Run sync_cache + sync-week before computing")
13781
- diff_p.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
13782
- help="Display timezone: local, utc, or IANA name. "
13783
- "Overrides config display.tz for this call.")
13784
- diff_p.add_argument("--no-color", action="store_true")
13785
- diff_p.add_argument("--json", dest="emit_json", action="store_true")
13786
- diff_p.add_argument("--width", type=int, help=argparse.SUPPRESS)
13787
- diff_p.add_argument("--debug-now", action="store_true", help=argparse.SUPPRESS)
13788
- # Session A (spec §7.6): ansi_emit=True. diff is the other real
13789
- # ANSI emitter. The helper skips its --no-color add (declared
13790
- # above) and adds the new bool --color flag wired through
13791
- # _resolve_color_enabled (§7.3). Note: --debug here collides with
13792
- # diff's existing `--debug-now` (SUPPRESS'd internal flag) but
13793
- # `--debug-now` is a different option string; the helper still
13794
- # adds plain `--debug` cleanly.
13795
- _add_ccusage_alias_args(diff_p, ansi_emit=True)
13796
- diff_p.set_defaults(func=cmd_diff)
13797
-
13798
- # -- session --
13799
- _build_session_parser(
13800
- sub, "session",
13801
- help_text="Show Claude usage grouped by sessionId (merges resumed-across-files sessions)",
13802
- xref="Alias of `cctally claude session` (the canonical form).")
13803
-
13804
- # --- `claude` subgroup (drop-in for `ccusage claude …`); issue #86 Session B ---
13805
- # Build-once, register-twice: these reuse the same nine builders as the flat
13806
- # forms above. Nested subparsers reuse dest="command" so args.command resolves
13807
- # to the leaf name (e.g. "blocks"), keeping banner suppression byte-identical
13808
- # to the flat form with zero hook-path changes.
13809
- claude_p = sub.add_parser(
13810
- "claude",
13811
- help="Claude-source reports (drop-in for `ccusage claude …`)",
13812
- formatter_class=CLIHelpFormatter,
13813
- description="Claude-source usage reports. Each subcommand is a drop-in for the "
13814
- "matching `ccusage claude <cmd>` and shares its engine with the "
13815
- "top-level `cctally <cmd>` alias.")
13816
- claude_sub = claude_p.add_subparsers(dest="command", required=True, metavar="<command>")
13817
- _build_daily_parser(claude_sub, "daily",
13818
- help_text="Show usage grouped by date",
13819
- xref="Drop-in for `ccusage claude daily`. Same engine as `cctally daily`.")
13820
- _build_monthly_parser(claude_sub, "monthly",
13821
- help_text="Show usage grouped by month",
13822
- xref="Drop-in for `ccusage claude monthly`. Same engine as `cctally monthly`.")
13823
- _build_weekly_parser(claude_sub, "weekly",
13824
- help_text="Show usage grouped by subscription week",
13825
- xref="Drop-in for `ccusage claude weekly`. Same engine as `cctally weekly`.")
13826
- _build_session_parser(claude_sub, "session",
13827
- help_text="Show usage grouped by session",
13828
- xref="Drop-in for `ccusage claude session`. Same engine as `cctally session`.")
13829
- _build_blocks_parser(claude_sub, "blocks",
13830
- help_text="Show usage grouped by 5-hour session blocks",
13831
- xref="Drop-in for `ccusage claude blocks`. Same engine as `cctally blocks`.")
13832
- _build_statusline_parser(claude_sub, "statusline",
13833
- help_text="Compact one-line status for Claude Code hooks",
13834
- xref="Canonical `cctally claude statusline` (flat alias: `cctally statusline`). "
13835
- "Drop-in for `ccusage statusline` plus cctally extension segments.")
13836
-
13837
- # --- `codex` subgroup (drop-in for `ccusage codex …`); issue #86 Session B ---
13838
- codex_p = sub.add_parser(
13839
- "codex",
13840
- help="Codex-source reports (drop-in for `ccusage codex …`)",
13841
- formatter_class=CLIHelpFormatter,
13842
- description="Codex-source usage reports. daily/monthly/session are drop-ins for "
13843
- "`ccusage codex <cmd>`; weekly is a cctally extension. Each shares its "
13844
- "engine with the matching `cctally codex-<cmd>` alias.")
13845
- codex_sub = codex_p.add_subparsers(dest="command", required=True, metavar="<command>")
13846
- _build_codex_daily_parser(codex_sub, "daily",
13847
- help_text="Show Codex usage grouped by date",
13848
- xref="Drop-in for `ccusage codex daily`. Same engine as `cctally codex-daily`.")
13849
- _build_codex_monthly_parser(codex_sub, "monthly",
13850
- help_text="Show Codex usage grouped by month",
13851
- xref="Drop-in for `ccusage codex monthly`. Same engine as `cctally codex-monthly`.")
13852
- _build_codex_session_parser(codex_sub, "session",
13853
- help_text="Show Codex usage grouped by session",
13854
- xref="Drop-in for `ccusage codex session`. Same engine as `cctally codex-session`.")
13855
- _build_codex_weekly_parser(codex_sub, "weekly",
13856
- help_text="Show Codex usage grouped by week",
13857
- xref="cctally extension (no upstream `ccusage codex weekly`). Same engine as "
13858
- "`cctally codex-weekly`.")
13859
-
13860
- # ---- config (persisted user preferences) ----
13861
- cfg_p = sub.add_parser(
13862
- "config",
13863
- help="Get / set / unset persisted user preferences",
13864
- formatter_class=CLIHelpFormatter,
13865
- description=textwrap.dedent("""\
13866
- Manage cctally user preferences in ~/.local/share/cctally/config.json.
13867
-
13868
- Currently supported keys:
13869
- display.tz Display timezone. Values: 'local' (default; host
13870
- zone via the OS locale), 'utc', or any IANA name
13871
- like 'America/New_York'. Per-call --tz flag on
13872
- any subcommand still wins over the persisted value.
13873
- alerts.enabled Enable/disable threshold alerts (true/false).
13874
- dashboard.bind Host the `dashboard` subcommand binds. Values:
13875
- 'loopback' (default; binds 127.0.0.1 —
13876
- loopback-only), 'lan' (binds 0.0.0.0 —
13877
- LAN-accessible), or any literal IP / hostname.
13878
-
13879
- Examples:
13880
- cctally config get
13881
- cctally config get display.tz
13882
- cctally config set display.tz America/New_York
13883
- cctally config set dashboard.bind lan
13884
- cctally config unset dashboard.bind
13885
- """),
13886
- )
13887
- cfg_sub = cfg_p.add_subparsers(dest="action", required=True)
13888
- cfg_get = cfg_sub.add_parser("get", help="Print current value(s)")
13889
- cfg_get.add_argument("key", nargs="?", help="Config key (omit to list all)")
13890
- cfg_get.add_argument("--json", dest="emit_json", action="store_true",
13891
- help="Emit JSON instead of key=value lines.")
13892
- cfg_get.set_defaults(func=cmd_config)
13893
- cfg_set = cfg_sub.add_parser("set", help="Set a config value")
13894
- cfg_set.add_argument("key", help="Config key")
13895
- cfg_set.add_argument("value", help="New value")
13896
- cfg_set.add_argument("--json", dest="emit_json", action="store_true",
13897
- help="Emit JSON instead of key=value confirmation.")
13898
- cfg_set.set_defaults(func=cmd_config)
13899
- cfg_unset = cfg_sub.add_parser("unset", help="Remove a config override")
13900
- cfg_unset.add_argument("key", help="Config key")
13901
- cfg_unset.set_defaults(func=cmd_config)
13902
-
13903
- # ---- alerts (threshold-actions Task 6) ----
13904
- p_alerts = sub.add_parser(
13905
- "alerts",
13906
- help="Manage threshold alerts",
13907
- formatter_class=CLIHelpFormatter,
13908
- description=textwrap.dedent("""\
13909
- Manage cctally threshold alerts.
13910
-
13911
- Subcommands:
13912
- test Send a synthetic test alert through the dispatch
13913
- pipeline (osascript spawn + alerts.log line). Logs
13914
- with mode=test so it doesn't pollute real-alert
13915
- history.
13916
-
13917
- Examples:
13918
- cctally alerts test
13919
- cctally alerts test --axis five-hour --threshold 95
13920
- """),
13921
- )
13922
- alerts_sub = p_alerts.add_subparsers(dest="alerts_command", required=True)
13923
- p_alerts_test = alerts_sub.add_parser(
13924
- "test",
13925
- help="Send a synthetic test alert through the dispatch pipeline",
13926
- formatter_class=CLIHelpFormatter,
13927
- description=textwrap.dedent("""\
13928
- Send a synthetic test alert end-to-end through the alert
13929
- dispatch pipeline.
13930
-
13931
- Builds a fake payload using the same content builders the
13932
- real-alert path uses, then routes through the same osascript
13933
- spawn and alerts.log writer as production. Distinguishes
13934
- itself from real threshold-crossing alerts by writing the
13935
- alerts.log line with mode=test (5th tab-delimited field) —
13936
- no DB writes, no envelope mutation, so it cannot pollute
13937
- real-alert history.
13938
-
13939
- Use this to verify Notification Center delivery is working
13940
- (osascript present, notifications enabled, no Do Not Disturb)
13941
- without waiting for a real percent crossing.
13942
-
13943
- Exit codes:
13944
- 0 alert was queued (osascript spawned successfully)
13945
- 1 osascript missing on this host (not macOS, or binary unavailable)
13946
- 2 --threshold out of [1, 100] range
13947
- 3 other spawn error (PermissionError, OSError, etc.)
13948
-
13949
- Examples:
13950
- cctally alerts test
13951
- cctally alerts test --axis five-hour --threshold 95
13952
- cctally alerts test --axis budget --threshold 100
13953
- """),
13954
- )
13955
- p_alerts_test.add_argument(
13956
- "--axis",
13957
- choices=["weekly", "five-hour", "budget"],
13958
- default="weekly",
13959
- help="Alert axis to simulate: weekly subscription window, 5h block, "
13960
- "or equiv-$ budget (default: weekly).",
13961
- )
13962
- p_alerts_test.add_argument(
13963
- "--threshold",
13964
- type=int,
13965
- default=90,
13966
- help="Threshold percent (1-100, default: 90).",
13967
- )
13968
- p_alerts_test.set_defaults(func=cmd_alerts_test)
13969
-
13970
- # ---- setup (onboarding spec §2) ----
13971
- sp = sub.add_parser(
13972
- "setup",
13973
- help="Install cctally into Claude Code (hooks + symlinks)",
13974
- formatter_class=CLIHelpFormatter,
13975
- description=textwrap.dedent(
13976
- """\
13977
- Install cctally into Claude Code by adding hook entries to
13978
- ~/.claude/settings.json (additive, idempotent) and creating
13979
- user-facing symlinks under ~/.local/bin/.
13980
-
13981
- Modes (mutually exclusive):
13982
- cctally setup # install (default)
13983
- cctally setup --dry-run # show planned changes, change nothing
13984
- cctally setup --status # report current install state
13985
- cctally setup --uninstall # remove hooks + symlinks (keep data)
13986
- cctally setup --uninstall --purge # also wipe ~/.local/share/cctally/
13987
- """
13988
- ),
13989
- )
13990
- mode = sp.add_mutually_exclusive_group()
13991
- mode.add_argument("--status", action="store_true", help="Report current install state")
13992
- mode.add_argument("--uninstall", action="store_true",
13993
- help="Remove hooks + symlinks (keep data unless --purge)")
13994
- mode.add_argument("--dry-run", action="store_true", dest="dry_run",
13995
- help="Show planned changes without modifying anything")
13996
- sp.add_argument("--purge", action="store_true",
13997
- help="With --uninstall: also wipe ~/.local/share/cctally/")
13998
- sp.add_argument("--yes", "-y", action="store_true",
13999
- help="Skip confirmations")
14000
- sp.add_argument("--json", action="store_true",
14001
- help="Emit machine-readable output")
14002
- sp.add_argument("--force-dev", action="store_true", dest="force_dev",
14003
- help="Allow setup to run from a dev checkout (writes "
14004
- "dev-pointing hooks into ~/.claude/settings.json)")
14005
- # Legacy bespoke-hook migration flags (install-mode only — see cmd_setup
14006
- # post-parse validation). Spec Section 2 mode×flag matrix.
14007
- mig_group = sp.add_mutually_exclusive_group()
14008
- mig_group.add_argument(
14009
- "--migrate-legacy-hooks", action="store_true", dest="migrate_legacy_hooks",
14010
- help="Auto-accept the legacy-bespoke-hook migration prompt (install only).",
14011
- )
14012
- mig_group.add_argument(
14013
- "--no-migrate-legacy-hooks", action="store_true", dest="no_migrate_legacy_hooks",
14014
- help="Auto-skip the legacy-bespoke-hook migration prompt (install only).",
14015
- )
14016
- sp.set_defaults(func=cmd_setup)
14017
-
14018
- # ---- db (migration framework — spec §4) ----
14019
- db_parser = sub.add_parser(
14020
- "db",
14021
- help="Migration / DB management (status, skip, unskip)",
14022
- formatter_class=CLIHelpFormatter,
14023
- description=textwrap.dedent(
14024
- """\
14025
- Inspect and manage cctally's SQLite migration state.
14026
-
14027
- Subcommands:
14028
- status List migrations + applied/pending/failed/skipped
14029
- state across stats.db and cache.db. Glyphs:
14030
- ✓ applied ✗ failed · pending ~ skipped
14031
- skip Mark a migration as skipped (manual poison-pill
14032
- escape — bypass an offending migration).
14033
- unskip Remove a skip mark; the migration runs on next
14034
- open.
14035
-
14036
- Migration names accept either bare ("003_…") or qualified
14037
- ("stats.db:003_…" / "cache.db:003_…") forms. Bare names are
14038
- rejected with exit 2 if the same NNN_… exists in both
14039
- registries.
14040
-
14041
- Examples:
14042
- cctally db status
14043
- cctally db status --json
14044
- cctally db skip 003_merge_5h_block_duplicates_v1 --reason "perf hot"
14045
- cctally db unskip stats.db:003_merge_5h_block_duplicates_v1
14046
- """
14047
- ),
14048
- )
14049
- db_sub = db_parser.add_subparsers(dest="db_action", required=True)
14050
-
14051
- db_status = db_sub.add_parser(
14052
- "status",
14053
- help="List migrations + applied/pending/failed/skipped state",
14054
- )
14055
- db_status.add_argument(
14056
- "--json",
14057
- action="store_true",
14058
- help="Emit JSON to stdout",
14059
- )
14060
- db_status.set_defaults(func=cmd_db_status)
14061
-
14062
- db_skip = db_sub.add_parser(
14063
- "skip",
14064
- help="Mark a migration as skipped",
14065
- )
14066
- db_skip.add_argument(
14067
- "name",
14068
- help="Migration name (NNN_… or stats.db:NNN_… / cache.db:NNN_…)",
14069
- )
14070
- db_skip.add_argument(
14071
- "--reason",
14072
- help="Free-text reason (shown in db status)",
14073
- )
14074
- db_skip.set_defaults(func=cmd_db_skip)
14075
-
14076
- db_unskip = db_sub.add_parser(
14077
- "unskip",
14078
- help="Remove a skip mark; migration runs on next open",
14079
- )
14080
- db_unskip.add_argument(
14081
- "name",
14082
- help="Migration name (NNN_… or qualified)",
14083
- )
14084
- db_unskip.set_defaults(func=cmd_db_unskip)
14085
-
14086
- # ─── doctor (Diagnostics) ───────────────────────────────────────────
14087
- doctor_p = sub.add_parser(
14088
- "doctor",
14089
- help="Diagnose data freshness and install state",
14090
- formatter_class=CLIHelpFormatter,
14091
- description=textwrap.dedent(
14092
- """\
14093
- Run all read-only diagnostic checks and emit a report.
14094
-
14095
- Categories: install, hooks, auth, db, data, safety. Each
14096
- category renders a severity (✓ ok / ⚠ warn / ✗ fail) and
14097
- actionable remediation guidance for non-OK rows.
14098
-
14099
- Exit code: 0 unless any check is FAIL (then exit 2). WARN
14100
- rows do not change the exit code — doctor is a read-only
14101
- diagnostic and warn-class findings are advisories.
14102
-
14103
- See docs/commands/doctor.md for the full check inventory
14104
- and JSON schema reference.
14105
- """
14106
- ),
14107
- )
14108
- doctor_p.add_argument(
14109
- "--json", action="store_true",
14110
- help="Emit machine-readable JSON to stdout (schema_version: 1)",
14111
- )
14112
- doctor_mutex = doctor_p.add_mutually_exclusive_group()
14113
- doctor_mutex.add_argument(
14114
- "--quiet", "-q", action="store_true",
14115
- help="Hide OK rows (human mode only)",
14116
- )
14117
- doctor_mutex.add_argument(
14118
- "--verbose", "-v", action="store_true",
14119
- help="Include each check's details block (human mode only)",
14120
- )
14121
- doctor_p.set_defaults(func=cmd_doctor)
14122
-
14123
- # ---- pricing-check (standalone diagnostic — NOT under claude/codex) ----
14124
- pc_p = sub.add_parser(
14125
- "pricing-check",
14126
- help="Detect stale or missing embedded model pricing",
14127
- formatter_class=CLIHelpFormatter,
14128
- description=textwrap.dedent(
14129
- """\
14130
- Check whether cctally's embedded model pricing is stale or
14131
- missing, across three independently-degrading legs:
14132
-
14133
- • coverage (offline, all-history) — models in your cached
14134
- session data that cctally cannot price (Claude $0) or only
14135
- approximates (Codex gpt-5 fallback).
14136
- • drift (network, LiteLLM) — embedded price values vs the
14137
- LiteLLM snapshot (direction-aware; allowlist-suppressed).
14138
- • existence (network, Anthropic /v1/models) — vendor models the
14139
- API offers that our table lacks. Maintainer-local (needs
14140
- OAuth); degrades to skipped/degraded otherwise.
14141
-
14142
- Exit codes:
14143
- 0 — no actionable findings (fully clean, OR partially/fully
14144
- network-degraded but nothing actionable; --json still
14145
- carries "status":"degraded").
14146
- 1 — any actionable finding (a coverage gap, value drift,
14147
- missing-from-us, or an existence gap) — EVEN IF a network
14148
- leg degraded. Findings always win over degradation.
14149
- 2 — argument/usage error.
14150
-
14151
- "status" (ok|degraded) reports check completeness; the exit code
14152
- reports whether you must act. They are orthogonal.
14153
-
14154
- See docs/commands/pricing-check.md for the JSON schema.
14155
- """
14156
- ),
14157
- )
14158
- pc_p.add_argument(
14159
- "--json", action="store_true",
14160
- help="Emit machine-readable JSON to stdout (schemaVersion: 1)",
14161
- )
14162
- pc_p.add_argument(
14163
- "--offline", action="store_true",
14164
- help="Coverage only — skip both network legs (LiteLLM + /v1/models)",
14165
- )
14166
- pc_p.set_defaults(func=cmd_pricing_check)
14167
-
14168
- # `release` is its own standalone entry-point (bin/cctally-release);
14169
- # no `release` subparser is registered on the main `cctally` CLI.
14170
- # See docs/RELEASE.md.
14171
-
14172
- # ---- hook-tick (internal — hidden from --help, see onboarding spec §3) ----
14173
- ht = sub.add_parser(
14174
- "hook-tick",
14175
- help=argparse.SUPPRESS,
14176
- formatter_class=CLIHelpFormatter,
14177
- description=textwrap.dedent(
14178
- """\
14179
- Internal subcommand invoked by Claude Code hooks.
14180
-
14181
- Reads CC's hook payload from stdin, runs sync_cache, and
14182
- conditionally refreshes the OAuth usage cache (throttled).
14183
- Returns 0 unconditionally in normal mode.
14184
- """
14185
- ),
14186
- )
14187
- ht.add_argument("--explain", action="store_true",
14188
- help="Run synchronously, print decision tree, exit informative code")
14189
- ht.add_argument("--no-oauth", action="store_true",
14190
- help="Skip the OAuth refresh entirely (local sync only)")
14191
- ht.add_argument("--throttle-seconds", type=float, default=None,
14192
- help=f"Override throttle (default {int(HOOK_TICK_DEFAULT_THROTTLE_SECONDS)}s)")
14193
- ht.add_argument("--event", type=str, default=None,
14194
- help="Override the event name written to the log line "
14195
- "(used by --explain and tests)")
14196
- ht.add_argument("--mock-oauth-response", type=str, default=None,
14197
- help=argparse.SUPPRESS) # JSON string fed to mock fetch (tests only)
14198
- ht.set_defaults(func=cmd_hook_tick)
14199
-
14200
- # ---- update (user-facing self-update subcommand; spec §4) ----
14201
- sub_update = sub.add_parser(
14202
- "update",
14203
- help="Update cctally to the latest version",
14204
- formatter_class=CLIHelpFormatter,
14205
- description=textwrap.dedent(
14206
- """\
14207
- Update cctally to the latest version (npm/brew installs only).
14208
-
14209
- Modes:
14210
- cctally update install the latest version
14211
- cctally update --check show update info without installing
14212
- cctally update --skip [VER] don't remind about VER (default: latest)
14213
- cctally update --remind-later [DAYS] defer the banner (default: 7)
14214
- """
14215
- ),
14216
- )
14217
- update_modes = sub_update.add_mutually_exclusive_group()
14218
- update_modes.add_argument(
14219
- "--check", action="store_true",
14220
- help="Show update info without installing",
14221
- )
14222
- update_modes.add_argument(
14223
- "--skip", nargs="?", const=SKIP_USE_STATE_LATEST, metavar="VERSION",
14224
- default=None,
14225
- help="Skip a specific version (default: latest in cache)",
14226
- )
14227
- update_modes.add_argument(
14228
- "--remind-later", nargs="?", type=int, const=7, metavar="DAYS",
14229
- default=None,
14230
- help="Defer reminders by N days (default: 7)",
14231
- )
14232
- # `--version` here is local to the update subparser. The subparser's
14233
- # value is bound to `args.install_version` (NOT `args.version`) to
14234
- # avoid a namespace collision with the top-level `--version`
14235
- # (store_true) flag handled in `main()` before subcommand dispatch:
14236
- # if both used `dest="version"`, `cctally update --version 1.2.3`
14237
- # would set `args.version="1.2.3"`, which `main()`'s truthy check
14238
- # would treat as "global --version requested" and short-circuit to
14239
- # print the version banner before `cmd_update` ever ran.
14240
- sub_update.add_argument(
14241
- "--version", metavar="X.Y.Z", default=None, dest="install_version",
14242
- help="Install a specific version (npm only; brew has no versioned formulae)",
14243
- )
14244
- sub_update.add_argument(
14245
- "--dry-run", action="store_true",
14246
- help="Show what would happen, don't install",
14247
- )
14248
- sub_update.add_argument(
14249
- "--force", action="store_true",
14250
- help="Bypass TTL on --check (force a fresh remote fetch)",
14251
- )
14252
- sub_update.add_argument(
14253
- "--json", action="store_true",
14254
- help="Emit JSON output (mostly with --check)",
14255
- )
14256
- sub_update.set_defaults(func=cmd_update)
14257
-
14258
- # ---- _update-check (internal — hidden, detached-refresh worker for `cctally update`) ----
14259
- uc = sub.add_parser(
14260
- "_update-check",
14261
- help=argparse.SUPPRESS,
14262
- formatter_class=CLIHelpFormatter,
14263
- description=textwrap.dedent(
14264
- """\
14265
- Internal subcommand: detached version-check worker spawned
14266
- by `cctally update` (spec §3.6). Touches the throttle
14267
- marker, fetches the latest version from npm or homebrew
14268
- depending on install method, and writes update-state.json.
14269
- Always returns 0; failures are logged to update.log.
14270
- """
14271
- ),
14272
- )
14273
- uc.set_defaults(func=cmd_update_check_internal)
14274
-
14275
- # ---- repair-symlinks (internal — hidden; npm-postinstall self-heal, issue #114) ----
14276
- rs = sub.add_parser(
14277
- "repair-symlinks",
14278
- help=argparse.SUPPRESS,
14279
- formatter_class=CLIHelpFormatter,
14280
- description=textwrap.dedent(
14281
- """\
14282
- Internal subcommand: additively create any missing
14283
- ~/.local/bin/ symlinks for cctally subcommands (issue #114).
14284
-
14285
- Invoked best-effort by the npm postinstall on upgrade so new
14286
- cctally-* binaries become reachable without re-running
14287
- `cctally setup`. Gated to existing installs (>=1 symlink
14288
- already present); a fresh install is a silent no-op. Touches
14289
- only symlinks — no hooks, settings.json, or cache. Refuses
14290
- from a dev checkout.
14291
- """
14292
- ),
14293
- )
14294
- rs.set_defaults(func=cmd_repair_symlinks)
14295
-
14296
- # Python 3.14 leaks `==SUPPRESS==` for hidden subparsers in --help; strip
14297
- # the pseudo-action so the row disappears entirely. (The choice still
14298
- # appears in the `{...}` choices header — there's no clean way to hide
14299
- # that without a custom formatter, and it's harmless.)
14300
- sub._choices_actions = [
14301
- a for a in getattr(sub, "_choices_actions", [])
14302
- if getattr(a, "help", None) is not argparse.SUPPRESS
14303
- ]
14304
-
14305
- return p
14306
-
14307
-
14308
- # ============================================================
14309
- # ==== Shareable reports: destination + emit ==== =
14310
- # ============================================================
14311
- # Translate parsed argparse args + a rendered string into actual delivery
14312
- # (stdout / file / clipboard / open). These helpers live here, NOT in
14313
- # `_lib_share.py`, so the kernel module stays I/O-pure (Section 5.8 of the
14314
- # shareable-reports spec).
14315
-
14316
-
14317
- # Module-level latch for the home-dir fallback hint. Spec Section 4.2 calls
14318
- # for a one-shot stderr suggestion when share output lands in $HOME because
14319
- # both XDG_DOWNLOAD_DIR and ~/Downloads were absent. Latched here (process
14320
- # scope) so a user running, e.g., a `cctally daily --format html` followed
14321
- # by `cctally weekly --format html` in the same shell sees the hint exactly
14322
- # once. Tests reset by reaching into the module globals if needed.
14323
- _DOWNLOADS_HOME_HINT_EMITTED = False
14324
-
14325
-
14326
- def _share_resolve_download_dir() -> pathlib.Path:
14327
- """XDG -> ~/Downloads -> ~ fallback (Section 4.2)."""
14328
- global _DOWNLOADS_HOME_HINT_EMITTED
14329
- xdg = os.environ.get("XDG_DOWNLOAD_DIR")
14330
- if xdg:
14331
- p = pathlib.Path(xdg).expanduser()
14332
- if p.exists():
14333
- return p
14334
- downloads = pathlib.Path.home() / "Downloads"
14335
- if downloads.exists():
14336
- return downloads
14337
- if not _DOWNLOADS_HOME_HINT_EMITTED:
14338
- sys.stderr.write(
14339
- "cctally: writing share output to home dir; "
14340
- "pass --output <path> to choose a destination\n"
14341
- )
14342
- _DOWNLOADS_HOME_HINT_EMITTED = True
14343
- return pathlib.Path.home()
14344
-
14345
-
14346
- def _share_unique_path(base: pathlib.Path) -> pathlib.Path:
14347
- """Auto-collision counter — base.html -> base-2.html -> base-3.html -> ... cap 99.
14348
-
14349
- Exhaustion (>99 same-day collisions) exits 3 per spec Section 4.4. Prior
14350
- code raised ``SystemExit("…")`` which yields exit 1 — broke the spec's
14351
- distinct-exit-code contract for collision exhaustion vs. generic errors.
14352
- """
14353
- if not base.exists():
14354
- return base
14355
- stem = base.stem
14356
- suffix = base.suffix
14357
- parent = base.parent
14358
- for n in range(2, 100):
14359
- candidate = parent / f"{stem}-{n}{suffix}"
14360
- if not candidate.exists():
14361
- return candidate
14362
- print(
14363
- f"cctally: too many same-day collisions in {parent}; use --output <path>",
14364
- file=sys.stderr,
14365
- )
14366
- sys.exit(3)
14367
-
14368
-
14369
- def _resolve_destination(
14370
- args, *, cmd: str, generated_at_utc_date: str
14371
- ) -> tuple[str, pathlib.Path | None]:
14372
- """Translate argparse args into (kind, value).
14373
-
14374
- kind: "stdout" | "file" | "clipboard"
14375
- value: pathlib.Path for "file"; None for "stdout" / "clipboard".
14376
-
14377
- Exit-code contract (spec Section 4.4):
14378
- - exit 2 on invalid flag combinations (--copy on non-md;
14379
- --copy + --output; --copy with no clipboard tool; --open + md).
14380
- - exit 3 on collision exhaustion (delegated to _share_unique_path).
14381
- """
14382
- fmt = args.format
14383
- if getattr(args, "copy", False) and getattr(args, "output", None) is not None:
14384
- # Mutex: a clipboard destination by definition has no path. Spec
14385
- # Section 4.4 line 132 calls this out explicitly. Prior code silently
14386
- # let --copy override --output, which surprised users who expected
14387
- # the file to land alongside the clipboard write.
14388
- print(
14389
- "cctally: --copy is mutually exclusive with --output",
14390
- file=sys.stderr,
14391
- )
14392
- sys.exit(2)
14393
- if getattr(args, "copy", False):
14394
- if fmt != "md":
14395
- print("cctally: --copy is only valid with --format md", file=sys.stderr)
14396
- sys.exit(2)
14397
- return ("clipboard", None)
14398
-
14399
- output = getattr(args, "output", None)
14400
- if output == "-":
14401
- return ("stdout", None)
14402
- if output:
14403
- return ("file", pathlib.Path(output).expanduser())
14404
-
14405
- if fmt == "md":
14406
- return ("stdout", None)
14407
- # html/svg default -> ~/Downloads/cctally-<cmd>-<utcdate>.<ext>
14408
- base = _share_resolve_download_dir() / f"cctally-{cmd}-{generated_at_utc_date}.{fmt}"
14409
- return ("file", _share_unique_path(base))
14410
-
14411
-
14412
- def _emit(content: str, *, kind: str, value: pathlib.Path | str | None) -> None:
14413
- """Deliver rendered content to stdout/file/clipboard."""
14414
- if kind == "stdout":
14415
- sys.stdout.write(content)
14416
- if not content.endswith("\n"):
14417
- sys.stdout.write("\n")
14418
- return
14419
-
14420
- if kind == "file":
14421
- path = pathlib.Path(value)
14422
- path.parent.mkdir(parents=True, exist_ok=True)
14423
- path.write_text(content, encoding="utf-8")
14424
- sys.stderr.write(f"Wrote {path}\n")
14425
- return
14426
-
14427
- if kind == "clipboard":
14428
- # Track tools that were found-but-failed separately from "no tool on
14429
- # PATH" so the error message accurately describes what went wrong.
14430
- # The prior shape ("requires pbcopy/xclip/clip on PATH") was
14431
- # misleading when e.g. pbcopy was present but exited non-zero.
14432
- tried = []
14433
- for cmd_args in (
14434
- ["pbcopy"],
14435
- ["xclip", "-sel", "clip"],
14436
- ["clip.exe"],
14437
- ):
14438
- tool = cmd_args[0]
14439
- if shutil.which(tool):
14440
- proc = subprocess.run(cmd_args, input=content, text=True, check=False)
14441
- if proc.returncode == 0:
14442
- sys.stderr.write(f"Copied to clipboard via {tool}\n")
14443
- return
14444
- tried.append(f"{tool} (exit {proc.returncode})")
14445
- if tried:
14446
- print(
14447
- f"cctally: clipboard tool failed: {', '.join(tried)}",
14448
- file=sys.stderr,
14449
- )
14450
- sys.exit(2)
14451
- print(
14452
- "cctally: --copy requires pbcopy, xclip, or clip on PATH",
14453
- file=sys.stderr,
14454
- )
14455
- sys.exit(2)
14456
-
14457
- raise ValueError(f"unknown destination kind: {kind!r}")
14458
-
14459
-
14460
- def _share_load_lib():
14461
- """Lazy-load `_lib_share` with sys.modules caching.
14462
-
14463
- Single-load semantics keep ShareSnapshot / MoneyCell / etc. class
14464
- identities stable across kernel imports: the test harness pre-registers
14465
- `_lib_share` in `sys.modules`, the wrapper imports it via this helper,
14466
- and snapshot builders import it via this helper — all paths must see
14467
- the SAME module object so `isinstance` checks on snapshot cells compare
14468
- across one class identity, not many. This is the chokepoint for the
14469
- duplicate-class-identity bug surfaced under the test harness in
14470
- Implementor 6's fix-loop.
14471
-
14472
- Registers in sys.modules BEFORE exec_module: Python 3.14's `dataclass`
14473
- decorator looks up `cls.__module__` in `sys.modules` for `KW_ONLY` type
14474
- checks, and an absent entry would re-trigger the dual-load path under
14475
- some import orders.
14476
- """
14477
- cached = sys.modules.get("_lib_share")
14478
- if cached is not None:
14479
- return cached
14480
- import importlib.util as _ilu
14481
- _lib_share_path = pathlib.Path(__file__).resolve().parent / "_lib_share.py"
14482
- _spec = _ilu.spec_from_file_location("_lib_share", _lib_share_path)
14483
- _mod = _ilu.module_from_spec(_spec)
14484
- sys.modules["_lib_share"] = _mod
14485
- _spec.loader.exec_module(_mod)
14486
- return _mod
14487
-
14488
-
14489
- def _share_now_utc() -> dt.datetime:
14490
- """`generated_at` source — honors CCTALLY_AS_OF env hook for fixture stability.
14491
-
14492
- Mirrors the existing `CCTALLY_AS_OF` precedent used by `project` /
14493
- `forecast` for deterministic fixture goldens. Format: ISO-8601 with `Z`
14494
- or explicit offset (e.g. `2026-05-09T12:00:00Z` or
14495
- `2026-05-09T12:00:00+00:00`); falls back to wall-clock UTC when unset.
14496
-
14497
- Raises ValueError on malformed `CCTALLY_AS_OF` input — deliberate
14498
- fail-loud behavior for the dev hook so fixture authors notice typos
14499
- immediately rather than silently falling back to wall-clock time.
14500
- """
14501
- override = os.environ.get("CCTALLY_AS_OF")
14502
- if override:
14503
- parsed = dt.datetime.fromisoformat(override.replace("Z", "+00:00"))
14504
- if parsed.tzinfo is None:
14505
- parsed = parsed.replace(tzinfo=dt.timezone.utc)
14506
- return parsed.astimezone(dt.timezone.utc)
14507
- return dt.datetime.now(dt.timezone.utc)
14508
-
14509
-
14510
- def _share_now_utc_iso() -> str:
14511
- """`generated_at` ISO-8601 source for /api/share/render snapshot envelopes.
14512
-
14513
- Honors `CCTALLY_AS_OF` like `_share_now_utc` so fixture goldens stay
14514
- deterministic across the CLI and HTTP paths. Format `YYYY-MM-DDTHH:MM:SSZ`.
14515
- """
14516
- return _share_now_utc().strftime("%Y-%m-%dT%H:%M:%SZ")
14517
-
14518
-
14519
- # Spec §11.4 — recent-shares ring buffer caps at 20. Server-side trim
14520
- # in `_handle_share_history_post` so the on-disk `config.json` can't
14521
- # grow unbounded even if a misbehaving client floods POSTs.
14522
- _SHARE_HISTORY_RING_CAP = 20
14523
-
14524
-
14525
- def _share_history_recipe_id() -> str:
14526
- """Server-stamped opaque id for a history record.
14527
-
14528
- Random base16 (26 chars / 13 bytes) is sufficient: we order by
14529
- insertion (ring buffer position), never by id, so we don't need
14530
- ULID timestamp-prefix monotonicity. `secrets.token_hex` keeps us
14531
- on stdlib and avoids the predictability of `random`.
14532
- """
14533
- import secrets
14534
- return secrets.token_hex(13)
14535
-
14536
-
14537
- def _share_resolve_version() -> str:
14538
- """Source from CHANGELOG via the public helper. Empty string if unset.
14539
-
14540
- `_lib_changelog._read_latest_changelog_version` returns
14541
- `(version, date) | None`; the snapshot's `version` field carries
14542
- the version string only.
14543
- """
14544
- info = _lib_changelog._read_latest_changelog_version()
14545
- return info[0] if info else ""
14546
-
14547
-
14548
- def _share_period_label(
14549
- period_start: dt.datetime,
14550
- period_end: dt.datetime,
14551
- display_tz_label: str,
14552
- ) -> str:
14553
- """Render the canonical "<start> → <end> (<tz>)" period label.
14554
-
14555
- Used by both the report and daily snapshot builders so the period label
14556
- format stays consistent across share-enabled subcommands.
14557
- """
14558
- return (
14559
- f"{period_start.strftime('%b %d')} → "
14560
- f"{period_end.strftime('%b %d')} ({display_tz_label})"
14561
- )
14562
-
14563
-
14564
- def _share_parse_date_to_dt(value, tz: "ZoneInfo | None") -> dt.datetime:
14565
- """Coerce a `YYYY-MM-DD` string or `dt.date` into a tz-aware datetime.
14566
-
14567
- Used by the share gate sites to lift week-boundary date strings
14568
- (`weekStartDate`, `weekEndDate`) into the tz-aware datetimes that
14569
- `PeriodSpec` expects. None / empty / unparseable -> current UTC; the
14570
- caller already gated on a non-empty trend before reaching this path,
14571
- so the fallback is purely defensive against missing-data corner cases.
14572
- """
14573
- if value is None:
14574
- return _share_now_utc()
14575
- if isinstance(value, dt.datetime):
14576
- return value if value.tzinfo else value.replace(tzinfo=tz or dt.timezone.utc)
14577
- if isinstance(value, dt.date):
14578
- d = value
14579
- else:
14580
- try:
14581
- d = dt.date.fromisoformat(str(value))
14582
- except ValueError:
14583
- return _share_now_utc()
14584
- midnight = dt.datetime(d.year, d.month, d.day)
14585
- return midnight.replace(tzinfo=tz or dt.timezone.utc)
14586
-
14587
-
14588
- def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
14589
- """Render a stable display-tz string for `PeriodSpec.display_tz`.
14590
-
14591
- `resolve_display_tz` returns `None` for "local" (caller does bare
14592
- astimezone); the share snapshot needs a non-None string. Map None ->
14593
- "local" and use ZoneInfo.key otherwise.
14594
- """
14595
- return tz.key if tz is not None else "local"
14596
-
14597
-
14598
- def _build_report_snapshot(
14599
- view: "TrendView",
14600
- *,
14601
- period_start: dt.datetime,
14602
- period_end: dt.datetime,
14603
- display_tz: str,
14604
- version: str,
14605
- theme: str,
14606
- reveal_projects: bool,
14607
- ) -> "ShareSnapshot":
14608
- """Build a ShareSnapshot for `cctally report`.
14609
-
14610
- Consumes the unified TrendView (spec §6.4). `view.rows` is the
14611
- chronological (oldest-first) TuiTrendRow tuple — exactly the order
14612
- the chart needs (BarChart polyline trends left→right with time);
14613
- no reversal needed.
14614
-
14615
- The earlier camelCase-dict workaround (recorded in the commit body
14616
- of Implementor 7 of the share-v2 work) is obsolete: `TuiTrendRow`
14617
- now carries 10 nullable extended fields (spec §4.1) and is the
14618
- single typed shape that flows through both CLI report and share
14619
- builders. Cmd_report's JSON serialization happens at the gate site
14620
- (camelCase mapping done in cmd_report); this function reads
14621
- attributes directly from the typed row.
14622
-
14623
- `theme` and `reveal_projects` flow into the subtitle directly so
14624
- the builder owns the canonical subtitle shape — no post-build
14625
- re-stamp at the gate site. The forward-reference return type
14626
- matches the kernel's lazy-import boundary.
14627
- """
14628
- _lib_share = _share_load_lib()
14629
- columns = (
14630
- _lib_share.ColumnSpec(key="week", label="Week", align="left"),
14631
- _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
14632
- _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right"),
14633
- _lib_share.ColumnSpec(key="dpp", label="$ / %", align="right",
14634
- emphasis=True),
14635
- )
14636
- rows = view.rows # oldest-first; matches chart's left→right walk.
14637
- snap_rows: list = []
14638
- chart_pts: list = []
14639
- for i, r in enumerate(rows):
14640
- wsd = r.week_start_date.isoformat() if r.week_start_date else None
14641
- if isinstance(wsd, str) and wsd:
14642
- try:
14643
- week_label = dt.date.fromisoformat(wsd).strftime("%b %d")
14644
- except ValueError:
14645
- week_label = wsd
14646
- else:
14647
- week_label = "—"
14648
- # Preserve None vs 0.0 distinction (parity with terminal/JSON).
14649
- # Terminal _render_weekly_table renders missing values as "—";
14650
- # share artifact follows the same convention. Coercing None to
14651
- # 0.0 would render `$0.00` / `0.0%` — indistinguishable from a
14652
- # genuine zero, and would skew the avg / chart.
14653
- used_pct_raw = r.used_pct
14654
- cost_raw = r.weekly_cost_usd
14655
- dpp_raw = r.dollars_per_percent
14656
- snap_rows.append(_lib_share.Row(cells={
14657
- "week": _lib_share.TextCell(week_label),
14658
- "used": (
14659
- _lib_share.PercentCell(float(used_pct_raw))
14660
- if used_pct_raw is not None else _lib_share.TextCell("—")
14661
- ),
14662
- "cost": (
14663
- _lib_share.MoneyCell(float(cost_raw))
14664
- if cost_raw is not None else _lib_share.TextCell("—")
14665
- ),
14666
- "dpp": (
14667
- _lib_share.MoneyCell(float(dpp_raw))
14668
- if dpp_raw is not None else _lib_share.TextCell("—")
14669
- ),
14670
- }))
14671
- # Skip chart points for weeks with no $/% sample — the polyline
14672
- # connects across the gap rather than dropping to 0, which would
14673
- # misrepresent missing data as a crash to zero.
14674
- if dpp_raw is not None:
14675
- chart_pts.append(_lib_share.ChartPoint(
14676
- x_label=week_label,
14677
- x_value=float(i),
14678
- y_value=float(dpp_raw),
14679
- ))
14680
- chart = (
14681
- _lib_share.LineChart(points=tuple(chart_pts), y_label="$ / %")
14682
- if len(chart_pts) >= 3 else None
14683
- )
14684
- # Source the avg from the view (3-sample rule). Falls back to a
14685
- # length-based average over the chart points for the <3-sample case
14686
- # so the Totalled cell always renders something concrete; preserves
14687
- # the prior $0.00 sentinel on empty data.
14688
- if view.avg_dollars_per_pct is not None:
14689
- avg_dpp = view.avg_dollars_per_pct
14690
- else:
14691
- avg_dpp = (
14692
- sum(p.y_value for p in chart_pts) / len(chart_pts)
14693
- if chart_pts else 0.0
14694
- )
14695
- totals = (
14696
- _lib_share.Totalled(label="Avg $/%", value=f"${avg_dpp:,.2f}"),
14697
- )
14698
- if rows:
14699
- title = f"Weekly $ / % trend — last {len(rows)} weeks"
14700
- else:
14701
- title = "Weekly $ / % trend — no data"
14702
- period_label = _share_period_label(period_start, period_end, display_tz)
14703
- subtitle = " · ".join([
14704
- period_label,
14705
- theme,
14706
- "real projects" if reveal_projects else "projects anonymized",
14707
- ])
14708
- return _lib_share.ShareSnapshot(
14709
- cmd="report",
14710
- title=title,
14711
- subtitle=subtitle,
14712
- period=_lib_share.PeriodSpec(
14713
- start=period_start, end=period_end,
14714
- display_tz=display_tz, label=period_label,
14715
- ),
14716
- columns=columns, rows=tuple(snap_rows),
14717
- chart=chart, totals=totals, notes=(),
14718
- generated_at=_share_now_utc(), version=version,
14719
- )
14720
-
14721
-
14722
- def _build_daily_snapshot(
14723
- view: "DailyView",
14724
- *,
14725
- period_start: dt.datetime,
14726
- period_end: dt.datetime,
14727
- display_tz: str,
14728
- version: str,
14729
- theme: str,
14730
- reveal_projects: bool,
14731
- ) -> "ShareSnapshot":
14732
- """Build a ShareSnapshot for `cctally daily`.
14733
-
14734
- Consumes the unified DailyView (spec §6.1). `view.aggregated` is
14735
- the gap-free BucketUsage tuple in newest-first order; we reverse
14736
- here so BarChart bars render left-to-right chronologically.
14737
- `view.total_cost_usd` is the pre-computed sum (replacing the
14738
- prior inline re-totaling).
14739
-
14740
- Deviations from the plan sketch (which assumed dict rows with keys
14741
- `date` / `cost_usd` / `pct_of_week` / `top_model`):
14742
-
14743
- - Rows are `BucketUsage` dataclasses; we read fields by attribute.
14744
- - Daily has no native `% of week` column — daily is range-scoped, not
14745
- week-scoped. We render `% of period` (this row's cost / total range
14746
- cost) so the column carries meaningful info; the `pct_week` key
14747
- survives in the column spec for plan-shape parity.
14748
- - `top_model` is the first entry of `model_breakdowns` (sorted by cost
14749
- desc per upstream ccusage parity); empty → "—".
14750
-
14751
- `period_start` / `period_end` / `display_tz` are passed by the
14752
- caller (they reflect the CLI's `--since` / `--until` window which
14753
- may extend past the data window). `theme` and `reveal_projects`
14754
- flow into the subtitle directly so the builder owns the canonical
14755
- subtitle shape — no post-build re-stamp at the gate site.
14756
- """
14757
- _lib_share = _share_load_lib()
14758
- columns = (
14759
- _lib_share.ColumnSpec(key="date", label="Date", align="left"),
14760
- _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
14761
- emphasis=True),
14762
- _lib_share.ColumnSpec(key="pct_week", label="% of Period",
14763
- align="right"),
14764
- _lib_share.ColumnSpec(key="top_model", label="Top Model",
14765
- align="left"),
14766
- )
14767
- # Caller MUST pass rows in chronological order so the BarChart bars
14768
- # line up left-to-right with time. view.aggregated is newest-first
14769
- # (matches dashboard convention); reverse for chronological iteration.
14770
- rows = list(reversed(view.aggregated))
14771
- total_cost = view.total_cost_usd
14772
-
14773
- snap_rows: list = []
14774
- chart_pts: list = []
14775
- for i, r in enumerate(rows):
14776
- # `BucketUsage.bucket` is typed `str` (YYYY-MM-DD); guard against
14777
- # empty / unparseable but skip the dead `dt.date` branch.
14778
- bucket = getattr(r, "bucket", None)
14779
- if isinstance(bucket, str) and bucket:
14780
- try:
14781
- date_str = dt.date.fromisoformat(bucket).strftime("%b %d")
14782
- except ValueError:
14783
- date_str = bucket
14784
- else:
14785
- date_str = "—"
14786
- cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
14787
- breakdowns = getattr(r, "model_breakdowns", None) or []
14788
- top_model = (breakdowns[0].get("modelName") if breakdowns else None) or "—"
14789
- pct_of_period = (cost_usd / total_cost * 100.0) if total_cost > 0 else 0.0
14790
- snap_rows.append(_lib_share.Row(cells={
14791
- "date": _lib_share.TextCell(date_str),
14792
- "cost": _lib_share.MoneyCell(cost_usd),
14793
- "pct_week": _lib_share.PercentCell(pct_of_period),
14794
- "top_model": _lib_share.TextCell(top_model),
14795
- }))
14796
- chart_pts.append(_lib_share.ChartPoint(
14797
- x_label=date_str,
14798
- x_value=float(i),
14799
- y_value=cost_usd,
14800
- ))
14801
- chart = (
14802
- _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
14803
- if chart_pts else None
14804
- )
14805
- avg_cost = (total_cost / len(chart_pts)) if chart_pts else 0.0
14806
- totals = (
14807
- _lib_share.Totalled(label="Sum", value=f"${total_cost:,.2f}"),
14808
- _lib_share.Totalled(label="Days", value=str(len(chart_pts))),
14809
- _lib_share.Totalled(label="Avg / day", value=f"${avg_cost:,.2f}"),
14810
- )
14811
- if rows:
14812
- title = (
14813
- f"Daily usage — {period_start.strftime('%b %d')} → "
14814
- f"{period_end.strftime('%b %d')}"
14815
- )
14816
- else:
14817
- title = "Daily usage — no data"
14818
- period_label = _share_period_label(period_start, period_end, display_tz)
14819
- subtitle = " · ".join([
14820
- period_label,
14821
- theme,
14822
- "real projects" if reveal_projects else "projects anonymized",
14823
- ])
14824
- return _lib_share.ShareSnapshot(
14825
- cmd="daily",
14826
- title=title,
14827
- subtitle=subtitle,
14828
- period=_lib_share.PeriodSpec(
14829
- start=period_start, end=period_end,
14830
- display_tz=display_tz, label=period_label,
14831
- ),
14832
- columns=columns, rows=tuple(snap_rows),
14833
- chart=chart, totals=totals, notes=(),
14834
- generated_at=_share_now_utc(), version=version,
14835
- )
14836
-
14837
-
14838
- def _build_monthly_snapshot(
14839
- view: "MonthlyView",
14840
- *,
14841
- period_start: dt.datetime,
14842
- period_end: dt.datetime,
14843
- display_tz: str,
14844
- version: str,
14845
- theme: str,
14846
- reveal_projects: bool,
14847
- ) -> "ShareSnapshot":
14848
- """Build a ShareSnapshot for `cctally monthly`.
14849
-
14850
- Consumes the unified MonthlyView (spec §6.2). `view.aggregated` is
14851
- the gap-free BucketUsage tuple in newest-first order; we reverse
14852
- so BarChart bars render left-to-right chronologically.
14853
-
14854
- Deviations from the plan sketch (which assumed dict rows with keys
14855
- `month` / `cost_usd` / `sessions`):
14856
-
14857
- - Rows are `BucketUsage` dataclasses; we read fields by attribute.
14858
- - The plan's `Sessions` column has no source in the underlying data
14859
- (`BucketUsage` carries no session count and `_aggregate_monthly`
14860
- never computes one). Substituted with a `Tokens` column carrying
14861
- total tokens — meaningful info already on the dataclass.
14862
- - `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
14863
- months, matching the plan's intent.
14864
-
14865
- `period_start` / `period_end` / `display_tz` are passed by the
14866
- caller (the CLI's `--since` / `--until` window may extend past
14867
- the data window). `theme` / `reveal_projects` flow into the
14868
- subtitle directly so the builder owns the canonical subtitle
14869
- shape — no post-build re-stamp at the gate site.
14870
- """
14871
- # Caller MUST pass rows in chronological order so the BarChart bars
14872
- # line up left-to-right with time. view.aggregated is newest-first.
14873
- rows = list(reversed(view.aggregated))
14874
- _lib_share = _share_load_lib()
14875
- columns = (
14876
- _lib_share.ColumnSpec(key="month", label="Month", align="left"),
14877
- _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
14878
- emphasis=True),
14879
- _lib_share.ColumnSpec(key="tokens", label="Tokens", align="right"),
14880
- _lib_share.ColumnSpec(key="delta", label="Δ vs prior", align="right"),
14881
- )
14882
- snap_rows: list = []
14883
- chart_pts: list = []
14884
- prev_cost: float | None = None
14885
- for i, r in enumerate(rows):
14886
- # `BucketUsage.bucket` is typed `str` ("YYYY-MM"); guard against
14887
- # empty / unparseable but skip the dead `dt.date` branch.
14888
- bucket = getattr(r, "bucket", None)
14889
- month_str = bucket if isinstance(bucket, str) and bucket else "—"
14890
- cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
14891
- total_tokens = int(getattr(r, "total_tokens", 0) or 0)
14892
- if prev_cost is not None and prev_cost > 0:
14893
- delta_pct = (cost_usd - prev_cost) / prev_cost * 100.0
14894
- delta_cell = _lib_share.DeltaCell(value=delta_pct, unit="%")
14895
- else:
14896
- delta_cell = _lib_share.TextCell("—")
14897
- snap_rows.append(_lib_share.Row(cells={
14898
- "month": _lib_share.TextCell(month_str),
14899
- "cost": _lib_share.MoneyCell(cost_usd),
14900
- "tokens": _lib_share.TextCell(f"{total_tokens:,}"),
14901
- "delta": delta_cell,
14902
- }))
14903
- chart_pts.append(_lib_share.ChartPoint(
14904
- x_label=month_str,
14905
- x_value=float(i),
14906
- y_value=cost_usd,
14907
- ))
14908
- prev_cost = cost_usd
14909
- chart = (
14910
- _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
14911
- if chart_pts else None
14912
- )
14913
- sum_cost = sum(p.y_value for p in chart_pts)
14914
- avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
14915
- totals = (
14916
- _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
14917
- _lib_share.Totalled(label="Months", value=str(len(chart_pts))),
14918
- _lib_share.Totalled(label="Avg / month", value=f"${avg_cost:,.2f}"),
14919
- )
14920
- if rows:
14921
- title = (
14922
- f"Monthly usage — {period_start.strftime('%Y-%m')} → "
14923
- f"{period_end.strftime('%Y-%m')}"
14924
- )
14925
- else:
14926
- title = "Monthly usage — no data"
14927
- period_label = (
14928
- f"{period_start.strftime('%Y-%m')} → "
14929
- f"{period_end.strftime('%Y-%m')} ({display_tz})"
14930
- )
14931
- subtitle = " · ".join([
14932
- period_label,
14933
- theme,
14934
- "real projects" if reveal_projects else "projects anonymized",
14935
- ])
14936
- return _lib_share.ShareSnapshot(
14937
- cmd="monthly",
14938
- title=title,
14939
- subtitle=subtitle,
14940
- period=_lib_share.PeriodSpec(
14941
- start=period_start, end=period_end,
14942
- display_tz=display_tz, label=period_label,
14943
- ),
14944
- columns=columns, rows=tuple(snap_rows),
14945
- chart=chart, totals=totals, notes=(),
14946
- generated_at=_share_now_utc(), version=version,
14947
- )
14948
-
14949
-
14950
- def _build_weekly_snapshot(
14951
- view: "WeeklyView",
14952
- *,
14953
- period_start: dt.datetime,
14954
- period_end: dt.datetime,
14955
- display_tz: str,
14956
- version: str,
14957
- theme: str,
14958
- reveal_projects: bool,
14959
- breakdown_model: bool,
14960
- ) -> "ShareSnapshot":
14961
- """Build a ShareSnapshot for `cctally weekly`.
14962
-
14963
- Consumes the unified WeeklyView (spec §6.3). `view.aggregated` is
14964
- the gap-free BucketUsage tuple newest-first; `view.overlay` is the
14965
- parallel `(used_pct, dollars_per_pct)` tuple. We reverse both for
14966
- chronological iteration so BarChart bars render left-to-right
14967
- with time.
14968
-
14969
- Each bucket carries `bucket` (week_start_date as "YYYY-MM-DD"),
14970
- `cost_usd`, `total_tokens`, and `model_breakdowns` (list[dict]
14971
- sorted by cost desc, each `{modelName, ..., cost}`). Either
14972
- overlay component may be `None` for a week with no captured
14973
- snapshot — surfaces in the snapshot row as a `0.0` PercentCell so
14974
- the column stays aligned (matching the table renderer's "no data
14975
- → 0%" behavior).
14976
-
14977
- Deviations from the plan sketch (which assumed dict rows with keys
14978
- `week_start_date` / `used_pct` / `cost_usd` / `sessions` /
14979
- `model_breakdown` and a `breakdown_model: bool` derived from
14980
- `args.breakdown == "model"`):
14981
-
14982
- - Rows are `BucketUsage` dataclasses; per-week `used_pct` lives in the
14983
- separate `overlay` list — neither shape matches the plan literal.
14984
- - The plan's `Sessions` column has no source — `BucketUsage` carries
14985
- no session count and `_aggregate_weekly` never computes one.
14986
- Substituted with a `Tokens` column (`total_tokens` formatted with
14987
- thousands separators).
14988
- - `args.breakdown` for `cmd_weekly` is `action="store_true"` (not a
14989
- `{model,project}` choice), so `breakdown_model` is just the boolean
14990
- `args.breakdown` from the gate site.
14991
- - `model_breakdowns` is a list-of-dicts (`modelName` / `cost`), not a
14992
- `{model: cost}` mapping; we coerce to a dict before key lookup.
14993
-
14994
- Honors `breakdown_model` by appending one `m_<model>` column per
14995
- distinct model and populating `BarChart.stacks` with per-model series.
14996
- All model-axis iteration uses a single sorted list (`all_model_keys`)
14997
- so column / stack ordering is deterministic across runs.
14998
-
14999
- `theme` and `reveal_projects` flow into the subtitle directly so the
15000
- builder owns the canonical subtitle shape — no post-build re-stamp at
15001
- the gate site.
15002
- """
15003
- # view.aggregated / view.overlay are newest-first; reverse for asc
15004
- # so BarChart bars are chronological.
15005
- rows = list(reversed(view.aggregated))
15006
- overlay = list(reversed(view.overlay))
15007
- _lib_share = _share_load_lib()
15008
- columns_list: list = [
15009
- _lib_share.ColumnSpec(key="week", label="Week Start", align="left"),
15010
- _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
15011
- _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
15012
- emphasis=True),
15013
- _lib_share.ColumnSpec(key="tokens", label="Tokens", align="right"),
15014
- ]
15015
- # Per-row model→cost lookup (BucketUsage exposes a list-of-dicts;
15016
- # collapse to dict here so per-row column population is O(1) per
15017
- # model). All breakdown-aware iteration goes through `all_model_keys`
15018
- # for deterministic ordering.
15019
- per_row_model_costs: list[dict[str, float]] = []
15020
- for r in rows:
15021
- breakdowns = getattr(r, "model_breakdowns", None) or []
15022
- per_row_model_costs.append({
15023
- (b.get("modelName") or "—"): float(b.get("cost") or 0.0)
15024
- for b in breakdowns
15025
- })
15026
- if breakdown_model:
15027
- all_model_keys = sorted({m for d in per_row_model_costs for m in d})
15028
- for m in all_model_keys:
15029
- columns_list.append(_lib_share.ColumnSpec(
15030
- key=f"m_{m}", label=m, align="right",
15031
- ))
15032
- else:
15033
- all_model_keys = []
15034
-
15035
- snap_rows: list = []
15036
- chart_pts: list = []
15037
- stacks: dict[str, list] = {}
15038
- for i, r in enumerate(rows):
15039
- # `BucketUsage.bucket` is typed `str` ("YYYY-MM-DD"); guard against
15040
- # empty / unparseable but skip the dead `dt.date` branch.
15041
- bucket = getattr(r, "bucket", None)
15042
- if isinstance(bucket, str) and bucket:
15043
- try:
15044
- week_label = dt.date.fromisoformat(bucket).strftime("%b %d")
15045
- except ValueError:
15046
- week_label = bucket
15047
- else:
15048
- week_label = "—"
15049
- cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
15050
- total_tokens = int(getattr(r, "total_tokens", 0) or 0)
15051
- # `used_pct` is None when the week lacks a `weekly_usage_snapshots`
15052
- # row — render as em-dash to match terminal `_render_weekly_table`.
15053
- # Coercing to 0.0 would conflate "no snapshot recorded" with "0%
15054
- # used," same divergence the report builder fixes. cost_usd from
15055
- # session_entries is genuinely 0 when there are no entries (not
15056
- # missing data) so that path keeps MoneyCell(0.0).
15057
- used_pct_raw = (
15058
- overlay[i][0] if i < len(overlay) else None
15059
- )
15060
- cells = {
15061
- "week": _lib_share.TextCell(week_label),
15062
- "used": (
15063
- _lib_share.PercentCell(float(used_pct_raw))
15064
- if used_pct_raw is not None else _lib_share.TextCell("—")
15065
- ),
15066
- "cost": _lib_share.MoneyCell(cost_usd),
15067
- "tokens": _lib_share.TextCell(f"{total_tokens:,}"),
15068
- }
15069
- if breakdown_model:
15070
- row_costs = per_row_model_costs[i]
15071
- for m in all_model_keys:
15072
- m_cost = float(row_costs.get(m) or 0.0)
15073
- cells[f"m_{m}"] = _lib_share.MoneyCell(m_cost)
15074
- stacks.setdefault(m, []).append(_lib_share.ChartPoint(
15075
- x_label=week_label,
15076
- x_value=float(i),
15077
- y_value=m_cost,
15078
- series_key=m,
15079
- ))
15080
- snap_rows.append(_lib_share.Row(cells=cells))
15081
- chart_pts.append(_lib_share.ChartPoint(
15082
- x_label=week_label,
15083
- x_value=float(i),
15084
- y_value=cost_usd,
15085
- ))
15086
- # `BarChart.stacks` is `Mapping[str, tuple[ChartPoint, ...]] | None`
15087
- # (Implementor 1's tightening); convert dict-of-lists to dict-of-tuples.
15088
- stacks_immut = (
15089
- {k: tuple(v) for k, v in stacks.items()} if stacks else None
15090
- )
15091
- chart = (
15092
- _lib_share.BarChart(
15093
- points=tuple(chart_pts), y_label="$", stacks=stacks_immut,
15094
- )
15095
- if chart_pts else None
15096
- )
15097
- sum_cost = sum(p.y_value for p in chart_pts)
15098
- pct_values = [
15099
- float(o[0]) for o in overlay
15100
- if o is not None and o[0] is not None
15101
- ]
15102
- avg_pct = (sum(pct_values) / len(pct_values)) if pct_values else 0.0
15103
- peak_pct = max(pct_values, default=0.0)
15104
- totals = (
15105
- _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
15106
- _lib_share.Totalled(label="Avg %/wk", value=f"{avg_pct:.1f}%"),
15107
- _lib_share.Totalled(label="Peak %", value=f"{peak_pct:.1f}%"),
15108
- )
15109
- title = (
15110
- f"Weekly usage — last {len(rows)} weeks"
15111
- if rows
15112
- else "Weekly usage — no data"
15113
- )
15114
- period_label = _share_period_label(period_start, period_end, display_tz)
15115
- subtitle = " · ".join([
15116
- period_label,
15117
- theme,
15118
- "real projects" if reveal_projects else "projects anonymized",
15119
- ])
15120
- return _lib_share.ShareSnapshot(
15121
- cmd="weekly",
15122
- title=title,
15123
- subtitle=subtitle,
15124
- period=_lib_share.PeriodSpec(
15125
- start=period_start, end=period_end,
15126
- display_tz=display_tz, label=period_label,
15127
- ),
15128
- columns=tuple(columns_list), rows=tuple(snap_rows),
15129
- chart=chart, totals=totals, notes=(),
15130
- generated_at=_share_now_utc(), version=version,
15131
- )
15132
-
15133
-
15134
- def _build_forecast_snapshot(
15135
- *,
15136
- week_start: dt.datetime,
15137
- week_end: dt.datetime,
15138
- display_tz: str,
15139
- version: str,
15140
- theme: str,
15141
- reveal_projects: bool,
15142
- actual_series: list[tuple[str, float, float]],
15143
- projected_series: list[tuple[str, float, float]],
15144
- current_pct: float,
15145
- projected_low_pct: float,
15146
- projected_high_pct: float,
15147
- days_remaining: float,
15148
- dollars_per_percent: float,
15149
- dollars_per_percent_source: str,
15150
- low_conf: bool,
15151
- notes: tuple[str, ...] = (),
15152
- ) -> "ShareSnapshot":
15153
- """Build a ShareSnapshot for `cctally forecast`.
15154
-
15155
- `actual_series` is a list of `(x_label, x_value, y_value)` tuples drawn
15156
- from `weekly_usage_snapshots` for the current week — each sample's
15157
- `captured_at_utc` is the x_label (formatted compactly), `x_value` is
15158
- elapsed-hours-since-week-start (a monotonic float so the LineChart
15159
- renders left→right), and `y_value` is `weekly_percent` at that capture.
15160
-
15161
- `projected_series` is a parallel list of `(x_label, x_value, y_value)`
15162
- tuples for the projection ray — the simplest form is a 2-point line
15163
- from `(now, current_pct)` to `(week_end, projected_eow_pct)`. The
15164
- renderer treats it as a `multi_series` overlay on top of the actual line.
15165
-
15166
- Deviations from the plan sketch (which assumed a single
15167
- `_compute_forecast_data(args) -> dict` helper and `dpp_week_avg` /
15168
- `dpp_24h` as separate columns):
15169
-
15170
- - `cmd_forecast` already exposes the data as `ForecastOutput` (which
15171
- wraps `ForecastInputs`). No helper extraction was needed; we pass
15172
- the actual scalars in directly.
15173
- - `ForecastInputs` carries a single `dollars_per_percent` value plus
15174
- a `dollars_per_percent_source` enum (`this_week` /
15175
- `trailing_4wk_median` / `this_week_sparse`); there is no separate
15176
- `dpp_week_avg` and `dpp_24h`. The table renders one $/1% row with
15177
- the source as a paren suffix in the metric cell.
15178
- - The plan's single `projected_eow_pct` is split into a low/high
15179
- range (matching `--render-forecast-terminal`'s "Forecast 80–95%"
15180
- band). The table shows both ends; the projected_series ray uses
15181
- the high end so the overlay aligns with the conservative budget
15182
- consumers expect from the chart.
15183
-
15184
- Reference lines at 90%/100% are LineChart-stable across all samples;
15185
- severities `warn` (90%) and `alarm` (100%) drive the renderer's
15186
- color mapping.
15187
-
15188
- `theme` and `reveal_projects` flow into the subtitle directly so the
15189
- builder owns the canonical subtitle shape — no post-build re-stamp
15190
- at the gate site.
15191
-
15192
- `notes`, when non-empty, overrides the auto-emitted "LOW CONF — data
15193
- thin" note. The empty-data fast-path passes a clearer "no snapshots
15194
- recorded" note so the artifact says what's actually wrong; the
15195
- confidence-thin terminal path passes nothing and falls back to the
15196
- auto LOW CONF banner.
15197
- """
15198
- _lib_share = _share_load_lib()
15199
- actual_pts = tuple(
15200
- _lib_share.ChartPoint(x_label=lbl, x_value=float(xv), y_value=float(yv))
15201
- for lbl, xv, yv in actual_series
15202
- )
15203
- projected_pts = tuple(
15204
- _lib_share.ChartPoint(x_label=lbl, x_value=float(xv), y_value=float(yv))
15205
- for lbl, xv, yv in projected_series
15206
- )
15207
- chart = (
15208
- _lib_share.LineChart(
15209
- points=actual_pts,
15210
- y_label="cumulative %",
15211
- reference_lines=(
15212
- (90.0, "90%", "warn"),
15213
- (100.0, "100%", "alarm"),
15214
- ),
15215
- multi_series={"projected": projected_pts} if projected_pts else None,
15216
- )
15217
- if actual_pts else None
15218
- )
15219
-
15220
- columns = (
15221
- _lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
15222
- _lib_share.ColumnSpec(key="value", label="Value", align="right",
15223
- emphasis=True),
15224
- )
15225
- # Render the projected band as "low-high%" so a single PercentCell
15226
- # carries the two-rate forecast spread. When the rates collapse to a
15227
- # single value (no recent-24h sample), low == high.
15228
- # 0.05 threshold: below .1f display precision — tighter spreads would
15229
- # render as identical decimals, so collapse to a single value.
15230
- if abs(projected_high_pct - projected_low_pct) < 0.05:
15231
- projected_text = f"{projected_high_pct:.1f}%"
15232
- else:
15233
- projected_text = (
15234
- f"{projected_low_pct:.1f}% — {projected_high_pct:.1f}%"
15235
- )
15236
- dpp_source_label = dollars_per_percent_source.replace("_", " ")
15237
- snap_rows = (
15238
- _lib_share.Row(cells={
15239
- "metric": _lib_share.TextCell("Current %"),
15240
- "value": _lib_share.PercentCell(float(current_pct)),
15241
- }),
15242
- _lib_share.Row(cells={
15243
- "metric": _lib_share.TextCell("Projected end-of-week %"),
15244
- "value": _lib_share.TextCell(projected_text),
15245
- }),
15246
- _lib_share.Row(cells={
15247
- "metric": _lib_share.TextCell("Days remaining"),
15248
- "value": _lib_share.TextCell(f"{days_remaining:.1f}"),
15249
- }),
15250
- _lib_share.Row(cells={
15251
- "metric": _lib_share.TextCell(f"$ / 1% ({dpp_source_label})"),
15252
- "value": _lib_share.MoneyCell(float(dollars_per_percent)),
15253
- }),
15254
- )
15255
- # Caller-provided `notes` (e.g., empty-data path's clearer message)
15256
- # take precedence over the auto LOW CONF banner. Sibling builders
15257
- # don't expose this knob; forecast does because its empty-state and
15258
- # thin-confidence states need different copy.
15259
- final_notes = notes if notes else (
15260
- ("LOW CONF — data thin",) if low_conf else ()
15261
- )
15262
- if actual_pts:
15263
- title = f"Forecast — week of {week_start.strftime('%b %d')}"
15264
- else:
15265
- title = "Forecast — no data"
15266
- # Reuse the shared period-label helper so forecast's subtitle period
15267
- # format matches sibling builders (cmd_daily / cmd_project / etc.).
15268
- period_label = _share_period_label(week_start, week_end, display_tz)
15269
- subtitle = " · ".join([
15270
- period_label,
15271
- theme,
15272
- "real projects" if reveal_projects else "projects anonymized",
15273
- ])
15274
- return _lib_share.ShareSnapshot(
15275
- cmd="forecast",
15276
- title=title,
15277
- subtitle=subtitle,
15278
- period=_lib_share.PeriodSpec(
15279
- start=week_start, end=week_end,
15280
- display_tz=display_tz, label=period_label,
15281
- ),
15282
- columns=columns, rows=snap_rows,
15283
- chart=chart, totals=(), notes=final_notes,
15284
- generated_at=_share_now_utc(), version=version,
15285
- )
15286
-
15287
-
15288
- def _build_project_snapshot(
15289
- rows: list[dict],
15290
- *,
15291
- period_start: dt.datetime,
15292
- period_end: dt.datetime,
15293
- display_tz: str,
15294
- version: str,
15295
- theme: str,
15296
- reveal_projects: bool,
15297
- ) -> "ShareSnapshot":
15298
- """Build a ShareSnapshot for `cctally project`.
15299
-
15300
- `rows` is the in-memory per-project aggregate list produced inside
15301
- `cmd_project` (`project_rows.values()` post-sort). Each row is a
15302
- dict with: `key` (a `ProjectKey` carrying `display_key` /
15303
- `bucket_path`), `cost_usd`, `attributed_pct` (`float | None`),
15304
- `sessions` (a `set` of session-IDs).
15305
-
15306
- Privacy invariant (Section 8.4 / Section 5.3): the builder populates
15307
- `ProjectCell.label` AND `ChartPoint.project_label` (and `x_label`,
15308
- which is the project axis on a HorizontalBarChart) with the REAL
15309
- `display_key`. The `_share_render_and_emit` wrapper then runs
15310
- `_lib_share._scrub` BEFORE rendering — that's the single chokepoint
15311
- that rewrites every project label to `project-1` / `project-2` /
15312
- ... unless `--reveal-projects` is passed. The Section 8.4 canary
15313
- test (`test_anonymized_output_contains_zero_original_tokens`) and
15314
- the wrapper-level regression
15315
- (`test_share_render_and_emit_scrubs_project_labels`) both anchor
15316
- this contract.
15317
-
15318
- Deviations from the plan sketch (which assumed dict rows with keys
15319
- `project` / `cost_usd` / `used_pct` / `sessions`):
15320
-
15321
- - Rows are dicts whose `key` field is a `ProjectKey` dataclass; the
15322
- project label comes from `key.display_key`. The plan's `project`
15323
- key does not exist on the actual `cmd_project` data shape.
15324
- - `attributed_pct` may be `None` for projects whose contributing
15325
- weeks all lacked a `weekly_usage_snapshots` row; the table renders
15326
- that as em-dash (parity with terminal `_render_project_table`).
15327
- - Sessions is a `set`; the cell carries its `len(...)` as text.
15328
-
15329
- `HorizontalBarChart.cap=12` matches the plan; when more than 12
15330
- projects exist, a note clarifies that the table includes all rows
15331
- while the chart shows only the top 12 by cost.
15332
-
15333
- Caller MUST pass `rows` already sorted in the desired order
15334
- (cmd_project honors `--sort` / `--order` upstream). The builder
15335
- preserves caller order for the table — terminal / JSON / share
15336
- artifacts all show the same row ordering. Internally the builder
15337
- ALSO computes a descending-cost copy that drives the HBar chart
15338
- and the basename-disambiguation rank (both must match
15339
- `_build_anon_mapping`'s descending-cost sort so `project-1` stays
15340
- glued to the highest-cost bar regardless of `--sort`). Anonymization
15341
- is row-identity based (`id(r)` → augmented label), not position
15342
- based, so the table sees the same disambiguated label as the chart.
15343
-
15344
- `theme` and `reveal_projects` flow into the subtitle directly so the
15345
- builder owns the canonical subtitle shape — no post-build re-stamp
15346
- at the gate site.
15347
- """
15348
- _lib_share = _share_load_lib()
15349
- columns = (
15350
- _lib_share.ColumnSpec(key="project", label="Project", align="left"),
15351
- _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
15352
- emphasis=True),
15353
- _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
15354
- _lib_share.ColumnSpec(key="sessions", label="Sessions", align="right"),
15355
- )
15356
- # Two orderings — same rows, different consumers:
15357
- #
15358
- # * `rows` (caller order) drives the table. `cmd_project` upstream
15359
- # has already applied `--sort` / `--order`, so the share artifact's
15360
- # table matches terminal / JSON output for any of `--sort cost`,
15361
- # `--sort name`, `--sort sessions` × `--order asc|desc`.
15362
- #
15363
- # * `cost_sorted_rows` (descending cost) drives the HBar chart and
15364
- # the basename-disambiguation rank — both must align with
15365
- # `_build_anon_mapping`'s descending-cost sort so `project-1` stays
15366
- # glued to the highest-cost bar regardless of `--sort` choice.
15367
- cost_sorted_rows = sorted(
15368
- rows, key=lambda r: -float(r.get("cost_usd") or 0.0)
15369
- )
15370
- # Basename-collision disambiguation: mirrors `_render_project_table`'s
15371
- # terminal logic. Computed on cost_sorted_rows; mapped back to row
15372
- # identity so the caller-ordered table picks up the same augmented
15373
- # label as the chart (anonymization is row-identity based, not
15374
- # position based). Without disambiguation, two `app` projects under
15375
- # different parent dirs collapse to ONE anonymous `project-N` after
15376
- # scrub — losing both privacy uniqueness and chart rank meaning.
15377
- augmented_by_index = _project_disambiguate_labels(cost_sorted_rows)
15378
- augmented_by_row_id: dict[int, str] = {
15379
- id(cost_sorted_rows[idx]): label
15380
- for idx, label in augmented_by_index.items()
15381
- }
15382
-
15383
- def _proj_label_for(r: dict) -> str:
15384
- bare = getattr(r.get("key"), "display_key", None) or "(unknown)"
15385
- return augmented_by_row_id.get(id(r), bare)
15386
-
15387
- # Table rows in CALLER order (--sort / --order parity).
15388
- snap_rows: list = []
15389
- for r in rows:
15390
- proj_label = _proj_label_for(r)
15391
- cost = float(r.get("cost_usd") or 0.0)
15392
- attr_pct = r.get("attributed_pct")
15393
- sessions = r.get("sessions")
15394
- sessions_count = len(sessions) if sessions is not None else 0
15395
- snap_rows.append(_lib_share.Row(cells={
15396
- "project": _lib_share.ProjectCell(proj_label),
15397
- "cost": _lib_share.MoneyCell(cost),
15398
- # Preserve None vs 0.0 — terminal renders missing as em-dash.
15399
- # Coercing None -> 0.0 would conflate "no usage snapshot for
15400
- # any week this project touched" with "0% attributed."
15401
- "used": (
15402
- _lib_share.PercentCell(float(attr_pct))
15403
- if attr_pct is not None else _lib_share.TextCell("—")
15404
- ),
15405
- "sessions": _lib_share.TextCell(str(sessions_count)),
15406
- }))
15407
-
15408
- # Chart points in COST-SORTED order (HBar shows top-N by cost).
15409
- chart_pts: list = []
15410
- for r in cost_sorted_rows:
15411
- proj_label = _proj_label_for(r)
15412
- cost = float(r.get("cost_usd") or 0.0)
15413
- chart_pts.append(_lib_share.ChartPoint(
15414
- x_label=proj_label,
15415
- x_value=cost,
15416
- y_value=cost,
15417
- project_label=proj_label,
15418
- ))
15419
- chart = (
15420
- _lib_share.HorizontalBarChart(
15421
- points=tuple(chart_pts), x_label="$", cap=12,
15422
- )
15423
- if chart_pts else None
15424
- )
15425
- notes: tuple[str, ...] = ()
15426
- if chart is not None and len(chart_pts) > 12:
15427
- notes = (
15428
- f"Showing top 12 in chart; table includes all {len(chart_pts)}.",
15429
- )
15430
- sum_cost = sum(p.y_value for p in chart_pts)
15431
- totals = (
15432
- _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
15433
- _lib_share.Totalled(label="Projects", value=str(len(chart_pts))),
15434
- )
15435
- if rows:
15436
- title = (
15437
- f"Per-project usage — {period_start.strftime('%b %d')} → "
15438
- f"{period_end.strftime('%b %d')}"
15439
- )
15440
- else:
15441
- title = "Per-project usage — no data"
15442
- period_label = _share_period_label(period_start, period_end, display_tz)
15443
- subtitle = " · ".join([
15444
- period_label,
15445
- theme,
15446
- "real projects" if reveal_projects else "projects anonymized",
15447
- ])
15448
- return _lib_share.ShareSnapshot(
15449
- cmd="project",
15450
- title=title,
15451
- subtitle=subtitle,
15452
- period=_lib_share.PeriodSpec(
15453
- start=period_start, end=period_end,
15454
- display_tz=display_tz, label=period_label,
15455
- ),
15456
- columns=columns, rows=tuple(snap_rows),
15457
- chart=chart, totals=totals, notes=notes,
15458
- generated_at=_share_now_utc(), version=version,
15459
- )
15460
-
15461
-
15462
- def _build_five_hour_blocks_snapshot(
15463
- view: "BlocksView",
15464
- *,
15465
- period_start: dt.datetime,
15466
- period_end: dt.datetime,
15467
- display_tz: str,
15468
- version: str,
15469
- theme: str,
15470
- reveal_projects: bool,
15471
- tz: "ZoneInfo | None",
15472
- ) -> "ShareSnapshot":
15473
- """Build a ShareSnapshot for `cctally five-hour-blocks`.
15474
-
15475
- `view` is the ``BlocksView`` produced by
15476
- ``build_blocks_view_from_table_rows`` (issue #56). The
15477
- API-anchored block dicts (sqlite Row → dict with the
15478
- ``__is_active`` / ``__credits`` side-channels attached) live on
15479
- ``view.aggregated``; reset-aware totals come from
15480
- ``view.total_cost_usd`` so the share footer reads from the typed
15481
- single source rather than re-summing inline. Schema fields used
15482
- from each dict: ``block_start_at`` (ISO timestamp),
15483
- ``total_cost_usd``, ``final_five_hour_percent``,
15484
- ``crossed_seven_day_reset`` (0/1 int),
15485
- ``seven_day_pct_at_block_start``, ``seven_day_pct_at_block_end``,
15486
- plus the synthetic ``__is_active`` flag.
15487
-
15488
- Deviations from the plan sketch (which assumed dict rows with keys
15489
- `block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
15490
- `cross_reset`):
15491
-
15492
- - Rows are sqlite-Row-derived dicts with snake_case schema column
15493
- names — `block_start_at`, `total_cost_usd`,
15494
- `final_five_hour_percent`, `crossed_seven_day_reset`. The plan
15495
- keys `block_start` / `cost_usd` / `used_pct_5h` / `cross_reset`
15496
- do not exist on the actual data shape.
15497
- - `top_model` does not live on the `five_hour_blocks` row at all;
15498
- `_load_breakdown` would have to be invoked per-block to derive
15499
- it. Per share-spec convention (matches cmd_daily / cmd_monthly),
15500
- the `--breakdown` flag is a no-op under `--format` and the
15501
- headline snapshot omits the per-model "top model" column.
15502
- - `crossed_seven_day_reset` is an INTEGER 0/1 (sqlite); coerce to
15503
- `bool` for cell formatting.
15504
-
15505
- Cross-reset markers (spec §6.5):
15506
- - `chart_pts` — `▲` (U+25B2) prefix in `x_label` so the SVG
15507
- x-axis label visually flags the crossed-reset blocks.
15508
- - `snap_rows` — `⚡` (U+26A1) glyph in the `cross_reset` cell
15509
- text so the markdown / HTML table cell carries the same
15510
- signal. The two glyphs are distinct (triangle for chart axis,
15511
- bolt for table cell) so the legend reads correctly in either
15512
- surface.
15513
-
15514
- `theme` and `reveal_projects` flow into the subtitle directly so
15515
- the builder owns the canonical subtitle shape — no post-build
15516
- re-stamp at the gate site.
15517
-
15518
- Caller MUST pass a view whose ``aggregated`` block dicts are
15519
- already in the desired chronological order (cmd_five_hour_blocks
15520
- pulls newest-first; we reverse here so the BarChart bars line up
15521
- oldest→newest left-to-right). Tabular row order in the snapshot is
15522
- irrelevant because the snapshot is what gets rendered (the gate
15523
- site short-circuits the table renderer).
15524
- """
15525
- _lib_share = _share_load_lib()
15526
- columns = (
15527
- _lib_share.ColumnSpec(key="block_start", label="Block Start",
15528
- align="left"),
15529
- _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
15530
- emphasis=True),
15531
- _lib_share.ColumnSpec(key="used_pct", label="5h %",
15532
- align="right"),
15533
- _lib_share.ColumnSpec(key="cross_reset", label="Reset",
15534
- align="left"),
15535
- )
15536
- # `view.aggregated` carries the newest-first DESC block dicts the
15537
- # caller built from the SELECT. Reverse so BarChart x-axis runs
15538
- # oldest→newest; table-row order tracks chart order so consumer
15539
- # expectations align.
15540
- rows = list(view.aggregated)
15541
- chrono_rows = list(reversed(rows))
15542
- snap_rows: list = []
15543
- chart_pts: list = []
15544
- for i, r in enumerate(chrono_rows):
15545
- block_iso = r.get("block_start_at") or ""
15546
- # Compact label respecting --tz; previously hard-coded to UTC
15547
- # (parsed.strftime renders the wall-clock IN the parsed tz, and
15548
- # `parsed` is tz-aware UTC after fromisoformat). UTC-vs-display_tz
15549
- # is orthogonal to the SVG x-axis width budget — both render at
15550
- # the same character count. Route through `format_display_dt`
15551
- # with suffix=False to satisfy the chokepoint rule while keeping
15552
- # the bar label compact (the subtitle's period_label already
15553
- # carries the active tz).
15554
- try:
15555
- parsed = dt.datetime.fromisoformat(
15556
- block_iso.replace("Z", "+00:00")
15557
- )
15558
- block_lbl = format_display_dt(
15559
- parsed, tz, fmt="%b %d %H:%M", suffix=False,
15560
- )
15561
- except (ValueError, AttributeError):
15562
- block_lbl = str(block_iso)
15563
- cost_usd = float(r.get("total_cost_usd") or 0.0)
15564
- used_pct = float(r.get("final_five_hour_percent") or 0.0)
15565
- crossed = bool(r.get("crossed_seven_day_reset"))
15566
- cell_text = "⚡" if crossed else "—"
15567
- # Spec §5.1.1 (Codex r2 finding 3): consume the ``__credits``
15568
- # side-channel set by ``cmd_five_hour_blocks`` and append a
15569
- # ``⚡ -Xpp, -Ypp`` chip to the block_start cell. Pure-string
15570
- # cell content flows uniformly through markdown / HTML table /
15571
- # SVG text renderers without per-format additions. Symmetric to
15572
- # the existing ⚡ glyph in the cross_reset cell — by position
15573
- # (block_start suffix vs. dedicated column) the two annotations
15574
- # remain visually distinguishable.
15575
- credits = r.get("__credits") or []
15576
- block_cell = block_lbl
15577
- if credits:
15578
- deltas = ", ".join(f"{c['deltaPp']:+.0f}pp" for c in credits)
15579
- block_cell = f"{block_lbl} ⚡ {deltas}"
15580
- snap_rows.append(_lib_share.Row(cells={
15581
- "block_start": _lib_share.TextCell(block_cell),
15582
- "cost": _lib_share.MoneyCell(cost_usd),
15583
- "used_pct": _lib_share.PercentCell(used_pct),
15584
- "cross_reset": _lib_share.TextCell(cell_text),
15585
- }))
15586
- x_label = f"▲ {block_lbl}" if crossed else block_lbl
15587
- chart_pts.append(_lib_share.ChartPoint(
15588
- x_label=x_label,
15589
- x_value=float(i),
15590
- y_value=cost_usd,
15591
- ))
15592
- chart = (
15593
- _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
15594
- if chart_pts else None
15595
- )
15596
- # Reset-aware total comes from the BlocksView (issue #56); avg
15597
- # divides by `chart_pts` count so the share footer "Sum" totalled
15598
- # and the per-block `chart_pts` cost values share a single source-
15599
- # of-truth at `view.total_cost_usd`.
15600
- sum_cost = view.total_cost_usd
15601
- avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
15602
- crossed_count = sum(
15603
- 1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))
15604
- )
15605
- totals_list = [
15606
- _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
15607
- _lib_share.Totalled(label="Blocks", value=str(len(chart_pts))),
15608
- _lib_share.Totalled(label="Avg / block", value=f"${avg_cost:,.2f}"),
15609
- ]
15610
- if crossed_count:
15611
- totals_list.append(_lib_share.Totalled(
15612
- label="Crossed reset", value=str(crossed_count),
15613
- ))
15614
- totals = tuple(totals_list)
15615
- notes: tuple[str, ...] = ()
15616
- if crossed_count:
15617
- notes = (
15618
- "▲ / ⚡ marks blocks that crossed the weekly reset boundary.",
15619
- )
15620
- if rows:
15621
- title = f"5-hour blocks — last {len(rows)} blocks"
15622
- else:
15623
- title = "5-hour blocks — no data"
15624
- period_label = _share_period_label(period_start, period_end, display_tz)
15625
- subtitle = " · ".join([
15626
- period_label,
15627
- theme,
15628
- "real projects" if reveal_projects else "projects anonymized",
15629
- ])
15630
- return _lib_share.ShareSnapshot(
15631
- cmd="five-hour-blocks",
15632
- title=title,
15633
- subtitle=subtitle,
15634
- period=_lib_share.PeriodSpec(
15635
- start=period_start, end=period_end,
15636
- display_tz=display_tz, label=period_label,
15637
- ),
15638
- columns=columns, rows=tuple(snap_rows),
15639
- chart=chart, totals=totals, notes=notes,
15640
- generated_at=_share_now_utc(), version=version,
15641
- )
15642
-
15643
-
15644
- def _session_disambiguate_labels(
15645
- sessions: list["ClaudeSessionUsage"],
15646
- ) -> dict[int, str]:
15647
- """Return ``{session_index: disambiguated_label}`` for sessions whose
15648
- bare ``project_path`` basename collides with another session's.
15649
-
15650
- Session-specific sibling of ``_project_disambiguate_labels`` (which
15651
- operates over project rollup rows whose `key` is a ``ProjectKey``).
15652
- Sessions carry only a `project_path` string — we derive the
15653
- basename, count collisions, and append a parent-dir suffix
15654
- ``" (parent)"`` to colliding rows so the post-scrub anonymization
15655
- still produces unique anonymous labels (otherwise two `app/`
15656
- sessions under different parents collapse to a single
15657
- ``project-N``, breaking both privacy uniqueness and the chart's
15658
- visual rank meaning).
15659
-
15660
- Sessions without collisions are absent from the returned dict;
15661
- callers fall back to the bare basename.
15662
- """
15663
- basenames: list[str] = []
15664
- for s in sessions:
15665
- path = s.project_path or ""
15666
- basenames.append(os.path.basename(path) or path or "(unknown)")
15667
- counts: dict[str, int] = {}
15668
- for bn in basenames:
15669
- counts[bn] = counts.get(bn, 0) + 1
15670
- augmented: dict[int, str] = {}
15671
- for idx, s in enumerate(sessions):
15672
- bn = basenames[idx]
15673
- # Skip suffixing the literal "(unknown)" bare label even on
15674
- # collision: `_build_anon_mapping` literal-passthrough-protects
15675
- # exact "(unknown)" only — a suffixed form like "(unknown) (/)"
15676
- # would be mapped to a regular `project-N` slot, losing the
15677
- # (unknown) semantic in the anonymized output.
15678
- if counts[bn] > 1 and bn != "(unknown)":
15679
- path = s.project_path or ""
15680
- parent = os.path.basename(os.path.dirname(path)) or "/"
15681
- augmented[idx] = f"{bn} ({parent})"
15682
- return augmented
15683
-
15684
-
15685
- def _build_session_snapshot(
15686
- view: "SessionsView",
15687
- *,
15688
- period_start: dt.datetime,
15689
- period_end: dt.datetime,
15690
- display_tz: str,
15691
- version: str,
15692
- theme: str,
15693
- reveal_projects: bool,
15694
- top_n: int | None,
15695
- tz: "ZoneInfo | None",
15696
- ) -> "ShareSnapshot":
15697
- """Build a ShareSnapshot for `cctally session`.
15698
-
15699
- Consumes the unified ``SessionsView`` (spec §6.5). ``view.aggregated``
15700
- is the ``ClaudeSessionUsage`` tuple — the shape this builder needs
15701
- for ``source_paths`` / ``model_breakdowns`` / ``last_activity``
15702
- (fields ``view.rows`` / ``TuiSessionRow`` doesn't carry). The
15703
- in-memory shape is unchanged at the read boundary — only the
15704
- parameter container differs.
15705
-
15706
- Each ``ClaudeSessionUsage`` has: ``session_id`` (UUID),
15707
- ``project_path`` (filesystem path), ``cost_usd``,
15708
- ``last_activity`` (``dt.datetime``), ``models`` (first-seen-order
15709
- ``list[str]``), and the token aggregates.
15710
-
15711
- Privacy invariant (Section 8.4 / Section 5.3): the builder populates
15712
- `ProjectCell.label`, `ChartPoint.project_label`, and
15713
- `ChartPoint.x_label` with the REAL `project_path` basename. The
15714
- `_share_render_and_emit` wrapper runs `_lib_share._scrub` BEFORE
15715
- rendering — that's the single chokepoint that rewrites every
15716
- project label to `project-1` / `project-2` / ... unless
15717
- `--reveal-projects` is passed.
15718
-
15719
- Deviations from the plan sketch (which assumed dict rows with keys
15720
- `session_id` / `started_at` / `project_path` / `cost_usd` /
15721
- `models`):
15722
-
15723
- - Sessions are `ClaudeSessionUsage` dataclasses; we read fields by
15724
- attribute. `last_activity` is the canonical timestamp (no
15725
- `started_at` field — sessions span a window via
15726
- `first_activity` → `last_activity`).
15727
- - The `project_path` column's basename can collide across two
15728
- different parent dirs. We use the session-specific
15729
- `_session_disambiguate_labels` helper (sibling of
15730
- `_project_disambiguate_labels`, which expects `ProjectKey` rows
15731
- not present on session data) to suffix `" (parent)"` on
15732
- collisions before the scrubber runs.
15733
-
15734
- Caller MUST pass ``view`` whose ``aggregated`` tuple is already
15735
- sorted in the desired order (``cmd_session`` keeps the
15736
- aggregator's descending-by-last_activity sort); the builder
15737
- re-sorts internally by descending cost so the chart's HBar bars
15738
- rank consistently with the anonymization-mapping
15739
- (``_build_anon_mapping`` also sorts by descending cost) — keeping
15740
- ``project-1`` aligned with the highest-cost bar in the chart even
15741
- when the user asked for ``--order asc``.
15742
-
15743
- `top_n`, when set (must be `>= 1`; caller validates), truncates
15744
- BOTH the table rows and the chart points to the top-N by cost.
15745
- The title shifts to `"Top N sessions"` whenever `top_n` actually
15746
- truncated (so users know rows were dropped). When more rows exist
15747
- than the chart cap (15) but `top_n` is None or `>= len(sessions)`,
15748
- the table includes all rows while the chart shows the top 15 by
15749
- cost (a note clarifies).
15750
-
15751
- `theme` and `reveal_projects` flow into the subtitle directly so
15752
- the builder owns the canonical subtitle shape — no post-build
15753
- re-stamp at the gate site.
15754
- """
15755
- _lib_share = _share_load_lib()
15756
- columns = (
15757
- _lib_share.ColumnSpec(key="session", label="Session", align="left"),
15758
- _lib_share.ColumnSpec(key="project", label="Project", align="left"),
15759
- _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
15760
- emphasis=True),
15761
- _lib_share.ColumnSpec(key="last_activity", label="Last Activity",
15762
- align="left"),
15763
- _lib_share.ColumnSpec(key="models", label="Models", align="left"),
15764
- )
15765
- # Sort by descending cost so the snapshot's chart-order matches the
15766
- # `_build_anon_mapping` sort key (also descending cost).
15767
- sorted_sessions = sorted(
15768
- view.aggregated,
15769
- key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0),
15770
- )
15771
- # Apply --top-n truncation (caller validated >= 1). Truncation status
15772
- # gates the title shape below.
15773
- truncated = (
15774
- top_n is not None and top_n < len(sorted_sessions)
15775
- )
15776
- if top_n is not None:
15777
- sorted_sessions = sorted_sessions[:top_n]
15778
- # Basename-collision disambiguation: session-specific sibling of
15779
- # `_project_disambiguate_labels`. Without this, two `app/` sessions
15780
- # under different parents collapse to a single `project-N` after
15781
- # scrub — losing both privacy uniqueness and chart rank meaning.
15782
- augmented = _session_disambiguate_labels(sorted_sessions)
15783
- snap_rows: list = []
15784
- chart_pts: list = []
15785
- for idx, s in enumerate(sorted_sessions):
15786
- bare_label = (
15787
- os.path.basename(s.project_path or "")
15788
- or s.project_path
15789
- or "(unknown)"
15790
- )
15791
- proj_label = augmented.get(idx, bare_label)
15792
- cost_usd = float(getattr(s, "cost_usd", 0.0) or 0.0)
15793
- sid_short = (s.session_id[:8] if s.session_id else "—") or "—"
15794
- # Datetime chokepoint rule: route human-displayed timestamps
15795
- # through `format_display_dt` so `--tz` is honored (was
15796
- # `.astimezone()` which used host-local regardless of `--tz`).
15797
- # `suffix=False` keeps the cell width tight — the subtitle's
15798
- # period_label already carries the active tz.
15799
- last_str = format_display_dt(
15800
- s.last_activity, tz, fmt="%Y-%m-%d %H:%M", suffix=False,
15801
- )
15802
- models_text = ", ".join(s.models) if s.models else "—"
15803
- snap_rows.append(_lib_share.Row(cells={
15804
- "session": _lib_share.TextCell(sid_short),
15805
- "project": _lib_share.ProjectCell(proj_label),
15806
- "cost": _lib_share.MoneyCell(cost_usd),
15807
- "last_activity": _lib_share.TextCell(last_str),
15808
- "models": _lib_share.TextCell(models_text),
15809
- }))
15810
- chart_pts.append(_lib_share.ChartPoint(
15811
- x_label=proj_label,
15812
- x_value=cost_usd,
15813
- y_value=cost_usd,
15814
- project_label=proj_label,
15815
- ))
15816
- chart = (
15817
- _lib_share.HorizontalBarChart(
15818
- points=tuple(chart_pts), x_label="$", cap=15,
15819
- )
15820
- if chart_pts else None
15821
- )
15822
- notes: tuple[str, ...] = ()
15823
- if chart is not None and len(chart_pts) > 15:
15824
- notes = (
15825
- f"Showing top 15 in chart; table includes all {len(chart_pts)}.",
15826
- )
15827
- sum_cost = sum(p.y_value for p in chart_pts)
15828
- totals = (
15829
- _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
15830
- _lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
15831
- )
15832
- if sorted_sessions:
15833
- if truncated:
15834
- title = f"Top {len(snap_rows)} sessions"
15835
- else:
15836
- title = (
15837
- f"Sessions — {period_start.strftime('%b %d')} → "
15838
- f"{period_end.strftime('%b %d')}"
15839
- )
15840
- else:
15841
- title = "Sessions — no data"
15842
- period_label = _share_period_label(period_start, period_end, display_tz)
15843
- subtitle = " · ".join([
15844
- period_label,
15845
- theme,
15846
- "real projects" if reveal_projects else "projects anonymized",
15847
- ])
15848
- return _lib_share.ShareSnapshot(
15849
- cmd="session",
15850
- title=title,
15851
- subtitle=subtitle,
15852
- period=_lib_share.PeriodSpec(
15853
- start=period_start, end=period_end,
15854
- display_tz=display_tz, label=period_label,
15855
- ),
15856
- columns=columns, rows=tuple(snap_rows),
15857
- chart=chart, totals=totals, notes=notes,
15858
- generated_at=_share_now_utc(), version=version,
15859
- )
15860
-
15861
-
15862
- # ---- v2 share panel_data builders (spec §5.2, plan M1.6) -------------
15863
- #
15864
- # These translate the live dashboard `DataSnapshot` into the dict shapes
15865
- # the M1.4 Recap builders (in `bin/_lib_share_templates.py`) consume.
15866
- # They're a thin extract step — the DataSnapshot was already built by
15867
- # the sync thread, so this path doesn't re-query the DB on the share
15868
- # hot path.
15869
- #
15870
- # Per-panel shape contracts live in each Recap builder's docstring in
15871
- # `bin/_lib_share_templates.py` (see `_build_<panel>_recap`); the keys
15872
- # below MUST stay in lockstep with those docstrings — the
15873
- # producer/consumer contract.
15874
- #
15875
- # When the snapshot has no data for a given panel (fresh install, no
15876
- # sync yet), the builder returns a minimal empty-shaped dict that the
15877
- # downstream Recap builder renders as a "no data" snapshot (kernel
15878
- # handles empty `weeks=[]` / `days=[]` / etc.).
15879
-
15880
-
15881
- def _share_iso(value) -> "str | None":
15882
- """Coerce a datetime / ISO-string into an ISO-8601 string with `Z` suffix.
15883
-
15884
- DataSnapshot mixes attribute types (`week_start_at` is a
15885
- `dt.datetime`; `WeeklyPeriodRow.week_start_at` is already a string).
15886
- Recap builders' `_parse_iso_utc` accepts both shapes via fromisoformat
15887
- + `Z`-swap, but normalizing here keeps the wire format consistent.
15888
- """
15889
- if value is None:
15890
- return None
15891
- if isinstance(value, dt.datetime):
15892
- v = value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)
15893
- return v.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
15894
- return str(value)
15895
-
15896
-
15897
- # ---- Period override (spec §6.2 Q4 + Codex P2 on PR #35) ----
15898
- #
15899
- # The share modal's Period control offers three kinds — current, previous,
15900
- # custom — but the original render path consumed the dashboard's cached
15901
- # DataSnapshot directly, which only ever holds "current" data. Override
15902
- # semantics by panel:
15903
- #
15904
- # panel current previous custom (start/end)
15905
- # -------- ------------------ -------------------- -------------------
15906
- # weekly this subscription one week earlier week containing end
15907
- # week
15908
- # daily last 7 display-tz 7 days earlier 7 days ending at end
15909
- # days ending today
15910
- # monthly last 12 months 12 months earlier 12 months ending at end
15911
- # ending now
15912
- # trend last 8 weeks 8 weeks earlier 8 weeks ending at end
15913
- # ending now
15914
- # blocks recent 5h blocks blocks ending one blocks ending at end
15915
- # 5h-window earlier
15916
- # forecast future projection (rejected: previous
15917
- # from now forecast doesn't exist)
15918
- # current-week this subscription (rejected: panel IS current)
15919
- # week
15920
- # sessions recent sessions (deferred: ambiguous semantics — could
15921
- # mean "older sessions" or "sessions in
15922
- # date range"; revisit when use case clear)
15923
- #
15924
- # Override mechanics: derive a `now_utc` from the period option and
15925
- # re-build only the relevant DataSnapshot field by calling the same
15926
- # `_dashboard_build_*` function the sync thread uses, just with a
15927
- # shifted `now_utc`. `dataclasses.replace` returns a new DataSnapshot
15928
- # with that field swapped; everything downstream (panel_data builder,
15929
- # template builder, kernel render) consumes it unchanged.
15930
- #
15931
- # Validation failures land on the request as HTTP 400 with
15932
- # `field: "options.period.<key>"` so the UI can highlight the offending
15933
- # control.
15934
-
15935
- def _share_render_and_emit(snap, args) -> None:
15936
- """End-to-end: scrub -> render -> emit -> optional open.
15937
-
15938
- Lazy-imports `_lib_share` so non-share invocations don't pay the import
15939
- cost. The kernel module stays I/O-pure; this wrapper does all the
15940
- side-effecting glue (destination resolution, file writes, clipboard,
15941
- post-write `--open` launch).
15942
-
15943
- Caller contract: ``args.format`` MUST be set ("md", "html", or "svg").
15944
- The wrapper raises ValueError if called without it — surfaces the
15945
- contract failure at the chokepoint instead of producing junk filenames
15946
- like ``cctally-daily-<date>.None``.
15947
- """
15948
- if args.format is None:
15949
- raise ValueError("_share_render_and_emit called without args.format")
15950
- if args.open_after_write and args.format == "md":
15951
- # Spec Section 4.4: --open is only meaningful for html/svg writes.
15952
- # Reject explicitly with exit 2 instead of silently no-opping (which
15953
- # the prior implementation did because the open-after-write branch
15954
- # gates on ``kind == "file"``, and md routes to stdout by default).
15955
- print(
15956
- "cctally: --open is only valid with --format html or --format svg",
15957
- file=sys.stderr,
15958
- )
15959
- sys.exit(2)
15960
- # Routed through `_share_load_lib` so wrapper / builders / test harness
15961
- # share one cached module object — see helper docstring for the
15962
- # class-identity invariant this enforces.
15963
- _lib_share = _share_load_lib()
15964
-
15965
- scrubbed = _lib_share._scrub(snap, reveal_projects=args.reveal_projects)
15966
- rendered = _lib_share.render(
15967
- scrubbed,
15968
- format=args.format,
15969
- theme=args.theme,
15970
- branding=not args.no_branding,
15971
- )
15972
-
15973
- utc_date = snap.generated_at.astimezone(dt.timezone.utc).strftime("%Y-%m-%d")
15974
- kind, value = _resolve_destination(args, cmd=snap.cmd, generated_at_utc_date=utc_date)
15975
- _emit(rendered, kind=kind, value=value)
15976
-
15977
- if args.open_after_write and kind == "file":
15978
- _share_open_file(pathlib.Path(value))
15979
-
15980
-
15981
- def _share_open_file(path: pathlib.Path) -> None:
15982
- """Run `open` (macOS) / `xdg-open` (Linux). Silent fail if launcher missing."""
15983
- for launcher in ("open", "xdg-open"):
15984
- if shutil.which(launcher):
15985
- subprocess.Popen(
15986
- [launcher, str(path)],
15987
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
15988
- )
15989
- return
15990
- sys.stderr.write("cctally: --open requires `open` or `xdg-open` on PATH; skipped\n")
15991
-
15992
-
15993
11859
  # Phase F #23 (the FINAL Phase F extraction): ``bin/_cctally_tui.py`` is
15994
11860
  # loaded eagerly per spec §4.8 carve-out — ``tests/test_dashboard_*.py``
15995
11861
  # and ``tests/test_tui_*.py`` reach into TUI symbols via ``ns["X"]``