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 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
@@ -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
@@ -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],
@@ -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: list[dict[str, Any]] = []
700
- tot_input = 0
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
- for d in buckets:
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 buckets)
1171
- tot_output = sum(d.output_tokens for d in buckets)
1172
- tot_cc = sum(d.cache_creation_tokens for d in buckets)
1173
- tot_cr = sum(d.cache_read_tokens for d in buckets)
1174
- tot_tokens = sum(d.total_tokens for d in buckets)
1175
- tot_cost = sum(d.cost_usd for d in buckets)
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
- # Apply sort order.
4587
- if args.order == "desc":
4588
- days = list(reversed(days))
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.17.0",
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": {