scorchmark 0.6.0__py3-none-any.whl

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.
adapters.py ADDED
@@ -0,0 +1,131 @@
1
+ """
2
+ adapters.py — turn logs you ALREADY HAVE into Scorchmark rows.
3
+
4
+ The native Scorchmark schema (see ingest.py) is the canonical input, but almost
5
+ nobody has a log in exactly that shape sitting around. The single biggest source
6
+ of friction is "first, reformat your log." This module removes it: point Scorchmark
7
+ at the logs your tools already write and it adapts them on the way in.
8
+
9
+ Currently supported source formats (auto-detected):
10
+ - "scorchmark" — the native schema (passthrough)
11
+ - "claude-code" — Claude Code session transcripts (~/.claude/projects/**/*.jsonl)
12
+
13
+ Each adapter yields RAW row dicts in the native schema; ingest.normalize_row then
14
+ validates + prices them, so adapters stay small and the pricing/cost logic lives in
15
+ exactly one place.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import glob as _glob
22
+
23
+ # Default location Claude Code writes its session transcripts.
24
+ CLAUDE_CODE_LOG_DIR = os.path.expanduser("~/.claude/projects")
25
+
26
+
27
+ def detect_format(text: str) -> str:
28
+ """
29
+ Sniff the source format from the first usable JSON line.
30
+
31
+ Claude Code transcripts are tagged objects with a top-level "type" and a nested
32
+ "message" carrying "usage"; the native schema has flat token fields. Returns
33
+ "claude-code" or "scorchmark" (the safe default — ingest skips rows it can't use).
34
+ """
35
+ for line in text.splitlines():
36
+ line = line.strip()
37
+ if not line:
38
+ continue
39
+ try:
40
+ obj = json.loads(line)
41
+ except json.JSONDecodeError:
42
+ continue
43
+ if not isinstance(obj, dict):
44
+ continue
45
+ # Claude Code event envelope: {"type": "...", "message": {...}, "timestamp": ...}
46
+ if "type" in obj and isinstance(obj.get("message"), dict):
47
+ return "claude-code"
48
+ # Native schema: flat token counts on the row itself.
49
+ if "input_tokens" in obj or "output_tokens" in obj or "cost_usd" in obj:
50
+ return "scorchmark"
51
+ # Keep looking; a stray line shouldn't decide the format.
52
+ return "scorchmark"
53
+
54
+
55
+ def from_claude_code(text: str):
56
+ """
57
+ Yield native rows from Claude Code session JSONL.
58
+
59
+ Only "assistant" events carry token usage. Each becomes one priced row:
60
+ agent_id = "cc:<last-8-of-sessionId>" → keeps a session's loop together so
61
+ detect_cache_waste sees the wake-up intervals (and not the long
62
+ gaps *between* sessions, which would be false TTL misses).
63
+ provider = "anthropic" (Claude Code is Anthropic-only)
64
+ cache_* = Anthropic's native cache_creation_/cache_read_input_tokens.
65
+ """
66
+ for line in text.splitlines():
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ try:
71
+ obj = json.loads(line)
72
+ except json.JSONDecodeError:
73
+ continue
74
+ if not isinstance(obj, dict) or obj.get("type") != "assistant":
75
+ continue
76
+ msg = obj.get("message")
77
+ if not isinstance(msg, dict):
78
+ continue
79
+ usage = msg.get("usage")
80
+ if not isinstance(usage, dict):
81
+ continue
82
+ session = str(obj.get("sessionId") or "")
83
+ agent = f"cc:{session[-8:]}" if session else "cc:default"
84
+ yield {
85
+ "request_id": obj.get("requestId") or obj.get("uuid") or "",
86
+ "ts": obj.get("timestamp"),
87
+ "provider": "anthropic",
88
+ "model": msg.get("model") or "",
89
+ "agent_id": agent,
90
+ "input_tokens": usage.get("input_tokens") or 0,
91
+ "output_tokens": usage.get("output_tokens") or 0,
92
+ "cache_write_tokens": usage.get("cache_creation_input_tokens") or 0,
93
+ "cache_read_tokens": usage.get("cache_read_input_tokens") or 0,
94
+ }
95
+
96
+
97
+ # Map of format name → raw-row generator. "scorchmark" is handled by ingest directly.
98
+ _ADAPTERS = {"claude-code": from_claude_code}
99
+
100
+
101
+ def to_raw_rows(text: str, fmt: str = "auto"):
102
+ """
103
+ Return a list of native raw-row dicts for the given source text.
104
+
105
+ fmt="auto" sniffs the format; an explicit fmt forces an adapter. The native
106
+ "scorchmark" format returns an empty list here — the caller (ingest.parse) parses
107
+ it directly so cost/normalization isn't duplicated.
108
+ """
109
+ if fmt == "auto":
110
+ fmt = detect_format(text)
111
+ if fmt in _ADAPTERS:
112
+ return list(_ADAPTERS[fmt](text))
113
+ return [] # native; ingest handles it
114
+
115
+
116
+ def read_source(path: str) -> str:
117
+ """
118
+ Read a log source into text. Accepts a file, '-' (stdin), or a DIRECTORY of
119
+ .jsonl files (e.g. ~/.claude/projects) — directories are read recursively and
120
+ concatenated, which is how Claude Code stores one file per session.
121
+ """
122
+ import sys
123
+ if path in ("-", ""):
124
+ return sys.stdin.read()
125
+ path = os.path.expanduser(path)
126
+ if os.path.isdir(path):
127
+ files = sorted(_glob.glob(os.path.join(path, "**", "*.jsonl"), recursive=True))
128
+ if not files:
129
+ raise FileNotFoundError(f"no .jsonl files under {path}")
130
+ return "\n".join(open(f, encoding="utf-8", errors="replace").read() for f in files)
131
+ return open(path, encoding="utf-8", errors="replace").read()
alerts.py ADDED
@@ -0,0 +1,136 @@
1
+ """
2
+ alerts.py — structured alert payloads for Scorchmark.
3
+
4
+ Converts a check_budget result (or any detector result with a 'status' field)
5
+ into a structured alert payload when the status is not 'ok'. The payload is
6
+ designed for webhook delivery — the caller (or host agent) is responsible for
7
+ POSTing it; we provide a best-effort helper using stdlib urllib so there is
8
+ zero new runtime dependency.
9
+
10
+ Design rationale: MCP tools are stateless per-call, so we cannot run a
11
+ background loop. The recommended pattern is:
12
+ 1. Call check_budget (or any detector) on each agent turn / scheduled ping.
13
+ 2. Pass the result to build_alert.
14
+ 3. If alert["should_fire"] is True, POST alert["payload"] to your webhook,
15
+ or use notify_webhook() if you have a URL configured.
16
+
17
+ No httpx, no requests. stdlib urllib.request only — and only when called explicitly.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from datetime import datetime, timezone
23
+ from typing import Any
24
+
25
+
26
+ # Severity map: detector status → alert level.
27
+ _LEVEL: dict[str, str] = {
28
+ "ok": "info",
29
+ "warn": "warning",
30
+ "warning": "warning",
31
+ "breach": "critical",
32
+ "critical":"critical",
33
+ }
34
+
35
+
36
+ def build_alert(
37
+ check_result: dict[str, Any],
38
+ webhook_url: str | None = None,
39
+ source: str = "scorchmark",
40
+ ) -> dict[str, Any]:
41
+ """
42
+ Build a structured alert payload from any detector result dict.
43
+
44
+ A result with status == 'ok' returns {should_fire: False}. Everything
45
+ else returns a full payload with severity, message, and context — ready
46
+ to POST to Slack / PagerDuty / ntfy / any JSON webhook.
47
+
48
+ Args:
49
+ check_result: the dict returned by check_budget, detect_spend_acceleration,
50
+ predict_rate_limit, or any detector.
51
+ webhook_url: if provided, the payload includes the target URL (the
52
+ caller decides whether to actually POST it — see
53
+ notify_webhook() below for an optional helper).
54
+ source: label for the alert source (used as title prefix).
55
+
56
+ Returns:
57
+ {
58
+ should_fire: bool,
59
+ level: 'info'|'warning'|'critical',
60
+ payload: { title, message, level, ts_utc, context, source },
61
+ webhook_url: str | None,
62
+ }
63
+ """
64
+ status = str(check_result.get("status", "ok")).lower()
65
+ level = _LEVEL.get(status, "warning")
66
+ should_fire = status != "ok"
67
+
68
+ if not should_fire:
69
+ return {"should_fire": False, "level": "info", "payload": None, "webhook_url": webhook_url}
70
+
71
+ # Build a human-readable message from the most useful fields.
72
+ msg_parts = []
73
+ if "spent_usd" in check_result:
74
+ msg_parts.append(f"Spent: ${check_result['spent_usd']:.4f}")
75
+ if "monthly_cap_usd" in check_result:
76
+ msg_parts.append(f"Cap: ${check_result['monthly_cap_usd']:.2f}")
77
+ if "burn_rate_usd_per_hr" in check_result:
78
+ msg_parts.append(f"Burn: ${check_result['burn_rate_usd_per_hr']:.4f}/hr")
79
+ if "projected_to_reset_usd" in check_result:
80
+ msg_parts.append(f"Projected: ${check_result['projected_to_reset_usd']:.4f}")
81
+ if "eta_to_cap_hours" in check_result and check_result["eta_to_cap_hours"] is not None:
82
+ msg_parts.append(f"ETA to cap: {check_result['eta_to_cap_hours']:.1f}h")
83
+ if "recommendation" in check_result:
84
+ msg_parts.append(check_result["recommendation"])
85
+ if "accelerating" in check_result and check_result["accelerating"]:
86
+ msg_parts.append(f"Acceleration factor: {check_result.get('factor', '?')}x")
87
+
88
+ message = " | ".join(msg_parts) if msg_parts else f"Status: {status}"
89
+ title = f"[{source}] {level.upper()}: agent spend alert"
90
+
91
+ payload = {
92
+ "title": title,
93
+ "message": message,
94
+ "level": level,
95
+ "status": status,
96
+ "ts_utc": datetime.now(timezone.utc).isoformat(),
97
+ "context": check_result,
98
+ "source": source,
99
+ }
100
+
101
+ return {
102
+ "should_fire": True,
103
+ "level": level,
104
+ "payload": payload,
105
+ "webhook_url": webhook_url,
106
+ }
107
+
108
+
109
+ def notify_webhook(alert: dict[str, Any]) -> dict[str, Any]:
110
+ """
111
+ Optionally POST the alert payload to the configured webhook_url.
112
+
113
+ Uses stdlib urllib.request — no new deps. Returns {sent, status_code, error}.
114
+ Call this only if you want the guard itself to push notifications; otherwise
115
+ read alert["payload"] and route it yourself (e.g. from the host agent).
116
+
117
+ Silently returns {sent: False} if should_fire is False or webhook_url is None.
118
+ """
119
+ if not alert.get("should_fire") or not alert.get("webhook_url"):
120
+ return {"sent": False, "status_code": None, "error": None}
121
+
122
+ import urllib.request # noqa: PLC0415 — intentional lazy import (stdlib only)
123
+
124
+ url = alert["webhook_url"]
125
+ body = json.dumps(alert["payload"]).encode("utf-8")
126
+ req = urllib.request.Request(
127
+ url,
128
+ data=body,
129
+ headers={"Content-Type": "application/json", "User-Agent": "ScorchmarkGuard/1.1"},
130
+ method="POST",
131
+ )
132
+ try:
133
+ with urllib.request.urlopen(req, timeout=5) as resp:
134
+ return {"sent": True, "status_code": resp.status, "error": None}
135
+ except Exception as exc: # noqa: BLE001
136
+ return {"sent": False, "status_code": None, "error": str(exc)}
cli.py ADDED
@@ -0,0 +1,318 @@
1
+ """
2
+ scorchmark — run Scorchmark checks on a cost log straight from the terminal,
3
+ no MCP client required. The zero-friction way to try it on your worst log.
4
+
5
+ scorchmark report log.jsonl --cap 100 # everything at once (default)
6
+ scorchmark budget log.jsonl --cap 100
7
+ scorchmark cache-waste log.jsonl
8
+ scorchmark by-agent log.jsonl
9
+ scorchmark swap log.jsonl --to claude-haiku-4-5
10
+ cat log.jsonl | scorchmark report - --cap 100 # read stdin with '-'
11
+
12
+ Add --json to any command for the raw machine-readable result.
13
+ Same engine as the MCP server (ingest + detectors); pure stdlib.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+ import ingest
23
+ import detectors
24
+ import licensing
25
+
26
+ _COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
27
+ _CODES = {"red": "31", "green": "32", "yellow": "33", "violet": "35", "dim": "2", "bold": "1"}
28
+ _STATUS_COLOR = {"ok": "green", "warn": "yellow", "warning": "yellow",
29
+ "breach": "red", "critical": "red"}
30
+
31
+
32
+ def _c(name: str, s: str) -> str:
33
+ return f"\033[{_CODES[name]}m{s}\033[0m" if _COLOR and name in _CODES else s
34
+
35
+
36
+ def _money(x: float) -> str:
37
+ return f"${x:,.2f}" if abs(x) >= 0.01 or x == 0 else f"${x:.4f}"
38
+
39
+
40
+ def _bar(frac: float, width: int = 28, status: str = "ok") -> str:
41
+ frac = max(0.0, min(frac, 1.0))
42
+ filled = int(round(frac * width))
43
+ return _c(_STATUS_COLOR.get(status, "green"), "█" * filled) + _c("dim", "░" * (width - filled))
44
+
45
+
46
+ def _badge(status: str) -> str:
47
+ return _c(_STATUS_COLOR.get(status, "green"), f"[{status.upper()}]")
48
+
49
+
50
+ def _load(path: str, fmt: str = "auto"):
51
+ import adapters
52
+ text = adapters.read_source(path)
53
+ return ingest.parse(text, fmt)
54
+
55
+
56
+ def _emit(result, as_json: bool, printer):
57
+ if as_json:
58
+ print(json.dumps(result, indent=2))
59
+ else:
60
+ printer(result)
61
+
62
+
63
+ # --- human-readable printers ------------------------------------------------
64
+ def _print_budget(r):
65
+ cap = r["monthly_cap_usd"]
66
+ frac = r["spent_usd"] / cap if cap > 0 else 0.0
67
+ print(f" {_bar(frac, status=r['status'])} {_badge(r['status'])}")
68
+ print(f" spent {_c('bold', _money(r['spent_usd']))} of {_money(cap)} cap"
69
+ f" · burn rate {_money(r['burn_rate_usd_per_hr'])}/hr")
70
+ # "projected" is a straight-line extrapolation of the burn rate, not a forecast —
71
+ # say "at this pace" so a high number from a short sample doesn't read as a prediction.
72
+ if r.get("eta_to_cap_hours") is not None:
73
+ print(_c("dim", f" at this pace → hits the cap in ~{r['eta_to_cap_hours']}h "
74
+ f"(≈{_money(r['projected_to_reset_usd'])} by reset day)"))
75
+ else:
76
+ print(_c("dim", f" at this pace → ≈{_money(r['projected_to_reset_usd'])} by reset day"))
77
+
78
+
79
+ def _print_cache_waste(r):
80
+ if not r["ttl_miss_count"]:
81
+ print(_c("green", " no systematic cache waste in window"))
82
+ return
83
+ print(f" {_c('red', _money(r['wasted_cost_usd']) + ' wasted')} across "
84
+ f"{r['ttl_miss_count']} rebuild(s) · loop ~{int(r['median_interval_s'])}s "
85
+ f"vs {r['cache_ttl_s']}s TTL ({r['cache_model']})")
86
+ print(_c("dim", f" {r['recommendation']}"))
87
+
88
+
89
+ def _print_by_agent(rows):
90
+ if not rows:
91
+ print(_c("dim", " no spend in window"))
92
+ return
93
+ for a in rows:
94
+ share = a["share_of_total"] * 100
95
+ name = a["agent_id"]
96
+ req = _c("dim", f"({a['requests']} req)")
97
+ print(f" {_bar(a['share_of_total'], width=20)} {share:5.1f}% "
98
+ f"{name:<20} {_money(a['cost_usd'])} {req}")
99
+
100
+
101
+ def _print_anomalies(rows):
102
+ if not rows:
103
+ print(_c("green", " no spend anomalies in window"))
104
+ return
105
+ for a in rows:
106
+ x = _c("yellow", f"{a['x_over_agent_median']}x")
107
+ loc = f"{a['agent_id']}/{a['request_id']}"
108
+ print(f" {x} {loc} {_money(a['cost_usd'])} {_c('dim', a['reason'])}")
109
+
110
+
111
+ def _print_swap(r):
112
+ saved = _c("green", f"{_money(r['savings_usd'])} / {r['savings_pct']}% saved")
113
+ print(f" swap → {_c('bold', r['to_model'])}: {_money(r['current_usd'])} would be "
114
+ f"{_money(r['simulated_usd'])} ({saved})")
115
+
116
+
117
+ def _print_gate(g):
118
+ print(f" {_c('yellow', '🔒 ' + g['tier_required'].upper())} {g['message']}")
119
+ print(_c("dim", f" unlock: {g['upgrade_url']}"))
120
+
121
+
122
+ def _print_license(st):
123
+ col = "green" if st["valid"] else "dim"
124
+ print(f" tier: {_c(col, st['tier'].upper())} · {st['reason']}")
125
+ if st.get("email"):
126
+ print(_c("dim", f" licensed to {st['email']} · expires {st.get('expires')}"))
127
+
128
+
129
+ def _section(title):
130
+ print()
131
+ print(_c("violet", _c("bold", f"▸ {title}")))
132
+
133
+
134
+ def _dur(seconds: float) -> str:
135
+ s = max(0, int(seconds))
136
+ if s < 90:
137
+ return f"{s}s"
138
+ if s < 5400:
139
+ return f"{s/60:.0f} min"
140
+ if s < 172800:
141
+ return f"{s/3600:.1f}h"
142
+ return f"{s/86400:.1f}d"
143
+
144
+
145
+ def _print_headline(rows, args):
146
+ """The 'so what' up top: total, the waste found (free), and the savings available."""
147
+ total = sum(r["cost_usd"] for r in rows)
148
+ span = (rows[-1]["ts"] - rows[0]["ts"]) if len(rows) > 1 else 0
149
+ span_txt = f" · over {_dur(span)}" if span else ""
150
+ print(f" {_c('bold', _money(total))} spent across {len(rows)} requests{span_txt}")
151
+ cw = detectors.detect_cache_waste(rows, args.window)
152
+ if cw["ttl_miss_count"]:
153
+ pct = (100 * cw["wasted_cost_usd"] / total) if total else 0
154
+ print(" " + _c("red", f"💸 {_money(cw['wasted_cost_usd'])} wasted on cache rebuilds")
155
+ + _c("dim", f" ({pct:.0f}% of spend — recoverable)"))
156
+ else:
157
+ print(" " + _c("green", "✓ no cache-rebuild waste found"))
158
+ g = licensing.gate("team") # model-swap savings are Team
159
+ if g:
160
+ print(" " + _c("dim", "🔒 model-swap savings hidden — Team tier"))
161
+ else:
162
+ sw = detectors.simulate_model_swap(rows, args.to, None, args.window)
163
+ if sw["savings_usd"] > 0.005:
164
+ print(" " + _c("green", f"💡 {_money(sw['savings_usd'])} saveable")
165
+ + _c("dim", f" by switching to {args.to} ({sw['savings_pct']:.0f}% less)"))
166
+ else:
167
+ print(" " + _c("dim", f" no model-swap savings vs {args.to} here"))
168
+
169
+
170
+ # --- commands ---------------------------------------------------------------
171
+ def _cmd_budget(rows, args):
172
+ return detectors.check_budget(rows, args.cap, args.reset_day, args.window), _print_budget
173
+
174
+
175
+ def _cmd_cache(rows, args):
176
+ return detectors.detect_cache_waste(rows, args.window), _print_cache_waste
177
+
178
+
179
+ def _cmd_agent(rows, args):
180
+ g = licensing.gate("team") # per-agent attribution is Team
181
+ if g:
182
+ return g, _print_gate
183
+ return detectors.cost_by_agent(rows, args.window), _print_by_agent
184
+
185
+
186
+ def _cmd_anomalies(rows, args):
187
+ return detectors.find_spend_anomalies(rows, args.threshold, args.window), _print_anomalies
188
+
189
+
190
+ def _cmd_swap(rows, args):
191
+ g = licensing.gate("team") # model-swap simulator is Team
192
+ if g:
193
+ return g, _print_gate
194
+ return detectors.simulate_model_swap(rows, args.to, None, args.window), _print_swap
195
+
196
+
197
+ def _cmd_report(rows, args):
198
+ # Composite human view; --json emits one combined object. Premium sections are
199
+ # gated (replaced by the upgrade-required object) exactly like the MCP server.
200
+ def _g(tier, fn):
201
+ return licensing.gate(tier) or fn()
202
+
203
+ if args.json:
204
+ return {
205
+ "license": licensing.status(),
206
+ "summary": {
207
+ "requests": len(rows),
208
+ "total_spend_usd": round(sum(r["cost_usd"] for r in rows), 4),
209
+ },
210
+ "cache_waste": detectors.detect_cache_waste(rows, args.window),
211
+ "model_swap": _g("team", lambda: detectors.simulate_model_swap(rows, args.to, None, args.window)),
212
+ "budget": detectors.check_budget(rows, args.cap, args.reset_day, args.window),
213
+ "acceleration": _g("pro", lambda: detectors.detect_spend_acceleration(rows, args.window)),
214
+ "cost_by_agent": _g("team", lambda: detectors.cost_by_agent(rows, args.window)),
215
+ "anomalies": detectors.find_spend_anomalies(rows, args.threshold, args.window),
216
+ }, (lambda r: print(json.dumps(r, indent=2)))
217
+
218
+ def printer(_):
219
+ # Lead with the findings (what you came for), then the breakdowns.
220
+ _section("Bottom line")
221
+ _print_headline(rows, args)
222
+ _section("Cache waste")
223
+ _print_cache_waste(detectors.detect_cache_waste(rows, args.window))
224
+ _section(f"Savings — swap → {args.to} (Team)")
225
+ gt = licensing.gate("team")
226
+ if gt:
227
+ _print_gate(gt)
228
+ else:
229
+ _print_swap(detectors.simulate_model_swap(rows, args.to, None, args.window))
230
+ _section(f"Budget (cap {_money(args.cap)})")
231
+ _print_budget(detectors.check_budget(rows, args.cap, args.reset_day, args.window))
232
+ _section("Spend acceleration (Pro)")
233
+ gp = licensing.gate("pro")
234
+ if gp:
235
+ _print_gate(gp)
236
+ else:
237
+ acc = detectors.detect_spend_acceleration(rows, args.window)
238
+ print(f" {_badge('warn' if acc['accelerating'] else 'ok')} {_c('dim', acc['recommendation'])}")
239
+ _section("Cost by agent (Team)")
240
+ gt2 = licensing.gate("team")
241
+ if gt2:
242
+ _print_gate(gt2)
243
+ else:
244
+ _print_by_agent(detectors.cost_by_agent(rows, args.window))
245
+ _section("Anomalies")
246
+ _print_anomalies(detectors.find_spend_anomalies(rows, args.threshold, args.window))
247
+ return None, printer
248
+
249
+
250
+ def _build_parser():
251
+ p = argparse.ArgumentParser(prog="scorchmark", description=__doc__.strip().splitlines()[1])
252
+ p.add_argument("--version", action="version", version="scorchmark 0.6.0")
253
+ sub = p.add_subparsers(dest="cmd")
254
+
255
+ def add(name, fn, help_):
256
+ sp = sub.add_parser(name, help=help_)
257
+ sp.add_argument("log", nargs="?", default="-",
258
+ help="cost-log file, a directory of .jsonl, or '-' for stdin")
259
+ sp.add_argument("--window", default="30d", help="lookback window (e.g. 24h, 7d, 30d)")
260
+ sp.add_argument("--from", dest="fmt", default="auto",
261
+ choices=["auto", "scorchmark", "claude-code"],
262
+ help="source log format (default: auto-detect)")
263
+ sp.add_argument("--claude-code", action="store_true",
264
+ help="analyze your own Claude Code logs (~/.claude/projects)")
265
+ sp.add_argument("--json", action="store_true", help="raw JSON output")
266
+ sp.set_defaults(_fn=fn)
267
+ return sp
268
+
269
+ sp = add("report", _cmd_report, "run all checks (default)")
270
+ sp.add_argument("--cap", type=float, default=100.0); sp.add_argument("--reset-day", type=int, default=1)
271
+ sp.add_argument("--threshold", type=float, default=3.0)
272
+ sp.add_argument("--to", default="claude-haiku-4-5",
273
+ help="model to price the swap-savings estimate against (default: claude-haiku-4-5)")
274
+ sp = add("budget", _cmd_budget, "live budget tripwire")
275
+ sp.add_argument("--cap", type=float, required=True); sp.add_argument("--reset-day", type=int, default=1)
276
+ add("cache-waste", _cmd_cache, "detect cache-TTL waste")
277
+ add("by-agent", _cmd_agent, "per-agent cost attribution")
278
+ sp = add("anomalies", _cmd_anomalies, "flag spend spikes"); sp.add_argument("--threshold", type=float, default=3.0)
279
+ sp = add("swap", _cmd_swap, "simulate a model swap (Team)"); sp.add_argument("--to", required=True, help="target model, e.g. claude-haiku-4-5")
280
+ lp = sub.add_parser("license", help="show active license tier (SCORCHMARK_LICENSE)")
281
+ lp.add_argument("--json", action="store_true", help="raw JSON output") # handled in main()
282
+ return p
283
+
284
+
285
+ def main(argv=None):
286
+ args = _build_parser().parse_args(argv)
287
+ if not getattr(args, "cmd", None):
288
+ _build_parser().print_help()
289
+ return 0
290
+ if args.cmd == "license": # no log needed
291
+ st = licensing.status()
292
+ print(json.dumps(st, indent=2)) if args.json else _print_license(st)
293
+ return 0
294
+ # --claude-code is shorthand: point at ~/.claude/projects and force the adapter.
295
+ if getattr(args, "claude_code", False):
296
+ import adapters
297
+ if args.log == "-":
298
+ args.log = adapters.CLAUDE_CODE_LOG_DIR
299
+ args.fmt = "claude-code"
300
+ try:
301
+ rows, summary = _load(args.log, getattr(args, "fmt", "auto"))
302
+ except FileNotFoundError as e:
303
+ print(_c("red", f"error: {e}"), file=sys.stderr)
304
+ return 2
305
+ if not rows:
306
+ print(_c("yellow", "no usable rows in log "
307
+ f"(skipped {summary['rows_skipped']})"), file=sys.stderr)
308
+ return 1
309
+ print(_c("dim", f"ingested {summary['rows_ingested']} rows · "
310
+ f"computed spend {_money(summary['computed_cost_usd'])}"))
311
+ result, printer = args._fn(rows, args)
312
+ _emit(result, args.json and result is not None, printer)
313
+ print()
314
+ return 0
315
+
316
+
317
+ if __name__ == "__main__":
318
+ sys.exit(main())