claude-finops 0.1.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.
@@ -0,0 +1,169 @@
1
+ """Printable HTML report -> PDF via the browser's print dialog (no dependencies)."""
2
+ import html
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ def _f(v, kind="usd"):
7
+ if v is None:
8
+ return "—"
9
+ if kind == "usd":
10
+ return f"${v:,.2f}"
11
+ if kind == "int":
12
+ return f"{int(v):,}"
13
+ if kind == "pct":
14
+ return f"{v:,.1f}%"
15
+ return html.escape(str(v))
16
+
17
+
18
+ def build_report(a, f):
19
+ ov = a.overview(f); sc = a.scorecard(f); fc = a.forecast(f)
20
+ md = a.models(f); pj = a.projects(f); wt = a.waste(f)
21
+ rc = a.recommendations(f); ad = a.advisor(f); ef = a.efficiency(f)
22
+ cat = a.categories(f); bp = ov["billing_period"]
23
+ lb = a.leaderboards(f, 10)
24
+
25
+ def table(headers, rows):
26
+ h = "".join(f"<th>{html.escape(x)}</th>" for x in headers)
27
+ b = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>" for r in rows)
28
+ return f"<table><thead><tr>{h}</tr></thead><tbody>{b}</tbody></table>"
29
+
30
+ parts = [f"""<!doctype html><html><head><meta charset="utf-8">
31
+ <title>Claude FinOps Report</title><style>
32
+ @page {{ size: A4; margin: 14mm; }}
33
+ body {{ font: 12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:#101418; }}
34
+ h1 {{ font-size: 24px; margin:0 0 4px; }} h2 {{ font-size:15px; margin:22px 0 8px;
35
+ border-bottom:2px solid #101418; padding-bottom:4px; page-break-after:avoid; }}
36
+ .sub {{ color:#5b6570; margin-bottom:18px; }}
37
+ table {{ width:100%; border-collapse:collapse; margin:8px 0 14px; font-size:11px; }}
38
+ th {{ text-align:left; background:#f2f4f7; padding:5px 7px; border-bottom:1px solid #d5dae1; }}
39
+ td {{ padding:5px 7px; border-bottom:1px solid #eceff3; vertical-align:top; }}
40
+ td:nth-child(n+2) {{ font-variant-numeric: tabular-nums; }}
41
+ .kpis {{ display:grid; grid-template-columns:repeat(4,1fr); gap:8px; }}
42
+ .kpi {{ border:1px solid #d5dae1; border-radius:6px; padding:9px 11px; }}
43
+ .kpi .l {{ font-size:9px; text-transform:uppercase; letter-spacing:.06em; color:#5b6570; }}
44
+ .kpi .v {{ font-size:19px; font-weight:650; margin-top:2px; }}
45
+ .badge {{ display:inline-block; font-size:9px; padding:1px 6px; border-radius:99px;
46
+ border:1px solid #c3cad3; color:#5b6570; text-transform:uppercase; letter-spacing:.05em; }}
47
+ .est {{ background:#fff6e5; border-color:#e0b055; color:#8a5a00; }}
48
+ .fc {{ background:#eef3ff; border-color:#8fa8e8; color:#2b47a8; }}
49
+ .rec {{ background:#f0f8f1; border-color:#8ec79a; color:#1d6b30; }}
50
+ .note {{ font-size:10px; color:#5b6570; font-style:italic; margin:6px 0 14px; }}
51
+ .score {{ font-size:44px; font-weight:700; }}
52
+ ul {{ margin:6px 0 12px 18px; padding:0; }} li {{ margin-bottom:4px; }}
53
+ </style></head><body>
54
+ <h1>Claude AI FinOps Report</h1>
55
+ <div class="sub">Account {html.escape(str(a.settings['account'].get('label')))} &middot;
56
+ Billing period {bp['start']} &rarr; {bp['end']} &middot;
57
+ Data {ov['date_range']['first']} &ndash; {ov['date_range']['last']} &middot;
58
+ Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}</div>
59
+ <p><span class="badge est">Estimated</span> All costs are derived from token counts in your local
60
+ Claude Code transcripts multiplied by the configurable price table in
61
+ <code>config/pricing.json</code> (updated {html.escape(str(a.pricing.updated))}). Claude Code
62
+ transcripts contain no billed amounts, so no figure here is an actual invoice value.</p>
63
+
64
+ <h2>1. Executive summary</h2>
65
+ <div class="kpis">
66
+ <div class="kpi"><div class="l">Estimated spend</div><div class="v">{_f(ov['est_cost_usd'])}</div></div>
67
+ <div class="kpi"><div class="l">Billable tokens</div><div class="v">{_f(ov['billable_tokens'],'int')}</div></div>
68
+ <div class="kpi"><div class="l">Requests</div><div class="v">{_f(ov['requests'],'int')}</div></div>
69
+ <div class="kpi"><div class="l">Sessions</div><div class="v">{_f(ov['sessions'],'int')}</div></div>
70
+ <div class="kpi"><div class="l">Prompts</div><div class="v">{_f(ov['prompts'],'int')}</div></div>
71
+ <div class="kpi"><div class="l">Active days</div><div class="v">{_f(ov['active_days'],'int')}</div></div>
72
+ <div class="kpi"><div class="l">Output tokens</div><div class="v">{_f(ov['output_tokens'],'int')}</div></div>
73
+ <div class="kpi"><div class="l">Avg / active day</div><div class="v">{_f(ov['avg_cost_per_active_day'])}</div></div>
74
+ </div>
75
+
76
+ <h2>2. FinOps scorecard</h2>
77
+ <p><span class="score">{sc['score']}</span> / 100 &nbsp; grade <b>{sc['grade']}</b></p>
78
+ {table(["Dimension","Score","Detail"], [(d['name'], d['score'], html.escape(d['detail'])) for d in sc['dimensions']])}
79
+ <b>What is good</b><ul>{''.join(f'<li>{html.escape(x)}</li>' for x in sc['what_is_good']) or '<li>&mdash;</li>'}</ul>
80
+ <b>Needs attention</b><ul>{''.join(f'<li>{html.escape(x)}</li>' for x in sc['needs_attention']) or '<li>&mdash;</li>'}</ul>
81
+
82
+ <h2>3. What should I do? <span class="badge">Advisor</span></h2>
83
+ <ol>{''.join(f"<li><b>{html.escape(x['text'])}</b><br><span style='color:#5b6570'>{html.escape(x['detail'])}</span></li>" for x in ad['actions']) or '<li>No actions.</li>'}</ol>
84
+
85
+ <h2>4. Model breakdown <span class="badge est">Estimated cost</span></h2>
86
+ {table(["Model","Requests","Input","Output","Cache read","Cache write","Billable tokens","Est. cost","% cost"],
87
+ [(html.escape(r['display_name']), _f(r['requests'],'int'), _f(r['input_tokens'],'int'),
88
+ _f(r['output_tokens'],'int'), _f(r['cache_read_tokens'],'int'), _f(r['cache_write_tokens'],'int'),
89
+ _f(r['tokens'],'int'), _f(r['cost']), _f(r['cost_pct'],'pct')) for r in md['rows']])}
90
+
91
+ <h2>5. Project breakdown</h2>
92
+ {table(["Project","Sessions","Prompts","Requests","Tokens","Est. cost","Avg / session"],
93
+ [(html.escape(r['name']), _f(r['sessions'],'int'), _f(r['prompts'],'int'), _f(r['requests'],'int'),
94
+ _f(r['tokens'],'int'), _f(r['cost']), _f(r['avg_cost_per_session'])) for r in pj[:20]])}
95
+
96
+ <h2>6. Spend by activity</h2>
97
+ {table(["Category","Prompts","Tokens","Est. cost","% of spend"],
98
+ [(html.escape(r['category']), _f(r['prompts'],'int'), _f(r['tokens'],'int'), _f(r['cost']),
99
+ _f(r['cost_pct'],'pct')) for r in cat['rows']])}
100
+ <div class="note">{html.escape(cat['note'])}</div>
101
+
102
+ <h2>7. Efficiency &amp; caching</h2>
103
+ {table(["Metric","Value"], [
104
+ ("Output share of billable tokens", f"{ef['output_ratio']*100:.2f}%"),
105
+ ("Output per prompt-side token", f"{ef['output_per_input']*100:.2f}%"),
106
+ ("Cache hit ratio", (f"{ef['cache_hit_ratio']*100:.1f}%" if ef['cache_hit_ratio'] is not None else "&mdash;")),
107
+ ("Tokens per request", _f(ef['tokens_per_request'],'int')),
108
+ ("Avg context per request", _f(ef['avg_context_tokens'],'int')),
109
+ ("Est. cost per 1K output tokens", f"${ef['cost_per_1k_output']:.3f}"),
110
+ ("Est. cost with caching", _f(ef['cache']['cost_with_cache'])),
111
+ ("Est. cost without caching", _f(ef['cache']['cost_without_cache'])),
112
+ ("Est. caching savings", f"{_f(ef['cache']['estimated_savings_usd'])} ({ef['cache']['savings_pct']}%)"),
113
+ ])}
114
+
115
+ <h2>8. Top 10 most expensive prompts <span class="badge est">Estimated</span></h2>
116
+ {table(["#","Prompt","Model","Tokens","Est. cost","Date"],
117
+ [(i, html.escape((r['preview'] or '')[:120]), html.escape((r['models'] or '')[:40]),
118
+ _f(r['ptokens'],'int'), _f(r['pcost']), r['day'])
119
+ for i, r in enumerate(lb['most_expensive'], 1)])}
120
+
121
+ <h2>9. Waste detection <span class="badge est">Estimated exposure</span></h2>
122
+ <p><b>Estimated excess: {_f(wt['estimated_excess_usd'])} ({wt['excess_pct']}%)</b> of
123
+ {_f(wt['total_cost_usd'])} — how much more the flagged work cost than a reasonable baseline.
124
+ It sits inside {_f(wt['exposed_cost_usd'])} ({wt['exposed_pct']}%) of exposed spend across
125
+ {wt['affected_prompts']} prompts and {wt['affected_sessions']} sessions.</p>
126
+ {table(["Severity","Finding","Est. excess","Exposed","Excess measured as","Recommended action"],
127
+ [(x['severity'].upper(), html.escape(x['title']), _f(x['est_excess_usd']), _f(x['est_cost_usd']),
128
+ html.escape(x['excess_basis']), html.escape(x['recommended_action'] or ''))
129
+ for x in wt['findings']])}
130
+ <div class="note">{html.escape(wt['note'])}</div>
131
+
132
+ <h2>10. Optimization recommendations <span class="badge rec">Recommendation</span></h2>
133
+ {table(["Recommendation","Actual cost","Est. alternative","Est. saving","Confidence"],
134
+ [(html.escape(r['title']), _f(r.get('actual_cost_usd')), _f(r.get('estimated_alternative_cost_usd')),
135
+ f"{_f(r.get('estimated_savings_usd'))} ({r.get('estimated_savings_pct')}%)",
136
+ r.get('confidence','')) for r in rc['recommendations']]) if rc['recommendations'] else '<p>No recommendation met the evidence threshold.</p>'}
137
+ <div class="note">Estimated savings assume identical token usage on the alternative and do not model
138
+ output quality. They are opportunities to evaluate, not booked savings.</div>
139
+
140
+ <h2>11. Forecast <span class="badge fc">Forecast</span></h2>"""]
141
+
142
+ if fc.get("available"):
143
+ parts.append(f"""<p>Method: {html.escape(fc['method'])} over {fc['sample_days']} days.
144
+ Period to date {_f(fc['period_used'])} with {fc['remaining_days']} days remaining.</p>
145
+ {table(["Scenario","Daily rate","Projected end of period"],
146
+ [(k.title(), _f(v['daily_rate']), _f(v['end_of_period_cost'])) for k, v in fc['scenarios'].items()])}
147
+ {table(["Horizon","Projection"], [
148
+ ("End of day", _f(fc['end_of_day_cost'])),
149
+ ("End of week", _f(fc['end_of_week_cost'])),
150
+ ("End of billing period (expected)", _f(fc['scenarios']['expected']['end_of_period_cost'])),
151
+ ("Limit exhaustion date", html.escape(str(fc['limit_exhaustion_date']))),
152
+ ])}""")
153
+ else:
154
+ parts.append("<p>Not enough data in range to forecast.</p>")
155
+
156
+ parts.append(f"""
157
+ <h2>12. Data provenance</h2>
158
+ {table(["Field","Value"], [
159
+ ("Source", html.escape(str(a.meta.get('source_dir')))),
160
+ ("Transcript files parsed", html.escape(str(a.meta.get('transcript_files')))),
161
+ ("Warehouse built", html.escape(str(a.meta.get('built_at')))),
162
+ ("Cost basis", "Estimated from token counts &times; config/pricing.json"),
163
+ ("Price table updated", html.escape(str(a.pricing.updated))),
164
+ ("Plan limits", "Unavailable from connected Claude data unless configured in config/settings.json"),
165
+ ])}
166
+ <div class="note">Claude Code transcripts do not expose plan allowances, remaining credits, billed
167
+ amounts, or git commit/PR linkage. Those fields are reported as unavailable rather than estimated.</div>
168
+ </body></html>""")
169
+ return "".join(parts)
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "claude-finops",
3
+ "version": "0.1.0",
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
+ "bin": {
6
+ "claude-finops": "bin/claude-finops.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "finops/*.py",
11
+ "web/",
12
+ "config/free_models.json",
13
+ "config/model_compare.json",
14
+ "config/pricing.json",
15
+ "config/settings.json",
16
+ "run.py",
17
+ "run.sh",
18
+ "run.cmd",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "claude",
27
+ "claude-code",
28
+ "cost",
29
+ "usage",
30
+ "tokens",
31
+ "spend",
32
+ "budget",
33
+ "dashboard",
34
+ "finops",
35
+ "analytics",
36
+ "cli",
37
+ "codex",
38
+ "gemini-cli",
39
+ "cursor"
40
+ ],
41
+ "license": "MIT",
42
+ "private": false,
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/mohit-raj-purohit/claude-finops.git"
46
+ },
47
+ "homepage": "https://github.com/mohit-raj-purohit/claude-finops#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/mohit-raj-purohit/claude-finops/issues"
50
+ },
51
+ "author": "Mohit Raj Purohit"
52
+ }
package/run.cmd ADDED
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ rem Claude FinOps Command Center (Windows). Same as: python run.py [--rebuild|--stop|--share]
3
+ cd /d "%~dp0"
4
+ where py >nul 2>nul && (py -3 run.py %*) || (python run.py %*)
package/run.py ADDED
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """Claude FinOps Command Center: one launcher for macOS, Windows and Linux.
3
+
4
+ python3 run.py build the warehouse if missing, then serve
5
+ python3 run.py --rebuild re-read transcripts first
6
+ python3 run.py --stop stop a running dashboard
7
+ python3 run.py --share write ../claude-finops.zip (code + defaults, never your data)
8
+ python3 run.py --set-key store a provider API key
9
+ python3 run.py --where print where your data and keys live
10
+ python3 run.py --help all commands
11
+ PORT=9000 python3 run.py different port
12
+
13
+ Your warehouse and local settings live in ~/.claude-finops (CLAUDE_FINOPS_HOME to
14
+ override), never in this folder, so upgrading or repackaging cannot touch them.
15
+ """
16
+ import os
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import zipfile
21
+
22
+ ROOT = os.path.dirname(os.path.abspath(__file__))
23
+ sys.path.insert(0, ROOT)
24
+ from finops.paths import DATA_DIR as DATA, HOME_DIR, PIDFILE, DB_PATH, migrate, ensure_dirs # noqa: E402
25
+
26
+ LEGACY_PIDFILE = os.path.join(ROOT, "data", "server.pid")
27
+ IS_WIN = os.name == "nt"
28
+
29
+
30
+ def _python():
31
+ # UTF-8 mode: transcripts are UTF-8, and Windows would otherwise default to cp1252
32
+ return [sys.executable, "-X", "utf8"]
33
+
34
+
35
+ def _alive(pid):
36
+ if IS_WIN:
37
+ out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"],
38
+ capture_output=True, text=True).stdout
39
+ return str(pid) in out
40
+ try:
41
+ os.kill(pid, 0)
42
+ return True
43
+ except OSError:
44
+ return False
45
+
46
+
47
+ def stop():
48
+ for pidfile in (PIDFILE, LEGACY_PIDFILE): # LEGACY_: a server started before the move
49
+ try:
50
+ with open(pidfile) as fh:
51
+ pid = int(fh.read().strip())
52
+ break
53
+ except (OSError, ValueError):
54
+ continue
55
+ else:
56
+ return False
57
+ 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)
62
+ os.remove(pidfile)
63
+ return True
64
+ os.remove(pidfile)
65
+ return False
66
+
67
+
68
+ def share(out=None):
69
+ out = out or os.path.join(os.path.dirname(ROOT), "claude-finops.zip")
70
+ skip_dirs = {"data", "__pycache__", ".git"}
71
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
72
+ for base, dirs, files in os.walk(ROOT):
73
+ dirs[:] = [d for d in dirs if d not in skip_dirs]
74
+ for f in files:
75
+ if f.endswith((".pyc", ".zip", ".tgz")) or f in ("settings.local.json", "secrets.local.json"):
76
+ continue
77
+ full = os.path.join(base, f)
78
+ arc = os.path.join("claude-finops", os.path.relpath(full, ROOT)).replace(os.sep, "/")
79
+ info = zipfile.ZipInfo.from_file(full, arc)
80
+ info.external_attr = (0o755 if f.endswith((".sh", ".py")) else 0o644) << 16
81
+ info.compress_type = zipfile.ZIP_DEFLATED
82
+ with open(full, "rb") as fh:
83
+ z.writestr(info, fh.read())
84
+ print(f"Wrote {out} (code + default config only; your data/ and local settings are excluded)")
85
+
86
+
87
+ HELP = """Claude FinOps Command Center
88
+
89
+ claude-finops start the dashboard (builds the warehouse first run)
90
+ claude-finops --rebuild re-read your Claude Code transcripts, then start
91
+ claude-finops --stop stop a running dashboard
92
+ claude-finops --where print where your data, settings and keys live
93
+ claude-finops --set-key store a provider API key (prompts, never echoes)
94
+ claude-finops --keys list which provider keys are configured
95
+ claude-finops --share write ../claude-finops.zip (code only, never your data)
96
+ claude-finops --help this message
97
+
98
+ Environment:
99
+ PORT=9000 serve on another port (default 8787)
100
+ CLAUDE_FINOPS_HOME=/path where your data lives (default ~/.claude-finops)
101
+ CLAUDE_PROJECTS=/path where to read transcripts from
102
+ CLAUDE_FINOPS_PYTHON=/path which Python the npm wrapper should use
103
+ """
104
+
105
+
106
+ def where():
107
+ from finops.paths import (HOME_DIR, DB_PATH, LOCAL_SETTINGS_PATH, SECRETS_PATH,
108
+ LOGFILE, CONFIG_DIR)
109
+ rows = [("state dir", HOME_DIR), ("warehouse", DB_PATH), ("your settings", LOCAL_SETTINGS_PATH),
110
+ ("your API keys", SECRETS_PATH), ("server log", LOGFILE),
111
+ ("shipped defaults", CONFIG_DIR)]
112
+ for label, path in rows:
113
+ mark = " " if os.path.exists(path) else " (not created yet)"
114
+ print(f" {label:<17} {path}{mark}")
115
+
116
+
117
+ def _providers():
118
+ from finops.cloud import PROVIDERS
119
+ return PROVIDERS
120
+
121
+
122
+ def keys():
123
+ """Show which keys are configured, and where each one came from."""
124
+ import json
125
+ from finops.paths import SECRETS_PATH
126
+ try:
127
+ stored = json.load(open(SECRETS_PATH))
128
+ except (OSError, ValueError):
129
+ stored = {}
130
+ for pid, p in _providers().items():
131
+ env, field = p["env"], p["field"]
132
+ if os.environ.get(env):
133
+ src = f"set (from ${env})"
134
+ elif stored.get(field):
135
+ src = "set (stored)"
136
+ else:
137
+ src = f"not set — --set-key {pid}, or export {env}=..."
138
+ print(f" {p['name']:<20} {src}")
139
+ print(f"\n Stored keys live in {SECRETS_PATH}")
140
+
141
+
142
+ def set_key(pid=None):
143
+ """Prompt for a key and store it 0600. The value is never echoed or logged."""
144
+ import getpass
145
+ import json
146
+ from finops.paths import SECRETS_PATH
147
+ provs = _providers()
148
+ if pid not in provs:
149
+ if pid:
150
+ print(f"Unknown provider {pid!r}.")
151
+ print("Which provider?")
152
+ for k, p in provs.items():
153
+ print(f" {k:<12} {p['name']}")
154
+ pid = input("provider: ").strip()
155
+ if pid not in provs:
156
+ sys.exit("Cancelled.")
157
+ p = provs[pid]
158
+ print(f"{p['name']}: paste the key (input stays hidden), or press Enter to remove it.")
159
+ value = getpass.getpass("key: ").strip()
160
+ try:
161
+ data = json.load(open(SECRETS_PATH))
162
+ except (OSError, ValueError):
163
+ data = {}
164
+ if value:
165
+ data[p["field"]] = value
166
+ else:
167
+ data.pop(p["field"], None)
168
+ fd = os.open(SECRETS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
169
+ with os.fdopen(fd, "w") as fh:
170
+ json.dump(data, fh, indent=2)
171
+ print(f"{'Stored' if value else 'Removed'} — {SECRETS_PATH}")
172
+ print("Restart the dashboard for it to take effect: claude-finops --stop && claude-finops")
173
+
174
+
175
+ def main():
176
+ args = sys.argv[1:]
177
+ os.chdir(ROOT)
178
+ ensure_dirs()
179
+ migrate()
180
+ if "--help" in args or "-h" in args:
181
+ return print(HELP)
182
+ if "--where" in args:
183
+ return where()
184
+ if "--keys" in args:
185
+ return keys()
186
+ if "--set-key" in args:
187
+ i = args.index("--set-key")
188
+ return set_key(args[i + 1] if len(args) > i + 1 else None)
189
+ if "--stop" in args:
190
+ print("Stopped." if stop() else "Not running.")
191
+ return
192
+ if "--share" in args:
193
+ return share()
194
+ stop() # replace a previous detached server
195
+ port = os.environ.get("PORT", "8787")
196
+ source = os.environ.get("CLAUDE_PROJECTS", os.path.join(os.path.expanduser("~"), ".claude", "projects"))
197
+ if "--rebuild" in args or not os.path.exists(DB_PATH):
198
+ if not os.path.isdir(source):
199
+ sys.exit(f"No Claude Code transcripts at {source}. Use Claude Code once, or set CLAUDE_PROJECTS.")
200
+ print(f"Building warehouse from {source} …")
201
+ subprocess.run(_python() + ["-m", "finops.etl", source], check=True)
202
+ rest = [a for a in args if a != "--rebuild"]
203
+ if IS_WIN:
204
+ print(f"Open http://127.0.0.1:{port} (Ctrl-C to stop)")
205
+ os.execv(sys.executable, _python() + ["-m", "finops.api", port] + rest) if not IS_WIN else \
206
+ sys.exit(subprocess.call(_python() + ["-m", "finops.api", port] + rest))
207
+
208
+
209
+ if __name__ == "__main__":
210
+ main()
package/run.sh ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Claude FinOps Command Center (macOS / Linux). Same as: python3 run.py [--rebuild|--stop|--share]
3
+ cd "$(dirname "$0")" && exec python3 run.py "$@"