@emesoft/tokenhub 0.4.0 → 0.6.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
@@ -2,12 +2,16 @@
2
2
 
3
3
  Pair Claude Code on this machine with a [TokenHub](https://tokenhub.aimpact.io.vn) pool. No dependencies, Node 18+.
4
4
 
5
+ `connect` pairs with https://tokenhub.aimpact.io.vn by default. For another pool, pass its address: `npx @emesoft/tokenhub connect https://pool.example.com`.
6
+
5
7
  ```
6
- npx @emesoft/tokenhub connect https://tokenhub.aimpact.io.vn
7
- npx @emesoft/tokenhub connect https://tokenhub.aimpact.io.vn --no-statusline
8
+ npx @emesoft/tokenhub connect
9
+ npx @emesoft/tokenhub connect --no-statusline
8
10
  npx @emesoft/tokenhub usage
9
11
  npx @emesoft/tokenhub dashboard
10
12
  npx @emesoft/tokenhub disconnect
13
+ npx @emesoft/tokenhub connect --admin
14
+ npx @emesoft/tokenhub disconnect --admin
11
15
  ```
12
16
 
13
17
  `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.
@@ -20,6 +24,8 @@ Requests to the pool carry your key and one extra header, `x-tokenhub-cli: <vers
20
24
 
21
25
  `disconnect` removes only those two entries and the TokenHub `statusLine`.
22
26
 
27
+ `connect --admin` is for pool admins. It pairs this machine as an *admin device* for the TokenHub desktop overlay: you confirm the code on the pool's **admin** console (so only the signed-in admin can approve it), and the CLI saves a read-only device token to `~/.tokenhub/admin.json` (or `$TOKENHUB_CONFIG_DIR/admin.json`). It never touches Claude Code's settings. The token can read account names and quota levels, nothing else. Revoke a device on the admin Devices page (`/admin/pair`); `disconnect --admin` just forgets the token locally.
28
+
23
29
  If `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` is set in your shell, it overrides `settings.json`.
24
30
 
25
- Sign in, manage your keys and top up at [tokenhub.aimpact.io.vn](https://tokenhub.aimpact.io.vn). The setup guide there has this command with your pool address filled in.
31
+ Sign in, manage your keys and top up at [tokenhub.aimpact.io.vn](https://tokenhub.aimpact.io.vn).
package/bin/tokenhub.js CHANGED
@@ -1,20 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
+ import { adminConfigPath, removeAdminConfig } from "../lib/admin.js";
3
4
  import { parseDashboardArgs, runDashboard } from "../lib/dashboard.js";
4
- import { runConnect } from "../lib/pair.js";
5
+ import { DEFAULT_POOL_URL, runConnect } from "../lib/pair.js";
5
6
  import { disconnectSettings, readSettings, settingsPath } from "../lib/settings.js";
6
7
  import { fetchUsage, formatUsage, resolveCredentials } from "../lib/usage.js";
7
8
 
8
9
  const HELP = `tokenhub - pair Claude Code with a TokenHub pool
9
10
 
10
11
  Usage:
11
- tokenhub connect <poolUrl> [--no-statusline]
12
+ tokenhub connect [poolUrl] [--no-statusline]
12
13
  Pair this machine and write ~/.claude/settings.json, including the status line
13
- (today's spend under the prompt) unless --no-statusline
14
+ (today's spend under the prompt) unless --no-statusline.
15
+ poolUrl defaults to ${DEFAULT_POOL_URL}
16
+ tokenhub connect [poolUrl] --admin
17
+ Pair this machine as an admin device (for the desktop overlay's pool view): the admin
18
+ confirms in the console, the token is saved to ~/.tokenhub/admin.json
14
19
  tokenhub usage [--json] Show today's and this week's spend, credit and pool health
15
20
  tokenhub dashboard [--interval <sec>] [--once]
16
21
  Live full-screen view of spend, pool health and recent requests (q quits)
17
22
  tokenhub disconnect Remove the pool entries and the status line from settings.json
23
+ tokenhub disconnect --admin Forget the admin device token on this machine (revoke it on the pool's admin Devices page)
18
24
  tokenhub --help | --version
19
25
  `;
20
26
 
@@ -38,19 +44,22 @@ async function main(argv) {
38
44
  }
39
45
  const file = settingsPath();
40
46
  if (command === "connect") {
41
- if (!arg) {
42
- console.error(`Missing pool URL.\n\n${HELP}`);
43
- return 1;
44
- }
45
47
  const controller = new AbortController();
46
48
  process.once("SIGINT", () => controller.abort());
47
- return runConnect({ poolUrl: arg, settingsFile: file, signal: controller.signal, statusLine: !flags.includes("--no-statusline") });
49
+ return runConnect({
50
+ poolUrl: arg,
51
+ settingsFile: file,
52
+ signal: controller.signal,
53
+ statusLine: !flags.includes("--no-statusline"),
54
+ admin: flags.includes("--admin"),
55
+ adminFile: adminConfigPath(),
56
+ });
48
57
  }
49
58
  if (command === "usage") {
50
59
  try {
51
60
  const credentials = resolveCredentials(readSettings(file).settings);
52
61
  if (!credentials) {
53
- console.error("Not connected. Run `tokenhub connect <poolUrl>` first.");
62
+ console.error("Not connected. Run `tokenhub connect` first.");
54
63
  return 1;
55
64
  }
56
65
  const report = await fetchUsage(credentials.baseUrl, credentials.key);
@@ -66,7 +75,7 @@ async function main(argv) {
66
75
  const options = parseDashboardArgs(argv.slice(1));
67
76
  const credentials = resolveCredentials(readSettings(file).settings);
68
77
  if (!credentials) {
69
- console.error("Not connected. Run `tokenhub connect <poolUrl>` first.");
78
+ console.error("Not connected. Run `tokenhub connect` first.");
70
79
  return 1;
71
80
  }
72
81
  return await runDashboard(credentials, options);
@@ -77,6 +86,11 @@ async function main(argv) {
77
86
  }
78
87
  if (command === "disconnect") {
79
88
  try {
89
+ if (flags.includes("--admin")) {
90
+ const adminFile = adminConfigPath();
91
+ console.log(removeAdminConfig(adminFile) ? `Admin device forgotten. Removed ${adminFile}` : `Nothing to remove at ${adminFile}`);
92
+ return 0;
93
+ }
80
94
  console.log(disconnectSettings(file) ? `Disconnected. Updated ${file}` : `Nothing to remove in ${file}`);
81
95
  return 0;
82
96
  } catch (error) {
package/lib/admin.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export function adminConfigPath(env?: Record<string, string | undefined>, home?: string): string;
2
+ export function writeAdminConfig(file: string, baseUrl: string, token: string): void;
3
+ export function readAdminConfig(file: string): { baseUrl: string; token: string } | null;
4
+ export function removeAdminConfig(file: string): boolean;
package/lib/admin.js ADDED
@@ -0,0 +1,51 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ /**
6
+ * Locate the file `tokenhub connect --admin` stores the admin device token in (read by the desktop overlay).
7
+ * @param {Record<string, string | undefined>} [env] Environment to read TOKENHUB_CONFIG_DIR from.
8
+ * @param {string} [home] Home directory used when TOKENHUB_CONFIG_DIR is unset.
9
+ * @returns {string} Absolute path of admin.json.
10
+ */
11
+ export function adminConfigPath(env = process.env, home = homedir()) {
12
+ return join(env.TOKENHUB_CONFIG_DIR || join(home, ".tokenhub"), "admin.json");
13
+ }
14
+
15
+ /**
16
+ * Store the pool address and admin device token, readable only by the current user where the OS supports it.
17
+ * @param {string} file Path from `adminConfigPath`.
18
+ * @param {string} baseUrl Pool address.
19
+ * @param {string} token Admin device token issued by the pool.
20
+ * @returns {void}
21
+ */
22
+ export function writeAdminConfig(file, baseUrl, token) {
23
+ mkdirSync(dirname(file), { recursive: true });
24
+ writeFileSync(file, `${JSON.stringify({ baseUrl, token }, null, 2)}\n`, { mode: 0o600 });
25
+ }
26
+
27
+ /**
28
+ * Read a stored admin device.
29
+ * @param {string} file Path from `adminConfigPath`.
30
+ * @returns {{ baseUrl: string, token: string } | null} The pair, or null when missing or unusable.
31
+ */
32
+ export function readAdminConfig(file) {
33
+ if (!existsSync(file)) return null;
34
+ try {
35
+ const { baseUrl, token } = JSON.parse(readFileSync(file, "utf8"));
36
+ return typeof baseUrl === "string" && typeof token === "string" && baseUrl && token ? { baseUrl, token } : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Forget the stored admin device (the token stays valid on the pool until revoked on its Devices page).
44
+ * @param {string} file Path from `adminConfigPath`.
45
+ * @returns {boolean} Whether a file was removed.
46
+ */
47
+ export function removeAdminConfig(file) {
48
+ if (!existsSync(file)) return false;
49
+ rmSync(file);
50
+ return true;
51
+ }
package/lib/pair.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ export const DEFAULT_POOL_URL: string;
1
2
  export function openBrowser(url: string, platform?: string): void;
2
3
  export function runConnect(options: {
3
- poolUrl: string;
4
+ /** Pool address; defaults to `DEFAULT_POOL_URL`. */
5
+ poolUrl?: string;
4
6
  settingsFile: string;
5
7
  log?: (line: string) => void;
6
8
  open?: (url: string) => void;
@@ -9,4 +11,8 @@ export function runConnect(options: {
9
11
  intervalOverrideMs?: number;
10
12
  env?: Record<string, string | undefined>;
11
13
  statusLine?: boolean;
14
+ /** Pair as an admin device (token goes to `adminFile`, not settings.json). */
15
+ admin?: boolean;
16
+ /** Where the admin device token is stored; required with `admin`. */
17
+ adminFile?: string;
12
18
  }): Promise<number>;
package/lib/pair.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { hostname } from "node:os";
3
+ import { writeAdminConfig } from "./admin.js";
3
4
  import { connectSettings, normalizeUrl, overridingEnvVars, readSettings } from "./settings.js";
4
5
 
5
6
  const MAX_WAIT_MS = 15 * 60 * 1000;
6
7
 
8
+ /** The pool `connect` pairs with when no address is given. */
9
+ export const DEFAULT_POOL_URL = "https://tokenhub.aimpact.io.vn";
10
+
7
11
  /**
8
12
  * Open a URL in the default browser; failures are ignored (the URL is printed anyway).
9
13
  * @param {string} url Address to open.
@@ -61,7 +65,7 @@ async function postJson(url, body, signal) {
61
65
  /**
62
66
  * Run the pairing flow and write the issued key into settings.json.
63
67
  * @param {object} options
64
- * @param {string} options.poolUrl Pool address as typed by the user.
68
+ * @param {string} [options.poolUrl] Pool address as typed by the user (default `DEFAULT_POOL_URL`).
65
69
  * @param {string} options.settingsFile Path of settings.json to update.
66
70
  * @param {(line: string) => void} [options.log] Output sink.
67
71
  * @param {(url: string) => void} [options.open] Browser opener.
@@ -70,10 +74,12 @@ async function postJson(url, body, signal) {
70
74
  * @param {number} [options.intervalOverrideMs] Poll interval override (tests).
71
75
  * @param {Record<string, string | undefined>} [options.env] Environment inspected for overriding variables.
72
76
  * @param {boolean} [options.statusLine] Also install the TokenHub status line (default true).
77
+ * @param {boolean} [options.admin] Pair as an admin device: the admin approves in the console and the token is stored in `adminFile` (for the desktop overlay), leaving Claude Code's settings alone.
78
+ * @param {string} [options.adminFile] Path from `adminConfigPath`; required with `admin`.
73
79
  * @returns {Promise<number>} Process exit code.
74
80
  */
75
81
  export async function runConnect({
76
- poolUrl,
82
+ poolUrl = DEFAULT_POOL_URL,
77
83
  settingsFile,
78
84
  log = console.log,
79
85
  open = openBrowser,
@@ -82,17 +88,19 @@ export async function runConnect({
82
88
  intervalOverrideMs,
83
89
  env = process.env,
84
90
  statusLine = true,
91
+ admin = false,
92
+ adminFile,
85
93
  }) {
86
94
  let pool;
87
95
  try {
88
96
  pool = normalizeUrl(poolUrl);
89
- readSettings(settingsFile);
97
+ if (!admin) readSettings(settingsFile);
90
98
  } catch (error) {
91
99
  log(`Error: ${error.message}`);
92
100
  return 1;
93
101
  }
94
102
  try {
95
- const start = await postJson(`${pool}/pair/start`, { deviceName }, signal);
103
+ const start = await postJson(`${pool}/pair/start`, admin ? { deviceName, kind: "admin" } : { deviceName }, signal);
96
104
  if (start.status === 429) {
97
105
  log("Too many pairing attempts. Wait a few minutes and try again.");
98
106
  return 1;
@@ -105,7 +113,7 @@ export async function runConnect({
105
113
  log("");
106
114
  log(` Your code: ${userCode}`);
107
115
  log("");
108
- log(`Confirm it at: ${verifyUrl}`);
116
+ log(`Confirm it at: ${verifyUrl}${admin ? " (sign in as the admin)" : ""}`);
109
117
  log("Opening your browser. Waiting for approval (Ctrl+C to cancel)...");
110
118
  open(verifyUrl);
111
119
 
@@ -120,6 +128,16 @@ export async function runConnect({
120
128
  const poll = await postJson(`${pool}/pair/poll`, { deviceCode }, signal);
121
129
  if (poll.status === 429) continue;
122
130
  const status = poll.body?.status;
131
+ if (status === "approved" && admin) {
132
+ if (poll.body.kind !== "admin") {
133
+ log("Error: the pool did not issue an admin device token. Update the pool, then run the command again.");
134
+ return 1;
135
+ }
136
+ writeAdminConfig(adminFile, poll.body.baseUrl, poll.body.key);
137
+ log(`Admin device paired. Token saved to ${adminFile}`);
138
+ log(`Revoke it any time on the pool's admin Devices page (${poll.body.baseUrl}/admin/pair).`);
139
+ return 0;
140
+ }
123
141
  if (status === "approved") {
124
142
  const statusResult = connectSettings(settingsFile, poll.body.baseUrl, poll.body.key, { statusLine });
125
143
  log(`Connected. Claude Code settings updated: ${settingsFile}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emesoft/tokenhub",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pair Claude Code on this machine with a TokenHub pool",
5
5
  "type": "module",
6
6
  "bin": {