@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yiftach Cohen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # Token Tab
2
+
3
+ <p align="center">
4
+ <img src="app/Branding/gauge-appicon.png" alt="Token Tab" width="116" height="116"><br>
5
+ <picture>
6
+ <source media="(prefers-color-scheme: dark)" srcset="app/Branding/gauge-wordmark-dark.png">
7
+ <img src="app/Branding/gauge-wordmark.png" alt="Token Tab" width="172">
8
+ </picture>
9
+ </p>
10
+
11
+ [![CI](https://github.com/YiftachCohen/token-tab/actions/workflows/ci.yml/badge.svg)](https://github.com/YiftachCohen/token-tab/actions/workflows/ci.yml)
12
+
13
+ Token Tab shows your Claude Code token usage in the macOS menu bar. It reads the logs
14
+ Claude Code already writes to `~/.claude` — no API keys, no keychain, no network calls.
15
+ It reads token counts off disk and shows them; nothing leaves your machine.
16
+
17
+ Click the menu bar item for your current 5-hour usage window (with an exact reset
18
+ countdown) and a local cost estimate.
19
+
20
+ ## What it reads
21
+
22
+ - `~/.claude/projects/**/*.jsonl` — the transcripts Claude Code already writes.
23
+ - Tokens per model, per surface (subscription / Bedrock), per window (today / this week / last 5h).
24
+ - A dollar **estimate** from a bundled per-model rate table — local arithmetic, not an invoice (see [Cost](#cost)).
25
+
26
+ Works the same whether Claude Code talks to the Anthropic API, a Max/Pro subscription,
27
+ or AWS Bedrock: the token counts are in the local logs either way, so no AWS
28
+ credentials are needed to read them.
29
+
30
+ ## Trust model
31
+
32
+ The point of Token Tab is that it has no way to leak anything. Each claim is verifiable:
33
+
34
+ - **No network.** No network code, no dependencies.
35
+ - **No content.** The parser decodes only the metadata it needs — `type`, `model`,
36
+ `message.id`, `requestId`, `usage`, `timestamp`, `isSidechain`. It never touches
37
+ `message.content` (your prompts, code, and responses).
38
+ - **No state.** No cache, no telemetry, nothing written.
39
+
40
+ What it can't claim is to be *blind* to your data. Any usage meter has to read the
41
+ logs, and those logs contain your prompts. The narrower guarantee holds: it reads the
42
+ numbers, sends nothing, stores nothing.
43
+
44
+ ### Audit it yourself
45
+
46
+ It's one dependency-free script. These greps over `src/` all print nothing:
47
+
48
+ ```sh
49
+ grep -RnE "fetch|http|https|net\.|URLSession|Socket|dns" src/ # no network
50
+ grep -RnE "child_process|spawn|execFile" src/ # no subprocess
51
+ grep -RnE "\.content" src/ | grep -v "//" # never reads content
52
+ cat package.json | grep -A1 dependencies # -> {}
53
+ ```
54
+
55
+ The parser is `recordFromLine` in `src/core.mjs` — it returns `message.id`, `model`,
56
+ `usage`, `timestamp`, `isSidechain`, never content. The one subprocess in the repo is
57
+ the opt-in live reader, fenced outside `src/` in `adapters/` (see
58
+ [Live server %](#live-server--opt-in)). The native app's own audit — sandbox
59
+ entitlements, no-network greps over `app/Sources` — is in
60
+ [`app/README.md`](app/README.md#the-two-minute-audit-native-build).
61
+
62
+ ## What runs where
63
+
64
+ One job — read the logs, aggregate, show usage — as two engines (a JS core and a Swift
65
+ port kept in parity) behind three front-ends. Only the opt-in live path touches the
66
+ network:
67
+
68
+ | Piece | What it is | Network? |
69
+ |---|---|---|
70
+ | `src/` JS engine | parse + dedup + aggregate | no |
71
+ | **CLI** | `node src/token-tab.mjs` → a terminal report | no |
72
+ | **SwiftBar** | shell wrappers run the JS engine on a timer | no¹ |
73
+ | **Native app** | `app/Token Tab.app`, the menu-bar UI (App-Sandboxed, no network entitlement) | no — kernel-enforced |
74
+ | **Live sidecar** | `adapters/write-live.mjs` runs `claude /usage`, writes a cache file | yes — via `claude` |
75
+
76
+ ¹ only the `…-live.2m.sh` variant calls `claude`; the default `…30s.sh` does not.
77
+
78
+ ## Quick start
79
+
80
+ ### CLI
81
+ ```sh
82
+ node src/token-tab.mjs # human report
83
+ node src/token-tab.mjs --json # machine-readable
84
+ node src/token-tab.mjs --swiftbar # SwiftBar format
85
+ ```
86
+
87
+ ### Menu bar — SwiftBar
88
+ One symlink and you have `◧ <tokens>` in the menu bar in about a minute. See
89
+ [`swiftbar/README.md`](swiftbar/README.md). SwiftBar may need Full Disk Access to read
90
+ `~/.claude` — broader than the native app's scoped grant; it's the fast on-ramp.
91
+
92
+ ### Menu bar — native app
93
+ A SwiftUI `MenuBarExtra` app, App-Sandboxed with no network entitlement, reading
94
+ `~/.claude` through a scoped read-only grant. Grab the notarized zip from the
95
+ [Releases page](https://github.com/YiftachCohen/token-tab/releases) when one is up, or
96
+ build it yourself in two minutes from [`app/README.md`](app/README.md) — the from-source
97
+ path is the point of the trust model anyway. Release builds are signed + notarized
98
+ locally, never in CI ([`RELEASING.md`](RELEASING.md)).
99
+
100
+ ## Accuracy
101
+
102
+ Validated against [`ccusage`](https://github.com/ryoppippi/ccusage) on real logs:
103
+ **99.997% match** on Claude token counts. Two notes:
104
+
105
+ - `ccusage` now also counts Codex (`gpt-5.5`); Token Tab is Claude-only, so compare
106
+ Claude-only subtotals.
107
+ - Streaming emits several usage lines per message that share one id, with
108
+ `output_tokens` growing across them; the parser keeps the last (final) line. It's the
109
+ one dedup rule that moves the total, and a test pins it.
110
+
111
+ ## Cost
112
+
113
+ The report and dropdown show a dollar **estimate** next to the token counts — today,
114
+ this week, all time, and per model. It's local arithmetic on a bundled rate table, on
115
+ by default (no network, no key). Scope:
116
+
117
+ - **An estimate, not your bill.** Bedrock region surcharges and cache-TTL nuances
118
+ aren't modeled.
119
+ - **The rate table is [`src/pricing.mjs`](src/pricing.mjs)** — Anthropic's published
120
+ USD-per-million list rates, there to audit and edit. Cache classes derive from the
121
+ input rate by Anthropic's multipliers: cache **write** = 1.25× input (the 5-minute
122
+ rate; logs don't record the TTL), cache **read** = 0.10× input. All four token
123
+ classes are priced separately.
124
+ - **`[1m]` and Bedrock ids normalize to the base model** — no long-context premium on
125
+ current models; `us.anthropic.<id>` reuses the same list rate.
126
+ - **Unknown model ⇒ tokens counted, price not invented.** It still counts toward every
127
+ token total; it just lands in an `unpriced` line instead of getting a guessed dollar
128
+ figure.
129
+
130
+ Token counts reconcile with `ccusage` (per-model within ~0.03%). The **dollar totals
131
+ differ by design** — `ccusage` prices off LiteLLM's community table, Token Tab off
132
+ Anthropic's list rates — so expect divergence on cache-heavy, Opus-tier usage. Token
133
+ Tab's rates sit in one short, editable file.
134
+
135
+ ## Configuration
136
+
137
+ Set these as env vars, or in a local `KEY=VALUE` file kept out of the repo —
138
+ `~/.config/token-tab/env` (or `~/.token-tab.env`). Only `TOKENTAB_*` keys (plus
139
+ `CLAUDE_CODE_USE_BEDROCK`) are read; real env vars take precedence.
140
+
141
+ | Var | What it does |
142
+ |---|---|
143
+ | `TOKENTAB_LOG_DIR` | non-default log directory (default `~/.claude/projects`) |
144
+ | `CLAUDE_CONFIG_DIR` | reads `$CLAUDE_CONFIG_DIR/projects` |
145
+ | `TOKENTAB_WINDOW_CAP` | your plan's 5h token cap, to show a window `%` (see below) |
146
+ | `CLAUDE_CODE_USE_BEDROCK` | Claude Code's Bedrock flag; switches the app to the pay-per-token panel² |
147
+ | `TOKENTAB_LIVE` | opt in to the live server `%` via `claude -p "/usage"` (off by default) |
148
+ | `TOKENTAB_LIVE_CACHE` | where the live sidecar writes its JSON (default `<logDir>/.token-tab-live.json`) |
149
+ | `TOKENTAB_CLAUDE_BIN` | absolute path to `claude` when it isn't auto-resolved (SwiftBar's minimal PATH often needs this) |
150
+ | `TOKENTAB_LIVE_DEBUG` | prints why live data was unavailable to stderr (diagnostic only) |
151
+
152
+ ² On Bedrock, Claude Code logs bare `claude-*` ids that are indistinguishable from a
153
+ subscription, so the mode can't be inferred from the logs — this flag is the signal. A
154
+ sandboxed app launched from Finder won't see your shell exports, so put it in the env
155
+ file above too.
156
+
157
+ ## The 5-hour window
158
+
159
+ The headline is your token count for today. On a subscription the dropdown also shows
160
+ your current 5-hour rate-limit window, computed entirely from local logs (Anthropic
161
+ resets usage in fixed 5-hour blocks anchored to your first message of the block).
162
+
163
+ - **The reset countdown is exact** ("Resets in 3h36m"), verified against Claude's own
164
+ `/usage`: the window starts at your first message, not the top of the hour.
165
+ - **A `%` shows only when you set `TOKENTAB_WINDOW_CAP`.** Anthropic doesn't publish the
166
+ per-plan cap, and Token Tab won't guess one. To get it: open Claude's `/usage`, note
167
+ "N% used", and set the cap to `current-window-tokens / (N/100)`. For example, 20M at
168
+ 5% ⇒ `TOKENTAB_WINDOW_CAP=400000000`.
169
+
170
+ ## Live server % (opt-in)
171
+
172
+ The live server `%` (what Claude's `/usage` shows) is available with `TOKENTAB_LIVE=1`.
173
+ It does not make Token Tab phone home: it shells out to the official `claude` CLI
174
+ (`claude -p "/usage"`), which does the keychain read and the network call, and Token
175
+ Tab only parses the printed summary. The spawn lives in `adapters/claude-live.mjs`,
176
+ outside the audited `src/` core, so the `src/` audit stays clean even with live on.
177
+
178
+ - **Fails closed.** If `claude` can't be resolved, times out, or its output format
179
+ changes, it falls back to the local estimate and shows a gray `live unavailable` line
180
+ (set `TOKENTAB_LIVE_DEBUG=1` for the reason).
181
+ - **Native app: via a sidecar.** The sandboxed app can't spawn `claude`, so the opt-in
182
+ writer `adapters/write-live.mjs` runs `/usage` in a separate, user-launched process and
183
+ writes the parsed `%` to `<logDir>/.token-tab-live.json`; the app reads that file as
184
+ plain data (inside the folder you already granted, ignored by both log walkers — hidden
185
+ and not `*.jsonl`). The "no network" guarantee stays enforced — the network lives in the
186
+ sidecar you scheduled.
187
+
188
+ ```sh
189
+ adapters/install-live.sh # LaunchAgent, refreshes every 5 min
190
+ adapters/install-live.sh uninstall # stop + remove it
191
+ node adapters/write-live.mjs # one-off, no scheduler
192
+ ```
193
+
194
+ With a fresh reading the app headlines `91% left · live` and learns your cap from it
195
+ (cap ≈ window tokens ÷ session `%`), persisting it so a real `%` keeps showing once the
196
+ reading goes stale. On the menu bar, install `swiftbar/token-tab-live.2m.sh` (refreshes
197
+ every 2 minutes, sets `TOKENTAB_LIVE=1`); the default `token-tab.30s.sh` never spawns
198
+ anything.
199
+
200
+ The local 5-hour window stays the default everywhere and needs no opt-in.
201
+
202
+ ## Develop
203
+
204
+ ```sh
205
+ npm test # node --test, golden-fixture suite for the parser core
206
+ ```
207
+
208
+ `src/core.mjs` is a pure, I/O-free parser (so tests pin every edge case without a
209
+ filesystem); `src/pricing.mjs` is the pure price table + cost math, injected into the
210
+ parser so the rates stay testable; `src/token-tab.mjs` is the thin I/O shell. The Swift
211
+ port in `app/Sources/TokenTabCore` is kept in deliberate parity — see
212
+ [`AGENTS.md`](AGENTS.md).
213
+
214
+ Branding assets and usage live in [`app/Branding/`](app/Branding/README.md).
215
+ License: MIT.
@@ -0,0 +1,90 @@
1
+ // Token Tab — opt-in live usage adapter.
2
+ //
3
+ // This is the ONLY file in the repo that imports node:child_process. It is
4
+ // deliberately fenced OUTSIDE src/ so the trust-critical core stays a clean
5
+ // sweep: `grep -RnE "child_process|spawn|execFile" src/` prints nothing.
6
+ //
7
+ // It is loaded only via a dynamic import() in src/token-tab.mjs, and only when
8
+ // TOKENTAB_LIVE is enabled. It spawns the official `claude` CLI in print mode
9
+ // (`claude -p "/usage"`), which does the keychain read and the network call;
10
+ // Token Tab only parses the printed stdout. The parser is pure and lives in
11
+ // src/live-parse.mjs.
12
+ //
13
+ // Fail closed: ANY problem (binary not found, non-zero exit, timeout, parse
14
+ // miss) resolves to null. Stores/transmits nothing. The only optional output is
15
+ // a single stderr line when TOKENTAB_LIVE_DEBUG is set.
16
+
17
+ import { execFile } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { homedir, tmpdir } from "node:os";
21
+ import { parseUsageOutput } from "../src/live-parse.mjs";
22
+
23
+ // SwiftBar runs plugins with a minimal PATH (the wrapper already hardcodes the
24
+ // `node` lookup for this reason), so `claude` is usually NOT on PATH there.
25
+ // Resolve it explicitly, honoring an override, then known install locations,
26
+ // then PATH as a last resort.
27
+ function resolveClaude() {
28
+ if (process.env.TOKENTAB_CLAUDE_BIN) return process.env.TOKENTAB_CLAUDE_BIN;
29
+ const candidates = [
30
+ "/opt/homebrew/bin/claude",
31
+ "/usr/local/bin/claude",
32
+ join(homedir(), ".claude", "local", "claude"),
33
+ join(homedir(), ".local", "bin", "claude"),
34
+ join(homedir(), ".bun", "bin", "claude"),
35
+ ];
36
+ for (const c of candidates) {
37
+ if (existsSync(c)) return c;
38
+ }
39
+ return "claude"; // last resort: PATH
40
+ }
41
+
42
+ const isDebug = (v) => typeof v === "string" && /^(1|true|yes|on)$/i.test(v.trim());
43
+
44
+ /**
45
+ * Spawn `claude -p "/usage" --output-format json`, parse it, and return the live
46
+ * window object — or null on any failure. Never throws.
47
+ *
48
+ * @param {{timeoutMs?:number}} opts
49
+ * @returns {Promise<null | object>}
50
+ */
51
+ export async function readLiveUsage({ timeoutMs = 5000 } = {}) {
52
+ const bin = resolveClaude();
53
+ const debug = isDebug(process.env.TOKENTAB_LIVE_DEBUG);
54
+ return await new Promise((resolve) => {
55
+ let settled = false;
56
+ const done = (val, reason) => {
57
+ if (settled) return; // settle exactly once (timeout vs late callback)
58
+ settled = true;
59
+ if (val == null && debug && reason) console.error(`[token-tab live] ${reason}`);
60
+ resolve(val);
61
+ };
62
+ let child;
63
+ try {
64
+ child = execFile(
65
+ bin,
66
+ ["-p", "/usage", "--output-format", "json"],
67
+ // Run claude in a neutral temp dir, NOT the inherited cwd. `claude -p` still
68
+ // boots a session rooted at its working directory and indexes it; under the
69
+ // LaunchAgent that cwd is $HOME, so it walks into ~/Desktop / ~/Documents /
70
+ // ~/Downloads and trips macOS's TCC consent prompts (attributed to the parent
71
+ // "node"). /usage reads the server quota, never local files, so an empty
72
+ // tmpdir gives it nothing to scan and the prompts never fire.
73
+ { cwd: tmpdir(), timeout: timeoutMs, killSignal: "SIGTERM", maxBuffer: 256 * 1024, windowsHide: true },
74
+ (err, stdout) => {
75
+ if (err) return done(null, `spawn failed: ${err.code || err.message}`);
76
+ const parsed = parseUsageOutput(stdout);
77
+ return done(parsed, parsed ? null : "parse miss");
78
+ },
79
+ );
80
+ } catch (e) {
81
+ return done(null, `exec threw: ${e.message}`);
82
+ }
83
+ // Never let `claude -p` block waiting on stdin.
84
+ try {
85
+ child.stdin?.end();
86
+ } catch {
87
+ /* ignore */
88
+ }
89
+ });
90
+ }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ # Token Tab — install the live sidecar as a per-user LaunchAgent.
3
+ #
4
+ # This is the "blessed" way to keep the live server-% fresh: it runs
5
+ # adapters/write-live.mjs on a timer so the menu-bar app always has a recent
6
+ # reading to display. The app itself never runs this — it's sandboxed with no
7
+ # network. Everything the network/keychain touches lives in this scheduled
8
+ # helper, which YOU install here and can remove at any time.
9
+ #
10
+ # Usage:
11
+ # adapters/install-live.sh # install + load (refresh every 5 min)
12
+ # adapters/install-live.sh uninstall # stop + remove the LaunchAgent
13
+ # adapters/install-live.sh print # print the plist it would write (no changes)
14
+ #
15
+ # Override the cadence: TOKENTAB_LIVE_INTERVAL=120 adapters/install-live.sh
16
+ set -euo pipefail
17
+
18
+ LABEL="com.tokentab.live"
19
+ PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
20
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # adapters/
21
+ SIDECAR="$HERE/write-live.mjs"
22
+ LOG="$HOME/Library/Logs/token-tab-live.log"
23
+ INTERVAL="${TOKENTAB_LIVE_INTERVAL:-300}"
24
+ DOMAIN="gui/$(id -u)"
25
+
26
+ # Resolve node (baked in absolutely — launchd has a minimal PATH) and claude
27
+ # (passed as TOKENTAB_CLAUDE_BIN so the sidecar finds it without a login shell).
28
+ NODE="$(command -v node || true)"
29
+ CLAUDE="$(command -v claude || true)"
30
+ for c in /opt/homebrew/bin/claude /usr/local/bin/claude "$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "$HOME/.bun/bin/claude"; do
31
+ [ -z "$CLAUDE" ] && [ -x "$c" ] && CLAUDE="$c"
32
+ done
33
+
34
+ gen_plist() {
35
+ local claude_env=""
36
+ [ -n "$CLAUDE" ] && claude_env="
37
+ <key>TOKENTAB_CLAUDE_BIN</key><string>$CLAUDE</string>"
38
+ cat <<PLIST
39
+ <?xml version="1.0" encoding="UTF-8"?>
40
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
41
+ <plist version="1.0">
42
+ <dict>
43
+ <key>Label</key><string>$LABEL</string>
44
+ <key>ProgramArguments</key>
45
+ <array>
46
+ <string>$NODE</string>
47
+ <string>$SIDECAR</string>
48
+ </array>
49
+ <key>EnvironmentVariables</key>
50
+ <dict>
51
+ <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>$claude_env
52
+ </dict>
53
+ <key>RunAtLoad</key><true/>
54
+ <key>StartInterval</key><integer>$INTERVAL</integer>
55
+ <key>StandardOutPath</key><string>$LOG</string>
56
+ <key>StandardErrorPath</key><string>$LOG</string>
57
+ </dict>
58
+ </plist>
59
+ PLIST
60
+ }
61
+
62
+ case "${1:-install}" in
63
+ print)
64
+ gen_plist
65
+ ;;
66
+ uninstall)
67
+ launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || launchctl unload "$PLIST" 2>/dev/null || true
68
+ rm -f "$PLIST"
69
+ echo "✓ live helper removed ($LABEL)"
70
+ ;;
71
+ install)
72
+ [ -n "$NODE" ] || { echo "✗ node not found on PATH — install Node, then re-run"; exit 1; }
73
+ [ -f "$SIDECAR" ] || { echo "✗ sidecar missing at $SIDECAR"; exit 1; }
74
+ [ -n "$CLAUDE" ] || echo "⚠ claude not found in the usual spots — set TOKENTAB_CLAUDE_BIN if live can't resolve it"
75
+ mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
76
+ gen_plist > "$PLIST"
77
+ launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
78
+ launchctl bootstrap "$DOMAIN" "$PLIST" 2>/dev/null || launchctl load "$PLIST"
79
+ echo "✓ live helper installed — refreshing every ${INTERVAL}s"
80
+ echo " reads: $SIDECAR"
81
+ echo " log: $LOG"
82
+ echo " remove: $0 uninstall"
83
+ ;;
84
+ *)
85
+ echo "usage: $0 [install|uninstall|print]"; exit 2
86
+ ;;
87
+ esac
@@ -0,0 +1,85 @@
1
+ // Token Tab — live-usage cache WRITER (opt-in, networked, fenced OUTSIDE src/).
2
+ //
3
+ // This is the bridge that lets the sandboxed menu-bar app show the real server-side %
4
+ // WITHOUT the app itself ever touching the network. The app cannot run `claude` (no
5
+ // network entitlement — enforced), so instead THIS process — separate, user-launched —
6
+ // does the `claude -p "/usage"` call (via the existing adapter), parses it, and writes the
7
+ // percentages to a small JSON file the app then reads as plain data.
8
+ //
9
+ // The network + keychain access lives entirely here, in something you opted into and
10
+ // scheduled yourself. Run it on a timer to keep the app's live numbers fresh:
11
+ //
12
+ // # one-off
13
+ // node adapters/write-live.mjs
14
+ // # every 2 minutes via launchd / cron / SwiftBar wrapper
15
+ //
16
+ // Writes to `<logDir>/.token-tab-live.json` (logDir = the same dir token-tab.mjs reads),
17
+ // which is inside the folder the app is already granted — so no extra permission, and both
18
+ // log walkers ignore it (hidden + not *.jsonl). Atomic write so the app never sees a
19
+ // half-written file. Fails closed: if the live read returns null, nothing is written.
20
+
21
+ import { writeFileSync, renameSync, mkdirSync } from "node:fs";
22
+ import { join, dirname } from "node:path";
23
+ import { homedir } from "node:os";
24
+ import { readLiveUsage } from "./claude-live.mjs";
25
+
26
+ const SCHEMA = 1;
27
+
28
+ /// Mirror resolveLogDir() in src/token-tab.mjs so the cache lands where the app reads.
29
+ export function liveCachePath(env = process.env) {
30
+ const file = ".token-tab-live.json";
31
+ if (env.TOKENTAB_LIVE_CACHE) return env.TOKENTAB_LIVE_CACHE; // explicit override
32
+ if (env.TOKENTAB_LOG_DIR) return join(env.TOKENTAB_LOG_DIR, file);
33
+ if (env.CLAUDE_CONFIG_DIR) return join(env.CLAUDE_CONFIG_DIR, "projects", file);
34
+ return join(homedir(), ".claude", "projects", file);
35
+ }
36
+
37
+ /// PURE: shape a parsed live reading into the on-disk JSON (or null if there's nothing to
38
+ /// write). Kept separate from I/O so it's unit-testable without spawning `claude`. Percent
39
+ /// fields are normalized to `null` (not undefined) so the JSON is stable and explicit.
40
+ export function serializeLive(reading, capturedAtIso) {
41
+ if (!reading || (reading.sessionPct == null && reading.weeklyPct == null)) return null;
42
+ return JSON.stringify(
43
+ {
44
+ schema: SCHEMA,
45
+ source: reading.source || "claude /usage",
46
+ capturedAt: capturedAtIso,
47
+ sessionPct: reading.sessionPct ?? null,
48
+ sessionResetText: reading.sessionResetText ?? null,
49
+ weeklyPct: reading.weeklyPct ?? null,
50
+ weeklyResetText: reading.weeklyResetText ?? null,
51
+ weeklyByModel: reading.weeklyByModel || {},
52
+ },
53
+ null,
54
+ 2,
55
+ );
56
+ }
57
+
58
+ /// Read live usage and atomically write the cache. Never throws; returns a small status.
59
+ export async function writeLiveCache({ path = liveCachePath(), now = new Date() } = {}) {
60
+ const reading = await readLiveUsage();
61
+ const json = serializeLive(reading, now.toISOString());
62
+ if (!json) return { ok: false, reason: "live unavailable — wrote nothing" };
63
+ try {
64
+ mkdirSync(dirname(path), { recursive: true });
65
+ const tmp = `${path}.tmp`;
66
+ writeFileSync(tmp, json, { mode: 0o600 });
67
+ renameSync(tmp, path); // atomic swap — the app never reads a torn file
68
+ return { ok: true, path };
69
+ } catch (e) {
70
+ return { ok: false, reason: `write failed: ${e.message}` };
71
+ }
72
+ }
73
+
74
+ // CLI entry: `node adapters/write-live.mjs`. Status goes to stderr (never stdout), so the
75
+ // command is quiet on success and composes cleanly inside a SwiftBar/cron wrapper.
76
+ if (import.meta.url === `file://${process.argv[1]}`) {
77
+ writeLiveCache().then((r) => {
78
+ if (r.ok) {
79
+ console.error(`[token-tab] wrote ${r.path}`);
80
+ } else {
81
+ console.error(`[token-tab] ${r.reason}`);
82
+ process.exit(1);
83
+ }
84
+ });
85
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@ycstudios/token-tab",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "A provably-safe AI usage meter for the macOS menu bar. Reads local Claude Code logs, makes no network calls, keeps none of your content.",
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/YiftachCohen/token-tab.git"
13
+ },
14
+ "homepage": "https://github.com/YiftachCohen/token-tab#readme",
15
+ "bugs": "https://github.com/YiftachCohen/token-tab/issues",
16
+ "bin": {
17
+ "token-tab": "src/token-tab.mjs"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "adapters",
22
+ "swiftbar"
23
+ ],
24
+ "scripts": {
25
+ "start": "node src/token-tab.mjs",
26
+ "swiftbar": "node src/token-tab.mjs --swiftbar",
27
+ "test": "node --test",
28
+ "prepublishOnly": "node --test"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "dependencies": {},
34
+ "keywords": ["claude", "claude-code", "tokens", "usage", "menubar", "macos", "ccusage"]
35
+ }