cctally 1.22.1 → 1.22.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/bin/_cctally_cache_report.py +1133 -880
- package/bin/_cctally_codex.py +518 -0
- package/bin/_cctally_dashboard.py +3 -3
- package/bin/_cctally_diff.py +240 -0
- package/bin/_cctally_doctor.py +479 -0
- package/bin/_cctally_five_hour.py +1688 -0
- package/bin/_cctally_forecast.py +1979 -0
- package/bin/_cctally_milestones.py +433 -0
- package/bin/_cctally_percent_breakdown.py +199 -0
- package/bin/_cctally_pricing_check.py +393 -0
- package/bin/_cctally_project.py +714 -0
- package/bin/_cctally_record.py +5 -3
- package/bin/_cctally_reporting.py +749 -0
- package/bin/_cctally_setup.py +172 -13
- package/bin/_cctally_statusline.py +630 -0
- package/bin/_cctally_sync_week.py +5 -4
- package/bin/_cctally_weekrefs.py +450 -0
- package/bin/_lib_cache_report.py +938 -0
- package/bin/_lib_pricing_debug.py +182 -0
- package/bin/_lib_subscription_weeks.py +2 -2
- package/bin/cctally +426 -9569
- package/package.json +15 -1
|
@@ -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
|