cctally 1.18.0 → 1.20.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 +10 -0
- package/bin/_cctally_cache.py +91 -23
- package/bin/_cctally_config.py +143 -0
- package/bin/_lib_aggregators.py +68 -19
- package/bin/_lib_blocks.py +55 -1
- package/bin/_lib_doctor.py +1 -1
- package/bin/_lib_render.py +212 -53
- package/bin/_lib_statusline.py +499 -0
- package/bin/cctally +903 -20
- package/bin/cctally-statusline +3 -0
- package/package.json +3 -1
package/bin/cctally
CHANGED
|
@@ -150,6 +150,7 @@ SETUP_SYMLINK_NAMES = (
|
|
|
150
150
|
"cctally-forecast",
|
|
151
151
|
"cctally-project",
|
|
152
152
|
"cctally-refresh-usage",
|
|
153
|
+
"cctally-statusline",
|
|
153
154
|
"cctally-sync-week",
|
|
154
155
|
"cctally-tui",
|
|
155
156
|
"cctally-update",
|
|
@@ -274,6 +275,26 @@ CODEX_FAST_MULTIPLIER_FALLBACK = _lib_pricing.CODEX_FAST_MULTIPLIER_FALLBACK
|
|
|
274
275
|
_codex_config_requests_fast_service_tier = _lib_pricing._codex_config_requests_fast_service_tier
|
|
275
276
|
_short_model_name = _lib_pricing._short_model_name
|
|
276
277
|
|
|
278
|
+
# Per-model context window (used by `cctally statusline` segment 4).
|
|
279
|
+
# Keep in sync with Anthropic's docs:
|
|
280
|
+
# https://docs.anthropic.com/en/docs/about-claude/models
|
|
281
|
+
# Unknown model id → segment renders `🧠 N/A` + one-shot stderr warn.
|
|
282
|
+
CLAUDE_MODEL_CONTEXT_WINDOWS = {
|
|
283
|
+
# 1M-token variants (explicit IDs override the family default).
|
|
284
|
+
"claude-opus-4-7[1m]": 1_000_000,
|
|
285
|
+
"claude-sonnet-4-5[1m]": 1_000_000,
|
|
286
|
+
# Default 200K for every other Sonnet/Opus/Haiku family member.
|
|
287
|
+
# The resolver does a substring match on the family token if the
|
|
288
|
+
# exact id is missing — see _resolve_context_window.
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY = {
|
|
292
|
+
# Substring (case-insensitive) → window. Order matters; first hit wins.
|
|
293
|
+
"sonnet": 200_000,
|
|
294
|
+
"opus": 200_000,
|
|
295
|
+
"haiku": 200_000,
|
|
296
|
+
}
|
|
297
|
+
|
|
277
298
|
_lib_display_tz = _load_sibling("_lib_display_tz")
|
|
278
299
|
DISPLAY_TZ_DEFAULT = _lib_display_tz.DISPLAY_TZ_DEFAULT
|
|
279
300
|
_DISPLAY_TZ_BAD_CONFIG_WARNED = _lib_display_tz._DISPLAY_TZ_BAD_CONFIG_WARNED
|
|
@@ -349,6 +370,11 @@ _group_entries_into_blocks = _lib_blocks._group_entries_into_blocks
|
|
|
349
370
|
_aggregate_block = _lib_blocks._aggregate_block
|
|
350
371
|
_build_activity_block = _lib_blocks._build_activity_block
|
|
351
372
|
_blocks_to_json = _lib_blocks._blocks_to_json
|
|
373
|
+
_max_completed_block_tokens = _lib_blocks._max_completed_block_tokens
|
|
374
|
+
_parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit
|
|
375
|
+
|
|
376
|
+
_lib_statusline = _load_sibling("_lib_statusline")
|
|
377
|
+
STATUSLINE_BURN_RATE_BANDS = _lib_statusline.STATUSLINE_BURN_RATE_BANDS
|
|
352
378
|
|
|
353
379
|
_lib_changelog = _load_sibling("_lib_changelog")
|
|
354
380
|
# Backward-compat re-export: callers and tests reach the helper through
|
|
@@ -409,6 +435,7 @@ build_codex_session_view = _lib_view_models.build_codex_session_view
|
|
|
409
435
|
_lib_render = _load_sibling("_lib_render")
|
|
410
436
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
411
437
|
_render_blocks_table = _lib_render._render_blocks_table
|
|
438
|
+
_render_active_block_box = _lib_render._render_active_block_box
|
|
412
439
|
_daily_row_dict = _lib_render._daily_row_dict
|
|
413
440
|
_bucket_totals_dict = _lib_render._bucket_totals_dict
|
|
414
441
|
_bucket_to_json = _lib_render._bucket_to_json
|
|
@@ -618,7 +645,6 @@ cmd_db_unskip = _cctally_db.cmd_db_unskip
|
|
|
618
645
|
_cctally_cache = _load_sibling("_cctally_cache")
|
|
619
646
|
ProjectKey = _cctally_cache.ProjectKey
|
|
620
647
|
_resolve_project_key = _cctally_cache._resolve_project_key
|
|
621
|
-
_get_codex_sessions_dir = _cctally_cache._get_codex_sessions_dir
|
|
622
648
|
_discover_codex_session_files = _cctally_cache._discover_codex_session_files
|
|
623
649
|
IngestStats = _cctally_cache.IngestStats
|
|
624
650
|
_progress_stderr = _cctally_cache._progress_stderr
|
|
@@ -875,6 +901,84 @@ LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"
|
|
|
875
901
|
|
|
876
902
|
CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"
|
|
877
903
|
|
|
904
|
+
|
|
905
|
+
def _codex_home_roots() -> list[pathlib.Path]:
|
|
906
|
+
"""ALL raw $CODEX_HOME entries (comma-split), else [~/.codex].
|
|
907
|
+
|
|
908
|
+
These are the entries upstream calls "CODEX_HOME roots" — the full
|
|
909
|
+
comma-split list, BEFORE the sessions/-subdir rule decides home-vs-direct.
|
|
910
|
+
The config reader (_detect_codex_fast_service_tier) reads <entry>/config.toml
|
|
911
|
+
for EVERY entry returned here, including ones that turn out to be direct
|
|
912
|
+
JSONL dirs; only _codex_session_roots() applies the sessions/-subdir
|
|
913
|
+
narrowing. Blank/whitespace entries are dropped; each is expanduser'd
|
|
914
|
+
(literal '~'; the shell already expands $VAR before we see the value)
|
|
915
|
+
then made ABSOLUTE via .absolute() — a relative $CODEX_HOME (e.g.
|
|
916
|
+
`./codexA`) would otherwise glob and store relative source_paths, which
|
|
917
|
+
the cache-prune step cannot distinguish from synthetic relative-path
|
|
918
|
+
fixture rows (issue #108; see the prune guard in _cctally_cache.py).
|
|
919
|
+
Canonicalizing here makes every REAL ingested source_path absolute, so
|
|
920
|
+
the prune's `isabs` fixture carve-out is correct by construction.
|
|
921
|
+
Order is preserved (first-match-wins downstream).
|
|
922
|
+
"""
|
|
923
|
+
raw = os.environ.get("CODEX_HOME", "").strip()
|
|
924
|
+
if not raw:
|
|
925
|
+
return [pathlib.Path.home() / ".codex"]
|
|
926
|
+
roots: list[pathlib.Path] = []
|
|
927
|
+
saw_part = False
|
|
928
|
+
for part in raw.split(","):
|
|
929
|
+
part = part.strip()
|
|
930
|
+
if not part:
|
|
931
|
+
continue
|
|
932
|
+
saw_part = True
|
|
933
|
+
try:
|
|
934
|
+
# expanduser() raises RuntimeError on a malformed `~user` entry
|
|
935
|
+
# (e.g. `~nonexistentuser/x` — the user doesn't exist). Drop the
|
|
936
|
+
# bad entry rather than aborting the whole command, so valid
|
|
937
|
+
# roots beside it survive. .absolute() canonicalizes a relative
|
|
938
|
+
# entry (e.g. `./codexA`) against cwd so real ingested source_paths
|
|
939
|
+
# are always absolute (issue #108 — keeps the cache-prune correct).
|
|
940
|
+
roots.append(pathlib.Path(part).expanduser().absolute())
|
|
941
|
+
except (RuntimeError, OSError, ValueError):
|
|
942
|
+
continue
|
|
943
|
+
# Fall back to the default home ONLY when the variable is unset or carries
|
|
944
|
+
# no non-blank entry at all. An explicit-but-all-invalid value (e.g. a lone
|
|
945
|
+
# `~baduser` that expanduser() drops) yields [] — respect the override and
|
|
946
|
+
# read nothing rather than silently reading the default account (issue
|
|
947
|
+
# #108). A plain dead path like `/typo` already reaches [] via the is_dir()
|
|
948
|
+
# filter in _codex_session_roots(); this aligns the `~user` flavor with it.
|
|
949
|
+
if not saw_part:
|
|
950
|
+
return [pathlib.Path.home() / ".codex"]
|
|
951
|
+
return roots
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _codex_session_roots() -> list[pathlib.Path]:
|
|
955
|
+
"""Directories to walk for *.jsonl, applying the sessions/-subdir rule.
|
|
956
|
+
|
|
957
|
+
For each home root r (in $CODEX_HOME order):
|
|
958
|
+
(r / "sessions").is_dir() -> r / "sessions" (Codex home)
|
|
959
|
+
elif r.is_dir() -> r (direct JSONL dir)
|
|
960
|
+
else -> skipped
|
|
961
|
+
Exact-duplicate roots are de-duped (first occurrence kept). File-level
|
|
962
|
+
de-dup for overlapping/prefix roots happens in the discovery walkers
|
|
963
|
+
(set of absolute paths); this function only collapses identical roots.
|
|
964
|
+
"""
|
|
965
|
+
out: list[pathlib.Path] = []
|
|
966
|
+
seen: set[pathlib.Path] = set()
|
|
967
|
+
for r in _codex_home_roots():
|
|
968
|
+
sess = r / "sessions"
|
|
969
|
+
if sess.is_dir():
|
|
970
|
+
cand = sess
|
|
971
|
+
elif r.is_dir():
|
|
972
|
+
cand = r
|
|
973
|
+
else:
|
|
974
|
+
continue
|
|
975
|
+
if cand in seen:
|
|
976
|
+
continue
|
|
977
|
+
seen.add(cand)
|
|
978
|
+
out.append(cand)
|
|
979
|
+
return out
|
|
980
|
+
|
|
981
|
+
|
|
878
982
|
# Note: `_cctally_db` reads its four path constants
|
|
879
983
|
# (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
|
|
880
984
|
# `_cctally_core.X` at call time — the canonical sibling pattern after
|
|
@@ -4223,6 +4327,15 @@ def _load_recorded_five_hour_windows(
|
|
|
4223
4327
|
|
|
4224
4328
|
def cmd_blocks(args: argparse.Namespace) -> int:
|
|
4225
4329
|
"""Show usage report grouped by 5-hour session blocks."""
|
|
4330
|
+
# -n/--session-length guard (#86 Session F). The flag is a documented
|
|
4331
|
+
# no-op (cctally blocks anchor to Anthropic's real 5h resets and are not
|
|
4332
|
+
# re-sizable), but a non-positive value still errors for drop-in fidelity
|
|
4333
|
+
# with ccusage's "Session length must be a positive number". Runs first,
|
|
4334
|
+
# before any data load — matches ccusage's command-flow ordering.
|
|
4335
|
+
if getattr(args, "session_length", 5.0) <= 0:
|
|
4336
|
+
eprint("blocks: session length must be a positive number")
|
|
4337
|
+
return 1
|
|
4338
|
+
|
|
4226
4339
|
config = _load_claude_config_for_args(args)
|
|
4227
4340
|
_bridge_z_into_tz(args, config)
|
|
4228
4341
|
tz = resolve_display_tz(args, config)
|
|
@@ -4322,20 +4435,567 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
4322
4435
|
# 20:50→01:50 with $45 cost vs the real $128).
|
|
4323
4436
|
_maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc, mode=args.mode)
|
|
4324
4437
|
|
|
4438
|
+
# ── Session F (#86): resolve token limit, then filter ────────────────
|
|
4439
|
+
# Auto-max baseline over ALL blocks (before --recent/--active filtering),
|
|
4440
|
+
# matching ccusage's maxTokensFromAll.
|
|
4441
|
+
max_completed = _max_completed_block_tokens(blocks)
|
|
4442
|
+
token_limit = _parse_blocks_token_limit(
|
|
4443
|
+
getattr(args, "token_limit", None), max_completed
|
|
4444
|
+
)
|
|
4445
|
+
# ``token_limit_explicit`` is the resolved limit ONLY when -t was passed
|
|
4446
|
+
# (any value incl. "max"); the implicit default leaves it None so the
|
|
4447
|
+
# box's Token Limit Status sub-block + the JSON tokenLimitStatus key are
|
|
4448
|
+
# omitted (ccusage `if (tokenLimit != null)` gate).
|
|
4449
|
+
token_limit_explicit = (
|
|
4450
|
+
token_limit if getattr(args, "token_limit", None) is not None else None
|
|
4451
|
+
)
|
|
4452
|
+
auto_max = getattr(args, "token_limit", None) in (None, "", "max")
|
|
4453
|
+
if auto_max and token_limit and not args.json:
|
|
4454
|
+
# ccusage parity: logger.info → stdout (Codex F1). Suppressed under
|
|
4455
|
+
# --json (ccusage sets logger.level=0), so --json goldens stay stable.
|
|
4456
|
+
print(f"Using max tokens from previous sessions: {_fmt_num(token_limit)}")
|
|
4457
|
+
|
|
4458
|
+
if getattr(args, "recent", False):
|
|
4459
|
+
cutoff = now_utc - dt.timedelta(days=3)
|
|
4460
|
+
blocks = [b for b in blocks if b.start_time >= cutoff or b.is_active]
|
|
4461
|
+
|
|
4462
|
+
if getattr(args, "active", False):
|
|
4463
|
+
blocks = [b for b in blocks if b.is_active and not b.is_gap]
|
|
4464
|
+
if not blocks:
|
|
4465
|
+
if args.json:
|
|
4466
|
+
print('{\n "blocks": [],\n "message": "No active block"\n}')
|
|
4467
|
+
else:
|
|
4468
|
+
print("No active session block found.")
|
|
4469
|
+
return 0
|
|
4470
|
+
|
|
4325
4471
|
if args.json:
|
|
4326
|
-
print(_blocks_to_json(blocks))
|
|
4472
|
+
print(_blocks_to_json(blocks, token_limit_status_limit=token_limit_explicit))
|
|
4473
|
+
return 0
|
|
4474
|
+
|
|
4475
|
+
if getattr(args, "active", False) and len(blocks) == 1:
|
|
4476
|
+
print(_render_active_block_box(
|
|
4477
|
+
blocks[0], now=now_utc, tz=tz,
|
|
4478
|
+
token_limit_explicit=token_limit_explicit,
|
|
4479
|
+
color=_supports_color_stdout(), unicode_ok=_supports_unicode_stdout(),
|
|
4480
|
+
))
|
|
4327
4481
|
return 0
|
|
4328
4482
|
|
|
4329
4483
|
# Table output. Session A (spec §7.6.1; Review-A P2-B): thread
|
|
4330
4484
|
# --compact through so the renderer's scale-down branch fires
|
|
4331
|
-
# regardless of terminal width when the flag is set.
|
|
4485
|
+
# regardless of terminal width when the flag is set. Session F: thread
|
|
4486
|
+
# the resolved token_limit so an explicit -t keys the %/REMAINING/
|
|
4487
|
+
# PROJECTED surface (the default path passes the same auto-max the
|
|
4488
|
+
# renderer computed internally, so it stays byte-identical).
|
|
4332
4489
|
print(_render_blocks_table(
|
|
4333
4490
|
blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
|
|
4334
|
-
compact=getattr(args, "compact", False),
|
|
4491
|
+
compact=getattr(args, "compact", False), token_limit=token_limit,
|
|
4335
4492
|
))
|
|
4336
4493
|
return 0
|
|
4337
4494
|
|
|
4338
4495
|
|
|
4496
|
+
def cmd_statusline(args: argparse.Namespace) -> int:
|
|
4497
|
+
"""`cctally statusline` — one-line status summary for CC hooks.
|
|
4498
|
+
|
|
4499
|
+
See docs/superpowers/specs/2026-05-28-issue-86-session-g-statusline-design.md
|
|
4500
|
+
for the full design.
|
|
4501
|
+
|
|
4502
|
+
Exit codes:
|
|
4503
|
+
0 success (every absent stdin field degrades gracefully)
|
|
4504
|
+
1 stdin is not parseable JSON OR root is not a JSON object
|
|
4505
|
+
2 argparse rejected a flag (e.g. --cost-source ccusage), OR
|
|
4506
|
+
--config PATH unreadable
|
|
4507
|
+
"""
|
|
4508
|
+
# NOTE: `--cost-source ccusage` is rejected at argparse-time by
|
|
4509
|
+
# `_CostSourceAction` in `_build_statusline_parser`; it exits 2 with
|
|
4510
|
+
# the rename hint before we get here, so no explicit re-check is
|
|
4511
|
+
# needed in this function.
|
|
4512
|
+
|
|
4513
|
+
# Validate `--context-{low,medium}-threshold` BEFORE reading stdin
|
|
4514
|
+
# so a misconfigured invocation fails fast without consuming the
|
|
4515
|
+
# CC hook's stdin payload.
|
|
4516
|
+
low = args.context_low_threshold
|
|
4517
|
+
med = args.context_medium_threshold
|
|
4518
|
+
if not isinstance(low, int) or low < 0 or low > 100:
|
|
4519
|
+
eprint(
|
|
4520
|
+
"cctally statusline: --context-low-threshold must be in [0, 100]"
|
|
4521
|
+
)
|
|
4522
|
+
return 2
|
|
4523
|
+
if not isinstance(med, int) or med < 0 or med > 100:
|
|
4524
|
+
eprint(
|
|
4525
|
+
"cctally statusline: --context-medium-threshold must be in [0, 100]"
|
|
4526
|
+
)
|
|
4527
|
+
return 2
|
|
4528
|
+
if low >= med:
|
|
4529
|
+
eprint(
|
|
4530
|
+
"cctally statusline: --context-low-threshold must be < "
|
|
4531
|
+
"--context-medium-threshold"
|
|
4532
|
+
)
|
|
4533
|
+
return 2
|
|
4534
|
+
|
|
4535
|
+
# Silently clamp `--refresh-interval` to [0, 600]. The flag is a
|
|
4536
|
+
# no-op alias for ccusage drop-in compat; users never observe the
|
|
4537
|
+
# effect, but the spec mandates the clamp for forward-compat (when
|
|
4538
|
+
# we promote it to a real flag, the clamped value should be the one
|
|
4539
|
+
# propagated downstream).
|
|
4540
|
+
try:
|
|
4541
|
+
args.refresh_interval = max(0, min(600, int(args.refresh_interval)))
|
|
4542
|
+
except (TypeError, ValueError):
|
|
4543
|
+
args.refresh_interval = 1
|
|
4544
|
+
|
|
4545
|
+
# Read stdin once.
|
|
4546
|
+
raw = sys.stdin.buffer.read()
|
|
4547
|
+
parse_result = _lib_statusline.parse_statusline_stdin(raw)
|
|
4548
|
+
if isinstance(parse_result, _lib_statusline.ParseError):
|
|
4549
|
+
eprint(f"cctally statusline: {parse_result.message}")
|
|
4550
|
+
return 1
|
|
4551
|
+
inp = parse_result
|
|
4552
|
+
|
|
4553
|
+
# Resolve effective config: CLI > config.json > built-in default.
|
|
4554
|
+
# `_load_claude_config_for_args` honors `--config PATH` (issue #88
|
|
4555
|
+
# plumbing); a missing/invalid PATH raises SystemExit(2) inside
|
|
4556
|
+
# `_load_config_from_explicit_path` so this call already enforces
|
|
4557
|
+
# exit-2 on a bad --config.
|
|
4558
|
+
cfg = _load_claude_config_for_args(args)
|
|
4559
|
+
sl_cfg = (cfg.get("statusline") or {}) if isinstance(cfg, dict) else {}
|
|
4560
|
+
if not isinstance(sl_cfg, dict):
|
|
4561
|
+
sl_cfg = {}
|
|
4562
|
+
|
|
4563
|
+
# Validate config values; on invalid, one-shot stderr warn + use default.
|
|
4564
|
+
_warned: set = set()
|
|
4565
|
+
|
|
4566
|
+
def warn_once(msg: str) -> None:
|
|
4567
|
+
if msg in _warned:
|
|
4568
|
+
return
|
|
4569
|
+
_warned.add(msg)
|
|
4570
|
+
eprint(msg)
|
|
4571
|
+
|
|
4572
|
+
def _resolve(cli_val, cfg_key, default):
|
|
4573
|
+
if cli_val is not None:
|
|
4574
|
+
return cli_val
|
|
4575
|
+
cv = sl_cfg.get(cfg_key)
|
|
4576
|
+
if cv is None:
|
|
4577
|
+
return default
|
|
4578
|
+
return cv
|
|
4579
|
+
|
|
4580
|
+
vbr = _resolve(args.visual_burn_rate, "visual_burn_rate", "off")
|
|
4581
|
+
if vbr not in ("off", "emoji", "text", "emoji-text"):
|
|
4582
|
+
warn_once(
|
|
4583
|
+
f"cctally statusline: invalid statusline.visual_burn_rate={vbr!r}; "
|
|
4584
|
+
f"using 'off'"
|
|
4585
|
+
)
|
|
4586
|
+
vbr = "off"
|
|
4587
|
+
|
|
4588
|
+
cs = _resolve(args.cost_source, "cost_source", "auto")
|
|
4589
|
+
if cs not in ("auto", "cctally", "cc", "both"):
|
|
4590
|
+
warn_once(
|
|
4591
|
+
f"cctally statusline: invalid statusline.cost_source={cs!r}; "
|
|
4592
|
+
f"using 'auto'"
|
|
4593
|
+
)
|
|
4594
|
+
cs = "auto"
|
|
4595
|
+
|
|
4596
|
+
ext_on = _resolve(args.cctally_extensions, "cctally_extensions", True)
|
|
4597
|
+
if not isinstance(ext_on, bool):
|
|
4598
|
+
warn_once(
|
|
4599
|
+
f"cctally statusline: invalid statusline.cctally_extensions="
|
|
4600
|
+
f"{ext_on!r}; using True"
|
|
4601
|
+
)
|
|
4602
|
+
ext_on = True
|
|
4603
|
+
|
|
4604
|
+
# Display tz: CLI -z/--timezone > display.tz config > "UTC".
|
|
4605
|
+
tz_name = getattr(args, "timezone", None)
|
|
4606
|
+
if not tz_name:
|
|
4607
|
+
display_block = cfg.get("display") if isinstance(cfg, dict) else None
|
|
4608
|
+
if isinstance(display_block, dict):
|
|
4609
|
+
tz_name = display_block.get("tz")
|
|
4610
|
+
if not tz_name:
|
|
4611
|
+
tz_name = "UTC"
|
|
4612
|
+
if tz_name in ("local", "LOCAL"):
|
|
4613
|
+
try:
|
|
4614
|
+
tz_name = _local_tz_name() or "UTC"
|
|
4615
|
+
except Exception:
|
|
4616
|
+
tz_name = "UTC"
|
|
4617
|
+
# Validate the IANA name; fall back to UTC with a one-shot warning.
|
|
4618
|
+
try:
|
|
4619
|
+
ZoneInfo(tz_name)
|
|
4620
|
+
except (ZoneInfoNotFoundError, Exception):
|
|
4621
|
+
warn_once(
|
|
4622
|
+
f"cctally statusline: invalid timezone {tz_name!r}; using 'UTC'"
|
|
4623
|
+
)
|
|
4624
|
+
tz_name = "UTC"
|
|
4625
|
+
|
|
4626
|
+
# Color: explicit CLI > NO_COLOR env > TTY detect.
|
|
4627
|
+
if args.color is True or args.color is False:
|
|
4628
|
+
color = args.color
|
|
4629
|
+
else:
|
|
4630
|
+
color = (os.environ.get("NO_COLOR", "") == "") and sys.stdout.isatty()
|
|
4631
|
+
|
|
4632
|
+
sargs = _lib_statusline.StatuslineArgs(
|
|
4633
|
+
visual_burn_rate=vbr,
|
|
4634
|
+
cost_source=cs,
|
|
4635
|
+
context_low_threshold=int(args.context_low_threshold),
|
|
4636
|
+
context_medium_threshold=int(args.context_medium_threshold),
|
|
4637
|
+
cctally_extensions=bool(ext_on),
|
|
4638
|
+
color=bool(color),
|
|
4639
|
+
display_tz_name=tz_name,
|
|
4640
|
+
debug=bool(args.debug),
|
|
4641
|
+
)
|
|
4642
|
+
|
|
4643
|
+
# Build injections (DB + transcript file IO).
|
|
4644
|
+
inj = _build_statusline_injections(warn_once)
|
|
4645
|
+
|
|
4646
|
+
# `_command_as_of()` honors the `CCTALLY_AS_OF` testing hook so the
|
|
4647
|
+
# golden harness can pin "now" for deterministic block-remaining and
|
|
4648
|
+
# 5h/7d countdown numbers. Falls back to wall-clock UTC otherwise.
|
|
4649
|
+
now = _command_as_of()
|
|
4650
|
+
try:
|
|
4651
|
+
line = _lib_statusline.render_statusline(inp, sargs, inj, now)
|
|
4652
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
4653
|
+
eprint(f"cctally statusline: render failed: {exc}")
|
|
4654
|
+
return 1
|
|
4655
|
+
print(line)
|
|
4656
|
+
return 0
|
|
4657
|
+
|
|
4658
|
+
|
|
4659
|
+
def _resolve_context_window(model_id, warn_once) -> "int | None":
|
|
4660
|
+
"""Look up ``model_id`` in ``CLAUDE_MODEL_CONTEXT_WINDOWS``; fall back
|
|
4661
|
+
to a family-substring match against
|
|
4662
|
+
``CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY``. Unknown id → ``None`` +
|
|
4663
|
+
one-shot stderr warning.
|
|
4664
|
+
"""
|
|
4665
|
+
if not model_id:
|
|
4666
|
+
return None
|
|
4667
|
+
if model_id in CLAUDE_MODEL_CONTEXT_WINDOWS:
|
|
4668
|
+
return CLAUDE_MODEL_CONTEXT_WINDOWS[model_id]
|
|
4669
|
+
mid_lower = model_id.lower()
|
|
4670
|
+
for family, window in CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY.items():
|
|
4671
|
+
if family in mid_lower:
|
|
4672
|
+
return window
|
|
4673
|
+
warn_once(
|
|
4674
|
+
f"cctally statusline: unknown model {model_id!r}; context % unavailable"
|
|
4675
|
+
)
|
|
4676
|
+
return None
|
|
4677
|
+
|
|
4678
|
+
|
|
4679
|
+
def _read_last_assistant_usage(transcript_path):
|
|
4680
|
+
"""Tail-walk the transcript JSONL backwards to the most recent
|
|
4681
|
+
``type=assistant`` line carrying ``message.usage``. Returns the usage
|
|
4682
|
+
dict or ``None``.
|
|
4683
|
+
|
|
4684
|
+
Reads in 64 KB chunks from the end so multi-MB transcripts don't
|
|
4685
|
+
block the hot statusline path with a full-file parse.
|
|
4686
|
+
"""
|
|
4687
|
+
if not transcript_path:
|
|
4688
|
+
return None
|
|
4689
|
+
path = pathlib.Path(transcript_path)
|
|
4690
|
+
if not path.exists():
|
|
4691
|
+
return None
|
|
4692
|
+
try:
|
|
4693
|
+
with path.open("rb") as fh:
|
|
4694
|
+
fh.seek(0, 2)
|
|
4695
|
+
size = fh.tell()
|
|
4696
|
+
tail = b""
|
|
4697
|
+
chunk = 65536
|
|
4698
|
+
# Read backwards in chunks until we have at least one full line
|
|
4699
|
+
# and the tail starts with a newline (so the first line is whole).
|
|
4700
|
+
while size > 0 and tail.count(b"\n") < 2:
|
|
4701
|
+
read_at = max(0, size - chunk)
|
|
4702
|
+
fh.seek(read_at)
|
|
4703
|
+
tail = fh.read(size - read_at) + tail
|
|
4704
|
+
size = read_at
|
|
4705
|
+
lines = tail.split(b"\n")
|
|
4706
|
+
except OSError:
|
|
4707
|
+
return None
|
|
4708
|
+
for line in reversed(lines):
|
|
4709
|
+
line = line.strip()
|
|
4710
|
+
if not line:
|
|
4711
|
+
continue
|
|
4712
|
+
try:
|
|
4713
|
+
obj = json.loads(line)
|
|
4714
|
+
except Exception:
|
|
4715
|
+
continue
|
|
4716
|
+
if not isinstance(obj, dict):
|
|
4717
|
+
continue
|
|
4718
|
+
if obj.get("type") != "assistant":
|
|
4719
|
+
continue
|
|
4720
|
+
msg = obj.get("message") or {}
|
|
4721
|
+
usage = msg.get("usage") if isinstance(msg, dict) else None
|
|
4722
|
+
if isinstance(usage, dict):
|
|
4723
|
+
return usage
|
|
4724
|
+
return None
|
|
4725
|
+
|
|
4726
|
+
|
|
4727
|
+
def _build_statusline_injections(warn_once):
|
|
4728
|
+
"""Wire DB- and FS-backed implementations for the kernel's injection ports.
|
|
4729
|
+
|
|
4730
|
+
See ``_lib_statusline.StatuslineInjections`` for the contract. All
|
|
4731
|
+
callables fast-fail to "no data" on any exception — statusline must
|
|
4732
|
+
NEVER block the Claude Code hook tick.
|
|
4733
|
+
"""
|
|
4734
|
+
def _cctally_session_cost(sid):
|
|
4735
|
+
if not sid:
|
|
4736
|
+
return None
|
|
4737
|
+
try:
|
|
4738
|
+
conn = open_cache_db()
|
|
4739
|
+
except Exception:
|
|
4740
|
+
return None
|
|
4741
|
+
try:
|
|
4742
|
+
# Walk all entries via session_files join; sum costs whose
|
|
4743
|
+
# session_id matches. Stays read-only — does NOT call
|
|
4744
|
+
# sync_cache (too heavy for the hot statusline path; the
|
|
4745
|
+
# record-usage + hook-tick paths keep the cache warm).
|
|
4746
|
+
sql = (
|
|
4747
|
+
"SELECT se.timestamp_utc, se.model, "
|
|
4748
|
+
" se.input_tokens, se.output_tokens, "
|
|
4749
|
+
" se.cache_create_tokens, se.cache_read_tokens, "
|
|
4750
|
+
" se.cost_usd_raw, se.usage_extra_json "
|
|
4751
|
+
"FROM session_entries se "
|
|
4752
|
+
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
4753
|
+
"WHERE sf.session_id = ?"
|
|
4754
|
+
)
|
|
4755
|
+
rows = list(conn.execute(sql, (sid,)))
|
|
4756
|
+
except Exception:
|
|
4757
|
+
return None
|
|
4758
|
+
finally:
|
|
4759
|
+
try:
|
|
4760
|
+
conn.close()
|
|
4761
|
+
except Exception:
|
|
4762
|
+
pass
|
|
4763
|
+
if not rows:
|
|
4764
|
+
return None
|
|
4765
|
+
total = 0.0
|
|
4766
|
+
for r in rows:
|
|
4767
|
+
usage = {
|
|
4768
|
+
"input_tokens": r[2] or 0,
|
|
4769
|
+
"output_tokens": r[3] or 0,
|
|
4770
|
+
"cache_creation_input_tokens": r[4] or 0,
|
|
4771
|
+
"cache_read_input_tokens": r[5] or 0,
|
|
4772
|
+
}
|
|
4773
|
+
try:
|
|
4774
|
+
if r[7]:
|
|
4775
|
+
extras = json.loads(r[7])
|
|
4776
|
+
if isinstance(extras, dict):
|
|
4777
|
+
usage.update(extras)
|
|
4778
|
+
except Exception:
|
|
4779
|
+
pass
|
|
4780
|
+
try:
|
|
4781
|
+
total += _calculate_entry_cost(
|
|
4782
|
+
r[1], usage, mode="auto", cost_usd=r[6],
|
|
4783
|
+
)
|
|
4784
|
+
except Exception:
|
|
4785
|
+
continue
|
|
4786
|
+
return total
|
|
4787
|
+
|
|
4788
|
+
def _today_cost(tz_name, now):
|
|
4789
|
+
try:
|
|
4790
|
+
tz = ZoneInfo(tz_name) if tz_name and tz_name != "UTC" else dt.timezone.utc
|
|
4791
|
+
except Exception:
|
|
4792
|
+
tz = dt.timezone.utc
|
|
4793
|
+
local_now = now.astimezone(tz)
|
|
4794
|
+
day_start_local = local_now.replace(
|
|
4795
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
4796
|
+
)
|
|
4797
|
+
day_end_local = day_start_local + dt.timedelta(days=1)
|
|
4798
|
+
range_start = day_start_local.astimezone(dt.timezone.utc)
|
|
4799
|
+
range_end = day_end_local.astimezone(dt.timezone.utc)
|
|
4800
|
+
# Two-filter pattern (UTC half-open + display-tz date check):
|
|
4801
|
+
# the UTC range fetches the candidate window cheaply via the
|
|
4802
|
+
# cache's indexed `timestamp` column; the display-tz date check
|
|
4803
|
+
# then trims any entries that fall outside today's local
|
|
4804
|
+
# calendar day (the UTC window straddles two local dates when
|
|
4805
|
+
# the display tz has any UTC offset, so the SQL range alone is
|
|
4806
|
+
# slightly wider than the local day).
|
|
4807
|
+
try:
|
|
4808
|
+
entries = get_entries(range_start, range_end, skip_sync=True)
|
|
4809
|
+
except Exception:
|
|
4810
|
+
return 0.0
|
|
4811
|
+
total = 0.0
|
|
4812
|
+
for e in entries:
|
|
4813
|
+
try:
|
|
4814
|
+
# Filter to today in display tz (second-pass trim).
|
|
4815
|
+
ts_local = e.timestamp.astimezone(tz)
|
|
4816
|
+
if ts_local.date() != local_now.date():
|
|
4817
|
+
continue
|
|
4818
|
+
total += _calculate_entry_cost(
|
|
4819
|
+
e.model, e.usage, mode="auto", cost_usd=e.cost_usd,
|
|
4820
|
+
)
|
|
4821
|
+
except Exception:
|
|
4822
|
+
continue
|
|
4823
|
+
return total
|
|
4824
|
+
|
|
4825
|
+
def _active_block(now):
|
|
4826
|
+
try:
|
|
4827
|
+
# Look at last 24h — captures the full active 5h window.
|
|
4828
|
+
range_start = now - dt.timedelta(hours=24)
|
|
4829
|
+
entries = get_entries(range_start, now, skip_sync=True)
|
|
4830
|
+
except Exception:
|
|
4831
|
+
return None
|
|
4832
|
+
if not entries:
|
|
4833
|
+
return None
|
|
4834
|
+
try:
|
|
4835
|
+
recorded_windows, block_start_overrides, canonical_intervals = (
|
|
4836
|
+
_load_recorded_five_hour_windows(
|
|
4837
|
+
range_start - BLOCK_DURATION, now + BLOCK_DURATION,
|
|
4838
|
+
)
|
|
4839
|
+
)
|
|
4840
|
+
except Exception:
|
|
4841
|
+
recorded_windows, block_start_overrides, canonical_intervals = (
|
|
4842
|
+
[], {}, {},
|
|
4843
|
+
)
|
|
4844
|
+
try:
|
|
4845
|
+
blocks = _group_entries_into_blocks(
|
|
4846
|
+
entries,
|
|
4847
|
+
mode="auto",
|
|
4848
|
+
recorded_windows=recorded_windows,
|
|
4849
|
+
block_start_overrides=block_start_overrides,
|
|
4850
|
+
canonical_intervals=canonical_intervals,
|
|
4851
|
+
now=now,
|
|
4852
|
+
)
|
|
4853
|
+
except Exception:
|
|
4854
|
+
return None
|
|
4855
|
+
for b in blocks:
|
|
4856
|
+
if not b.is_gap and b.is_active:
|
|
4857
|
+
remaining_s = int((b.end_time - now).total_seconds())
|
|
4858
|
+
elapsed_s = int((now - b.start_time).total_seconds())
|
|
4859
|
+
return (float(b.cost_usd or 0.0), remaining_s, elapsed_s)
|
|
4860
|
+
return None
|
|
4861
|
+
|
|
4862
|
+
def _hwm_clamp(five_resets, seven_resets):
|
|
4863
|
+
five_hwm = None
|
|
4864
|
+
seven_hwm = None
|
|
4865
|
+
try:
|
|
4866
|
+
conn = open_db()
|
|
4867
|
+
except Exception:
|
|
4868
|
+
return (None, None)
|
|
4869
|
+
try:
|
|
4870
|
+
if five_resets is not None:
|
|
4871
|
+
try:
|
|
4872
|
+
key = _canonical_5h_window_key(int(five_resets))
|
|
4873
|
+
row = conn.execute(
|
|
4874
|
+
"SELECT MAX(five_hour_percent) "
|
|
4875
|
+
"FROM weekly_usage_snapshots "
|
|
4876
|
+
"WHERE five_hour_window_key = ?",
|
|
4877
|
+
(key,),
|
|
4878
|
+
).fetchone()
|
|
4879
|
+
if row and row[0] is not None:
|
|
4880
|
+
five_hwm = float(row[0])
|
|
4881
|
+
except Exception:
|
|
4882
|
+
pass
|
|
4883
|
+
if seven_resets is not None:
|
|
4884
|
+
try:
|
|
4885
|
+
# Seven-day window: derive week_start_date from the
|
|
4886
|
+
# resets_at epoch (reset - 7 days, UTC date).
|
|
4887
|
+
week_start_date = dt.datetime.fromtimestamp(
|
|
4888
|
+
int(seven_resets) - 7 * 86400, tz=dt.timezone.utc,
|
|
4889
|
+
).date().isoformat()
|
|
4890
|
+
row = conn.execute(
|
|
4891
|
+
"SELECT MAX(weekly_percent) "
|
|
4892
|
+
"FROM weekly_usage_snapshots "
|
|
4893
|
+
"WHERE week_start_date = ?",
|
|
4894
|
+
(week_start_date,),
|
|
4895
|
+
).fetchone()
|
|
4896
|
+
if row and row[0] is not None:
|
|
4897
|
+
seven_hwm = float(row[0])
|
|
4898
|
+
except Exception:
|
|
4899
|
+
pass
|
|
4900
|
+
finally:
|
|
4901
|
+
try:
|
|
4902
|
+
conn.close()
|
|
4903
|
+
except Exception:
|
|
4904
|
+
pass
|
|
4905
|
+
return (five_hwm, seven_hwm)
|
|
4906
|
+
|
|
4907
|
+
def _db_latest_rate_limits():
|
|
4908
|
+
try:
|
|
4909
|
+
conn = open_db()
|
|
4910
|
+
except Exception:
|
|
4911
|
+
return None
|
|
4912
|
+
try:
|
|
4913
|
+
# Prefer `week_end_at` (ISO timestamp; sub-day precision) over
|
|
4914
|
+
# `week_end_date` (date-only; UTC-midnight). Older snapshots
|
|
4915
|
+
# may have `week_end_at` NULL — fall back to the date column
|
|
4916
|
+
# in that case. See the neighbor query in `pick_week_selection`
|
|
4917
|
+
# (bin/cctally:3849) for the precedent.
|
|
4918
|
+
row = conn.execute(
|
|
4919
|
+
"SELECT five_hour_percent, five_hour_window_key, "
|
|
4920
|
+
" weekly_percent, week_end_at, week_end_date "
|
|
4921
|
+
"FROM weekly_usage_snapshots "
|
|
4922
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
4923
|
+
).fetchone()
|
|
4924
|
+
if not row:
|
|
4925
|
+
return None
|
|
4926
|
+
five_pct = float(row[0]) if row[0] is not None else None
|
|
4927
|
+
five_resets = int(row[1]) if row[1] is not None else None
|
|
4928
|
+
seven_pct = float(row[2]) if row[2] is not None else None
|
|
4929
|
+
seven_resets = None
|
|
4930
|
+
week_end_at = row[3]
|
|
4931
|
+
week_end_date = row[4]
|
|
4932
|
+
if week_end_at:
|
|
4933
|
+
try:
|
|
4934
|
+
# `datetime.fromisoformat` accepts the trailing `Z`
|
|
4935
|
+
# only on Python 3.11+; normalize to `+00:00` so 3.10
|
|
4936
|
+
# checkouts (and any odd Z-suffixed snapshot) parse.
|
|
4937
|
+
raw_iso = str(week_end_at)
|
|
4938
|
+
if raw_iso.endswith("Z"):
|
|
4939
|
+
raw_iso = raw_iso[:-1] + "+00:00"
|
|
4940
|
+
end_dt = dt.datetime.fromisoformat(raw_iso)
|
|
4941
|
+
if end_dt.tzinfo is None:
|
|
4942
|
+
end_dt = end_dt.replace(tzinfo=dt.timezone.utc)
|
|
4943
|
+
seven_resets = int(end_dt.timestamp())
|
|
4944
|
+
except Exception:
|
|
4945
|
+
seven_resets = None
|
|
4946
|
+
if seven_resets is None and week_end_date:
|
|
4947
|
+
try:
|
|
4948
|
+
end_dt = dt.datetime.fromisoformat(str(week_end_date))
|
|
4949
|
+
if end_dt.tzinfo is None:
|
|
4950
|
+
end_dt = end_dt.replace(tzinfo=dt.timezone.utc)
|
|
4951
|
+
# week_end_date is exclusive — that's the reset moment.
|
|
4952
|
+
seven_resets = int(end_dt.timestamp())
|
|
4953
|
+
except Exception:
|
|
4954
|
+
seven_resets = None
|
|
4955
|
+
return (five_pct, five_resets, seven_pct, seven_resets)
|
|
4956
|
+
except Exception:
|
|
4957
|
+
return None
|
|
4958
|
+
finally:
|
|
4959
|
+
try:
|
|
4960
|
+
conn.close()
|
|
4961
|
+
except Exception:
|
|
4962
|
+
pass
|
|
4963
|
+
|
|
4964
|
+
def _context_pct(transcript_path, model_id):
|
|
4965
|
+
if not transcript_path or not model_id:
|
|
4966
|
+
return None
|
|
4967
|
+
window = _resolve_context_window(model_id, warn_once)
|
|
4968
|
+
if window is None:
|
|
4969
|
+
return None
|
|
4970
|
+
try:
|
|
4971
|
+
usage = _read_last_assistant_usage(transcript_path)
|
|
4972
|
+
except Exception:
|
|
4973
|
+
return None
|
|
4974
|
+
if not isinstance(usage, dict):
|
|
4975
|
+
return None
|
|
4976
|
+
try:
|
|
4977
|
+
ctx_tokens = (
|
|
4978
|
+
int(usage.get("input_tokens", 0) or 0)
|
|
4979
|
+
+ int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
4980
|
+
+ int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
4981
|
+
)
|
|
4982
|
+
except (TypeError, ValueError):
|
|
4983
|
+
return None
|
|
4984
|
+
if window <= 0:
|
|
4985
|
+
return None
|
|
4986
|
+
return ctx_tokens / window * 100.0
|
|
4987
|
+
|
|
4988
|
+
return _lib_statusline.StatuslineInjections(
|
|
4989
|
+
cctally_session_cost=_cctally_session_cost,
|
|
4990
|
+
today_cost=_today_cost,
|
|
4991
|
+
active_block=_active_block,
|
|
4992
|
+
hwm_clamp=_hwm_clamp,
|
|
4993
|
+
db_latest_rate_limits=_db_latest_rate_limits,
|
|
4994
|
+
context_pct=_context_pct,
|
|
4995
|
+
warn_once=warn_once,
|
|
4996
|
+
)
|
|
4997
|
+
|
|
4998
|
+
|
|
4339
4999
|
def _maybe_swap_active_block_to_canonical(
|
|
4340
5000
|
blocks: list[Any],
|
|
4341
5001
|
all_entries: list[Any],
|
|
@@ -4971,25 +5631,32 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
4971
5631
|
|
|
4972
5632
|
|
|
4973
5633
|
def _detect_codex_fast_service_tier() -> bool:
|
|
4974
|
-
"""True iff
|
|
4975
|
-
|
|
4976
|
-
Reads
|
|
4977
|
-
|
|
4978
|
-
|
|
5634
|
+
"""True iff any $CODEX_HOME root's config.toml requests fast/priority tier.
|
|
5635
|
+
|
|
5636
|
+
Reads <root>/config.toml for EVERY entry in _codex_home_roots() (comma-
|
|
5637
|
+
separated $CODEX_HOME, else ~/.codex) — including direct-JSONL entries,
|
|
5638
|
+
which usually have no config.toml (read → absent → skipped) but DO count
|
|
5639
|
+
if one is present. Returns on the first root that requests it (any-root
|
|
5640
|
+
semantics, matching upstream ccusage). Tolerates absent/unreadable config
|
|
5641
|
+
(→ that root contributes nothing).
|
|
4979
5642
|
"""
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
5643
|
+
for root in _codex_home_roots():
|
|
5644
|
+
cfg = root / "config.toml"
|
|
5645
|
+
try:
|
|
5646
|
+
content = cfg.read_text(encoding="utf-8", errors="replace")
|
|
5647
|
+
except OSError:
|
|
5648
|
+
continue
|
|
5649
|
+
if _codex_config_requests_fast_service_tier(content):
|
|
5650
|
+
return True
|
|
5651
|
+
return False
|
|
4986
5652
|
|
|
4987
5653
|
|
|
4988
5654
|
def _resolve_codex_speed(requested: str) -> str:
|
|
4989
5655
|
"""Resolve a ``--speed`` value to an effective tier.
|
|
4990
5656
|
|
|
4991
|
-
``auto`` → ``fast`` iff
|
|
4992
|
-
``standard``. ``fast``/``standard`` pass through
|
|
5657
|
+
``auto`` → ``fast`` iff any ``$CODEX_HOME`` root's ``config.toml``
|
|
5658
|
+
requests it, else ``standard``. ``fast``/``standard`` pass through
|
|
5659
|
+
unchanged.
|
|
4993
5660
|
"""
|
|
4994
5661
|
if requested == "auto":
|
|
4995
5662
|
return "fast" if _detect_codex_fast_service_tier() else "standard"
|
|
@@ -9830,11 +10497,16 @@ def doctor_gather_state(
|
|
|
9830
10497
|
except Exception:
|
|
9831
10498
|
pass
|
|
9832
10499
|
|
|
10500
|
+
# Issue #109: probe every $CODEX_HOME session root (not the single
|
|
10501
|
+
# hardcoded ~/.codex/sessions), matching the multi-root ingestion path
|
|
10502
|
+
# from #108. _codex_session_roots() already applies the sessions/-subdir
|
|
10503
|
+
# rule and filters to existing dirs, so a bare glob per root suffices.
|
|
9833
10504
|
codex_jsonl_present = False
|
|
9834
10505
|
try:
|
|
9835
|
-
codex_dir
|
|
9836
|
-
|
|
9837
|
-
|
|
10506
|
+
for codex_dir in _codex_session_roots():
|
|
10507
|
+
if next(codex_dir.glob("**/*.jsonl"), None) is not None:
|
|
10508
|
+
codex_jsonl_present = True
|
|
10509
|
+
break
|
|
9838
10510
|
except Exception:
|
|
9839
10511
|
pass
|
|
9840
10512
|
|
|
@@ -10601,12 +11273,213 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
|
|
|
10601
11273
|
help="Display timezone: local, utc, or IANA name. "
|
|
10602
11274
|
"Overrides config display.tz for this call.",
|
|
10603
11275
|
)
|
|
11276
|
+
p.add_argument(
|
|
11277
|
+
"-a", "--active", action="store_true",
|
|
11278
|
+
help="Show only the active block, with burn-rate + projection "
|
|
11279
|
+
"(ccusage drop-in).",
|
|
11280
|
+
)
|
|
11281
|
+
p.add_argument(
|
|
11282
|
+
"-r", "--recent", action="store_true",
|
|
11283
|
+
help="Show only blocks from the last 3 days (plus the active block).",
|
|
11284
|
+
)
|
|
11285
|
+
p.add_argument(
|
|
11286
|
+
"-t", "--token-limit", dest="token_limit", default=None,
|
|
11287
|
+
metavar="N|max",
|
|
11288
|
+
help="Token limit for the quota %% column / projection warnings. "
|
|
11289
|
+
"An integer, or 'max' (default) to derive from the largest "
|
|
11290
|
+
"completed block.",
|
|
11291
|
+
)
|
|
11292
|
+
p.add_argument(
|
|
11293
|
+
"-n", "--session-length", dest="session_length", type=float,
|
|
11294
|
+
default=5.0, metavar="N",
|
|
11295
|
+
help="Accepted for ccusage drop-in compat; no-op — cctally blocks "
|
|
11296
|
+
"follow Anthropic's real 5-hour resets and are not re-sizable. "
|
|
11297
|
+
"A value <= 0 is rejected.",
|
|
11298
|
+
)
|
|
10604
11299
|
_add_ccusage_alias_args(p, ansi_emit=False)
|
|
10605
11300
|
_add_mode_arg(p)
|
|
10606
11301
|
p.set_defaults(func=cmd_blocks)
|
|
10607
11302
|
return p
|
|
10608
11303
|
|
|
10609
11304
|
|
|
11305
|
+
def _build_statusline_parser(subparsers, name, *, help_text, xref):
|
|
11306
|
+
"""Build the `statusline` (or `claude statusline`) leaf parser.
|
|
11307
|
+
|
|
11308
|
+
Registered TWICE per the Session B build-once register-twice pattern:
|
|
11309
|
+
once on the flat ``cctally statusline`` subparser, once under the
|
|
11310
|
+
nested ``cctally claude statusline`` subgroup. Output is byte-identical
|
|
11311
|
+
between the two forms; only ``--help`` text differs (the ``xref``
|
|
11312
|
+
paragraph appended to ``description``).
|
|
11313
|
+
"""
|
|
11314
|
+
p = subparsers.add_parser(
|
|
11315
|
+
name,
|
|
11316
|
+
help=help_text,
|
|
11317
|
+
formatter_class=CLIHelpFormatter,
|
|
11318
|
+
description=(
|
|
11319
|
+
"Display a compact one-line status for Claude Code hooks "
|
|
11320
|
+
"(ccusage drop-in + cctally extensions).\n\n" + xref
|
|
11321
|
+
),
|
|
11322
|
+
)
|
|
11323
|
+
# ccusage-shape flags
|
|
11324
|
+
p.add_argument(
|
|
11325
|
+
"-B", "--visual-burn-rate",
|
|
11326
|
+
dest="visual_burn_rate",
|
|
11327
|
+
default=None,
|
|
11328
|
+
choices=["off", "emoji", "text", "emoji-text"],
|
|
11329
|
+
help="Burn-rate visualization (default: off; config key "
|
|
11330
|
+
"statusline.visual_burn_rate).",
|
|
11331
|
+
)
|
|
11332
|
+
# NOTE: `ccusage` is intentionally NOT in `choices=` so it doesn't
|
|
11333
|
+
# appear in `--help` advertised options. Argparse runs the `choices`
|
|
11334
|
+
# check BEFORE the action's `__call__`, so we cannot list `ccusage`
|
|
11335
|
+
# in `choices=` AND catch it in the action. Instead we omit `choices=`
|
|
11336
|
+
# entirely, manually validate inside the action, and re-raise the
|
|
11337
|
+
# spec's rename hint via `parser.error` for `ccusage` specifically.
|
|
11338
|
+
# Help text below hardcodes the legal set so users still see it in
|
|
11339
|
+
# `--help`. A typo like `ccussage` falls through to a standard
|
|
11340
|
+
# argparse-style "invalid choice" error from `parser.error`.
|
|
11341
|
+
class _CostSourceAction(argparse.Action):
|
|
11342
|
+
_ACCEPTED = ("auto", "cctally", "cc", "both")
|
|
11343
|
+
_RENAMED = "ccusage"
|
|
11344
|
+
|
|
11345
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
11346
|
+
if values == self._RENAMED:
|
|
11347
|
+
parser.error(
|
|
11348
|
+
f"argument {option_string}: invalid choice: "
|
|
11349
|
+
f"{values!r} — cctally renamed it; try "
|
|
11350
|
+
f"--cost-source cctally"
|
|
11351
|
+
)
|
|
11352
|
+
if values not in self._ACCEPTED:
|
|
11353
|
+
parser.error(
|
|
11354
|
+
f"argument {option_string}: invalid choice: "
|
|
11355
|
+
f"{values!r} (choose from "
|
|
11356
|
+
+ ", ".join(repr(c) for c in self._ACCEPTED)
|
|
11357
|
+
+ ")"
|
|
11358
|
+
)
|
|
11359
|
+
setattr(namespace, self.dest, values)
|
|
11360
|
+
|
|
11361
|
+
p.add_argument(
|
|
11362
|
+
"--cost-source",
|
|
11363
|
+
dest="cost_source",
|
|
11364
|
+
default=None,
|
|
11365
|
+
action=_CostSourceAction,
|
|
11366
|
+
metavar="{auto,cctally,cc,both}",
|
|
11367
|
+
help="Session cost source (default: auto; config key "
|
|
11368
|
+
"statusline.cost_source). Note: 'ccusage' errors with a "
|
|
11369
|
+
"rename hint — use 'cctally' instead.",
|
|
11370
|
+
)
|
|
11371
|
+
p.add_argument(
|
|
11372
|
+
"--cache",
|
|
11373
|
+
dest="cache",
|
|
11374
|
+
action="store_true",
|
|
11375
|
+
default=None,
|
|
11376
|
+
help="Accepted for ccusage drop-in compat; cctally renders from "
|
|
11377
|
+
"cache.db directly without an extra output cache.",
|
|
11378
|
+
)
|
|
11379
|
+
p.add_argument(
|
|
11380
|
+
"--no-cache",
|
|
11381
|
+
dest="cache",
|
|
11382
|
+
action="store_false",
|
|
11383
|
+
help="(no-op alias)",
|
|
11384
|
+
)
|
|
11385
|
+
p.add_argument(
|
|
11386
|
+
"--refresh-interval",
|
|
11387
|
+
dest="refresh_interval",
|
|
11388
|
+
default=1,
|
|
11389
|
+
type=int,
|
|
11390
|
+
metavar="N",
|
|
11391
|
+
help="(no-op alias) Accepted for ccusage drop-in compat.",
|
|
11392
|
+
)
|
|
11393
|
+
p.add_argument(
|
|
11394
|
+
"--context-low-threshold",
|
|
11395
|
+
dest="context_low_threshold",
|
|
11396
|
+
default=50,
|
|
11397
|
+
type=int,
|
|
11398
|
+
metavar="N",
|
|
11399
|
+
help="Below this %% → segment 4 green (default: 50, 0-100).",
|
|
11400
|
+
)
|
|
11401
|
+
p.add_argument(
|
|
11402
|
+
"--context-medium-threshold",
|
|
11403
|
+
dest="context_medium_threshold",
|
|
11404
|
+
default=80,
|
|
11405
|
+
type=int,
|
|
11406
|
+
metavar="N",
|
|
11407
|
+
help="Below this %% → segment 4 yellow; else red (default: 80, 0-100).",
|
|
11408
|
+
)
|
|
11409
|
+
p.add_argument(
|
|
11410
|
+
"-z", "--timezone",
|
|
11411
|
+
dest="timezone",
|
|
11412
|
+
default=None,
|
|
11413
|
+
metavar="TZ",
|
|
11414
|
+
help="Display tz (IANA) for `today` calendar day. Overrides "
|
|
11415
|
+
"display.tz config.",
|
|
11416
|
+
)
|
|
11417
|
+
p.add_argument(
|
|
11418
|
+
"-O", "--offline",
|
|
11419
|
+
dest="offline",
|
|
11420
|
+
action="store_true",
|
|
11421
|
+
default=True,
|
|
11422
|
+
help="(no-op alias) cctally is always offline.",
|
|
11423
|
+
)
|
|
11424
|
+
p.add_argument(
|
|
11425
|
+
"--no-offline",
|
|
11426
|
+
dest="offline",
|
|
11427
|
+
action="store_false",
|
|
11428
|
+
help="(no-op alias)",
|
|
11429
|
+
)
|
|
11430
|
+
p.add_argument(
|
|
11431
|
+
"--color",
|
|
11432
|
+
dest="color",
|
|
11433
|
+
action="store_true",
|
|
11434
|
+
default=None,
|
|
11435
|
+
help="Force ANSI colors on (default: auto via NO_COLOR + TTY).",
|
|
11436
|
+
)
|
|
11437
|
+
p.add_argument(
|
|
11438
|
+
"--no-color",
|
|
11439
|
+
dest="color",
|
|
11440
|
+
action="store_false",
|
|
11441
|
+
help="Force ANSI colors off.",
|
|
11442
|
+
)
|
|
11443
|
+
p.add_argument(
|
|
11444
|
+
"--cctally-extensions",
|
|
11445
|
+
dest="cctally_extensions",
|
|
11446
|
+
action="store_true",
|
|
11447
|
+
default=None,
|
|
11448
|
+
help="Append cctally 5h%%/7d%% segment (default: on; config key "
|
|
11449
|
+
"statusline.cctally_extensions).",
|
|
11450
|
+
)
|
|
11451
|
+
p.add_argument(
|
|
11452
|
+
"--no-cctally-extensions",
|
|
11453
|
+
dest="cctally_extensions",
|
|
11454
|
+
action="store_false",
|
|
11455
|
+
help="Suppress cctally 5h%%/7d%% segment.",
|
|
11456
|
+
)
|
|
11457
|
+
p.add_argument(
|
|
11458
|
+
"--config",
|
|
11459
|
+
dest="config",
|
|
11460
|
+
default=None,
|
|
11461
|
+
metavar="PATH",
|
|
11462
|
+
help="Read config from PATH for this invocation only (no "
|
|
11463
|
+
"mutation of the default config). Missing/invalid PATH "
|
|
11464
|
+
"exits 2.",
|
|
11465
|
+
)
|
|
11466
|
+
p.add_argument(
|
|
11467
|
+
"--single-thread",
|
|
11468
|
+
dest="single_thread",
|
|
11469
|
+
action="store_true",
|
|
11470
|
+
help="(no-op alias) cctally is always single-threaded via the "
|
|
11471
|
+
"session-entry cache.",
|
|
11472
|
+
)
|
|
11473
|
+
p.add_argument(
|
|
11474
|
+
"-d", "--debug",
|
|
11475
|
+
dest="debug",
|
|
11476
|
+
action="store_true",
|
|
11477
|
+
help="Emit pricing-mismatch / config diagnostics on stderr.",
|
|
11478
|
+
)
|
|
11479
|
+
p.set_defaults(func=cmd_statusline, command=name)
|
|
11480
|
+
return p
|
|
11481
|
+
|
|
11482
|
+
|
|
10610
11483
|
def _build_codex_daily_parser(subparsers, name, *, help_text, xref):
|
|
10611
11484
|
"""Build the `codex-daily` leaf parser (issue #86 Session B; routes to cmd_codex_daily)."""
|
|
10612
11485
|
p = subparsers.add_parser(
|
|
@@ -11467,6 +12340,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
11467
12340
|
help_text="Show usage report grouped by 5-hour session blocks",
|
|
11468
12341
|
xref="Alias of `cctally claude blocks` (the canonical form).")
|
|
11469
12342
|
|
|
12343
|
+
# -- statusline --
|
|
12344
|
+
_build_statusline_parser(
|
|
12345
|
+
sub, "statusline",
|
|
12346
|
+
help_text="Compact one-line status for Claude Code hooks",
|
|
12347
|
+
xref="Alias of `cctally claude statusline` (the canonical form).")
|
|
12348
|
+
|
|
11470
12349
|
# -- five-hour-blocks --
|
|
11471
12350
|
fhb = sub.add_parser(
|
|
11472
12351
|
"five-hour-blocks",
|
|
@@ -11722,6 +12601,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
11722
12601
|
_build_blocks_parser(claude_sub, "blocks",
|
|
11723
12602
|
help_text="Show usage grouped by 5-hour session blocks",
|
|
11724
12603
|
xref="Drop-in for `ccusage claude blocks`. Same engine as `cctally blocks`.")
|
|
12604
|
+
_build_statusline_parser(claude_sub, "statusline",
|
|
12605
|
+
help_text="Compact one-line status for Claude Code hooks",
|
|
12606
|
+
xref="Canonical `cctally claude statusline` (flat alias: `cctally statusline`). "
|
|
12607
|
+
"Drop-in for `ccusage statusline` plus cctally extension segments.")
|
|
11725
12608
|
|
|
11726
12609
|
# --- `codex` subgroup (drop-in for `ccusage codex …`); issue #86 Session B ---
|
|
11727
12610
|
codex_p = sub.add_parser(
|