cctally 1.22.2 → 1.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,749 @@
1
+ # bin/_cctally_reporting.py
2
+ """Claude reporting command family.
3
+
4
+ Holds the four Claude reporting commands — `cmd_daily`, `cmd_monthly`,
5
+ `cmd_weekly`, `cmd_session` — and the daily-only render helper
6
+ `_emit_daily_view_table_or_json`.
7
+
8
+ Honest *name* imports are KERNEL-ONLY (`_cctally_core`). This module
9
+ references the bin/cctally RE-EXPORTED names of every library kernel it
10
+ needs (`build_daily_view`, `_render_bucket_table`, `_compute_subscription_weeks`,
11
+ `_build_daily_snapshot`, …) — NOT the `_lib_*` module objects — so NO
12
+ qualified `_lib_*` import is required; every such name is reached via the
13
+ call-time `_cctally()` accessor so test monkeypatches through `cctally`'s
14
+ namespace are preserved (spec §3.2). The shared join/filter helpers
15
+ (`_usage_entry_from_joined`, `_project_filter_matches`, `_parse_project_aliases`,
16
+ `_alias_for`, `_resolve_session_id_for_filter`, …) STAY in bin/cctally; the
17
+ week-boundary infra (`get_recent_weeks`, `_apply_reset_events_to_weekrefs`,
18
+ `_get_canonical_boundary_for_date`) lives in `_cctally_weekrefs.py`
19
+ (re-exported on the cctally ns). Both groups are reached via `c.`.
20
+
21
+ bin/cctally re-exports EVERY moved symbol (eager): the parser resolves
22
+ `c.cmd_daily` / `c.cmd_monthly` / `c.cmd_weekly` / `c.cmd_session`; tests
23
+ retrieve `ns["cmd_session"]` etc. off the `cctally` namespace.
24
+
25
+ Spec: docs/superpowers/specs/2026-05-31-extract-codex-reporting-cmd-design.md
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import dataclasses
31
+ import datetime as dt
32
+ import json
33
+ import sys
34
+ from typing import Any
35
+
36
+ from _cctally_core import _command_as_of, eprint, open_db, parse_iso_datetime
37
+
38
+
39
+ def _cctally():
40
+ """Resolve the current `cctally` module at call-time (spec §3.2)."""
41
+ return sys.modules["cctally"]
42
+
43
+
44
+ # === moved verbatim from bin/cctally (Regions R1–R2) ===
45
+
46
+
47
+ def _emit_daily_view_table_or_json(view, args):
48
+ """Order + emit a DailyView as the flat daily table or {daily} JSON.
49
+
50
+ Shared by cmd_daily's default path and its -p-only (filter, no grouping)
51
+ path so the two cannot drift. Body is exactly the default path's order +
52
+ emit tail; callers keep their own --format share gate upstream of this.
53
+ """
54
+ c = _cctally()
55
+ days = list(reversed(view.aggregated))
56
+ if args.order == "desc":
57
+ days = list(reversed(days))
58
+ if args.json:
59
+ print(c._bucket_to_json(days, list_key="daily", date_key="date"))
60
+ return
61
+ print(c._render_bucket_table(
62
+ days,
63
+ first_col_name="Date",
64
+ title_suffix="Daily",
65
+ compact_split_fn=c._daily_compact_split,
66
+ breakdown=args.breakdown,
67
+ compact=getattr(args, "compact", False),
68
+ ))
69
+
70
+
71
+ def cmd_daily(args: argparse.Namespace) -> int:
72
+ """Show usage report grouped by display-timezone date."""
73
+ c = _cctally()
74
+ c._share_validate_args(args)
75
+ config = c._load_claude_config_for_args(args)
76
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
77
+ # existing resolve_display_tz precedence absorbs the new alias. The
78
+ # canonical --tz still wins (it's set on the namespace before this
79
+ # bridge fires); when --tz is unset and -z is supplied, use -z.
80
+ c._bridge_z_into_tz(args, config)
81
+ tz = c.resolve_display_tz(args, config)
82
+ args._resolved_tz = tz
83
+
84
+ range = c._parse_cli_date_range(
85
+ args,
86
+ tz_name=(tz.key if tz is not None else None),
87
+ now_utc=_command_as_of(),
88
+ )
89
+ if isinstance(range, int):
90
+ return range
91
+ range_start, range_end = range
92
+
93
+ # ── Project-axis path (issue #86 Session E / T1.11) ────────────────────
94
+ # Gated by -i/--instances or -p/--project; the default path below is
95
+ # untouched/byte-stable. Mirrors cmd_project's I/O-layer git-root
96
+ # resolution + substring-OR-path filter.
97
+ aliases = c._parse_project_aliases(getattr(args, "project_aliases", None))
98
+ project_patterns = [p.lower() for p in (getattr(args, "project", None) or [])]
99
+
100
+ if getattr(args, "instances", False) or project_patterns:
101
+ joined = list(c.get_claude_session_entries(range_start, range_end))
102
+ resolver_cache: dict = {}
103
+ keyed: list = [] # [(ProjectKey, UsageEntry)] — for -i grouping
104
+ filtered_uentries: list = [] # UsageEntry — for -p-only / --format / debug
105
+ for je in joined:
106
+ if je.model == "<synthetic>":
107
+ continue
108
+ key = c._resolve_project_key(je.project_path, "git-root", resolver_cache)
109
+ if project_patterns and not c._project_filter_matches(key, project_patterns):
110
+ continue
111
+ ue = c._usage_entry_from_joined(je)
112
+ keyed.append((key, ue))
113
+ filtered_uentries.append(ue)
114
+
115
+ # Debug scope = the filtered entries (mirrors cmd_project).
116
+ c._emit_debug_samples_if_set(args, filtered_uentries, command_label="daily")
117
+
118
+ # --format share gate: -i is a no-op (no project-section share render),
119
+ # but -p IS honored by building the snapshot from the filtered view.
120
+ if getattr(args, "format", None):
121
+ view = c.build_daily_view(filtered_uentries, now_utc=_command_as_of(),
122
+ display_tz=tz, mode=args.mode)
123
+ display_tz_str = c._share_display_tz_label(tz)
124
+ snap = c._build_daily_snapshot(
125
+ view, period_start=range_start, period_end=range_end,
126
+ display_tz=display_tz_str, version=c._share_resolve_version(),
127
+ theme=args.theme, reveal_projects=args.reveal_projects,
128
+ )
129
+ if args.order == "desc":
130
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
131
+ c._share_render_and_emit(snap, args)
132
+ return 0
133
+
134
+ if getattr(args, "instances", False):
135
+ groups = c._aggregate_daily_by_project(keyed, tz=tz, mode=args.mode)
136
+ aug = c._project_disambiguate_labels(
137
+ [{"key": k, "cost_usd": sum(b.cost_usd for b in bl)}
138
+ for k, bl in groups]
139
+ )
140
+ json_groups: list = []
141
+ table_groups: list = []
142
+ # `_project_disambiguate_labels` only suffixes the immediate
143
+ # parent-dir basename, so two distinct git-roots like
144
+ # `/a/x/app` + `/b/x/app` both resolve to `app (x)`. Guarantee
145
+ # per-group JSON-key uniqueness with a counter suffix on any
146
+ # residual collision — otherwise `_bucket_by_project_to_json`'s
147
+ # `projects[label] = ...` silently overwrites the earlier group
148
+ # (data loss in --json). The table_label derives from the now-
149
+ # unique json_label, so section headers stay distinct too.
150
+ # `json_label`s are unique by construction (the `(#N)` counter
151
+ # above). Table labels, however, can re-collide: `_alias_for`
152
+ # matches on `display_key` first, so a basename alias like
153
+ # `--project-aliases app=Alias` maps BOTH same-basename git-roots
154
+ # to "Alias" — re-merging the exact sections this feature
155
+ # disambiguates. Apply the SAME `(#N)` counter to table labels so
156
+ # the two distinct-total sections stay tellable apart (JSON keys
157
+ # are untouched — they use the non-aliased `json_label`).
158
+ seen_json_labels: dict[str, int] = {}
159
+ seen_table_labels: dict[str, int] = {}
160
+ for i, (k, bl) in enumerate(groups):
161
+ ordered = list(reversed(bl)) if args.order == "desc" else bl
162
+ base_json_label = aug.get(i, k.display_key)
163
+ n = seen_json_labels.get(base_json_label, 0) + 1
164
+ seen_json_labels[base_json_label] = n
165
+ json_label = (
166
+ base_json_label if n == 1 else f"{base_json_label} (#{n})"
167
+ )
168
+ base_table_label = c._alias_for(k, aliases) or json_label
169
+ nt = seen_table_labels.get(base_table_label, 0) + 1
170
+ seen_table_labels[base_table_label] = nt
171
+ table_label = (
172
+ base_table_label if nt == 1
173
+ else f"{base_table_label} (#{nt})"
174
+ )
175
+ json_groups.append((json_label, ordered))
176
+ table_groups.append((table_label, ordered))
177
+ if args.json:
178
+ print(c._bucket_by_project_to_json(json_groups, date_key="date"))
179
+ return 0
180
+ print(c._render_bucket_table(
181
+ [], first_col_name="Date", title_suffix="Daily",
182
+ compact_split_fn=c._daily_compact_split,
183
+ breakdown=args.breakdown,
184
+ compact=getattr(args, "compact", False),
185
+ project_groups=table_groups,
186
+ ))
187
+ return 0
188
+
189
+ # -p only (no -i): filter-only → normal date-aggregated daily output.
190
+ view = c.build_daily_view(filtered_uentries, now_utc=_command_as_of(),
191
+ display_tz=tz, mode=args.mode)
192
+ _emit_daily_view_table_or_json(view, args)
193
+ return 0
194
+
195
+ # ── Default path (UNCHANGED) ───────────────────────────────────────────
196
+ # Collect entries.
197
+ all_entries = c.get_entries(range_start, range_end)
198
+
199
+ c._emit_debug_samples_if_set(
200
+ args, all_entries, command_label="daily",
201
+ )
202
+
203
+ # Build the unified daily view (spec §5.1: gap-free; the dashboard
204
+ # heatmap's contiguous-window materialization stays at the dashboard
205
+ # envelope adapter so CLI byte-stability is preserved). Consume
206
+ # `view.aggregated` (BucketUsage tuple) for the CLI renderers — the
207
+ # JSON shape's `bucket` / `model_breakdowns` / `models: list[str]`
208
+ # fields live on BucketUsage, not on DailyPanelRow. The builder's
209
+ # `_aggregate_daily` call is the same one we used inline.
210
+ view = c.build_daily_view(all_entries, now_utc=_command_as_of(),
211
+ display_tz=tz, mode=args.mode)
212
+
213
+ # Shareable-reports gate: --format short-circuits the JSON / table
214
+ # dispatch via `_share_render_and_emit`. The mutex in
215
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
216
+ # Gate runs BEFORE the `--order desc` reversal so the BarChart bars
217
+ # render chronologically regardless of `--order`. Table rows in the
218
+ # rendered artifact, however, must respect `--order desc` (parity
219
+ # with terminal / JSON output) — handled by reversing snap.rows
220
+ # post-build below; the chart points stay chronological because
221
+ # they were built from ascending `days`.
222
+ if getattr(args, "format", None):
223
+ # Note: --breakdown is a no-op under --format (snapshot focuses on
224
+ # the headline daily-cost trend; per-model sub-rows aren't in the
225
+ # share spec scope). Same convention applies to other share-enabled
226
+ # subcommands (cmd_report's --detail, etc.).
227
+ display_tz_str = c._share_display_tz_label(tz)
228
+ snap = c._build_daily_snapshot(
229
+ view,
230
+ period_start=range_start,
231
+ period_end=range_end,
232
+ display_tz=display_tz_str,
233
+ version=c._share_resolve_version(),
234
+ theme=args.theme,
235
+ reveal_projects=args.reveal_projects,
236
+ )
237
+ if args.order == "desc":
238
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
239
+ c._share_render_and_emit(snap, args)
240
+ return 0
241
+
242
+ # Order + emit the flat daily table / {daily} JSON. Extracted into
243
+ # `_emit_daily_view_table_or_json` so this default path and the
244
+ # -p-only (filter, no grouping) path above stay byte-identical.
245
+ _emit_daily_view_table_or_json(view, args)
246
+ return 0
247
+
248
+
249
+ def cmd_monthly(args: argparse.Namespace) -> int:
250
+ """Show usage report grouped by display-timezone calendar month."""
251
+ c = _cctally()
252
+ c._share_validate_args(args)
253
+ config = c._load_claude_config_for_args(args)
254
+ c._bridge_z_into_tz(args, config)
255
+ tz = c.resolve_display_tz(args, config)
256
+ args._resolved_tz = tz
257
+
258
+ range = c._parse_cli_date_range(
259
+ args,
260
+ tz_name=(tz.key if tz is not None else None),
261
+ now_utc=_command_as_of(),
262
+ )
263
+ if isinstance(range, int):
264
+ return range
265
+ range_start, range_end = range
266
+
267
+ all_entries = c.get_entries(range_start, range_end)
268
+
269
+ c._emit_debug_samples_if_set(
270
+ args, all_entries, command_label="monthly",
271
+ )
272
+
273
+ # Build the unified monthly view (spec §5.2: drops boundary-spillover
274
+ # bucket; computes delta_cost_pct internally). Consume
275
+ # `view.aggregated` (BucketUsage tuple, newest-first) for CLI byte-
276
+ # stability — `_bucket_to_json` reads BucketUsage fields not present
277
+ # on MonthlyPeriodRow.
278
+ #
279
+ # Pass a large `n` so the CLI's `--since`/`--until` window controls
280
+ # how many months render (the dashboard caps at n=12; CLI doesn't).
281
+ view = c.build_monthly_view(all_entries, now_utc=_command_as_of(),
282
+ n=10**6, display_tz=tz, mode=args.mode)
283
+ # The view stores `aggregated` newest-first; CLI default is asc.
284
+ months = list(reversed(view.aggregated))
285
+
286
+ # Shareable-reports gate: --format short-circuits the JSON / table
287
+ # dispatch via `_share_render_and_emit`. The mutex in
288
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
289
+ # Gate runs BEFORE the `--order desc` reversal so the BarChart bars
290
+ # render chronologically regardless of `--order`. Table rows in the
291
+ # rendered artifact respect `--order desc` (parity with terminal /
292
+ # JSON) via post-build snap.rows reversal; chart stays chronological.
293
+ if getattr(args, "format", None):
294
+ # Note: --breakdown is a no-op under --format (snapshot focuses on
295
+ # the headline monthly-cost trend; per-model sub-rows aren't in the
296
+ # share spec scope). Same convention as cmd_daily / cmd_report.
297
+ display_tz_str = c._share_display_tz_label(tz)
298
+ snap = c._build_monthly_snapshot(
299
+ view,
300
+ period_start=range_start,
301
+ period_end=range_end,
302
+ display_tz=display_tz_str,
303
+ version=c._share_resolve_version(),
304
+ theme=args.theme,
305
+ reveal_projects=args.reveal_projects,
306
+ )
307
+ if args.order == "desc":
308
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
309
+ c._share_render_and_emit(snap, args)
310
+ return 0
311
+
312
+ if args.order == "desc":
313
+ months = list(reversed(months))
314
+
315
+ if args.json:
316
+ print(c._bucket_to_json(months, list_key="monthly", date_key="month"))
317
+ return 0
318
+
319
+ print(c._render_bucket_table(
320
+ months,
321
+ first_col_name="Month",
322
+ title_suffix="Monthly",
323
+ compact_split_fn=c._monthly_compact_split,
324
+ breakdown=args.breakdown,
325
+ compact=getattr(args, "compact", False),
326
+ ))
327
+ return 0
328
+
329
+
330
+ def cmd_weekly(args: argparse.Namespace) -> int:
331
+ """Show Claude usage grouped by subscription week."""
332
+ c = _cctally()
333
+ c._share_validate_args(args)
334
+ config = c._load_claude_config_for_args(args)
335
+ c._bridge_z_into_tz(args, config)
336
+ args._resolved_tz = c.resolve_display_tz(args, config)
337
+
338
+ now_utc = _command_as_of()
339
+ range = c._parse_cli_date_range(args, now_utc=now_utc)
340
+ if isinstance(range, int):
341
+ return range
342
+ range_start, range_end = range
343
+
344
+ conn = open_db()
345
+
346
+ # Build the subscription-week list spanning the range. Boundaries are
347
+ # anchored in `weekly_usage_snapshots` when available and otherwise
348
+ # extrapolated (see `_compute_subscription_weeks`). Pass the
349
+ # `--config`-honoring resolved config (issue #88) so the no-snapshot
350
+ # calendar-week fallback uses the explicit override's `week_start`.
351
+ weeks = c._compute_subscription_weeks(
352
+ conn, range_start, range_end, config=config,
353
+ )
354
+
355
+ # Fetch entries and aggregate.
356
+ # Cover each SubWeek's full [start_ts, end_ts) on the range_start side —
357
+ # `_compute_subscription_weeks` can emit weeks whose start_ts precedes
358
+ # range_start (any week overlapping the range). Without widening, boundary
359
+ # weeks get tail-only cost divided by full-week usedPct → understated
360
+ # totalCost and $/1%. range_end stays as the upper bound so historical
361
+ # `--until <past>` queries still clip the tail week (paired with the
362
+ # as_of_utc bound on get_latest_usage_for_week below).
363
+ if weeks:
364
+ fetch_start = min(
365
+ range_start,
366
+ parse_iso_datetime(weeks[0].start_ts, "week_start_at"),
367
+ )
368
+ else:
369
+ fetch_start = range_start
370
+ all_entries = c.get_entries(fetch_start, range_end)
371
+
372
+ c._emit_debug_samples_if_set(
373
+ args, all_entries, command_label="weekly",
374
+ )
375
+
376
+ # Bound the usage-snapshot lookup to `<= range_end` so historical
377
+ # `--until <past date>` queries pick the usage% that was current at
378
+ # the end of the requested window rather than the globally latest
379
+ # snapshot for the week. Cost is already truncated to `range_end` by
380
+ # `_aggregate_weekly`, so using a later usedPct would produce a
381
+ # silently wrong $/1%. Match the stored `captured_at_utc` format
382
+ # (see now_utc_iso): UTC, seconds precision, `Z` suffix — otherwise
383
+ # lexicographic string compare inside SQLite would misorder `+00:00`
384
+ # vs. `Z` at the same instant.
385
+ as_of_utc = (
386
+ range_end.astimezone(dt.timezone.utc)
387
+ .replace(microsecond=0)
388
+ .isoformat()
389
+ .replace("+00:00", "Z")
390
+ )
391
+
392
+ # Build the unified weekly view (spec §5.3): runs _aggregate_weekly,
393
+ # overlays weekly_usage_snapshots per WeekRef. view.aggregated is
394
+ # the BucketUsage tuple newest-first; view.overlay is the parallel
395
+ # (used_pct, dollar_per_pct) tuple. We reverse both for CLI's
396
+ # default asc rendering so the existing renderer's len-equality
397
+ # assertions stay aligned.
398
+ view = c.build_weekly_view(
399
+ conn, all_entries, weeks=weeks, now_utc=now_utc,
400
+ display_tz=args._resolved_tz, as_of_utc=as_of_utc, mode=args.mode,
401
+ )
402
+ buckets = list(reversed(view.aggregated))
403
+ overlay = list(reversed(view.overlay))
404
+
405
+ # Shareable-reports gate: --format short-circuits the JSON / table
406
+ # dispatch via `_share_render_and_emit`. The mutex in
407
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
408
+ # Gate runs BEFORE the `--order desc` reversal so the BarChart bars
409
+ # render chronologically regardless of `--order`. Table rows in the
410
+ # rendered artifact respect `--order desc` (parity with terminal /
411
+ # JSON) via post-build snap.rows reversal. `--breakdown` is honored:
412
+ # when set, the snapshot adds per-model columns + stacked bar series
413
+ # (vs. cmd_daily / cmd_monthly where --breakdown is a no-op under
414
+ # --format).
415
+ if getattr(args, "format", None):
416
+ display_tz_str = c._share_display_tz_label(args._resolved_tz)
417
+ snap = c._build_weekly_snapshot(
418
+ view,
419
+ period_start=range_start,
420
+ period_end=range_end,
421
+ display_tz=display_tz_str,
422
+ version=c._share_resolve_version(),
423
+ theme=args.theme,
424
+ reveal_projects=args.reveal_projects,
425
+ breakdown_model=bool(getattr(args, "breakdown", False)),
426
+ )
427
+ if args.order == "desc":
428
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
429
+ c._share_render_and_emit(snap, args)
430
+ return 0
431
+
432
+ # Apply sort order. Buckets and overlay must reverse together so their
433
+ # indices stay aligned (both _render_weekly_table and _weekly_to_json
434
+ # assert len equality).
435
+ if args.order == "desc":
436
+ buckets = list(reversed(buckets))
437
+ overlay = list(reversed(overlay))
438
+
439
+ if args.json:
440
+ print(c._weekly_to_json(buckets, weeks, overlay))
441
+ return 0
442
+
443
+ if not buckets:
444
+ print("No Claude usage found.")
445
+ return 0
446
+
447
+ print(c._render_weekly_table(
448
+ buckets,
449
+ overlay,
450
+ weeks=weeks,
451
+ compact_split_fn=c._daily_compact_split,
452
+ breakdown=args.breakdown,
453
+ compact=getattr(args, "compact", False),
454
+ ))
455
+ return 0
456
+
457
+
458
+ def cmd_session(args: argparse.Namespace) -> int:
459
+ """Show Claude usage grouped by sessionId (merges resumed-across-files sessions)."""
460
+ c = _cctally()
461
+ c._share_validate_args(args)
462
+ config = c._load_claude_config_for_args(args)
463
+ c._bridge_z_into_tz(args, config)
464
+ tz = c.resolve_display_tz(args, config)
465
+ args._resolved_tz = tz
466
+
467
+ range = c._parse_cli_date_range(
468
+ args,
469
+ tz_name=(tz.key if tz is not None else None),
470
+ now_utc=_command_as_of(),
471
+ )
472
+ if isinstance(range, int):
473
+ return range
474
+ range_start, range_end = range
475
+
476
+ entries = c.get_claude_session_entries(range_start, range_end)
477
+
478
+ # Issue #89: --debug report describes the joined-entry list filtered
479
+ # by --id (post-fallback session_id resolution) when set, matching
480
+ # the rendered scope of `sessions`.
481
+ # `is not None`, not truthiness: an explicit empty `--id ''` must still
482
+ # engage the filter (→ empty render), not silently fall through to
483
+ # "describe/show all sessions" (code-review finding). Mirrored on the
484
+ # post-aggregation filter below.
485
+ if getattr(args, "id", None) is not None:
486
+ joined_for_report = [
487
+ je for je in entries
488
+ if c._resolve_session_id_for_filter(je) == args.id
489
+ ]
490
+ else:
491
+ joined_for_report = entries
492
+ c._emit_debug_samples_if_set(
493
+ args,
494
+ [c._usage_entry_from_joined(je) for je in joined_for_report],
495
+ command_label="session",
496
+ )
497
+
498
+ # Unified view-model kernel (spec §6.5). `limit=None` keeps the
499
+ # full aggregator output — `cctally session` has no `--limit` flag
500
+ # and emits every session in the requested range. `view.aggregated`
501
+ # is the `list[ClaudeSessionUsage]` shape the legacy CLI / share
502
+ # renderers consume (table, --json, share-snapshot); `view.rows`
503
+ # is the typed `TuiSessionRow` tuple reserved for the TUI /
504
+ # dashboard wiring in Task 15 / 16. Keeping both shapes parallel
505
+ # at the builder preserves the resumed-session merge invariant
506
+ # documented in CLAUDE.md (one sessionId across multiple JSONL
507
+ # files collapses to ONE entry in BOTH tuples).
508
+ view = c.build_sessions_view(
509
+ entries, now_utc=_command_as_of(), limit=None, display_tz=tz,
510
+ mode=args.mode,
511
+ )
512
+ sessions = list(view.aggregated)
513
+
514
+ # Session A (spec §7.4): exact-string filter on sessionId. Applied
515
+ # AFTER aggregation (so resume-merged sessions across multiple JSONL
516
+ # files are matched against their post-merge id) and BEFORE the
517
+ # `--order asc` reversal and the JSON / share / table render
518
+ # branches. Unknown id → empty `sessions` list, which falls through
519
+ # to the existing "no sessions" branch (table: "No Claude session
520
+ # data found."; JSON: `{"sessions": []}`).
521
+ if getattr(args, "id", None) is not None: # explicit '' still filters
522
+ sessions = [s for s in sessions if s.session_id == args.id]
523
+
524
+ # Shareable-reports gate: --format short-circuits the JSON / table
525
+ # dispatch via `_share_render_and_emit`. The mutex in
526
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
527
+ # Privacy invariant (Section 8.4 / 5.3): the wrapper runs `_lib_share._scrub`
528
+ # before rendering, so default output anonymizes project labels to
529
+ # `project-1` / `project-2` / ...; `--reveal-projects` opts back in.
530
+ # The builder populates `ProjectCell.label` / `ChartPoint.project_label`
531
+ # / `ChartPoint.x_label` with REAL basenames; the wrapper-level scrubber
532
+ # is the single chokepoint that rewrites them.
533
+ if getattr(args, "format", None):
534
+ # --top-n validation. Spec convention (Implementor 6 fix-loop):
535
+ # invalid flag combinations exit 2; the soft-warn upper threshold
536
+ # (>50) writes to stderr but proceeds.
537
+ top_n = getattr(args, "top_n", None)
538
+ if top_n is not None:
539
+ if top_n < 1:
540
+ print(
541
+ "cctally: --top-n must be >= 1",
542
+ file=sys.stderr,
543
+ )
544
+ return 2
545
+ if top_n > 50:
546
+ sys.stderr.write(
547
+ f"cctally: --top-n {top_n} will likely produce an "
548
+ "unreadable chart (consider 15 or fewer)\n"
549
+ )
550
+ # Note: --breakdown is a no-op under --format (snapshot focuses
551
+ # on the headline per-session usage table + HBar chart; per-model
552
+ # sub-rows aren't in the share spec scope). Same convention as
553
+ # cmd_daily / cmd_project.
554
+ display_tz_str = c._share_display_tz_label(tz)
555
+ # Session A (spec §7.4): `_build_session_snapshot` reads
556
+ # `view.aggregated`, so the `--id` filter applied to the local
557
+ # `sessions` list above would otherwise be ignored for share
558
+ # exports (HTML/Markdown/SVG). Hand the builder a view whose
559
+ # `aggregated` is the filtered list so `--id` is honored across
560
+ # every output path. The builder reads only `aggregated` (never
561
+ # `rows` / `total_sessions`), so the parallel-tuple mismatch from
562
+ # the replace is inert here.
563
+ share_view = dataclasses.replace(view, aggregated=tuple(sessions))
564
+ snap = c._build_session_snapshot(
565
+ share_view,
566
+ period_start=range_start,
567
+ period_end=range_end,
568
+ display_tz=display_tz_str,
569
+ version=c._share_resolve_version(),
570
+ theme=args.theme,
571
+ reveal_projects=args.reveal_projects,
572
+ top_n=top_n,
573
+ tz=tz,
574
+ )
575
+ c._share_render_and_emit(snap, args)
576
+ return 0
577
+
578
+ # Aggregator returns descending by last_activity; --order asc reverses.
579
+ if args.order == "asc":
580
+ sessions = list(reversed(sessions))
581
+
582
+ if args.json:
583
+ print(c._claude_sessions_to_json(sessions))
584
+ return 0
585
+
586
+ if not sessions:
587
+ print("No Claude session data found.")
588
+ return 0
589
+
590
+ # Session A (spec §7.6.1; Review-A P2-B): thread --compact through
591
+ # so the renderer's scale-down branch fires regardless of terminal
592
+ # width when the flag is set.
593
+ print(c._render_claude_session_table(
594
+ sessions,
595
+ breakdown=args.breakdown,
596
+ tz=tz,
597
+ compact=getattr(args, "compact", False),
598
+ ))
599
+ return 0
600
+
601
+
602
+ def cmd_range_cost(args: argparse.Namespace) -> int:
603
+ c = _cctally()
604
+ # Session A (spec §7.2 / §7.6 row note): range-cost has no --tz of
605
+ # its own — start/end carry their own zone via ISO 8601. Calling the
606
+ # bridge keeps the alias-surface contract uniform across the 10
607
+ # in-scope cmds: -z/--timezone lands on args.tz unchanged (no
608
+ # downstream consumer), so this is a documented no-op for this
609
+ # command. The bridge still runs _resolve_claude_tz_name so the §9.2a
610
+ # production-path coverage is exercised here too.
611
+ config = c._load_claude_config_for_args(args)
612
+ c._bridge_z_into_tz(args, config)
613
+ start_dt = parse_iso_datetime(args.start, "--start")
614
+ if args.end:
615
+ end_dt = parse_iso_datetime(args.end, "--end")
616
+ else:
617
+ # internal fallback: host-local intentional
618
+ end_dt = dt.datetime.now().astimezone()
619
+ if end_dt < start_dt:
620
+ eprint("Error: --end must be after --start")
621
+ return 1
622
+
623
+ total_cost = 0.0
624
+ matched_entries = 0
625
+ first_match: dt.datetime | None = None
626
+ last_match: dt.datetime | None = None
627
+ model_buckets: dict[str, dict[str, Any]] = {}
628
+
629
+ # Issue #89: keep the loaded list around so the --debug report can
630
+ # describe the same dataset as the rendered output. Project filter is
631
+ # applied at the loader (SELECT-time), so the scope is the same.
632
+ # P2.2 (issue #89 review-loop): get_entries already returns
633
+ # list[UsageEntry] per bin/_cctally_cache.py:1224 — no list() wrap.
634
+ entries_list = c.get_entries(start_dt, end_dt, project=args.project)
635
+ c._emit_debug_samples_if_set(
636
+ args, entries_list, command_label="range-cost",
637
+ )
638
+
639
+ for entry in entries_list:
640
+ cost = c._calculate_entry_cost(
641
+ entry.model, entry.usage, mode=args.mode, cost_usd=entry.cost_usd,
642
+ )
643
+ total_cost += cost
644
+ matched_entries += 1
645
+
646
+ if first_match is None or entry.timestamp < first_match:
647
+ first_match = entry.timestamp
648
+ if last_match is None or entry.timestamp > last_match:
649
+ last_match = entry.timestamp
650
+
651
+ if entry.model not in model_buckets:
652
+ model_buckets[entry.model] = {
653
+ "entries": 0, "inputTokens": 0, "outputTokens": 0,
654
+ "cacheCreationTokens": 0, "cacheReadTokens": 0, "costUSD": 0.0,
655
+ }
656
+ b = model_buckets[entry.model]
657
+ b["entries"] += 1
658
+ b["inputTokens"] += entry.usage.get("input_tokens", 0)
659
+ b["outputTokens"] += entry.usage.get("output_tokens", 0)
660
+ b["cacheCreationTokens"] += entry.usage.get("cache_creation_input_tokens", 0)
661
+ b["cacheReadTokens"] += entry.usage.get("cache_read_input_tokens", 0)
662
+ b["costUSD"] += cost
663
+
664
+ if args.total_only:
665
+ print(f"{total_cost:.9f}")
666
+ return 0
667
+
668
+ if args.json:
669
+ breakdowns = []
670
+ for model in sorted(model_buckets, key=lambda m: -model_buckets[m]["costUSD"]):
671
+ b = model_buckets[model]
672
+ total_tokens = (
673
+ b["inputTokens"] + b["outputTokens"]
674
+ + b["cacheCreationTokens"] + b["cacheReadTokens"]
675
+ )
676
+ breakdowns.append({
677
+ "model": model,
678
+ "entries": b["entries"],
679
+ "inputTokens": b["inputTokens"],
680
+ "outputTokens": b["outputTokens"],
681
+ "cacheCreationTokens": b["cacheCreationTokens"],
682
+ "cacheReadTokens": b["cacheReadTokens"],
683
+ "totalTokens": total_tokens,
684
+ "costUSD": round(b["costUSD"], 9),
685
+ })
686
+ output = {
687
+ "start": start_dt.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
688
+ "end": end_dt.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
689
+ "mode": args.mode,
690
+ "project": args.project,
691
+ "matchedEntries": matched_entries,
692
+ "totalCostUSD": round(total_cost, 9),
693
+ "firstMatchedEntry": (
694
+ first_match.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
695
+ if first_match else None
696
+ ),
697
+ "lastMatchedEntry": (
698
+ last_match.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
699
+ if last_match else None
700
+ ),
701
+ "modelBreakdowns": breakdowns,
702
+ }
703
+ print(json.dumps(output, indent=2))
704
+ return 0
705
+
706
+ if args.breakdown:
707
+ headers = ["Model", "Entries", "Input", "Output", "Cache Create", "Cache Read", "Total Tokens", "Cost (USD)"]
708
+ rows: list[list[str]] = []
709
+ for model in sorted(model_buckets, key=lambda m: -model_buckets[m]["costUSD"]):
710
+ b = model_buckets[model]
711
+ total_tokens = (
712
+ b["inputTokens"] + b["outputTokens"]
713
+ + b["cacheCreationTokens"] + b["cacheReadTokens"]
714
+ )
715
+ rows.append([
716
+ model,
717
+ f"{b['entries']:,}",
718
+ f"{b['inputTokens']:,}",
719
+ f"{b['outputTokens']:,}",
720
+ f"{b['cacheCreationTokens']:,}",
721
+ f"{b['cacheReadTokens']:,}",
722
+ f"{total_tokens:,}",
723
+ f"${b['costUSD']:.9f}",
724
+ ])
725
+ # Total row
726
+ tot_entries = matched_entries
727
+ tot_inp = sum(b["inputTokens"] for b in model_buckets.values())
728
+ tot_out = sum(b["outputTokens"] for b in model_buckets.values())
729
+ tot_cc = sum(b["cacheCreationTokens"] for b in model_buckets.values())
730
+ tot_cr = sum(b["cacheReadTokens"] for b in model_buckets.values())
731
+ tot_tokens = tot_inp + tot_out + tot_cc + tot_cr
732
+ rows.append([
733
+ "Total",
734
+ f"{tot_entries:,}",
735
+ f"{tot_inp:,}",
736
+ f"{tot_out:,}",
737
+ f"{tot_cc:,}",
738
+ f"{tot_cr:,}",
739
+ f"{tot_tokens:,}",
740
+ f"${total_cost:.9f}",
741
+ ])
742
+
743
+ aligns = ["left"] + ["right"] * (len(headers) - 1)
744
+ print(c._boxed_table(headers, rows, aligns, compact=args.compact))
745
+ return 0
746
+
747
+ # Default: print cost
748
+ print(f"${total_cost:.9f}")
749
+ return 0