cctally 1.11.1 → 1.13.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 +62 -0
- package/bin/_cctally_cache.py +342 -113
- package/bin/_cctally_config.py +55 -9
- package/bin/_cctally_core.py +51 -0
- package/bin/_cctally_db.py +1654 -5
- package/bin/_cctally_record.py +1 -1
- package/bin/_cctally_setup.py +11 -1
- package/bin/_lib_diff_kernel.py +14 -4
- package/bin/_lib_jsonl.py +88 -17
- package/bin/_lib_render.py +193 -22
- package/bin/_lib_subscription_weeks.py +21 -3
- package/bin/cctally +1278 -85
- package/package.json +1 -1
package/bin/cctally
CHANGED
|
@@ -288,6 +288,26 @@ _compute_display_block = _lib_display_tz._compute_display_block
|
|
|
288
288
|
format_display_dt = _lib_display_tz.format_display_dt
|
|
289
289
|
_argparse_tz = _lib_display_tz._argparse_tz
|
|
290
290
|
|
|
291
|
+
|
|
292
|
+
def _nonneg_int(raw: str) -> int:
|
|
293
|
+
"""argparse `type=` validator for non-negative integer flags (issue #89).
|
|
294
|
+
|
|
295
|
+
Used by ``--debug-samples`` so a negative N is rejected at parse time
|
|
296
|
+
rather than silently coerced inside the helper. Raises
|
|
297
|
+
``argparse.ArgumentTypeError`` so argparse surfaces the message under
|
|
298
|
+
the standard ``argument <flag>:`` prefix.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
n = int(raw)
|
|
302
|
+
except ValueError:
|
|
303
|
+
raise argparse.ArgumentTypeError(
|
|
304
|
+
f"must be a non-negative integer, got '{raw}'"
|
|
305
|
+
)
|
|
306
|
+
if n < 0:
|
|
307
|
+
raise argparse.ArgumentTypeError(f"must be >= 0, got {n}")
|
|
308
|
+
return n
|
|
309
|
+
|
|
310
|
+
|
|
291
311
|
_lib_alerts_payload = _load_sibling("_lib_alerts_payload")
|
|
292
312
|
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
293
313
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
@@ -995,6 +1015,648 @@ def _sum_cost_for_range(
|
|
|
995
1015
|
return total
|
|
996
1016
|
|
|
997
1017
|
|
|
1018
|
+
def _bridge_z_into_tz(args: argparse.Namespace,
|
|
1019
|
+
config: "dict | None" = None) -> None:
|
|
1020
|
+
"""Promote ``-z`` / ``--timezone`` onto ``args.tz`` when ``--tz`` is unset.
|
|
1021
|
+
|
|
1022
|
+
Session A (spec §7.2) defines a 4-rung precedence for Claude display tz:
|
|
1023
|
+
``--tz`` > ``-z --timezone`` > ``config.display.tz`` > host-local. The
|
|
1024
|
+
existing ``resolve_display_tz`` reads only ``args.tz`` and config; the
|
|
1025
|
+
minimal-invasive way to fold in ``-z`` without modifying the shared
|
|
1026
|
+
pure-fn layer is to set ``args.tz`` here when ``--tz`` wasn't
|
|
1027
|
+
supplied. This mutates the namespace and is safe because each cmd_*
|
|
1028
|
+
receives a fresh ``args`` per invocation.
|
|
1029
|
+
|
|
1030
|
+
Internally delegates to :func:`_resolve_claude_tz_name`, which
|
|
1031
|
+
encodes the 4-rung precedence and returns the tz-name string (or
|
|
1032
|
+
``None`` for host-local fallback). This wires the resolver into the
|
|
1033
|
+
production path so the 7-case test contract (spec §9.2a) is
|
|
1034
|
+
exercised end-to-end, not only by unit tests (Review-A P2-A).
|
|
1035
|
+
|
|
1036
|
+
Validates the bridged rung-2 value via ``_argparse_tz`` so an
|
|
1037
|
+
invalid tz name from ``-z`` surfaces the same canonical error as
|
|
1038
|
+
``--tz`` (argparse-time validation can't run on the alias because
|
|
1039
|
+
the alias flag is plain string, not ``type=_argparse_tz``). The
|
|
1040
|
+
resolver returns the raw string, so we revalidate here on the path
|
|
1041
|
+
that actually bridges into ``args.tz``.
|
|
1042
|
+
|
|
1043
|
+
Error attribution is rung-aware (#90). A malformed value typed on
|
|
1044
|
+
the command line (rung-2, ``-z``/``--timezone``) is a usage error
|
|
1045
|
+
and hard-fails with the argparse-style message + exit 2. A malformed
|
|
1046
|
+
``config.display.tz`` (rung-3) is NOT promoted or hard-failed here:
|
|
1047
|
+
it falls through to ``resolve_display_tz`` / ``get_display_tz_pref``
|
|
1048
|
+
downstream, which warn once and default to host-local (exit 0). That
|
|
1049
|
+
restores the pre-Session-A contract and matches how codex commands
|
|
1050
|
+
and the dashboard already treat a malformed persisted tz pref.
|
|
1051
|
+
"""
|
|
1052
|
+
# Short-circuit: --tz already set wins all rungs (the resolver
|
|
1053
|
+
# returns args.tz unchanged anyway, but skipping avoids touching
|
|
1054
|
+
# the namespace and re-validating an already-canonical value).
|
|
1055
|
+
if getattr(args, "tz", None) is not None:
|
|
1056
|
+
return
|
|
1057
|
+
resolved = _resolve_claude_tz_name(args, config)
|
|
1058
|
+
if resolved is None:
|
|
1059
|
+
return
|
|
1060
|
+
# ``args.tz`` is None here (short-circuited above), so ``resolved``
|
|
1061
|
+
# came from rung-2 (``-z``/``--timezone``) iff ``args.timezone`` is
|
|
1062
|
+
# set; otherwise it is the rung-3 ``config.display.tz`` value.
|
|
1063
|
+
from_cli_alias = getattr(args, "timezone", None) is not None
|
|
1064
|
+
# Validate via _argparse_tz so an invalid --timezone surfaces the
|
|
1065
|
+
# canonical argparse-style error (matches --tz's type-check).
|
|
1066
|
+
try:
|
|
1067
|
+
canonical = _argparse_tz(resolved)
|
|
1068
|
+
except argparse.ArgumentTypeError as exc:
|
|
1069
|
+
if from_cli_alias:
|
|
1070
|
+
# Rung-2: a value the user typed on the command line. Surface
|
|
1071
|
+
# the same error --tz gives at parse time. We can't call
|
|
1072
|
+
# parser.error from here, so emit-and-raise via SystemExit(2)
|
|
1073
|
+
# for argparse-shape parity.
|
|
1074
|
+
eprint(f"cctally: argument -z/--timezone: {exc}")
|
|
1075
|
+
raise SystemExit(2) from exc
|
|
1076
|
+
# Rung-3: malformed config.display.tz with no CLI tz flag. Don't
|
|
1077
|
+
# mis-attribute the error to -z/--timezone or hard-fail (#90).
|
|
1078
|
+
# Leave args.tz unset; resolve_display_tz / get_display_tz_pref
|
|
1079
|
+
# warn once and default to host-local downstream (exit 0).
|
|
1080
|
+
return
|
|
1081
|
+
args.tz = canonical
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _resolve_claude_tz_name(args: argparse.Namespace,
|
|
1085
|
+
config: "dict | None") -> "str | None":
|
|
1086
|
+
"""Resolve display-tz precedence for Claude reporting commands.
|
|
1087
|
+
|
|
1088
|
+
Returns the tz name string (or ``None`` for host-local fallback).
|
|
1089
|
+
Precedence (top wins):
|
|
1090
|
+
1. ``--tz`` flag (canonical cctally flag).
|
|
1091
|
+
2. ``-z`` / ``--timezone`` flag (ccusage-codex sharedArgs alias).
|
|
1092
|
+
3. ``config.display.tz`` (persisted user pref).
|
|
1093
|
+
4. ``None`` → host-local (existing fallback in ``resolve_display_tz``).
|
|
1094
|
+
|
|
1095
|
+
Spec §7.2 (issue #86 Session A). Mirrors ``_resolve_codex_tz_name``'s
|
|
1096
|
+
structural shape for code-review familiarity, but the rung order
|
|
1097
|
+
diverges: codex's resolver falls back to upstream's ``--timezone``
|
|
1098
|
+
ONLY when neither ``--tz`` nor ``display.tz`` is set, while Claude's
|
|
1099
|
+
Session A contract puts ``-z --timezone`` ahead of ``display.tz``.
|
|
1100
|
+
Both shapes are intentional — the Codex helper preserves drop-in
|
|
1101
|
+
parity with upstream's ``ccusage-codex sharedArgs``; the Claude
|
|
1102
|
+
helper expresses the Session A precedence the spec promises.
|
|
1103
|
+
"""
|
|
1104
|
+
tz = getattr(args, "tz", None)
|
|
1105
|
+
if tz is not None:
|
|
1106
|
+
return tz
|
|
1107
|
+
tzname = getattr(args, "timezone", None)
|
|
1108
|
+
if tzname is not None:
|
|
1109
|
+
return tzname
|
|
1110
|
+
display = (config or {}).get("display") or {}
|
|
1111
|
+
cfg_tz = display.get("tz")
|
|
1112
|
+
if cfg_tz:
|
|
1113
|
+
return cfg_tz
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
# ---------------------------------------------------------------------------
|
|
1118
|
+
# Issue #89 — Real --debug diagnostic-sample emission
|
|
1119
|
+
# ---------------------------------------------------------------------------
|
|
1120
|
+
# Spec: docs/superpowers/specs/2026-05-23-issue-89-debug-sample-emission.md
|
|
1121
|
+
# Replaces the §7.6.2 placeholder note with a real ccusage-parity
|
|
1122
|
+
# "Pricing Mismatch Debug Report" on stderr.
|
|
1123
|
+
|
|
1124
|
+
@dataclass
|
|
1125
|
+
class _MismatchModelStat:
|
|
1126
|
+
total: int = 0
|
|
1127
|
+
matches: int = 0
|
|
1128
|
+
mismatches: int = 0
|
|
1129
|
+
avg_percent_diff: float = 0.0
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
@dataclass
|
|
1133
|
+
class _MismatchSample:
|
|
1134
|
+
file: str
|
|
1135
|
+
timestamp: str
|
|
1136
|
+
model: str
|
|
1137
|
+
original_cost: float
|
|
1138
|
+
calculated_cost: float
|
|
1139
|
+
difference: float
|
|
1140
|
+
percent_diff: float
|
|
1141
|
+
usage: dict
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
@dataclass
|
|
1145
|
+
class _MismatchStats:
|
|
1146
|
+
command_label: str | None = None
|
|
1147
|
+
total_entries: int = 0
|
|
1148
|
+
entries_with_both: int = 0
|
|
1149
|
+
matches: int = 0
|
|
1150
|
+
mismatches: int = 0
|
|
1151
|
+
model_stats: dict = field(default_factory=dict)
|
|
1152
|
+
discrepancies: list = field(default_factory=list)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _compute_pricing_mismatch_stats(entries):
|
|
1156
|
+
"""Walk ``entries: Iterable[UsageEntry]`` and compute the mismatch stats
|
|
1157
|
+
that ``_render_pricing_mismatch_report`` consumes.
|
|
1158
|
+
|
|
1159
|
+
Mirrors ccusage upstream's ``detectMismatches``
|
|
1160
|
+
(``~/.npm/_npx/.../node_modules/ccusage/dist/debug-DvI5DUKR.js:6-95``):
|
|
1161
|
+
|
|
1162
|
+
- An entry counts toward ``entries_with_both`` iff its ``cost_usd``
|
|
1163
|
+
is not None AND the model has pricing in ``CLAUDE_MODEL_PRICING``.
|
|
1164
|
+
- Threshold: ``percent_diff < 0.1`` is a match; anything else is a
|
|
1165
|
+
mismatch and gets appended to ``discrepancies`` in iteration order.
|
|
1166
|
+
- ``percent_diff`` is ``0.0`` when recorded cost is zero (parity with
|
|
1167
|
+
upstream's divide-by-zero guard).
|
|
1168
|
+
- Per-model ``avg_percent_diff`` updated by streaming mean recurrence
|
|
1169
|
+
to match upstream's per-row accumulation.
|
|
1170
|
+
"""
|
|
1171
|
+
stats = _MismatchStats()
|
|
1172
|
+
for entry in entries:
|
|
1173
|
+
# P1.1 (issue #89 review-loop): mirror ccusage upstream's
|
|
1174
|
+
# ``detectMismatches`` precondition filter at debug-DvI5DUKR.js:42
|
|
1175
|
+
# — synthetic entries are excluded from total_entries AND skip the
|
|
1176
|
+
# _resolve_model_pricing call (which would otherwise emit a
|
|
1177
|
+
# ``[cost] unknown model: <synthetic>`` warning and mutate the
|
|
1178
|
+
# module-level _unknown_model_warnings set, suppressing future
|
|
1179
|
+
# legitimate emissions).
|
|
1180
|
+
if entry.model == "<synthetic>":
|
|
1181
|
+
continue
|
|
1182
|
+
stats.total_entries += 1
|
|
1183
|
+
if entry.cost_usd is None:
|
|
1184
|
+
continue
|
|
1185
|
+
if _resolve_model_pricing(entry.model) is None:
|
|
1186
|
+
continue
|
|
1187
|
+
stats.entries_with_both += 1
|
|
1188
|
+
calculated = _calculate_entry_cost(
|
|
1189
|
+
entry.model, entry.usage, mode="calculate",
|
|
1190
|
+
)
|
|
1191
|
+
original = float(entry.cost_usd)
|
|
1192
|
+
difference = abs(original - calculated)
|
|
1193
|
+
percent_diff = (difference / original * 100) if original > 0 else 0.0
|
|
1194
|
+
ms = stats.model_stats.setdefault(entry.model, _MismatchModelStat())
|
|
1195
|
+
ms.total += 1
|
|
1196
|
+
if percent_diff < 0.1:
|
|
1197
|
+
stats.matches += 1
|
|
1198
|
+
ms.matches += 1
|
|
1199
|
+
else:
|
|
1200
|
+
stats.mismatches += 1
|
|
1201
|
+
ms.mismatches += 1
|
|
1202
|
+
stats.discrepancies.append(_MismatchSample(
|
|
1203
|
+
file=os.path.basename(entry.source_path),
|
|
1204
|
+
timestamp=entry.timestamp.isoformat(),
|
|
1205
|
+
model=entry.model,
|
|
1206
|
+
original_cost=original,
|
|
1207
|
+
calculated_cost=calculated,
|
|
1208
|
+
difference=difference,
|
|
1209
|
+
percent_diff=percent_diff,
|
|
1210
|
+
usage=dict(entry.usage),
|
|
1211
|
+
))
|
|
1212
|
+
# Streaming-mean update for avg_percent_diff (matches upstream).
|
|
1213
|
+
ms.avg_percent_diff = (
|
|
1214
|
+
ms.avg_percent_diff * (ms.total - 1) + percent_diff
|
|
1215
|
+
) / ms.total
|
|
1216
|
+
return stats
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _render_pricing_mismatch_report(stats, sample_limit):
|
|
1220
|
+
"""Return the report as a list of stderr lines (caller prints \\n-joined).
|
|
1221
|
+
|
|
1222
|
+
Matches ccusage upstream's ``printMismatchReport``
|
|
1223
|
+
(debug-DvI5DUKR.js:97-145) including:
|
|
1224
|
+
- Early-return ``"No pricing data found to analyze."`` when
|
|
1225
|
+
``entries_with_both == 0``.
|
|
1226
|
+
- Model Statistics + Sample Discrepancies sections omitted when
|
|
1227
|
+
``mismatches == 0``.
|
|
1228
|
+
- Models with ``mismatches == 0`` omitted from Model Statistics.
|
|
1229
|
+
- Sample header prints the requested ``sample_limit`` (not min with
|
|
1230
|
+
discrepancies length).
|
|
1231
|
+
Adds ONE intentional non-upstream line: ``Command: cctally <label>``
|
|
1232
|
+
under the header so the report self-identifies (issue #89 acceptance
|
|
1233
|
+
re: "command in each sample's context").
|
|
1234
|
+
"""
|
|
1235
|
+
out = []
|
|
1236
|
+
if stats.entries_with_both == 0:
|
|
1237
|
+
out.append("No pricing data found to analyze.")
|
|
1238
|
+
return out
|
|
1239
|
+
|
|
1240
|
+
match_rate = stats.matches / stats.entries_with_both * 100
|
|
1241
|
+
out.append("")
|
|
1242
|
+
out.append("=== Pricing Mismatch Debug Report ===")
|
|
1243
|
+
if stats.command_label:
|
|
1244
|
+
out.append(f"Command: cctally {stats.command_label}")
|
|
1245
|
+
out.append(f"Total entries processed: {stats.total_entries:,}")
|
|
1246
|
+
out.append(
|
|
1247
|
+
f"Entries with both costUSD and model: {stats.entries_with_both:,}"
|
|
1248
|
+
)
|
|
1249
|
+
out.append(f"Matches (within 0.1%): {stats.matches:,}")
|
|
1250
|
+
out.append(f"Mismatches: {stats.mismatches:,}")
|
|
1251
|
+
out.append(f"Match rate: {match_rate:.2f}%")
|
|
1252
|
+
|
|
1253
|
+
if stats.mismatches > 0 and stats.model_stats:
|
|
1254
|
+
out.append("")
|
|
1255
|
+
out.append("=== Model Statistics ===")
|
|
1256
|
+
sorted_models = sorted(
|
|
1257
|
+
stats.model_stats.items(),
|
|
1258
|
+
key=lambda kv: -kv[1].mismatches,
|
|
1259
|
+
)
|
|
1260
|
+
for model, ms in sorted_models:
|
|
1261
|
+
if ms.mismatches == 0:
|
|
1262
|
+
continue
|
|
1263
|
+
rate = ms.matches / ms.total * 100
|
|
1264
|
+
out.append(f"{model}:")
|
|
1265
|
+
out.append(f" Total entries: {ms.total:,}")
|
|
1266
|
+
out.append(f" Matches: {ms.matches:,} ({rate:.1f}%)")
|
|
1267
|
+
out.append(f" Mismatches: {ms.mismatches:,}")
|
|
1268
|
+
out.append(f" Avg % difference: {ms.avg_percent_diff:.1f}%")
|
|
1269
|
+
|
|
1270
|
+
if stats.discrepancies and sample_limit > 0:
|
|
1271
|
+
out.append("")
|
|
1272
|
+
out.append(f"=== Sample Discrepancies (first {sample_limit}) ===")
|
|
1273
|
+
for d in stats.discrepancies[:sample_limit]:
|
|
1274
|
+
out.append(f"File: {d.file}")
|
|
1275
|
+
out.append(f"Timestamp: {d.timestamp}")
|
|
1276
|
+
out.append(f"Model: {d.model}")
|
|
1277
|
+
out.append(f"Original cost: ${d.original_cost:.6f}")
|
|
1278
|
+
out.append(f"Calculated cost: ${d.calculated_cost:.6f}")
|
|
1279
|
+
out.append(
|
|
1280
|
+
f"Difference: ${d.difference:.6f} ({d.percent_diff:.2f}%)"
|
|
1281
|
+
)
|
|
1282
|
+
out.append(f"Tokens: {json.dumps(d.usage)}")
|
|
1283
|
+
out.append("---")
|
|
1284
|
+
return out
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
_DEBUG_REPORT_EMITTED = False
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _emit_debug_samples_if_set(
|
|
1291
|
+
args,
|
|
1292
|
+
entries_or_loader,
|
|
1293
|
+
*,
|
|
1294
|
+
command_label: str,
|
|
1295
|
+
) -> None:
|
|
1296
|
+
"""Emit the §7.6.2 normative stderr report exactly once per process
|
|
1297
|
+
when ``args.debug`` is True (issue #89).
|
|
1298
|
+
|
|
1299
|
+
``entries_or_loader`` is either a list[UsageEntry] (eager) or a
|
|
1300
|
+
zero-arg callable returning list[UsageEntry] (deferred). The deferred
|
|
1301
|
+
form keeps the JSONL scan off the hot path on SQL-backed cmds whose
|
|
1302
|
+
--debug is rare.
|
|
1303
|
+
|
|
1304
|
+
The report mirrors ccusage's ``printMismatchReport`` shape per spec
|
|
1305
|
+
§7.1.2; the one-time-per-process guard ensures a single CLI
|
|
1306
|
+
invocation that composes multiple cmd_* doesn't double-emit. The
|
|
1307
|
+
diff two-window case (``_emit_diff_debug_samples``) bypasses this
|
|
1308
|
+
guard internally for the second window then sets the guard at the
|
|
1309
|
+
end.
|
|
1310
|
+
"""
|
|
1311
|
+
global _DEBUG_REPORT_EMITTED
|
|
1312
|
+
if _DEBUG_REPORT_EMITTED:
|
|
1313
|
+
return
|
|
1314
|
+
if not getattr(args, "debug", False):
|
|
1315
|
+
return
|
|
1316
|
+
sample_limit = int(getattr(args, "debug_samples", 5))
|
|
1317
|
+
# Negative values are rejected at argparse parse time via _nonneg_int
|
|
1318
|
+
# (§7.5); the helper assumes sample_limit >= 0.
|
|
1319
|
+
|
|
1320
|
+
# P1.2 (issue #89 review-loop): only the loader call is wrapped — the
|
|
1321
|
+
# pure compute + render functions raising would be a programmer bug,
|
|
1322
|
+
# not a transient. On loader failure we degrade gracefully with a
|
|
1323
|
+
# one-line stderr notice and DO set the guard so a downstream cmd_*
|
|
1324
|
+
# composition doesn't retry (and re-emit) on the same transient.
|
|
1325
|
+
if callable(entries_or_loader):
|
|
1326
|
+
try:
|
|
1327
|
+
entries = entries_or_loader()
|
|
1328
|
+
except (sqlite3.DatabaseError, OSError) as exc:
|
|
1329
|
+
eprint(f"cctally --debug: report unavailable: {exc}")
|
|
1330
|
+
_DEBUG_REPORT_EMITTED = True
|
|
1331
|
+
return
|
|
1332
|
+
else:
|
|
1333
|
+
entries = entries_or_loader
|
|
1334
|
+
stats = _compute_pricing_mismatch_stats(entries)
|
|
1335
|
+
stats.command_label = command_label
|
|
1336
|
+
for line in _render_pricing_mismatch_report(stats, sample_limit):
|
|
1337
|
+
eprint(line)
|
|
1338
|
+
_DEBUG_REPORT_EMITTED = True
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
# ---------------------------------------------------------------------------
|
|
1342
|
+
# Codex --debug report (issue #92).
|
|
1343
|
+
#
|
|
1344
|
+
# Codex JSONL carries NO recorded costUSD, so the Claude-side "recorded vs
|
|
1345
|
+
# calculated mismatch" framing does not apply. The codex variant (chosen in
|
|
1346
|
+
# #92's design Q&A, option 2; #89 spec §12 precedent) is a "Codex Pricing
|
|
1347
|
+
# Debug Report": totals header (entries processed, models seen, total
|
|
1348
|
+
# computed cost) + a "Sample Top Entries" block of the N highest
|
|
1349
|
+
# computed-cost entries, each tagged ``Recorded cost: (none)``. Reuses the
|
|
1350
|
+
# process-wide ``_DEBUG_REPORT_EMITTED`` guard so one CLI invocation emits a
|
|
1351
|
+
# single debug report regardless of which family it ran.
|
|
1352
|
+
# ---------------------------------------------------------------------------
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
@dataclass
|
|
1356
|
+
class _CodexCostSample:
|
|
1357
|
+
file: str
|
|
1358
|
+
timestamp: str
|
|
1359
|
+
model: str
|
|
1360
|
+
calculated_cost: float
|
|
1361
|
+
usage: dict
|
|
1362
|
+
is_fallback: bool
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
@dataclass
|
|
1366
|
+
class _CodexCostStats:
|
|
1367
|
+
command_label: str | None = None
|
|
1368
|
+
total_entries: int = 0
|
|
1369
|
+
total_cost: float = 0.0
|
|
1370
|
+
model_counts: dict = field(default_factory=dict)
|
|
1371
|
+
fallback_models: set = field(default_factory=set)
|
|
1372
|
+
samples: list = field(default_factory=list)
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _compute_codex_cost_stats(entries):
|
|
1376
|
+
"""Walk ``entries: Iterable[CodexEntry]`` and compute the totals +
|
|
1377
|
+
per-entry computed-cost samples that ``_render_codex_cost_report``
|
|
1378
|
+
consumes (issue #92).
|
|
1379
|
+
|
|
1380
|
+
Unlike the Claude ``_compute_pricing_mismatch_stats`` there is no
|
|
1381
|
+
recorded cost to diff against, so every entry contributes a sample.
|
|
1382
|
+
Samples are collected for all entries and sorted descending by
|
|
1383
|
+
computed cost; the renderer slices to ``--debug-samples``. (Memory is
|
|
1384
|
+
O(entries); acceptable for typical codex histories and symmetric with
|
|
1385
|
+
the Claude helper, which retains its full discrepancy list.)
|
|
1386
|
+
|
|
1387
|
+
Cost + fallback resolution mirror the live aggregation path:
|
|
1388
|
+
``_calculate_codex_entry_cost`` (LiteLLM token semantics) and
|
|
1389
|
+
``_resolve_codex_pricing`` (unknown model → ``gpt-5`` fallback).
|
|
1390
|
+
"""
|
|
1391
|
+
stats = _CodexCostStats()
|
|
1392
|
+
for entry in entries:
|
|
1393
|
+
stats.total_entries += 1
|
|
1394
|
+
stats.model_counts[entry.model] = (
|
|
1395
|
+
stats.model_counts.get(entry.model, 0) + 1
|
|
1396
|
+
)
|
|
1397
|
+
_, is_fallback = _resolve_codex_pricing(entry.model)
|
|
1398
|
+
if is_fallback:
|
|
1399
|
+
stats.fallback_models.add(entry.model)
|
|
1400
|
+
cost = _calculate_codex_entry_cost(
|
|
1401
|
+
entry.model,
|
|
1402
|
+
entry.input_tokens,
|
|
1403
|
+
entry.cached_input_tokens,
|
|
1404
|
+
entry.output_tokens,
|
|
1405
|
+
entry.reasoning_output_tokens,
|
|
1406
|
+
)
|
|
1407
|
+
stats.total_cost += cost
|
|
1408
|
+
stats.samples.append(_CodexCostSample(
|
|
1409
|
+
file=os.path.basename(entry.source_path),
|
|
1410
|
+
timestamp=entry.timestamp.isoformat(),
|
|
1411
|
+
model=entry.model,
|
|
1412
|
+
calculated_cost=cost,
|
|
1413
|
+
usage={
|
|
1414
|
+
"input_tokens": entry.input_tokens,
|
|
1415
|
+
"cached_input_tokens": entry.cached_input_tokens,
|
|
1416
|
+
"output_tokens": entry.output_tokens,
|
|
1417
|
+
"reasoning_output_tokens": entry.reasoning_output_tokens,
|
|
1418
|
+
"total_tokens": entry.total_tokens,
|
|
1419
|
+
},
|
|
1420
|
+
is_fallback=is_fallback,
|
|
1421
|
+
))
|
|
1422
|
+
# Stable sort: equal-cost samples keep iteration order (mirrors the
|
|
1423
|
+
# Claude helper's iteration-order discrepancy list).
|
|
1424
|
+
stats.samples.sort(key=lambda s: -s.calculated_cost)
|
|
1425
|
+
return stats
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _render_codex_cost_report(stats, sample_limit):
|
|
1429
|
+
"""Return the codex --debug report as a list of stderr lines (issue #92).
|
|
1430
|
+
|
|
1431
|
+
Structurally parallel to ``_render_pricing_mismatch_report`` but with
|
|
1432
|
+
no match/mismatch framing (codex has no recorded cost):
|
|
1433
|
+
|
|
1434
|
+
- Early-return ``"No Codex usage data found to analyze."`` when
|
|
1435
|
+
``total_entries == 0``.
|
|
1436
|
+
- Totals header: entries processed, models seen (count desc, ties
|
|
1437
|
+
by name asc; fallback models tagged ``(N, fallback→gpt-5)``),
|
|
1438
|
+
total computed cost.
|
|
1439
|
+
- ``Command: cctally <label>`` self-identifier when set (parity
|
|
1440
|
+
with the Claude report's one non-upstream line).
|
|
1441
|
+
- Sample block omitted when ``sample_limit == 0`` or no samples;
|
|
1442
|
+
header prints the requested ``sample_limit`` (upstream parity).
|
|
1443
|
+
Each sample carries ``Recorded cost: (none)`` and a
|
|
1444
|
+
``(fallback→gpt-5)`` model-line marker when applicable.
|
|
1445
|
+
"""
|
|
1446
|
+
out = []
|
|
1447
|
+
if stats.total_entries == 0:
|
|
1448
|
+
out.append("No Codex usage data found to analyze.")
|
|
1449
|
+
return out
|
|
1450
|
+
|
|
1451
|
+
fallback = CODEX_LEGACY_FALLBACK_MODEL
|
|
1452
|
+
parts = []
|
|
1453
|
+
for model, count in sorted(
|
|
1454
|
+
stats.model_counts.items(), key=lambda kv: (-kv[1], kv[0]),
|
|
1455
|
+
):
|
|
1456
|
+
if model in stats.fallback_models:
|
|
1457
|
+
parts.append(f"{model} ({count:,}, fallback→{fallback})")
|
|
1458
|
+
else:
|
|
1459
|
+
parts.append(f"{model} ({count:,})")
|
|
1460
|
+
|
|
1461
|
+
out.append("")
|
|
1462
|
+
out.append("=== Codex Pricing Debug Report ===")
|
|
1463
|
+
if stats.command_label:
|
|
1464
|
+
out.append(f"Command: cctally {stats.command_label}")
|
|
1465
|
+
out.append(f"Total entries processed: {stats.total_entries:,}")
|
|
1466
|
+
out.append(f"Models seen: {', '.join(parts)}")
|
|
1467
|
+
out.append(f"Total computed cost: ${stats.total_cost:.6f}")
|
|
1468
|
+
|
|
1469
|
+
if stats.samples and sample_limit > 0:
|
|
1470
|
+
out.append("")
|
|
1471
|
+
out.append(f"=== Sample Top Entries (first {sample_limit}) ===")
|
|
1472
|
+
for s in stats.samples[:sample_limit]:
|
|
1473
|
+
model_line = (
|
|
1474
|
+
f"{s.model} (fallback→{fallback})"
|
|
1475
|
+
if s.is_fallback else s.model
|
|
1476
|
+
)
|
|
1477
|
+
out.append(f"File: {s.file}")
|
|
1478
|
+
out.append(f"Timestamp: {s.timestamp}")
|
|
1479
|
+
out.append(f"Model: {model_line}")
|
|
1480
|
+
out.append("Recorded cost: (none)")
|
|
1481
|
+
out.append(f"Calculated cost: ${s.calculated_cost:.6f}")
|
|
1482
|
+
out.append(f"Tokens: {json.dumps(s.usage)}")
|
|
1483
|
+
out.append("---")
|
|
1484
|
+
return out
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def _emit_codex_debug_samples_if_set(
|
|
1488
|
+
args,
|
|
1489
|
+
entries,
|
|
1490
|
+
*,
|
|
1491
|
+
command_label: str,
|
|
1492
|
+
) -> None:
|
|
1493
|
+
"""Emit the codex --debug report once per process when ``args.debug``
|
|
1494
|
+
is True (issue #92).
|
|
1495
|
+
|
|
1496
|
+
``entries`` is an eager ``list[CodexEntry]`` — each ``cmd_codex_*`` body
|
|
1497
|
+
already loads them via ``get_codex_entries`` before this call, so unlike
|
|
1498
|
+
the Claude helper there is no deferred-loader variant. Shares the
|
|
1499
|
+
process-wide ``_DEBUG_REPORT_EMITTED`` guard with
|
|
1500
|
+
``_emit_debug_samples_if_set`` so a single CLI invocation emits one
|
|
1501
|
+
report regardless of family.
|
|
1502
|
+
"""
|
|
1503
|
+
global _DEBUG_REPORT_EMITTED
|
|
1504
|
+
if _DEBUG_REPORT_EMITTED:
|
|
1505
|
+
return
|
|
1506
|
+
if not getattr(args, "debug", False):
|
|
1507
|
+
return
|
|
1508
|
+
sample_limit = int(getattr(args, "debug_samples", 5))
|
|
1509
|
+
stats = _compute_codex_cost_stats(entries)
|
|
1510
|
+
stats.command_label = command_label
|
|
1511
|
+
for line in _render_codex_cost_report(stats, sample_limit):
|
|
1512
|
+
eprint(line)
|
|
1513
|
+
_DEBUG_REPORT_EMITTED = True
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def _usage_entry_from_joined(je) -> "UsageEntry":
|
|
1517
|
+
"""Shape a ``_JoinedClaudeEntry`` into a ``UsageEntry`` so the §7.1
|
|
1518
|
+
mismatch helpers can consume both pipelines uniformly (issue #89
|
|
1519
|
+
spec §7.2.1).
|
|
1520
|
+
|
|
1521
|
+
The joined-entry shape already carries ``source_path``, ``cost_usd``,
|
|
1522
|
+
and the per-token integers; this adapter is pure shape conversion
|
|
1523
|
+
with no cache re-read.
|
|
1524
|
+
"""
|
|
1525
|
+
return UsageEntry(
|
|
1526
|
+
timestamp=je.timestamp,
|
|
1527
|
+
model=je.model,
|
|
1528
|
+
usage={
|
|
1529
|
+
"input_tokens": je.input_tokens,
|
|
1530
|
+
"output_tokens": je.output_tokens,
|
|
1531
|
+
"cache_creation_input_tokens": je.cache_creation_tokens,
|
|
1532
|
+
"cache_read_input_tokens": je.cache_read_tokens,
|
|
1533
|
+
},
|
|
1534
|
+
cost_usd=je.cost_usd,
|
|
1535
|
+
source_path=je.source_path,
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _resolve_session_id_for_filter(je) -> str:
|
|
1540
|
+
"""Mirror ``_aggregate_claude_sessions``'s session_id resolution
|
|
1541
|
+
(``bin/_lib_aggregators.py:623-627``): use ``je.session_id`` when
|
|
1542
|
+
set, else fall back to the filename stem of ``je.source_path``.
|
|
1543
|
+
|
|
1544
|
+
Used by ``cmd_session``'s ``--id`` filter on the joined-entry list
|
|
1545
|
+
(spec §7.2.1.1) so a rendered session that was assigned its id via
|
|
1546
|
+
the filename-stem fallback isn't silently dropped from the --debug
|
|
1547
|
+
report scope.
|
|
1548
|
+
"""
|
|
1549
|
+
if je.session_id is not None:
|
|
1550
|
+
return je.session_id
|
|
1551
|
+
return os.path.splitext(os.path.basename(je.source_path))[0]
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def _project_filter_matches(key, project_patterns):
|
|
1555
|
+
"""Mirror ``cmd_project``'s ``--project`` predicate
|
|
1556
|
+
(``bin/cctally:4792-4803``): substring match against
|
|
1557
|
+
``key.display_key`` AND ``key.git_root or key.bucket_path``.
|
|
1558
|
+
|
|
1559
|
+
Used by ``cmd_project``'s --debug report scope so basename-collision
|
|
1560
|
+
suffixes (e.g. ``foo (repos)``) are still selectable by their path
|
|
1561
|
+
segment (spec §7.2.1.2). Do NOT simplify to a raw
|
|
1562
|
+
``p in (je.project_path or "")`` substring — the report scope would
|
|
1563
|
+
drift from the rendered scope.
|
|
1564
|
+
"""
|
|
1565
|
+
if not project_patterns:
|
|
1566
|
+
return True
|
|
1567
|
+
dname = key.display_key.lower()
|
|
1568
|
+
pname = (key.git_root or key.bucket_path or "").lower()
|
|
1569
|
+
return any((p in dname) or (p in pname) for p in project_patterns)
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _emit_diff_debug_samples(args, window_a, window_b) -> None:
|
|
1573
|
+
"""Two-window diff report (spec §7.2.2 Pattern D).
|
|
1574
|
+
|
|
1575
|
+
``cmd_diff`` aggregates two windows; emitting a single union-report
|
|
1576
|
+
would conflate per-window stats. This helper emits two separate
|
|
1577
|
+
reports labeled by window token, then sets ``_DEBUG_REPORT_EMITTED``
|
|
1578
|
+
so a downstream cmd_* composition doesn't double-emit.
|
|
1579
|
+
|
|
1580
|
+
Bypasses ``_emit_debug_samples_if_set``'s one-time guard internally
|
|
1581
|
+
(it would short-circuit the second window).
|
|
1582
|
+
"""
|
|
1583
|
+
global _DEBUG_REPORT_EMITTED
|
|
1584
|
+
if _DEBUG_REPORT_EMITTED:
|
|
1585
|
+
return
|
|
1586
|
+
if not getattr(args, "debug", False):
|
|
1587
|
+
return
|
|
1588
|
+
sample_limit = int(getattr(args, "debug_samples", 5))
|
|
1589
|
+
# Sync intent must match `_build_diff_result` (`skip_sync=not args.sync`).
|
|
1590
|
+
# Under `diff --sync --debug` the rendered diff reflects freshly-synced
|
|
1591
|
+
# JSONL while these debug stats, if they skipped sync, would be computed
|
|
1592
|
+
# from the STALE cache — misleading in precisely the stale-cache case
|
|
1593
|
+
# `--sync` exists to fix. So honor `--sync` here too: the first
|
|
1594
|
+
# `get_entries(skip_sync=False)` runs the delta ingest, and every
|
|
1595
|
+
# subsequent read (the second window here + `_build_diff_result`'s own
|
|
1596
|
+
# reads) is a cheap delta no-op (file size/offset unchanged) — no
|
|
1597
|
+
# redundant full walk. Without `--sync`, keep skipping (debug observes
|
|
1598
|
+
# the cache as-is).
|
|
1599
|
+
skip_sync = not bool(getattr(args, "sync", False))
|
|
1600
|
+
try:
|
|
1601
|
+
for window, label_letter, token in (
|
|
1602
|
+
(window_a, "A", getattr(args, "a", "")),
|
|
1603
|
+
(window_b, "B", getattr(args, "b", "")),
|
|
1604
|
+
):
|
|
1605
|
+
try:
|
|
1606
|
+
# Reuse the SAME half-open window accessor the rendered diff
|
|
1607
|
+
# aggregation uses (`_diff_iter_claude_entries`): `ParsedWindow`
|
|
1608
|
+
# exposes `start_utc`/`end_utc` (NOT `.start`/`.end`), and its
|
|
1609
|
+
# `end_utc` is documented exclusive — the helper trims by 1 µs
|
|
1610
|
+
# before hitting the inclusive-end shared cache reader so the
|
|
1611
|
+
# debug report scopes to exactly the entries the rendered diff
|
|
1612
|
+
# counts. Reading via `get_entries(window.start, window.end)`
|
|
1613
|
+
# both raised AttributeError (wrong field names) and would have
|
|
1614
|
+
# over-counted the exclusive end boundary.
|
|
1615
|
+
entries = list(
|
|
1616
|
+
_diff_iter_claude_entries(window, skip_sync=skip_sync)
|
|
1617
|
+
)
|
|
1618
|
+
except (sqlite3.DatabaseError, OSError) as exc:
|
|
1619
|
+
eprint(
|
|
1620
|
+
f"cctally --debug: window {label_letter} report "
|
|
1621
|
+
f"unavailable: {exc}"
|
|
1622
|
+
)
|
|
1623
|
+
continue
|
|
1624
|
+
# `_diff_iter_claude_entries` yields `_JoinedClaudeEntry`, which
|
|
1625
|
+
# has no `.usage` attribute; adapt to `UsageEntry` before the
|
|
1626
|
+
# mismatch compute, mirroring cmd_project / cmd_session. The stats
|
|
1627
|
+
# helper reads `entry.usage`, so passing raw joined entries here
|
|
1628
|
+
# crashed every priced entry with AttributeError — and the inner
|
|
1629
|
+
# `try/except` only catches DatabaseError/OSError, so the crash
|
|
1630
|
+
# escaped to main()'s generic handler (exit 1, zero diff output).
|
|
1631
|
+
stats = _compute_pricing_mismatch_stats(
|
|
1632
|
+
_usage_entry_from_joined(je) for je in entries
|
|
1633
|
+
)
|
|
1634
|
+
stats.command_label = f"diff (Window {label_letter}: {token})"
|
|
1635
|
+
for line in _render_pricing_mismatch_report(stats, sample_limit):
|
|
1636
|
+
eprint(line)
|
|
1637
|
+
finally:
|
|
1638
|
+
# P1.2 (issue #89 review-loop): set the guard in finally so a
|
|
1639
|
+
# downstream cmd_* composition doesn't double-emit even if one
|
|
1640
|
+
# window raised — the partial output we did emit is enough.
|
|
1641
|
+
_DEBUG_REPORT_EMITTED = True
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _load_claude_config_for_args(args: argparse.Namespace) -> "dict":
|
|
1645
|
+
"""Load config honoring the ccusage ``--config <path>`` per-invocation override.
|
|
1646
|
+
|
|
1647
|
+
Thin shim over :func:`load_config` so the 10 in-scope Claude reporting
|
|
1648
|
+
commands (spec §3 T1.6 / issue #88) surface the override behavior
|
|
1649
|
+
uniformly. When ``args.config`` is set, reads from that explicit path
|
|
1650
|
+
only — no first-run create, no writer-lock acquisition, no mutation
|
|
1651
|
+
of the persisted default config at ``_cctally_core.CONFIG_PATH``.
|
|
1652
|
+
Missing / unreadable / non-object-JSON paths raise ``SystemExit(2)``
|
|
1653
|
+
with a clear stderr message (see ``_load_config_from_explicit_path``).
|
|
1654
|
+
When ``args.config`` is unset (or absent), behavior is identical to
|
|
1655
|
+
a bare ``load_config()`` call.
|
|
1656
|
+
"""
|
|
1657
|
+
return load_config(getattr(args, "config", None))
|
|
1658
|
+
|
|
1659
|
+
|
|
998
1660
|
def _resolve_codex_tz_name(args: argparse.Namespace,
|
|
999
1661
|
config: "dict | None") -> "str | None":
|
|
1000
1662
|
"""Resolve the IANA tz NAME (or None for host-local) used by Codex
|
|
@@ -1333,6 +1995,64 @@ def _supports_color_stdout() -> bool:
|
|
|
1333
1995
|
return False
|
|
1334
1996
|
|
|
1335
1997
|
|
|
1998
|
+
def _resolve_color_enabled(args: argparse.Namespace) -> bool:
|
|
1999
|
+
"""Resolve effective color-on/off for the 2 real ANSI Claude cmds.
|
|
2000
|
+
|
|
2001
|
+
Spec §7.3 (issue #86 Session A). Precedence (top wins):
|
|
2002
|
+
|
|
2003
|
+
1. ``args.no_color`` → False (deny-wins; overrides ``FORCE_COLOR``
|
|
2004
|
+
env AND a co-supplied ``--color`` flag).
|
|
2005
|
+
2. ``args.color`` → True (overrides ``NO_COLOR`` env).
|
|
2006
|
+
3. ``FORCE_COLOR`` → True.
|
|
2007
|
+
4. ``NO_COLOR`` → False.
|
|
2008
|
+
5. ``CI`` env with a TTY stdout → True (CI forces color, but only
|
|
2009
|
+
when stdout is itself a terminal — a piped /
|
|
2010
|
+
redirected stdout under CI stays plain text,
|
|
2011
|
+
issue #100); else ``isatty(stdout)`` with
|
|
2012
|
+
non-dumb TERM → True (the diff/project
|
|
2013
|
+
auto-detect).
|
|
2014
|
+
6. else → False (incl. a piped stdout under CI).
|
|
2015
|
+
|
|
2016
|
+
The helper is consumed by ``cmd_project`` and ``cmd_diff`` — the only
|
|
2017
|
+
two in-scope Claude cmds whose renderers honor color. The other 8
|
|
2018
|
+
in-scope cmds parse ``--color`` / ``--no-color`` as documented no-op
|
|
2019
|
+
surface (spec §3 T1.5).
|
|
2020
|
+
|
|
2021
|
+
The rung-5 auto-detect keys on ``sys.stdout.isatty()`` ONLY — NOT the
|
|
2022
|
+
shared ``_supports_color_stdout()`` (which is stdout-OR-stderr to match
|
|
2023
|
+
ccusage's picocolors behavior). ``diff``'s pre-Session-A computation was
|
|
2024
|
+
``sys.stdout.isatty() and not args.no_color`` (stdout-only), and spec
|
|
2025
|
+
§7.3 specifies preserving the *existing* auto-detect on the no-flag
|
|
2026
|
+
rungs. Routing the fallback through the stdout-OR-stderr helper would
|
|
2027
|
+
write ANSI into the pipe under ``cctally diff … | cat`` (stderr is still
|
|
2028
|
+
a TTY) — a backwards-incompatible regression for plain-text consumers.
|
|
2029
|
+
"""
|
|
2030
|
+
# Rung 1 — deny-wins: --no-color always disables.
|
|
2031
|
+
if getattr(args, "no_color", False):
|
|
2032
|
+
return False
|
|
2033
|
+
# Rung 2 — --color overrides env (NO_COLOR auto-detect).
|
|
2034
|
+
if getattr(args, "color", False):
|
|
2035
|
+
return True
|
|
2036
|
+
# Rung 3 — FORCE_COLOR env always enables (any value).
|
|
2037
|
+
if "FORCE_COLOR" in os.environ:
|
|
2038
|
+
return True
|
|
2039
|
+
# Rung 4 — NO_COLOR env always disables (any value).
|
|
2040
|
+
if "NO_COLOR" in os.environ:
|
|
2041
|
+
return False
|
|
2042
|
+
# Rung 5 — CI forces color, but ONLY with a TTY stdout; else stdout-only
|
|
2043
|
+
# isatty with non-dumb TERM. Both branches key on sys.stdout.isatty()
|
|
2044
|
+
# (NOT the stdout-OR-stderr _supports_color_stdout) so a piped stdout —
|
|
2045
|
+
# under CI or otherwise — stays uncolored, preserving diff/project's
|
|
2046
|
+
# pre-Session-A plain-text-on-pipe contract (issue #100). CI still wins
|
|
2047
|
+
# over a dumb TERM when stdout is a real terminal (picocolors parity).
|
|
2048
|
+
if sys.stdout.isatty():
|
|
2049
|
+
if "CI" in os.environ:
|
|
2050
|
+
return True
|
|
2051
|
+
return os.environ.get("TERM", "").lower() != "dumb"
|
|
2052
|
+
# Rung 6 — no flag, no env, non-tty stdout (incl. a piped stdout under CI).
|
|
2053
|
+
return False
|
|
2054
|
+
|
|
2055
|
+
|
|
1336
2056
|
def _style_ansi(text: str, code: str, enabled: bool) -> str:
|
|
1337
2057
|
if not enabled:
|
|
1338
2058
|
return text
|
|
@@ -1373,6 +2093,7 @@ def _boxed_table(
|
|
|
1373
2093
|
aligns: list[str] | None = None,
|
|
1374
2094
|
*,
|
|
1375
2095
|
color_header: bool = True,
|
|
2096
|
+
compact: bool = False,
|
|
1376
2097
|
) -> str:
|
|
1377
2098
|
if not headers:
|
|
1378
2099
|
return ""
|
|
@@ -1439,10 +2160,17 @@ def _boxed_table(
|
|
|
1439
2160
|
def _dim(s: str) -> str:
|
|
1440
2161
|
return _style_ansi(s, "90", color_enabled)
|
|
1441
2162
|
|
|
2163
|
+
# Issue #91 (Shape B): ``compact`` drops the 1-space cell padding to
|
|
2164
|
+
# 0 on this content-sized table (which has no proportional-width path
|
|
2165
|
+
# to force). Borders and rows both key off ``pad`` so the default
|
|
2166
|
+
# (``pad == 1``) reproduces the prior output byte-for-byte.
|
|
2167
|
+
pad = 0 if compact else 1
|
|
2168
|
+
pad_s = " " * pad
|
|
2169
|
+
|
|
1442
2170
|
def make_border(left: str, mid: str, right: str) -> str:
|
|
1443
2171
|
return _dim(
|
|
1444
2172
|
left
|
|
1445
|
-
+ mid.join(chars["h"] * (w + 2) for w in widths)
|
|
2173
|
+
+ mid.join(chars["h"] * (w + 2 * pad) for w in widths)
|
|
1446
2174
|
+ right
|
|
1447
2175
|
)
|
|
1448
2176
|
|
|
@@ -1459,9 +2187,9 @@ def _boxed_table(
|
|
|
1459
2187
|
v = _dim(chars["v"])
|
|
1460
2188
|
return (
|
|
1461
2189
|
v
|
|
1462
|
-
+
|
|
1463
|
-
+ f"
|
|
1464
|
-
+
|
|
2190
|
+
+ pad_s
|
|
2191
|
+
+ f"{pad_s}{v}{pad_s}".join(styled_cells)
|
|
2192
|
+
+ pad_s
|
|
1465
2193
|
+ v
|
|
1466
2194
|
)
|
|
1467
2195
|
|
|
@@ -1542,6 +2270,7 @@ def _layout_cache_table(
|
|
|
1542
2270
|
wide_text_min: int = 15,
|
|
1543
2271
|
narrow_text_min: int = 12,
|
|
1544
2272
|
droppable_col_index: int | None = None,
|
|
2273
|
+
compact: bool = False,
|
|
1545
2274
|
) -> str:
|
|
1546
2275
|
"""Shared responsive-width table layout for cache-report renderers.
|
|
1547
2276
|
|
|
@@ -1645,7 +2374,11 @@ def _layout_cache_table(
|
|
|
1645
2374
|
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
1646
2375
|
|
|
1647
2376
|
border_overhead = 3 * num_cols + 1
|
|
1648
|
-
|
|
2377
|
+
# Issue #91 (Shape A): the ``compact`` kwarg forces the responsive
|
|
2378
|
+
# scale-down path regardless of terminal width, mirroring
|
|
2379
|
+
# ``_render_project_table`` / ``_render_bucket_table``. Auto-detected
|
|
2380
|
+
# width-overflow continues to trigger the same path as before.
|
|
2381
|
+
compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
|
|
1649
2382
|
|
|
1650
2383
|
if compact_mode:
|
|
1651
2384
|
available = term_width - border_overhead
|
|
@@ -1857,7 +2590,9 @@ def _layout_cache_table(
|
|
|
1857
2590
|
return "\n".join(lines)
|
|
1858
2591
|
|
|
1859
2592
|
|
|
1860
|
-
def _render_cache_day_rows(
|
|
2593
|
+
def _render_cache_day_rows(
|
|
2594
|
+
rows: list[CacheRow], title: str, *, compact: bool = False,
|
|
2595
|
+
) -> str:
|
|
1861
2596
|
"""Render daily-mode cache report.
|
|
1862
2597
|
|
|
1863
2598
|
Columns: Date, Models, Cache %, Input, Cache Create, Cache Read,
|
|
@@ -1986,12 +2721,13 @@ def _render_cache_day_rows(rows: list[CacheRow], title: str) -> str:
|
|
|
1986
2721
|
wide_text_min=15,
|
|
1987
2722
|
narrow_text_min=12,
|
|
1988
2723
|
droppable_col_index=3, # Input column
|
|
2724
|
+
compact=compact,
|
|
1989
2725
|
)
|
|
1990
2726
|
|
|
1991
2727
|
|
|
1992
2728
|
def _render_cache_session_rows(
|
|
1993
2729
|
rows: list[CacheRow], title: str,
|
|
1994
|
-
*, tz: "ZoneInfo | None" = None,
|
|
2730
|
+
*, tz: "ZoneInfo | None" = None, compact: bool = False,
|
|
1995
2731
|
) -> str:
|
|
1996
2732
|
"""Render session-mode cache report.
|
|
1997
2733
|
|
|
@@ -2135,6 +2871,7 @@ def _render_cache_session_rows(
|
|
|
2135
2871
|
wide_text_min=18,
|
|
2136
2872
|
narrow_text_min=12,
|
|
2137
2873
|
droppable_col_index=4, # Input column
|
|
2874
|
+
compact=compact,
|
|
2138
2875
|
)
|
|
2139
2876
|
|
|
2140
2877
|
|
|
@@ -2144,16 +2881,20 @@ def _render_cache_report_table(
|
|
|
2144
2881
|
*,
|
|
2145
2882
|
mode: Literal["day", "session"] = "day",
|
|
2146
2883
|
tz: "ZoneInfo | None" = None,
|
|
2884
|
+
compact: bool = False,
|
|
2147
2885
|
) -> str:
|
|
2148
2886
|
"""Dispatcher: routes to daily or session renderer based on mode.
|
|
2149
2887
|
|
|
2150
2888
|
``tz`` is the resolved display zone (None = host local). Day-mode
|
|
2151
2889
|
rows have no clock-instant cells (date strings only) so the parameter
|
|
2152
2890
|
is currently consumed only by the session-mode renderer.
|
|
2891
|
+
|
|
2892
|
+
``compact`` (issue #91, Shape A) forces ``_layout_cache_table``'s
|
|
2893
|
+
responsive scale-down branch regardless of terminal width.
|
|
2153
2894
|
"""
|
|
2154
2895
|
if mode == "session":
|
|
2155
|
-
return _render_cache_session_rows(rows, title, tz=tz)
|
|
2156
|
-
return _render_cache_day_rows(rows, title)
|
|
2896
|
+
return _render_cache_session_rows(rows, title, tz=tz, compact=compact)
|
|
2897
|
+
return _render_cache_day_rows(rows, title, compact=compact)
|
|
2157
2898
|
|
|
2158
2899
|
|
|
2159
2900
|
def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
|
|
@@ -3405,17 +4146,19 @@ def _load_recorded_five_hour_windows(
|
|
|
3405
4146
|
|
|
3406
4147
|
def cmd_blocks(args: argparse.Namespace) -> int:
|
|
3407
4148
|
"""Show usage report grouped by 5-hour session blocks."""
|
|
3408
|
-
config =
|
|
4149
|
+
config = _load_claude_config_for_args(args)
|
|
4150
|
+
_bridge_z_into_tz(args, config)
|
|
3409
4151
|
tz = resolve_display_tz(args, config)
|
|
3410
4152
|
args._resolved_tz = tz
|
|
3411
4153
|
|
|
3412
4154
|
now_utc = _command_as_of()
|
|
3413
|
-
# Parse --since / --until into datetime range
|
|
4155
|
+
# Parse --since / --until into datetime range. Session A (spec §7.1.1)
|
|
4156
|
+
# routes through the centralized dual-form helper so YYYY-MM-DD also
|
|
4157
|
+
# works and the error message matches the other in-scope cmds.
|
|
3414
4158
|
if args.since:
|
|
3415
4159
|
try:
|
|
3416
|
-
since_date =
|
|
4160
|
+
since_date = _parse_dual_form_date(args.since, "--since")
|
|
3417
4161
|
except ValueError:
|
|
3418
|
-
eprint(f"Error: --since must be YYYYMMDD format, got '{args.since}'")
|
|
3419
4162
|
return 1
|
|
3420
4163
|
range_start = since_date.replace(tzinfo=dt.timezone.utc)
|
|
3421
4164
|
else:
|
|
@@ -3424,9 +4167,8 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
3424
4167
|
|
|
3425
4168
|
if args.until:
|
|
3426
4169
|
try:
|
|
3427
|
-
until_date =
|
|
4170
|
+
until_date = _parse_dual_form_date(args.until, "--until")
|
|
3428
4171
|
except ValueError:
|
|
3429
|
-
eprint(f"Error: --until must be YYYYMMDD format, got '{args.until}'")
|
|
3430
4172
|
return 1
|
|
3431
4173
|
# End of that day
|
|
3432
4174
|
range_end = until_date.replace(
|
|
@@ -3439,6 +4181,10 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
3439
4181
|
# Collect all entries
|
|
3440
4182
|
all_entries = get_entries(range_start, range_end)
|
|
3441
4183
|
|
|
4184
|
+
_emit_debug_samples_if_set(
|
|
4185
|
+
args, all_entries, command_label="blocks",
|
|
4186
|
+
)
|
|
4187
|
+
|
|
3442
4188
|
# Load recorded 5-hour reset timestamps. Widen both bounds by
|
|
3443
4189
|
# BLOCK_DURATION: a window covers [R - 5h, R), so a reset R just
|
|
3444
4190
|
# before ``range_start`` can still anchor entries near it, and a
|
|
@@ -3503,8 +4249,13 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
3503
4249
|
print(_blocks_to_json(blocks))
|
|
3504
4250
|
return 0
|
|
3505
4251
|
|
|
3506
|
-
# Table output
|
|
3507
|
-
|
|
4252
|
+
# Table output. Session A (spec §7.6.1; Review-A P2-B): thread
|
|
4253
|
+
# --compact through so the renderer's scale-down branch fires
|
|
4254
|
+
# regardless of terminal width when the flag is set.
|
|
4255
|
+
print(_render_blocks_table(
|
|
4256
|
+
blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
|
|
4257
|
+
compact=getattr(args, "compact", False),
|
|
4258
|
+
))
|
|
3508
4259
|
return 0
|
|
3509
4260
|
|
|
3510
4261
|
|
|
@@ -3607,6 +4358,45 @@ def _maybe_swap_active_block_to_canonical(
|
|
|
3607
4358
|
blocks[active_idx] = rebuilt
|
|
3608
4359
|
|
|
3609
4360
|
|
|
4361
|
+
def _try_dual_form_date(raw: str) -> dt.datetime | None:
|
|
4362
|
+
"""Parse ``YYYY-MM-DD`` / ``YYYYMMDD`` WITHOUT emitting an error.
|
|
4363
|
+
|
|
4364
|
+
Returns the parsed naive datetime, or ``None`` when ``raw`` matches
|
|
4365
|
+
neither form. The non-eprinting core of ``_parse_dual_form_date``:
|
|
4366
|
+
callers that have their own fallback for a non-dual-form input must
|
|
4367
|
+
use this, because ``_parse_dual_form_date`` eprints before it raises
|
|
4368
|
+
— so a successful fallback parse would otherwise leak a spurious
|
|
4369
|
+
"must be YYYY-MM-DD" line to stderr (cache-report's ``parse_iso_datetime``
|
|
4370
|
+
second-chance, issue #101).
|
|
4371
|
+
"""
|
|
4372
|
+
for fmt in ("%Y-%m-%d", "%Y%m%d"):
|
|
4373
|
+
try:
|
|
4374
|
+
return dt.datetime.strptime(raw, fmt)
|
|
4375
|
+
except ValueError:
|
|
4376
|
+
continue
|
|
4377
|
+
return None
|
|
4378
|
+
|
|
4379
|
+
|
|
4380
|
+
def _parse_dual_form_date(raw: str, flag: str) -> dt.datetime:
|
|
4381
|
+
"""Parse a CLI date arg accepting both ``YYYY-MM-DD`` and ``YYYYMMDD``.
|
|
4382
|
+
|
|
4383
|
+
Tries ``%Y-%m-%d`` first (the more readable upstream ccusage / codex
|
|
4384
|
+
sharedArgs preference), then ``%Y%m%d``. On failure, emits a stderr
|
|
4385
|
+
error and raises ``ValueError``. Callers swallow the ``ValueError``
|
|
4386
|
+
and return exit code 1.
|
|
4387
|
+
|
|
4388
|
+
Promoted from ``_resolve_codex_range._parse_date_arg`` so Claude
|
|
4389
|
+
reporting commands can share the dual-form contract (issue #86
|
|
4390
|
+
Session A, spec §7.1.1). Centralizes the error message so the eight
|
|
4391
|
+
in-scope date-taking commands all surface the same diagnostic.
|
|
4392
|
+
"""
|
|
4393
|
+
naive = _try_dual_form_date(raw)
|
|
4394
|
+
if naive is not None:
|
|
4395
|
+
return naive
|
|
4396
|
+
eprint(f"Error: {flag} must be YYYY-MM-DD or YYYYMMDD format, got '{raw}'")
|
|
4397
|
+
raise ValueError
|
|
4398
|
+
|
|
4399
|
+
|
|
3610
4400
|
def _parse_cli_date_range(
|
|
3611
4401
|
args: argparse.Namespace,
|
|
3612
4402
|
*,
|
|
@@ -3639,14 +4429,11 @@ def _parse_cli_date_range(
|
|
|
3639
4429
|
the given date — this avoids DST-boundary drift that would occur if
|
|
3640
4430
|
we used today's offset (via `datetime.now().astimezone().tzinfo`).
|
|
3641
4431
|
"""
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
continue
|
|
3648
|
-
eprint(f"Error: {flag} must be YYYY-MM-DD or YYYYMMDD format, got '{raw}'")
|
|
3649
|
-
raise ValueError
|
|
4432
|
+
# Local binding to the module-level dual-form helper (spec §7.1.1).
|
|
4433
|
+
# Keeps the diff inside _parse_cli_date_range minimal while sharing
|
|
4434
|
+
# the centralized error message with cmd_blocks, _parse_date_filter,
|
|
4435
|
+
# and _parse_window_arg.
|
|
4436
|
+
_parse_date_arg = _parse_dual_form_date
|
|
3650
4437
|
|
|
3651
4438
|
tz: Any = None
|
|
3652
4439
|
if tz_name:
|
|
@@ -3701,7 +4488,12 @@ def _parse_cli_date_range(
|
|
|
3701
4488
|
def cmd_daily(args: argparse.Namespace) -> int:
|
|
3702
4489
|
"""Show usage report grouped by display-timezone date."""
|
|
3703
4490
|
_share_validate_args(args)
|
|
3704
|
-
config =
|
|
4491
|
+
config = _load_claude_config_for_args(args)
|
|
4492
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
4493
|
+
# existing resolve_display_tz precedence absorbs the new alias. The
|
|
4494
|
+
# canonical --tz still wins (it's set on the namespace before this
|
|
4495
|
+
# bridge fires); when --tz is unset and -z is supplied, use -z.
|
|
4496
|
+
_bridge_z_into_tz(args, config)
|
|
3705
4497
|
tz = resolve_display_tz(args, config)
|
|
3706
4498
|
args._resolved_tz = tz
|
|
3707
4499
|
|
|
@@ -3717,6 +4509,10 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
3717
4509
|
# Collect entries.
|
|
3718
4510
|
all_entries = get_entries(range_start, range_end)
|
|
3719
4511
|
|
|
4512
|
+
_emit_debug_samples_if_set(
|
|
4513
|
+
args, all_entries, command_label="daily",
|
|
4514
|
+
)
|
|
4515
|
+
|
|
3720
4516
|
# Build the unified daily view (spec §5.1: gap-free; the dashboard
|
|
3721
4517
|
# heatmap's contiguous-window materialization stays at the dashboard
|
|
3722
4518
|
# envelope adapter so CLI byte-stability is preserved). Consume
|
|
@@ -3775,6 +4571,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
3775
4571
|
title_suffix="Daily",
|
|
3776
4572
|
compact_split_fn=_daily_compact_split,
|
|
3777
4573
|
breakdown=args.breakdown,
|
|
4574
|
+
compact=getattr(args, "compact", False),
|
|
3778
4575
|
))
|
|
3779
4576
|
return 0
|
|
3780
4577
|
|
|
@@ -3782,7 +4579,8 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
3782
4579
|
def cmd_monthly(args: argparse.Namespace) -> int:
|
|
3783
4580
|
"""Show usage report grouped by display-timezone calendar month."""
|
|
3784
4581
|
_share_validate_args(args)
|
|
3785
|
-
config =
|
|
4582
|
+
config = _load_claude_config_for_args(args)
|
|
4583
|
+
_bridge_z_into_tz(args, config)
|
|
3786
4584
|
tz = resolve_display_tz(args, config)
|
|
3787
4585
|
args._resolved_tz = tz
|
|
3788
4586
|
|
|
@@ -3797,6 +4595,10 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
3797
4595
|
|
|
3798
4596
|
all_entries = get_entries(range_start, range_end)
|
|
3799
4597
|
|
|
4598
|
+
_emit_debug_samples_if_set(
|
|
4599
|
+
args, all_entries, command_label="monthly",
|
|
4600
|
+
)
|
|
4601
|
+
|
|
3800
4602
|
# Build the unified monthly view (spec §5.2: drops boundary-spillover
|
|
3801
4603
|
# bucket; computes delta_cost_pct internally). Consume
|
|
3802
4604
|
# `view.aggregated` (BucketUsage tuple, newest-first) for CLI byte-
|
|
@@ -3849,6 +4651,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
3849
4651
|
title_suffix="Monthly",
|
|
3850
4652
|
compact_split_fn=_monthly_compact_split,
|
|
3851
4653
|
breakdown=args.breakdown,
|
|
4654
|
+
compact=getattr(args, "compact", False),
|
|
3852
4655
|
))
|
|
3853
4656
|
return 0
|
|
3854
4657
|
|
|
@@ -3856,7 +4659,8 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
3856
4659
|
def cmd_weekly(args: argparse.Namespace) -> int:
|
|
3857
4660
|
"""Show Claude usage grouped by subscription week."""
|
|
3858
4661
|
_share_validate_args(args)
|
|
3859
|
-
config =
|
|
4662
|
+
config = _load_claude_config_for_args(args)
|
|
4663
|
+
_bridge_z_into_tz(args, config)
|
|
3860
4664
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
3861
4665
|
|
|
3862
4666
|
now_utc = _command_as_of()
|
|
@@ -3869,8 +4673,12 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
3869
4673
|
|
|
3870
4674
|
# Build the subscription-week list spanning the range. Boundaries are
|
|
3871
4675
|
# anchored in `weekly_usage_snapshots` when available and otherwise
|
|
3872
|
-
# extrapolated (see `_compute_subscription_weeks`).
|
|
3873
|
-
|
|
4676
|
+
# extrapolated (see `_compute_subscription_weeks`). Pass the
|
|
4677
|
+
# `--config`-honoring resolved config (issue #88) so the no-snapshot
|
|
4678
|
+
# calendar-week fallback uses the explicit override's `week_start`.
|
|
4679
|
+
weeks = _compute_subscription_weeks(
|
|
4680
|
+
conn, range_start, range_end, config=config,
|
|
4681
|
+
)
|
|
3874
4682
|
|
|
3875
4683
|
# Fetch entries and aggregate.
|
|
3876
4684
|
# Cover each SubWeek's full [start_ts, end_ts) on the range_start side —
|
|
@@ -3889,6 +4697,10 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
3889
4697
|
fetch_start = range_start
|
|
3890
4698
|
all_entries = get_entries(fetch_start, range_end)
|
|
3891
4699
|
|
|
4700
|
+
_emit_debug_samples_if_set(
|
|
4701
|
+
args, all_entries, command_label="weekly",
|
|
4702
|
+
)
|
|
4703
|
+
|
|
3892
4704
|
# Bound the usage-snapshot lookup to `<= range_end` so historical
|
|
3893
4705
|
# `--until <past date>` queries pick the usage% that was current at
|
|
3894
4706
|
# the end of the requested window rather than the globally latest
|
|
@@ -3966,6 +4778,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
3966
4778
|
weeks=weeks,
|
|
3967
4779
|
compact_split_fn=_daily_compact_split,
|
|
3968
4780
|
breakdown=args.breakdown,
|
|
4781
|
+
compact=getattr(args, "compact", False),
|
|
3969
4782
|
))
|
|
3970
4783
|
return 0
|
|
3971
4784
|
|
|
@@ -3989,6 +4802,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
|
|
|
3989
4802
|
range_start, range_end = range
|
|
3990
4803
|
|
|
3991
4804
|
entries = get_codex_entries(range_start, range_end)
|
|
4805
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily")
|
|
3992
4806
|
# Route through ``build_codex_daily_view`` (issue #58). The View
|
|
3993
4807
|
# wraps ``_aggregate_codex_daily`` without changing it — preserves
|
|
3994
4808
|
# LiteLLM token semantics, intentional dedup vs upstream, and
|
|
@@ -4048,6 +4862,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
|
|
|
4048
4862
|
range_start, range_end = range
|
|
4049
4863
|
|
|
4050
4864
|
entries = get_codex_entries(range_start, range_end)
|
|
4865
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly")
|
|
4051
4866
|
# Route through ``build_codex_monthly_view`` (issue #58).
|
|
4052
4867
|
view = build_codex_monthly_view(
|
|
4053
4868
|
entries, now_utc=_command_as_of(), tz_name=tz_name,
|
|
@@ -4107,6 +4922,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
|
|
|
4107
4922
|
week_start_idx = WEEKDAY_MAP[week_start_name]
|
|
4108
4923
|
|
|
4109
4924
|
entries = get_codex_entries(range_start, range_end)
|
|
4925
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly")
|
|
4110
4926
|
# Route through ``build_codex_weekly_view`` (issue #58).
|
|
4111
4927
|
view = build_codex_weekly_view(
|
|
4112
4928
|
entries, now_utc=now_utc, tz_name=tz_name,
|
|
@@ -4167,6 +4983,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
|
|
|
4167
4983
|
range_start, range_end = range
|
|
4168
4984
|
|
|
4169
4985
|
entries = get_codex_entries(range_start, range_end)
|
|
4986
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-session")
|
|
4170
4987
|
# Route through ``build_codex_session_view`` (issue #58). View rows
|
|
4171
4988
|
# come descending by last_activity (aggregator default + upstream
|
|
4172
4989
|
# parity); --order asc reverses.
|
|
@@ -4426,7 +5243,10 @@ def _project_sort_key(row: dict, sort_by: str, order: str):
|
|
|
4426
5243
|
def cmd_project(args: argparse.Namespace) -> int:
|
|
4427
5244
|
"""Roll entries up by project (git-root) with per-project usage attribution."""
|
|
4428
5245
|
_share_validate_args(args)
|
|
4429
|
-
config =
|
|
5246
|
+
config = _load_claude_config_for_args(args)
|
|
5247
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
5248
|
+
# existing resolve_display_tz precedence absorbs the new alias.
|
|
5249
|
+
_bridge_z_into_tz(args, config)
|
|
4430
5250
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
4431
5251
|
|
|
4432
5252
|
# Flag-combination validation (must run before any expensive work).
|
|
@@ -4477,7 +5297,7 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
4477
5297
|
# false, which would otherwise wrongly fall through to the Monday
|
|
4478
5298
|
# fallback for non-Monday-reset accounts).
|
|
4479
5299
|
current_weeks = _compute_subscription_weeks(
|
|
4480
|
-
conn, now, now + dt.timedelta(microseconds=1)
|
|
5300
|
+
conn, now, now + dt.timedelta(microseconds=1), config=config,
|
|
4481
5301
|
)
|
|
4482
5302
|
if current_weeks:
|
|
4483
5303
|
cw_start = parse_iso_datetime(
|
|
@@ -4497,7 +5317,9 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
4497
5317
|
# Pre-compute subscription-week bounds for the query window so each entry
|
|
4498
5318
|
# can be bucketed onto a canonical subscription-week start_ts. Mirrors
|
|
4499
5319
|
# `_aggregate_weekly`'s bisect pattern (first-match-wins on overlap).
|
|
4500
|
-
subweeks = _compute_subscription_weeks(
|
|
5320
|
+
subweeks = _compute_subscription_weeks(
|
|
5321
|
+
conn, since_dt, until_dt, config=config,
|
|
5322
|
+
)
|
|
4501
5323
|
parsed_bounds: list[tuple[dt.datetime, dt.datetime]] = []
|
|
4502
5324
|
for sw in subweeks:
|
|
4503
5325
|
s_dt = parse_iso_datetime(sw.start_ts, "week.start_ts").astimezone(dt.timezone.utc)
|
|
@@ -4548,7 +5370,45 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
4548
5370
|
unknown_entry_count = 0
|
|
4549
5371
|
missing_sid_count = 0
|
|
4550
5372
|
|
|
4551
|
-
|
|
5373
|
+
# Issue #89: materialize the joined-entry iterator once so we can
|
|
5374
|
+
# (a) pre-compute the --debug report's scope (entries passing all
|
|
5375
|
+
# rendered-row filters — user slice + --model + --project) BEFORE
|
|
5376
|
+
# the aggregation loop runs and (b) preserve the existing
|
|
5377
|
+
# aggregation semantics (denominator widened to ALL entries; visible
|
|
5378
|
+
# rows only the post-filter subset). The list is small enough to
|
|
5379
|
+
# hold (entries already in memory via the cache row factory).
|
|
5380
|
+
joined_entries_all = list(get_claude_session_entries(scan_start, scan_end))
|
|
5381
|
+
|
|
5382
|
+
# Build the --debug report dataset: skip synthetic + out-of-window
|
|
5383
|
+
# entries, then apply --model and --project filters (mirroring the
|
|
5384
|
+
# exact predicate at the aggregation loop below). This must match
|
|
5385
|
+
# the rendered scope, NOT the denominator scope.
|
|
5386
|
+
if getattr(args, "debug", False):
|
|
5387
|
+
filtered_for_report = []
|
|
5388
|
+
for je in joined_entries_all:
|
|
5389
|
+
if je.model == "<synthetic>":
|
|
5390
|
+
continue
|
|
5391
|
+
if _week_start_for(je.timestamp) is None:
|
|
5392
|
+
continue
|
|
5393
|
+
if je.timestamp < since_dt or je.timestamp > until_dt:
|
|
5394
|
+
continue
|
|
5395
|
+
if model_patterns:
|
|
5396
|
+
mname = (je.model or "").lower()
|
|
5397
|
+
if not any(p in mname for p in model_patterns):
|
|
5398
|
+
continue
|
|
5399
|
+
key_for_filter = _resolve_project_key(
|
|
5400
|
+
je.project_path, args.group, resolver_cache,
|
|
5401
|
+
)
|
|
5402
|
+
if not _project_filter_matches(key_for_filter, project_patterns):
|
|
5403
|
+
continue
|
|
5404
|
+
filtered_for_report.append(je)
|
|
5405
|
+
_emit_debug_samples_if_set(
|
|
5406
|
+
args,
|
|
5407
|
+
[_usage_entry_from_joined(je) for je in filtered_for_report],
|
|
5408
|
+
command_label="project",
|
|
5409
|
+
)
|
|
5410
|
+
|
|
5411
|
+
for entry in joined_entries_all:
|
|
4552
5412
|
# Skip synthetic entries (Claude Code internal markers) to match
|
|
4553
5413
|
# `_aggregate_cache_by_session` / `_aggregate_claude_sessions`.
|
|
4554
5414
|
if entry.model == "<synthetic>":
|
|
@@ -4813,13 +5673,21 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
4813
5673
|
eprint("No project usage found in range.")
|
|
4814
5674
|
return 0
|
|
4815
5675
|
|
|
5676
|
+
# Session A (spec §7.3): the new --color flag overrides NO_COLOR
|
|
5677
|
+
# env; --no-color overrides FORCE_COLOR env; deny-wins on the
|
|
5678
|
+
# --color + --no-color clash. _resolve_color_enabled returns the
|
|
5679
|
+
# effective bool; pass it as ``color=`` so the renderer skips its
|
|
5680
|
+
# internal _supports_color_stdout() auto-detect (which would
|
|
5681
|
+
# re-consult NO_COLOR and incorrectly disable color when the user
|
|
5682
|
+
# passed --color under NO_COLOR=1).
|
|
4816
5683
|
print(_render_project_table(
|
|
4817
5684
|
sorted_rows,
|
|
4818
5685
|
title=title,
|
|
4819
5686
|
breakdown=args.breakdown,
|
|
4820
5687
|
weeks_missing_snapshot=len(weeks_missing_snapshot),
|
|
4821
5688
|
weeks_in_range=len(weeks_in_range),
|
|
4822
|
-
|
|
5689
|
+
color=_resolve_color_enabled(args),
|
|
5690
|
+
compact=args.compact,
|
|
4823
5691
|
))
|
|
4824
5692
|
return 0
|
|
4825
5693
|
|
|
@@ -4909,7 +5777,10 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
4909
5777
|
|
|
4910
5778
|
# Validation already happened via _argparse_tz; resolve now to a ZoneInfo
|
|
4911
5779
|
# (or None for "local") and derive the IANA name for window resolution.
|
|
4912
|
-
config =
|
|
5780
|
+
config = _load_claude_config_for_args(args)
|
|
5781
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
5782
|
+
# existing resolve_display_tz precedence absorbs the new alias.
|
|
5783
|
+
_bridge_z_into_tz(args, config)
|
|
4913
5784
|
tz_obj = resolve_display_tz(args, config)
|
|
4914
5785
|
args._resolved_tz = tz_obj
|
|
4915
5786
|
tz_name = (tz_obj.key if tz_obj is not None else _local_tz_name())
|
|
@@ -4934,16 +5805,12 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
4934
5805
|
print(f"diff: {exc}", file=sys.stderr)
|
|
4935
5806
|
return 2
|
|
4936
5807
|
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
threshold = dataclasses.replace(threshold, min_delta_usd=args.min_delta_usd)
|
|
4944
|
-
if args.min_delta_pct is not None:
|
|
4945
|
-
threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
|
|
4946
|
-
|
|
5808
|
+
# Validate the remaining CLI surface (`--only` / `--with`) BEFORE the
|
|
5809
|
+
# `--debug` emission below. `_emit_diff_debug_samples` prints reports and,
|
|
5810
|
+
# under `--sync`, runs a cache ingest (a local-state mutation). A
|
|
5811
|
+
# fail-fast usage error like `diff ... --only bogus --debug --sync` must
|
|
5812
|
+
# not print unrelated debug output or touch the cache before returning
|
|
5813
|
+
# exit 2 — so the validation gate has to precede the debug scan.
|
|
4947
5814
|
sections_requested = ["overall", "models", "projects", "cache"]
|
|
4948
5815
|
if args.only is not None:
|
|
4949
5816
|
sections_requested = [s.strip() for s in args.only.split(",") if s.strip()]
|
|
@@ -4970,6 +5837,23 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
4970
5837
|
)
|
|
4971
5838
|
return 1
|
|
4972
5839
|
|
|
5840
|
+
# Issue #89 spec §7.2.2 Pattern D: emit one --debug report per window
|
|
5841
|
+
# before any rendering, with window-A then window-B labels. Runs only
|
|
5842
|
+
# after the validation gate above so a usage error fails fast without
|
|
5843
|
+
# debug output or a cache sync.
|
|
5844
|
+
if getattr(args, "debug", False):
|
|
5845
|
+
_emit_diff_debug_samples(args, window_a, window_b)
|
|
5846
|
+
|
|
5847
|
+
threshold = NoiseThreshold(
|
|
5848
|
+
show_all=bool(args.show_all),
|
|
5849
|
+
user_override=(args.min_delta_usd is not None
|
|
5850
|
+
or args.min_delta_pct is not None),
|
|
5851
|
+
)
|
|
5852
|
+
if args.min_delta_usd is not None:
|
|
5853
|
+
threshold = dataclasses.replace(threshold, min_delta_usd=args.min_delta_usd)
|
|
5854
|
+
if args.min_delta_pct is not None:
|
|
5855
|
+
threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
|
|
5856
|
+
|
|
4973
5857
|
try:
|
|
4974
5858
|
result = _build_diff_result(
|
|
4975
5859
|
window_a, window_b,
|
|
@@ -5002,12 +5886,18 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
5002
5886
|
print(_diff_render_json(result, options=options))
|
|
5003
5887
|
return 0
|
|
5004
5888
|
|
|
5005
|
-
|
|
5889
|
+
# Session A (spec §7.3): route through the new color resolver so
|
|
5890
|
+
# the bool --color flag overrides NO_COLOR env, --no-color overrides
|
|
5891
|
+
# FORCE_COLOR env, and deny-wins on the --color + --no-color clash.
|
|
5892
|
+
# The old computation (sys.stdout.isatty() and not args.no_color)
|
|
5893
|
+
# only honored --no-color + isatty; the resolver supersedes both
|
|
5894
|
+
# with the full spec §7.3 precedence.
|
|
5895
|
+
color = _resolve_color_enabled(args)
|
|
5006
5896
|
width = args.width or shutil.get_terminal_size().columns
|
|
5007
5897
|
width = max(80, min(width, 160))
|
|
5008
5898
|
print(_diff_render_full_output(
|
|
5009
5899
|
result, color=color, width=width, raw_aggregates=result.raw_totals,
|
|
5010
|
-
tz=tz_obj,
|
|
5900
|
+
tz=tz_obj, compact=args.compact,
|
|
5011
5901
|
))
|
|
5012
5902
|
return 0
|
|
5013
5903
|
|
|
@@ -5015,7 +5905,8 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
5015
5905
|
def cmd_session(args: argparse.Namespace) -> int:
|
|
5016
5906
|
"""Show Claude usage grouped by sessionId (merges resumed-across-files sessions)."""
|
|
5017
5907
|
_share_validate_args(args)
|
|
5018
|
-
config =
|
|
5908
|
+
config = _load_claude_config_for_args(args)
|
|
5909
|
+
_bridge_z_into_tz(args, config)
|
|
5019
5910
|
tz = resolve_display_tz(args, config)
|
|
5020
5911
|
args._resolved_tz = tz
|
|
5021
5912
|
|
|
@@ -5029,6 +5920,27 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
5029
5920
|
range_start, range_end = range
|
|
5030
5921
|
|
|
5031
5922
|
entries = get_claude_session_entries(range_start, range_end)
|
|
5923
|
+
|
|
5924
|
+
# Issue #89: --debug report describes the joined-entry list filtered
|
|
5925
|
+
# by --id (post-fallback session_id resolution) when set, matching
|
|
5926
|
+
# the rendered scope of `sessions`.
|
|
5927
|
+
# `is not None`, not truthiness: an explicit empty `--id ''` must still
|
|
5928
|
+
# engage the filter (→ empty render), not silently fall through to
|
|
5929
|
+
# "describe/show all sessions" (code-review finding). Mirrored on the
|
|
5930
|
+
# post-aggregation filter below.
|
|
5931
|
+
if getattr(args, "id", None) is not None:
|
|
5932
|
+
joined_for_report = [
|
|
5933
|
+
je for je in entries
|
|
5934
|
+
if _resolve_session_id_for_filter(je) == args.id
|
|
5935
|
+
]
|
|
5936
|
+
else:
|
|
5937
|
+
joined_for_report = entries
|
|
5938
|
+
_emit_debug_samples_if_set(
|
|
5939
|
+
args,
|
|
5940
|
+
[_usage_entry_from_joined(je) for je in joined_for_report],
|
|
5941
|
+
command_label="session",
|
|
5942
|
+
)
|
|
5943
|
+
|
|
5032
5944
|
# Unified view-model kernel (spec §6.5). `limit=None` keeps the
|
|
5033
5945
|
# full aggregator output — `cctally session` has no `--limit` flag
|
|
5034
5946
|
# and emits every session in the requested range. `view.aggregated`
|
|
@@ -5044,6 +5956,16 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
5044
5956
|
)
|
|
5045
5957
|
sessions = list(view.aggregated)
|
|
5046
5958
|
|
|
5959
|
+
# Session A (spec §7.4): exact-string filter on sessionId. Applied
|
|
5960
|
+
# AFTER aggregation (so resume-merged sessions across multiple JSONL
|
|
5961
|
+
# files are matched against their post-merge id) and BEFORE the
|
|
5962
|
+
# `--order asc` reversal and the JSON / share / table render
|
|
5963
|
+
# branches. Unknown id → empty `sessions` list, which falls through
|
|
5964
|
+
# to the existing "no sessions" branch (table: "No Claude session
|
|
5965
|
+
# data found."; JSON: `{"sessions": []}`).
|
|
5966
|
+
if getattr(args, "id", None) is not None: # explicit '' still filters
|
|
5967
|
+
sessions = [s for s in sessions if s.session_id == args.id]
|
|
5968
|
+
|
|
5047
5969
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
5048
5970
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
5049
5971
|
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
@@ -5075,8 +5997,17 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
5075
5997
|
# sub-rows aren't in the share spec scope). Same convention as
|
|
5076
5998
|
# cmd_daily / cmd_project.
|
|
5077
5999
|
display_tz_str = _share_display_tz_label(tz)
|
|
6000
|
+
# Session A (spec §7.4): `_build_session_snapshot` reads
|
|
6001
|
+
# `view.aggregated`, so the `--id` filter applied to the local
|
|
6002
|
+
# `sessions` list above would otherwise be ignored for share
|
|
6003
|
+
# exports (HTML/Markdown/SVG). Hand the builder a view whose
|
|
6004
|
+
# `aggregated` is the filtered list so `--id` is honored across
|
|
6005
|
+
# every output path. The builder reads only `aggregated` (never
|
|
6006
|
+
# `rows` / `total_sessions`), so the parallel-tuple mismatch from
|
|
6007
|
+
# the replace is inert here.
|
|
6008
|
+
share_view = dataclasses.replace(view, aggregated=tuple(sessions))
|
|
5078
6009
|
snap = _build_session_snapshot(
|
|
5079
|
-
|
|
6010
|
+
share_view,
|
|
5080
6011
|
period_start=range_start,
|
|
5081
6012
|
period_end=range_end,
|
|
5082
6013
|
display_tz=display_tz_str,
|
|
@@ -5101,10 +6032,14 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
5101
6032
|
print("No Claude session data found.")
|
|
5102
6033
|
return 0
|
|
5103
6034
|
|
|
6035
|
+
# Session A (spec §7.6.1; Review-A P2-B): thread --compact through
|
|
6036
|
+
# so the renderer's scale-down branch fires regardless of terminal
|
|
6037
|
+
# width when the flag is set.
|
|
5104
6038
|
print(_render_claude_session_table(
|
|
5105
6039
|
sessions,
|
|
5106
6040
|
breakdown=args.breakdown,
|
|
5107
6041
|
tz=tz,
|
|
6042
|
+
compact=getattr(args, "compact", False),
|
|
5108
6043
|
))
|
|
5109
6044
|
return 0
|
|
5110
6045
|
|
|
@@ -7178,19 +8113,19 @@ def _latest_seven_day_and_window(
|
|
|
7178
8113
|
|
|
7179
8114
|
|
|
7180
8115
|
def _parse_date_filter(value: str, flag_name: str) -> str:
|
|
7181
|
-
"""Parse ``
|
|
8116
|
+
"""Parse ``YYYY-MM-DD`` or ``YYYYMMDD`` into an ISO date for SQL ``WHERE`` clauses.
|
|
7182
8117
|
|
|
7183
8118
|
Used by ``cmd_five_hour_blocks`` ``--since``/``--until``. Mirrors the
|
|
7184
|
-
upstream ccusage convention.
|
|
7185
|
-
|
|
7186
|
-
|
|
8119
|
+
upstream ccusage convention. Routes through the centralized
|
|
8120
|
+
``_parse_dual_form_date`` (spec §7.1.1) so the dual-form contract and
|
|
8121
|
+
error message are shared with cmd_blocks / cmd_daily / etc.
|
|
8122
|
+
|
|
8123
|
+
The helper already eprints its own diagnostic and raises a bare
|
|
8124
|
+
``ValueError``; we propagate that bare exception so callers can
|
|
8125
|
+
return an exit code without double-printing (Review-A P1-1; mirrors
|
|
8126
|
+
the bare-re-raise pattern used by ``cmd_cache_report``).
|
|
7187
8127
|
"""
|
|
7188
|
-
|
|
7189
|
-
return dt.datetime.strptime(value, "%Y%m%d").date().isoformat()
|
|
7190
|
-
except ValueError as e:
|
|
7191
|
-
raise ValueError(
|
|
7192
|
-
f"five-hour-blocks: {flag_name} expects YYYYMMDD (got '{value}')"
|
|
7193
|
-
) from e
|
|
8128
|
+
return _parse_dual_form_date(value, flag_name).date().isoformat()
|
|
7194
8129
|
|
|
7195
8130
|
|
|
7196
8131
|
def _load_breakdown(
|
|
@@ -7219,7 +8154,10 @@ def _load_breakdown(
|
|
|
7219
8154
|
def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
7220
8155
|
"""List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
|
|
7221
8156
|
_share_validate_args(args)
|
|
7222
|
-
config =
|
|
8157
|
+
config = _load_claude_config_for_args(args)
|
|
8158
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz before
|
|
8159
|
+
# resolve_display_tz so the new alias precedence lands.
|
|
8160
|
+
_bridge_z_into_tz(args, config)
|
|
7223
8161
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
7224
8162
|
# Pin "now" once (CCTALLY_AS_OF for fixture-pinned harnesses; mirrors
|
|
7225
8163
|
# cmd_five_hour_breakdown). Used by the active-predicate to gate
|
|
@@ -7228,6 +8166,9 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
7228
8166
|
conn = open_db()
|
|
7229
8167
|
try:
|
|
7230
8168
|
# Date filter parsing — same convention as cmd_blocks.
|
|
8169
|
+
# _parse_date_filter routes through _parse_dual_form_date, which
|
|
8170
|
+
# eprints its own diagnostic and raises a bare ValueError on bad
|
|
8171
|
+
# input (Review-A P1-1 — dedup stderr by NOT re-emitting here).
|
|
7231
8172
|
try:
|
|
7232
8173
|
since_iso = (
|
|
7233
8174
|
_parse_date_filter(args.since, "--since")
|
|
@@ -7237,8 +8178,7 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
7237
8178
|
_parse_date_filter(args.until, "--until")
|
|
7238
8179
|
if args.until else None
|
|
7239
8180
|
)
|
|
7240
|
-
except ValueError
|
|
7241
|
-
print(str(e), file=sys.stderr)
|
|
8181
|
+
except ValueError:
|
|
7242
8182
|
return 2
|
|
7243
8183
|
|
|
7244
8184
|
where: list[str] = []
|
|
@@ -7266,6 +8206,31 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
7266
8206
|
params,
|
|
7267
8207
|
).fetchall()
|
|
7268
8208
|
|
|
8209
|
+
# Issue #89: --debug report scope = the time range spanned by
|
|
8210
|
+
# the rendered block rows. When `rows` is empty, pass an empty
|
|
8211
|
+
# list to short-circuit the loader entirely.
|
|
8212
|
+
if rows:
|
|
8213
|
+
# rows are ORDER BY block_start_at DESC; first row is newest,
|
|
8214
|
+
# last row is oldest. The rendered window is
|
|
8215
|
+
# [oldest_block_start, newest_block_start + BLOCK_DURATION).
|
|
8216
|
+
oldest_start_iso = rows[-1]["block_start_at"]
|
|
8217
|
+
newest_start_iso = rows[0]["block_start_at"]
|
|
8218
|
+
block_window_start = parse_iso_datetime(
|
|
8219
|
+
oldest_start_iso, "block_start_at",
|
|
8220
|
+
)
|
|
8221
|
+
block_window_end = parse_iso_datetime(
|
|
8222
|
+
newest_start_iso, "block_start_at",
|
|
8223
|
+
) + BLOCK_DURATION
|
|
8224
|
+
_emit_debug_samples_if_set(
|
|
8225
|
+
args,
|
|
8226
|
+
lambda: get_entries(block_window_start, block_window_end),
|
|
8227
|
+
command_label="five-hour-blocks",
|
|
8228
|
+
)
|
|
8229
|
+
else:
|
|
8230
|
+
_emit_debug_samples_if_set(
|
|
8231
|
+
args, [], command_label="five-hour-blocks",
|
|
8232
|
+
)
|
|
8233
|
+
|
|
7269
8234
|
# Detect truncation: cap applied AND there's at least one older
|
|
7270
8235
|
# block beyond the cap. Probe with LIMIT 1 OFFSET <cap> over the
|
|
7271
8236
|
# SAME filter set (none here, but kept symmetric for clarity).
|
|
@@ -7868,18 +8833,28 @@ def _resolve_cache_report_window(
|
|
|
7868
8833
|
# Full-ISO args carry an explicit time component or tz marker.
|
|
7869
8834
|
if "T" in raw or "+" in raw or "Z" in raw:
|
|
7870
8835
|
return parse_iso_datetime(raw, flag)
|
|
7871
|
-
# Date-only:
|
|
7872
|
-
#
|
|
7873
|
-
|
|
8836
|
+
# Date-only: route through the centralized dual-form helper
|
|
8837
|
+
# (spec §7.1.1) so YYYY-MM-DD / YYYYMMDD parsing and the error
|
|
8838
|
+
# message stay consistent with cmd_blocks / cmd_daily / etc.
|
|
8839
|
+
naive = _try_dual_form_date(raw)
|
|
8840
|
+
if naive is None:
|
|
8841
|
+
# Second-chance (issue #101): the pre-Session-A code fell
|
|
8842
|
+
# through to parse_iso_datetime here, so space-separated
|
|
8843
|
+
# datetimes (`2026-05-01 12:30:00`) and ISO week-dates
|
|
8844
|
+
# (`2026-W18-1`) — both accepted by datetime.fromisoformat but
|
|
8845
|
+
# rejected by the dual-form parser — kept working. cache-report
|
|
8846
|
+
# is the only date-taking command with this fallthrough; the
|
|
8847
|
+
# other commands accept YYYY-MM-DD / YYYYMMDD only. Returned
|
|
8848
|
+
# verbatim: a full datetime carries its own time component, so
|
|
8849
|
+
# the is_upper_bound end-of-day expansion is NOT applied (it's
|
|
8850
|
+
# for bare date-only forms). On TOTAL failure (neither dual-form
|
|
8851
|
+
# nor ISO), fall back to the centralized dual-form diagnostic
|
|
8852
|
+
# rather than parse_iso_datetime's more generic message.
|
|
7874
8853
|
try:
|
|
7875
|
-
|
|
7876
|
-
break
|
|
8854
|
+
return parse_iso_datetime(raw, flag)
|
|
7877
8855
|
except ValueError:
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
# Not a date-only form either — fall back to parse_iso_datetime
|
|
7881
|
-
# so callers still get a consistent error message.
|
|
7882
|
-
return parse_iso_datetime(raw, flag)
|
|
8856
|
+
_parse_dual_form_date(raw, flag) # eprints + raises ValueError
|
|
8857
|
+
raise # unreachable — the call above always raises here
|
|
7883
8858
|
if is_upper_bound:
|
|
7884
8859
|
naive = naive.replace(
|
|
7885
8860
|
hour=23, minute=59, second=59, microsecond=999999,
|
|
@@ -8131,15 +9106,47 @@ def _sort_cache_rows(
|
|
|
8131
9106
|
|
|
8132
9107
|
|
|
8133
9108
|
def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
8134
|
-
config =
|
|
9109
|
+
config = _load_claude_config_for_args(args)
|
|
9110
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
9111
|
+
# existing resolve_display_tz precedence absorbs the new alias. The
|
|
9112
|
+
# canonical --tz still wins (it's set on the namespace before this
|
|
9113
|
+
# bridge fires); when --tz is unset and -z is supplied, use -z.
|
|
9114
|
+
_bridge_z_into_tz(args, config)
|
|
8135
9115
|
tz = resolve_display_tz(args, config)
|
|
8136
9116
|
args._resolved_tz = tz
|
|
8137
9117
|
|
|
8138
9118
|
now_utc = _command_as_of()
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
9119
|
+
# Session A (spec §7.1.1): the dual-form helper eprints its own
|
|
9120
|
+
# diagnostic and raises a bare ValueError; catch it here so main()'s
|
|
9121
|
+
# generic ``Error: {exc}`` fallback doesn't double-print an empty
|
|
9122
|
+
# trailer. Mirrors the catch-and-return-1 shape in cmd_blocks.
|
|
9123
|
+
#
|
|
9124
|
+
# Session A note (Review-A P2-D): cache-report's argparse alias
|
|
9125
|
+
# surface lands in Implementor 2's scope (B-series). The try/except
|
|
9126
|
+
# here only routes _resolve_cache_report_window's bare ValueError
|
|
9127
|
+
# around main()'s generic Error: handler — no parser-level changes.
|
|
9128
|
+
try:
|
|
9129
|
+
since, until = _resolve_cache_report_window(
|
|
9130
|
+
args, now_utc=now_utc,
|
|
9131
|
+
tz_name=(tz.key if tz is not None else None),
|
|
9132
|
+
)
|
|
9133
|
+
except ValueError as exc:
|
|
9134
|
+
# Centralized helper already eprinted on the dual-form path; for
|
|
9135
|
+
# the legacy parse_iso_datetime path (full-ISO mis-format) the
|
|
9136
|
+
# ValueError carries the message, so eprint it.
|
|
9137
|
+
msg = str(exc)
|
|
9138
|
+
if msg:
|
|
9139
|
+
eprint(f"Error: {msg}")
|
|
9140
|
+
return 1
|
|
9141
|
+
|
|
9142
|
+
# Issue #89 Pattern C: deferred loader scoped to the rendered window
|
|
9143
|
+
# (project filter mirrors what the cache-aggregator uses).
|
|
9144
|
+
_emit_debug_samples_if_set(
|
|
9145
|
+
args,
|
|
9146
|
+
lambda: get_entries(since, until, project=args.project),
|
|
9147
|
+
command_label="cache-report",
|
|
8142
9148
|
)
|
|
9149
|
+
|
|
8143
9150
|
mode = "session" if getattr(args, "by_session", False) else "day"
|
|
8144
9151
|
top_key = "sessions" if mode == "session" else "days"
|
|
8145
9152
|
|
|
@@ -8193,11 +9200,20 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
8193
9200
|
return 0
|
|
8194
9201
|
|
|
8195
9202
|
title = _build_cache_report_title(args, mode)
|
|
8196
|
-
print(_render_cache_report_table(rows, title, mode=mode, tz=tz))
|
|
9203
|
+
print(_render_cache_report_table(rows, title, mode=mode, tz=tz, compact=args.compact))
|
|
8197
9204
|
return 0
|
|
8198
9205
|
|
|
8199
9206
|
|
|
8200
9207
|
def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
9208
|
+
# Session A (spec §7.2 / §7.6 row note): range-cost has no --tz of
|
|
9209
|
+
# its own — start/end carry their own zone via ISO 8601. Calling the
|
|
9210
|
+
# bridge keeps the alias-surface contract uniform across the 10
|
|
9211
|
+
# in-scope cmds: -z/--timezone lands on args.tz unchanged (no
|
|
9212
|
+
# downstream consumer), so this is a documented no-op for this
|
|
9213
|
+
# command. The bridge still runs _resolve_claude_tz_name so the §9.2a
|
|
9214
|
+
# production-path coverage is exercised here too.
|
|
9215
|
+
config = _load_claude_config_for_args(args)
|
|
9216
|
+
_bridge_z_into_tz(args, config)
|
|
8201
9217
|
start_dt = parse_iso_datetime(args.start, "--start")
|
|
8202
9218
|
if args.end:
|
|
8203
9219
|
end_dt = parse_iso_datetime(args.end, "--end")
|
|
@@ -8214,7 +9230,17 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
|
8214
9230
|
last_match: dt.datetime | None = None
|
|
8215
9231
|
model_buckets: dict[str, dict[str, Any]] = {}
|
|
8216
9232
|
|
|
8217
|
-
|
|
9233
|
+
# Issue #89: keep the loaded list around so the --debug report can
|
|
9234
|
+
# describe the same dataset as the rendered output. Project filter is
|
|
9235
|
+
# applied at the loader (SELECT-time), so the scope is the same.
|
|
9236
|
+
# P2.2 (issue #89 review-loop): get_entries already returns
|
|
9237
|
+
# list[UsageEntry] per bin/_cctally_cache.py:1224 — no list() wrap.
|
|
9238
|
+
entries_list = get_entries(start_dt, end_dt, project=args.project)
|
|
9239
|
+
_emit_debug_samples_if_set(
|
|
9240
|
+
args, entries_list, command_label="range-cost",
|
|
9241
|
+
)
|
|
9242
|
+
|
|
9243
|
+
for entry in entries_list:
|
|
8218
9244
|
cost = _calculate_entry_cost(
|
|
8219
9245
|
entry.model, entry.usage, mode=args.mode, cost_usd=entry.cost_usd,
|
|
8220
9246
|
)
|
|
@@ -8319,7 +9345,7 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
|
8319
9345
|
])
|
|
8320
9346
|
|
|
8321
9347
|
aligns = ["left"] + ["right"] * (len(headers) - 1)
|
|
8322
|
-
print(_boxed_table(headers, rows, aligns))
|
|
9348
|
+
print(_boxed_table(headers, rows, aligns, compact=args.compact))
|
|
8323
9349
|
return 0
|
|
8324
9350
|
|
|
8325
9351
|
# Default: print cost
|
|
@@ -8733,6 +9759,110 @@ def _argparse_has_arg(parser, option_string: str) -> bool:
|
|
|
8733
9759
|
return False
|
|
8734
9760
|
|
|
8735
9761
|
|
|
9762
|
+
def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
|
|
9763
|
+
"""Attach the Session A ccusage alias surface to a Claude-cmd subparser.
|
|
9764
|
+
|
|
9765
|
+
Sibling to ``_add_codex_shared_args`` (declared inside ``build_parser``)
|
|
9766
|
+
but tailored for Claude commands. Every flag is guarded with
|
|
9767
|
+
``_argparse_has_arg`` so existing per-parser declarations
|
|
9768
|
+
(cache-report's ``--offline``, project / five-hour-blocks / diff's
|
|
9769
|
+
``--no-color``) do NOT cause ``argparse.ArgumentError`` — the helper
|
|
9770
|
+
just skips the duplicate. This makes future collisions self-healing
|
|
9771
|
+
when a contributor adds a Session A-managed flag directly on a
|
|
9772
|
+
subparser.
|
|
9773
|
+
|
|
9774
|
+
Args:
|
|
9775
|
+
parser: the subparser to mutate.
|
|
9776
|
+
ansi_emit: ``True`` for project + diff (the 2 real ANSI emitters).
|
|
9777
|
+
``False`` for the other 8 in-scope cmds. Controls only
|
|
9778
|
+
the ``--color`` help text and whether ``--no-color`` is
|
|
9779
|
+
attempted as a fresh add (when ``ansi_emit=True`` we
|
|
9780
|
+
skip ``--no-color`` entirely — those parsers already
|
|
9781
|
+
declared it themselves).
|
|
9782
|
+
|
|
9783
|
+
Spec §7.1.2 / issue #86 Session A.
|
|
9784
|
+
"""
|
|
9785
|
+
|
|
9786
|
+
def _maybe_add(opt: str, *args, **kwargs):
|
|
9787
|
+
if _argparse_has_arg(parser, opt):
|
|
9788
|
+
return
|
|
9789
|
+
parser.add_argument(opt, *args, **kwargs)
|
|
9790
|
+
|
|
9791
|
+
def _maybe_add2(opt1: str, opt2: str, *args, **kwargs):
|
|
9792
|
+
# Two-form add (short + long) — skip if EITHER is present.
|
|
9793
|
+
if _argparse_has_arg(parser, opt1) or _argparse_has_arg(parser, opt2):
|
|
9794
|
+
return
|
|
9795
|
+
parser.add_argument(opt1, opt2, *args, **kwargs)
|
|
9796
|
+
|
|
9797
|
+
_maybe_add2(
|
|
9798
|
+
"-z", "--timezone", default=None, metavar="TZ",
|
|
9799
|
+
help="Alias for --tz (drop-in compat with ccusage). When both "
|
|
9800
|
+
"are supplied, --tz wins.",
|
|
9801
|
+
)
|
|
9802
|
+
_maybe_add2(
|
|
9803
|
+
"-O", "--offline",
|
|
9804
|
+
action=argparse.BooleanOptionalAction, default=False,
|
|
9805
|
+
help="Accepted for ccusage drop-in compat; cctally is always offline.",
|
|
9806
|
+
)
|
|
9807
|
+
_maybe_add(
|
|
9808
|
+
"--compact", action="store_true",
|
|
9809
|
+
help="Force compact table layout regardless of terminal width.",
|
|
9810
|
+
)
|
|
9811
|
+
_maybe_add(
|
|
9812
|
+
"--config", default=None, metavar="PATH",
|
|
9813
|
+
help="Read config from PATH for this invocation only (no "
|
|
9814
|
+
"mutation of the default config at "
|
|
9815
|
+
"~/.local/share/cctally/config.json). Missing or invalid "
|
|
9816
|
+
"PATH errors out with a clear message.",
|
|
9817
|
+
)
|
|
9818
|
+
_maybe_add2(
|
|
9819
|
+
"-d", "--debug", action="store_true",
|
|
9820
|
+
help="Emit a stderr 'Pricing Mismatch Debug Report' "
|
|
9821
|
+
"(totals + per-model stats + sample discrepancies, "
|
|
9822
|
+
"matching ccusage's --debug shape).",
|
|
9823
|
+
)
|
|
9824
|
+
_maybe_add(
|
|
9825
|
+
"--debug-samples", type=_nonneg_int, default=5, metavar="N",
|
|
9826
|
+
help="Cap on sample-discrepancy rows in the --debug report "
|
|
9827
|
+
"(default 5; N=0 suppresses the sample block; "
|
|
9828
|
+
"negatives rejected at parse time).",
|
|
9829
|
+
)
|
|
9830
|
+
_maybe_add(
|
|
9831
|
+
"--single-thread", action="store_true",
|
|
9832
|
+
help="Accepted for ccusage drop-in compat; cctally ingestion "
|
|
9833
|
+
"is already single-threaded via the session-entry cache.",
|
|
9834
|
+
)
|
|
9835
|
+
if ansi_emit:
|
|
9836
|
+
_maybe_add(
|
|
9837
|
+
"--color", action="store_true", default=False,
|
|
9838
|
+
help="Force ANSI color output (overrides NO_COLOR env). When "
|
|
9839
|
+
"neither --color nor --no-color is set, color is auto-"
|
|
9840
|
+
"detected from isatty() and NO_COLOR/FORCE_COLOR env.",
|
|
9841
|
+
)
|
|
9842
|
+
# --no-color already declared on these parsers; do nothing here.
|
|
9843
|
+
else:
|
|
9844
|
+
# No-op-for-compat surface (spec §7.3): these flags parse but do
|
|
9845
|
+
# NOT flow through the color resolver on this command. Color (where
|
|
9846
|
+
# the renderer emits any) follows the auto-detect — isatty() plus
|
|
9847
|
+
# NO_COLOR / FORCE_COLOR env — so the help must NOT claim "no ANSI
|
|
9848
|
+
# is emitted" (daily/monthly/weekly/blocks/session/cache-report DO
|
|
9849
|
+
# emit auto-detected ANSI on a TTY; only the no-color env vars
|
|
9850
|
+
# suppress it). Force/suppress color on the 2 real ANSI commands
|
|
9851
|
+
# (project, diff) instead, or use NO_COLOR=1 / FORCE_COLOR=1.
|
|
9852
|
+
_maybe_add(
|
|
9853
|
+
"--color", action="store_true", default=False,
|
|
9854
|
+
help="Accepted for ccusage drop-in compat; does not control "
|
|
9855
|
+
"this command's color. Color auto-detects from isatty() "
|
|
9856
|
+
"and honors NO_COLOR / FORCE_COLOR env.",
|
|
9857
|
+
)
|
|
9858
|
+
_maybe_add(
|
|
9859
|
+
"--no-color", action="store_true", default=False,
|
|
9860
|
+
help="Accepted for ccusage drop-in compat; does not suppress "
|
|
9861
|
+
"this command's color. Use NO_COLOR=1 env (or pipe stdout) "
|
|
9862
|
+
"to disable auto-detected ANSI.",
|
|
9863
|
+
)
|
|
9864
|
+
|
|
9865
|
+
|
|
8736
9866
|
def _add_share_args(parser, *, has_status_line: bool = False) -> None:
|
|
8737
9867
|
"""Attach shareable-reports flags + format/json mutex to a subparser.
|
|
8738
9868
|
|
|
@@ -8887,7 +10017,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
8887
10017
|
),
|
|
8888
10018
|
)
|
|
8889
10019
|
p.add_argument(
|
|
8890
|
-
"--version",
|
|
10020
|
+
"-v", "--version",
|
|
8891
10021
|
action="store_true",
|
|
8892
10022
|
default=argparse.SUPPRESS,
|
|
8893
10023
|
help="Print cctally version (from CHANGELOG.md latest release header) and exit",
|
|
@@ -9472,9 +10602,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9472
10602
|
"Adds SessionId, Last Activity, and Project identity columns.",
|
|
9473
10603
|
)
|
|
9474
10604
|
pc.add_argument(
|
|
9475
|
-
"--offline",
|
|
9476
|
-
action=
|
|
9477
|
-
help="Use cached pricing data in ccusage."
|
|
10605
|
+
"-O", "--offline",
|
|
10606
|
+
action=argparse.BooleanOptionalAction, default=False,
|
|
10607
|
+
help="Use cached pricing data in ccusage. Session A (spec §7.1.2)"
|
|
10608
|
+
" promotes the existing flag to BooleanOptionalAction + -O"
|
|
10609
|
+
" short form so the ccusage drop-in alias surface (-O,"
|
|
10610
|
+
" --offline, --no-offline) all work on cache-report; the"
|
|
10611
|
+
" behavior under each is unchanged (cctally is always"
|
|
10612
|
+
" offline — args.offline still lands as a bool).",
|
|
9478
10613
|
)
|
|
9479
10614
|
pc.add_argument(
|
|
9480
10615
|
"--project",
|
|
@@ -9521,6 +10656,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9521
10656
|
help="Display timezone: local, utc, or IANA name. "
|
|
9522
10657
|
"Overrides config display.tz for this call.",
|
|
9523
10658
|
)
|
|
10659
|
+
# Session A (spec §7.6): ansi_emit=False; existing `--offline` is
|
|
10660
|
+
# skipped by the helper's `_argparse_has_arg` guard (the collision
|
|
10661
|
+
# case spec §7.1.2 calls out explicitly).
|
|
10662
|
+
_add_ccusage_alias_args(pc, ansi_emit=False)
|
|
9524
10663
|
pc.set_defaults(func=cmd_cache_report)
|
|
9525
10664
|
|
|
9526
10665
|
# -- range-cost --
|
|
@@ -9575,6 +10714,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9575
10714
|
dest="total_only",
|
|
9576
10715
|
help="Print numeric USD total only.",
|
|
9577
10716
|
)
|
|
10717
|
+
# Session A (spec §7.6): ansi_emit=False. range-cost has no --tz of
|
|
10718
|
+
# its own (ISO timestamps carry zone info), but the helper-added
|
|
10719
|
+
# -z/--timezone still lands on the namespace; the bridge promotes
|
|
10720
|
+
# it onto args.tz where the rest of the pipeline treats it as a
|
|
10721
|
+
# documented no-op (cmd_range_cost does not consume args.tz).
|
|
10722
|
+
_add_ccusage_alias_args(rc, ansi_emit=False)
|
|
9578
10723
|
rc.set_defaults(func=cmd_range_cost)
|
|
9579
10724
|
|
|
9580
10725
|
# -- blocks --
|
|
@@ -9619,6 +10764,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9619
10764
|
help="Display timezone: local, utc, or IANA name. "
|
|
9620
10765
|
"Overrides config display.tz for this call.",
|
|
9621
10766
|
)
|
|
10767
|
+
_add_ccusage_alias_args(bl, ansi_emit=False)
|
|
9622
10768
|
bl.set_defaults(func=cmd_blocks)
|
|
9623
10769
|
|
|
9624
10770
|
# -- five-hour-blocks --
|
|
@@ -9666,7 +10812,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9666
10812
|
fhb.add_argument(
|
|
9667
10813
|
"--no-color",
|
|
9668
10814
|
action="store_true",
|
|
9669
|
-
help="
|
|
10815
|
+
help="Accepted for ccusage drop-in compat; this command emits "
|
|
10816
|
+
"plain-text output and no ANSI is suppressed.",
|
|
9670
10817
|
)
|
|
9671
10818
|
fhb.add_argument(
|
|
9672
10819
|
"--tz",
|
|
@@ -9676,6 +10823,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9676
10823
|
help="Display timezone: local, utc, or IANA name. "
|
|
9677
10824
|
"Overrides config display.tz for this call.",
|
|
9678
10825
|
)
|
|
10826
|
+
# Session A (spec §7.6 / §7.6.3): ansi_emit=False. fhb already
|
|
10827
|
+
# declares --no-color (refreshed to no-op text in spec §7.6.3); the
|
|
10828
|
+
# helper's --no-color add is short-circuited by the existing-arg
|
|
10829
|
+
# guard. The helper's --color add lands as a parsed-and-ignored
|
|
10830
|
+
# no-op (the renderer emits plain text).
|
|
10831
|
+
_add_ccusage_alias_args(fhb, ansi_emit=False)
|
|
9679
10832
|
_add_share_args(fhb)
|
|
9680
10833
|
fhb.set_defaults(func=cmd_five_hour_blocks)
|
|
9681
10834
|
|
|
@@ -9747,6 +10900,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9747
10900
|
help="Display timezone: local, utc, or IANA name. "
|
|
9748
10901
|
"Overrides config display.tz for this call.",
|
|
9749
10902
|
)
|
|
10903
|
+
_add_ccusage_alias_args(dy, ansi_emit=False)
|
|
9750
10904
|
_add_share_args(dy)
|
|
9751
10905
|
dy.set_defaults(func=cmd_daily)
|
|
9752
10906
|
|
|
@@ -9800,6 +10954,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9800
10954
|
help="Display timezone: local, utc, or IANA name. "
|
|
9801
10955
|
"Overrides config display.tz for this call.",
|
|
9802
10956
|
)
|
|
10957
|
+
_add_ccusage_alias_args(mo, ansi_emit=False)
|
|
9803
10958
|
_add_share_args(mo)
|
|
9804
10959
|
mo.set_defaults(func=cmd_monthly)
|
|
9805
10960
|
|
|
@@ -9835,6 +10990,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9835
10990
|
we.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
9836
10991
|
help="Display timezone: local, utc, or IANA name. "
|
|
9837
10992
|
"Overrides config display.tz for this call.")
|
|
10993
|
+
_add_ccusage_alias_args(we, ansi_emit=False)
|
|
9838
10994
|
_add_share_args(we)
|
|
9839
10995
|
we.set_defaults(func=cmd_weekly)
|
|
9840
10996
|
|
|
@@ -9885,6 +11041,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9885
11041
|
"config display.tz for this call. Takes precedence over "
|
|
9886
11042
|
"upstream's --timezone for drop-in parity.",
|
|
9887
11043
|
)
|
|
11044
|
+
# Issue #92: codex parity for the #89 --debug surface. Codex JSONL
|
|
11045
|
+
# has no recorded costUSD to diff against, so the report is the
|
|
11046
|
+
# codex variant ("Codex Pricing Debug Report": totals + top-N
|
|
11047
|
+
# highest computed-cost entries), wired via
|
|
11048
|
+
# _emit_codex_debug_samples_if_set in each cmd_codex_* body.
|
|
11049
|
+
parser.add_argument(
|
|
11050
|
+
"-d", "--debug", action="store_true",
|
|
11051
|
+
help="Emit a stderr 'Codex Pricing Debug Report' (totals + "
|
|
11052
|
+
"the N highest computed-cost sample entries).",
|
|
11053
|
+
)
|
|
11054
|
+
parser.add_argument(
|
|
11055
|
+
"--debug-samples", type=_nonneg_int, default=5, metavar="N",
|
|
11056
|
+
help="Cap on top-entry sample rows in the --debug report "
|
|
11057
|
+
"(default 5; N=0 suppresses the sample block; "
|
|
11058
|
+
"negatives rejected at parse time).",
|
|
11059
|
+
)
|
|
9888
11060
|
|
|
9889
11061
|
# -- codex-daily --
|
|
9890
11062
|
cd = sub.add_parser(
|
|
@@ -10039,6 +11211,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
10039
11211
|
p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
10040
11212
|
help="Display timezone: local, utc, or IANA name. "
|
|
10041
11213
|
"Overrides config display.tz for this call.")
|
|
11214
|
+
# Session A (spec §7.6): ansi_emit=True. project is one of the two
|
|
11215
|
+
# real ANSI emitters. The helper skips its --no-color add (already
|
|
11216
|
+
# declared at p_project above) and adds the new bool --color flag
|
|
11217
|
+
# whose precedence flows through _resolve_color_enabled (§7.3).
|
|
11218
|
+
_add_ccusage_alias_args(p_project, ansi_emit=True)
|
|
10042
11219
|
_add_share_args(p_project)
|
|
10043
11220
|
p_project.set_defaults(func=cmd_project)
|
|
10044
11221
|
|
|
@@ -10073,6 +11250,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
10073
11250
|
diff_p.add_argument("--json", dest="emit_json", action="store_true")
|
|
10074
11251
|
diff_p.add_argument("--width", type=int, help=argparse.SUPPRESS)
|
|
10075
11252
|
diff_p.add_argument("--debug-now", action="store_true", help=argparse.SUPPRESS)
|
|
11253
|
+
# Session A (spec §7.6): ansi_emit=True. diff is the other real
|
|
11254
|
+
# ANSI emitter. The helper skips its --no-color add (declared
|
|
11255
|
+
# above) and adds the new bool --color flag wired through
|
|
11256
|
+
# _resolve_color_enabled (§7.3). Note: --debug here collides with
|
|
11257
|
+
# diff's existing `--debug-now` (SUPPRESS'd internal flag) but
|
|
11258
|
+
# `--debug-now` is a different option string; the helper still
|
|
11259
|
+
# adds plain `--debug` cleanly.
|
|
11260
|
+
_add_ccusage_alias_args(diff_p, ansi_emit=True)
|
|
10076
11261
|
diff_p.set_defaults(func=cmd_diff)
|
|
10077
11262
|
|
|
10078
11263
|
# -- session --
|
|
@@ -10111,6 +11296,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
10111
11296
|
se.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
10112
11297
|
help="Display timezone: local, utc, or IANA name. "
|
|
10113
11298
|
"Overrides config display.tz for this call.")
|
|
11299
|
+
se.add_argument(
|
|
11300
|
+
"-i", "--id", default=None, metavar="SESSION_ID", dest="id",
|
|
11301
|
+
help="Filter to a single session by exact-string sessionId. "
|
|
11302
|
+
"Match is against the post-resume-merge id (sessions "
|
|
11303
|
+
"resumed across multiple JSONL files collapse to one id). "
|
|
11304
|
+
"Unknown id → exit 0 with the empty-render branch.",
|
|
11305
|
+
)
|
|
11306
|
+
_add_ccusage_alias_args(se, ansi_emit=False)
|
|
10114
11307
|
_add_share_args(se)
|
|
10115
11308
|
se.set_defaults(func=cmd_session)
|
|
10116
11309
|
|