claude-finops 0.2.0 → 0.4.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/README.md CHANGED
@@ -290,8 +290,15 @@ place for you to delete once you are happy.
290
290
  ## Privacy
291
291
 
292
292
  `~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
293
- server binds to `127.0.0.1` only and makes no outbound requests, but treat the
294
- database and any export you generate as sensitive.
293
+ server binds to `127.0.0.1` only, but treat the database and any export you
294
+ generate as sensitive.
295
+
296
+ The app makes exactly one outbound request of its own: once a day it asks
297
+ `registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
298
+ you when an upgrade is out (npm has no way to push one at you). It sends nothing
299
+ about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
300
+ `CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Provider cost APIs are called only if you
301
+ configure a key with `--set-key`.
295
302
 
296
303
  ---
297
304
 
@@ -30,22 +30,57 @@ def _merge(base, over):
30
30
  return base
31
31
 
32
32
 
33
+ # claude_max -> "Max", claude_pro -> "Pro": the tier string carries a 5x/20x
34
+ # suffix we keep, because which Max you are on changes every limit in the app.
35
+ _PLANS = {"claude_max": "Max", "claude_pro": "Pro", "claude_team": "Team",
36
+ "claude_enterprise": "Enterprise"}
37
+
38
+
39
+ def _plan(acct):
40
+ base = _PLANS.get(acct.get("organizationType") or "")
41
+ mult = ""
42
+ tier = str(acct.get("organizationRateLimitTier") or "")
43
+ if base == "Max":
44
+ for m in ("5x", "20x"):
45
+ if tier.endswith(m):
46
+ mult = " " + m
47
+ return (base + mult) if base else ""
48
+
49
+
33
50
  def detect_account():
34
- """The signed-in Claude Code account, read from ~/.claude.json (actual, not guessed)."""
51
+ """Who Claude Code is signed in as, read from ~/.claude.json (actual, not guessed).
52
+
53
+ Everything here is already on this machine, written by Claude Code itself at
54
+ login. We only surface it, so a shared screenshot says whose numbers these
55
+ are — a dashboard with no name on it is the one people misread.
56
+ """
35
57
  try:
36
58
  with open(os.path.expanduser("~/.claude.json")) as fh:
37
59
  acct = json.load(fh).get("oauthAccount") or {}
38
60
  except (OSError, ValueError):
39
61
  return {}
40
- return {"label": acct.get("emailAddress")} if acct.get("emailAddress") else {}
62
+ email = acct.get("emailAddress") or ""
63
+ name = acct.get("fullName") or acct.get("displayName") or ""
64
+ org = acct.get("organizationName") or ""
65
+ out = {"name": name, "email": email,
66
+ # A personal plan names the org after the person; repeating it is noise.
67
+ "org": "" if org == name else org,
68
+ "plan": _plan(acct)}
69
+ if email:
70
+ out["label"] = email
71
+ return {k: v for k, v in out.items() if v}
41
72
 
42
73
 
43
74
  def load_settings():
44
75
  """Shared defaults (settings.json) + this machine's overrides (settings.local.json)."""
45
76
  with open(SETTINGS_PATH) as fh:
46
77
  cur = json.load(fh)
47
- if not cur.get("account", {}).get("label"):
48
- cur.setdefault("account", {}).update(detect_account())
78
+ # Detected identity first, so a configured settings.json still wins below.
79
+ detected = detect_account()
80
+ acct = cur.setdefault("account", {})
81
+ for k, v in detected.items():
82
+ if not acct.get(k):
83
+ acct[k] = v
49
84
  if os.path.exists(LOCAL_SETTINGS_PATH):
50
85
  with open(LOCAL_SETTINGS_PATH) as fh:
51
86
  _merge(cur, json.load(fh))
package/finops/api.py CHANGED
@@ -226,6 +226,9 @@ class Handler(BaseHTTPRequestHandler):
226
226
  self.send_json({"error": traceback.format_exc()}, 500)
227
227
 
228
228
  def api(self, route, qs):
229
+ if route == "update":
230
+ from .update import check
231
+ return self.send_json(check(force=qs.get("refresh", [""])[0] == "1"))
229
232
  if route == "usage":
230
233
  from .limits import usage
231
234
  return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
@@ -406,6 +409,11 @@ def _sync_job(log):
406
409
  return {"built_at": A.q("SELECT value FROM meta WHERE key='built_at'")[0]["value"]}
407
410
 
408
411
 
412
+ def _notify_update():
413
+ from .update import notify
414
+ notify()
415
+
416
+
409
417
  def serve(port=8787, db=DB_PATH, background=None):
410
418
  global A
411
419
  if not os.path.exists(db):
@@ -420,6 +428,8 @@ def serve(port=8787, db=DB_PATH, background=None):
420
428
  print(f" warehouse: {db}")
421
429
  print(f" data : {A.first_day} .. {A.last_day}")
422
430
  print(" Ctrl-C to stop.")
431
+ # Off the main thread: a slow registry must never delay the dashboard.
432
+ threading.Thread(target=_notify_update, daemon=True).start()
423
433
  try:
424
434
  srv.serve_forever()
425
435
  except KeyboardInterrupt:
package/finops/report.py CHANGED
@@ -3,6 +3,15 @@ import html
3
3
  from datetime import datetime, timezone
4
4
 
5
5
 
6
+ def _who(acct):
7
+ """"Mohit Raj Purohit <mohit@example.com>" when we know both, else whichever
8
+ one we have. A report that leaves the house should name its account."""
9
+ name, email = acct.get("name") or "", acct.get("email") or acct.get("label") or ""
10
+ if name and email:
11
+ return f"{name} <{email}>"
12
+ return name or email or "unknown"
13
+
14
+
6
15
  def _f(v, kind="usd"):
7
16
  if v is None:
8
17
  return "&mdash;"
@@ -52,7 +61,7 @@ td:nth-child(n+2) {{ font-variant-numeric: tabular-nums; }}
52
61
  ul {{ margin:6px 0 12px 18px; padding:0; }} li {{ margin-bottom:4px; }}
53
62
  </style></head><body>
54
63
  <h1>Claude AI FinOps Report</h1>
55
- <div class="sub">Account {html.escape(str(a.settings['account'].get('label')))} &middot;
64
+ <div class="sub">Account {html.escape(_who(a.settings['account']))} &middot;
56
65
  Billing period {bp['start']} &rarr; {bp['end']} &middot;
57
66
  Data {ov['date_range']['first']} &ndash; {ov['date_range']['last']} &middot;
58
67
  Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}</div>
@@ -0,0 +1,133 @@
1
+ """Is there a newer release on npm?
2
+
3
+ npm has no way to push: a global install stays on whatever version it was
4
+ installed at until someone runs `npm i -g claude-finops@latest`. So we ask the
5
+ registry ourselves, once a day, and say so if there is something newer.
6
+
7
+ This is the only outbound request the app makes on its own. It sends nothing
8
+ about you — no identifiers, no usage, not even a User-Agent beyond the package
9
+ name and the version you already published to npm by installing it. Turn it off
10
+ with NO_UPDATE_NOTIFIER=1 (the ecosystem-wide convention) or
11
+ CLAUDE_FINOPS_NO_UPDATE_CHECK=1.
12
+
13
+ The answer is cached in ~/.claude-finops/data/update_cache.json for a day, so a
14
+ dashboard that restarts twenty times makes one request. Every failure is
15
+ silent: no network, a proxy, an offline laptop and a 500 from the registry all
16
+ look the same to the caller, which is "we don't know", never an error.
17
+ """
18
+ import json
19
+ import os
20
+ import re
21
+ import time
22
+ import urllib.request
23
+
24
+ from .paths import DATA_DIR, ROOT, ensure_dirs
25
+
26
+ CACHE = os.path.join(DATA_DIR, "update_cache.json")
27
+ URL = "https://registry.npmjs.org/claude-finops/latest"
28
+ TTL_S = 24 * 60 * 60
29
+ TIMEOUT_S = 3
30
+
31
+ _NUM = re.compile(r"\d+")
32
+
33
+
34
+ def _installed():
35
+ """Our own version, from the package.json we ship next to the code."""
36
+ try:
37
+ with open(os.path.join(ROOT, "package.json")) as fh:
38
+ return str(json.load(fh).get("version") or "").strip()
39
+ except (OSError, ValueError):
40
+ return ""
41
+
42
+
43
+ def _key(v):
44
+ """Compare 1.10.0 above 1.9.0, and treat 1.0.0-rc.1 as below 1.0.0.
45
+
46
+ Only the numeric core is ordered; a prerelease suffix just loses the tie.
47
+ That is enough for a notifier — we publish plain releases.
48
+ """
49
+ core, _, pre = str(v).partition("-")
50
+ nums = [int(n) for n in _NUM.findall(core)[:3]]
51
+ return (nums + [0, 0, 0])[:3], 0 if pre else 1
52
+
53
+
54
+ def _newer(latest, current):
55
+ return bool(latest) and bool(current) and _key(latest) > _key(current)
56
+
57
+
58
+ def _read_cache():
59
+ try:
60
+ with open(CACHE) as fh:
61
+ c = json.load(fh)
62
+ return c if isinstance(c, dict) else {}
63
+ except (OSError, ValueError):
64
+ return {}
65
+
66
+
67
+ def _write_cache(latest):
68
+ ensure_dirs()
69
+ tmp = CACHE + ".tmp"
70
+ try:
71
+ with open(tmp, "w") as fh:
72
+ json.dump({"latest": latest, "checked_at": int(time.time())}, fh)
73
+ os.replace(tmp, CACHE)
74
+ except OSError:
75
+ pass
76
+
77
+
78
+ def _fetch():
79
+ req = urllib.request.Request(URL, headers={
80
+ "Accept": "application/json",
81
+ "User-Agent": f"claude-finops/{_installed() or '0'}",
82
+ })
83
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S) as r:
84
+ return str(json.load(r).get("version") or "").strip()
85
+
86
+
87
+ def disabled():
88
+ return bool(os.environ.get("NO_UPDATE_NOTIFIER")
89
+ or os.environ.get("CLAUDE_FINOPS_NO_UPDATE_CHECK"))
90
+
91
+
92
+ def check(force=False):
93
+ """Return {current, latest, update_available, checked_at, ...}.
94
+
95
+ Reads the cache unless it is older than a day (or `force`). Never raises,
96
+ and never blocks longer than TIMEOUT_S.
97
+ """
98
+ current = _installed()
99
+ out = {"current": current, "latest": "", "update_available": False,
100
+ "command": "npm i -g claude-finops@latest", "checked_at": 0,
101
+ "disabled": disabled()}
102
+ if out["disabled"]:
103
+ return out
104
+
105
+ cache = _read_cache()
106
+ age = time.time() - float(cache.get("checked_at") or 0)
107
+ if cache.get("latest") and age < TTL_S and not force:
108
+ out.update(latest=cache["latest"], checked_at=int(cache["checked_at"]), cached=True)
109
+ else:
110
+ try:
111
+ latest = _fetch()
112
+ except Exception:
113
+ # Offline, blocked, rate-limited — fall back to the last good answer
114
+ # rather than telling anyone anything is wrong.
115
+ latest = str(cache.get("latest") or "")
116
+ out["checked_at"] = int(cache.get("checked_at") or 0)
117
+ out["stale"] = True
118
+ else:
119
+ _write_cache(latest)
120
+ out["checked_at"] = int(time.time())
121
+ out["latest"] = latest
122
+ out["update_available"] = _newer(out["latest"], current)
123
+ return out
124
+
125
+
126
+ def notify(log=print):
127
+ """Print one line if a newer version is out. Safe to call from a thread."""
128
+ try:
129
+ u = check()
130
+ except Exception:
131
+ return
132
+ if u.get("update_available"):
133
+ log(f" update : {u['current']} -> {u['latest']} {u['command']}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.2.0",
3
+ "version": "0.4.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.py CHANGED
@@ -100,6 +100,7 @@ Environment:
100
100
  CLAUDE_FINOPS_HOME=/path where your data lives (default ~/.claude-finops)
101
101
  CLAUDE_PROJECTS=/path where to read transcripts from
102
102
  CLAUDE_FINOPS_PYTHON=/path which Python the npm wrapper should use
103
+ NO_UPDATE_NOTIFIER=1 never check npm for a newer release
103
104
  """
104
105
 
105
106
 
package/web/app.js CHANGED
@@ -151,7 +151,8 @@ function shell() {
151
151
  <nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
152
152
  items.map(([id, ic, label]) =>
153
153
  `<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${ic}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
