claude-finops 0.1.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 +20 -3
- package/finops/limits.py +105 -0
- package/finops/procs.py +160 -0
- package/finops/update.py +133 -0
- package/package.json +1 -1
- package/run.py +1 -0
- package/web/app.js +64 -3
- 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
|
@@ -177,7 +177,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
177
177
|
return self.send_json({"error": "unknown route"}, 404)
|
|
178
178
|
|
|
179
179
|
def session_action(self, path):
|
|
180
|
-
"""POST /api/live/<pid>/<interrupt|close|kill>.
|
|
180
|
+
"""POST /api/live/<pid>/<interrupt|close|kill|compact|handover>.
|
|
181
181
|
|
|
182
182
|
Guarded against cross-site requests: a custom header can't be sent by another
|
|
183
183
|
origin without a CORS preflight (which this server never approves), and any
|
|
@@ -195,12 +195,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
195
195
|
agent = (self._payload or {}).get("agent") or "claude"
|
|
196
196
|
if agent != "claude":
|
|
197
197
|
from .procs import act_agent
|
|
198
|
-
if parts[3]
|
|
199
|
-
return self.send_json({"ok": False,
|
|
198
|
+
if parts[3] in ("handover", "compact"):
|
|
199
|
+
return self.send_json({"ok": False,
|
|
200
|
+
"error": f"{parts[3].capitalize()} is only available for Claude Code."})
|
|
200
201
|
return self.send_json(act_agent(int(parts[2]), parts[3], agent))
|
|
201
202
|
if parts[3] == "handover":
|
|
202
203
|
from .procs import handover
|
|
203
204
|
return self.send_json(handover(int(parts[2]), self._payload))
|
|
205
|
+
if parts[3] == "compact":
|
|
206
|
+
from .procs import compact
|
|
207
|
+
return self.send_json(compact(int(parts[2]), self._payload))
|
|
204
208
|
return self.send_json(act(int(parts[2]), parts[3]))
|
|
205
209
|
|
|
206
210
|
def do_GET(self):
|
|
@@ -222,6 +226,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
222
226
|
self.send_json({"error": traceback.format_exc()}, 500)
|
|
223
227
|
|
|
224
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"))
|
|
232
|
+
if route == "usage":
|
|
233
|
+
from .limits import usage
|
|
234
|
+
return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
|
|
225
235
|
if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
|
|
226
236
|
return self.actions_get(route)
|
|
227
237
|
f = filters_from(qs)
|
|
@@ -399,6 +409,11 @@ def _sync_job(log):
|
|
|
399
409
|
return {"built_at": A.q("SELECT value FROM meta WHERE key='built_at'")[0]["value"]}
|
|
400
410
|
|
|
401
411
|
|
|
412
|
+
def _notify_update():
|
|
413
|
+
from .update import notify
|
|
414
|
+
notify()
|
|
415
|
+
|
|
416
|
+
|
|
402
417
|
def serve(port=8787, db=DB_PATH, background=None):
|
|
403
418
|
global A
|
|
404
419
|
if not os.path.exists(db):
|
|
@@ -413,6 +428,8 @@ def serve(port=8787, db=DB_PATH, background=None):
|
|
|
413
428
|
print(f" warehouse: {db}")
|
|
414
429
|
print(f" data : {A.first_day} .. {A.last_day}")
|
|
415
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()
|
|
416
433
|
try:
|
|
417
434
|
srv.serve_forever()
|
|
418
435
|
except KeyboardInterrupt:
|
package/finops/limits.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Current plan usage, as `/usage` reports it.
|
|
2
|
+
|
|
3
|
+
Claude Code owns those numbers (5-hour session window and weekly windows); they
|
|
4
|
+
are not in any file on this machine. The one way to read them without an account
|
|
5
|
+
API is to ask Claude Code itself:
|
|
6
|
+
|
|
7
|
+
claude -p "/usage" --output-format json
|
|
8
|
+
|
|
9
|
+
That runs the local slash command, spends no tokens and makes no model call, and
|
|
10
|
+
returns the same text `/usage` prints in the TUI. The reply is parsed here and
|
|
11
|
+
cached briefly so the dashboard never shells out on every render.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from .paths import DATA_DIR, ensure_dirs
|
|
21
|
+
|
|
22
|
+
CACHE = os.path.join(DATA_DIR, "usage_cache.json")
|
|
23
|
+
TTL_S = 120
|
|
24
|
+
TIMEOUT_S = 90
|
|
25
|
+
|
|
26
|
+
# "Current session: 0% used · resets Sep 22 at 2:40pm (Asia/Calcutta)"
|
|
27
|
+
_LIMIT = re.compile(r"^(?P<label>[^:]+?):\s*(?P<pct>\d+(?:\.\d+)?)%\s*used"
|
|
28
|
+
r"(?:\s*[·-]\s*resets\s*(?P<resets>.+?))?\s*$")
|
|
29
|
+
# " 40% of your usage was at >150k context" / " Top skills: /run 2%"
|
|
30
|
+
_WINDOW = re.compile(r"^Last\s+(?P<window>\S+)\s*·\s*(?P<detail>.+)$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse(text):
|
|
34
|
+
limits, windows, cur = [], [], None
|
|
35
|
+
for raw in text.splitlines():
|
|
36
|
+
line = raw.strip()
|
|
37
|
+
if not line:
|
|
38
|
+
continue
|
|
39
|
+
m = _WINDOW.match(line)
|
|
40
|
+
if m:
|
|
41
|
+
cur = {"window": m.group("window"), "detail": m.group("detail"), "notes": []}
|
|
42
|
+
windows.append(cur)
|
|
43
|
+
continue
|
|
44
|
+
m = _LIMIT.match(line)
|
|
45
|
+
if m and line.lower().startswith("current"):
|
|
46
|
+
limits.append({"label": m.group("label").strip(),
|
|
47
|
+
"pct": float(m.group("pct")),
|
|
48
|
+
"resets": (m.group("resets") or "").strip()})
|
|
49
|
+
cur = None
|
|
50
|
+
continue
|
|
51
|
+
if cur is not None and raw.startswith((" ", "\t")):
|
|
52
|
+
cur["notes"].append(line)
|
|
53
|
+
return limits, windows
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run():
|
|
57
|
+
exe = shutil.which("claude")
|
|
58
|
+
if not exe:
|
|
59
|
+
return {"ok": False, "error": "`claude` is not on PATH, so /usage can't be read here."}
|
|
60
|
+
try:
|
|
61
|
+
p = subprocess.run([exe, "-p", "/usage", "--output-format", "json"],
|
|
62
|
+
capture_output=True, text=True, timeout=TIMEOUT_S,
|
|
63
|
+
cwd=os.path.expanduser("~"))
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
return {"ok": False, "error": f"`claude -p /usage` did not answer within {TIMEOUT_S}s."}
|
|
66
|
+
except OSError as e:
|
|
67
|
+
return {"ok": False, "error": f"Could not run `claude`: {e}"}
|
|
68
|
+
if p.returncode != 0:
|
|
69
|
+
return {"ok": False, "error": (p.stderr or p.stdout or "claude exited non-zero").strip()[:400]}
|
|
70
|
+
try:
|
|
71
|
+
text = (json.loads(p.stdout) or {}).get("result") or ""
|
|
72
|
+
except ValueError:
|
|
73
|
+
return {"ok": False, "error": "Could not parse the reply from `claude -p /usage`."}
|
|
74
|
+
if not text.strip():
|
|
75
|
+
return {"ok": False, "error": "`/usage` returned nothing."}
|
|
76
|
+
limits, windows = _parse(text)
|
|
77
|
+
return {"ok": True, "limits": limits, "windows": windows, "text": text,
|
|
78
|
+
"fetched_at": time.time(),
|
|
79
|
+
"note": "Read from Claude Code's own /usage. Percentages are plan limits, not cost; "
|
|
80
|
+
"the contributing-behaviour lines cover local sessions on this machine only."}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def usage(force=False):
|
|
84
|
+
"""Cached /usage. Reads the cache unless it is stale or `force` is set."""
|
|
85
|
+
ensure_dirs()
|
|
86
|
+
cached = None
|
|
87
|
+
try:
|
|
88
|
+
with open(CACHE) as fh:
|
|
89
|
+
cached = json.load(fh)
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
pass
|
|
92
|
+
if cached and cached.get("ok") and not force and time.time() - cached.get("fetched_at", 0) < TTL_S:
|
|
93
|
+
return dict(cached, cached=True, age_s=int(time.time() - cached["fetched_at"]))
|
|
94
|
+
res = _run()
|
|
95
|
+
if res.get("ok"):
|
|
96
|
+
try:
|
|
97
|
+
with open(CACHE, "w") as fh:
|
|
98
|
+
json.dump(res, fh)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
return dict(res, cached=False, age_s=0)
|
|
102
|
+
if cached and cached.get("ok"): # keep showing the last good read
|
|
103
|
+
return dict(cached, cached=True, stale=True, error=res.get("error"),
|
|
104
|
+
age_s=int(time.time() - cached["fetched_at"]))
|
|
105
|
+
return res
|
package/finops/procs.py
CHANGED
|
@@ -497,3 +497,163 @@ def handover(pid, opts):
|
|
|
497
497
|
res["closed"] = act(pid, "close")
|
|
498
498
|
res["message"] += " Old session closed (resumable)."
|
|
499
499
|
return res
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ------------------------------------------------------------------ compact ----
|
|
503
|
+
# /compact is typed *into* the running session — there is no signal for it. Nothing
|
|
504
|
+
# can write to another process's terminal directly (TIOCSTI is off on macOS and on
|
|
505
|
+
# modern Linux), so the keystrokes are handed to whatever owns that terminal:
|
|
506
|
+
# tmux if the session runs inside one, else Terminal.app or iTerm2 via
|
|
507
|
+
# AppleScript, else Windows Terminal/console via SendKeys. When none of those owns
|
|
508
|
+
# it, the UI falls back to copying the command for you to paste.
|
|
509
|
+
|
|
510
|
+
def _tty_of(pid):
|
|
511
|
+
"""Controlling terminal of a pid, as a device path ('/dev/ttys004')."""
|
|
512
|
+
if os.name == "nt":
|
|
513
|
+
return ""
|
|
514
|
+
try:
|
|
515
|
+
t = subprocess.run(["ps", "-o", "tty=", "-p", str(pid)],
|
|
516
|
+
capture_output=True, text=True, timeout=10).stdout.strip()
|
|
517
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
518
|
+
return ""
|
|
519
|
+
if not t or t in ("?", "??", "-"):
|
|
520
|
+
return ""
|
|
521
|
+
return t if t.startswith("/dev/") else "/dev/" + t
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _send_tmux(tty, pid, text):
|
|
525
|
+
if not shutil.which("tmux"):
|
|
526
|
+
return False, ""
|
|
527
|
+
try:
|
|
528
|
+
out = subprocess.run(["tmux", "list-panes", "-a", "-F",
|
|
529
|
+
"#{pane_tty}\t#{pane_pid}\t#{session_name}:#{window_index}.#{pane_index}"],
|
|
530
|
+
capture_output=True, text=True, timeout=10)
|
|
531
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
532
|
+
return False, ""
|
|
533
|
+
if out.returncode != 0:
|
|
534
|
+
return False, ""
|
|
535
|
+
procs = _ps()
|
|
536
|
+
for line in out.stdout.splitlines():
|
|
537
|
+
parts = line.split("\t")
|
|
538
|
+
if len(parts) != 3:
|
|
539
|
+
continue
|
|
540
|
+
pane_tty, pane_pid, target = parts
|
|
541
|
+
if pane_tty != tty and not (pane_pid.isdigit() and int(pane_pid) in _ancestors(pid, procs)):
|
|
542
|
+
continue
|
|
543
|
+
r = subprocess.run(["tmux", "send-keys", "-t", target, "--", text],
|
|
544
|
+
capture_output=True, text=True)
|
|
545
|
+
if r.returncode:
|
|
546
|
+
return False, (r.stderr or "").strip()
|
|
547
|
+
subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], capture_output=True)
|
|
548
|
+
return True, "tmux"
|
|
549
|
+
return False, ""
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _send_macos(tty, text):
|
|
553
|
+
"""Terminal.app / iTerm2 both expose each tab's tty, so the right one is addressable."""
|
|
554
|
+
if sys.platform != "darwin" or not tty:
|
|
555
|
+
return False, ""
|
|
556
|
+
t = json.dumps(text)
|
|
557
|
+
dev = json.dumps(tty)
|
|
558
|
+
osa_terminal = f'''
|
|
559
|
+
tell application "System Events" to set running_ to (exists process "Terminal")
|
|
560
|
+
if running_ then
|
|
561
|
+
tell application "Terminal"
|
|
562
|
+
repeat with w in windows
|
|
563
|
+
repeat with tb in tabs of w
|
|
564
|
+
if (tty of tb) is {dev} then
|
|
565
|
+
do script {t} in tb
|
|
566
|
+
return "ok"
|
|
567
|
+
end if
|
|
568
|
+
end repeat
|
|
569
|
+
end repeat
|
|
570
|
+
end tell
|
|
571
|
+
end if
|
|
572
|
+
return "no"'''
|
|
573
|
+
osa_iterm = f'''
|
|
574
|
+
tell application "System Events" to set running_ to (exists process "iTerm2")
|
|
575
|
+
if running_ then
|
|
576
|
+
tell application "iTerm2"
|
|
577
|
+
repeat with w in windows
|
|
578
|
+
repeat with tb in tabs of w
|
|
579
|
+
repeat with s in sessions of tb
|
|
580
|
+
if (tty of s) is {dev} then
|
|
581
|
+
tell s to write text {t}
|
|
582
|
+
return "ok"
|
|
583
|
+
end if
|
|
584
|
+
end repeat
|
|
585
|
+
end repeat
|
|
586
|
+
end repeat
|
|
587
|
+
end tell
|
|
588
|
+
end if
|
|
589
|
+
return "no"'''
|
|
590
|
+
for script, who in ((osa_terminal, "Terminal.app"), (osa_iterm, "iTerm2")):
|
|
591
|
+
try:
|
|
592
|
+
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=25)
|
|
593
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
594
|
+
continue
|
|
595
|
+
if r.returncode == 0 and r.stdout.strip() == "ok":
|
|
596
|
+
return True, who
|
|
597
|
+
if r.returncode and "not allowed" in (r.stderr or "").lower():
|
|
598
|
+
return False, ("macOS blocked the keystroke: allow this terminal under System Settings → "
|
|
599
|
+
"Privacy & Security → Automation.")
|
|
600
|
+
return False, ""
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _send_windows(pid, text):
|
|
604
|
+
"""Windows: focus the session's console window, then SendKeys into it."""
|
|
605
|
+
if os.name != "nt":
|
|
606
|
+
return False, ""
|
|
607
|
+
ps = ('$ErrorActionPreference="Stop";'
|
|
608
|
+
'Add-Type -AssemblyName Microsoft.VisualBasic;'
|
|
609
|
+
'Add-Type -AssemblyName System.Windows.Forms;'
|
|
610
|
+
f'$p=Get-Process -Id {pid};'
|
|
611
|
+
'$h=$p.MainWindowHandle;'
|
|
612
|
+
'while($h -eq 0 -and $p.Parent){$p=$p.Parent;$h=$p.MainWindowHandle};'
|
|
613
|
+
'if($h -eq 0){"no";exit};'
|
|
614
|
+
'[Microsoft.VisualBasic.Interaction]::AppActivate($p.Id);'
|
|
615
|
+
'Start-Sleep -Milliseconds 300;'
|
|
616
|
+
f'[System.Windows.Forms.SendKeys]::SendWait({json.dumps(text)}+"{{ENTER}}");"ok"')
|
|
617
|
+
try:
|
|
618
|
+
r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
|
|
619
|
+
capture_output=True, text=True, timeout=30)
|
|
620
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
621
|
+
return False, ""
|
|
622
|
+
return (r.returncode == 0 and r.stdout.strip().endswith("ok")), ""
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def compact(pid, opts=None):
|
|
626
|
+
"""Type `/compact` into a running Claude Code session's terminal.
|
|
627
|
+
|
|
628
|
+
opts: instructions – optional focus for the summary ("/compact <instructions>")
|
|
629
|
+
"""
|
|
630
|
+
opts = opts or {}
|
|
631
|
+
extra = " ".join((opts.get("instructions") or "").split())[:400]
|
|
632
|
+
text = "/compact" + (f" {extra}" if extra else "")
|
|
633
|
+
try:
|
|
634
|
+
with open(os.path.join(REG, f"{pid}.json")) as fh:
|
|
635
|
+
reg = json.load(fh)
|
|
636
|
+
except (OSError, ValueError):
|
|
637
|
+
return {"ok": False, "error": "Not a registered Claude Code session.", "copy": text}
|
|
638
|
+
p = _ps().get(pid)
|
|
639
|
+
if not p or not _is_claude(p):
|
|
640
|
+
return {"ok": False, "error": "Process is no longer running.", "copy": text}
|
|
641
|
+
if reg.get("procStart") and reg["procStart"] != p["lstart"]:
|
|
642
|
+
return {"ok": False, "error": "PID was reused by another process; refusing.", "copy": text}
|
|
643
|
+
|
|
644
|
+
tty = _tty_of(pid)
|
|
645
|
+
hint = ""
|
|
646
|
+
for send in (lambda: _send_tmux(tty, pid, text),
|
|
647
|
+
lambda: _send_macos(tty, text),
|
|
648
|
+
lambda: _send_windows(pid, text)):
|
|
649
|
+
ok, info = send()
|
|
650
|
+
if ok:
|
|
651
|
+
return {"ok": True, "message": f"Sent `{text}` to the session"
|
|
652
|
+
f"{f' via {info}' if info else ''}. "
|
|
653
|
+
"It compacts on its next turn; watch that window.",
|
|
654
|
+
"sent": True, "copy": text}
|
|
655
|
+
if info and not hint:
|
|
656
|
+
hint = info
|
|
657
|
+
return {"ok": False, "sent": False, "copy": text,
|
|
658
|
+
"error": (hint or "Couldn't reach that session's terminal") +
|
|
659
|
+
f" — `{text}` is on your clipboard; paste it in that window."}
|
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']];
|
|
@@ -1510,13 +1534,37 @@ async function sessionAction(pid, action, body = {}) {
|
|
|
1510
1534
|
headers: {'X-FinOps-Action': '1', 'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
|
1511
1535
|
return r.json();
|
|
1512
1536
|
}
|
|
1537
|
+
// Plan limits as `/usage` reports them: asked of Claude Code itself, server-side.
|
|
1538
|
+
const usageSev = p => p >= 90 ? 'critical' : p >= 75 ? 'approaching' : p >= 50 ? 'high' : 'healthy';
|
|
1539
|
+
const usageBody = u => {
|
|
1540
|
+
if (!u || !u.ok) return `<div class="empty">${esc((u && u.error) || 'Usage is unavailable')}</div>`;
|
|
1541
|
+
const meters = (u.limits || []).map(l => `
|
|
1542
|
+
<div class="item">
|
|
1543
|
+
<div class="hd"><b>${esc(l.label)}</b><span class="spacer"></span>
|
|
1544
|
+
<span style="font-variant-numeric:tabular-nums">${fmtPct(l.pct)} used</span></div>
|
|
1545
|
+
<div class="meter ${usageSev(l.pct)}"><i style="width:${Math.min(100, l.pct)}%"></i></div>
|
|
1546
|
+
${l.resets ? `<div class="dt note">Resets ${esc(l.resets)}</div>` : ''}</div>`).join('')
|
|
1547
|
+
|| `<pre class="note">${esc(u.text || '')}</pre>`;
|
|
1548
|
+
const windows = (u.windows || []).map(w => `
|
|
1549
|
+
<div class="item"><div class="hd">Last ${esc(w.window)}<span class="spacer"></span>
|
|
1550
|
+
<span class="note">${esc(w.detail)}</span></div>
|
|
1551
|
+
${(w.notes || []).map(n => `<div class="dt">• ${esc(n)}</div>`).join('')}</div>`).join('');
|
|
1552
|
+
return `<div class="stack">${meters}${windows}</div>
|
|
1553
|
+
<div class="note">${esc(u.note || '')}${u.cached ? ` · read ${dur(u.age_s)} ago` : ''}${u.stale
|
|
1554
|
+
? ` · <b>last good read</b> (refresh failed: ${esc(u.error || '')})` : ''}</div>`;
|
|
1555
|
+
};
|
|
1513
1556
|
VIEWS.live = async (page) => {
|
|
1514
|
-
const res = await
|
|
1557
|
+
const [res, usage0] = await Promise.all([
|
|
1558
|
+
fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
|
|
1559
|
+
fetch('/api/usage').then(r => r.json()).catch(e => ({ok: false, error: e.message}))]);
|
|
1515
1560
|
const list = res.sessions || [];
|
|
1516
1561
|
const agentName = id => ((S.opts?.agents || []).find(a => a.id === id) || {}).name || id;
|
|
1517
1562
|
const busy = list.filter(x => x.status === 'busy');
|
|
1518
1563
|
const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
|
|
1519
1564
|
page.innerHTML = `
|
|
1565
|
+
${card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
|
|
1566
|
+
{badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
|
|
1567
|
+
actions: '<button class="btn pb-copy" id="usage-refresh">↻ Refresh usage</button>'})}
|
|
1520
1568
|
<div class="grid g4">
|
|
1521
1569
|
${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
|
|
1522
1570
|
${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
|
|
@@ -1525,6 +1573,8 @@ VIEWS.live = async (page) => {
|
|
|
1525
1573
|
</div>
|
|
1526
1574
|
<div class="note"><b>Idle sessions don't use tokens</b>; they only cost again when you send the next message,
|
|
1527
1575
|
which re-reads their whole context. <b>Interrupt</b> stops the current turn (like Esc).
|
|
1576
|
+
<b>Compact</b> types <code>/compact</code> into that session's terminal (tmux, Terminal.app, iTerm2 or
|
|
1577
|
+
Windows console; anywhere else it lands on your clipboard to paste).
|
|
1528
1578
|
<b>Close</b> exits the session cleanly; copy its resume command to bring it back.
|
|
1529
1579
|
<b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
|
|
1530
1580
|
Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
|
|
@@ -1543,9 +1593,10 @@ VIEWS.live = async (page) => {
|
|
|
1543
1593
|
<span class="note">${esc(x.cwd)}</span></div>
|
|
1544
1594
|
${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
|
|
1545
1595
|
? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
|
|
1546
|
-
: 'Getting heavy.
|
|
1596
|
+
: 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
|
|
1547
1597
|
<div class="live-actions">
|
|
1548
1598
|
${x.signalable ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button>
|
|
1599
|
+
${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
|
|
1549
1600
|
<button class="act warn" data-a="close">⏹ Close session</button>
|
|
1550
1601
|
<button class="act danger" data-a="kill">✖ Force kill</button>` : `<span class="note">${x.agent === 'cursor' ? 'Runs inside the Cursor IDE: stop it there.' : 'No matching process found: stop it in its terminal.'}</span>`}
|
|
1551
1602
|
${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
|
|
@@ -1566,6 +1617,15 @@ VIEWS.live = async (page) => {
|
|
|
1566
1617
|
</div>`}
|
|
1567
1618
|
</div>`).join('') || `<div class="empty">No ${esc(agentWord())} sessions are running</div>`}</div>`;
|
|
1568
1619
|
$('#live-refresh', page).onclick = () => render();
|
|
1620
|
+
const ub = $('#usage-refresh', page);
|
|
1621
|
+
ub.onclick = async () => {
|
|
1622
|
+
ub.disabled = true; ub.textContent = 'Reading…';
|
|
1623
|
+
try {
|
|
1624
|
+
const u = await fetch('/api/usage?refresh=1').then(r => r.json());
|
|
1625
|
+
$('#live-usage', page).innerHTML = usageBody(u);
|
|
1626
|
+
} catch (e) { $('#live-usage', page).innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
|
|
1627
|
+
ub.disabled = false; ub.textContent = '↻ Refresh usage';
|
|
1628
|
+
};
|
|
1569
1629
|
page.querySelectorAll('.item[data-i]').forEach(row => {
|
|
1570
1630
|
const x = list[+row.dataset.i], pid = x.pid, msg = row.querySelector('.live-msg');
|
|
1571
1631
|
const ho = row.querySelector('.handover'), ta = ho?.querySelector('textarea'), hoOut = ho?.querySelector('.ho-out');
|
|
@@ -1597,7 +1657,7 @@ VIEWS.live = async (page) => {
|
|
|
1597
1657
|
if (!b.classList.contains('armed')) {
|
|
1598
1658
|
row.querySelectorAll('button.armed').forEach(o => { o.classList.remove('armed'); o.textContent = o.dataset.label; });
|
|
1599
1659
|
b.dataset.label = label; b.classList.add('armed');
|
|
1600
|
-
b.textContent = x.hosts_dashboard && b.dataset.a
|
|
1660
|
+
b.textContent = x.hosts_dashboard && !['interrupt', 'compact'].includes(b.dataset.a)
|
|
1601
1661
|
? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)' : `Click again to ${b.dataset.a}`;
|
|
1602
1662
|
setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } }, 4000);
|
|
1603
1663
|
return;
|
|
@@ -1605,6 +1665,7 @@ VIEWS.live = async (page) => {
|
|
|
1605
1665
|
b.classList.remove('armed'); b.disabled = true; b.textContent = 'Working…';
|
|
1606
1666
|
try {
|
|
1607
1667
|
const r = await sessionAction(pid, b.dataset.a, {agent: x.agent});
|
|
1668
|
+
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
1608
1669
|
msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
|
|
1609
1670
|
msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
|
|
1610
1671
|
if (r.ok && r.exited) { row.classList.add('gone'); setTimeout(() => render(), 1500); }
|
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; }
|