@ycstudios/token-tab 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 +215 -0
- package/adapters/claude-live.mjs +90 -0
- package/adapters/install-live.sh +87 -0
- package/adapters/write-live.mjs +85 -0
- package/package.json +35 -0
- package/src/core.mjs +278 -0
- package/src/live-parse.mjs +74 -0
- package/src/pricing.mjs +104 -0
- package/src/token-tab.mjs +353 -0
- package/swiftbar/README.md +46 -0
- package/swiftbar/token-tab-live.2m.sh +55 -0
- package/swiftbar/token-tab.30s.sh +40 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Token Tab — I/O shell (Approach C validation probe / SwiftBar plugin).
|
|
3
|
+
//
|
|
4
|
+
// Walks the Claude Code log dir, streams each JSONL file line-by-line, feeds the
|
|
5
|
+
// pure core, and prints your tab. Zero dependencies, no build step: what you read
|
|
6
|
+
// is exactly what runs. Reads only token-usage metadata, never your prompts/code,
|
|
7
|
+
// and makes no network calls.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node src/token-tab.mjs human report (default)
|
|
11
|
+
// node src/token-tab.mjs --json machine-readable aggregate (for reconciliation)
|
|
12
|
+
// node src/token-tab.mjs --swiftbar SwiftBar menu-bar format
|
|
13
|
+
//
|
|
14
|
+
// Log dir resolution: $TOKENTAB_LOG_DIR > $CLAUDE_CONFIG_DIR/projects > ~/.claude/projects
|
|
15
|
+
|
|
16
|
+
import { createReadStream, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { aggregate, recordFromLine, classifySurface } from "./core.mjs";
|
|
21
|
+
import { costOfUsage } from "./pricing.mjs";
|
|
22
|
+
|
|
23
|
+
function resolveLogDir() {
|
|
24
|
+
if (process.env.TOKENTAB_LOG_DIR) return process.env.TOKENTAB_LOG_DIR;
|
|
25
|
+
if (process.env.CLAUDE_CONFIG_DIR) return join(process.env.CLAUDE_CONFIG_DIR, "projects");
|
|
26
|
+
return join(homedir(), ".claude", "projects");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Load machine-local settings (e.g. TOKENTAB_WINDOW_CAP) from a KEY=VALUE file kept
|
|
30
|
+
// OUTSIDE the repo, so your plan cap never gets committed. Real env vars win. Only
|
|
31
|
+
// TOKENTAB_* keys are honored. This only reads a local file — no network, no secrets.
|
|
32
|
+
function loadLocalConfig() {
|
|
33
|
+
const candidates = [
|
|
34
|
+
process.env.TOKENTAB_CONFIG,
|
|
35
|
+
join(homedir(), ".config", "token-tab", "env"),
|
|
36
|
+
join(homedir(), ".token-tab.env"),
|
|
37
|
+
].filter(Boolean);
|
|
38
|
+
for (const path of candidates) {
|
|
39
|
+
let txt;
|
|
40
|
+
try {
|
|
41
|
+
txt = readFileSync(path, "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
for (const line of txt.split("\n")) {
|
|
46
|
+
const m = line.match(/^\s*(TOKENTAB_[A-Z0-9_]+|CLAUDE_CODE_USE_BEDROCK)\s*=\s*(.*?)\s*$/);
|
|
47
|
+
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findJsonl(dir) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const walk = (d) => {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const e of entries) {
|
|
62
|
+
const p = join(d, e.name);
|
|
63
|
+
if (e.isDirectory()) walk(p);
|
|
64
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) out.push(p);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(dir);
|
|
68
|
+
// Deterministic order so first-seen dedup is reproducible: oldest mtime first.
|
|
69
|
+
// Tolerate a file vanishing between the walk and the stat — Claude Code rotates
|
|
70
|
+
// logs under us, and a hard crash here would just blank the menu bar.
|
|
71
|
+
return out
|
|
72
|
+
.map((p) => {
|
|
73
|
+
try {
|
|
74
|
+
return { p, mtime: statSync(p).mtimeMs };
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.sort((a, b) => a.mtime - b.mtime || (a.p < b.p ? -1 : 1))
|
|
81
|
+
.map((x) => x.p);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readRecords(files) {
|
|
85
|
+
const records = [];
|
|
86
|
+
const parseErrors = []; // {path, line} only — never the content of the bad line
|
|
87
|
+
for (const path of files) {
|
|
88
|
+
let lineNo = 0;
|
|
89
|
+
try {
|
|
90
|
+
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
91
|
+
for await (const line of rl) {
|
|
92
|
+
lineNo++;
|
|
93
|
+
if (!line.trim()) continue;
|
|
94
|
+
let obj;
|
|
95
|
+
try {
|
|
96
|
+
obj = JSON.parse(line);
|
|
97
|
+
} catch {
|
|
98
|
+
parseErrors.push({ path, line: lineNo }); // tolerate malformed (live-write) lines
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const rec = recordFromLine(obj);
|
|
102
|
+
if (rec) records.push(rec);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// File vanished or became unreadable mid-read — skip the rest of it and
|
|
106
|
+
// keep going so one rotated log doesn't abort the whole report.
|
|
107
|
+
parseErrors.push({ path, line: lineNo });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { records, parseErrors };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function abbrev(n) {
|
|
114
|
+
if (n < 1000) return String(n);
|
|
115
|
+
if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0) + "K";
|
|
116
|
+
if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0) + "M";
|
|
117
|
+
return (n / 1e9).toFixed(2) + "B";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fmtDur(ms) {
|
|
121
|
+
if (ms == null || ms < 0) return "—";
|
|
122
|
+
const mins = Math.round(ms / 60000);
|
|
123
|
+
const h = Math.floor(mins / 60);
|
|
124
|
+
return h > 0 ? `${h}h${String(mins % 60).padStart(2, "0")}m` : `${mins}m`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Dollars: cents under $1k, whole dollars above (a tab, not an invoice). A nonzero
|
|
128
|
+
// amount that rounds below a cent shows "<$0.01" so tiny spend never reads as free.
|
|
129
|
+
function fmtUsd(n) {
|
|
130
|
+
if (!n || n < 0) return "$0.00";
|
|
131
|
+
if (n < 0.01) return "<$0.01";
|
|
132
|
+
if (n >= 1000) return "$" + Math.round(n).toLocaleString();
|
|
133
|
+
return "$" + n.toFixed(2);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function dominantSurface(bySurface) {
|
|
137
|
+
let best = null,
|
|
138
|
+
bestN = -1;
|
|
139
|
+
for (const [s, n] of Object.entries(bySurface)) {
|
|
140
|
+
if (s !== "untracked" && n > bestN) {
|
|
141
|
+
best = s;
|
|
142
|
+
bestN = n;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return best || "untracked";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Opt-in live usage is OFF unless TOKENTAB_LIVE is truthy. Forgiving on value so
|
|
149
|
+
// `TOKENTAB_LIVE=true` (etc.) doesn't silently no-op; `=1` is the canonical form.
|
|
150
|
+
function isLiveEnabled(v) {
|
|
151
|
+
return typeof v === "string" && /^(1|true|yes|on)$/i.test(v.trim());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Force the displayed surface, mirroring the native app's Config.swift:
|
|
155
|
+
// TOKENTAB_MODE wins (bedrock | subscription/max/pro/sub | payg/pay-per-token/api/untracked),
|
|
156
|
+
// else a truthy CLAUDE_CODE_USE_BEDROCK forces bedrock, else null (auto-detect).
|
|
157
|
+
// Needed because Claude Code on Bedrock logs bare claude-* ids that classify as
|
|
158
|
+
// subscription — the logs alone can't reveal Bedrock.
|
|
159
|
+
function resolveSurfaceOverride(env) {
|
|
160
|
+
const mode = (env.TOKENTAB_MODE || "").trim().toLowerCase();
|
|
161
|
+
switch (mode) {
|
|
162
|
+
case "bedrock":
|
|
163
|
+
return "bedrock";
|
|
164
|
+
case "subscription":
|
|
165
|
+
case "max":
|
|
166
|
+
case "pro":
|
|
167
|
+
case "sub":
|
|
168
|
+
return "subscription";
|
|
169
|
+
case "payg":
|
|
170
|
+
case "pay-per-token":
|
|
171
|
+
case "paypertoken":
|
|
172
|
+
case "api":
|
|
173
|
+
case "untracked":
|
|
174
|
+
return "untracked";
|
|
175
|
+
}
|
|
176
|
+
const bd = (env.CLAUDE_CODE_USE_BEDROCK || "").trim().toLowerCase();
|
|
177
|
+
if (bd === "1" || bd === "true" || bd === "yes" || bd === "on") return "bedrock";
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function main() {
|
|
182
|
+
loadLocalConfig();
|
|
183
|
+
const mode = process.argv.includes("--json")
|
|
184
|
+
? "json"
|
|
185
|
+
: process.argv.includes("--swiftbar")
|
|
186
|
+
? "swiftbar"
|
|
187
|
+
: "report";
|
|
188
|
+
const dir = resolveLogDir();
|
|
189
|
+
|
|
190
|
+
if (!existsSync(dir)) {
|
|
191
|
+
if (mode === "swiftbar") {
|
|
192
|
+
console.log("Token Tab —");
|
|
193
|
+
console.log("---");
|
|
194
|
+
console.log(`No logs found at ${dir} | color=gray`);
|
|
195
|
+
console.log("Open Claude Code once, then refresh. | color=gray");
|
|
196
|
+
} else {
|
|
197
|
+
console.log(`No Claude Code logs found at ${dir}`);
|
|
198
|
+
console.log("Set $TOKENTAB_LOG_DIR if your logs live elsewhere.");
|
|
199
|
+
}
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const files = findJsonl(dir);
|
|
204
|
+
const { records, parseErrors } = await readRecords(files);
|
|
205
|
+
const cap = Number(process.env.TOKENTAB_WINDOW_CAP);
|
|
206
|
+
// Dollars are local-only arithmetic on a bundled price table — no network, no key —
|
|
207
|
+
// so the estimate is on by default (unlike the live server-%, which is opt-in).
|
|
208
|
+
const agg = aggregate(records, {
|
|
209
|
+
cap: Number.isFinite(cap) && cap > 0 ? cap : undefined,
|
|
210
|
+
cost: costOfUsage,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Opt-in live usage. Computed ONCE, before any render branch, and only when
|
|
214
|
+
// enabled — the live adapter (the sole subprocess user, fenced under adapters/,
|
|
215
|
+
// outside the audited core) is dynamically imported so the default path never
|
|
216
|
+
// loads it. Fails closed to null: a missing/broken adapter never breaks the report.
|
|
217
|
+
const liveEnabled = isLiveEnabled(process.env.TOKENTAB_LIVE);
|
|
218
|
+
let live = null;
|
|
219
|
+
if (liveEnabled) {
|
|
220
|
+
try {
|
|
221
|
+
const { readLiveUsage } = await import("../adapters/claude-live.mjs");
|
|
222
|
+
live = await readLiveUsage();
|
|
223
|
+
} catch {
|
|
224
|
+
live = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (mode === "json") {
|
|
229
|
+
// Default (flag unset) output stays byte-for-byte identical: `live` is only
|
|
230
|
+
// appended when present, never as `live: null`, and `window` is untouched.
|
|
231
|
+
const out = { ...agg, files: files.length, parseErrors: parseErrors.length };
|
|
232
|
+
if (live) out.live = live;
|
|
233
|
+
console.log(JSON.stringify(out, null, 2));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const surfaceOverride = resolveSurfaceOverride(process.env);
|
|
238
|
+
const surface = surfaceOverride ?? dominantSurface(agg.bySurface);
|
|
239
|
+
const w = agg.window;
|
|
240
|
+
|
|
241
|
+
if (mode === "swiftbar") {
|
|
242
|
+
// Local default: the headline is tokens (today). The 5h window — exact reset
|
|
243
|
+
// countdown, plus a % only if you've set a cap — lives in the dropdown. All local,
|
|
244
|
+
// no network. (A live server-% is a future opt-in that would phone home.)
|
|
245
|
+
console.log(`◧ ${abbrev(agg.today)}`);
|
|
246
|
+
console.log("---");
|
|
247
|
+
if (surface === "subscription") {
|
|
248
|
+
if (live) {
|
|
249
|
+
// Live server numbers are authoritative, so they headline; the local
|
|
250
|
+
// estimate is demoted to one gray line so two competing "5h window"
|
|
251
|
+
// percentages never sit side by side.
|
|
252
|
+
console.log(`5h window: ${live.sessionPct}% used · live (claude /usage)`);
|
|
253
|
+
if (live.sessionResetText) console.log(`Resets ${live.sessionResetText} | color=gray`);
|
|
254
|
+
if (live.weeklyPct != null) console.log(`This week: ${live.weeklyPct}% used · live`);
|
|
255
|
+
for (const [m, p] of Object.entries(live.weeklyByModel || {}))
|
|
256
|
+
console.log(`This week (${m}): ${p}% used · live | color=gray`);
|
|
257
|
+
console.log(`local estimate: ${w.tokens.toLocaleString()} tokens${w.pct != null ? ` · ${w.pct}%` : ""} | color=gray`);
|
|
258
|
+
console.log("---");
|
|
259
|
+
} else {
|
|
260
|
+
console.log(`5h window: ${w.tokens.toLocaleString()} tokens${w.pct != null ? ` · ${w.pct}%` : ""}`);
|
|
261
|
+
console.log(`${w.active ? `Resets in ${fmtDur(w.msToReset)}` : "Window idle"}${w.pct != null ? " · cap from config" : ""} | color=gray`);
|
|
262
|
+
if (w.pct == null) console.log("For a %, set TOKENTAB_WINDOW_CAP to your plan cap (from Claude /usage) | color=gray");
|
|
263
|
+
// Flag set but the live read failed (e.g. claude not resolvable, parse
|
|
264
|
+
// miss): say so instead of silently looking like live was never requested.
|
|
265
|
+
if (liveEnabled) console.log("live unavailable — using local estimate | color=gray");
|
|
266
|
+
console.log("---");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
console.log(`Today: ${agg.today.toLocaleString()} tokens`);
|
|
270
|
+
console.log(`This week: ${agg.thisWeek.toLocaleString()}`);
|
|
271
|
+
console.log(`Last 5h: ${agg.rolling5h.toLocaleString()}`);
|
|
272
|
+
if (agg.cost) {
|
|
273
|
+
console.log(
|
|
274
|
+
`Est. cost: ${fmtUsd(agg.cost.today)} today · ${fmtUsd(agg.cost.thisWeek)} week · ${fmtUsd(agg.cost.total)} all | color=gray`,
|
|
275
|
+
);
|
|
276
|
+
if (agg.cost.unpriced.tokens > 0)
|
|
277
|
+
console.log(` (${abbrev(agg.cost.unpriced.tokens)} tokens unpriced) | color=gray`);
|
|
278
|
+
}
|
|
279
|
+
console.log("---");
|
|
280
|
+
for (const [s, n] of Object.entries(agg.bySurface)) console.log(`${s}: ${n.toLocaleString()}`);
|
|
281
|
+
console.log("---");
|
|
282
|
+
console.log("Local only · No network · ~/.claude/projects read-only | color=gray");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// human report
|
|
287
|
+
const line = (l) => console.log(l);
|
|
288
|
+
line("");
|
|
289
|
+
line(" Token Tab " + "─".repeat(40));
|
|
290
|
+
line(` Today: ${abbrev(agg.today).padStart(8)} (${agg.today.toLocaleString()} tokens)`);
|
|
291
|
+
line(` This week: ${abbrev(agg.thisWeek).padStart(8)} (${agg.thisWeek.toLocaleString()})`);
|
|
292
|
+
line(` Last 5h: ${abbrev(agg.rolling5h).padStart(8)} (${agg.rolling5h.toLocaleString()})`);
|
|
293
|
+
line(` All time: ${abbrev(agg.total).padStart(8)} (${agg.total.toLocaleString()})`);
|
|
294
|
+
line("");
|
|
295
|
+
line(
|
|
296
|
+
` 5h window: ${abbrev(w.tokens).padStart(8)} ${w.pct != null ? `${w.pct}% of cap (${w.capSource})` : "set TOKENTAB_WINDOW_CAP for %"}` +
|
|
297
|
+
` ${w.active ? "resets in " + fmtDur(w.msToReset) : "(idle)"}`,
|
|
298
|
+
);
|
|
299
|
+
line("");
|
|
300
|
+
if (live) {
|
|
301
|
+
line(" live · claude /usage " + "─".repeat(29));
|
|
302
|
+
line(` session: ${live.sessionPct}% used${live.sessionResetText ? ` resets ${live.sessionResetText}` : ""}`);
|
|
303
|
+
if (live.weeklyPct != null)
|
|
304
|
+
line(` week: ${live.weeklyPct}% used${live.weeklyResetText ? ` resets ${live.weeklyResetText}` : ""}`);
|
|
305
|
+
for (const [m, p] of Object.entries(live.weeklyByModel || {})) line(` week (${m}): ${p}% used`);
|
|
306
|
+
line("");
|
|
307
|
+
} else if (liveEnabled) {
|
|
308
|
+
line(" live unavailable — using local estimate (set TOKENTAB_LIVE_DEBUG=1 for the reason)");
|
|
309
|
+
line("");
|
|
310
|
+
}
|
|
311
|
+
if (agg.cost) {
|
|
312
|
+
line(" Cost estimate " + "─".repeat(38));
|
|
313
|
+
line(
|
|
314
|
+
` today: ${fmtUsd(agg.cost.today).padStart(9)} this week: ${fmtUsd(agg.cost.thisWeek).padStart(9)} all time: ${fmtUsd(agg.cost.total).padStart(9)}`,
|
|
315
|
+
);
|
|
316
|
+
const priced = Object.entries(agg.cost.byModel).sort((a, b) => b[1] - a[1]);
|
|
317
|
+
if (priced.length) {
|
|
318
|
+
line(" by model:");
|
|
319
|
+
for (const [m, d] of priced.slice(0, 8)) line(` ${m.padEnd(28)} ${fmtUsd(d).padStart(9)}`);
|
|
320
|
+
}
|
|
321
|
+
if (agg.cost.unpriced.tokens > 0)
|
|
322
|
+
line(
|
|
323
|
+
` unpriced: ${agg.cost.unpriced.requests} requests / ${abbrev(agg.cost.unpriced.tokens)} tokens (no rate for: ${agg.cost.unpriced.models.join(", ")})`,
|
|
324
|
+
);
|
|
325
|
+
line(" estimate from a bundled price table — a tab, not an invoice.");
|
|
326
|
+
line("");
|
|
327
|
+
}
|
|
328
|
+
line(` Surface: ${surface} (${surfaceOverride ? "mode override" : "dominant"})`);
|
|
329
|
+
for (const [s, n] of Object.entries(agg.bySurface).sort((a, b) => b[1] - a[1]))
|
|
330
|
+
line(` ${s.padEnd(13)} ${abbrev(n).padStart(8)}`);
|
|
331
|
+
line("");
|
|
332
|
+
line(" By model:");
|
|
333
|
+
for (const [m, n] of Object.entries(agg.byModel).sort((a, b) => b[1] - a[1]).slice(0, 8))
|
|
334
|
+
line(` ${m.padEnd(28)} ${abbrev(n).padStart(8)}`);
|
|
335
|
+
line("");
|
|
336
|
+
line(" By token class:");
|
|
337
|
+
line(` input ${abbrev(agg.byClass.input)} · cache-create ${abbrev(agg.byClass.cacheCreate)} · cache-read ${abbrev(agg.byClass.cacheRead)} · output ${abbrev(agg.byClass.output)}`);
|
|
338
|
+
line("");
|
|
339
|
+
line(" Parse health " + "─".repeat(38));
|
|
340
|
+
line(` files: ${files.length}`);
|
|
341
|
+
line(` usage records counted: ${agg.dedup.counted.toLocaleString()}`);
|
|
342
|
+
line(` duplicates dropped: ${agg.dedup.duplicatesDropped.toLocaleString()}`);
|
|
343
|
+
line(` keep-last revisions (normal for streaming): ${agg.dedup.collisionsDifferingTotals}`);
|
|
344
|
+
line(` approximate (missing id): ${agg.approximate}`);
|
|
345
|
+
line(` untracked: ${agg.untracked.requests} requests / ${abbrev(agg.untracked.tokens)} tokens`);
|
|
346
|
+
line(` malformed lines skipped: ${parseErrors.length}`);
|
|
347
|
+
line("");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
main().catch((e) => {
|
|
351
|
+
console.error("token-tab error:", e.message);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Token Tab: SwiftBar plugin
|
|
2
|
+
|
|
3
|
+
The fast on-ramp: get your token tab in the menu bar tonight, no app to build or sign.
|
|
4
|
+
(The native sandboxed app is the long-term keeper; see the root README. This path
|
|
5
|
+
trades the scoped-read trust story for speed: **SwiftBar itself may need Full Disk
|
|
6
|
+
Access** to read `~/.claude`, which is a broader grant than the native app asks for.)
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
1. Install [SwiftBar](https://github.com/swiftbar/SwiftBar): `brew install swiftbar`,
|
|
11
|
+
launch it, and pick a plugin folder when prompted.
|
|
12
|
+
2. Symlink this plugin into that folder (symlink, so updates to the repo apply live):
|
|
13
|
+
```sh
|
|
14
|
+
ln -s "$(pwd)/swiftbar/token-tab.30s.sh" \
|
|
15
|
+
~/Library/Application\ Support/SwiftBar/token-tab.30s.sh
|
|
16
|
+
```
|
|
17
|
+
(Use your actual SwiftBar plugin folder if you chose a different one.)
|
|
18
|
+
3. Make sure it's executable: `chmod +x swiftbar/token-tab.30s.sh`
|
|
19
|
+
4. SwiftBar → **Refresh all**. You should see `◧ <today's tokens>` in the menu bar.
|
|
20
|
+
|
|
21
|
+
## Live server % (opt-in)
|
|
22
|
+
|
|
23
|
+
Want the authoritative session/weekly `%` (what `claude /usage` shows), not just the
|
|
24
|
+
local estimate? Install the live plugin instead of (or alongside) the default:
|
|
25
|
+
```sh
|
|
26
|
+
ln -s "$(pwd)/swiftbar/token-tab-live.2m.sh" \
|
|
27
|
+
~/Library/Application\ Support/SwiftBar/token-tab-live.2m.sh
|
|
28
|
+
```
|
|
29
|
+
It refreshes every 2 minutes (not 30s — `/usage` shouldn't be polled that often), sets
|
|
30
|
+
`TOKENTAB_LIVE=1`, and resolves the `claude` binary. It shells out to the official
|
|
31
|
+
`claude` CLI (which does the network/keychain); Token Tab only parses the output and
|
|
32
|
+
falls back to the local estimate on any failure. See "The live server %" in the root
|
|
33
|
+
README. If `claude` isn't auto-found, set `TOKENTAB_CLAUDE_BIN` to its absolute path.
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
- `node` (≥18) or `bun` on disk. The wrapper looks in Homebrew/system paths because
|
|
37
|
+
SwiftBar runs with a minimal `PATH`.
|
|
38
|
+
|
|
39
|
+
## Troubleshooting
|
|
40
|
+
- **Nothing shows / blank:** SwiftBar may need **Full Disk Access** to read
|
|
41
|
+
`~/.claude/projects`. System Settings → Privacy & Security → Full Disk Access → add
|
|
42
|
+
SwiftBar. (This broad grant is exactly why the native app exists.)
|
|
43
|
+
- **"No node/bun found":** install Node (`brew install node`) or Bun.
|
|
44
|
+
- **Custom log location:** set `TOKENTAB_LOG_DIR` in the plugin's environment, or rely
|
|
45
|
+
on `CLAUDE_CONFIG_DIR`.
|
|
46
|
+
- **Different refresh rate:** rename the symlink, e.g. `token-tab.1m.sh` for 1 minute.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Token Tab — OPT-IN live SwiftBar plugin (server %s via `claude -p "/usage"`).
|
|
3
|
+
#
|
|
4
|
+
# Install THIS file (instead of, or alongside, token-tab.30s.sh) to enable the
|
|
5
|
+
# live server percentages in the menu bar. It refreshes every 2 minutes (the
|
|
6
|
+
# ".2m." in the filename), not 30s, because each refresh spawns the full `claude`
|
|
7
|
+
# CLI and the research is explicit: do not poll /usage every 30s.
|
|
8
|
+
#
|
|
9
|
+
# The default token-tab.30s.sh stays purely local and never spawns anything.
|
|
10
|
+
# Installing this plugin IS how you opt in on the menu bar.
|
|
11
|
+
#
|
|
12
|
+
# Install: symlink into your SwiftBar plugin folder, e.g.
|
|
13
|
+
# ln -s "$PWD/swiftbar/token-tab-live.2m.sh" ~/Library/Application\ Support/SwiftBar/token-tab-live.2m.sh
|
|
14
|
+
# Then SwiftBar -> Refresh.
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
# 1. Resolve this script's real location (follow symlinks), then the repo root.
|
|
19
|
+
SELF="${BASH_SOURCE[0]}"
|
|
20
|
+
while [ -L "$SELF" ]; do
|
|
21
|
+
TARGET="$(readlink "$SELF")"
|
|
22
|
+
case "$TARGET" in
|
|
23
|
+
/*) SELF="$TARGET" ;;
|
|
24
|
+
*) SELF="$(dirname "$SELF")/$TARGET" ;;
|
|
25
|
+
esac
|
|
26
|
+
done
|
|
27
|
+
REPO="$(cd "$(dirname "$SELF")/.." && pwd)"
|
|
28
|
+
|
|
29
|
+
# 2. Resolve a JS runtime (SwiftBar gives plugins a minimal PATH, so a bare
|
|
30
|
+
# `node` shebang often fails).
|
|
31
|
+
RT=""
|
|
32
|
+
for c in /opt/homebrew/bin/node /usr/local/bin/node "$HOME/.bun/bin/bun" /opt/homebrew/bin/bun; do
|
|
33
|
+
[ -x "$c" ] && RT="$c" && break
|
|
34
|
+
done
|
|
35
|
+
[ -z "$RT" ] && RT="$(command -v node || command -v bun || true)"
|
|
36
|
+
|
|
37
|
+
if [ -z "$RT" ]; then
|
|
38
|
+
echo "Token Tab ⚠️"
|
|
39
|
+
echo "---"
|
|
40
|
+
echo "No node/bun found on PATH. Install Node or Bun. | color=red"
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# 3. Resolve the `claude` binary the same way, for the same reason: SwiftBar's
|
|
45
|
+
# minimal PATH usually won't have it. The adapter also resolves this itself,
|
|
46
|
+
# but exporting it here is belt-and-suspenders.
|
|
47
|
+
if [ -z "${TOKENTAB_CLAUDE_BIN:-}" ]; then
|
|
48
|
+
for c in /opt/homebrew/bin/claude /usr/local/bin/claude "$HOME/.claude/local/claude" "$HOME/.local/bin/claude" "$HOME/.bun/bin/claude"; do
|
|
49
|
+
[ -x "$c" ] && export TOKENTAB_CLAUDE_BIN="$c" && break
|
|
50
|
+
done
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
export TOKENTAB_LIVE=1
|
|
54
|
+
|
|
55
|
+
exec "$RT" "$REPO/src/token-tab.mjs" --swiftbar
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Token Tab — SwiftBar plugin (the "tonight" on-ramp; the native app is the keeper).
|
|
3
|
+
#
|
|
4
|
+
# Refresh: every 30s (the ".30s." in the filename — SwiftBar reads it from there).
|
|
5
|
+
# Install: symlink this file into your SwiftBar plugin folder, e.g.
|
|
6
|
+
# ln -s "$PWD/swiftbar/token-tab.30s.sh" ~/Library/Application\ Support/SwiftBar/token-tab.30s.sh
|
|
7
|
+
# Then SwiftBar → Refresh. See swiftbar/README.md.
|
|
8
|
+
#
|
|
9
|
+
# This wrapper exists because SwiftBar runs plugins with a minimal PATH, so a bare
|
|
10
|
+
# `node` shebang often fails. It (1) resolves a JS runtime from known locations and
|
|
11
|
+
# (2) resolves the repo even when invoked via a symlink in the plugin folder.
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
# 1. Resolve this script's real location (follow symlinks), then the repo root.
|
|
16
|
+
SELF="${BASH_SOURCE[0]}"
|
|
17
|
+
while [ -L "$SELF" ]; do
|
|
18
|
+
TARGET="$(readlink "$SELF")"
|
|
19
|
+
case "$TARGET" in
|
|
20
|
+
/*) SELF="$TARGET" ;;
|
|
21
|
+
*) SELF="$(dirname "$SELF")/$TARGET" ;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
REPO="$(cd "$(dirname "$SELF")/.." && pwd)"
|
|
25
|
+
|
|
26
|
+
# 2. Resolve a JS runtime (Homebrew arm/intel, system, bun, then PATH).
|
|
27
|
+
RT=""
|
|
28
|
+
for c in /opt/homebrew/bin/node /usr/local/bin/node "$HOME/.bun/bin/bun" /opt/homebrew/bin/bun; do
|
|
29
|
+
[ -x "$c" ] && RT="$c" && break
|
|
30
|
+
done
|
|
31
|
+
[ -z "$RT" ] && RT="$(command -v node || command -v bun || true)"
|
|
32
|
+
|
|
33
|
+
if [ -z "$RT" ]; then
|
|
34
|
+
echo "Token Tab ⚠️"
|
|
35
|
+
echo "---"
|
|
36
|
+
echo "No node/bun found on PATH. Install Node or Bun. | color=red"
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
exec "$RT" "$REPO/src/token-tab.mjs" --swiftbar
|