154
- </nav></aside>
154
+ </nav>
155
+ <div class="who" id="who"></div></aside>
155
156
  <div class="main">
156
157
  <header class="topbar">
157
158
  <div class="r1">
@@ -160,6 +161,7 @@ function shell() {
160
161
  <span class="spacer"></span>
161
162
  <div class="search"><span class="mag">⌕</span>
162
163
  <input id="gsearch" placeholder="Search prompts, sessions, models, dates…"></div>
164
+ <button class="iconbtn upd" id="upd" hidden></button>
163
165
  <button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
164
166
  <button class="iconbtn" id="theme" title="Toggle theme">◐</button>
165
167
  <button class="iconbtn" id="refresh" title="Reload data">↻</button>
@@ -182,6 +184,7 @@ function shell() {
182
184
  render();
183
185
  };
184
186
  $('#refresh').onclick = () => { bust(); render(); };
187
+ updateChip();
185
188
  $('#sync').onclick = runSync;
186
189
  syncLabel();
187
190
  let t;
@@ -190,6 +193,28 @@ function shell() {
190
193
  else if (S.view === 'search') go('overview'); }, 260); };
191
194
  }
192
195
 
196
+ /* ---------- update notice ----------
197
+ npm cannot push a new release at anyone, so the server asks the registry once
198
+ a day and we surface the answer here. Silent when you are current, when the
199
+ check is switched off, and when it simply could not reach the registry. */
200
+ async function updateChip() {
201
+ const el = $('#upd');
202
+ if (!el) return;
203
+ let u;
204
+ try { u = await fetch('/api/update').then(r => r.json()); } catch { return; }
205
+ if (!u || !u.update_available) return;
206
+ el.hidden = false;
207
+ el.textContent = `↑ v${u.latest} available`;
208
+ el.title = `You are on ${u.current}. Click to copy: ${u.command}`;
209
+ el.onclick = async () => {
210
+ try {
211
+ await navigator.clipboard.writeText(u.command);
212
+ el.textContent = '✓ command copied';
213
+ setTimeout(() => { el.textContent = `↑ v${u.latest} available`; }, 2200);
214
+ } catch { prompt('Run this to upgrade:', u.command); }
215
+ };
216
+ }
217
+
193
218
  /* ---------- filter bar ---------- */
