@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.
@@ -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