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.
@@ -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
- """The signed-in Claude Code account, read from ~/.claude.json (actual, not guessed)."""
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
- return {"label": acct.get("emailAddress")} if acct.get("emailAddress") else {}
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
- if not cur.get("account", {}).get("label"):
48
- cur.setdefault("account", {}).update(detect_account())
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 "&mdash;"
@@ -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(str(a.settings['account'].get('label')))} &middot;
64
+ <div class="sub">Account {html.escape(_who(a.settings['account']))} &middot;
56
65
  Billing period {bp['start']} &rarr; {bp['end']} &middot;
57
66
  Data {ov['date_range']['first']} &ndash; {ov['date_range']['last']} &middot;
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.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></aside>
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; }