@emesoft/tokenhub 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/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @emesoft/tokenhub
2
+
3
+ Pair Claude Code on this machine with a TokenHub pool. No dependencies, Node 18+.
4
+
5
+ ```
6
+ npx @emesoft/tokenhub connect https://pool.example.com
7
+ npx @emesoft/tokenhub disconnect
8
+ ```
9
+
10
+ `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.
11
+
12
+ `disconnect` removes only those two entries.
13
+
14
+ If `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` is set in your shell, it overrides `settings.json`.
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { runConnect } from "../lib/pair.js";
4
+ import { disconnectSettings, settingsPath } from "../lib/settings.js";
5
+
6
+ const HELP = `tokenhub - pair Claude Code with a TokenHub pool
7
+
8
+ Usage:
9
+ tokenhub connect <poolUrl> Pair this machine and write ~/.claude/settings.json
10
+ tokenhub disconnect Remove the pool entries from settings.json
11
+ tokenhub --help | --version
12
+ `;
13
+
14
+ /**
15
+ * CLI entry point.
16
+ * @param {string[]} argv Arguments after the script name.
17
+ * @returns {Promise<number>} Process exit code.
18
+ */
19
+ async function main(argv) {
20
+ const [command, arg] = argv;
21
+ if (!command || command === "--help" || command === "-h" || command === "help") {
22
+ console.log(HELP);
23
+ return command ? 0 : 1;
24
+ }
25
+ if (command === "--version" || command === "-v") {
26
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
27
+ console.log(pkg.version);
28
+ return 0;
29
+ }
30
+ const file = settingsPath();
31
+ if (command === "connect") {
32
+ if (!arg) {
33
+ console.error(`Missing pool URL.\n\n${HELP}`);
34
+ return 1;
35
+ }
36
+ const controller = new AbortController();
37
+ process.once("SIGINT", () => controller.abort());
38
+ return runConnect({ poolUrl: arg, settingsFile: file, signal: controller.signal });
39
+ }
40
+ if (command === "disconnect") {
41
+ try {
42
+ console.log(disconnectSettings(file) ? `Disconnected. Updated ${file}` : `Nothing to remove in ${file}`);
43
+ return 0;
44
+ } catch (error) {
45
+ console.error(`Error: ${error.message}`);
46
+ return 1;
47
+ }
48
+ }
49
+ console.error(`Unknown command: ${command}\n\n${HELP}`);
50
+ return 1;
51
+ }
52
+
53
+ process.exitCode = await main(process.argv.slice(2));
package/lib/pair.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export function openBrowser(url: string, platform?: string): void;
2
+ export function runConnect(options: {
3
+ poolUrl: string;
4
+ settingsFile: string;
5
+ log?: (line: string) => void;
6
+ open?: (url: string) => void;
7
+ signal?: AbortSignal;
8
+ deviceName?: string;
9
+ intervalOverrideMs?: number;
10
+ env?: Record<string, string | undefined>;
11
+ }): Promise<number>;
package/lib/pair.js ADDED
@@ -0,0 +1,149 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+ import { connectSettings, normalizeUrl, overridingEnvVars, readSettings } from "./settings.js";
4
+
5
+ const MAX_WAIT_MS = 15 * 60 * 1000;
6
+
7
+ /**
8
+ * Open a URL in the default browser; failures are ignored (the URL is printed anyway).
9
+ * @param {string} url Address to open.
10
+ * @param {string} [platform] Node platform name.
11
+ * @returns {void}
12
+ */
13
+ export function openBrowser(url, platform = process.platform) {
14
+ const [command, args] =
15
+ platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
16
+ try {
17
+ const child = spawn(command, args, { stdio: "ignore", detached: true, windowsVerbatimArguments: platform === "win32" });
18
+ child.on("error", () => {});
19
+ child.unref();
20
+ } catch {
21
+ // no browser available; the URL was already printed
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Sleep that resolves early when the signal aborts.
27
+ * @param {number} ms Duration in milliseconds.
28
+ * @param {AbortSignal} [signal] Cancels the wait.
29
+ * @returns {Promise<void>}
30
+ */
31
+ function sleep(ms, signal) {
32
+ return new Promise((resolve) => {
33
+ if (signal?.aborted) return resolve();
34
+ const timer = setTimeout(done, ms);
35
+ function done() {
36
+ clearTimeout(timer);
37
+ signal?.removeEventListener("abort", done);
38
+ resolve();
39
+ }
40
+ signal?.addEventListener("abort", done);
41
+ });
42
+ }
43
+
44
+ /**
45
+ * POST JSON to the pool and parse the reply.
46
+ * @param {string} url Endpoint URL.
47
+ * @param {object} body JSON payload.
48
+ * @param {AbortSignal} [signal] Cancels the request.
49
+ * @returns {Promise<{ status: number, body: any }>} HTTP status and parsed JSON (null when not JSON).
50
+ */
51
+ async function postJson(url, body, signal) {
52
+ const res = await fetch(url, {
53
+ method: "POST",
54
+ headers: { "content-type": "application/json" },
55
+ body: JSON.stringify(body),
56
+ signal,
57
+ });
58
+ return { status: res.status, body: await res.json().catch(() => null) };
59
+ }
60
+
61
+ /**
62
+ * Run the pairing flow and write the issued key into settings.json.
63
+ * @param {object} options
64
+ * @param {string} options.poolUrl Pool address as typed by the user.
65
+ * @param {string} options.settingsFile Path of settings.json to update.
66
+ * @param {(line: string) => void} [options.log] Output sink.
67
+ * @param {(url: string) => void} [options.open] Browser opener.
68
+ * @param {AbortSignal} [options.signal] Aborts the flow (Ctrl+C).
69
+ * @param {string} [options.deviceName] Name shown on the confirmation page.
70
+ * @param {number} [options.intervalOverrideMs] Poll interval override (tests).
71
+ * @param {Record<string, string | undefined>} [options.env] Environment inspected for overriding variables.
72
+ * @returns {Promise<number>} Process exit code.
73
+ */
74
+ export async function runConnect({
75
+ poolUrl,
76
+ settingsFile,
77
+ log = console.log,
78
+ open = openBrowser,
79
+ signal,
80
+ deviceName = hostname(),
81
+ intervalOverrideMs,
82
+ env = process.env,
83
+ }) {
84
+ let pool;
85
+ try {
86
+ pool = normalizeUrl(poolUrl);
87
+ readSettings(settingsFile);
88
+ } catch (error) {
89
+ log(`Error: ${error.message}`);
90
+ return 1;
91
+ }
92
+ try {
93
+ const start = await postJson(`${pool}/pair/start`, { deviceName }, signal);
94
+ if (start.status === 429) {
95
+ log("Too many pairing attempts. Wait a few minutes and try again.");
96
+ return 1;
97
+ }
98
+ if (start.status !== 200 || !start.body?.deviceCode) {
99
+ log(`Error: the pool rejected the pairing request (HTTP ${start.status}).`);
100
+ return 1;
101
+ }
102
+ const { deviceCode, userCode, verifyUrl, interval, expiresIn } = start.body;
103
+ log("");
104
+ log(` Your code: ${userCode}`);
105
+ log("");
106
+ log(`Confirm it at: ${verifyUrl}`);
107
+ log("Opening your browser. Waiting for approval (Ctrl+C to cancel)...");
108
+ open(verifyUrl);
109
+
110
+ const intervalMs = intervalOverrideMs ?? Math.max(1, Number(interval) || 3) * 1000;
111
+ const deadline = Date.now() + Math.min(MAX_WAIT_MS, (Number(expiresIn) || 600) * 1000 + intervalMs);
112
+ while (Date.now() < deadline) {
113
+ await sleep(intervalMs, signal);
114
+ if (signal?.aborted) {
115
+ log("Cancelled.");
116
+ return 130;
117
+ }
118
+ const poll = await postJson(`${pool}/pair/poll`, { deviceCode }, signal);
119
+ if (poll.status === 429) continue;
120
+ const status = poll.body?.status;
121
+ if (status === "approved") {
122
+ connectSettings(settingsFile, poll.body.baseUrl, poll.body.key);
123
+ log(`Connected. Claude Code settings updated: ${settingsFile}`);
124
+ const overriding = overridingEnvVars(env);
125
+ if (overriding.length > 0) {
126
+ log(`Warning: ${overriding.join(", ")} set in your shell overrides settings.json. Unset it to use the pool.`);
127
+ }
128
+ return 0;
129
+ }
130
+ if (status === "denied") {
131
+ log("The request was denied.");
132
+ return 1;
133
+ }
134
+ if (status === "expired") {
135
+ log("The code expired. Run the command again.");
136
+ return 1;
137
+ }
138
+ }
139
+ log("Timed out waiting for approval. Run the command again.");
140
+ return 1;
141
+ } catch (error) {
142
+ if (signal?.aborted) {
143
+ log("Cancelled.");
144
+ return 130;
145
+ }
146
+ log(`Error: ${error.message}`);
147
+ return 1;
148
+ }
149
+ }
@@ -0,0 +1,9 @@
1
+ export function settingsPath(env?: Record<string, string | undefined>, home?: string): string;
2
+ export function normalizeUrl(input: string): string;
3
+ export function mergeSettings(settings: Record<string, any>, baseUrl: string, key: string): Record<string, any>;
4
+ export function removeSettings(settings: Record<string, any>): Record<string, any>;
5
+ export function overridingEnvVars(env?: Record<string, string | undefined>): string[];
6
+ export function readSettings(file: string): { exists: boolean; settings: Record<string, any> };
7
+ export function writeSettings(file: string, settings: Record<string, any>): void;
8
+ export function connectSettings(file: string, baseUrl: string, key: string): void;
9
+ export function disconnectSettings(file: string): boolean;
@@ -0,0 +1,139 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const BASE_URL_KEY = "ANTHROPIC_BASE_URL";
6
+ const TOKEN_KEY = "ANTHROPIC_AUTH_TOKEN";
7
+
8
+ /**
9
+ * Locate Claude Code's user settings file.
10
+ * @param {Record<string, string | undefined>} [env] Environment to read CLAUDE_CONFIG_DIR from.
11
+ * @param {string} [home] Home directory used when CLAUDE_CONFIG_DIR is unset.
12
+ * @returns {string} Absolute path of settings.json.
13
+ */
14
+ export function settingsPath(env = process.env, home = homedir()) {
15
+ const dir = env.CLAUDE_CONFIG_DIR || join(home, ".claude");
16
+ return join(dir, "settings.json");
17
+ }
18
+
19
+ /**
20
+ * Normalize a pool URL: require http/https, drop trailing slashes, query and hash.
21
+ * @param {string} input Raw URL typed by the user.
22
+ * @returns {string} Origin plus path without a trailing slash.
23
+ * @throws {Error} When the URL is invalid or not http/https.
24
+ */
25
+ export function normalizeUrl(input) {
26
+ let url;
27
+ try {
28
+ url = new URL(String(input).trim());
29
+ } catch {
30
+ throw new Error(`Invalid pool URL: ${input}`);
31
+ }
32
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
33
+ throw new Error(`Pool URL must start with http:// or https://: ${input}`);
34
+ }
35
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
36
+ }
37
+
38
+ /**
39
+ * Return a copy of the settings with the pool connection set in `env`.
40
+ * @param {Record<string, any>} settings Existing settings object.
41
+ * @param {string} baseUrl Value for ANTHROPIC_BASE_URL.
42
+ * @param {string} key Value for ANTHROPIC_AUTH_TOKEN.
43
+ * @returns {Record<string, any>} New settings; all other keys are preserved.
44
+ */
45
+ export function mergeSettings(settings, baseUrl, key) {
46
+ const env = isPlainObject(settings.env) ? settings.env : {};
47
+ return { ...settings, env: { ...env, [BASE_URL_KEY]: baseUrl, [TOKEN_KEY]: key } };
48
+ }
49
+
50
+ /**
51
+ * Return a copy of the settings without the pool connection entries.
52
+ * @param {Record<string, any>} settings Existing settings object.
53
+ * @returns {Record<string, any>} New settings; `env` is dropped when it ends up empty.
54
+ */
55
+ export function removeSettings(settings) {
56
+ const { env, ...rest } = settings;
57
+ if (!isPlainObject(env)) return { ...settings };
58
+ const { [BASE_URL_KEY]: _url, [TOKEN_KEY]: _token, ...otherEnv } = env;
59
+ return Object.keys(otherEnv).length > 0 ? { ...rest, env: otherEnv } : rest;
60
+ }
61
+
62
+ /**
63
+ * Shell variables that take precedence over settings.json.
64
+ * @param {Record<string, string | undefined>} [env] Environment to inspect.
65
+ * @returns {string[]} Names of the overriding variables that are set.
66
+ */
67
+ export function overridingEnvVars(env = process.env) {
68
+ return [BASE_URL_KEY, TOKEN_KEY, "ANTHROPIC_API_KEY"].filter((name) => env[name]);
69
+ }
70
+
71
+ /**
72
+ * Read and parse a settings file.
73
+ * @param {string} file Path of settings.json.
74
+ * @returns {{ exists: boolean, settings: Record<string, any> }} Parsed settings ({} when the file is missing).
75
+ * @throws {Error} When the file exists but is not a JSON object.
76
+ */
77
+ export function readSettings(file) {
78
+ if (!existsSync(file)) return { exists: false, settings: {} };
79
+ const text = readFileSync(file, "utf8");
80
+ if (text.trim() === "") return { exists: true, settings: {} };
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(text);
84
+ } catch {
85
+ throw new Error(`${file} is not valid JSON. Fix or remove it, then run again.`);
86
+ }
87
+ if (!isPlainObject(parsed)) {
88
+ throw new Error(`${file} does not contain a JSON object. Fix or remove it, then run again.`);
89
+ }
90
+ return { exists: true, settings: parsed };
91
+ }
92
+
93
+ /**
94
+ * Write settings, creating the directory and a `.bak` copy of any existing file.
95
+ * @param {string} file Path of settings.json.
96
+ * @param {Record<string, any>} settings Settings to persist.
97
+ * @returns {void}
98
+ */
99
+ export function writeSettings(file, settings) {
100
+ mkdirSync(dirname(file), { recursive: true });
101
+ if (existsSync(file)) copyFileSync(file, `${file}.bak`);
102
+ writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
103
+ }
104
+
105
+ /**
106
+ * Persist a pool connection into the settings file.
107
+ * @param {string} file Path of settings.json.
108
+ * @param {string} baseUrl Value for ANTHROPIC_BASE_URL.
109
+ * @param {string} key Value for ANTHROPIC_AUTH_TOKEN.
110
+ * @returns {void}
111
+ * @throws {Error} When the existing file is invalid JSON (left untouched).
112
+ */
113
+ export function connectSettings(file, baseUrl, key) {
114
+ const { settings } = readSettings(file);
115
+ writeSettings(file, mergeSettings(settings, baseUrl, key));
116
+ }
117
+
118
+ /**
119
+ * Remove the pool connection from the settings file.
120
+ * @param {string} file Path of settings.json.
121
+ * @returns {boolean} True when the file changed, false when there was nothing to remove.
122
+ * @throws {Error} When the existing file is invalid JSON (left untouched).
123
+ */
124
+ export function disconnectSettings(file) {
125
+ const { exists, settings } = readSettings(file);
126
+ if (!exists) return false;
127
+ const next = removeSettings(settings);
128
+ if (JSON.stringify(next) === JSON.stringify(settings)) return false;
129
+ writeSettings(file, next);
130
+ return true;
131
+ }
132
+
133
+ /**
134
+ * @param {unknown} value Value to test.
135
+ * @returns {value is Record<string, any>} True for non-null, non-array objects.
136
+ */
137
+ function isPlainObject(value) {
138
+ return typeof value === "object" && value !== null && !Array.isArray(value);
139
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@emesoft/tokenhub",
3
+ "version": "0.1.0",
4
+ "description": "Pair Claude Code on this machine with a TokenHub pool",
5
+ "type": "module",
6
+ "bin": {
7
+ "tokenhub": "bin/tokenhub.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "license": "UNLICENSED"
21
+ }