claude-finops 0.4.3 → 0.4.4
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/package.json +1 -1
- package/run.py +106 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
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
|
@@ -17,6 +17,7 @@ import os
|
|
|
17
17
|
import signal
|
|
18
18
|
import subprocess
|
|
19
19
|
import sys
|
|
20
|
+
import time
|
|
20
21
|
import zipfile
|
|
21
22
|
|
|
22
23
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -44,7 +45,86 @@ def _alive(pid):
|
|
|
44
45
|
return False
|
|
45
46
|
|
|
46
47
|
|
|
47
|
-
def
|
|
48
|
+
def _free_port(start):
|
|
49
|
+
"""The first free port at or above start+1, for the suggestion we print."""
|
|
50
|
+
import socket
|
|
51
|
+
for p in range(start + 1, start + 40):
|
|
52
|
+
with socket.socket() as sk:
|
|
53
|
+
try:
|
|
54
|
+
sk.bind(("127.0.0.1", p))
|
|
55
|
+
return p
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
return start + 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _port_owner(port):
|
|
62
|
+
"""PID listening on 127.0.0.1:<port>, or None.
|
|
63
|
+
|
|
64
|
+
The pidfile is not enough on its own: a dashboard started from a different
|
|
65
|
+
copy (a global npm install alongside a checkout) writes its own, and one
|
|
66
|
+
that was killed hard leaves a stale file behind. Asking the OS who actually
|
|
67
|
+
holds the port is the only answer that is always true.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
if IS_WIN:
|
|
71
|
+
out = subprocess.run(["netstat", "-ano", "-p", "TCP"],
|
|
72
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
73
|
+
for line in out.splitlines():
|
|
74
|
+
f = line.split()
|
|
75
|
+
if len(f) >= 5 and f[1].endswith(f":{port}") and f[3] == "LISTENING":
|
|
76
|
+
return int(f[4])
|
|
77
|
+
return None
|
|
78
|
+
out = subprocess.run(["lsof", "-tnP", f"-iTCP:{port}", "-sTCP:LISTEN"],
|
|
79
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
80
|
+
return int(out.split()[0]) if out.split() else None
|
|
81
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_ours(pid):
|
|
86
|
+
"""True only if that PID is a claude-finops server.
|
|
87
|
+
|
|
88
|
+
Whatever is on the port may be someone else's service. We stop our own
|
|
89
|
+
dashboard without asking; anything else we report and leave alone.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
if IS_WIN:
|
|
93
|
+
out = subprocess.run(
|
|
94
|
+
["wmic", "process", "where", f"ProcessId={pid}", "get", "CommandLine"],
|
|
95
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
96
|
+
else:
|
|
97
|
+
out = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
|
|
98
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
99
|
+
except (OSError, subprocess.SubprocessError):
|
|
100
|
+
return False
|
|
101
|
+
return "finops.api" in out or "claude-finops" in out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _kill(pid):
|
|
105
|
+
if IS_WIN:
|
|
106
|
+
subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
|
|
107
|
+
else:
|
|
108
|
+
os.kill(pid, signal.SIGTERM)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def stop(port=None):
|
|
112
|
+
"""Stop a running dashboard. Returns True if we stopped one."""
|
|
113
|
+
if stop_pidfile():
|
|
114
|
+
return True
|
|
115
|
+
# No usable pidfile: fall back to whoever owns the port, but only if it is
|
|
116
|
+
# ours to stop.
|
|
117
|
+
pid = _port_owner(port or os.environ.get("PORT", "8787"))
|
|
118
|
+
if pid and _is_ours(pid):
|
|
119
|
+
try:
|
|
120
|
+
_kill(pid)
|
|
121
|
+
return True
|
|
122
|
+
except OSError:
|
|
123
|
+
return False
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def stop_pidfile():
|
|
48
128
|
for pidfile in (PIDFILE, LEGACY_PIDFILE): # LEGACY_: a server started before the move
|
|
49
129
|
try:
|
|
50
130
|
with open(pidfile) as fh:
|
|
@@ -55,10 +135,7 @@ def stop():
|
|
|
55
135
|
else:
|
|
56
136
|
return False
|
|
57
137
|
if _alive(pid):
|
|
58
|
-
|
|
59
|
-
subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
|
|
60
|
-
else:
|
|
61
|
-
os.kill(pid, signal.SIGTERM)
|
|
138
|
+
_kill(pid)
|
|
62
139
|
os.remove(pidfile)
|
|
63
140
|
return True
|
|
64
141
|
os.remove(pidfile)
|
|
@@ -237,13 +314,35 @@ def main():
|
|
|
237
314
|
print(f"This is claude-finops {_version() or 'unknown'}; "
|
|
238
315
|
f"run --help to see what it supports.")
|
|
239
316
|
sys.exit(2)
|
|
240
|
-
stop() # replace a previous detached server
|
|
241
317
|
port = os.environ.get("PORT", "8787")
|
|
318
|
+
# Decide from who actually holds *this* port, not from the pidfile alone:
|
|
319
|
+
# the pidfile may describe a dashboard serving some other port entirely.
|
|
320
|
+
owner = _port_owner(port)
|
|
321
|
+
if owner is None or _is_ours(owner):
|
|
322
|
+
if owner is not None:
|
|
323
|
+
# flush=True: execv below replaces this process without flushing,
|
|
324
|
+
# so an unflushed line would simply never reach your terminal.
|
|
325
|
+
print(f"Restarting the dashboard already running on {port} …", flush=True)
|
|
326
|
+
stop(port) # replace it, whoever started it
|
|
327
|
+
for _ in range(20): # give the socket a moment to clear
|
|
328
|
+
if _port_owner(port) is None:
|
|
329
|
+
break
|
|
330
|
+
time.sleep(0.25)
|
|
331
|
+
# Still occupied means it is not ours: say so instead of dying in a traceback.
|
|
332
|
+
if (busy := _port_owner(port)) is not None:
|
|
333
|
+
free = _free_port(int(port))
|
|
334
|
+
print(f"Port {port} is already in use by PID {busy}, and it is not a "
|
|
335
|
+
f"claude-finops dashboard.")
|
|
336
|
+
print(f"Start on another port: PORT={free} claude-finops")
|
|
337
|
+
print(f"Or find out what is holding it: "
|
|
338
|
+
+ (f"netstat -ano -p TCP | findstr :{port}" if IS_WIN
|
|
339
|
+
else f"lsof -iTCP:{port} -sTCP:LISTEN"))
|
|
340
|
+
sys.exit(1)
|
|
242
341
|
source = os.environ.get("CLAUDE_PROJECTS", os.path.join(os.path.expanduser("~"), ".claude", "projects"))
|
|
243
342
|
if "--rebuild" in args or not os.path.exists(DB_PATH):
|
|
244
343
|
if not os.path.isdir(source):
|
|
245
344
|
sys.exit(f"No Claude Code transcripts at {source}. Use Claude Code once, or set CLAUDE_PROJECTS.")
|
|
246
|
-
print(f"Building warehouse from {source} …")
|
|
345
|
+
print(f"Building warehouse from {source} …", flush=True)
|
|
247
346
|
subprocess.run(_python() + ["-m", "finops.etl", source], check=True)
|
|
248
347
|
rest = [a for a in args if a != "--rebuild"]
|
|
249
348
|
if IS_WIN:
|