cctally 1.12.0 → 1.14.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 +29 -0
- package/bin/_cctally_cache.py +4 -2
- package/bin/_cctally_config.py +55 -9
- package/bin/_cctally_core.py +50 -2
- package/bin/_cctally_db.py +79 -0
- package/bin/_cctally_record.py +7 -1
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_setup.py +80 -0
- package/bin/_lib_aggregators.py +18 -5
- package/bin/_lib_diff_kernel.py +14 -4
- package/bin/_lib_doctor.py +39 -0
- package/bin/_lib_jsonl.py +8 -1
- package/bin/_lib_render.py +236 -41
- package/bin/_lib_subscription_weeks.py +21 -3
- package/bin/cctally +1319 -90
- 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
|
|
@@ -889,6 +909,17 @@ def _migrate_legacy_data_dir() -> None:
|
|
|
889
909
|
Removable in a future major version once early users have been on
|
|
890
910
|
cctally long enough that the legacy dir is gone everywhere.
|
|
891
911
|
"""
|
|
912
|
+
# Dev-instance isolation (F2): the legacy ccusage-subscription rename is a
|
|
913
|
+
# PROD-only concern. Skip it whenever the data dir was relocated away from
|
|
914
|
+
# the canonical prod path — i.e. dev-checkout auto-detect (DEV_MODE) or an
|
|
915
|
+
# explicit CCTALLY_DATA_DIR override — so a dev run (APP_DIR = cctally-dev)
|
|
916
|
+
# or a per-branch override never hijacks the one-shot move into the wrong
|
|
917
|
+
# dir. "not DEV_MODE and not CCTALLY_DATA_DIR" is exactly the prod-default
|
|
918
|
+
# resolution branch, i.e. APP_DIR == ~/.local/share/cctally. Under the test
|
|
919
|
+
# suppressor DEV_MODE is False and the existing migration tests (which pin
|
|
920
|
+
# APP_DIR directly, no override) still exercise the move.
|
|
921
|
+
if _cctally_core.DEV_MODE or os.environ.get("CCTALLY_DATA_DIR", "").strip():
|
|
922
|
+
return
|
|
892
923
|
if _cctally_core.APP_DIR.exists():
|
|
893
924
|
return # already migrated, or fresh install at the new path
|
|
894
925
|
if not _cctally_core.LEGACY_APP_DIR.exists():
|
|
@@ -995,6 +1026,648 @@ def _sum_cost_for_range(
|
|
|
995
1026
|
return total
|
|
996
1027
|
|
|
997
1028
|
|
|
1029
|
+
def _bridge_z_into_tz(args: argparse.Namespace,
|
|
1030
|
+
config: "dict | None" = None) -> None:
|
|
1031
|
+
"""Promote ``-z`` / ``--timezone`` onto ``args.tz`` when ``--tz`` is unset.
|
|
1032
|
+
|
|
1033
|
+
Session A (spec §7.2) defines a 4-rung precedence for Claude display tz:
|
|
1034
|
+
``--tz`` > ``-z --timezone`` > ``config.display.tz`` > host-local. The
|
|
1035
|
+
existing ``resolve_display_tz`` reads only ``args.tz`` and config; the
|
|
1036
|
+
minimal-invasive way to fold in ``-z`` without modifying the shared
|
|
1037
|
+
pure-fn layer is to set ``args.tz`` here when ``--tz`` wasn't
|
|
1038
|
+
supplied. This mutates the namespace and is safe because each cmd_*
|
|
1039
|
+
receives a fresh ``args`` per invocation.
|
|
1040
|
+
|
|
1041
|
+
Internally delegates to :func:`_resolve_claude_tz_name`, which
|
|
1042
|
+
encodes the 4-rung precedence and returns the tz-name string (or
|
|
1043
|
+
``None`` for host-local fallback). This wires the resolver into the
|
|
1044
|
+
production path so the 7-case test contract (spec §9.2a) is
|
|
1045
|
+
exercised end-to-end, not only by unit tests (Review-A P2-A).
|
|
1046
|
+
|
|
1047
|
+
Validates the bridged rung-2 value via ``_argparse_tz`` so an
|
|
1048
|
+
invalid tz name from ``-z`` surfaces the same canonical error as
|
|
1049
|
+
``--tz`` (argparse-time validation can't run on the alias because
|
|
1050
|
+
the alias flag is plain string, not ``type=_argparse_tz``). The
|
|
1051
|
+
resolver returns the raw string, so we revalidate here on the path
|
|
1052
|
+
that actually bridges into ``args.tz``.
|
|
1053
|
+
|
|
1054
|
+
Error attribution is rung-aware (#90). A malformed value typed on
|
|
1055
|
+
the command line (rung-2, ``-z``/``--timezone``) is a usage error
|
|
1056
|
+
and hard-fails with the argparse-style message + exit 2. A malformed
|
|
1057
|
+
``config.display.tz`` (rung-3) is NOT promoted or hard-failed here:
|
|
1058
|
+
it falls through to ``resolve_display_tz`` / ``get_display_tz_pref``
|
|
1059
|
+
downstream, which warn once and default to host-local (exit 0). That
|
|
1060
|
+
restores the pre-Session-A contract and matches how codex commands
|
|
1061
|
+
and the dashboard already treat a malformed persisted tz pref.
|
|
1062
|
+
"""
|
|
1063
|
+
# Short-circuit: --tz already set wins all rungs (the resolver
|
|
1064
|
+
# returns args.tz unchanged anyway, but skipping avoids touching
|
|
1065
|
+
# the namespace and re-validating an already-canonical value).
|
|
1066
|
+
if getattr(args, "tz", None) is not None:
|
|
1067
|
+
return
|
|
1068
|
+
resolved = _resolve_claude_tz_name(args, config)
|
|
1069
|
+
if resolved is None:
|
|
1070
|
+
return
|
|
1071
|
+
# ``args.tz`` is None here (short-circuited above), so ``resolved``
|
|
1072
|
+
# came from rung-2 (``-z``/``--timezone``) iff ``args.timezone`` is
|
|
1073
|
+
# set; otherwise it is the rung-3 ``config.display.tz`` value.
|
|
1074
|
+
from_cli_alias = getattr(args, "timezone", None) is not None
|
|
1075
|
+
# Validate via _argparse_tz so an invalid --timezone surfaces the
|
|
1076
|
+
# canonical argparse-style error (matches --tz's type-check).
|
|
1077
|
+
try:
|
|
1078
|
+
canonical = _argparse_tz(resolved)
|
|
1079
|
+
except argparse.ArgumentTypeError as exc:
|
|
1080
|
+
if from_cli_alias:
|
|
1081
|
+
# Rung-2: a value the user typed on the command line. Surface
|
|
1082
|
+
# the same error --tz gives at parse time. We can't call
|
|
1083
|
+
# parser.error from here, so emit-and-raise via SystemExit(2)
|
|
1084
|
+
# for argparse-shape parity.
|
|
1085
|
+
eprint(f"cctally: argument -z/--timezone: {exc}")
|
|
1086
|
+
raise SystemExit(2) from exc
|
|
1087
|
+
# Rung-3: malformed config.display.tz with no CLI tz flag. Don't
|
|
1088
|
+
# mis-attribute the error to -z/--timezone or hard-fail (#90).
|
|
1089
|
+
# Leave args.tz unset; resolve_display_tz / get_display_tz_pref
|
|
1090
|
+
# warn once and default to host-local downstream (exit 0).
|
|
1091
|
+
return
|
|
1092
|
+
args.tz = canonical
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _resolve_claude_tz_name(args: argparse.Namespace,
|
|
1096
|
+
config: "dict | None") -> "str | None":
|
|
1097
|
+
"""Resolve display-tz precedence for Claude reporting commands.
|
|
1098
|
+
|
|
1099
|
+
Returns the tz name string (or ``None`` for host-local fallback).
|
|
1100
|
+
Precedence (top wins):
|
|
1101
|
+
1. ``--tz`` flag (canonical cctally flag).
|
|
1102
|
+
2. ``-z`` / ``--timezone`` flag (ccusage-codex sharedArgs alias).
|
|
1103
|
+
3. ``config.display.tz`` (persisted user pref).
|
|
1104
|
+
4. ``None`` → host-local (existing fallback in ``resolve_display_tz``).
|
|
1105
|
+
|
|
1106
|
+
Spec §7.2 (issue #86 Session A). Mirrors ``_resolve_codex_tz_name``'s
|
|
1107
|
+
structural shape for code-review familiarity, but the rung order
|
|
1108
|
+
diverges: codex's resolver falls back to upstream's ``--timezone``
|
|
1109
|
+
ONLY when neither ``--tz`` nor ``display.tz`` is set, while Claude's
|
|
1110
|
+
Session A contract puts ``-z --timezone`` ahead of ``display.tz``.
|
|
1111
|
+
Both shapes are intentional — the Codex helper preserves drop-in
|
|
1112
|
+
parity with upstream's ``ccusage-codex sharedArgs``; the Claude
|
|
1113
|
+
helper expresses the Session A precedence the spec promises.
|
|
1114
|
+
"""
|
|
1115
|
+
tz = getattr(args, "tz", None)
|
|
1116
|
+
if tz is not None:
|
|
1117
|
+
return tz
|
|
1118
|
+
tzname = getattr(args, "timezone", None)
|
|
1119
|
+
if tzname is not None:
|
|
1120
|
+
return tzname
|
|
1121
|
+
display = (config or {}).get("display") or {}
|
|
1122
|
+
cfg_tz = display.get("tz")
|
|
1123
|
+
if cfg_tz:
|
|
1124
|
+
return cfg_tz
|
|
1125
|
+
return None
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
# ---------------------------------------------------------------------------
|
|
1129
|
+
# Issue #89 — Real --debug diagnostic-sample emission
|
|
1130
|
+
# ---------------------------------------------------------------------------
|
|
1131
|
+
# Spec: docs/superpowers/specs/2026-05-23-issue-89-debug-sample-emission.md
|
|
1132
|
+
# Replaces the §7.6.2 placeholder note with a real ccusage-parity
|
|
1133
|
+
# "Pricing Mismatch Debug Report" on stderr.
|
|
1134
|
+
|
|
1135
|
+
@dataclass
|
|
1136
|
+
class _MismatchModelStat:
|
|
1137
|
+
total: int = 0
|
|
1138
|
+
matches: int = 0
|
|
1139
|
+
mismatches: int = 0
|
|
1140
|
+
avg_percent_diff: float = 0.0
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
@dataclass
|
|
1144
|
+
class _MismatchSample:
|
|
1145
|
+
file: str
|
|
1146
|
+
timestamp: str
|
|
1147
|
+
model: str
|
|
1148
|
+
original_cost: float
|
|
1149
|
+
calculated_cost: float
|
|
1150
|
+
difference: float
|
|
1151
|
+
percent_diff: float
|
|
1152
|
+
usage: dict
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
@dataclass
|
|
1156
|
+
class _MismatchStats:
|
|
1157
|
+
command_label: str | None = None
|
|
1158
|
+
total_entries: int = 0
|
|
1159
|
+
entries_with_both: int = 0
|
|
1160
|
+
matches: int = 0
|
|
1161
|
+
mismatches: int = 0
|
|
1162
|
+
model_stats: dict = field(default_factory=dict)
|
|
1163
|
+
discrepancies: list = field(default_factory=list)
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _compute_pricing_mismatch_stats(entries):
|
|
1167
|
+
"""Walk ``entries: Iterable[UsageEntry]`` and compute the mismatch stats
|
|
1168
|
+
that ``_render_pricing_mismatch_report`` consumes.
|
|
1169
|
+
|
|
1170
|
+
Mirrors ccusage upstream's ``detectMismatches``
|
|
1171
|
+
(``~/.npm/_npx/.../node_modules/ccusage/dist/debug-DvI5DUKR.js:6-95``):
|
|
1172
|
+
|
|
1173
|
+
- An entry counts toward ``entries_with_both`` iff its ``cost_usd``
|
|
1174
|
+
is not None AND the model has pricing in ``CLAUDE_MODEL_PRICING``.
|
|
1175
|
+
- Threshold: ``percent_diff < 0.1`` is a match; anything else is a
|
|
1176
|
+
mismatch and gets appended to ``discrepancies`` in iteration order.
|
|
1177
|
+
- ``percent_diff`` is ``0.0`` when recorded cost is zero (parity with
|
|
1178
|
+
upstream's divide-by-zero guard).
|
|
1179
|
+
- Per-model ``avg_percent_diff`` updated by streaming mean recurrence
|
|
1180
|
+
to match upstream's per-row accumulation.
|
|
1181
|
+
"""
|
|
1182
|
+
stats = _MismatchStats()
|
|
1183
|
+
for entry in entries:
|
|
1184
|
+
# P1.1 (issue #89 review-loop): mirror ccusage upstream's
|
|
1185
|
+
# ``detectMismatches`` precondition filter at debug-DvI5DUKR.js:42
|
|
1186
|
+
# — synthetic entries are excluded from total_entries AND skip the
|
|
1187
|
+
# _resolve_model_pricing call (which would otherwise emit a
|
|
1188
|
+
# ``[cost] unknown model: <synthetic>`` warning and mutate the
|
|
1189
|
+
# module-level _unknown_model_warnings set, suppressing future
|
|
1190
|
+
# legitimate emissions).
|
|
1191
|
+
if entry.model == "<synthetic>":
|
|
1192
|
+
continue
|
|
1193
|
+
stats.total_entries += 1
|
|
1194
|
+
if entry.cost_usd is None:
|
|
1195
|
+
continue
|
|
1196
|
+
if _resolve_model_pricing(entry.model) is None:
|
|
1197
|
+
continue
|
|
1198
|
+
stats.entries_with_both += 1
|
|
1199
|
+
calculated = _calculate_entry_cost(
|
|
1200
|
+
entry.model, entry.usage, mode="calculate",
|
|
1201
|
+
)
|
|
1202
|
+
original = float(entry.cost_usd)
|
|
1203
|
+
difference = abs(original - calculated)
|
|
1204
|
+
percent_diff = (difference / original * 100) if original > 0 else 0.0
|
|
1205
|
+
ms = stats.model_stats.setdefault(entry.model, _MismatchModelStat())
|
|
1206
|
+
ms.total += 1
|
|
1207
|
+
if percent_diff < 0.1:
|
|
1208
|
+
stats.matches += 1
|
|
1209
|
+
ms.matches += 1
|
|
1210
|
+
else:
|
|
1211
|
+
stats.mismatches += 1
|
|
1212
|
+
ms.mismatches += 1
|
|
1213
|
+
stats.discrepancies.append(_MismatchSample(
|
|
1214
|
+
file=os.path.basename(entry.source_path),
|
|
1215
|
+
timestamp=entry.timestamp.isoformat(),
|
|
1216
|
+
model=entry.model,
|
|
1217
|
+
original_cost=original,
|
|
1218
|
+
calculated_cost=calculated,
|
|
1219
|
+
difference=difference,
|
|
1220
|
+
percent_diff=percent_diff,
|
|
1221
|
+
usage=dict(entry.usage),
|
|
1222
|
+
))
|
|
1223
|
+
# Streaming-mean update for avg_percent_diff (matches upstream).
|
|
1224
|
+
ms.avg_percent_diff = (
|
|
1225
|
+
ms.avg_percent_diff * (ms.total - 1) + percent_diff
|
|
1226
|
+
) / ms.total
|
|
1227
|
+
return stats
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _render_pricing_mismatch_report(stats, sample_limit):
|
|
1231
|
+
"""Return the report as a list of stderr lines (caller prints \\n-joined).
|
|
1232
|
+
|
|
1233
|
+
Matches ccusage upstream's ``printMismatchReport``
|
|
1234
|
+
(debug-DvI5DUKR.js:97-145) including:
|
|
1235
|
+
- Early-return ``"No pricing data found to analyze."`` when
|
|
1236
|
+
``entries_with_both == 0``.
|
|
1237
|
+
- Model Statistics + Sample Discrepancies sections omitted when
|
|
1238
|
+
``mismatches == 0``.
|
|
1239
|
+
- Models with ``mismatches == 0`` omitted from Model Statistics.
|
|
1240
|
+
- Sample header prints the requested ``sample_limit`` (not min with
|
|
1241
|
+
discrepancies length).
|
|
1242
|
+
Adds ONE intentional non-upstream line: ``Command: cctally <label>``
|
|
1243
|
+
under the header so the report self-identifies (issue #89 acceptance
|
|
1244
|
+
re: "command in each sample's context").
|
|
1245
|
+
"""
|
|
1246
|
+
out = []
|
|
1247
|
+
if stats.entries_with_both == 0:
|
|
1248
|
+
out.append("No pricing data found to analyze.")
|
|
1249
|
+
return out
|
|
1250
|
+
|
|
1251
|
+
match_rate = stats.matches / stats.entries_with_both * 100
|
|
1252
|
+
out.append("")
|
|
1253
|
+
out.append("=== Pricing Mismatch Debug Report ===")
|
|
1254
|
+
if stats.command_label:
|
|
1255
|
+
out.append(f"Command: cctally {stats.command_label}")
|
|
1256
|
+
out.append(f"Total entries processed: {stats.total_entries:,}")
|
|
1257
|
+
out.append(
|
|
1258
|
+
f"Entries with both costUSD and model: {stats.entries_with_both:,}"
|
|
1259
|
+
)
|
|
1260
|
+
out.append(f"Matches (within 0.1%): {stats.matches:,}")
|
|
1261
|
+
out.append(f"Mismatches: {stats.mismatches:,}")
|
|
1262
|
+
out.append(f"Match rate: {match_rate:.2f}%")
|
|
1263
|
+
|
|
1264
|
+
if stats.mismatches > 0 and stats.model_stats:
|
|
1265
|
+
out.append("")
|
|
1266
|
+
out.append("=== Model Statistics ===")
|
|
1267
|
+
sorted_models = sorted(
|
|
1268
|
+
stats.model_stats.items(),
|
|
1269
|
+
key=lambda kv: -kv[1].mismatches,
|
|
1270
|
+
)
|
|
1271
|
+
for model, ms in sorted_models:
|
|
1272
|
+
if ms.mismatches == 0:
|
|
1273
|
+
continue
|
|
1274
|
+
rate = ms.matches / ms.total * 100
|
|
1275
|
+
out.append(f"{model}:")
|
|
1276
|
+
out.append(f" Total entries: {ms.total:,}")
|
|
1277
|
+
out.append(f" Matches: {ms.matches:,} ({rate:.1f}%)")
|
|
1278
|
+
out.append(f" Mismatches: {ms.mismatches:,}")
|
|
1279
|
+
out.append(f" Avg % difference: {ms.avg_percent_diff:.1f}%")
|
|
1280
|
+
|
|
1281
|
+
if stats.discrepancies and sample_limit > 0:
|
|
1282
|
+
out.append("")
|
|
1283
|
+
out.append(f"=== Sample Discrepancies (first {sample_limit}) ===")
|
|
1284
|
+
for d in stats.discrepancies[:sample_limit]:
|
|
1285
|
+
out.append(f"File: {d.file}")
|
|
1286
|
+
out.append(f"Timestamp: {d.timestamp}")
|
|
1287
|
+
out.append(f"Model: {d.model}")
|
|
1288
|
+
out.append(f"Original cost: ${d.original_cost:.6f}")
|
|
1289
|
+
out.append(f"Calculated cost: ${d.calculated_cost:.6f}")
|
|
1290
|
+
out.append(
|
|
1291
|
+
f"Difference: ${d.difference:.6f} ({d.percent_diff:.2f}%)"
|
|
1292
|
+
)
|
|
1293
|
+
out.append(f"Tokens: {json.dumps(d.usage)}")
|
|
1294
|
+
out.append("---")
|
|
1295
|
+
return out
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
_DEBUG_REPORT_EMITTED = False
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def _emit_debug_samples_if_set(
|
|
1302
|
+
args,
|
|
1303
|
+
entries_or_loader,
|
|
1304
|
+
*,
|
|
1305
|
+
command_label: str,
|
|
1306
|
+
) -> None:
|
|
1307
|
+
"""Emit the §7.6.2 normative stderr report exactly once per process
|
|
1308
|
+
when ``args.debug`` is True (issue #89).
|
|
1309
|
+
|
|
1310
|
+
``entries_or_loader`` is either a list[UsageEntry] (eager) or a
|
|
1311
|
+
zero-arg callable returning list[UsageEntry] (deferred). The deferred
|
|
1312
|
+
form keeps the JSONL scan off the hot path on SQL-backed cmds whose
|
|
1313
|
+
--debug is rare.
|
|
1314
|
+
|
|
1315
|
+
The report mirrors ccusage's ``printMismatchReport`` shape per spec
|
|
1316
|
+
§7.1.2; the one-time-per-process guard ensures a single CLI
|
|
1317
|
+
invocation that composes multiple cmd_* doesn't double-emit. The
|
|
1318
|
+
diff two-window case (``_emit_diff_debug_samples``) bypasses this
|
|
1319
|
+
guard internally for the second window then sets the guard at the
|
|
1320
|
+
end.
|
|
1321
|
+
"""
|
|
1322
|
+
global _DEBUG_REPORT_EMITTED
|
|
1323
|
+
if _DEBUG_REPORT_EMITTED:
|
|
1324
|
+
return
|
|
1325
|
+
if not getattr(args, "debug", False):
|
|
1326
|
+
return
|
|
1327
|
+
sample_limit = int(getattr(args, "debug_samples", 5))
|
|
1328
|
+
# Negative values are rejected at argparse parse time via _nonneg_int
|
|
1329
|
+
# (§7.5); the helper assumes sample_limit >= 0.
|
|
1330
|
+
|
|
1331
|
+
# P1.2 (issue #89 review-loop): only the loader call is wrapped — the
|
|
1332
|
+
# pure compute + render functions raising would be a programmer bug,
|
|
1333
|
+
# not a transient. On loader failure we degrade gracefully with a
|
|
1334
|
+
# one-line stderr notice and DO set the guard so a downstream cmd_*
|
|
1335
|
+
# composition doesn't retry (and re-emit) on the same transient.
|
|
1336
|
+
if callable(entries_or_loader):
|
|
1337
|
+
try:
|
|
1338
|
+
entries = entries_or_loader()
|
|
1339
|
+
except (sqlite3.DatabaseError, OSError) as exc:
|
|
1340
|
+
eprint(f"cctally --debug: report unavailable: {exc}")
|
|
1341
|
+
_DEBUG_REPORT_EMITTED = True
|
|
1342
|
+
return
|
|
1343
|
+
else:
|
|
1344
|
+
entries = entries_or_loader
|
|
1345
|
+
stats = _compute_pricing_mismatch_stats(entries)
|
|
1346
|
+
stats.command_label = command_label
|
|
1347
|
+
for line in _render_pricing_mismatch_report(stats, sample_limit):
|
|
1348
|
+
eprint(line)
|
|
1349
|
+
_DEBUG_REPORT_EMITTED = True
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
# ---------------------------------------------------------------------------
|
|
1353
|
+
# Codex --debug report (issue #92).
|
|
1354
|
+
#
|
|
1355
|
+
# Codex JSONL carries NO recorded costUSD, so the Claude-side "recorded vs
|
|
1356
|
+
# calculated mismatch" framing does not apply. The codex variant (chosen in
|
|
1357
|
+
# #92's design Q&A, option 2; #89 spec §12 precedent) is a "Codex Pricing
|
|
1358
|
+
# Debug Report": totals header (entries processed, models seen, total
|
|
1359
|
+
# computed cost) + a "Sample Top Entries" block of the N highest
|
|
1360
|
+
# computed-cost entries, each tagged ``Recorded cost: (none)``. Reuses the
|
|
1361
|
+
# process-wide ``_DEBUG_REPORT_EMITTED`` guard so one CLI invocation emits a
|
|
1362
|
+
# single debug report regardless of which family it ran.
|
|
1363
|
+
# ---------------------------------------------------------------------------
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
@dataclass
|
|
1367
|
+
class _CodexCostSample:
|
|
1368
|
+
file: str
|
|
1369
|
+
timestamp: str
|
|
1370
|
+
model: str
|
|
1371
|
+
calculated_cost: float
|
|
1372
|
+
usage: dict
|
|
1373
|
+
is_fallback: bool
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
@dataclass
|
|
1377
|
+
class _CodexCostStats:
|
|
1378
|
+
command_label: str | None = None
|
|
1379
|
+
total_entries: int = 0
|
|
1380
|
+
total_cost: float = 0.0
|
|
1381
|
+
model_counts: dict = field(default_factory=dict)
|
|
1382
|
+
fallback_models: set = field(default_factory=set)
|
|
1383
|
+
samples: list = field(default_factory=list)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _compute_codex_cost_stats(entries):
|
|
1387
|
+
"""Walk ``entries: Iterable[CodexEntry]`` and compute the totals +
|
|
1388
|
+
per-entry computed-cost samples that ``_render_codex_cost_report``
|
|
1389
|
+
consumes (issue #92).
|
|
1390
|
+
|
|
1391
|
+
Unlike the Claude ``_compute_pricing_mismatch_stats`` there is no
|
|
1392
|
+
recorded cost to diff against, so every entry contributes a sample.
|
|
1393
|
+
Samples are collected for all entries and sorted descending by
|
|
1394
|
+
computed cost; the renderer slices to ``--debug-samples``. (Memory is
|
|
1395
|
+
O(entries); acceptable for typical codex histories and symmetric with
|
|
1396
|
+
the Claude helper, which retains its full discrepancy list.)
|
|
1397
|
+
|
|
1398
|
+
Cost + fallback resolution mirror the live aggregation path:
|
|
1399
|
+
``_calculate_codex_entry_cost`` (LiteLLM token semantics) and
|
|
1400
|
+
``_resolve_codex_pricing`` (unknown model → ``gpt-5`` fallback).
|
|
1401
|
+
"""
|
|
1402
|
+
stats = _CodexCostStats()
|
|
1403
|
+
for entry in entries:
|
|
1404
|
+
stats.total_entries += 1
|
|
1405
|
+
stats.model_counts[entry.model] = (
|
|
1406
|
+
stats.model_counts.get(entry.model, 0) + 1
|
|
1407
|
+
)
|
|
1408
|
+
_, is_fallback = _resolve_codex_pricing(entry.model)
|
|
1409
|
+
if is_fallback:
|
|
1410
|
+
stats.fallback_models.add(entry.model)
|
|
1411
|
+
cost = _calculate_codex_entry_cost(
|
|
1412
|
+
entry.model,
|
|
1413
|
+
entry.input_tokens,
|
|
1414
|
+
entry.cached_input_tokens,
|
|
1415
|
+
entry.output_tokens,
|
|
1416
|
+
entry.reasoning_output_tokens,
|
|
1417
|
+
)
|
|
1418
|
+
stats.total_cost += cost
|
|
1419
|
+
stats.samples.append(_CodexCostSample(
|
|
1420
|
+
file=os.path.basename(entry.source_path),
|
|
1421
|
+
timestamp=entry.timestamp.isoformat(),
|
|
1422
|
+
model=entry.model,
|
|
1423
|
+
calculated_cost=cost,
|
|
1424
|
+
usage={
|
|
1425
|
+
"input_tokens": entry.input_tokens,
|
|
1426
|
+
"cached_input_tokens": entry.cached_input_tokens,
|
|
1427
|
+
"output_tokens": entry.output_tokens,
|
|
1428
|
+
"reasoning_output_tokens": entry.reasoning_output_tokens,
|
|
1429
|
+
"total_tokens": entry.total_tokens,
|
|
1430
|
+
},
|
|
1431
|
+
is_fallback=is_fallback,
|
|
1432
|
+
))
|
|
1433
|
+
# Stable sort: equal-cost samples keep iteration order (mirrors the
|
|
1434
|
+
# Claude helper's iteration-order discrepancy list).
|
|
1435
|
+
stats.samples.sort(key=lambda s: -s.calculated_cost)
|
|
1436
|
+
return stats
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _render_codex_cost_report(stats, sample_limit):
|
|
1440
|
+
"""Return the codex --debug report as a list of stderr lines (issue #92).
|
|
1441
|
+
|
|
1442
|
+
Structurally parallel to ``_render_pricing_mismatch_report`` but with
|
|
1443
|
+
no match/mismatch framing (codex has no recorded cost):
|
|
1444
|
+
|
|
1445
|
+
- Early-return ``"No Codex usage data found to analyze."`` when
|
|
1446
|
+
``total_entries == 0``.
|
|
1447
|
+
- Totals header: entries processed, models seen (count desc, ties
|
|
1448
|
+
by name asc; fallback models tagged ``(N, fallback→gpt-5)``),
|
|
1449
|
+
total computed cost.
|
|
1450
|
+
- ``Command: cctally <label>`` self-identifier when set (parity
|
|
1451
|
+
with the Claude report's one non-upstream line).
|
|
1452
|
+
- Sample block omitted when ``sample_limit == 0`` or no samples;
|
|
1453
|
+
header prints the requested ``sample_limit`` (upstream parity).
|
|
1454
|
+
Each sample carries ``Recorded cost: (none)`` and a
|
|
1455
|
+
``(fallback→gpt-5)`` model-line marker when applicable.
|
|
1456
|
+
"""
|
|
1457
|
+
out = []
|
|
1458
|
+
if stats.total_entries == 0:
|
|
1459
|
+
out.append("No Codex usage data found to analyze.")
|
|
1460
|
+
return out
|
|
1461
|
+
|
|
1462
|
+
fallback = CODEX_LEGACY_FALLBACK_MODEL
|
|
1463
|
+
parts = []
|
|
1464
|
+
for model, count in sorted(
|
|
1465
|
+
stats.model_counts.items(), key=lambda kv: (-kv[1], kv[0]),
|
|
1466
|
+
):
|
|
1467
|
+
if model in stats.fallback_models:
|
|
1468
|
+
parts.append(f"{model} ({count:,}, fallback→{fallback})")
|
|
1469
|
+
else:
|
|
1470
|
+
parts.append(f"{model} ({count:,})")
|
|
1471
|
+
|
|
1472
|
+
out.append("")
|
|
1473
|
+
out.append("=== Codex Pricing Debug Report ===")
|
|
1474
|
+
if stats.command_label:
|
|
1475
|
+
out.append(f"Command: cctally {stats.command_label}")
|
|
1476
|
+
out.append(f"Total entries processed: {stats.total_entries:,}")
|
|
1477
|
+
out.append(f"Models seen: {', '.join(parts)}")
|
|
1478
|
+
out.append(f"Total computed cost: ${stats.total_cost:.6f}")
|
|
1479
|
+
|
|
1480
|
+
if stats.samples and sample_limit > 0:
|
|
1481
|
+
out.append("")
|
|
1482
|
+
out.append(f"=== Sample Top Entries (first {sample_limit}) ===")
|
|
1483
|
+
for s in stats.samples[:sample_limit]:
|
|
1484
|
+
model_line = (
|
|
1485
|
+
f"{s.model} (fallback→{fallback})"
|
|
1486
|
+
if s.is_fallback else s.model
|
|
1487
|
+
)
|
|
1488
|
+
out.append(f"File: {s.file}")
|
|
1489
|
+
out.append(f"Timestamp: {s.timestamp}")
|
|
1490
|
+
out.append(f"Model: {model_line}")
|
|
1491
|
+
out.append("Recorded cost: (none)")
|
|
1492
|
+
out.append(f"Calculated cost: ${s.calculated_cost:.6f}")
|
|
1493
|
+
out.append(f"Tokens: {json.dumps(s.usage)}")
|
|
1494
|
+
out.append("---")
|
|
1495
|
+
return out
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _emit_codex_debug_samples_if_set(
|
|
1499
|
+
args,
|
|
1500
|
+
entries,
|
|
1501
|
+
*,
|
|
1502
|
+
command_label: str,
|
|
1503
|
+
) -> None:
|
|
1504
|
+
"""Emit the codex --debug report once per process when ``args.debug``
|
|
1505
|
+
is True (issue #92).
|
|
1506
|
+
|
|
1507
|
+
``entries`` is an eager ``list[CodexEntry]`` — each ``cmd_codex_*`` body
|
|
1508
|
+
already loads them via ``get_codex_entries`` before this call, so unlike
|
|
1509
|
+
the Claude helper there is no deferred-loader variant. Shares the
|
|
1510
|
+
process-wide ``_DEBUG_REPORT_EMITTED`` guard with
|
|
1511
|
+
``_emit_debug_samples_if_set`` so a single CLI invocation emits one
|
|
1512
|
+
report regardless of family.
|
|
1513
|
+
"""
|
|
1514
|
+
global _DEBUG_REPORT_EMITTED
|
|
1515
|
+
if _DEBUG_REPORT_EMITTED:
|
|
1516
|
+
return
|
|
1517
|
+
if not getattr(args, "debug", False):
|
|
1518
|
+
return
|
|
1519
|
+
sample_limit = int(getattr(args, "debug_samples", 5))
|
|
1520
|
+
stats = _compute_codex_cost_stats(entries)
|
|
1521
|
+
stats.command_label = command_label
|
|
1522
|
+
for line in _render_codex_cost_report(stats, sample_limit):
|
|
1523
|
+
eprint(line)
|
|
1524
|
+
_DEBUG_REPORT_EMITTED = True
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _usage_entry_from_joined(je) -> "UsageEntry":
|
|
1528
|
+
"""Shape a ``_JoinedClaudeEntry`` into a ``UsageEntry`` so the §7.1
|
|
1529
|
+
mismatch helpers can consume both pipelines uniformly (issue #89
|
|
1530
|
+
spec §7.2.1).
|
|
1531
|
+
|
|
1532
|
+
The joined-entry shape already carries ``source_path``, ``cost_usd``,
|
|
1533
|
+
and the per-token integers; this adapter is pure shape conversion
|
|
1534
|
+
with no cache re-read.
|
|
1535
|
+
"""
|
|
1536
|
+
return UsageEntry(
|
|
1537
|
+
timestamp=je.timestamp,
|
|
1538
|
+
model=je.model,
|
|
1539
|
+
usage={
|
|
1540
|
+
"input_tokens": je.input_tokens,
|
|
1541
|
+
"output_tokens": je.output_tokens,
|
|
1542
|
+
"cache_creation_input_tokens": je.cache_creation_tokens,
|
|
1543
|
+
"cache_read_input_tokens": je.cache_read_tokens,
|
|
1544
|
+
},
|
|
1545
|
+
cost_usd=je.cost_usd,
|
|
1546
|
+
source_path=je.source_path,
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _resolve_session_id_for_filter(je) -> str:
|
|
1551
|
+
"""Mirror ``_aggregate_claude_sessions``'s session_id resolution
|
|
1552
|
+
(``bin/_lib_aggregators.py:623-627``): use ``je.session_id`` when
|
|
1553
|
+
set, else fall back to the filename stem of ``je.source_path``.
|
|
1554
|
+
|
|
1555
|
+
Used by ``cmd_session``'s ``--id`` filter on the joined-entry list
|
|
1556
|
+
(spec §7.2.1.1) so a rendered session that was assigned its id via
|
|
1557
|
+
the filename-stem fallback isn't silently dropped from the --debug
|
|
1558
|
+
report scope.
|
|
1559
|
+
"""
|
|
1560
|
+
if je.session_id is not None:
|
|
1561
|
+
return je.session_id
|
|
1562
|
+
return os.path.splitext(os.path.basename(je.source_path))[0]
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _project_filter_matches(key, project_patterns):
|
|
1566
|
+
"""Mirror ``cmd_project``'s ``--project`` predicate
|
|
1567
|
+
(``bin/cctally:4792-4803``): substring match against
|
|
1568
|
+
``key.display_key`` AND ``key.git_root or key.bucket_path``.
|
|
1569
|
+
|
|
1570
|
+
Used by ``cmd_project``'s --debug report scope so basename-collision
|
|
1571
|
+
suffixes (e.g. ``foo (repos)``) are still selectable by their path
|
|
1572
|
+
segment (spec §7.2.1.2). Do NOT simplify to a raw
|
|
1573
|
+
``p in (je.project_path or "")`` substring — the report scope would
|
|
1574
|
+
drift from the rendered scope.
|
|
1575
|
+
"""
|
|
1576
|
+
if not project_patterns:
|
|
1577
|
+
return True
|
|
1578
|
+
dname = key.display_key.lower()
|
|
1579
|
+
pname = (key.git_root or key.bucket_path or "").lower()
|
|
1580
|
+
return any((p in dname) or (p in pname) for p in project_patterns)
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def _emit_diff_debug_samples(args, window_a, window_b) -> None:
|
|
1584
|
+
"""Two-window diff report (spec §7.2.2 Pattern D).
|
|
1585
|
+
|
|
1586
|
+
``cmd_diff`` aggregates two windows; emitting a single union-report
|
|
1587
|
+
would conflate per-window stats. This helper emits two separate
|
|
1588
|
+
reports labeled by window token, then sets ``_DEBUG_REPORT_EMITTED``
|
|
1589
|
+
so a downstream cmd_* composition doesn't double-emit.
|
|
1590
|
+
|
|
1591
|
+
Bypasses ``_emit_debug_samples_if_set``'s one-time guard internally
|
|
1592
|
+
(it would short-circuit the second window).
|
|
1593
|
+
"""
|
|
1594
|
+
global _DEBUG_REPORT_EMITTED
|
|
1595
|
+
if _DEBUG_REPORT_EMITTED:
|
|
1596
|
+
return
|
|
1597
|
+
if not getattr(args, "debug", False):
|
|
1598
|
+
return
|
|
1599
|
+
sample_limit = int(getattr(args, "debug_samples", 5))
|
|
1600
|
+
# Sync intent must match `_build_diff_result` (`skip_sync=not args.sync`).
|
|
1601
|
+
# Under `diff --sync --debug` the rendered diff reflects freshly-synced
|
|
1602
|
+
# JSONL while these debug stats, if they skipped sync, would be computed
|
|
1603
|
+
# from the STALE cache — misleading in precisely the stale-cache case
|
|
1604
|
+
# `--sync` exists to fix. So honor `--sync` here too: the first
|
|
1605
|
+
# `get_entries(skip_sync=False)` runs the delta ingest, and every
|
|
1606
|
+
# subsequent read (the second window here + `_build_diff_result`'s own
|
|
1607
|
+
# reads) is a cheap delta no-op (file size/offset unchanged) — no
|
|
1608
|
+
# redundant full walk. Without `--sync`, keep skipping (debug observes
|
|
1609
|
+
# the cache as-is).
|
|
1610
|
+
skip_sync = not bool(getattr(args, "sync", False))
|
|
1611
|
+
try:
|
|
1612
|
+
for window, label_letter, token in (
|
|
1613
|
+
(window_a, "A", getattr(args, "a", "")),
|
|
1614
|
+
(window_b, "B", getattr(args, "b", "")),
|
|
1615
|
+
):
|
|
1616
|
+
try:
|
|
1617
|
+
# Reuse the SAME half-open window accessor the rendered diff
|
|
1618
|
+
# aggregation uses (`_diff_iter_claude_entries`): `ParsedWindow`
|
|
1619
|
+
# exposes `start_utc`/`end_utc` (NOT `.start`/`.end`), and its
|
|
1620
|
+
# `end_utc` is documented exclusive — the helper trims by 1 µs
|
|
1621
|
+
# before hitting the inclusive-end shared cache reader so the
|
|
1622
|
+
# debug report scopes to exactly the entries the rendered diff
|
|
1623
|
+
# counts. Reading via `get_entries(window.start, window.end)`
|
|
1624
|
+
# both raised AttributeError (wrong field names) and would have
|
|
1625
|
+
# over-counted the exclusive end boundary.
|
|
1626
|
+
entries = list(
|
|
1627
|
+
_diff_iter_claude_entries(window, skip_sync=skip_sync)
|
|
1628
|
+
)
|
|
1629
|
+
except (sqlite3.DatabaseError, OSError) as exc:
|
|
1630
|
+
eprint(
|
|
1631
|
+
f"cctally --debug: window {label_letter} report "
|
|
1632
|
+
f"unavailable: {exc}"
|
|
1633
|
+
)
|
|
1634
|
+
continue
|
|
1635
|
+
# `_diff_iter_claude_entries` yields `_JoinedClaudeEntry`, which
|
|
1636
|
+
# has no `.usage` attribute; adapt to `UsageEntry` before the
|
|
1637
|
+
# mismatch compute, mirroring cmd_project / cmd_session. The stats
|
|
1638
|
+
# helper reads `entry.usage`, so passing raw joined entries here
|
|
1639
|
+
# crashed every priced entry with AttributeError — and the inner
|
|
1640
|
+
# `try/except` only catches DatabaseError/OSError, so the crash
|
|
1641
|
+
# escaped to main()'s generic handler (exit 1, zero diff output).
|
|
1642
|
+
stats = _compute_pricing_mismatch_stats(
|
|
1643
|
+
_usage_entry_from_joined(je) for je in entries
|
|
1644
|
+
)
|
|
1645
|
+
stats.command_label = f"diff (Window {label_letter}: {token})"
|
|
1646
|
+
for line in _render_pricing_mismatch_report(stats, sample_limit):
|
|
1647
|
+
eprint(line)
|
|
1648
|
+
finally:
|
|
1649
|
+
# P1.2 (issue #89 review-loop): set the guard in finally so a
|
|
1650
|
+
# downstream cmd_* composition doesn't double-emit even if one
|
|
1651
|
+
# window raised — the partial output we did emit is enough.
|
|
1652
|
+
_DEBUG_REPORT_EMITTED = True
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _load_claude_config_for_args(args: argparse.Namespace) -> "dict":
|
|
1656
|
+
"""Load config honoring the ccusage ``--config <path>`` per-invocation override.
|
|
1657
|
+
|
|
1658
|
+
Thin shim over :func:`load_config` so the 10 in-scope Claude reporting
|
|
1659
|
+
commands (spec §3 T1.6 / issue #88) surface the override behavior
|
|
1660
|
+
uniformly. When ``args.config`` is set, reads from that explicit path
|
|
1661
|
+
only — no first-run create, no writer-lock acquisition, no mutation
|
|
1662
|
+
of the persisted default config at ``_cctally_core.CONFIG_PATH``.
|
|
1663
|
+
Missing / unreadable / non-object-JSON paths raise ``SystemExit(2)``
|
|
1664
|
+
with a clear stderr message (see ``_load_config_from_explicit_path``).
|
|
1665
|
+
When ``args.config`` is unset (or absent), behavior is identical to
|
|
1666
|
+
a bare ``load_config()`` call.
|
|
1667
|
+
"""
|
|
1668
|
+
return load_config(getattr(args, "config", None))
|
|
1669
|
+
|
|
1670
|
+
|
|
998
1671
|
def _resolve_codex_tz_name(args: argparse.Namespace,
|
|
999
1672
|
config: "dict | None") -> "str | None":
|
|
1000
1673
|
"""Resolve the IANA tz NAME (or None for host-local) used by Codex
|
|
@@ -1333,6 +2006,64 @@ def _supports_color_stdout() -> bool:
|
|
|
1333
2006
|
return False
|
|
1334
2007
|
|
|
1335
2008
|
|
|
2009
|
+
def _resolve_color_enabled(args: argparse.Namespace) -> bool:
|
|
2010
|
+
"""Resolve effective color-on/off for the 2 real ANSI Claude cmds.
|
|
2011
|
+
|
|
2012
|
+
Spec §7.3 (issue #86 Session A). Precedence (top wins):
|
|
2013
|
+
|
|
2014
|
+
1. ``args.no_color`` → False (deny-wins; overrides ``FORCE_COLOR``
|
|
2015
|
+
env AND a co-supplied ``--color`` flag).
|
|
2016
|
+
2. ``args.color`` → True (overrides ``NO_COLOR`` env).
|
|
2017
|
+
3. ``FORCE_COLOR`` → True.
|
|
2018
|
+
4. ``NO_COLOR`` → False.
|
|
2019
|
+
5. ``CI`` env with a TTY stdout → True (CI forces color, but only
|
|
2020
|
+
when stdout is itself a terminal — a piped /
|
|
2021
|
+
redirected stdout under CI stays plain text,
|
|
2022
|
+
issue #100); else ``isatty(stdout)`` with
|
|
2023
|
+
non-dumb TERM → True (the diff/project
|
|
2024
|
+
auto-detect).
|
|
2025
|
+
6. else → False (incl. a piped stdout under CI).
|
|
2026
|
+
|
|
2027
|
+
The helper is consumed by ``cmd_project`` and ``cmd_diff`` — the only
|
|
2028
|
+
two in-scope Claude cmds whose renderers honor color. The other 8
|
|
2029
|
+
in-scope cmds parse ``--color`` / ``--no-color`` as documented no-op
|
|
2030
|
+
surface (spec §3 T1.5).
|
|
2031
|
+
|
|
2032
|
+
The rung-5 auto-detect keys on ``sys.stdout.isatty()`` ONLY — NOT the
|
|
2033
|
+
shared ``_supports_color_stdout()`` (which is stdout-OR-stderr to match
|
|
2034
|
+
ccusage's picocolors behavior). ``diff``'s pre-Session-A computation was
|
|
2035
|
+
``sys.stdout.isatty() and not args.no_color`` (stdout-only), and spec
|
|
2036
|
+
§7.3 specifies preserving the *existing* auto-detect on the no-flag
|
|
2037
|
+
rungs. Routing the fallback through the stdout-OR-stderr helper would
|
|
2038
|
+
write ANSI into the pipe under ``cctally diff … | cat`` (stderr is still
|
|
2039
|
+
a TTY) — a backwards-incompatible regression for plain-text consumers.
|
|
2040
|
+
"""
|
|
2041
|
+
# Rung 1 — deny-wins: --no-color always disables.
|
|
2042
|
+
if getattr(args, "no_color", False):
|
|
2043
|
+
return False
|
|
2044
|
+
# Rung 2 — --color overrides env (NO_COLOR auto-detect).
|
|
2045
|
+
if getattr(args, "color", False):
|
|
2046
|
+
return True
|
|
2047
|
+
# Rung 3 — FORCE_COLOR env always enables (any value).
|
|
2048
|
+
if "FORCE_COLOR" in os.environ:
|
|
2049
|
+
return True
|
|
2050
|
+
# Rung 4 — NO_COLOR env always disables (any value).
|
|
2051
|
+
if "NO_COLOR" in os.environ:
|
|
2052
|
+
return False
|
|
2053
|
+
# Rung 5 — CI forces color, but ONLY with a TTY stdout; else stdout-only
|
|
2054
|
+
# isatty with non-dumb TERM. Both branches key on sys.stdout.isatty()
|
|
2055
|
+
# (NOT the stdout-OR-stderr _supports_color_stdout) so a piped stdout —
|
|
2056
|
+
# under CI or otherwise — stays uncolored, preserving diff/project's
|
|
2057
|
+
# pre-Session-A plain-text-on-pipe contract (issue #100). CI still wins
|
|
2058
|
+
# over a dumb TERM when stdout is a real terminal (picocolors parity).
|
|
2059
|
+
if sys.stdout.isatty():
|
|
2060
|
+
if "CI" in os.environ:
|
|
2061
|
+
return True
|
|
2062
|
+
return os.environ.get("TERM", "").lower() != "dumb"
|
|
2063
|
+
# Rung 6 — no flag, no env, non-tty stdout (incl. a piped stdout under CI).
|
|
2064
|
+
return False
|
|
2065
|
+
|
|
2066
|
+
|
|
1336
2067
|
def _style_ansi(text: str, code: str, enabled: bool) -> str:
|
|
1337
2068
|
if not enabled:
|
|
1338
2069
|
return text
|
|
@@ -1373,6 +2104,7 @@ def _boxed_table(
|
|
|
1373
2104
|
aligns: list[str] | None = None,
|
|
1374
2105
|
*,
|
|
1375
2106
|
color_header: bool = True,
|
|
2107
|
+
compact: bool = False,
|
|
1376
2108
|
) -> str:
|
|
1377
2109
|
if not headers:
|
|
1378
2110
|
return ""
|
|
@@ -1439,10 +2171,17 @@ def _boxed_table(
|
|
|
1439
2171
|
def _dim(s: str) -> str:
|
|
1440
2172
|
return _style_ansi(s, "90", color_enabled)
|
|
1441
2173
|
|
|
2174
|
+
# Issue #91 (Shape B): ``compact`` drops the 1-space cell padding to
|
|
2175
|
+
# 0 on this content-sized table (which has no proportional-width path
|
|
2176
|
+
# to force). Borders and rows both key off ``pad`` so the default
|
|
2177
|
+
# (``pad == 1``) reproduces the prior output byte-for-byte.
|
|
2178
|
+
pad = 0 if compact else 1
|
|
2179
|
+
pad_s = " " * pad
|
|
2180
|
+
|
|
1442
2181
|
def make_border(left: str, mid: str, right: str) -> str:
|
|
1443
2182
|
return _dim(
|
|
1444
2183
|
left
|
|
1445
|
-
+ mid.join(chars["h"] * (w + 2) for w in widths)
|
|
2184
|
+
+ mid.join(chars["h"] * (w + 2 * pad) for w in widths)
|
|
1446
2185
|
+ right
|
|
1447
2186
|
)
|
|
1448
2187
|
|
|
@@ -1459,9 +2198,9 @@ def _boxed_table(
|
|
|
1459
2198
|
v = _dim(chars["v"])
|
|
1460
2199
|
return (
|
|
1461
2200
|
v
|
|
1462
|
-
+
|
|
1463
|
-
+ f"
|
|
1464
|
-
+
|
|
2201
|
+
+ pad_s
|
|
2202
|
+
+ f"{pad_s}{v}{pad_s}".join(styled_cells)
|
|
2203
|
+
+ pad_s
|
|
1465
2204
|
+ v
|
|
1466
2205
|
)
|
|
1467
2206
|
|
|
@@ -1542,6 +2281,7 @@ def _layout_cache_table(
|
|
|
1542
2281
|
wide_text_min: int = 15,
|
|
1543
2282
|
narrow_text_min: int = 12,
|
|
1544
2283
|
droppable_col_index: int | None = None,
|
|
2284
|
+
compact: bool = False,
|
|
1545
2285
|
) -> str:
|
|
1546
2286
|
"""Shared responsive-width table layout for cache-report renderers.
|
|
1547
2287
|
|
|
@@ -1645,7 +2385,11 @@ def _layout_cache_table(
|
|
|
1645
2385
|
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
1646
2386
|
|
|
1647
2387
|
border_overhead = 3 * num_cols + 1
|
|
1648
|
-
|
|
2388
|
+
# Issue #91 (Shape A): the ``compact`` kwarg forces the responsive
|
|
2389
|
+
# scale-down path regardless of terminal width, mirroring
|
|
2390
|
+
# ``_render_project_table`` / ``_render_bucket_table``. Auto-detected
|
|
2391
|
+
# width-overflow continues to trigger the same path as before.
|
|
2392
|
+
compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
|
|
1649
2393
|
|
|
1650
2394
|
if compact_mode:
|
|
1651
2395
|
available = term_width - border_overhead
|
|
@@ -1857,7 +2601,9 @@ def _layout_cache_table(
|
|
|
1857
2601
|
return "\n".join(lines)
|
|
1858
2602
|
|
|
1859
2603
|
|
|
1860
|
-
def _render_cache_day_rows(
|
|
2604
|
+
def _render_cache_day_rows(
|
|
2605
|
+
rows: list[CacheRow], title: str, *, compact: bool = False,
|
|
2606
|
+
) -> str:
|
|
1861
2607
|
"""Render daily-mode cache report.
|
|
1862
2608
|
|
|
1863
2609
|
Columns: Date, Models, Cache %, Input, Cache Create, Cache Read,
|
|
@@ -1986,12 +2732,13 @@ def _render_cache_day_rows(rows: list[CacheRow], title: str) -> str:
|
|
|
1986
2732
|
wide_text_min=15,
|
|
1987
2733
|
narrow_text_min=12,
|
|
1988
2734
|
droppable_col_index=3, # Input column
|
|
2735
|
+
compact=compact,
|
|
1989
2736
|
)
|
|
1990
2737
|
|
|
1991
2738
|
|
|
1992
2739
|
def _render_cache_session_rows(
|
|
1993
2740
|
rows: list[CacheRow], title: str,
|
|
1994
|
-
*, tz: "ZoneInfo | None" = None,
|
|
2741
|
+
*, tz: "ZoneInfo | None" = None, compact: bool = False,
|
|
1995
2742
|
) -> str:
|
|
1996
2743
|
"""Render session-mode cache report.
|
|
1997
2744
|
|
|
@@ -2135,6 +2882,7 @@ def _render_cache_session_rows(
|
|
|
2135
2882
|
wide_text_min=18,
|
|
2136
2883
|
narrow_text_min=12,
|
|
2137
2884
|
droppable_col_index=4, # Input column
|
|
2885
|
+
compact=compact,
|
|
2138
2886
|
)
|
|
2139
2887
|
|
|
2140
2888
|
|
|
@@ -2144,16 +2892,20 @@ def _render_cache_report_table(
|
|
|
2144
2892
|
*,
|
|
2145
2893
|
mode: Literal["day", "session"] = "day",
|
|
2146
2894
|
tz: "ZoneInfo | None" = None,
|
|
2895
|
+
compact: bool = False,
|
|
2147
2896
|
) -> str:
|
|
2148
2897
|
"""Dispatcher: routes to daily or session renderer based on mode.
|
|
2149
2898
|
|
|
2150
2899
|
``tz`` is the resolved display zone (None = host local). Day-mode
|
|
2151
2900
|
rows have no clock-instant cells (date strings only) so the parameter
|
|
2152
2901
|
is currently consumed only by the session-mode renderer.
|
|
2902
|
+
|
|
2903
|
+
``compact`` (issue #91, Shape A) forces ``_layout_cache_table``'s
|
|
2904
|
+
responsive scale-down branch regardless of terminal width.
|
|
2153
2905
|
"""
|
|
2154
2906
|
if mode == "session":
|
|
2155
|
-
return _render_cache_session_rows(rows, title, tz=tz)
|
|
2156
|
-
return _render_cache_day_rows(rows, title)
|
|
2907
|
+
return _render_cache_session_rows(rows, title, tz=tz, compact=compact)
|
|
2908
|
+
return _render_cache_day_rows(rows, title, compact=compact)
|
|
2157
2909
|
|
|
2158
2910
|
|
|
2159
2911
|
def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
|
|
@@ -2628,7 +3380,16 @@ def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
|
|
|
2628
3380
|
now_iso = now_utc_iso()
|
|
2629
3381
|
now_dt = parse_iso_datetime(now_iso, "now")
|
|
2630
3382
|
|
|
2631
|
-
|
|
3383
|
+
# BEGIN IMMEDIATE (not deferred): this transaction's first DML is a
|
|
3384
|
+
# READ (min_row/max_row below), so a plain deferred BEGIN takes a read
|
|
3385
|
+
# snapshot and only tries to upgrade to the write lock at the first
|
|
3386
|
+
# INSERT OR IGNORE. Under concurrent first-run openers, a competing
|
|
3387
|
+
# commit landing between that read and the first write makes the upgrade
|
|
3388
|
+
# fail with SQLITE_BUSY_SNAPSHOT *immediately* — busy_timeout cannot
|
|
3389
|
+
# absorb it, and the whole backfill rolls back. Acquiring the write lock
|
|
3390
|
+
# up front serializes the backfill cleanly behind busy_timeout instead.
|
|
3391
|
+
# See cctally-dev#87.
|
|
3392
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
2632
3393
|
try:
|
|
2633
3394
|
for key in keys:
|
|
2634
3395
|
# MIN-captured row defines the immutable block boundary
|
|
@@ -3405,17 +4166,19 @@ def _load_recorded_five_hour_windows(
|
|
|
3405
4166
|
|
|
3406
4167
|
def cmd_blocks(args: argparse.Namespace) -> int:
|
|
3407
4168
|
"""Show usage report grouped by 5-hour session blocks."""
|
|
3408
|
-
config =
|
|
4169
|
+
config = _load_claude_config_for_args(args)
|
|
4170
|
+
_bridge_z_into_tz(args, config)
|
|
3409
4171
|
tz = resolve_display_tz(args, config)
|
|
3410
4172
|
args._resolved_tz = tz
|
|
3411
4173
|
|
|
3412
4174
|
now_utc = _command_as_of()
|
|
3413
|
-
# Parse --since / --until into datetime range
|
|
4175
|
+
# Parse --since / --until into datetime range. Session A (spec §7.1.1)
|
|
4176
|
+
# routes through the centralized dual-form helper so YYYY-MM-DD also
|
|
4177
|
+
# works and the error message matches the other in-scope cmds.
|
|
3414
4178
|
if args.since:
|
|
3415
4179
|
try:
|
|
3416
|
-
since_date =
|
|
4180
|
+
since_date = _parse_dual_form_date(args.since, "--since")
|
|
3417
4181
|
except ValueError:
|
|
3418
|
-
eprint(f"Error: --since must be YYYYMMDD format, got '{args.since}'")
|
|
3419
4182
|
return 1
|
|
3420
4183
|
range_start = since_date.replace(tzinfo=dt.timezone.utc)
|
|
3421
4184
|
else:
|
|
@@ -3424,9 +4187,8 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
3424
4187
|
|
|
3425
4188
|
if args.until:
|
|
3426
4189
|
try:
|
|
3427
|
-
until_date =
|
|
4190
|
+
until_date = _parse_dual_form_date(args.until, "--until")
|
|
3428
4191
|
except ValueError:
|
|
3429
|
-
eprint(f"Error: --until must be YYYYMMDD format, got '{args.until}'")
|
|
3430
4192
|
return 1
|
|
3431
4193
|
# End of that day
|
|
3432
4194
|
range_end = until_date.replace(
|
|
@@ -3439,6 +4201,10 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
3439
4201
|
# Collect all entries
|
|
3440
4202
|
all_entries = get_entries(range_start, range_end)
|
|
3441
4203
|
|
|
4204
|
+
_emit_debug_samples_if_set(
|
|
4205
|
+
args, all_entries, command_label="blocks",
|
|
4206
|
+
)
|
|
4207
|
+
|
|
3442
4208
|
# Load recorded 5-hour reset timestamps. Widen both bounds by
|
|
3443
4209
|
# BLOCK_DURATION: a window covers [R - 5h, R), so a reset R just
|
|
3444
4210
|
# before ``range_start`` can still anchor entries near it, and a
|
|
@@ -3503,8 +4269,13 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
3503
4269
|
print(_blocks_to_json(blocks))
|
|
3504
4270
|
return 0
|
|
3505
4271
|
|
|
3506
|
-
# Table output
|
|
3507
|
-
|
|
4272
|
+
# Table output. Session A (spec §7.6.1; Review-A P2-B): thread
|
|
4273
|
+
# --compact through so the renderer's scale-down branch fires
|
|
4274
|
+
# regardless of terminal width when the flag is set.
|
|
4275
|
+
print(_render_blocks_table(
|
|
4276
|
+
blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
|
|
4277
|
+
compact=getattr(args, "compact", False),
|
|
4278
|
+
))
|
|
3508
4279
|
return 0
|
|
3509
4280
|
|
|
3510
4281
|
|
|
@@ -3607,6 +4378,45 @@ def _maybe_swap_active_block_to_canonical(
|
|
|
3607
4378
|
blocks[active_idx] = rebuilt
|
|
3608
4379
|
|
|
3609
4380
|
|
|
4381
|
+
def _try_dual_form_date(raw: str) -> dt.datetime | None:
|
|
4382
|
+
"""Parse ``YYYY-MM-DD`` / ``YYYYMMDD`` WITHOUT emitting an error.
|
|
4383
|
+
|
|
4384
|
+
Returns the parsed naive datetime, or ``None`` when ``raw`` matches
|
|
4385
|
+
neither form. The non-eprinting core of ``_parse_dual_form_date``:
|
|
4386
|
+
callers that have their own fallback for a non-dual-form input must
|
|
4387
|
+
use this, because ``_parse_dual_form_date`` eprints before it raises
|
|
4388
|
+
— so a successful fallback parse would otherwise leak a spurious
|
|
4389
|
+
"must be YYYY-MM-DD" line to stderr (cache-report's ``parse_iso_datetime``
|
|
4390
|
+
second-chance, issue #101).
|
|
4391
|
+
"""
|
|
4392
|
+
for fmt in ("%Y-%m-%d", "%Y%m%d"):
|
|
4393
|
+
try:
|
|
4394
|
+
return dt.datetime.strptime(raw, fmt)
|
|
4395
|
+
except ValueError:
|
|
4396
|
+
continue
|
|
4397
|
+
return None
|
|
4398
|
+
|
|
4399
|
+
|
|
4400
|
+
def _parse_dual_form_date(raw: str, flag: str) -> dt.datetime:
|
|
4401
|
+
"""Parse a CLI date arg accepting both ``YYYY-MM-DD`` and ``YYYYMMDD``.
|
|
4402
|
+
|
|
4403
|
+
Tries ``%Y-%m-%d`` first (the more readable upstream ccusage / codex
|
|
4404
|
+
sharedArgs preference), then ``%Y%m%d``. On failure, emits a stderr
|
|
4405
|
+
error and raises ``ValueError``. Callers swallow the ``ValueError``
|
|
4406
|
+
and return exit code 1.
|
|
4407
|
+
|
|
4408
|
+
Promoted from ``_resolve_codex_range._parse_date_arg`` so Claude
|
|
4409
|
+
reporting commands can share the dual-form contract (issue #86
|
|
4410
|
+
Session A, spec §7.1.1). Centralizes the error message so the eight
|
|
4411
|
+
in-scope date-taking commands all surface the same diagnostic.
|
|
4412
|
+
"""
|
|
4413
|
+
naive = _try_dual_form_date(raw)
|
|
4414
|
+
if naive is not None:
|
|
4415
|
+
return naive
|
|
4416
|
+
eprint(f"Error: {flag} must be YYYY-MM-DD or YYYYMMDD format, got '{raw}'")
|
|
4417
|
+
raise ValueError
|
|
4418
|
+
|
|
4419
|
+
|
|
3610
4420
|
def _parse_cli_date_range(
|
|
3611
4421
|
args: argparse.Namespace,
|
|
3612
4422
|
*,
|
|
@@ -3639,14 +4449,11 @@ def _parse_cli_date_range(
|
|
|
3639
4449
|
the given date — this avoids DST-boundary drift that would occur if
|
|
3640
4450
|
we used today's offset (via `datetime.now().astimezone().tzinfo`).
|
|
3641
4451
|
"""
|
|
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
|
|
4452
|
+
# Local binding to the module-level dual-form helper (spec §7.1.1).
|
|
4453
|
+
# Keeps the diff inside _parse_cli_date_range minimal while sharing
|
|
4454
|
+
# the centralized error message with cmd_blocks, _parse_date_filter,
|
|
4455
|
+
# and _parse_window_arg.
|
|
4456
|
+
_parse_date_arg = _parse_dual_form_date
|
|
3650
4457
|
|
|
3651
4458
|
tz: Any = None
|
|
3652
4459
|
if tz_name:
|
|
@@ -3701,7 +4508,12 @@ def _parse_cli_date_range(
|
|
|
3701
4508
|
def cmd_daily(args: argparse.Namespace) -> int:
|
|
3702
4509
|
"""Show usage report grouped by display-timezone date."""
|
|
3703
4510
|
_share_validate_args(args)
|
|
3704
|
-
config =
|
|
4511
|
+
config = _load_claude_config_for_args(args)
|
|
4512
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
4513
|
+
# existing resolve_display_tz precedence absorbs the new alias. The
|
|
4514
|
+
# canonical --tz still wins (it's set on the namespace before this
|
|
4515
|
+
# bridge fires); when --tz is unset and -z is supplied, use -z.
|
|
4516
|
+
_bridge_z_into_tz(args, config)
|
|
3705
4517
|
tz = resolve_display_tz(args, config)
|
|
3706
4518
|
args._resolved_tz = tz
|
|
3707
4519
|
|
|
@@ -3717,6 +4529,10 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
3717
4529
|
# Collect entries.
|
|
3718
4530
|
all_entries = get_entries(range_start, range_end)
|
|
3719
4531
|
|
|
4532
|
+
_emit_debug_samples_if_set(
|
|
4533
|
+
args, all_entries, command_label="daily",
|
|
4534
|
+
)
|
|
4535
|
+
|
|
3720
4536
|
# Build the unified daily view (spec §5.1: gap-free; the dashboard
|
|
3721
4537
|
# heatmap's contiguous-window materialization stays at the dashboard
|
|
3722
4538
|
# envelope adapter so CLI byte-stability is preserved). Consume
|
|
@@ -3775,6 +4591,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
3775
4591
|
title_suffix="Daily",
|
|
3776
4592
|
compact_split_fn=_daily_compact_split,
|
|
3777
4593
|
breakdown=args.breakdown,
|
|
4594
|
+
compact=getattr(args, "compact", False),
|
|
3778
4595
|
))
|
|
3779
4596
|
return 0
|
|
3780
4597
|
|
|
@@ -3782,7 +4599,8 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
3782
4599
|
def cmd_monthly(args: argparse.Namespace) -> int:
|
|
3783
4600
|
"""Show usage report grouped by display-timezone calendar month."""
|
|
3784
4601
|
_share_validate_args(args)
|
|
3785
|
-
config =
|
|
4602
|
+
config = _load_claude_config_for_args(args)
|
|
4603
|
+
_bridge_z_into_tz(args, config)
|
|
3786
4604
|
tz = resolve_display_tz(args, config)
|
|
3787
4605
|
args._resolved_tz = tz
|
|
3788
4606
|
|
|
@@ -3797,6 +4615,10 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
3797
4615
|
|
|
3798
4616
|
all_entries = get_entries(range_start, range_end)
|
|
3799
4617
|
|
|
4618
|
+
_emit_debug_samples_if_set(
|
|
4619
|
+
args, all_entries, command_label="monthly",
|
|
4620
|
+
)
|
|
4621
|
+
|
|
3800
4622
|
# Build the unified monthly view (spec §5.2: drops boundary-spillover
|
|
3801
4623
|
# bucket; computes delta_cost_pct internally). Consume
|
|
3802
4624
|
# `view.aggregated` (BucketUsage tuple, newest-first) for CLI byte-
|
|
@@ -3849,6 +4671,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
3849
4671
|
title_suffix="Monthly",
|
|
3850
4672
|
compact_split_fn=_monthly_compact_split,
|
|
3851
4673
|
breakdown=args.breakdown,
|
|
4674
|
+
compact=getattr(args, "compact", False),
|
|
3852
4675
|
))
|
|
3853
4676
|
return 0
|
|
3854
4677
|
|
|
@@ -3856,7 +4679,8 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
3856
4679
|
def cmd_weekly(args: argparse.Namespace) -> int:
|
|
3857
4680
|
"""Show Claude usage grouped by subscription week."""
|
|
3858
4681
|
_share_validate_args(args)
|
|
3859
|
-
config =
|
|
4682
|
+
config = _load_claude_config_for_args(args)
|
|
4683
|
+
_bridge_z_into_tz(args, config)
|
|
3860
4684
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
3861
4685
|
|
|
3862
4686
|
now_utc = _command_as_of()
|
|
@@ -3869,8 +4693,12 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
3869
4693
|
|
|
3870
4694
|
# Build the subscription-week list spanning the range. Boundaries are
|
|
3871
4695
|
# anchored in `weekly_usage_snapshots` when available and otherwise
|
|
3872
|
-
# extrapolated (see `_compute_subscription_weeks`).
|
|
3873
|
-
|
|
4696
|
+
# extrapolated (see `_compute_subscription_weeks`). Pass the
|
|
4697
|
+
# `--config`-honoring resolved config (issue #88) so the no-snapshot
|
|
4698
|
+
# calendar-week fallback uses the explicit override's `week_start`.
|
|
4699
|
+
weeks = _compute_subscription_weeks(
|
|
4700
|
+
conn, range_start, range_end, config=config,
|
|
4701
|
+
)
|
|
3874
4702
|
|
|
3875
4703
|
# Fetch entries and aggregate.
|
|
3876
4704
|
# Cover each SubWeek's full [start_ts, end_ts) on the range_start side —
|
|
@@ -3889,6 +4717,10 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
3889
4717
|
fetch_start = range_start
|
|
3890
4718
|
all_entries = get_entries(fetch_start, range_end)
|
|
3891
4719
|
|
|
4720
|
+
_emit_debug_samples_if_set(
|
|
4721
|
+
args, all_entries, command_label="weekly",
|
|
4722
|
+
)
|
|
4723
|
+
|
|
3892
4724
|
# Bound the usage-snapshot lookup to `<= range_end` so historical
|
|
3893
4725
|
# `--until <past date>` queries pick the usage% that was current at
|
|
3894
4726
|
# the end of the requested window rather than the globally latest
|
|
@@ -3966,6 +4798,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
3966
4798
|
weeks=weeks,
|
|
3967
4799
|
compact_split_fn=_daily_compact_split,
|
|
3968
4800
|
breakdown=args.breakdown,
|
|
4801
|
+
compact=getattr(args, "compact", False),
|
|
3969
4802
|
))
|
|
3970
4803
|
return 0
|
|
3971
4804
|
|
|
@@ -3989,6 +4822,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
|
|
|
3989
4822
|
range_start, range_end = range
|
|
3990
4823
|
|
|
3991
4824
|
entries = get_codex_entries(range_start, range_end)
|
|
4825
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily")
|
|
3992
4826
|
# Route through ``build_codex_daily_view`` (issue #58). The View
|
|
3993
4827
|
# wraps ``_aggregate_codex_daily`` without changing it — preserves
|
|
3994
4828
|
# LiteLLM token semantics, intentional dedup vs upstream, and
|
|
@@ -4048,6 +4882,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
|
|
|
4048
4882
|
range_start, range_end = range
|
|
4049
4883
|
|
|
4050
4884
|
entries = get_codex_entries(range_start, range_end)
|
|
4885
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly")
|
|
4051
4886
|
# Route through ``build_codex_monthly_view`` (issue #58).
|
|
4052
4887
|
view = build_codex_monthly_view(
|
|
4053
4888
|
entries, now_utc=_command_as_of(), tz_name=tz_name,
|
|
@@ -4107,6 +4942,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
|
|
|
4107
4942
|
week_start_idx = WEEKDAY_MAP[week_start_name]
|
|
4108
4943
|
|
|
4109
4944
|
entries = get_codex_entries(range_start, range_end)
|
|
4945
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly")
|
|
4110
4946
|
# Route through ``build_codex_weekly_view`` (issue #58).
|
|
4111
4947
|
view = build_codex_weekly_view(
|
|
4112
4948
|
entries, now_utc=now_utc, tz_name=tz_name,
|
|
@@ -4167,6 +5003,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
|
|
|
4167
5003
|
range_start, range_end = range
|
|
4168
5004
|
|
|
4169
5005
|
entries = get_codex_entries(range_start, range_end)
|
|
5006
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-session")
|
|
4170
5007
|
# Route through ``build_codex_session_view`` (issue #58). View rows
|
|
4171
5008
|
# come descending by last_activity (aggregator default + upstream
|
|
4172
5009
|
# parity); --order asc reverses.
|
|
@@ -4426,7 +5263,10 @@ def _project_sort_key(row: dict, sort_by: str, order: str):
|
|
|
4426
5263
|
def cmd_project(args: argparse.Namespace) -> int:
|
|
4427
5264
|
"""Roll entries up by project (git-root) with per-project usage attribution."""
|
|
4428
5265
|
_share_validate_args(args)
|
|
4429
|
-
config =
|
|
5266
|
+
config = _load_claude_config_for_args(args)
|
|
5267
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
5268
|
+
# existing resolve_display_tz precedence absorbs the new alias.
|
|
5269
|
+
_bridge_z_into_tz(args, config)
|
|
4430
5270
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
4431
5271
|
|
|
4432
5272
|
# Flag-combination validation (must run before any expensive work).
|
|
@@ -4477,7 +5317,7 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
4477
5317
|
# false, which would otherwise wrongly fall through to the Monday
|
|
4478
5318
|
# fallback for non-Monday-reset accounts).
|
|
4479
5319
|
current_weeks = _compute_subscription_weeks(
|
|
4480
|
-
conn, now, now + dt.timedelta(microseconds=1)
|
|
5320
|
+
conn, now, now + dt.timedelta(microseconds=1), config=config,
|
|
4481
5321
|
)
|
|
4482
5322
|
if current_weeks:
|
|
4483
5323
|
cw_start = parse_iso_datetime(
|
|
@@ -4497,7 +5337,9 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
4497
5337
|
# Pre-compute subscription-week bounds for the query window so each entry
|
|
4498
5338
|
# can be bucketed onto a canonical subscription-week start_ts. Mirrors
|
|
4499
5339
|
# `_aggregate_weekly`'s bisect pattern (first-match-wins on overlap).
|
|
4500
|
-
subweeks = _compute_subscription_weeks(
|
|
5340
|
+
subweeks = _compute_subscription_weeks(
|
|
5341
|
+
conn, since_dt, until_dt, config=config,
|
|
5342
|
+
)
|
|
4501
5343
|
parsed_bounds: list[tuple[dt.datetime, dt.datetime]] = []
|
|
4502
5344
|
for sw in subweeks:
|
|
4503
5345
|
s_dt = parse_iso_datetime(sw.start_ts, "week.start_ts").astimezone(dt.timezone.utc)
|
|
@@ -4548,7 +5390,45 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
4548
5390
|
unknown_entry_count = 0
|
|
4549
5391
|
missing_sid_count = 0
|
|
4550
5392
|
|
|
4551
|
-
|
|
5393
|
+
# Issue #89: materialize the joined-entry iterator once so we can
|
|
5394
|
+
# (a) pre-compute the --debug report's scope (entries passing all
|
|
5395
|
+
# rendered-row filters — user slice + --model + --project) BEFORE
|
|
5396
|
+
# the aggregation loop runs and (b) preserve the existing
|
|
5397
|
+
# aggregation semantics (denominator widened to ALL entries; visible
|
|
5398
|
+
# rows only the post-filter subset). The list is small enough to
|
|
5399
|
+
# hold (entries already in memory via the cache row factory).
|
|
5400
|
+
joined_entries_all = list(get_claude_session_entries(scan_start, scan_end))
|
|
5401
|
+
|
|
5402
|
+
# Build the --debug report dataset: skip synthetic + out-of-window
|
|
5403
|
+
# entries, then apply --model and --project filters (mirroring the
|
|
5404
|
+
# exact predicate at the aggregation loop below). This must match
|
|
5405
|
+
# the rendered scope, NOT the denominator scope.
|
|
5406
|
+
if getattr(args, "debug", False):
|
|
5407
|
+
filtered_for_report = []
|
|
5408
|
+
for je in joined_entries_all:
|
|
5409
|
+
if je.model == "<synthetic>":
|
|
5410
|
+
continue
|
|
5411
|
+
if _week_start_for(je.timestamp) is None:
|
|
5412
|
+
continue
|
|
5413
|
+
if je.timestamp < since_dt or je.timestamp > until_dt:
|
|
5414
|
+
continue
|
|
5415
|
+
if model_patterns:
|
|
5416
|
+
mname = (je.model or "").lower()
|
|
5417
|
+
if not any(p in mname for p in model_patterns):
|
|
5418
|
+
continue
|
|
5419
|
+
key_for_filter = _resolve_project_key(
|
|
5420
|
+
je.project_path, args.group, resolver_cache,
|
|
5421
|
+
)
|
|
5422
|
+
if not _project_filter_matches(key_for_filter, project_patterns):
|
|
5423
|
+
continue
|
|
5424
|
+
filtered_for_report.append(je)
|
|
5425
|
+
_emit_debug_samples_if_set(
|
|
5426
|
+
args,
|
|
5427
|
+
[_usage_entry_from_joined(je) for je in filtered_for_report],
|
|
5428
|
+
command_label="project",
|
|
5429
|
+
)
|
|
5430
|
+
|
|
5431
|
+
for entry in joined_entries_all:
|
|
4552
5432
|
# Skip synthetic entries (Claude Code internal markers) to match
|
|
4553
5433
|
# `_aggregate_cache_by_session` / `_aggregate_claude_sessions`.
|
|
4554
5434
|
if entry.model == "<synthetic>":
|
|
@@ -4813,13 +5693,21 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
4813
5693
|
eprint("No project usage found in range.")
|
|
4814
5694
|
return 0
|
|
4815
5695
|
|
|
5696
|
+
# Session A (spec §7.3): the new --color flag overrides NO_COLOR
|
|
5697
|
+
# env; --no-color overrides FORCE_COLOR env; deny-wins on the
|
|
5698
|
+
# --color + --no-color clash. _resolve_color_enabled returns the
|
|
5699
|
+
# effective bool; pass it as ``color=`` so the renderer skips its
|
|
5700
|
+
# internal _supports_color_stdout() auto-detect (which would
|
|
5701
|
+
# re-consult NO_COLOR and incorrectly disable color when the user
|
|
5702
|
+
# passed --color under NO_COLOR=1).
|
|
4816
5703
|
print(_render_project_table(
|
|
4817
5704
|
sorted_rows,
|
|
4818
5705
|
title=title,
|
|
4819
5706
|
breakdown=args.breakdown,
|
|
4820
5707
|
weeks_missing_snapshot=len(weeks_missing_snapshot),
|
|
4821
5708
|
weeks_in_range=len(weeks_in_range),
|
|
4822
|
-
|
|
5709
|
+
color=_resolve_color_enabled(args),
|
|
5710
|
+
compact=args.compact,
|
|
4823
5711
|
))
|
|
4824
5712
|
return 0
|
|
4825
5713
|
|
|
@@ -4909,7 +5797,10 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
4909
5797
|
|
|
4910
5798
|
# Validation already happened via _argparse_tz; resolve now to a ZoneInfo
|
|
4911
5799
|
# (or None for "local") and derive the IANA name for window resolution.
|
|
4912
|
-
config =
|
|
5800
|
+
config = _load_claude_config_for_args(args)
|
|
5801
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
5802
|
+
# existing resolve_display_tz precedence absorbs the new alias.
|
|
5803
|
+
_bridge_z_into_tz(args, config)
|
|
4913
5804
|
tz_obj = resolve_display_tz(args, config)
|
|
4914
5805
|
args._resolved_tz = tz_obj
|
|
4915
5806
|
tz_name = (tz_obj.key if tz_obj is not None else _local_tz_name())
|
|
@@ -4934,16 +5825,12 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
4934
5825
|
print(f"diff: {exc}", file=sys.stderr)
|
|
4935
5826
|
return 2
|
|
4936
5827
|
|
|
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
|
-
|
|
5828
|
+
# Validate the remaining CLI surface (`--only` / `--with`) BEFORE the
|
|
5829
|
+
# `--debug` emission below. `_emit_diff_debug_samples` prints reports and,
|
|
5830
|
+
# under `--sync`, runs a cache ingest (a local-state mutation). A
|
|
5831
|
+
# fail-fast usage error like `diff ... --only bogus --debug --sync` must
|
|
5832
|
+
# not print unrelated debug output or touch the cache before returning
|
|
5833
|
+
# exit 2 — so the validation gate has to precede the debug scan.
|
|
4947
5834
|
sections_requested = ["overall", "models", "projects", "cache"]
|
|
4948
5835
|
if args.only is not None:
|
|
4949
5836
|
sections_requested = [s.strip() for s in args.only.split(",") if s.strip()]
|
|
@@ -4970,6 +5857,23 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
4970
5857
|
)
|
|
4971
5858
|
return 1
|
|
4972
5859
|
|
|
5860
|
+
# Issue #89 spec §7.2.2 Pattern D: emit one --debug report per window
|
|
5861
|
+
# before any rendering, with window-A then window-B labels. Runs only
|
|
5862
|
+
# after the validation gate above so a usage error fails fast without
|
|
5863
|
+
# debug output or a cache sync.
|
|
5864
|
+
if getattr(args, "debug", False):
|
|
5865
|
+
_emit_diff_debug_samples(args, window_a, window_b)
|
|
5866
|
+
|
|
5867
|
+
threshold = NoiseThreshold(
|
|
5868
|
+
show_all=bool(args.show_all),
|
|
5869
|
+
user_override=(args.min_delta_usd is not None
|
|
5870
|
+
or args.min_delta_pct is not None),
|
|
5871
|
+
)
|
|
5872
|
+
if args.min_delta_usd is not None:
|
|
5873
|
+
threshold = dataclasses.replace(threshold, min_delta_usd=args.min_delta_usd)
|
|
5874
|
+
if args.min_delta_pct is not None:
|
|
5875
|
+
threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
|
|
5876
|
+
|
|
4973
5877
|
try:
|
|
4974
5878
|
result = _build_diff_result(
|
|
4975
5879
|
window_a, window_b,
|
|
@@ -5002,12 +5906,18 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
5002
5906
|
print(_diff_render_json(result, options=options))
|
|
5003
5907
|
return 0
|
|
5004
5908
|
|
|
5005
|
-
|
|
5909
|
+
# Session A (spec §7.3): route through the new color resolver so
|
|
5910
|
+
# the bool --color flag overrides NO_COLOR env, --no-color overrides
|
|
5911
|
+
# FORCE_COLOR env, and deny-wins on the --color + --no-color clash.
|
|
5912
|
+
# The old computation (sys.stdout.isatty() and not args.no_color)
|
|
5913
|
+
# only honored --no-color + isatty; the resolver supersedes both
|
|
5914
|
+
# with the full spec §7.3 precedence.
|
|
5915
|
+
color = _resolve_color_enabled(args)
|
|
5006
5916
|
width = args.width or shutil.get_terminal_size().columns
|
|
5007
5917
|
width = max(80, min(width, 160))
|
|
5008
5918
|
print(_diff_render_full_output(
|
|
5009
5919
|
result, color=color, width=width, raw_aggregates=result.raw_totals,
|
|
5010
|
-
tz=tz_obj,
|
|
5920
|
+
tz=tz_obj, compact=args.compact,
|
|
5011
5921
|
))
|
|
5012
5922
|
return 0
|
|
5013
5923
|
|
|
@@ -5015,7 +5925,8 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
5015
5925
|
def cmd_session(args: argparse.Namespace) -> int:
|
|
5016
5926
|
"""Show Claude usage grouped by sessionId (merges resumed-across-files sessions)."""
|
|
5017
5927
|
_share_validate_args(args)
|
|
5018
|
-
config =
|
|
5928
|
+
config = _load_claude_config_for_args(args)
|
|
5929
|
+
_bridge_z_into_tz(args, config)
|
|
5019
5930
|
tz = resolve_display_tz(args, config)
|
|
5020
5931
|
args._resolved_tz = tz
|
|
5021
5932
|
|
|
@@ -5029,6 +5940,27 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
5029
5940
|
range_start, range_end = range
|
|
5030
5941
|
|
|
5031
5942
|
entries = get_claude_session_entries(range_start, range_end)
|
|
5943
|
+
|
|
5944
|
+
# Issue #89: --debug report describes the joined-entry list filtered
|
|
5945
|
+
# by --id (post-fallback session_id resolution) when set, matching
|
|
5946
|
+
# the rendered scope of `sessions`.
|
|
5947
|
+
# `is not None`, not truthiness: an explicit empty `--id ''` must still
|
|
5948
|
+
# engage the filter (→ empty render), not silently fall through to
|
|
5949
|
+
# "describe/show all sessions" (code-review finding). Mirrored on the
|
|
5950
|
+
# post-aggregation filter below.
|
|
5951
|
+
if getattr(args, "id", None) is not None:
|
|
5952
|
+
joined_for_report = [
|
|
5953
|
+
je for je in entries
|
|
5954
|
+
if _resolve_session_id_for_filter(je) == args.id
|
|
5955
|
+
]
|
|
5956
|
+
else:
|
|
5957
|
+
joined_for_report = entries
|
|
5958
|
+
_emit_debug_samples_if_set(
|
|
5959
|
+
args,
|
|
5960
|
+
[_usage_entry_from_joined(je) for je in joined_for_report],
|
|
5961
|
+
command_label="session",
|
|
5962
|
+
)
|
|
5963
|
+
|
|
5032
5964
|
# Unified view-model kernel (spec §6.5). `limit=None` keeps the
|
|
5033
5965
|
# full aggregator output — `cctally session` has no `--limit` flag
|
|
5034
5966
|
# and emits every session in the requested range. `view.aggregated`
|
|
@@ -5044,6 +5976,16 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
5044
5976
|
)
|
|
5045
5977
|
sessions = list(view.aggregated)
|
|
5046
5978
|
|
|
5979
|
+
# Session A (spec §7.4): exact-string filter on sessionId. Applied
|
|
5980
|
+
# AFTER aggregation (so resume-merged sessions across multiple JSONL
|
|
5981
|
+
# files are matched against their post-merge id) and BEFORE the
|
|
5982
|
+
# `--order asc` reversal and the JSON / share / table render
|
|
5983
|
+
# branches. Unknown id → empty `sessions` list, which falls through
|
|
5984
|
+
# to the existing "no sessions" branch (table: "No Claude session
|
|
5985
|
+
# data found."; JSON: `{"sessions": []}`).
|
|
5986
|
+
if getattr(args, "id", None) is not None: # explicit '' still filters
|
|
5987
|
+
sessions = [s for s in sessions if s.session_id == args.id]
|
|
5988
|
+
|
|
5047
5989
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
5048
5990
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
5049
5991
|
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
@@ -5075,8 +6017,17 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
5075
6017
|
# sub-rows aren't in the share spec scope). Same convention as
|
|
5076
6018
|
# cmd_daily / cmd_project.
|
|
5077
6019
|
display_tz_str = _share_display_tz_label(tz)
|
|
6020
|
+
# Session A (spec §7.4): `_build_session_snapshot` reads
|
|
6021
|
+
# `view.aggregated`, so the `--id` filter applied to the local
|
|
6022
|
+
# `sessions` list above would otherwise be ignored for share
|
|
6023
|
+
# exports (HTML/Markdown/SVG). Hand the builder a view whose
|
|
6024
|
+
# `aggregated` is the filtered list so `--id` is honored across
|
|
6025
|
+
# every output path. The builder reads only `aggregated` (never
|
|
6026
|
+
# `rows` / `total_sessions`), so the parallel-tuple mismatch from
|
|
6027
|
+
# the replace is inert here.
|
|
6028
|
+
share_view = dataclasses.replace(view, aggregated=tuple(sessions))
|
|
5078
6029
|
snap = _build_session_snapshot(
|
|
5079
|
-
|
|
6030
|
+
share_view,
|
|
5080
6031
|
period_start=range_start,
|
|
5081
6032
|
period_end=range_end,
|
|
5082
6033
|
display_tz=display_tz_str,
|
|
@@ -5101,10 +6052,14 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
5101
6052
|
print("No Claude session data found.")
|
|
5102
6053
|
return 0
|
|
5103
6054
|
|
|
6055
|
+
# Session A (spec §7.6.1; Review-A P2-B): thread --compact through
|
|
6056
|
+
# so the renderer's scale-down branch fires regardless of terminal
|
|
6057
|
+
# width when the flag is set.
|
|
5104
6058
|
print(_render_claude_session_table(
|
|
5105
6059
|
sessions,
|
|
5106
6060
|
breakdown=args.breakdown,
|
|
5107
6061
|
tz=tz,
|
|
6062
|
+
compact=getattr(args, "compact", False),
|
|
5108
6063
|
))
|
|
5109
6064
|
return 0
|
|
5110
6065
|
|
|
@@ -7178,19 +8133,19 @@ def _latest_seven_day_and_window(
|
|
|
7178
8133
|
|
|
7179
8134
|
|
|
7180
8135
|
def _parse_date_filter(value: str, flag_name: str) -> str:
|
|
7181
|
-
"""Parse ``
|
|
8136
|
+
"""Parse ``YYYY-MM-DD`` or ``YYYYMMDD`` into an ISO date for SQL ``WHERE`` clauses.
|
|
7182
8137
|
|
|
7183
8138
|
Used by ``cmd_five_hour_blocks`` ``--since``/``--until``. Mirrors the
|
|
7184
|
-
upstream ccusage convention.
|
|
7185
|
-
|
|
7186
|
-
|
|
8139
|
+
upstream ccusage convention. Routes through the centralized
|
|
8140
|
+
``_parse_dual_form_date`` (spec §7.1.1) so the dual-form contract and
|
|
8141
|
+
error message are shared with cmd_blocks / cmd_daily / etc.
|
|
8142
|
+
|
|
8143
|
+
The helper already eprints its own diagnostic and raises a bare
|
|
8144
|
+
``ValueError``; we propagate that bare exception so callers can
|
|
8145
|
+
return an exit code without double-printing (Review-A P1-1; mirrors
|
|
8146
|
+
the bare-re-raise pattern used by ``cmd_cache_report``).
|
|
7187
8147
|
"""
|
|
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
|
|
8148
|
+
return _parse_dual_form_date(value, flag_name).date().isoformat()
|
|
7194
8149
|
|
|
7195
8150
|
|
|
7196
8151
|
def _load_breakdown(
|
|
@@ -7219,7 +8174,10 @@ def _load_breakdown(
|
|
|
7219
8174
|
def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
7220
8175
|
"""List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
|
|
7221
8176
|
_share_validate_args(args)
|
|
7222
|
-
config =
|
|
8177
|
+
config = _load_claude_config_for_args(args)
|
|
8178
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz before
|
|
8179
|
+
# resolve_display_tz so the new alias precedence lands.
|
|
8180
|
+
_bridge_z_into_tz(args, config)
|
|
7223
8181
|
args._resolved_tz = resolve_display_tz(args, config)
|
|
7224
8182
|
# Pin "now" once (CCTALLY_AS_OF for fixture-pinned harnesses; mirrors
|
|
7225
8183
|
# cmd_five_hour_breakdown). Used by the active-predicate to gate
|
|
@@ -7228,6 +8186,9 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
7228
8186
|
conn = open_db()
|
|
7229
8187
|
try:
|
|
7230
8188
|
# Date filter parsing — same convention as cmd_blocks.
|
|
8189
|
+
# _parse_date_filter routes through _parse_dual_form_date, which
|
|
8190
|
+
# eprints its own diagnostic and raises a bare ValueError on bad
|
|
8191
|
+
# input (Review-A P1-1 — dedup stderr by NOT re-emitting here).
|
|
7231
8192
|
try:
|
|
7232
8193
|
since_iso = (
|
|
7233
8194
|
_parse_date_filter(args.since, "--since")
|
|
@@ -7237,8 +8198,7 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
7237
8198
|
_parse_date_filter(args.until, "--until")
|
|
7238
8199
|
if args.until else None
|
|
7239
8200
|
)
|
|
7240
|
-
except ValueError
|
|
7241
|
-
print(str(e), file=sys.stderr)
|
|
8201
|
+
except ValueError:
|
|
7242
8202
|
return 2
|
|
7243
8203
|
|
|
7244
8204
|
where: list[str] = []
|
|
@@ -7266,6 +8226,31 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
7266
8226
|
params,
|
|
7267
8227
|
).fetchall()
|
|
7268
8228
|
|
|
8229
|
+
# Issue #89: --debug report scope = the time range spanned by
|
|
8230
|
+
# the rendered block rows. When `rows` is empty, pass an empty
|
|
8231
|
+
# list to short-circuit the loader entirely.
|
|
8232
|
+
if rows:
|
|
8233
|
+
# rows are ORDER BY block_start_at DESC; first row is newest,
|
|
8234
|
+
# last row is oldest. The rendered window is
|
|
8235
|
+
# [oldest_block_start, newest_block_start + BLOCK_DURATION).
|
|
8236
|
+
oldest_start_iso = rows[-1]["block_start_at"]
|
|
8237
|
+
newest_start_iso = rows[0]["block_start_at"]
|
|
8238
|
+
block_window_start = parse_iso_datetime(
|
|
8239
|
+
oldest_start_iso, "block_start_at",
|
|
8240
|
+
)
|
|
8241
|
+
block_window_end = parse_iso_datetime(
|
|
8242
|
+
newest_start_iso, "block_start_at",
|
|
8243
|
+
) + BLOCK_DURATION
|
|
8244
|
+
_emit_debug_samples_if_set(
|
|
8245
|
+
args,
|
|
8246
|
+
lambda: get_entries(block_window_start, block_window_end),
|
|
8247
|
+
command_label="five-hour-blocks",
|
|
8248
|
+
)
|
|
8249
|
+
else:
|
|
8250
|
+
_emit_debug_samples_if_set(
|
|
8251
|
+
args, [], command_label="five-hour-blocks",
|
|
8252
|
+
)
|
|
8253
|
+
|
|
7269
8254
|
# Detect truncation: cap applied AND there's at least one older
|
|
7270
8255
|
# block beyond the cap. Probe with LIMIT 1 OFFSET <cap> over the
|
|
7271
8256
|
# SAME filter set (none here, but kept symmetric for clarity).
|
|
@@ -7868,18 +8853,28 @@ def _resolve_cache_report_window(
|
|
|
7868
8853
|
# Full-ISO args carry an explicit time component or tz marker.
|
|
7869
8854
|
if "T" in raw or "+" in raw or "Z" in raw:
|
|
7870
8855
|
return parse_iso_datetime(raw, flag)
|
|
7871
|
-
# Date-only:
|
|
7872
|
-
#
|
|
7873
|
-
|
|
8856
|
+
# Date-only: route through the centralized dual-form helper
|
|
8857
|
+
# (spec §7.1.1) so YYYY-MM-DD / YYYYMMDD parsing and the error
|
|
8858
|
+
# message stay consistent with cmd_blocks / cmd_daily / etc.
|
|
8859
|
+
naive = _try_dual_form_date(raw)
|
|
8860
|
+
if naive is None:
|
|
8861
|
+
# Second-chance (issue #101): the pre-Session-A code fell
|
|
8862
|
+
# through to parse_iso_datetime here, so space-separated
|
|
8863
|
+
# datetimes (`2026-05-01 12:30:00`) and ISO week-dates
|
|
8864
|
+
# (`2026-W18-1`) — both accepted by datetime.fromisoformat but
|
|
8865
|
+
# rejected by the dual-form parser — kept working. cache-report
|
|
8866
|
+
# is the only date-taking command with this fallthrough; the
|
|
8867
|
+
# other commands accept YYYY-MM-DD / YYYYMMDD only. Returned
|
|
8868
|
+
# verbatim: a full datetime carries its own time component, so
|
|
8869
|
+
# the is_upper_bound end-of-day expansion is NOT applied (it's
|
|
8870
|
+
# for bare date-only forms). On TOTAL failure (neither dual-form
|
|
8871
|
+
# nor ISO), fall back to the centralized dual-form diagnostic
|
|
8872
|
+
# rather than parse_iso_datetime's more generic message.
|
|
7874
8873
|
try:
|
|
7875
|
-
|
|
7876
|
-
break
|
|
8874
|
+
return parse_iso_datetime(raw, flag)
|
|
7877
8875
|
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)
|
|
8876
|
+
_parse_dual_form_date(raw, flag) # eprints + raises ValueError
|
|
8877
|
+
raise # unreachable — the call above always raises here
|
|
7883
8878
|
if is_upper_bound:
|
|
7884
8879
|
naive = naive.replace(
|
|
7885
8880
|
hour=23, minute=59, second=59, microsecond=999999,
|
|
@@ -8131,15 +9126,47 @@ def _sort_cache_rows(
|
|
|
8131
9126
|
|
|
8132
9127
|
|
|
8133
9128
|
def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
8134
|
-
config =
|
|
9129
|
+
config = _load_claude_config_for_args(args)
|
|
9130
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
9131
|
+
# existing resolve_display_tz precedence absorbs the new alias. The
|
|
9132
|
+
# canonical --tz still wins (it's set on the namespace before this
|
|
9133
|
+
# bridge fires); when --tz is unset and -z is supplied, use -z.
|
|
9134
|
+
_bridge_z_into_tz(args, config)
|
|
8135
9135
|
tz = resolve_display_tz(args, config)
|
|
8136
9136
|
args._resolved_tz = tz
|
|
8137
9137
|
|
|
8138
9138
|
now_utc = _command_as_of()
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
9139
|
+
# Session A (spec §7.1.1): the dual-form helper eprints its own
|
|
9140
|
+
# diagnostic and raises a bare ValueError; catch it here so main()'s
|
|
9141
|
+
# generic ``Error: {exc}`` fallback doesn't double-print an empty
|
|
9142
|
+
# trailer. Mirrors the catch-and-return-1 shape in cmd_blocks.
|
|
9143
|
+
#
|
|
9144
|
+
# Session A note (Review-A P2-D): cache-report's argparse alias
|
|
9145
|
+
# surface lands in Implementor 2's scope (B-series). The try/except
|
|
9146
|
+
# here only routes _resolve_cache_report_window's bare ValueError
|
|
9147
|
+
# around main()'s generic Error: handler — no parser-level changes.
|
|
9148
|
+
try:
|
|
9149
|
+
since, until = _resolve_cache_report_window(
|
|
9150
|
+
args, now_utc=now_utc,
|
|
9151
|
+
tz_name=(tz.key if tz is not None else None),
|
|
9152
|
+
)
|
|
9153
|
+
except ValueError as exc:
|
|
9154
|
+
# Centralized helper already eprinted on the dual-form path; for
|
|
9155
|
+
# the legacy parse_iso_datetime path (full-ISO mis-format) the
|
|
9156
|
+
# ValueError carries the message, so eprint it.
|
|
9157
|
+
msg = str(exc)
|
|
9158
|
+
if msg:
|
|
9159
|
+
eprint(f"Error: {msg}")
|
|
9160
|
+
return 1
|
|
9161
|
+
|
|
9162
|
+
# Issue #89 Pattern C: deferred loader scoped to the rendered window
|
|
9163
|
+
# (project filter mirrors what the cache-aggregator uses).
|
|
9164
|
+
_emit_debug_samples_if_set(
|
|
9165
|
+
args,
|
|
9166
|
+
lambda: get_entries(since, until, project=args.project),
|
|
9167
|
+
command_label="cache-report",
|
|
8142
9168
|
)
|
|
9169
|
+
|
|
8143
9170
|
mode = "session" if getattr(args, "by_session", False) else "day"
|
|
8144
9171
|
top_key = "sessions" if mode == "session" else "days"
|
|
8145
9172
|
|
|
@@ -8193,11 +9220,20 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
8193
9220
|
return 0
|
|
8194
9221
|
|
|
8195
9222
|
title = _build_cache_report_title(args, mode)
|
|
8196
|
-
print(_render_cache_report_table(rows, title, mode=mode, tz=tz))
|
|
9223
|
+
print(_render_cache_report_table(rows, title, mode=mode, tz=tz, compact=args.compact))
|
|
8197
9224
|
return 0
|
|
8198
9225
|
|
|
8199
9226
|
|
|
8200
9227
|
def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
9228
|
+
# Session A (spec §7.2 / §7.6 row note): range-cost has no --tz of
|
|
9229
|
+
# its own — start/end carry their own zone via ISO 8601. Calling the
|
|
9230
|
+
# bridge keeps the alias-surface contract uniform across the 10
|
|
9231
|
+
# in-scope cmds: -z/--timezone lands on args.tz unchanged (no
|
|
9232
|
+
# downstream consumer), so this is a documented no-op for this
|
|
9233
|
+
# command. The bridge still runs _resolve_claude_tz_name so the §9.2a
|
|
9234
|
+
# production-path coverage is exercised here too.
|
|
9235
|
+
config = _load_claude_config_for_args(args)
|
|
9236
|
+
_bridge_z_into_tz(args, config)
|
|
8201
9237
|
start_dt = parse_iso_datetime(args.start, "--start")
|
|
8202
9238
|
if args.end:
|
|
8203
9239
|
end_dt = parse_iso_datetime(args.end, "--end")
|
|
@@ -8214,7 +9250,17 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
|
8214
9250
|
last_match: dt.datetime | None = None
|
|
8215
9251
|
model_buckets: dict[str, dict[str, Any]] = {}
|
|
8216
9252
|
|
|
8217
|
-
|
|
9253
|
+
# Issue #89: keep the loaded list around so the --debug report can
|
|
9254
|
+
# describe the same dataset as the rendered output. Project filter is
|
|
9255
|
+
# applied at the loader (SELECT-time), so the scope is the same.
|
|
9256
|
+
# P2.2 (issue #89 review-loop): get_entries already returns
|
|
9257
|
+
# list[UsageEntry] per bin/_cctally_cache.py:1224 — no list() wrap.
|
|
9258
|
+
entries_list = get_entries(start_dt, end_dt, project=args.project)
|
|
9259
|
+
_emit_debug_samples_if_set(
|
|
9260
|
+
args, entries_list, command_label="range-cost",
|
|
9261
|
+
)
|
|
9262
|
+
|
|
9263
|
+
for entry in entries_list:
|
|
8218
9264
|
cost = _calculate_entry_cost(
|
|
8219
9265
|
entry.model, entry.usage, mode=args.mode, cost_usd=entry.cost_usd,
|
|
8220
9266
|
)
|
|
@@ -8319,7 +9365,7 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
|
8319
9365
|
])
|
|
8320
9366
|
|
|
8321
9367
|
aligns = ["left"] + ["right"] * (len(headers) - 1)
|
|
8322
|
-
print(_boxed_table(headers, rows, aligns))
|
|
9368
|
+
print(_boxed_table(headers, rows, aligns, compact=args.compact))
|
|
8323
9369
|
return 0
|
|
8324
9370
|
|
|
8325
9371
|
# Default: print cost
|
|
@@ -8689,6 +9735,10 @@ def doctor_gather_state(
|
|
|
8689
9735
|
effective_update_reason=effective_update_reason,
|
|
8690
9736
|
now_utc=now_utc,
|
|
8691
9737
|
cctally_version=cctally_version,
|
|
9738
|
+
# Dev-instance isolation (§4): which data dir resolved + how.
|
|
9739
|
+
dev_mode=_cctally_core.DEV_MODE,
|
|
9740
|
+
app_dir=str(_cctally_core.APP_DIR),
|
|
9741
|
+
is_dev_checkout=_cctally_core._is_dev_checkout(),
|
|
8692
9742
|
)
|
|
8693
9743
|
|
|
8694
9744
|
|
|
@@ -8733,6 +9783,110 @@ def _argparse_has_arg(parser, option_string: str) -> bool:
|
|
|
8733
9783
|
return False
|
|
8734
9784
|
|
|
8735
9785
|
|
|
9786
|
+
def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
|
|
9787
|
+
"""Attach the Session A ccusage alias surface to a Claude-cmd subparser.
|
|
9788
|
+
|
|
9789
|
+
Sibling to ``_add_codex_shared_args`` (declared inside ``build_parser``)
|
|
9790
|
+
but tailored for Claude commands. Every flag is guarded with
|
|
9791
|
+
``_argparse_has_arg`` so existing per-parser declarations
|
|
9792
|
+
(cache-report's ``--offline``, project / five-hour-blocks / diff's
|
|
9793
|
+
``--no-color``) do NOT cause ``argparse.ArgumentError`` — the helper
|
|
9794
|
+
just skips the duplicate. This makes future collisions self-healing
|
|
9795
|
+
when a contributor adds a Session A-managed flag directly on a
|
|
9796
|
+
subparser.
|
|
9797
|
+
|
|
9798
|
+
Args:
|
|
9799
|
+
parser: the subparser to mutate.
|
|
9800
|
+
ansi_emit: ``True`` for project + diff (the 2 real ANSI emitters).
|
|
9801
|
+
``False`` for the other 8 in-scope cmds. Controls only
|
|
9802
|
+
the ``--color`` help text and whether ``--no-color`` is
|
|
9803
|
+
attempted as a fresh add (when ``ansi_emit=True`` we
|
|
9804
|
+
skip ``--no-color`` entirely — those parsers already
|
|
9805
|
+
declared it themselves).
|
|
9806
|
+
|
|
9807
|
+
Spec §7.1.2 / issue #86 Session A.
|
|
9808
|
+
"""
|
|
9809
|
+
|
|
9810
|
+
def _maybe_add(opt: str, *args, **kwargs):
|
|
9811
|
+
if _argparse_has_arg(parser, opt):
|
|
9812
|
+
return
|
|
9813
|
+
parser.add_argument(opt, *args, **kwargs)
|
|
9814
|
+
|
|
9815
|
+
def _maybe_add2(opt1: str, opt2: str, *args, **kwargs):
|
|
9816
|
+
# Two-form add (short + long) — skip if EITHER is present.
|
|
9817
|
+
if _argparse_has_arg(parser, opt1) or _argparse_has_arg(parser, opt2):
|
|
9818
|
+
return
|
|
9819
|
+
parser.add_argument(opt1, opt2, *args, **kwargs)
|
|
9820
|
+
|
|
9821
|
+
_maybe_add2(
|
|
9822
|
+
"-z", "--timezone", default=None, metavar="TZ",
|
|
9823
|
+
help="Alias for --tz (drop-in compat with ccusage). When both "
|
|
9824
|
+
"are supplied, --tz wins.",
|
|
9825
|
+
)
|
|
9826
|
+
_maybe_add2(
|
|
9827
|
+
"-O", "--offline",
|
|
9828
|
+
action=argparse.BooleanOptionalAction, default=False,
|
|
9829
|
+
help="Accepted for ccusage drop-in compat; cctally is always offline.",
|
|
9830
|
+
)
|
|
9831
|
+
_maybe_add(
|
|
9832
|
+
"--compact", action="store_true",
|
|
9833
|
+
help="Force compact table layout regardless of terminal width.",
|
|
9834
|
+
)
|
|
9835
|
+
_maybe_add(
|
|
9836
|
+
"--config", default=None, metavar="PATH",
|
|
9837
|
+
help="Read config from PATH for this invocation only (no "
|
|
9838
|
+
"mutation of the default config at "
|
|
9839
|
+
"~/.local/share/cctally/config.json). Missing or invalid "
|
|
9840
|
+
"PATH errors out with a clear message.",
|
|
9841
|
+
)
|
|
9842
|
+
_maybe_add2(
|
|
9843
|
+
"-d", "--debug", action="store_true",
|
|
9844
|
+
help="Emit a stderr 'Pricing Mismatch Debug Report' "
|
|
9845
|
+
"(totals + per-model stats + sample discrepancies, "
|
|
9846
|
+
"matching ccusage's --debug shape).",
|
|
9847
|
+
)
|
|
9848
|
+
_maybe_add(
|
|
9849
|
+
"--debug-samples", type=_nonneg_int, default=5, metavar="N",
|
|
9850
|
+
help="Cap on sample-discrepancy rows in the --debug report "
|
|
9851
|
+
"(default 5; N=0 suppresses the sample block; "
|
|
9852
|
+
"negatives rejected at parse time).",
|
|
9853
|
+
)
|
|
9854
|
+
_maybe_add(
|
|
9855
|
+
"--single-thread", action="store_true",
|
|
9856
|
+
help="Accepted for ccusage drop-in compat; cctally ingestion "
|
|
9857
|
+
"is already single-threaded via the session-entry cache.",
|
|
9858
|
+
)
|
|
9859
|
+
if ansi_emit:
|
|
9860
|
+
_maybe_add(
|
|
9861
|
+
"--color", action="store_true", default=False,
|
|
9862
|
+
help="Force ANSI color output (overrides NO_COLOR env). When "
|
|
9863
|
+
"neither --color nor --no-color is set, color is auto-"
|
|
9864
|
+
"detected from isatty() and NO_COLOR/FORCE_COLOR env.",
|
|
9865
|
+
)
|
|
9866
|
+
# --no-color already declared on these parsers; do nothing here.
|
|
9867
|
+
else:
|
|
9868
|
+
# No-op-for-compat surface (spec §7.3): these flags parse but do
|
|
9869
|
+
# NOT flow through the color resolver on this command. Color (where
|
|
9870
|
+
# the renderer emits any) follows the auto-detect — isatty() plus
|
|
9871
|
+
# NO_COLOR / FORCE_COLOR env — so the help must NOT claim "no ANSI
|
|
9872
|
+
# is emitted" (daily/monthly/weekly/blocks/session/cache-report DO
|
|
9873
|
+
# emit auto-detected ANSI on a TTY; only the no-color env vars
|
|
9874
|
+
# suppress it). Force/suppress color on the 2 real ANSI commands
|
|
9875
|
+
# (project, diff) instead, or use NO_COLOR=1 / FORCE_COLOR=1.
|
|
9876
|
+
_maybe_add(
|
|
9877
|
+
"--color", action="store_true", default=False,
|
|
9878
|
+
help="Accepted for ccusage drop-in compat; does not control "
|
|
9879
|
+
"this command's color. Color auto-detects from isatty() "
|
|
9880
|
+
"and honors NO_COLOR / FORCE_COLOR env.",
|
|
9881
|
+
)
|
|
9882
|
+
_maybe_add(
|
|
9883
|
+
"--no-color", action="store_true", default=False,
|
|
9884
|
+
help="Accepted for ccusage drop-in compat; does not suppress "
|
|
9885
|
+
"this command's color. Use NO_COLOR=1 env (or pipe stdout) "
|
|
9886
|
+
"to disable auto-detected ANSI.",
|
|
9887
|
+
)
|
|
9888
|
+
|
|
9889
|
+
|
|
8736
9890
|
def _add_share_args(parser, *, has_status_line: bool = False) -> None:
|
|
8737
9891
|
"""Attach shareable-reports flags + format/json mutex to a subparser.
|
|
8738
9892
|
|
|
@@ -8887,7 +10041,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
8887
10041
|
),
|
|
8888
10042
|
)
|
|
8889
10043
|
p.add_argument(
|
|
8890
|
-
"--version",
|
|
10044
|
+
"-v", "--version",
|
|
8891
10045
|
action="store_true",
|
|
8892
10046
|
default=argparse.SUPPRESS,
|
|
8893
10047
|
help="Print cctally version (from CHANGELOG.md latest release header) and exit",
|
|
@@ -9472,9 +10626,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9472
10626
|
"Adds SessionId, Last Activity, and Project identity columns.",
|
|
9473
10627
|
)
|
|
9474
10628
|
pc.add_argument(
|
|
9475
|
-
"--offline",
|
|
9476
|
-
action=
|
|
9477
|
-
help="Use cached pricing data in ccusage."
|
|
10629
|
+
"-O", "--offline",
|
|
10630
|
+
action=argparse.BooleanOptionalAction, default=False,
|
|
10631
|
+
help="Use cached pricing data in ccusage. Session A (spec §7.1.2)"
|
|
10632
|
+
" promotes the existing flag to BooleanOptionalAction + -O"
|
|
10633
|
+
" short form so the ccusage drop-in alias surface (-O,"
|
|
10634
|
+
" --offline, --no-offline) all work on cache-report; the"
|
|
10635
|
+
" behavior under each is unchanged (cctally is always"
|
|
10636
|
+
" offline — args.offline still lands as a bool).",
|
|
9478
10637
|
)
|
|
9479
10638
|
pc.add_argument(
|
|
9480
10639
|
"--project",
|
|
@@ -9521,6 +10680,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9521
10680
|
help="Display timezone: local, utc, or IANA name. "
|
|
9522
10681
|
"Overrides config display.tz for this call.",
|
|
9523
10682
|
)
|
|
10683
|
+
# Session A (spec §7.6): ansi_emit=False; existing `--offline` is
|
|
10684
|
+
# skipped by the helper's `_argparse_has_arg` guard (the collision
|
|
10685
|
+
# case spec §7.1.2 calls out explicitly).
|
|
10686
|
+
_add_ccusage_alias_args(pc, ansi_emit=False)
|
|
9524
10687
|
pc.set_defaults(func=cmd_cache_report)
|
|
9525
10688
|
|
|
9526
10689
|
# -- range-cost --
|
|
@@ -9575,6 +10738,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9575
10738
|
dest="total_only",
|
|
9576
10739
|
help="Print numeric USD total only.",
|
|
9577
10740
|
)
|
|
10741
|
+
# Session A (spec §7.6): ansi_emit=False. range-cost has no --tz of
|
|
10742
|
+
# its own (ISO timestamps carry zone info), but the helper-added
|
|
10743
|
+
# -z/--timezone still lands on the namespace; the bridge promotes
|
|
10744
|
+
# it onto args.tz where the rest of the pipeline treats it as a
|
|
10745
|
+
# documented no-op (cmd_range_cost does not consume args.tz).
|
|
10746
|
+
_add_ccusage_alias_args(rc, ansi_emit=False)
|
|
9578
10747
|
rc.set_defaults(func=cmd_range_cost)
|
|
9579
10748
|
|
|
9580
10749
|
# -- blocks --
|
|
@@ -9619,6 +10788,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9619
10788
|
help="Display timezone: local, utc, or IANA name. "
|
|
9620
10789
|
"Overrides config display.tz for this call.",
|
|
9621
10790
|
)
|
|
10791
|
+
_add_ccusage_alias_args(bl, ansi_emit=False)
|
|
9622
10792
|
bl.set_defaults(func=cmd_blocks)
|
|
9623
10793
|
|
|
9624
10794
|
# -- five-hour-blocks --
|
|
@@ -9666,7 +10836,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9666
10836
|
fhb.add_argument(
|
|
9667
10837
|
"--no-color",
|
|
9668
10838
|
action="store_true",
|
|
9669
|
-
help="
|
|
10839
|
+
help="Accepted for ccusage drop-in compat; this command emits "
|
|
10840
|
+
"plain-text output and no ANSI is suppressed.",
|
|
9670
10841
|
)
|
|
9671
10842
|
fhb.add_argument(
|
|
9672
10843
|
"--tz",
|
|
@@ -9676,6 +10847,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9676
10847
|
help="Display timezone: local, utc, or IANA name. "
|
|
9677
10848
|
"Overrides config display.tz for this call.",
|
|
9678
10849
|
)
|
|
10850
|
+
# Session A (spec §7.6 / §7.6.3): ansi_emit=False. fhb already
|
|
10851
|
+
# declares --no-color (refreshed to no-op text in spec §7.6.3); the
|
|
10852
|
+
# helper's --no-color add is short-circuited by the existing-arg
|
|
10853
|
+
# guard. The helper's --color add lands as a parsed-and-ignored
|
|
10854
|
+
# no-op (the renderer emits plain text).
|
|
10855
|
+
_add_ccusage_alias_args(fhb, ansi_emit=False)
|
|
9679
10856
|
_add_share_args(fhb)
|
|
9680
10857
|
fhb.set_defaults(func=cmd_five_hour_blocks)
|
|
9681
10858
|
|
|
@@ -9747,6 +10924,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9747
10924
|
help="Display timezone: local, utc, or IANA name. "
|
|
9748
10925
|
"Overrides config display.tz for this call.",
|
|
9749
10926
|
)
|
|
10927
|
+
_add_ccusage_alias_args(dy, ansi_emit=False)
|
|
9750
10928
|
_add_share_args(dy)
|
|
9751
10929
|
dy.set_defaults(func=cmd_daily)
|
|
9752
10930
|
|
|
@@ -9800,6 +10978,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9800
10978
|
help="Display timezone: local, utc, or IANA name. "
|
|
9801
10979
|
"Overrides config display.tz for this call.",
|
|
9802
10980
|
)
|
|
10981
|
+
_add_ccusage_alias_args(mo, ansi_emit=False)
|
|
9803
10982
|
_add_share_args(mo)
|
|
9804
10983
|
mo.set_defaults(func=cmd_monthly)
|
|
9805
10984
|
|
|
@@ -9835,6 +11014,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9835
11014
|
we.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
9836
11015
|
help="Display timezone: local, utc, or IANA name. "
|
|
9837
11016
|
"Overrides config display.tz for this call.")
|
|
11017
|
+
_add_ccusage_alias_args(we, ansi_emit=False)
|
|
9838
11018
|
_add_share_args(we)
|
|
9839
11019
|
we.set_defaults(func=cmd_weekly)
|
|
9840
11020
|
|
|
@@ -9885,6 +11065,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9885
11065
|
"config display.tz for this call. Takes precedence over "
|
|
9886
11066
|
"upstream's --timezone for drop-in parity.",
|
|
9887
11067
|
)
|
|
11068
|
+
# Issue #92: codex parity for the #89 --debug surface. Codex JSONL
|
|
11069
|
+
# has no recorded costUSD to diff against, so the report is the
|
|
11070
|
+
# codex variant ("Codex Pricing Debug Report": totals + top-N
|
|
11071
|
+
# highest computed-cost entries), wired via
|
|
11072
|
+
# _emit_codex_debug_samples_if_set in each cmd_codex_* body.
|
|
11073
|
+
parser.add_argument(
|
|
11074
|
+
"-d", "--debug", action="store_true",
|
|
11075
|
+
help="Emit a stderr 'Codex Pricing Debug Report' (totals + "
|
|
11076
|
+
"the N highest computed-cost sample entries).",
|
|
11077
|
+
)
|
|
11078
|
+
parser.add_argument(
|
|
11079
|
+
"--debug-samples", type=_nonneg_int, default=5, metavar="N",
|
|
11080
|
+
help="Cap on top-entry sample rows in the --debug report "
|
|
11081
|
+
"(default 5; N=0 suppresses the sample block; "
|
|
11082
|
+
"negatives rejected at parse time).",
|
|
11083
|
+
)
|
|
9888
11084
|
|
|
9889
11085
|
# -- codex-daily --
|
|
9890
11086
|
cd = sub.add_parser(
|
|
@@ -10039,6 +11235,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
10039
11235
|
p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
10040
11236
|
help="Display timezone: local, utc, or IANA name. "
|
|
10041
11237
|
"Overrides config display.tz for this call.")
|
|
11238
|
+
# Session A (spec §7.6): ansi_emit=True. project is one of the two
|
|
11239
|
+
# real ANSI emitters. The helper skips its --no-color add (already
|
|
11240
|
+
# declared at p_project above) and adds the new bool --color flag
|
|
11241
|
+
# whose precedence flows through _resolve_color_enabled (§7.3).
|
|
11242
|
+
_add_ccusage_alias_args(p_project, ansi_emit=True)
|
|
10042
11243
|
_add_share_args(p_project)
|
|
10043
11244
|
p_project.set_defaults(func=cmd_project)
|
|
10044
11245
|
|
|
@@ -10073,6 +11274,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
10073
11274
|
diff_p.add_argument("--json", dest="emit_json", action="store_true")
|
|
10074
11275
|
diff_p.add_argument("--width", type=int, help=argparse.SUPPRESS)
|
|
10075
11276
|
diff_p.add_argument("--debug-now", action="store_true", help=argparse.SUPPRESS)
|
|
11277
|
+
# Session A (spec §7.6): ansi_emit=True. diff is the other real
|
|
11278
|
+
# ANSI emitter. The helper skips its --no-color add (declared
|
|
11279
|
+
# above) and adds the new bool --color flag wired through
|
|
11280
|
+
# _resolve_color_enabled (§7.3). Note: --debug here collides with
|
|
11281
|
+
# diff's existing `--debug-now` (SUPPRESS'd internal flag) but
|
|
11282
|
+
# `--debug-now` is a different option string; the helper still
|
|
11283
|
+
# adds plain `--debug` cleanly.
|
|
11284
|
+
_add_ccusage_alias_args(diff_p, ansi_emit=True)
|
|
10076
11285
|
diff_p.set_defaults(func=cmd_diff)
|
|
10077
11286
|
|
|
10078
11287
|
# -- session --
|
|
@@ -10111,6 +11320,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
10111
11320
|
se.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
10112
11321
|
help="Display timezone: local, utc, or IANA name. "
|
|
10113
11322
|
"Overrides config display.tz for this call.")
|
|
11323
|
+
se.add_argument(
|
|
11324
|
+
"-i", "--id", default=None, metavar="SESSION_ID", dest="id",
|
|
11325
|
+
help="Filter to a single session by exact-string sessionId. "
|
|
11326
|
+
"Match is against the post-resume-merge id (sessions "
|
|
11327
|
+
"resumed across multiple JSONL files collapse to one id). "
|
|
11328
|
+
"Unknown id → exit 0 with the empty-render branch.",
|
|
11329
|
+
)
|
|
11330
|
+
_add_ccusage_alias_args(se, ansi_emit=False)
|
|
10114
11331
|
_add_share_args(se)
|
|
10115
11332
|
se.set_defaults(func=cmd_session)
|
|
10116
11333
|
|
|
@@ -10255,6 +11472,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
10255
11472
|
help="Skip confirmations")
|
|
10256
11473
|
sp.add_argument("--json", action="store_true",
|
|
10257
11474
|
help="Emit machine-readable output")
|
|
11475
|
+
sp.add_argument("--force-dev", action="store_true", dest="force_dev",
|
|
11476
|
+
help="Allow setup to run from a dev checkout (writes "
|
|
11477
|
+
"dev-pointing hooks into ~/.claude/settings.json)")
|
|
10258
11478
|
# Legacy bespoke-hook migration flags (install-mode only — see cmd_setup
|
|
10259
11479
|
# post-parse validation). Spec Section 2 mode×flag matrix.
|
|
10260
11480
|
mig_group = sp.add_mutually_exclusive_group()
|
|
@@ -12302,10 +13522,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
12302
13522
|
# works without a subcommand (`cctally --version`).
|
|
12303
13523
|
if getattr(args, "version", False):
|
|
12304
13524
|
v = _lib_changelog._read_latest_changelog_version()
|
|
12305
|
-
if v is None
|
|
12306
|
-
|
|
12307
|
-
|
|
12308
|
-
|
|
13525
|
+
base = "cctally unknown" if v is None else f"cctally {v[0]}"
|
|
13526
|
+
# Dev-instance isolation (§4, P3): append the dev marker + resolved
|
|
13527
|
+
# data dir whenever running from a checkout — keyed on
|
|
13528
|
+
# _is_dev_checkout(), NOT DEV_MODE, so the CCTALLY_DATA_DIR
|
|
13529
|
+
# override-on-checkout case (DEV_MODE False) still shows the marker
|
|
13530
|
+
# instead of masquerading as the installed binary. Prod (no .git)
|
|
13531
|
+
# output is unchanged. The override case is labelled distinctly so
|
|
13532
|
+
# the user can tell auto-detect (cctally-dev) from an explicit dir.
|
|
13533
|
+
if _cctally_core.DEV_MODE:
|
|
13534
|
+
base += f" (dev — {_cctally_core.APP_DIR})"
|
|
13535
|
+
elif _cctally_core._is_dev_checkout():
|
|
13536
|
+
base += f" (dev checkout, custom data dir — {_cctally_core.APP_DIR})"
|
|
13537
|
+
print(base)
|
|
12309
13538
|
return 0
|
|
12310
13539
|
if not getattr(args, "func", None):
|
|
12311
13540
|
parser.error("a subcommand is required")
|