@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
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
|
+
[](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
|
+
}
|