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.
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/bin/claude-finops.js +74 -0
- package/config/free_models.json +49 -0
- package/config/model_compare.json +13 -0
- package/config/pricing.json +232 -0
- package/config/settings.json +57 -0
- package/finops/__init__.py +0 -0
- package/finops/actions.py +671 -0
- package/finops/agents.py +296 -0
- package/finops/analytics.py +1579 -0
- package/finops/api.py +426 -0
- package/finops/classify.py +48 -0
- package/finops/cloud.py +336 -0
- package/finops/diagnose.py +1002 -0
- package/finops/etl.py +533 -0
- package/finops/paths.py +111 -0
- package/finops/playbook.py +229 -0
- package/finops/pricing.py +74 -0
- package/finops/procs.py +499 -0
- package/finops/report.py +169 -0
- package/package.json +52 -0
- package/run.cmd +4 -0
- package/run.py +210 -0
- package/run.sh +3 -0
- package/web/app.js +2923 -0
- package/web/charts.js +340 -0
- package/web/index.html +14 -0
- package/web/styles.css +387 -0
package/finops/report.py
ADDED
|
@@ -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')))} ·
|
|
56
|
+
Billing period {bp['start']} → {bp['end']} ·
|
|
57
|
+
Data {ov['date_range']['first']} – {ov['date_range']['last']} ·
|
|
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 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>—</li>'}</ul>
|
|
80
|
+
<b>Needs attention</b><ul>{''.join(f'<li>{html.escape(x)}</li>' for x in sc['needs_attention']) or '<li>—</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 & 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 "—")),
|
|
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 × 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
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