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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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",
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 stop():
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
- if IS_WIN:
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: