@emesoft/tokenhub 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,12 +4,13 @@ Pair Claude Code on this machine with a [TokenHub](https://tokenhub.aimpact.io.v
4
4
 
5
5
  ```
6
6
  npx @emesoft/tokenhub connect https://tokenhub.aimpact.io.vn
7
+ npx @emesoft/tokenhub connect https://tokenhub.aimpact.io.vn --no-statusline
7
8
  npx @emesoft/tokenhub usage
8
9
  npx @emesoft/tokenhub dashboard
9
10
  npx @emesoft/tokenhub disconnect
10
11
  ```
11
12
 
12
- `connect` prints a short code and opens your browser. Confirm the code on the pool's member site and the CLI writes `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` into `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`). Other settings are kept, and the previous file is saved as `settings.json.bak`. Each paired machine gets its own key, which you can revoke on the Keys page.
13
+ `connect` prints a short code and opens your browser. Confirm the code on the pool's member site and the CLI writes `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` into `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`). It also adds a `statusLine` that shows today's spend and credit under the Claude Code prompt (pass `--no-statusline` to skip it; a `statusLine` you already had is left alone). Other settings are kept, and the previous file is saved as `settings.json.bak`. Each paired machine gets its own key, which you can revoke on the Keys page.
13
14
 
14
15
  `usage` shows today's and this week's spend against your limits, your credit and the pool's health (`--json` for the raw report). It exits 2 when the pool would currently refuse your requests (out of credit or budget reached).
15
16
 
@@ -17,7 +18,7 @@ npx @emesoft/tokenhub disconnect
17
18
 
18
19
  Requests to the pool carry your key and one extra header, `x-tokenhub-cli: <version>` (this CLI's version), and nothing else.
19
20
 
20
- `disconnect` removes only those two entries.
21
+ `disconnect` removes only those two entries and the TokenHub `statusLine`.
21
22
 
22
23
  If `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` is set in your shell, it overrides `settings.json`.
23
24
 
package/bin/tokenhub.js CHANGED
@@ -8,11 +8,13 @@ import { fetchUsage, formatUsage, resolveCredentials } from "../lib/usage.js";
8
8
  const HELP = `tokenhub - pair Claude Code with a TokenHub pool
9
9
 
10
10
  Usage:
11
- tokenhub connect <poolUrl> Pair this machine and write ~/.claude/settings.json
11
+ tokenhub connect <poolUrl> [--no-statusline]
12
+ Pair this machine and write ~/.claude/settings.json, including the status line
13
+ (today's spend under the prompt) unless --no-statusline
12
14
  tokenhub usage [--json] Show today's and this week's spend, credit and pool health
13
15
  tokenhub dashboard [--interval <sec>] [--once]
14
16
  Live full-screen view of spend, pool health and recent requests (q quits)
15
- tokenhub disconnect Remove the pool entries from settings.json
17
+ tokenhub disconnect Remove the pool entries and the status line from settings.json
16
18
  tokenhub --help | --version
17
19
  `;
18
20
 
@@ -22,7 +24,9 @@ Usage:
22
24
  * @returns {Promise<number>} Process exit code.
23
25
  */
24
26
  async function main(argv) {
25
- const [command, arg] = argv;
27
+ const [command, ...rest] = argv;
28
+ const flags = rest.filter((item) => item.startsWith("--"));
29
+ const arg = rest.find((item) => !item.startsWith("--"));
26
30
  if (!command || command === "--help" || command === "-h" || command === "help") {
27
31
  console.log(HELP);
28
32
  return command ? 0 : 1;
@@ -40,7 +44,7 @@ async function main(argv) {
40
44
  }
41
45
  const controller = new AbortController();
42
46
  process.once("SIGINT", () => controller.abort());
43
- return runConnect({ poolUrl: arg, settingsFile: file, signal: controller.signal });
47
+ return runConnect({ poolUrl: arg, settingsFile: file, signal: controller.signal, statusLine: !flags.includes("--no-statusline") });
44
48
  }
45
49
  if (command === "usage") {
46
50
  try {
@@ -50,7 +54,7 @@ async function main(argv) {
50
54
  return 1;
51
55
  }
52
56
  const report = await fetchUsage(credentials.baseUrl, credentials.key);
53
- console.log(arg === "--json" ? JSON.stringify(report, null, 2) : formatUsage(report));
57
+ console.log(flags.includes("--json") ? JSON.stringify(report, null, 2) : formatUsage(report));
54
58
  return report.blocked ? 2 : 0;
55
59
  } catch (error) {
56
60
  console.error(`Error: ${error.message}`);
package/lib/dashboard.js CHANGED
@@ -151,13 +151,16 @@ function blockedText(report, timeZone) {
151
151
  return `BLOCKED: ${period} budget reached${lifts}.`;
152
152
  }
153
153
 
154
+ const MAX_MODEL_WIDTH = 24;
155
+ const MIN_MODEL_WIDTH = 5;
156
+
154
157
  /**
155
- * Pick the optional table columns that fit the terminal width.
156
- * @param {number} columns Terminal width.
157
- * @returns {{ latency: boolean, tokens: boolean }} Which optional columns to show.
158
+ * Format a request cost at the precision the pool logs it, so small amounts are never rounded away.
159
+ * @param {number} amount USD.
160
+ * @returns {string} For example `$0.0435`.
158
161
  */
159
- function tableLayout(columns) {
160
- return { latency: columns >= 50, tokens: columns >= 70 };
162
+ function exactMoney(amount) {
163
+ return `$${(Number(amount) || 0).toFixed(4)}`;
161
164
  }
162
165
 
163
166
  /**
@@ -170,37 +173,116 @@ function statusTone(status) {
170
173
  }
171
174
 
172
175
  /**
173
- * Render one request as a table row.
176
+ * Choose the table columns: sized to the content, then thinned to fit the terminal.
177
+ * Drops In/Out first, then Latency, then shrinks the model column.
178
+ * @param {number} columns Terminal width.
179
+ * @param {Record<string, any>[]} recent Requests to show (the model column fits the longest name, capped).
180
+ * @returns {{ id: string, title: string, width: number, right: boolean }[]} Visible columns in order.
181
+ */
182
+ function tableColumns(columns, recent) {
183
+ const model = Math.min(MAX_MODEL_WIDTH, Math.max(MIN_MODEL_WIDTH, ...recent.map((request) => String(request.model ?? "-").length)));
184
+ let cols = [
185
+ { id: "time", title: "Time", width: 8, right: false },
186
+ { id: "model", title: "Model", width: model, right: false },
187
+ { id: "status", title: "Status", width: 6, right: false },
188
+ { id: "latency", title: "Latency", width: 7, right: true },
189
+ { id: "in", title: "In", width: 6, right: true },
190
+ { id: "out", title: "Out", width: 6, right: true },
191
+ { id: "cost", title: "Cost", width: 9, right: true },
192
+ ];
193
+ for (const drop of [["in", "out"], ["latency"]]) {
194
+ if (tableWidth(cols) <= columns - 1) break;
195
+ cols = cols.filter((col) => !drop.includes(col.id));
196
+ }
197
+ const excess = tableWidth(cols) - (columns - 1);
198
+ if (excess > 0) {
199
+ const modelCol = cols.find((col) => col.id === "model");
200
+ modelCol.width = Math.max(MIN_MODEL_WIDTH, modelCol.width - excess);
201
+ }
202
+ return cols;
203
+ }
204
+
205
+ /**
206
+ * Total width of a bordered table.
207
+ * @param {{ width: number }[]} cols Columns.
208
+ * @returns {number} Characters per line, borders and padding included.
209
+ */
210
+ function tableWidth(cols) {
211
+ return cols.reduce((sum, col) => sum + col.width + 3, 1);
212
+ }
213
+
214
+ /**
215
+ * Draw a horizontal table border.
216
+ * @param {{ width: number }[]} cols Columns.
217
+ * @param {[string, string, string]} corners Left, junction and right characters.
218
+ * @param {boolean} color Whether to emit colour.
219
+ * @returns {string} One grey line.
220
+ */
221
+ function border(cols, [left, mid, right], color) {
222
+ return paint(color, "grey", `${left}${cols.map((col) => "─".repeat(col.width + 2)).join(mid)}${right}`);
223
+ }
224
+
225
+ /**
226
+ * Draw one table line from already padded cells.
227
+ * @param {string[]} cells Cell text, each exactly its column width (colour codes allowed).
228
+ * @param {boolean} color Whether to emit colour.
229
+ * @returns {string} One line with grey vertical borders.
230
+ */
231
+ function tableLine(cells, color) {
232
+ const bar = paint(color, "grey", "│");
233
+ return `${bar}${cells.map((cell) => ` ${cell} `).join(bar)}${bar}`;
234
+ }
235
+
236
+ /**
237
+ * Pad text to a column width on its alignment side.
238
+ * @param {string} text Plain text, already no longer than the column.
239
+ * @param {{ width: number, right: boolean }} col Column.
240
+ * @returns {string} Text of exactly `col.width` characters.
241
+ */
242
+ function fit(text, col) {
243
+ return col.right ? text.padStart(col.width) : text.padEnd(col.width);
244
+ }
245
+
246
+ /**
247
+ * Render one request as table cells.
174
248
  * @param {Record<string, any>} request Entry from `/member/api/recent`.
175
- * @param {{ latency: boolean, tokens: boolean }} layout Visible columns.
176
- * @param {number} modelWidth Width reserved for the model name.
249
+ * @param {{ id: string, width: number, right: boolean }[]} cols Visible columns.
177
250
  * @param {boolean} color Whether to emit colour.
178
251
  * @param {string | undefined} timeZone IANA zone.
179
- * @returns {string} One line.
252
+ * @returns {string[]} One padded cell per column.
180
253
  */
181
- function requestRow(request, layout, modelWidth, color, timeZone) {
182
- const cells = [clock(request.started_at, timeZone), truncate(String(request.model ?? "-"), modelWidth).padEnd(modelWidth)];
183
- cells.push(paint(color, statusTone(request.status), String(request.status).padEnd(6)));
184
- if (layout.latency) cells.push(latency(request.latency_ms).padStart(7));
185
- if (layout.tokens) cells.push(tokens(request.input_tokens).padStart(7), tokens(request.output_tokens).padStart(7));
186
- cells.push(money(request.usd).padStart(8));
187
- return cells.join(" ");
254
+ function requestCells(request, cols, color, timeZone) {
255
+ const text = {
256
+ time: clock(request.started_at, timeZone),
257
+ model: String(request.model ?? "-"),
258
+ status: String(request.status),
259
+ latency: latency(request.latency_ms),
260
+ in: tokens(request.input_tokens),
261
+ out: tokens(request.output_tokens),
262
+ cost: exactMoney(request.usd),
263
+ };
264
+ return cols.map((col) => {
265
+ const cell = fit(truncate(text[col.id], col.width), col);
266
+ return col.id === "status" ? paint(color, statusTone(request.status), cell) : cell;
267
+ });
188
268
  }
189
269
 
190
270
  /**
191
271
  * Render the whole dashboard as text. Pure: no I/O, so it can be tested without a terminal.
192
272
  * @param {{ report: Record<string, any> | null, recent: Record<string, any>[], refreshedAt: number | null, error: string | null, intervalSec: number, poolHost?: string, timeZone?: string }} state Latest poll result.
193
273
  * @param {{ columns: number, rows: number, color: boolean, now: number, fill?: boolean }} view Terminal size, colour switch, current epoch ms and whether to pad to `rows` so the footer sits on the last row (default true).
194
- * @returns {string} Lines joined with `\n`, each at most `columns` wide, at most `rows` lines, no trailing newline.
274
+ * @returns {string} Lines joined with `\n`, each at most `columns - 1` wide (the last column is left free: some terminals clip it), at most `rows` lines, no trailing newline.
195
275
  */
196
276
  export function renderFrame(state, { columns, rows, color, now, fill = true }) {
197
277
  const { report, recent, refreshedAt, error, intervalSec, timeZone } = state;
278
+ const cols = tableColumns(columns, recent);
279
+ const width = tableWidth(cols);
198
280
  const lines = [];
199
281
  const stale = refreshedAt !== null && now - refreshedAt > intervalSec * 2000 + 1000;
200
282
  const refreshed = refreshedAt === null ? "waiting for first refresh" : `refreshed ${clock(refreshedAt, timeZone)}${stale ? " (stale)" : ""}`;
201
283
  const title = `${report ? report.member : "TokenHub"}${state.poolHost ? ` @ ${state.poolHost}` : ""}`;
202
284
  const right = `${refreshed} · every ${intervalSec}s`;
203
- lines.push(`${paint(color, "bold", title)}${" ".repeat(Math.max(2, columns - title.length - right.length))}${paint(color, "grey", right)}`);
285
+ lines.push(`${paint(color, "bold", title)}${" ".repeat(Math.max(2, width - title.length - right.length))}${paint(color, "grey", right)}`);
204
286
  lines.push("");
205
287
 
206
288
  if (report) {
@@ -216,23 +298,19 @@ export function renderFrame(state, { columns, rows, color, now, fill = true }) {
216
298
  lines.push("");
217
299
 
218
300
  const footer = error ? paint(color, "red", `reconnecting… ${error}`) : paint(color, "grey", "q quit · r refresh now");
219
- const layout = tableLayout(columns);
220
- const fixed = 8 + 1 + 6 + 1 + 8 + (layout.latency ? 8 : 0) + (layout.tokens ? 16 : 0) + 1;
221
- const modelWidth = Math.max(8, columns - fixed);
222
- const header = ["Time".padEnd(8), "Model".padEnd(modelWidth), "Status".padEnd(6)];
223
- if (layout.latency) header.push("Latency".padStart(7));
224
- if (layout.tokens) header.push("In".padStart(7), "Out".padStart(7));
225
- header.push("$".padStart(8));
226
- lines.push(paint(color, "grey", header.join(" ")));
227
- const room = Math.max(0, rows - lines.length - 1);
301
+ lines.push(border(cols, ["┌", "┬", "┐"], color));
302
+ lines.push(tableLine(cols.map((col) => paint(color, "bold", fit(col.title, col))), color));
303
+ lines.push(border(cols, ["├", "┼", "┤"], color));
304
+ const room = Math.max(0, rows - lines.length - 2);
228
305
  if (recent.length === 0) {
229
- if (room > 0) lines.push(paint(color, "grey", "No requests yet."));
306
+ if (room > 0) lines.push(tableLine([paint(color, "grey", "No requests yet.".padEnd(width - 4))], color));
230
307
  } else {
231
- for (const request of recent.slice(0, room)) lines.push(requestRow(request, layout, modelWidth, color, timeZone));
308
+ for (const request of recent.slice(0, room)) lines.push(tableLine(requestCells(request, cols, color, timeZone), color));
232
309
  }
310
+ lines.push(border(cols, ["└", "┴", "┘"], color));
233
311
  while (fill && lines.length < rows - 1) lines.push("");
234
312
  lines.push(footer);
235
- return lines.slice(-Math.max(rows, 1)).map((line) => clip(line, columns)).join("\n");
313
+ return lines.slice(-Math.max(rows, 1)).map((line) => clip(line, Math.max(columns - 1, 1))).join("\n");
236
314
  }
237
315
 
238
316
  /**
package/lib/pair.d.ts CHANGED
@@ -8,4 +8,5 @@ export function runConnect(options: {
8
8
  deviceName?: string;
9
9
  intervalOverrideMs?: number;
10
10
  env?: Record<string, string | undefined>;
11
+ statusLine?: boolean;
11
12
  }): Promise<number>;
package/lib/pair.js CHANGED
@@ -69,6 +69,7 @@ async function postJson(url, body, signal) {
69
69
  * @param {string} [options.deviceName] Name shown on the confirmation page.
70
70
  * @param {number} [options.intervalOverrideMs] Poll interval override (tests).
71
71
  * @param {Record<string, string | undefined>} [options.env] Environment inspected for overriding variables.
72
+ * @param {boolean} [options.statusLine] Also install the TokenHub status line (default true).
72
73
  * @returns {Promise<number>} Process exit code.
73
74
  */
74
75
  export async function runConnect({
@@ -80,6 +81,7 @@ export async function runConnect({
80
81
  deviceName = hostname(),
81
82
  intervalOverrideMs,
82
83
  env = process.env,
84
+ statusLine = true,
83
85
  }) {
84
86
  let pool;
85
87
  try {
@@ -119,8 +121,11 @@ export async function runConnect({
119
121
  if (poll.status === 429) continue;
120
122
  const status = poll.body?.status;
121
123
  if (status === "approved") {
122
- connectSettings(settingsFile, poll.body.baseUrl, poll.body.key);
124
+ const statusResult = connectSettings(settingsFile, poll.body.baseUrl, poll.body.key, { statusLine });
123
125
  log(`Connected. Claude Code settings updated: ${settingsFile}`);
126
+ if (statusResult === "installed") log("Status line added: today's spend and credit show under the Claude Code prompt.");
127
+ if (statusResult === "updated") log("Status line refreshed with your new key.");
128
+ if (statusResult === "kept") log("Your existing statusLine was left as is. The setup guide on the pool site has the TokenHub one if you want it.");
124
129
  const overriding = overridingEnvVars(env);
125
130
  if (overriding.length > 0) {
126
131
  log(`Warning: ${overriding.join(", ")} set in your shell overrides settings.json. Unset it to use the pool.`);
package/lib/settings.d.ts CHANGED
@@ -1,9 +1,22 @@
1
1
  export function settingsPath(env?: Record<string, string | undefined>, home?: string): string;
2
2
  export function normalizeUrl(input: string): string;
3
3
  export function mergeSettings(settings: Record<string, any>, baseUrl: string, key: string): Record<string, any>;
4
+ export function statusLineCommand(baseUrl: string, key: string, platform?: string): string;
5
+ export function isTokenhubStatusLine(value: unknown): boolean;
6
+ export function installStatusLine(
7
+ settings: Record<string, any>,
8
+ baseUrl: string,
9
+ key: string,
10
+ platform?: string,
11
+ ): { settings: Record<string, any>; result: "installed" | "updated" | "kept" };
4
12
  export function removeSettings(settings: Record<string, any>): Record<string, any>;
5
13
  export function overridingEnvVars(env?: Record<string, string | undefined>): string[];
6
14
  export function readSettings(file: string): { exists: boolean; settings: Record<string, any> };
7
15
  export function writeSettings(file: string, settings: Record<string, any>): void;
8
- export function connectSettings(file: string, baseUrl: string, key: string): void;
16
+ export function connectSettings(
17
+ file: string,
18
+ baseUrl: string,
19
+ key: string,
20
+ options?: { statusLine?: boolean; platform?: string },
21
+ ): "installed" | "updated" | "kept" | "skipped";
9
22
  export function disconnectSettings(file: string): boolean;
package/lib/settings.js CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
 
5
5
  const BASE_URL_KEY = "ANTHROPIC_BASE_URL";
6
6
  const TOKEN_KEY = "ANTHROPIC_AUTH_TOKEN";
7
+ const STATUS_PATH = "/member/api/status";
7
8
 
8
9
  /**
9
10
  * Locate Claude Code's user settings file.
@@ -48,15 +49,52 @@ export function mergeSettings(settings, baseUrl, key) {
48
49
  }
49
50
 
50
51
  /**
51
- * Return a copy of the settings without the pool connection entries.
52
+ * The shell command Claude Code runs to draw the TokenHub status line (today's spend and credit under the prompt).
53
+ * @param {string} baseUrl Pool address.
54
+ * @param {string} key The member's key.
55
+ * @param {string} [platform] Node platform name; Windows gets `curl.exe` with double quotes (works from cmd, PowerShell and Git Bash).
56
+ * @returns {string} The command line.
57
+ */
58
+ export function statusLineCommand(baseUrl, key, platform = process.platform) {
59
+ const url = `${baseUrl}${STATUS_PATH}?format=line`;
60
+ return platform === "win32" ? `curl.exe -s -H "x-api-key: ${key}" "${url}"` : `curl -s -H 'x-api-key: ${key}' '${url}'`;
61
+ }
62
+
63
+ /**
64
+ * @param {unknown} value A `statusLine` setting.
65
+ * @returns {boolean} True when it is the TokenHub status line (a command calling the pool's status endpoint).
66
+ */
67
+ export function isTokenhubStatusLine(value) {
68
+ return isPlainObject(value) && typeof value.command === "string" && value.command.includes(STATUS_PATH);
69
+ }
70
+
71
+ /**
72
+ * Add the TokenHub status line unless the user already has a different one.
52
73
  * @param {Record<string, any>} settings Existing settings object.
53
- * @returns {Record<string, any>} New settings; `env` is dropped when it ends up empty.
74
+ * @param {string} baseUrl Pool address.
75
+ * @param {string} key The member's key.
76
+ * @param {string} [platform] Node platform name.
77
+ * @returns {{ settings: Record<string, any>, result: "installed" | "updated" | "kept" }} `installed` when there was none,
78
+ * `updated` when ours was refreshed with the new key, `kept` when another status line was left alone.
79
+ */
80
+ export function installStatusLine(settings, baseUrl, key, platform = process.platform) {
81
+ const existing = settings.statusLine;
82
+ if (existing !== undefined && !isTokenhubStatusLine(existing)) return { settings, result: "kept" };
83
+ const statusLine = { ...(isPlainObject(existing) ? existing : {}), type: "command", command: statusLineCommand(baseUrl, key, platform) };
84
+ return { settings: { ...settings, statusLine }, result: existing === undefined ? "installed" : "updated" };
85
+ }
86
+
87
+ /**
88
+ * Return a copy of the settings without the pool connection entries (and without our status line).
89
+ * @param {Record<string, any>} settings Existing settings object.
90
+ * @returns {Record<string, any>} New settings; `env` is dropped when it ends up empty, another status line is kept.
54
91
  */
55
92
  export function removeSettings(settings) {
56
- const { env, ...rest } = settings;
57
- if (!isPlainObject(env)) return { ...settings };
93
+ const { env, statusLine, ...rest } = settings;
94
+ const keep = statusLine !== undefined && !isTokenhubStatusLine(statusLine) ? { statusLine } : {};
95
+ if (!isPlainObject(env)) return { ...rest, ...keep, ...(env === undefined ? {} : { env }) };
58
96
  const { [BASE_URL_KEY]: _url, [TOKEN_KEY]: _token, ...otherEnv } = env;
59
- return Object.keys(otherEnv).length > 0 ? { ...rest, env: otherEnv } : rest;
97
+ return Object.keys(otherEnv).length > 0 ? { ...rest, ...keep, env: otherEnv } : { ...rest, ...keep };
60
98
  }
61
99
 
62
100
  /**
@@ -103,20 +141,28 @@ export function writeSettings(file, settings) {
103
141
  }
104
142
 
105
143
  /**
106
- * Persist a pool connection into the settings file.
144
+ * Persist a pool connection (and the TokenHub status line) into the settings file.
107
145
  * @param {string} file Path of settings.json.
108
146
  * @param {string} baseUrl Value for ANTHROPIC_BASE_URL.
109
147
  * @param {string} key Value for ANTHROPIC_AUTH_TOKEN.
110
- * @returns {void}
148
+ * @param {{ statusLine?: boolean, platform?: string }} [options] `statusLine: false` skips the status line; `platform` is for tests.
149
+ * @returns {"installed" | "updated" | "kept" | "skipped"} What happened to the status line.
111
150
  * @throws {Error} When the existing file is invalid JSON (left untouched).
112
151
  */
113
- export function connectSettings(file, baseUrl, key) {
152
+ export function connectSettings(file, baseUrl, key, options = {}) {
114
153
  const { settings } = readSettings(file);
115
- writeSettings(file, mergeSettings(settings, baseUrl, key));
154
+ const merged = mergeSettings(settings, baseUrl, key);
155
+ if (options.statusLine === false) {
156
+ writeSettings(file, merged);
157
+ return "skipped";
158
+ }
159
+ const { settings: next, result } = installStatusLine(merged, baseUrl, key, options.platform);
160
+ writeSettings(file, next);
161
+ return result;
116
162
  }
117
163
 
118
164
  /**
119
- * Remove the pool connection from the settings file.
165
+ * Remove the pool connection (and our status line) from the settings file.
120
166
  * @param {string} file Path of settings.json.
121
167
  * @returns {boolean} True when the file changed, false when there was nothing to remove.
122
168
  * @throws {Error} When the existing file is invalid JSON (left untouched).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emesoft/tokenhub",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pair Claude Code on this machine with a TokenHub pool",
5
5
  "type": "module",
6
6
  "bin": {