194
219
  const RANGES = [['today', 'Today'], ['7d', '7 days'], ['14d', '14 days'], ['30d', '30 days'],
195
220
  ['period', 'Billing period'], ['all', 'All time'], ['custom', 'Custom']];
@@ -2199,9 +2224,27 @@ const navAllowed = scope => !scope || (scope === 'claude' ? hasClaude()
2199
2224
  : scope === 'cloud' ? hasCloudKey() : hasPriced());
2200
2225
  // "Claude Code", "Codex", or "agent" for a mixed selection: used in data-availability text
2201
2226
  const agentWord = () => { const a = selAgents(); return a.length === 1 ? a[0].name : 'agent'; };
2227
+ // Whose usage this is. Read from ~/.claude.json by the server, so a screenshot
2228
+ // or a shared dashboard always says which account the numbers belong to.
2229
+ function whoBlock() {
2230
+ const el = $('#who'), a = S.opts?.settings?.account || {};
2231
+ if (!el) return;
2232
+ if (!a.name && !a.email) { el.innerHTML = ''; return; }
2233
+ const initials = (a.name || a.email || '?').split(/[\s@.]+/).filter(Boolean)
2234
+ .slice(0, 2).map(w => w[0].toUpperCase()).join('');
2235
+ el.innerHTML = `<div class="av">${esc(initials)}</div>
2236
+ <div class="id">
2237
+ <div class="nm">${esc(a.name || a.email)}</div>
2238
+ <div class="em" title="${esc(a.email || '')}">${esc(a.email || '')}</div>
2239
+ ${a.plan || a.org ? `<div class="pl">${esc([a.plan, a.org].filter(Boolean).join(' · '))}</div>` : ''}
2240
+ </div>`;
2241
+ el.title = `Claude Code is signed in as ${a.name || ''} <${a.email || ''}>`.trim();
2242
+ }
2243
+
2202
2244
  function applyAgentChrome() {
2203
2245
  const a = selAgents();
2204
2246
  const label = a.length === 1 ? a[0].name.replace(/ (Code|CLI)$/, '') : a.length ? 'Multi-agent' : 'AI';
2247
+ whoBlock();
2205
2248
  const mark = $('.brand .mark');
2206
2249
  if (mark) mark.innerHTML = `<span class="dot"></span>${esc(label)} FinOps`;
2207
2250
  const sub = $('.brand .sub');
package/web/styles.css CHANGED
@@ -77,7 +77,8 @@ button, input, select { font: inherit; color: inherit; }
77
77
  /* ---------- shell ---------- */
78
78
  .app { display: grid; grid-template-columns: 216px 1fr; min-height: 100vh; }
79
79
  .sidebar { background: var(--surface); border-right: 1px solid var(--border);
80
- padding: 14px 0; position: sticky; top:0; height:100vh; overflow-y:auto; }
80
+ padding: 14px 0; position: sticky; top:0; height:100vh; overflow-y:auto;
81
+ display:flex; flex-direction:column; }
81
82
  .brand { padding: 0 16px 14px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
82
83
  .brand .mark { display:flex; align-items:center; gap:8px; font-weight:660; font-size:14px;
83
84
  letter-spacing:-0.01em; }
@@ -385,3 +386,31 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
385
386
  #tour .tour-ft .spacer { flex: 1; }
386
387
  #tour .act.primary { background: var(--accent, #eb6834); border-color: var(--accent, #eb6834); color: #fff; }
387
388
  #tour .act[disabled] { opacity: .45; cursor: default; }
389
+
390
+ /* Update notice in the topbar: present, never alarming — this is good news,
391
+ not a problem, so it borrows the accent colour rather than the warning one. */
392
+ .iconbtn.upd {
393
+ border-color: var(--accent, #eb6834);
394
+ color: var(--accent, #eb6834);
395
+ font-weight: 600;
396
+ white-space: nowrap;
397
+ }
398
+ .iconbtn.upd:hover { background: var(--accent, #eb6834); color: #fff; }
399
+
400
+ /* Whose account this dashboard is reading — pinned to the foot of the sidebar,
401
+ so every screenshot carries the name of the account it came from. */
402
+ /* Sticky, because the nav is taller than the viewport and an identity you have
403
+ to scroll to find is one nobody checks. */
404
+ .who { margin-top:auto; position:sticky; bottom:0; z-index:2;
405
+ display:flex; align-items:center; gap:9px;
406
+ padding:12px 16px; background:var(--surface);
407
+ border-top:1px solid var(--border); }
408
+ .who:empty { display:none; }
409
+ .who .av { flex:none; width:26px; height:26px; border-radius:50%; display:grid;
410
+ place-items:center; font-size:10.5px; font-weight:700; color:#fff;
411
+ background: var(--accent, #eb6834); }
412
+ .who .id { min-width:0; }
413
+ .who .nm { font-size:11.5px; font-weight:620; line-height:1.25;
414
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
415
+ .who .em, .who .pl { font-size:10px; color:var(--muted); line-height:1.35;
416
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }