claude-finops 0.3.0 → 0.4.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/finops/analytics.py +39 -4
- package/finops/report.py +10 -1
- package/package.json +1 -1
- package/web/app.js +20 -1
- package/web/styles.css +20 -1
package/finops/analytics.py
CHANGED
|
@@ -30,22 +30,57 @@ def _merge(base, over):
|
|
|
30
30
|
return base
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# claude_max -> "Max", claude_pro -> "Pro": the tier string carries a 5x/20x
|
|
34
|
+
# suffix we keep, because which Max you are on changes every limit in the app.
|
|
35
|
+
_PLANS = {"claude_max": "Max", "claude_pro": "Pro", "claude_team": "Team",
|
|
36
|
+
"claude_enterprise": "Enterprise"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _plan(acct):
|
|
40
|
+
base = _PLANS.get(acct.get("organizationType") or "")
|
|
41
|
+
mult = ""
|
|
42
|
+
tier = str(acct.get("organizationRateLimitTier") or "")
|
|
43
|
+
if base == "Max":
|
|
44
|
+
for m in ("5x", "20x"):
|
|
45
|
+
if tier.endswith(m):
|
|
46
|
+
mult = " " + m
|
|
47
|
+
return (base + mult) if base else ""
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
def detect_account():
|
|
34
|
-
"""
|
|
51
|
+
"""Who Claude Code is signed in as, read from ~/.claude.json (actual, not guessed).
|
|
52
|
+
|
|
53
|
+
Everything here is already on this machine, written by Claude Code itself at
|
|
54
|
+
login. We only surface it, so a shared screenshot says whose numbers these
|
|
55
|
+
are — a dashboard with no name on it is the one people misread.
|
|
56
|
+
"""
|
|
35
57
|
try:
|
|
36
58
|
with open(os.path.expanduser("~/.claude.json")) as fh:
|
|
37
59
|
acct = json.load(fh).get("oauthAccount") or {}
|
|
38
60
|
except (OSError, ValueError):
|
|
39
61
|
return {}
|
|
40
|
-
|
|
62
|
+
email = acct.get("emailAddress") or ""
|
|
63
|
+
name = acct.get("fullName") or acct.get("displayName") or ""
|
|
64
|
+
org = acct.get("organizationName") or ""
|
|
65
|
+
out = {"name": name, "email": email,
|
|
66
|
+
# A personal plan names the org after the person; repeating it is noise.
|
|
67
|
+
"org": "" if org == name else org,
|
|
68
|
+
"plan": _plan(acct)}
|
|
69
|
+
if email:
|
|
70
|
+
out["label"] = email
|
|
71
|
+
return {k: v for k, v in out.items() if v}
|
|
41
72
|
|
|
42
73
|
|
|
43
74
|
def load_settings():
|
|
44
75
|
"""Shared defaults (settings.json) + this machine's overrides (settings.local.json)."""
|
|
45
76
|
with open(SETTINGS_PATH) as fh:
|
|
46
77
|
cur = json.load(fh)
|
|
47
|
-
|
|
48
|
-
|
|
78
|
+
# Detected identity first, so a configured settings.json still wins below.
|
|
79
|
+
detected = detect_account()
|
|
80
|
+
acct = cur.setdefault("account", {})
|
|
81
|
+
for k, v in detected.items():
|
|
82
|
+
if not acct.get(k):
|
|
83
|
+
acct[k] = v
|
|
49
84
|
if os.path.exists(LOCAL_SETTINGS_PATH):
|
|
50
85
|
with open(LOCAL_SETTINGS_PATH) as fh:
|
|
51
86
|
_merge(cur, json.load(fh))
|
package/finops/report.py
CHANGED
|
@@ -3,6 +3,15 @@ import html
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
def _who(acct):
|
|
7
|
+
""""Mohit Raj Purohit <mohit@example.com>" when we know both, else whichever
|
|
8
|
+
one we have. A report that leaves the house should name its account."""
|
|
9
|
+
name, email = acct.get("name") or "", acct.get("email") or acct.get("label") or ""
|
|
10
|
+
if name and email:
|
|
11
|
+
return f"{name} <{email}>"
|
|
12
|
+
return name or email or "unknown"
|
|
13
|
+
|
|
14
|
+
|
|
6
15
|
def _f(v, kind="usd"):
|
|
7
16
|
if v is None:
|
|
8
17
|
return "—"
|
|
@@ -52,7 +61,7 @@ td:nth-child(n+2) {{ font-variant-numeric: tabular-nums; }}
|
|
|
52
61
|
ul {{ margin:6px 0 12px 18px; padding:0; }} li {{ margin-bottom:4px; }}
|
|
53
62
|
</style></head><body>
|
|
54
63
|
<h1>Claude AI FinOps Report</h1>
|
|
55
|
-
<div class="sub">Account {html.escape(
|
|
64
|
+
<div class="sub">Account {html.escape(_who(a.settings['account']))} ·
|
|
56
65
|
Billing period {bp['start']} → {bp['end']} ·
|
|
57
66
|
Data {ov['date_range']['first']} – {ov['date_range']['last']} ·
|
|
58
67
|
Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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/web/app.js
CHANGED
|
@@ -151,7 +151,8 @@ function shell() {
|
|
|
151
151
|
<nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
|
|
152
152
|
items.map(([id, ic, label]) =>
|
|
153
153
|
`<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${ic}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
|
|
154
|
-
</nav
|
|
154
|
+
</nav>
|
|
155
|
+
<div class="who" id="who"></div></aside>
|
|
155
156
|
<div class="main">
|
|
156
157
|
<header class="topbar">
|
|
157
158
|
<div class="r1">
|
|
@@ -2223,9 +2224,27 @@ const navAllowed = scope => !scope || (scope === 'claude' ? hasClaude()
|
|
|
2223
2224
|
: scope === 'cloud' ? hasCloudKey() : hasPriced());
|
|
2224
2225
|
// "Claude Code", "Codex", or "agent" for a mixed selection: used in data-availability text
|
|
2225
2226
|
const agentWord = () => { const a = selAgents(); return a.length === 1 ? a[0].name : 'agent'; };
|
|
2227
|
+
// Whose usage this is. Read from ~/.claude.json by the server, so a screenshot
|
|
2228
|
+
// or a shared dashboard always says which account the numbers belong to.
|
|
2229
|
+
function whoBlock() {
|
|
2230
|
+
const el = $('#who'), a = S.opts?.settings?.account || {};
|
|
2231
|
+
if (!el) return;
|
|
2232
|
+
if (!a.name && !a.email) { el.innerHTML = ''; return; }
|
|
2233
|
+
const initials = (a.name || a.email || '?').split(/[\s@.]+/).filter(Boolean)
|
|
2234
|
+
.slice(0, 2).map(w => w[0].toUpperCase()).join('');
|
|
2235
|
+
el.innerHTML = `<div class="av">${esc(initials)}</div>
|
|
2236
|
+
<div class="id">
|
|
2237
|
+
<div class="nm">${esc(a.name || a.email)}</div>
|
|
2238
|
+
<div class="em" title="${esc(a.email || '')}">${esc(a.email || '')}</div>
|
|
2239
|
+
${a.plan || a.org ? `<div class="pl">${esc([a.plan, a.org].filter(Boolean).join(' · '))}</div>` : ''}
|
|
2240
|
+
</div>`;
|
|
2241
|
+
el.title = `Claude Code is signed in as ${a.name || ''} <${a.email || ''}>`.trim();
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2226
2244
|
function applyAgentChrome() {
|
|
2227
2245
|
const a = selAgents();
|
|
2228
2246
|
const label = a.length === 1 ? a[0].name.replace(/ (Code|CLI)$/, '') : a.length ? 'Multi-agent' : 'AI';
|
|
2247
|
+
whoBlock();
|
|
2229
2248
|
const mark = $('.brand .mark');
|
|
2230
2249
|
if (mark) mark.innerHTML = `<span class="dot"></span>${esc(label)} FinOps`;
|
|
2231
2250
|
const sub = $('.brand .sub');
|
package/web/styles.css
CHANGED
|
@@ -77,7 +77,8 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
77
77
|
/* ---------- shell ---------- */
|
|
78
78
|
.app { display: grid; grid-template-columns: 216px 1fr; min-height: 100vh; }
|
|
79
79
|
.sidebar { background: var(--surface); border-right: 1px solid var(--border);
|
|
80
|
-
padding: 14px 0; position: sticky; top:0; height:100vh; overflow-y:auto;
|
|
80
|
+
padding: 14px 0; position: sticky; top:0; height:100vh; overflow-y:auto;
|
|
81
|
+
display:flex; flex-direction:column; }
|
|
81
82
|
.brand { padding: 0 16px 14px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
|
|
82
83
|
.brand .mark { display:flex; align-items:center; gap:8px; font-weight:660; font-size:14px;
|
|
83
84
|
letter-spacing:-0.01em; }
|
|
@@ -395,3 +396,21 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
395
396
|
white-space: nowrap;
|
|
396
397
|
}
|
|
397
398
|
.iconbtn.upd:hover { background: var(--accent, #eb6834); color: #fff; }
|
|
399
|
+
|
|
400
|
+
/* Whose account this dashboard is reading — pinned to the foot of the sidebar,
|
|
401
|
+
so every screenshot carries the name of the account it came from. */
|
|
402
|
+
/* Sticky, because the nav is taller than the viewport and an identity you have
|
|
403
|
+
to scroll to find is one nobody checks. */
|
|
404
|
+
.who { margin-top:auto; position:sticky; bottom:0; z-index:2;
|
|
405
|
+
display:flex; align-items:center; gap:9px;
|
|
406
|
+
padding:12px 16px; background:var(--surface);
|
|
407
|
+
border-top:1px solid var(--border); }
|
|
408
|
+
.who:empty { display:none; }
|
|
409
|
+
.who .av { flex:none; width:26px; height:26px; border-radius:50%; display:grid;
|
|
410
|
+
place-items:center; font-size:10.5px; font-weight:700; color:#fff;
|
|
411
|
+
background: var(--accent, #eb6834); }
|
|
412
|
+
.who .id { min-width:0; }
|
|
413
|
+
.who .nm { font-size:11.5px; font-weight:620; line-height:1.25;
|
|
414
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
415
|
+
.who .em, .who .pl { font-size:10px; color:var(--muted); line-height:1.35;
|
|
416
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|