claude-finops 0.2.0 → 0.3.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
 
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:
@@ -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.3.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
@@ -160,6 +160,7 @@ function shell() {
160
160
  <span class="spacer"></span>
161
161
  <div class="search"><span class="mag">⌕</span>
162
162
  <input id="gsearch" placeholder="Search prompts, sessions, models, dates…"></div>
163
+ <button class="iconbtn upd" id="upd" hidden></button>
163
164
  <button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
164
165
  <button class="iconbtn" id="theme" title="Toggle theme">◐</button>
165
166
  <button class="iconbtn" id="refresh" title="Reload data">↻</button>
@@ -182,6 +183,7 @@ function shell() {
182
183
  render();
183
184
  };
184
185
  $('#refresh').onclick = () => { bust(); render(); };
186
+ updateChip();
185
187
  $('#sync').onclick = runSync;
186
188
  syncLabel();
187
189
  let t;
@@ -190,6 +192,28 @@ function shell() {
190
192
  else if (S.view === 'search') go('overview'); }, 260); };
191
193
  }
192
194
 
195
+ /* ---------- update notice ----------
196
+ npm cannot push a new release at anyone, so the server asks the registry once
197
+ a day and we surface the answer here. Silent when you are current, when the
198
+ check is switched off, and when it simply could not reach the registry. */
199
+ async function updateChip() {
200
+ const el = $('#upd');
201
+ if (!el) return;
202
+ let u;
203
+ try { u = await fetch('/api/update').then(r => r.json()); } catch { return; }
204
+ if (!u || !u.update_available) return;
205
+ el.hidden = false;
206
+ el.textContent = `↑ v${u.latest} available`;
207
+ el.title = `You are on ${u.current}. Click to copy: ${u.command}`;
208
+ el.onclick = async () => {
209
+ try {
210
+ await navigator.clipboard.writeText(u.command);
211
+ el.textContent = '✓ command copied';
212
+ setTimeout(() => { el.textContent = `↑ v${u.latest} available`; }, 2200);
213
+ } catch { prompt('Run this to upgrade:', u.command); }
214
+ };
215
+ }
216
+
193
217
  /* ---------- filter bar ---------- */
194
218
  const RANGES = [['today', 'Today'], ['7d', '7 days'], ['14d', '14 days'], ['30d', '30 days'],
195
219
  ['period', 'Billing period'], ['all', 'All time'], ['custom', 'Custom']];
package/web/styles.css CHANGED
@@ -385,3 +385,13 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
385
385
  #tour .tour-ft .spacer { flex: 1; }
386
386
  #tour .act.primary { background: var(--accent, #eb6834); border-color: var(--accent, #eb6834); color: #fff; }
387
387
  #tour .act[disabled] { opacity: .45; cursor: default; }
388
+
389
+ /* Update notice in the topbar: present, never alarming — this is good news,
390
+ not a problem, so it borrows the accent colour rather than the warning one. */
391
+ .iconbtn.upd {
392
+ border-color: var(--accent, #eb6834);
393
+ color: var(--accent, #eb6834);
394
+ font-weight: 600;
395
+ white-space: nowrap;
396
+ }
397
+ .iconbtn.upd:hover { background: var(--accent, #eb6834); color: #fff; }