claude-finops 0.7.2 → 0.9.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/finops/pricing.py CHANGED
@@ -5,6 +5,7 @@ produced here is an ESTIMATE. Callers must label it as such.
5
5
  """
6
6
  import json
7
7
  import os
8
+ import re
8
9
 
9
10
  from .paths import ROOT, PRICING_PATH
10
11
 
@@ -13,6 +14,11 @@ M = 1_000_000.0
13
14
 
14
15
  FREE = {"display_name": None, "tier": "free", "input": 0.0, "output": 0.0, "cache_read": 0.0,
15
16
  "cache_write_5m": 0.0, "cache_write_1h": 0.0}
17
+ UNPRICED = dict(FREE, tier="unpriced")
18
+
19
+ # provider prefixes (bedrock/vertex regions), dated and @version suffixes, bedrock ":0"
20
+ _PREFIX = re.compile(r"^(?:[a-z]{2}(?:-[a-z]+)?\.)?anthropic\.")
21
+ _SUFFIX = re.compile(r"(?:-\d{8}|@\d{8}|-v\d+:\d+|:\d+)+$")
16
22
 
17
23
 
18
24
  class Pricing:
@@ -20,21 +26,40 @@ class Pricing:
20
26
  with open(path) as fh:
21
27
  self.raw = json.load(fh)
22
28
  self.models = self.raw.get("models", {})
23
- self.default = self.raw.get("default_model_pricing", {})
29
+ self.aliases = self.raw.get("aliases", {})
30
+ self.default = {} # kept for callers; unknown ids are UNPRICED now
24
31
  self.updated = self.raw.get("updated")
25
32
  self.source = self.raw.get("source")
26
33
 
27
- def rates(self, model):
34
+ def normalize(self, model):
35
+ """Canonical price-table key for any id Claude Code may record."""
36
+ if not model:
37
+ return "unknown"
28
38
  if model in self.models:
29
- return self.models[model]
39
+ return model
40
+ m = _PREFIX.sub("", model)
41
+ m = _SUFFIX.sub("", m)
42
+ m = self.aliases.get(m, m)
43
+ if m in self.models:
44
+ return m
45
+ # a dated key in the table for an undated id: claude-haiku-4-5 -> ...-20251001
46
+ for k in self.models:
47
+ if k.startswith(m + "-") and re.fullmatch(r"\d{8}", k[len(m) + 1:]):
48
+ return k
49
+ return m
50
+
51
+ def rates(self, model):
52
+ m = self.normalize(model)
53
+ if m in self.models:
54
+ return self.models[m]
30
55
  # An unlisted non-Claude model reached through Claude Code (e.g. a local Ollama or
31
56
  # free OpenRouter model via a claude-qwen launcher) has no Anthropic price: $0.
32
- if model and not model.startswith("claude") and model not in ("unknown",):
57
+ if m and not m.startswith("claude") and m != "unknown":
33
58
  return FREE
34
- return self.default
59
+ return UNPRICED
35
60
 
36
61
  def is_known(self, model):
37
- return model in self.models
62
+ return self.normalize(model) in self.models
38
63
 
39
64
  def display_name(self, model):
40
65
  return self.rates(model).get("display_name") or model
@@ -45,6 +70,32 @@ class Pricing:
45
70
  def context_window(self, model):
46
71
  return self.rates(model).get("context_window")
47
72
 
73
+ def effective_model(self, model, context_tokens=0, speed=None):
74
+ """The price list that actually applied, given how much context was sent.
75
+
76
+ A request whose prompt side exceeds the model's standard context window cannot
77
+ have been served by the standard variant — it was the long-context one, which
78
+ is billed at a premium. The transcript records only the base model name, so
79
+ pricing off that name alone understates every long-context request. Fast mode
80
+ is billed at its own rate; a `[1m]` premium applies only when the table lists
81
+ one for this model.
82
+
83
+ Returns (model_id_to_price_with, unpriced_long_context). The flag is set when
84
+ the context clearly exceeded the window but no `[1m]` entry exists to price it
85
+ with, so callers can surface it rather than quietly bill it at the low rate.
86
+ """
87
+ m = self.normalize(model)
88
+ if speed == "fast" and f"{m}[fast]" in self.models:
89
+ return f"{m}[fast]", False
90
+ r = self.models.get(m)
91
+ win = (r or {}).get("context_window") or 0
92
+ if not r or not win or not context_tokens or context_tokens <= win:
93
+ return m, False
94
+ alt = f"{m}[1m]"
95
+ if alt in self.models:
96
+ return alt, False
97
+ return m, True
98
+
48
99
  def estimate(self, model, input_tokens=0, output_tokens=0, cache_read=0,
49
100
  cache_write_5m=0, cache_write_1h=0):
50
101
  """Return estimated USD for one request."""
package/finops/procs.py CHANGED
@@ -18,11 +18,13 @@ HOME = os.path.expanduser("~")
18
18
  REG = os.path.join(HOME, ".claude", "sessions")
19
19
  PROJECTS = os.path.join(HOME, ".claude", "projects")
20
20
 
21
- ACTIONS = {
22
- "interrupt": (getattr(signal, "CTRL_C_EVENT", signal.SIGINT) if os.name == "nt" else signal.SIGINT, "Interrupted the current turn (like pressing Esc/Ctrl-C)."),
23
- "close": (signal.SIGTERM, "Asked the session to exit. Resume it later with `claude --resume`."),
24
- "kill": (getattr(signal, "SIGKILL", signal.SIGTERM), "Force-killed the process."),
25
- }
21
+ ACTIONS = {}
22
+ if os.name != "nt":
23
+ # On Windows, CTRL_C_EVENT signals the whole console process group, not just
24
+ # the target process, so there is no safe way to interrupt just one session.
25
+ ACTIONS["interrupt"] = (signal.SIGINT, "Interrupted the current turn (like pressing Esc/Ctrl-C).")
26
+ ACTIONS["close"] = (signal.SIGTERM, "Asked the session to exit. Resume it later with `claude --resume`.")
27
+ ACTIONS["kill"] = (getattr(signal, "SIGKILL", signal.SIGTERM), "Force-killed the process.")
26
28
 
27
29
 
28
30
  def _ps():
package/finops/report.py CHANGED
@@ -29,7 +29,7 @@ def build_report(a, f):
29
29
  md = a.models(f); pj = a.projects(f); wt = a.waste(f)
30
30
  rc = a.recommendations(f); ad = a.advisor(f); ef = a.efficiency(f)
31
31
  cat = a.categories(f); bp = ov["billing_period"]
32
- lb = a.leaderboards(f, 10)
32
+ lb = a.leaderboards(f, 10); hy = a.hygiene(f)
33
33
 
34
34
  def table(headers, rows):
35
35
  h = "".join(f"<th>{html.escape(x)}</th>" for x in headers)
@@ -82,52 +82,61 @@ transcripts contain no billed amounts, so no figure here is an actual invoice va
82
82
  <div class="kpi"><div class="l">Avg / active day</div><div class="v">{_f(ov['avg_cost_per_active_day'])}</div></div>
83
83
  </div>
84
84
 
85
- <h2>2. FinOps scorecard</h2>
86
- <p><span class="score">{sc['score']}</span> / 100 &nbsp; grade <b>{sc['grade']}</b></p>
85
+ <h2>2. Context hygiene</h2>
86
+ {table(["Threshold", "Requests", "Cost", "Sessions", "Cost after first cross"],
87
+ [(f"{int(thr):,}", _f(v['requests'],'int'), _f(v['cost_usd']), _f(v['sessions'],'int'),
88
+ _f(v['cost_after_first_cross_usd'])) for thr, v in hy['above'].items()])}
89
+ <div class="note">"Cost after first cross" is the spend on requests made once a session first passed the
90
+ threshold in that column &mdash; not a saving, an observation of where spend concentrates.</div>
91
+
92
+ <h2>3. FinOps scorecard</h2>
93
+ <p><span class="score">{sc['score']}</span> / 100</p>
87
94
  {table(["Dimension","Score","Detail"], [(d['name'], d['score'], html.escape(d['detail'])) for d in sc['dimensions']])}
88
95
  <b>What is good</b><ul>{''.join(f'<li>{html.escape(x)}</li>' for x in sc['what_is_good']) or '<li>&mdash;</li>'}</ul>
89
96
  <b>Needs attention</b><ul>{''.join(f'<li>{html.escape(x)}</li>' for x in sc['needs_attention']) or '<li>&mdash;</li>'}</ul>
90
97
 
91
- <h2>3. What should I do? <span class="badge">Advisor</span></h2>
98
+ <h2>4. What should I do? <span class="badge">Advisor</span></h2>
92
99
  <ol>{''.join(f"<li><b>{html.escape(x['text'])}</b><br><span style='color:#5b6570'>{html.escape(x['detail'])}</span></li>" for x in ad['actions']) or '<li>No actions.</li>'}</ol>
93
100
 
94
- <h2>4. Model breakdown <span class="badge est">Estimated cost</span></h2>
101
+ <h2>5. Model breakdown <span class="badge est">Estimated cost</span></h2>
95
102
  {table(["Model","Requests","Input","Output","Cache read","Cache write","Billable tokens","Est. cost","% cost"],
96
103
  [(html.escape(r['display_name']), _f(r['requests'],'int'), _f(r['input_tokens'],'int'),
97
104
  _f(r['output_tokens'],'int'), _f(r['cache_read_tokens'],'int'), _f(r['cache_write_tokens'],'int'),
98
105
  _f(r['tokens'],'int'), _f(r['cost']), _f(r['cost_pct'],'pct')) for r in md['rows']])}
99
106
 
100
- <h2>5. Project breakdown</h2>
107
+ <h2>6. Project breakdown</h2>
101
108
  {table(["Project","Sessions","Prompts","Requests","Tokens","Est. cost","Avg / session"],
102
109
  [(html.escape(r['name']), _f(r['sessions'],'int'), _f(r['prompts'],'int'), _f(r['requests'],'int'),
103
110
  _f(r['tokens'],'int'), _f(r['cost']), _f(r['avg_cost_per_session'])) for r in pj[:20]])}
104
111
 
105
- <h2>6. Spend by activity</h2>
112
+ <h2>7. Spend by activity</h2>
106
113
  {table(["Category","Prompts","Tokens","Est. cost","% of spend"],
107
114
  [(html.escape(r['category']), _f(r['prompts'],'int'), _f(r['tokens'],'int'), _f(r['cost']),
108
115
  _f(r['cost_pct'],'pct')) for r in cat['rows']])}
109
116
  <div class="note">{html.escape(cat['note'])}</div>
110
117
 
111
- <h2>7. Efficiency &amp; caching</h2>
118
+ <h2>8. Efficiency &amp; caching</h2>
112
119
  {table(["Metric","Value"], [
113
120
  ("Output share of billable tokens", f"{ef['output_ratio']*100:.2f}%"),
114
121
  ("Output per prompt-side token", f"{ef['output_per_input']*100:.2f}%"),
115
- ("Cache hit ratio", (f"{ef['cache_hit_ratio']*100:.1f}%" if ef['cache_hit_ratio'] is not None else "&mdash;")),
122
+ ("Cache hit ratio (by token)", (f"{ef['cache_hit_ratio']*100:.1f}%" if ef['cache_hit_ratio'] is not None else "&mdash;")),
123
+ ("Cache reads as share of cache cost", (f"{ef['cache_read_cost_share']*100:.1f}%"
124
+ if ef.get('cache_read_cost_share') is not None else "&mdash;")),
116
125
  ("Tokens per request", _f(ef['tokens_per_request'],'int')),
117
126
  ("Avg context per request", _f(ef['avg_context_tokens'],'int')),
118
127
  ("Est. cost per 1K output tokens", f"${ef['cost_per_1k_output']:.3f}"),
119
128
  ("Est. cost with caching", _f(ef['cache']['cost_with_cache'])),
120
129
  ("Est. cost without caching", _f(ef['cache']['cost_without_cache'])),
121
- ("Est. caching savings", f"{_f(ef['cache']['estimated_savings_usd'])} ({ef['cache']['savings_pct']}%)"),
130
+ ("Uncached counterfactual — not a saving", f"{_f(ef['cache']['uncached_counterfactual_delta_usd'])} ({ef['cache']['uncached_counterfactual_pct']}%)"),
122
131
  ])}
123
132
 
124
- <h2>8. Top 10 most expensive prompts <span class="badge est">Estimated</span></h2>
133
+ <h2>9. Top 10 most expensive prompts <span class="badge est">Estimated</span></h2>
125
134
  {table(["#","Prompt","Model","Tokens","Est. cost","Date"],
126
135
  [(i, html.escape((r['preview'] or '')[:120]), html.escape((r['models'] or '')[:40]),
127
136
  _f(r['ptokens'],'int'), _f(r['pcost']), r['day'])
128
137
  for i, r in enumerate(lb['most_expensive'], 1)])}
129
138
 
130
- <h2>9. Waste detection <span class="badge est">Estimated exposure</span></h2>
139
+ <h2>10. Waste detection <span class="badge est">Estimated exposure</span></h2>
131
140
  <p><b>Estimated excess: {_f(wt['estimated_excess_usd'])} ({wt['excess_pct']}%)</b> of
132
141
  {_f(wt['total_cost_usd'])} — how much more the flagged work cost than a reasonable baseline.
133
142
  It sits inside {_f(wt['exposed_cost_usd'])} ({wt['exposed_pct']}%) of exposed spend across
@@ -138,24 +147,21 @@ It sits inside {_f(wt['exposed_cost_usd'])} ({wt['exposed_pct']}%) of exposed sp
138
147
  for x in wt['findings']])}
139
148
  <div class="note">{html.escape(wt['note'])}</div>
140
149
 
141
- <h2>10. Optimization recommendations <span class="badge rec">Recommendation</span></h2>
142
- {table(["Recommendation","Actual cost","Est. alternative","Est. saving","Confidence"],
143
- [(html.escape(r['title']), _f(r.get('actual_cost_usd')), _f(r.get('estimated_alternative_cost_usd')),
144
- f"{_f(r.get('estimated_savings_usd'))} ({r.get('estimated_savings_pct')}%)",
145
- r.get('confidence','')) for r in rc['recommendations']]) if rc['recommendations'] else '<p>No recommendation met the evidence threshold.</p>'}
146
- <div class="note">Estimated savings assume identical token usage on the alternative and do not model
147
- output quality. They are opportunities to evaluate, not booked savings.</div>
150
+ <h2>11. Optimization recommendations <span class="badge rec">Recommendation</span></h2>
151
+ {table(["Recommendation","Spend involved","Basis"],
152
+ [(html.escape(r['title']), _f(r.get('actual_cost_usd')), html.escape(r.get('basis','')))
153
+ for r in rc['recommendations']]) if rc['recommendations'] else '<p>No recommendation met the evidence threshold.</p>'}
154
+ <div class="note">Observations only. No saving is estimated: what an alternative would have cost is a counterfactual.</div>
148
155
 
149
- <h2>11. Forecast <span class="badge fc">Forecast</span></h2>"""]
156
+ <h2>12. Forecast <span class="badge fc">Forecast</span></h2>"""]
150
157
 
151
158
  if fc.get("available"):
152
159
  parts.append(f"""<p>Method: {html.escape(fc['method'])} over {fc['sample_days']} days.
153
160
  Period to date {_f(fc['period_used'])} with {fc['remaining_days']} days remaining.</p>
154
161
  {table(["Scenario","Daily rate","Projected end of period"],
155
162
  [(k.title(), _f(v['daily_rate']), _f(v['end_of_period_cost'])) for k, v in fc['scenarios'].items()])}
163
+ {'<p class="note">Fewer than 7 priced days in the window: bands not shown.</p>' if fc.get('insufficient_history') else ''}
156
164
  {table(["Horizon","Projection"], [
157
- ("End of day", _f(fc['end_of_day_cost'])),
158
- ("End of week", _f(fc['end_of_week_cost'])),
159
165
  ("End of billing period (expected)", _f(fc['scenarios']['expected']['end_of_period_cost'])),
160
166
  ("Limit exhaustion date", html.escape(str(fc['limit_exhaustion_date']))),
161
167
  ])}""")
@@ -163,7 +169,7 @@ Period to date {_f(fc['period_used'])} with {fc['remaining_days']} days remainin
163
169
  parts.append("<p>Not enough data in range to forecast.</p>")
164
170
 
165
171
  parts.append(f"""
166
- <h2>12. Data provenance</h2>
172
+ <h2>13. Data provenance</h2>
167
173
  {table(["Field","Value"], [
168
174
  ("Source", html.escape(str(a.meta.get('source_dir')))),
169
175
  ("Transcript files parsed", html.escape(str(a.meta.get('transcript_files')))),
@@ -0,0 +1,149 @@
1
+ """Cache segments and cache-TTL replay.
2
+
3
+ A prompt cache belongs to one model and one continuous run of turns. The moment the
4
+ run breaks — a new session, a subagent, a model change — the next turn pays to write
5
+ the whole prefix again. Those breaks are the only places a model switch is free, so
6
+ they are the unit this module produces.
7
+
8
+ Everything here is arithmetic over token counts and timestamps that the transcripts
9
+ already carry. No behavioural assumption is made about what a different model would
10
+ have done, which is what separates these numbers from the model-switch estimates.
11
+
12
+ Boundaries this can see:
13
+ * session start
14
+ * subagent (sidechain) start
15
+ * a model change between consecutive turns
16
+ * a compaction: context dropping by more than half (auto-compaction is not
17
+ recorded directly, but this is its trace; a typed /compact is recorded too)
18
+ """
19
+ from datetime import datetime
20
+
21
+ # Multipliers are derived per model from the price table rather than hardcoded: the
22
+ # 0.1x read / 1.25x 5m / 2.0x 1h shape holds for Anthropic, but Gemini reads at 0.25x
23
+ # and OpenAI writes at 1.0x, so a fixed constant would quietly corrupt those agents.
24
+ READ = "cache_read"
25
+ W5M = "cache_write_5m"
26
+ W1H = "cache_write_1h"
27
+
28
+
29
+ def parse_ts(ts):
30
+ try:
31
+ return datetime.fromisoformat((ts or "").replace("Z", "+00:00"))
32
+ except (ValueError, AttributeError):
33
+ return None
34
+
35
+
36
+ def multipliers(pricing, model):
37
+ """(read, write_5m, write_1h) as multiples of the model's base input price."""
38
+ r = pricing.rates(model) or {}
39
+ base = float(r.get("input") or 0)
40
+ if not base:
41
+ return 0.0, 0.0, 0.0
42
+ g = lambda k: float(r.get(k) or 0) / base
43
+ return g(READ), g(W5M), g(W1H)
44
+
45
+
46
+ def is_compaction(prev_ctx, ctx, threshold=100_000):
47
+ """A context drop of more than half from above `threshold` is a compaction or a
48
+ /clear. Claude Code does not record auto-compaction; this is the only trace."""
49
+ return bool(prev_ctx) and prev_ctx >= threshold and ctx < prev_ctx * 0.5
50
+
51
+
52
+ def split_segments(turns):
53
+ """Split one session's turns into cache segments.
54
+
55
+ `turns` must be ordered by timestamp and carry: session_id, model, is_sidechain,
56
+ agent_id. Returns a list of lists, preserving order.
57
+ """
58
+ segments, current, prev = [], [], None
59
+ for t in turns:
60
+ boundary = (
61
+ prev is None
62
+ or t.get("session_id") != prev.get("session_id")
63
+ or t.get("model") != prev.get("model")
64
+ # a sidechain turn runs against its own prefix; entering or leaving one,
65
+ # or moving between two different subagents, rebuilds the cache
66
+ or (t.get("agent_id") or None) != (prev.get("agent_id") or None)
67
+ or bool(t.get("is_sidechain")) != bool(prev.get("is_sidechain"))
68
+ or is_compaction(prev.get("ctx") if prev else None, t.get("ctx"))
69
+ )
70
+ if boundary and current:
71
+ segments.append(current)
72
+ current = []
73
+ current.append(t)
74
+ prev = t
75
+ if current:
76
+ segments.append(current)
77
+ return segments
78
+
79
+
80
+ def ttl_cost(segment, ttl_minutes, write_mult, pricing):
81
+ """Replay one segment's cost under a given cache TTL.
82
+
83
+ Within the TTL the prefix is still resident: the turn pays the read rate for it and
84
+ the write rate only for the delta it adds. Past the TTL the prefix is gone and the
85
+ whole thing is written again. A turn refreshes the TTL whether it read or wrote.
86
+ """
87
+ total = 0.0
88
+ last_cache_time = None
89
+ for t in segment:
90
+ # Price with the list that actually applied (a long-context variant bills at a
91
+ # premium), while segmentation keys off the plain model name, since that is what
92
+ # identifies the cache.
93
+ model = t.get("priced_as") or t.get("model")
94
+ rates = pricing.rates(model) or {}
95
+ base = float(rates.get("input") or 0) / 1e6
96
+ out_rate = float(rates.get("output") or 0) / 1e6
97
+ read_m, _, _ = multipliers(pricing, model)
98
+
99
+ prefix = t.get("cache_read_tokens") or 0
100
+ delta = (t.get("cache_write_5m") or 0) + (t.get("cache_write_1h") or 0)
101
+ when = parse_ts(t.get("ts"))
102
+ gap = None
103
+ if last_cache_time and when:
104
+ gap = (when - last_cache_time).total_seconds() / 60.0
105
+
106
+ if gap is not None and gap > ttl_minutes:
107
+ # The prefix expired between turns, so it has to be written again before it
108
+ # can be read. Charged on top of the delta this turn added.
109
+ total += prefix * write_mult * base
110
+ total += delta * write_mult * base
111
+ else:
112
+ # Either still inside the TTL, or the first turn we can see — where the
113
+ # tokens themselves say what happened, so they are charged as recorded
114
+ # rather than assumed cold. A segment often opens warm (a resumed session
115
+ # reads a prefix it did not pay to write inside this segment).
116
+ total += prefix * read_m * base
117
+ total += delta * write_mult * base
118
+
119
+ total += (t.get("input_tokens") or 0) * base
120
+ total += (t.get("output_tokens") or 0) * out_rate
121
+ if when:
122
+ last_cache_time = when
123
+ return total
124
+
125
+
126
+ def replay(segments, pricing, tolerance=0.05):
127
+ """Compare the actual 1h TTL against a 5m TTL across every segment.
128
+
129
+ Reconciles the 1h replay against the cost actually logged first. A replay that
130
+ cannot reproduce the real bill is not evidence about a counterfactual one, so the
131
+ caller is handed `reconciled=False` and should show nothing.
132
+ """
133
+ actual = at_1h = at_5m = 0.0
134
+ for seg in segments:
135
+ actual += sum((t.get("est_cost_usd") or 0) for t in seg)
136
+ at_1h += ttl_cost(seg, 60, 2.0, pricing)
137
+ at_5m += ttl_cost(seg, 5, 1.25, pricing)
138
+ drift = abs(at_1h - actual) / actual if actual else 0.0
139
+ return {
140
+ "segments": len(segments),
141
+ "logged_cost_usd": actual,
142
+ "replay_1h_usd": at_1h,
143
+ "replay_5m_usd": at_5m,
144
+ "difference_usd": at_1h - at_5m, # positive => 5m is cheaper
145
+ "cheaper_ttl": "5m" if at_5m < at_1h else "1h",
146
+ "reconciliation_drift_pct": round(100 * drift, 2),
147
+ "reconciled": drift <= tolerance,
148
+ "basis": "arithmetic",
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.7.2",
3
+ "version": "0.9.0",
4
4
  "description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
5
5
  "bin": {
6
6
  "claude-finops": "bin/claude-finops.js"
package/run.cmd CHANGED
@@ -1,4 +1,5 @@
1
1
  @echo off
2
- rem Claude FinOps Command Center (Windows). Same as: python run.py [--rebuild|--stop|--share]
2
+ rem Claude FinOps Command Center (Windows). Same as: python run.py [--rebuild|--stop]
3
3
  cd /d "%~dp0"
4
- where py >nul 2>nul && (py -3 run.py %*) || (python run.py %*)
4
+ where py >nul 2>nul
5
+ if %errorlevel%==0 (py -3 "%~dp0run.py" %*) else (python "%~dp0run.py" %*)
package/run.py CHANGED
@@ -4,7 +4,6 @@
4
4
  python3 run.py build the warehouse if missing, then serve
5
5
  python3 run.py --rebuild re-read transcripts first
6
6
  python3 run.py --stop stop a running dashboard
7
- python3 run.py --share write ../claude-finops.zip (code + defaults, never your data)
8
7
  python3 run.py --set-key store a provider API key
9
8
  python3 run.py --where print where your data and keys live
10
9
  python3 run.py --help all commands
@@ -18,7 +17,6 @@ import signal
18
17
  import subprocess
19
18
  import sys
20
19
  import time
21
- import zipfile
22
20
 
23
21
  ROOT = os.path.dirname(os.path.abspath(__file__))
24
22
  sys.path.insert(0, ROOT)
@@ -91,7 +89,9 @@ def _is_ours(pid):
91
89
  try:
92
90
  if IS_WIN:
93
91
  out = subprocess.run(
94
- ["wmic", "process", "where", f"ProcessId={pid}", "get", "CommandLine"],
92
+ ["powershell", "-NoProfile", "-Command",
93
+ f'Get-CimInstance Win32_Process -Filter "ProcessId={pid}" | '
94
+ "Select-Object -ExpandProperty CommandLine"],
95
95
  capture_output=True, text=True, timeout=10).stdout
96
96
  else:
97
97
  out = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
@@ -142,25 +142,6 @@ def stop_pidfile():
142
142
  return False
143
143
 
144
144
 
145
- def share(out=None):
146
- out = out or os.path.join(os.path.dirname(ROOT), "claude-finops.zip")
147
- skip_dirs = {"data", "__pycache__", ".git"}
148
- with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
149
- for base, dirs, files in os.walk(ROOT):
150
- dirs[:] = [d for d in dirs if d not in skip_dirs]
151
- for f in files:
152
- if f.endswith((".pyc", ".zip", ".tgz")) or f in ("settings.local.json", "secrets.local.json"):
153
- continue
154
- full = os.path.join(base, f)
155
- arc = os.path.join("claude-finops", os.path.relpath(full, ROOT)).replace(os.sep, "/")
156
- info = zipfile.ZipInfo.from_file(full, arc)
157
- info.external_attr = (0o755 if f.endswith((".sh", ".py")) else 0o644) << 16
158
- info.compress_type = zipfile.ZIP_DEFLATED
159
- with open(full, "rb") as fh:
160
- z.writestr(info, fh.read())
161
- print(f"Wrote {out} (code + default config only; your data/ and local settings are excluded)")
162
-
163
-
164
145
  HELP = """Claude FinOps Command Center
165
146
 
166
147
  claude-finops start the dashboard (builds the warehouse first run)
@@ -169,11 +150,10 @@ HELP = """Claude FinOps Command Center
169
150
  claude-finops --where print where your data, settings and keys live
170
151
  claude-finops --set-key store a provider API key (prompts, never echoes)
171
152
  claude-finops --keys list which provider keys are configured
172
- claude-finops --share write ../claude-finops.zip (code only, never your data)
173
153
  claude-finops --version print the installed version, and whether a newer one is out
174
- claude-finops --advise what to switch to in the sessions running right now
175
- claude-finops --install-hook suggest a cheaper model in Claude Code, as you send each prompt
176
- claude-finops --install-statusline show model, context and advice in your statusline
154
+ claude-finops --no-update-check skip the once-a-day npm version check
155
+ claude-finops --install-hook retired no-op (prints a message, changes nothing)
156
+ claude-finops --install-statusline show model and context % in your statusline
177
157
  claude-finops --help this message
178
158
 
179
159
  Environment:
@@ -182,31 +162,10 @@ Environment:
182
162
  CLAUDE_PROJECTS=/path where to read transcripts from
183
163
  CLAUDE_FINOPS_PYTHON=/path which Python the npm wrapper should use
184
164
  NO_UPDATE_NOTIFIER=1 never check npm for a newer release
165
+ CLAUDE_FINOPS_NO_UPDATE_CHECK=1 same as --no-update-check
185
166
  """
186
167
 
187
168
 
188
- def advise_now():
189
- """What every session running right now should consider switching to."""
190
- from finops.advisor import advise, evidence
191
- from finops.procs import list_sessions
192
- from finops.analytics import Analytics
193
- ev = evidence()
194
- if not ev:
195
- return print("No model evidence yet — you need two models run on the same kind of "
196
- "work before there is anything to compare.")
197
- rows = list_sessions(Analytics(DB_PATH).pricing)
198
- if not rows:
199
- return print("No Claude Code sessions are running.")
200
- for r in rows:
201
- a = advise(model=r.get("model"), transcript=r.get("transcript"), ev=ev)
202
- head = f" {r.get('project') or r.get('session_id') or 'session'}"
203
- if a:
204
- print(f"{head}: {a['line']}")
205
- print(f"{' ' * len(head)} run {a['command']}")
206
- else:
207
- print(f"{head}: nothing to change.")
208
-
209
-
210
169
  def _version():
211
170
  from finops.update import _installed
212
171
  return _installed()
@@ -219,16 +178,20 @@ def version():
219
178
  a repo checkout side by side, and the usual confusion is not "what version
220
179
  am I on" but "why does the one I am looking at not have the feature".
221
180
  """
222
- from finops.update import check, disabled, _key
181
+ from finops.update import check, disabled, _key, _installed
182
+ # Print what we know locally first: the network check can be slow, disabled,
183
+ # or simply fail, and none of that should delay the one line people actually
184
+ # came here for.
185
+ print(f" claude-finops {_installed() or 'unknown'}")
186
+ print(f" installed at {ROOT}")
187
+ if disabled():
188
+ print(" update check is off (NO_UPDATE_NOTIFIER)")
189
+ return
223
190
  # Asking outright is worth a fresh request: a day-old cached answer is the
224
191
  # one thing this command must not give you.
225
192
  u = check(force=True)
226
- print(f" claude-finops {u['current'] or 'unknown'}")
227
- print(f" installed at {ROOT}")
228
193
  if u.get("update_available"):
229
194
  print(f" update {u['latest']} is out - {u['command']}")
230
- elif disabled():
231
- print(" update check is off (NO_UPDATE_NOTIFIER)")
232
195
  elif u.get("latest") and _key(u["current"]) > _key(u["latest"]):
233
196
  # A checkout mid-release is ahead of what is published. Saying "up to
234
197
  # date" there would hide exactly the gap you are looking for.
@@ -313,6 +276,8 @@ def main():
313
276
  os.chdir(ROOT)
314
277
  ensure_dirs()
315
278
  migrate()
279
+ if "--no-update-check" in args:
280
+ os.environ["CLAUDE_FINOPS_NO_UPDATE_CHECK"] = "1"
316
281
  if "--help" in args or "-h" in args:
317
282
  return print(HELP)
318
283
  if "--version" in args or "-v" in args or "-V" in args:
@@ -332,8 +297,6 @@ def main():
332
297
  if "--install-statusline" in args or "--uninstall-statusline" in args:
333
298
  from finops.integrate import install_statusline
334
299
  return install_statusline(remove="--uninstall-statusline" in args)
335
- if "--advise" in args:
336
- return advise_now()
337
300
  if "--where" in args:
338
301
  return where()
339
302
  if "--keys" in args:
@@ -344,12 +307,10 @@ def main():
344
307
  if "--stop" in args:
345
308
  print("Stopped." if stop() else "Not running.")
346
309
  return
347
- if "--share" in args:
348
- return share()
349
310
  # A flag we do not know used to fall straight through and start the
350
311
  # dashboard, so a typo (or a flag from a newer release than the one you
351
312
  # have installed) looked like the command silently doing nothing.
352
- known = {"--rebuild", "--detach", "--foreground"}
313
+ known = {"--rebuild", "--detach", "--foreground", "--no-update-check"}
353
314
  unknown = [a for a in args if a.startswith("-") and a not in known]
354
315
  if unknown:
355
316
  print(f"Unknown option: {unknown[0]}")
@@ -381,9 +342,15 @@ def main():
381
342
  else f"lsof -iTCP:{port} -sTCP:LISTEN"))
382
343
  sys.exit(1)
383
344
  source = os.environ.get("CLAUDE_PROJECTS", os.path.join(os.path.expanduser("~"), ".claude", "projects"))
384
- if "--rebuild" in args or not os.path.exists(DB_PATH):
345
+ from finops.etl import needs_rebuild
346
+ db_existed = os.path.exists(DB_PATH)
347
+ rebuild_needed = needs_rebuild(DB_PATH)
348
+ if "--rebuild" in args or rebuild_needed:
385
349
  if not os.path.isdir(source):
386
350
  sys.exit(f"No Claude Code transcripts at {source}. Use Claude Code once, or set CLAUDE_PROJECTS.")
351
+ if db_existed and rebuild_needed and "--rebuild" not in args:
352
+ print("Warehouse schema changed (pricing and request counting were corrected); rebuilding…",
353
+ flush=True)
387
354
  print(f"Building warehouse from {source} …", flush=True)
388
355
  subprocess.run(_python() + ["-m", "finops.etl", source], check=True)
389
356
  rest = [a for a in args if a != "--rebuild"]