cctally 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/bin/_cctally_cache.py +17 -1
- package/bin/_lib_aggregators.py +35 -0
- package/bin/_lib_render.py +84 -42
- package/bin/cctally +211 -27
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.18.0] - 2026-05-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Project-axis flags on `cctally daily` (and `cctally claude daily`): `-i/--instances`, `-p/--project`, and `--project-aliases`** — a drop-in for `ccusage daily`. `-i/--instances` groups the daily report by project (git-root), rendering a `Project: <label>` section per project with one global Total (`--json` becomes `{projects: {...}, totals}`); `-p/--project PATTERN` filters to matching projects by case-insensitive substring of the project label or path, and is repeatable with OR semantics; `--project-aliases key=Label,...` overrides project display labels (table headers only — never the JSON keys). Two distinct git-roots that share a basename stay separate (`app (work)` / `app (personal)`), null-project entries collect under `(unknown)`, and under `--format` shareable output `-i` is a no-op while `-p` is honored (the filter survives into the artifact). (#86)
|
|
12
|
+
|
|
8
13
|
## [1.17.0] - 2026-05-27
|
|
9
14
|
|
|
10
15
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -937,6 +937,13 @@ class _JoinedClaudeEntry:
|
|
|
937
937
|
# reconciles with daily/range-cost paths that already pass
|
|
938
938
|
# `entry.cost_usd` into `_calculate_entry_cost`.
|
|
939
939
|
cost_usd: float | None = None
|
|
940
|
+
# Non-token `usage` extras (parsed `usage_extra_json`) — notably
|
|
941
|
+
# `speed`, which `_aggregate_buckets` reads to render `<model>-fast`.
|
|
942
|
+
# `iter_entries` merges these into its `UsageEntry.usage`; the joined
|
|
943
|
+
# path must carry them too so `_usage_entry_from_joined` can restore
|
|
944
|
+
# them (else `daily -i`/`-p` lose fast-tier model labels). None when
|
|
945
|
+
# the row has no extras.
|
|
946
|
+
usage_extra: dict | None = None
|
|
940
947
|
|
|
941
948
|
|
|
942
949
|
def get_claude_session_entries(
|
|
@@ -996,7 +1003,7 @@ def get_claude_session_entries(
|
|
|
996
1003
|
" se.cache_create_tokens, se.cache_read_tokens, "
|
|
997
1004
|
" se.source_path, "
|
|
998
1005
|
" sf.session_id, sf.project_path, "
|
|
999
|
-
" se.cost_usd_raw "
|
|
1006
|
+
" se.cost_usd_raw, se.usage_extra_json "
|
|
1000
1007
|
"FROM session_entries se "
|
|
1001
1008
|
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
1002
1009
|
"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
|
|
@@ -1024,6 +1031,7 @@ def get_claude_session_entries(
|
|
|
1024
1031
|
session_id=row[7],
|
|
1025
1032
|
project_path=row[8],
|
|
1026
1033
|
cost_usd=row[9],
|
|
1034
|
+
usage_extra=(json.loads(row[10]) if row[10] else None),
|
|
1027
1035
|
)
|
|
1028
1036
|
for row in rows
|
|
1029
1037
|
]
|
|
@@ -1135,9 +1143,16 @@ def _direct_parse_claude_session_entries(
|
|
|
1135
1143
|
results: list[_JoinedClaudeEntry] = []
|
|
1136
1144
|
flat: list[tuple[UsageEntry, str]] = list(dedupe_map.values()) + no_key_with_meta
|
|
1137
1145
|
flat.sort(key=lambda pair: pair[0].timestamp)
|
|
1146
|
+
_token_keys = {
|
|
1147
|
+
"input_tokens", "output_tokens",
|
|
1148
|
+
"cache_creation_input_tokens", "cache_read_input_tokens",
|
|
1149
|
+
}
|
|
1138
1150
|
for entry, source_path in flat:
|
|
1139
1151
|
usage = entry.usage
|
|
1140
1152
|
sid, cwd = meta_by_path[source_path]
|
|
1153
|
+
# Mirror the cache-backed path: carry non-token `usage` extras
|
|
1154
|
+
# (e.g. `speed`) so `_usage_entry_from_joined` can restore them.
|
|
1155
|
+
extras = {k: v for k, v in usage.items() if k not in _token_keys}
|
|
1141
1156
|
results.append(_JoinedClaudeEntry(
|
|
1142
1157
|
timestamp=entry.timestamp,
|
|
1143
1158
|
model=entry.model,
|
|
@@ -1153,6 +1168,7 @@ def _direct_parse_claude_session_entries(
|
|
|
1153
1168
|
session_id=sid,
|
|
1154
1169
|
project_path=cwd,
|
|
1155
1170
|
cost_usd=entry.cost_usd,
|
|
1171
|
+
usage_extra=(extras or None),
|
|
1156
1172
|
))
|
|
1157
1173
|
|
|
1158
1174
|
return results
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -232,6 +232,41 @@ def _aggregate_monthly(
|
|
|
232
232
|
)
|
|
233
233
|
|
|
234
234
|
|
|
235
|
+
def _aggregate_daily_by_project(
|
|
236
|
+
keyed_entries: list[tuple[Any, UsageEntry]],
|
|
237
|
+
*,
|
|
238
|
+
tz: "Any | None" = None,
|
|
239
|
+
mode: str = "auto",
|
|
240
|
+
) -> list[tuple[Any, list[BucketUsage]]]:
|
|
241
|
+
"""Group ``(project_key, UsageEntry)`` pairs into per-project daily buckets.
|
|
242
|
+
|
|
243
|
+
Returns ``[(project_key, [BucketUsage date-asc]), ...]`` ordered by each
|
|
244
|
+
project's total cost descending, ties broken by ``project_key.display_key``
|
|
245
|
+
ascending. ``project_key`` is opaque/hashable (a ``ProjectKey``); resolution
|
|
246
|
+
happened in the caller, so this stays pure (no filesystem).
|
|
247
|
+
|
|
248
|
+
Reuses ``_aggregate_daily`` per group, so per-model breakdowns, token sums,
|
|
249
|
+
and ``mode``/``cost_usd`` threading are identical to the non-instances path.
|
|
250
|
+
"""
|
|
251
|
+
grouped: dict[Any, list[UsageEntry]] = {}
|
|
252
|
+
order: list[Any] = []
|
|
253
|
+
for key, entry in keyed_entries:
|
|
254
|
+
bucket = grouped.get(key)
|
|
255
|
+
if bucket is None:
|
|
256
|
+
grouped[key] = bucket = []
|
|
257
|
+
order.append(key)
|
|
258
|
+
bucket.append(entry)
|
|
259
|
+
|
|
260
|
+
ranked: list[tuple[Any, list[BucketUsage], float]] = []
|
|
261
|
+
for key in order:
|
|
262
|
+
buckets = _aggregate_daily(grouped[key], mode=mode, tz=tz) # date-asc
|
|
263
|
+
total = sum(b.cost_usd for b in buckets)
|
|
264
|
+
ranked.append((key, buckets, total))
|
|
265
|
+
|
|
266
|
+
ranked.sort(key=lambda t: (-t[2], t[0].display_key))
|
|
267
|
+
return [(key, buckets) for key, buckets, _ in ranked]
|
|
268
|
+
|
|
269
|
+
|
|
235
270
|
def _aggregate_weekly(
|
|
236
271
|
entries: list[UsageEntry],
|
|
237
272
|
weeks: list[SubWeek],
|
package/bin/_lib_render.py
CHANGED
|
@@ -680,6 +680,40 @@ def _render_blocks_table(
|
|
|
680
680
|
return rendered
|
|
681
681
|
|
|
682
682
|
|
|
683
|
+
def _daily_row_dict(d: BucketUsage, *, date_key: str) -> dict[str, Any]:
|
|
684
|
+
"""Single bucket → upstream-shaped row dict.
|
|
685
|
+
|
|
686
|
+
Shared by `_bucket_to_json` and `_bucket_by_project_to_json` so the per-row
|
|
687
|
+
shape (field set + order) can never drift. Key order matches ccusage:
|
|
688
|
+
date_key, inputTokens, outputTokens, cacheCreationTokens,
|
|
689
|
+
cacheReadTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns.
|
|
690
|
+
"""
|
|
691
|
+
return {
|
|
692
|
+
date_key: d.bucket,
|
|
693
|
+
"inputTokens": d.input_tokens,
|
|
694
|
+
"outputTokens": d.output_tokens,
|
|
695
|
+
"cacheCreationTokens": d.cache_creation_tokens,
|
|
696
|
+
"cacheReadTokens": d.cache_read_tokens,
|
|
697
|
+
"totalTokens": d.total_tokens,
|
|
698
|
+
"totalCost": d.cost_usd,
|
|
699
|
+
"modelsUsed": list(d.models),
|
|
700
|
+
"modelBreakdowns": list(d.model_breakdowns),
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _bucket_totals_dict(buckets) -> dict[str, Any]:
|
|
705
|
+
"""Aggregate totals across buckets (key order matches ccusage; note
|
|
706
|
+
totalCost BEFORE totalTokens)."""
|
|
707
|
+
return {
|
|
708
|
+
"inputTokens": sum(b.input_tokens for b in buckets),
|
|
709
|
+
"outputTokens": sum(b.output_tokens for b in buckets),
|
|
710
|
+
"cacheCreationTokens": sum(b.cache_creation_tokens for b in buckets),
|
|
711
|
+
"cacheReadTokens": sum(b.cache_read_tokens for b in buckets),
|
|
712
|
+
"totalCost": sum(b.cost_usd for b in buckets),
|
|
713
|
+
"totalTokens": sum(b.total_tokens for b in buckets),
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
|
|
683
717
|
def _bucket_to_json(
|
|
684
718
|
buckets: list[BucketUsage],
|
|
685
719
|
*,
|
|
@@ -696,43 +730,27 @@ def _bucket_to_json(
|
|
|
696
730
|
cacheReadTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns.
|
|
697
731
|
Totals key order (note: totalCost BEFORE totalTokens, per ccusage).
|
|
698
732
|
"""
|
|
699
|
-
bucket_list
|
|
700
|
-
|
|
701
|
-
tot_output = 0
|
|
702
|
-
tot_cc = 0
|
|
703
|
-
tot_cr = 0
|
|
704
|
-
tot_cost = 0.0
|
|
705
|
-
tot_tokens = 0
|
|
706
|
-
for d in buckets:
|
|
707
|
-
bucket_list.append({
|
|
708
|
-
date_key: d.bucket,
|
|
709
|
-
"inputTokens": d.input_tokens,
|
|
710
|
-
"outputTokens": d.output_tokens,
|
|
711
|
-
"cacheCreationTokens": d.cache_creation_tokens,
|
|
712
|
-
"cacheReadTokens": d.cache_read_tokens,
|
|
713
|
-
"totalTokens": d.total_tokens,
|
|
714
|
-
"totalCost": d.cost_usd,
|
|
715
|
-
"modelsUsed": list(d.models),
|
|
716
|
-
"modelBreakdowns": list(d.model_breakdowns),
|
|
717
|
-
})
|
|
718
|
-
tot_input += d.input_tokens
|
|
719
|
-
tot_output += d.output_tokens
|
|
720
|
-
tot_cc += d.cache_creation_tokens
|
|
721
|
-
tot_cr += d.cache_read_tokens
|
|
722
|
-
tot_cost += d.cost_usd
|
|
723
|
-
tot_tokens += d.total_tokens
|
|
724
|
-
|
|
725
|
-
totals = {
|
|
726
|
-
"inputTokens": tot_input,
|
|
727
|
-
"outputTokens": tot_output,
|
|
728
|
-
"cacheCreationTokens": tot_cc,
|
|
729
|
-
"cacheReadTokens": tot_cr,
|
|
730
|
-
"totalCost": tot_cost,
|
|
731
|
-
"totalTokens": tot_tokens,
|
|
732
|
-
}
|
|
733
|
+
bucket_list = [_daily_row_dict(d, date_key=date_key) for d in buckets]
|
|
734
|
+
totals = _bucket_totals_dict(buckets)
|
|
733
735
|
return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
|
|
734
736
|
|
|
735
737
|
|
|
738
|
+
def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str:
|
|
739
|
+
"""Serialize ``[(label, [BucketUsage]), ...]`` to ``{projects:{label:[rows]},
|
|
740
|
+
totals}`` (upstream ccusage daily --instances shape). Caller passes the
|
|
741
|
+
disambiguated (unique, non-aliased) label per group; insertion order is
|
|
742
|
+
preserved as the JSON key order."""
|
|
743
|
+
projects: dict[str, Any] = {}
|
|
744
|
+
all_buckets: list = []
|
|
745
|
+
for label, buckets in project_groups:
|
|
746
|
+
projects[label] = [_daily_row_dict(b, date_key=date_key) for b in buckets]
|
|
747
|
+
all_buckets.extend(buckets)
|
|
748
|
+
return json.dumps(
|
|
749
|
+
{"projects": projects, "totals": _bucket_totals_dict(all_buckets)},
|
|
750
|
+
indent=2,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
|
|
736
754
|
def _weekly_to_json(
|
|
737
755
|
buckets: list[BucketUsage],
|
|
738
756
|
weeks: list[SubWeek],
|
|
@@ -1079,6 +1097,7 @@ def _render_bucket_table(
|
|
|
1079
1097
|
compact_split_fn: Callable[[str], str],
|
|
1080
1098
|
breakdown: bool = False,
|
|
1081
1099
|
compact: bool = False,
|
|
1100
|
+
project_groups=None,
|
|
1082
1101
|
) -> str:
|
|
1083
1102
|
"""Render bucket aggregates as a ccusage-style ANSI table.
|
|
1084
1103
|
|
|
@@ -1089,6 +1108,12 @@ def _render_bucket_table(
|
|
|
1089
1108
|
"YYYY\\n..." for compact-mode two-line display.
|
|
1090
1109
|
compact — force compact layout regardless of terminal width
|
|
1091
1110
|
(Session A `--compact` flag; spec §7.6.1).
|
|
1111
|
+
project_groups — when provided as ``[(label, [BucketUsage]), ...]``,
|
|
1112
|
+
render section layout: a ``Project: <label>`` header
|
|
1113
|
+
per group with one global Total footer summed across
|
|
1114
|
+
all groups (`daily -i/--instances`; issue #86 Session
|
|
1115
|
+
E). Default ``None`` = today's flat single-table
|
|
1116
|
+
behavior driven by ``buckets``.
|
|
1092
1117
|
|
|
1093
1118
|
Mirrors ccusage's ResponsiveTable behavior: single-line headers and dates
|
|
1094
1119
|
when content fits the terminal; falls back to two-line compact headers
|
|
@@ -1126,10 +1151,10 @@ def _render_bucket_table(
|
|
|
1126
1151
|
# ── Build raw rows: each is (cells, row_type) where a cell is the
|
|
1127
1152
|
# tuple (text, color_fn_or_none). `text` may contain '\n' for
|
|
1128
1153
|
# multi-line cells (Models list, compact Date).
|
|
1129
|
-
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
|
|
1154
|
+
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER, ROW_PROJECT = "data", "breakdown", "footer", "project"
|
|
1130
1155
|
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
1131
1156
|
|
|
1132
|
-
|
|
1157
|
+
def _emit_data_and_breakdown(d):
|
|
1133
1158
|
# ccusage formatModelsDisplayMultiline: uniq → sort alphabetical
|
|
1134
1159
|
short_models = sorted({_short_model_name(m) for m in d.models})
|
|
1135
1160
|
models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
|
|
@@ -1166,13 +1191,30 @@ def _render_bucket_table(
|
|
|
1166
1191
|
]
|
|
1167
1192
|
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
1168
1193
|
|
|
1194
|
+
if project_groups is not None:
|
|
1195
|
+
# Project-aware section layout (daily --instances): one cyan
|
|
1196
|
+
# `Project: <label>` header per group, then that group's normal
|
|
1197
|
+
# ROW_DATA (+ ROW_BREAKDOWN children); the single footer is computed
|
|
1198
|
+
# by flattening all groups' buckets.
|
|
1199
|
+
footer_buckets: list = []
|
|
1200
|
+
for label, group_buckets in project_groups:
|
|
1201
|
+
header_cells = [(f"Project: {label}", _cyan)] + [("", None)] * (num_cols - 1)
|
|
1202
|
+
raw_rows.append((header_cells, ROW_PROJECT))
|
|
1203
|
+
for d in group_buckets:
|
|
1204
|
+
_emit_data_and_breakdown(d)
|
|
1205
|
+
footer_buckets.append(d)
|
|
1206
|
+
else:
|
|
1207
|
+
for d in buckets:
|
|
1208
|
+
_emit_data_and_breakdown(d)
|
|
1209
|
+
footer_buckets = buckets
|
|
1210
|
+
|
|
1169
1211
|
# Total footer row — yellow on all populated cells.
|
|
1170
|
-
tot_input = sum(d.input_tokens for d in
|
|
1171
|
-
tot_output = sum(d.output_tokens for d in
|
|
1172
|
-
tot_cc = sum(d.cache_creation_tokens for d in
|
|
1173
|
-
tot_cr = sum(d.cache_read_tokens for d in
|
|
1174
|
-
tot_tokens = sum(d.total_tokens for d in
|
|
1175
|
-
tot_cost = sum(d.cost_usd for d in
|
|
1212
|
+
tot_input = sum(d.input_tokens for d in footer_buckets)
|
|
1213
|
+
tot_output = sum(d.output_tokens for d in footer_buckets)
|
|
1214
|
+
tot_cc = sum(d.cache_creation_tokens for d in footer_buckets)
|
|
1215
|
+
tot_cr = sum(d.cache_read_tokens for d in footer_buckets)
|
|
1216
|
+
tot_tokens = sum(d.total_tokens for d in footer_buckets)
|
|
1217
|
+
tot_cost = sum(d.cost_usd for d in footer_buckets)
|
|
1176
1218
|
footer_cells = [
|
|
1177
1219
|
("Total", _yellow),
|
|
1178
1220
|
("", None),
|
package/bin/cctally
CHANGED
|
@@ -368,6 +368,7 @@ CodexSessionUsage = _lib_aggregators.CodexSessionUsage
|
|
|
368
368
|
ClaudeSessionUsage = _lib_aggregators.ClaudeSessionUsage
|
|
369
369
|
_aggregate_buckets = _lib_aggregators._aggregate_buckets
|
|
370
370
|
_aggregate_daily = _lib_aggregators._aggregate_daily
|
|
371
|
+
_aggregate_daily_by_project = _lib_aggregators._aggregate_daily_by_project
|
|
371
372
|
_aggregate_monthly = _lib_aggregators._aggregate_monthly
|
|
372
373
|
_aggregate_weekly = _lib_aggregators._aggregate_weekly
|
|
373
374
|
_aggregate_codex_buckets = _lib_aggregators._aggregate_codex_buckets
|
|
@@ -408,7 +409,10 @@ build_codex_session_view = _lib_view_models.build_codex_session_view
|
|
|
408
409
|
_lib_render = _load_sibling("_lib_render")
|
|
409
410
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
410
411
|
_render_blocks_table = _lib_render._render_blocks_table
|
|
412
|
+
_daily_row_dict = _lib_render._daily_row_dict
|
|
413
|
+
_bucket_totals_dict = _lib_render._bucket_totals_dict
|
|
411
414
|
_bucket_to_json = _lib_render._bucket_to_json
|
|
415
|
+
_bucket_by_project_to_json = _lib_render._bucket_by_project_to_json
|
|
412
416
|
_weekly_to_json = _lib_render._weekly_to_json
|
|
413
417
|
_daily_compact_split = _lib_render._daily_compact_split
|
|
414
418
|
_monthly_compact_split = _lib_render._monthly_compact_split
|
|
@@ -1538,16 +1542,27 @@ def _usage_entry_from_joined(je) -> "UsageEntry":
|
|
|
1538
1542
|
The joined-entry shape already carries ``source_path``, ``cost_usd``,
|
|
1539
1543
|
and the per-token integers; this adapter is pure shape conversion
|
|
1540
1544
|
with no cache re-read.
|
|
1545
|
+
|
|
1546
|
+
Non-token ``usage`` extras (``je.usage_extra``) — notably ``speed`` —
|
|
1547
|
+
are merged AFTER the four token keys, mirroring ``iter_entries``'
|
|
1548
|
+
``usage.update(json.loads(...))``. Without this, the project-axis
|
|
1549
|
+
``daily`` path (and the diff/report joined-entry consumers) would
|
|
1550
|
+
drop the fast-tier flag and render ``<model>`` where the normal path
|
|
1551
|
+
renders ``<model>-fast``. The write-side strips the four token keys
|
|
1552
|
+
from ``usage_extra_json`` so the merge never shadows the integers.
|
|
1541
1553
|
"""
|
|
1554
|
+
usage = {
|
|
1555
|
+
"input_tokens": je.input_tokens,
|
|
1556
|
+
"output_tokens": je.output_tokens,
|
|
1557
|
+
"cache_creation_input_tokens": je.cache_creation_tokens,
|
|
1558
|
+
"cache_read_input_tokens": je.cache_read_tokens,
|
|
1559
|
+
}
|
|
1560
|
+
if je.usage_extra:
|
|
1561
|
+
usage.update(je.usage_extra)
|
|
1542
1562
|
return UsageEntry(
|
|
1543
1563
|
timestamp=je.timestamp,
|
|
1544
1564
|
model=je.model,
|
|
1545
|
-
usage=
|
|
1546
|
-
"input_tokens": je.input_tokens,
|
|
1547
|
-
"output_tokens": je.output_tokens,
|
|
1548
|
-
"cache_creation_input_tokens": je.cache_creation_tokens,
|
|
1549
|
-
"cache_read_input_tokens": je.cache_read_tokens,
|
|
1550
|
-
},
|
|
1565
|
+
usage=usage,
|
|
1551
1566
|
cost_usd=je.cost_usd,
|
|
1552
1567
|
source_path=je.source_path,
|
|
1553
1568
|
)
|
|
@@ -1586,6 +1601,42 @@ def _project_filter_matches(key, project_patterns):
|
|
|
1586
1601
|
return any((p in dname) or (p in pname) for p in project_patterns)
|
|
1587
1602
|
|
|
1588
1603
|
|
|
1604
|
+
def _parse_project_aliases(raw):
|
|
1605
|
+
"""Parse a ``--project-aliases`` value into ``{key: label}``.
|
|
1606
|
+
|
|
1607
|
+
Form: comma-separated ``key=Label`` pairs. Whitespace around keys, labels,
|
|
1608
|
+
and pairs is stripped; segments without ``=`` or with an empty key/label are
|
|
1609
|
+
dropped (ported from ccusage's tolerant parser). ``None``/"" → ``{}``.
|
|
1610
|
+
"""
|
|
1611
|
+
result: dict[str, str] = {}
|
|
1612
|
+
if not raw:
|
|
1613
|
+
return result
|
|
1614
|
+
for pair in raw.split(","):
|
|
1615
|
+
pair = pair.strip()
|
|
1616
|
+
if not pair or "=" not in pair:
|
|
1617
|
+
continue
|
|
1618
|
+
k, _, v = pair.partition("=")
|
|
1619
|
+
k = k.strip()
|
|
1620
|
+
v = v.strip()
|
|
1621
|
+
if k and v:
|
|
1622
|
+
result[k] = v
|
|
1623
|
+
return result
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _alias_for(key, aliases):
|
|
1627
|
+
"""Return the alias label for a ProjectKey, or None.
|
|
1628
|
+
|
|
1629
|
+
Looks up ``aliases`` by ``display_key``, then ``git_root``, then
|
|
1630
|
+
``bucket_path`` (first hit). Display-only — never alters JSON keys.
|
|
1631
|
+
"""
|
|
1632
|
+
if not aliases:
|
|
1633
|
+
return None
|
|
1634
|
+
for cand in (key.display_key, key.git_root, key.bucket_path):
|
|
1635
|
+
if cand and cand in aliases:
|
|
1636
|
+
return aliases[cand]
|
|
1637
|
+
return None
|
|
1638
|
+
|
|
1639
|
+
|
|
1589
1640
|
def _emit_diff_debug_samples(args, window_a, window_b) -> None:
|
|
1590
1641
|
"""Two-window diff report (spec §7.2.2 Pattern D).
|
|
1591
1642
|
|
|
@@ -4512,6 +4563,29 @@ def _parse_cli_date_range(
|
|
|
4512
4563
|
return range_start, range_end
|
|
4513
4564
|
|
|
4514
4565
|
|
|
4566
|
+
def _emit_daily_view_table_or_json(view, args):
|
|
4567
|
+
"""Order + emit a DailyView as the flat daily table or {daily} JSON.
|
|
4568
|
+
|
|
4569
|
+
Shared by cmd_daily's default path and its -p-only (filter, no grouping)
|
|
4570
|
+
path so the two cannot drift. Body is exactly the default path's order +
|
|
4571
|
+
emit tail; callers keep their own --format share gate upstream of this.
|
|
4572
|
+
"""
|
|
4573
|
+
days = list(reversed(view.aggregated))
|
|
4574
|
+
if args.order == "desc":
|
|
4575
|
+
days = list(reversed(days))
|
|
4576
|
+
if args.json:
|
|
4577
|
+
print(_bucket_to_json(days, list_key="daily", date_key="date"))
|
|
4578
|
+
return
|
|
4579
|
+
print(_render_bucket_table(
|
|
4580
|
+
days,
|
|
4581
|
+
first_col_name="Date",
|
|
4582
|
+
title_suffix="Daily",
|
|
4583
|
+
compact_split_fn=_daily_compact_split,
|
|
4584
|
+
breakdown=args.breakdown,
|
|
4585
|
+
compact=getattr(args, "compact", False),
|
|
4586
|
+
))
|
|
4587
|
+
|
|
4588
|
+
|
|
4515
4589
|
def cmd_daily(args: argparse.Namespace) -> int:
|
|
4516
4590
|
"""Show usage report grouped by display-timezone date."""
|
|
4517
4591
|
_share_validate_args(args)
|
|
@@ -4533,6 +4607,109 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
4533
4607
|
return range
|
|
4534
4608
|
range_start, range_end = range
|
|
4535
4609
|
|
|
4610
|
+
# ── Project-axis path (issue #86 Session E / T1.11) ────────────────────
|
|
4611
|
+
# Gated by -i/--instances or -p/--project; the default path below is
|
|
4612
|
+
# untouched/byte-stable. Mirrors cmd_project's I/O-layer git-root
|
|
4613
|
+
# resolution + substring-OR-path filter.
|
|
4614
|
+
aliases = _parse_project_aliases(getattr(args, "project_aliases", None))
|
|
4615
|
+
project_patterns = [p.lower() for p in (getattr(args, "project", None) or [])]
|
|
4616
|
+
|
|
4617
|
+
if getattr(args, "instances", False) or project_patterns:
|
|
4618
|
+
joined = list(get_claude_session_entries(range_start, range_end))
|
|
4619
|
+
resolver_cache: dict = {}
|
|
4620
|
+
keyed: list = [] # [(ProjectKey, UsageEntry)] — for -i grouping
|
|
4621
|
+
filtered_uentries: list = [] # UsageEntry — for -p-only / --format / debug
|
|
4622
|
+
for je in joined:
|
|
4623
|
+
if je.model == "<synthetic>":
|
|
4624
|
+
continue
|
|
4625
|
+
key = _resolve_project_key(je.project_path, "git-root", resolver_cache)
|
|
4626
|
+
if project_patterns and not _project_filter_matches(key, project_patterns):
|
|
4627
|
+
continue
|
|
4628
|
+
ue = _usage_entry_from_joined(je)
|
|
4629
|
+
keyed.append((key, ue))
|
|
4630
|
+
filtered_uentries.append(ue)
|
|
4631
|
+
|
|
4632
|
+
# Debug scope = the filtered entries (mirrors cmd_project).
|
|
4633
|
+
_emit_debug_samples_if_set(args, filtered_uentries, command_label="daily")
|
|
4634
|
+
|
|
4635
|
+
# --format share gate: -i is a no-op (no project-section share render),
|
|
4636
|
+
# but -p IS honored by building the snapshot from the filtered view.
|
|
4637
|
+
if getattr(args, "format", None):
|
|
4638
|
+
view = build_daily_view(filtered_uentries, now_utc=_command_as_of(),
|
|
4639
|
+
display_tz=tz, mode=args.mode)
|
|
4640
|
+
display_tz_str = _share_display_tz_label(tz)
|
|
4641
|
+
snap = _build_daily_snapshot(
|
|
4642
|
+
view, period_start=range_start, period_end=range_end,
|
|
4643
|
+
display_tz=display_tz_str, version=_share_resolve_version(),
|
|
4644
|
+
theme=args.theme, reveal_projects=args.reveal_projects,
|
|
4645
|
+
)
|
|
4646
|
+
if args.order == "desc":
|
|
4647
|
+
snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
|
|
4648
|
+
_share_render_and_emit(snap, args)
|
|
4649
|
+
return 0
|
|
4650
|
+
|
|
4651
|
+
if getattr(args, "instances", False):
|
|
4652
|
+
groups = _aggregate_daily_by_project(keyed, tz=tz, mode=args.mode)
|
|
4653
|
+
aug = _project_disambiguate_labels(
|
|
4654
|
+
[{"key": k, "cost_usd": sum(b.cost_usd for b in bl)}
|
|
4655
|
+
for k, bl in groups]
|
|
4656
|
+
)
|
|
4657
|
+
json_groups: list = []
|
|
4658
|
+
table_groups: list = []
|
|
4659
|
+
# `_project_disambiguate_labels` only suffixes the immediate
|
|
4660
|
+
# parent-dir basename, so two distinct git-roots like
|
|
4661
|
+
# `/a/x/app` + `/b/x/app` both resolve to `app (x)`. Guarantee
|
|
4662
|
+
# per-group JSON-key uniqueness with a counter suffix on any
|
|
4663
|
+
# residual collision — otherwise `_bucket_by_project_to_json`'s
|
|
4664
|
+
# `projects[label] = ...` silently overwrites the earlier group
|
|
4665
|
+
# (data loss in --json). The table_label derives from the now-
|
|
4666
|
+
# unique json_label, so section headers stay distinct too.
|
|
4667
|
+
# `json_label`s are unique by construction (the `(#N)` counter
|
|
4668
|
+
# above). Table labels, however, can re-collide: `_alias_for`
|
|
4669
|
+
# matches on `display_key` first, so a basename alias like
|
|
4670
|
+
# `--project-aliases app=Alias` maps BOTH same-basename git-roots
|
|
4671
|
+
# to "Alias" — re-merging the exact sections this feature
|
|
4672
|
+
# disambiguates. Apply the SAME `(#N)` counter to table labels so
|
|
4673
|
+
# the two distinct-total sections stay tellable apart (JSON keys
|
|
4674
|
+
# are untouched — they use the non-aliased `json_label`).
|
|
4675
|
+
seen_json_labels: dict[str, int] = {}
|
|
4676
|
+
seen_table_labels: dict[str, int] = {}
|
|
4677
|
+
for i, (k, bl) in enumerate(groups):
|
|
4678
|
+
ordered = list(reversed(bl)) if args.order == "desc" else bl
|
|
4679
|
+
base_json_label = aug.get(i, k.display_key)
|
|
4680
|
+
n = seen_json_labels.get(base_json_label, 0) + 1
|
|
4681
|
+
seen_json_labels[base_json_label] = n
|
|
4682
|
+
json_label = (
|
|
4683
|
+
base_json_label if n == 1 else f"{base_json_label} (#{n})"
|
|
4684
|
+
)
|
|
4685
|
+
base_table_label = _alias_for(k, aliases) or json_label
|
|
4686
|
+
nt = seen_table_labels.get(base_table_label, 0) + 1
|
|
4687
|
+
seen_table_labels[base_table_label] = nt
|
|
4688
|
+
table_label = (
|
|
4689
|
+
base_table_label if nt == 1
|
|
4690
|
+
else f"{base_table_label} (#{nt})"
|
|
4691
|
+
)
|
|
4692
|
+
json_groups.append((json_label, ordered))
|
|
4693
|
+
table_groups.append((table_label, ordered))
|
|
4694
|
+
if args.json:
|
|
4695
|
+
print(_bucket_by_project_to_json(json_groups, date_key="date"))
|
|
4696
|
+
return 0
|
|
4697
|
+
print(_render_bucket_table(
|
|
4698
|
+
[], first_col_name="Date", title_suffix="Daily",
|
|
4699
|
+
compact_split_fn=_daily_compact_split,
|
|
4700
|
+
breakdown=args.breakdown,
|
|
4701
|
+
compact=getattr(args, "compact", False),
|
|
4702
|
+
project_groups=table_groups,
|
|
4703
|
+
))
|
|
4704
|
+
return 0
|
|
4705
|
+
|
|
4706
|
+
# -p only (no -i): filter-only → normal date-aggregated daily output.
|
|
4707
|
+
view = build_daily_view(filtered_uentries, now_utc=_command_as_of(),
|
|
4708
|
+
display_tz=tz, mode=args.mode)
|
|
4709
|
+
_emit_daily_view_table_or_json(view, args)
|
|
4710
|
+
return 0
|
|
4711
|
+
|
|
4712
|
+
# ── Default path (UNCHANGED) ───────────────────────────────────────────
|
|
4536
4713
|
# Collect entries.
|
|
4537
4714
|
all_entries = get_entries(range_start, range_end)
|
|
4538
4715
|
|
|
@@ -4549,10 +4726,6 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
4549
4726
|
# `_aggregate_daily` call is the same one we used inline.
|
|
4550
4727
|
view = build_daily_view(all_entries, now_utc=_command_as_of(),
|
|
4551
4728
|
display_tz=tz, mode=args.mode)
|
|
4552
|
-
# `_aggregate_daily` returned ascending order; build_daily_view stores
|
|
4553
|
-
# `aggregated` newest-first. CLI's default order is ascending, so
|
|
4554
|
-
# re-reverse to match the prior on-the-wire shape.
|
|
4555
|
-
days = list(reversed(view.aggregated))
|
|
4556
4729
|
|
|
4557
4730
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
4558
4731
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
@@ -4583,23 +4756,10 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
4583
4756
|
_share_render_and_emit(snap, args)
|
|
4584
4757
|
return 0
|
|
4585
4758
|
|
|
4586
|
-
#
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
if args.json:
|
|
4591
|
-
print(_bucket_to_json(days, list_key="daily", date_key="date"))
|
|
4592
|
-
return 0
|
|
4593
|
-
|
|
4594
|
-
# Table output.
|
|
4595
|
-
print(_render_bucket_table(
|
|
4596
|
-
days,
|
|
4597
|
-
first_col_name="Date",
|
|
4598
|
-
title_suffix="Daily",
|
|
4599
|
-
compact_split_fn=_daily_compact_split,
|
|
4600
|
-
breakdown=args.breakdown,
|
|
4601
|
-
compact=getattr(args, "compact", False),
|
|
4602
|
-
))
|
|
4759
|
+
# Order + emit the flat daily table / {daily} JSON. Extracted into
|
|
4760
|
+
# `_emit_daily_view_table_or_json` so this default path and the
|
|
4761
|
+
# -p-only (filter, no grouping) path above stay byte-identical.
|
|
4762
|
+
_emit_daily_view_table_or_json(view, args)
|
|
4603
4763
|
return 0
|
|
4604
4764
|
|
|
4605
4765
|
|
|
@@ -10173,6 +10333,8 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
|
|
|
10173
10333
|
cctally daily --since 20260414 --breakdown
|
|
10174
10334
|
cctally daily --since 20260414 --json
|
|
10175
10335
|
cctally daily --order desc
|
|
10336
|
+
cctally daily --instances
|
|
10337
|
+
cctally daily -i --project-aliases repos=Repos
|
|
10176
10338
|
"""),
|
|
10177
10339
|
)
|
|
10178
10340
|
p.add_argument(
|
|
@@ -10210,6 +10372,28 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
|
|
|
10210
10372
|
help="Display timezone: local, utc, or IANA name. "
|
|
10211
10373
|
"Overrides config display.tz for this call.",
|
|
10212
10374
|
)
|
|
10375
|
+
p.add_argument(
|
|
10376
|
+
"-i", "--instances",
|
|
10377
|
+
action="store_true",
|
|
10378
|
+
default=False,
|
|
10379
|
+
help="Group the report by project (git-root).",
|
|
10380
|
+
)
|
|
10381
|
+
p.add_argument(
|
|
10382
|
+
"-p", "--project",
|
|
10383
|
+
action="append",
|
|
10384
|
+
default=None,
|
|
10385
|
+
metavar="PATTERN",
|
|
10386
|
+
help="Filter to projects matching PATTERN (substring of the project "
|
|
10387
|
+
"label or path; repeatable, OR semantics).",
|
|
10388
|
+
)
|
|
10389
|
+
p.add_argument(
|
|
10390
|
+
"--project-aliases",
|
|
10391
|
+
dest="project_aliases",
|
|
10392
|
+
default=None,
|
|
10393
|
+
metavar="PAIRS",
|
|
10394
|
+
help="Comma-separated key=Label pairs overriding project display "
|
|
10395
|
+
"labels (e.g. cctally-dev=Tracker). Display-only.",
|
|
10396
|
+
)
|
|
10213
10397
|
_add_ccusage_alias_args(p, ansi_emit=False)
|
|
10214
10398
|
_add_mode_arg(p)
|
|
10215
10399
|
_add_share_args(p)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|