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 +9 -2
- package/finops/api.py +10 -0
- package/finops/update.py +133 -0
- package/package.json +1 -1
- package/run.py +1 -0
- package/web/app.js +24 -0
- package/web/styles.css +10 -0
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
|
|
294
|
-
|
|
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:
|
package/finops/update.py
ADDED
|
@@ -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.
|
|
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; }
|