how-much-claude 1.0.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 +22 -0
- package/connect.mjs +107 -0
- package/credential-source.mjs +140 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# how-much-claude
|
|
2
|
+
|
|
3
|
+
The device-pairing helper for **[How Much Claude](https://howmuchclaude.com)** — a live dashboard for your Claude usage limits across multiple accounts.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
In the app, click **Connect account** to get a pairing code, then run:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx how-much-claude connect ABCD-EFGH-JKLM
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What it does (and doesn't)
|
|
14
|
+
|
|
15
|
+
- Reads the Claude Code login token **already stored on this machine** (macOS Keychain, or `~/.claude/.credentials.json`).
|
|
16
|
+
- Asks for your confirmation, then sends the token **only** to `https://howmuchclaude.com` over HTTPS to pair the device showing your code. Stored encrypted.
|
|
17
|
+
- Never prints, logs, or sends the token anywhere else. No dependencies — Node built-ins only.
|
|
18
|
+
- Self-hosting? Point it at your own deployment: `HMC_URL=https://your-deploy npx how-much-claude connect <code>`
|
|
19
|
+
|
|
20
|
+
Source lives in the [main repo](https://github.com/SeraphKc/how-much-claude) (`bin/connect.mjs` + `lib/credential-source.mjs`); this package is synced from it at publish time.
|
|
21
|
+
|
|
22
|
+
Unofficial tool — not affiliated with Anthropic.
|
package/connect.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// "How Much Claude" device-pairing helper (Feature B).
|
|
4
|
+
//
|
|
5
|
+
// Reads the Claude Code OAuth token already stored on THIS machine and sends it to the app to pair a
|
|
6
|
+
// device shown a code in the browser. Uses ONLY Node built-ins (+ the dependency-free credential
|
|
7
|
+
// reader shared with the web app) so `npx` starts instantly with no install.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node bin/connect.mjs connect <code>
|
|
10
|
+
// npx github:SeraphKc/how-much-claude connect ABCD-EFGH-JKLM
|
|
11
|
+
// HMC_URL=https://your-deploy.example connect ABCD-EFGH-JKLM (self-hosted target)
|
|
12
|
+
//
|
|
13
|
+
// Flags: -y / --yes skip the confirmation prompt (for scripting)
|
|
14
|
+
|
|
15
|
+
import process from "node:process";
|
|
16
|
+
import readline from "node:readline";
|
|
17
|
+
import { readLocalCredentialRaw, extractTokens, LocalCredentialError } from "./credential-source.mjs";
|
|
18
|
+
|
|
19
|
+
const BASE_URL = (process.env.HMC_URL || "https://howmuchclaude.com").replace(/\/$/, "");
|
|
20
|
+
|
|
21
|
+
function ask(question) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
24
|
+
rl.question(question, (answer) => {
|
|
25
|
+
rl.close();
|
|
26
|
+
resolve(String(answer).trim());
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
const args = process.argv.slice(2);
|
|
33
|
+
const yes = args.includes("-y") || args.includes("--yes");
|
|
34
|
+
const positional = args.filter((a) => a !== "-y" && a !== "--yes");
|
|
35
|
+
// Accept "connect <code>" or a bare "<code>".
|
|
36
|
+
const code = positional[0] === "connect" ? positional[1] : positional[0];
|
|
37
|
+
|
|
38
|
+
if (!code) {
|
|
39
|
+
console.error("Usage: connect <code>\n Get the code from the \"Connect account\" dialog at " + BASE_URL + ".");
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Plain-English notice + consent, so nothing happens silently.
|
|
45
|
+
console.log(
|
|
46
|
+
[
|
|
47
|
+
"",
|
|
48
|
+
"How Much Claude — connect this device",
|
|
49
|
+
"",
|
|
50
|
+
"This reads the Claude Code login token already on THIS machine and sends it ONLY to",
|
|
51
|
+
` ${BASE_URL}`,
|
|
52
|
+
"to pair this device with the code you were shown. The token is sent over HTTPS, stored",
|
|
53
|
+
"encrypted, and this helper is open source.",
|
|
54
|
+
"",
|
|
55
|
+
].join("\n"),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (!yes) {
|
|
59
|
+
const answer = await ask("Continue? [y/N] ");
|
|
60
|
+
if (!/^y(es)?$/i.test(answer)) {
|
|
61
|
+
console.log("Aborted — nothing was sent.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read the local credential (macOS Keychain via execFile, else ~/.claude/.credentials.json).
|
|
67
|
+
let tokens;
|
|
68
|
+
try {
|
|
69
|
+
const raw = await readLocalCredentialRaw();
|
|
70
|
+
tokens = extractTokens(raw);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err instanceof LocalCredentialError) {
|
|
73
|
+
console.error(`\n✗ ${err.message}\n ${err.recommendation}`);
|
|
74
|
+
} else {
|
|
75
|
+
console.error(`\n✗ Couldn't read the local Claude Code credential: ${err instanceof Error ? err.message : err}`);
|
|
76
|
+
}
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Send it. Never print the token.
|
|
82
|
+
let res;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetch(`${BASE_URL}/api/connect/pair/complete`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ code, token: tokens }),
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(`\n✗ Couldn't reach ${BASE_URL}: ${err instanceof Error ? err.message : err}`);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const data = await res.json().catch(() => ({}));
|
|
96
|
+
if (res.ok && data && data.ok) {
|
|
97
|
+
console.log(`\n✓ Connected ${data.email || "your account"}. You can close this window — the dashboard will update.`);
|
|
98
|
+
} else {
|
|
99
|
+
console.error(`\n✗ ${(data && data.error) || `Pairing failed (HTTP ${res.status}).`}`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch((err) => {
|
|
105
|
+
console.error(err instanceof Error ? err.message : err);
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Shared, DEPENDENCY-FREE reader for the Claude Code credential stored on THIS machine.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately plain ESM using only Node built-ins so BOTH consumers can use it with zero install:
|
|
5
|
+
// - the self-hosted local-connect route (lib/local-credentials.ts wraps this), and
|
|
6
|
+
// - the `bin/connect.mjs` CLI helper run via `npx` (which must start instantly, no `npm install`).
|
|
7
|
+
//
|
|
8
|
+
// It NEVER interpolates a shell string: the macOS keychain is read with execFile + a fixed argv, so
|
|
9
|
+
// there is no shell-injection surface. It returns the RAW credential JSON for the caller to parse.
|
|
10
|
+
|
|
11
|
+
import { execFile as _execFile } from "node:child_process";
|
|
12
|
+
import { readFile as _readFile } from "node:fs/promises";
|
|
13
|
+
import { homedir as _homedir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
// The macOS Keychain generic-password entry Claude Code stores its OAuth credential under.
|
|
17
|
+
export const KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
18
|
+
|
|
19
|
+
/** An error the UI/CLI can present, carrying an actionable next step in `recommendation`. */
|
|
20
|
+
export class LocalCredentialError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} message
|
|
23
|
+
* @param {string} recommendation - what the user should do next (shown in the UI / printed by the CLI).
|
|
24
|
+
*/
|
|
25
|
+
constructor(message, recommendation) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "LocalCredentialError";
|
|
28
|
+
this.recommendation = recommendation;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @typedef {{ accessToken: string, refreshToken: string | null, expiresAt: number }} ExtractedTokens */
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} CredentialDeps
|
|
36
|
+
* @property {NodeJS.Platform | string} [platform] - defaults to process.platform.
|
|
37
|
+
* @property {(cmd: string, args: string[]) => Promise<string>} [execFile] - returns stdout.
|
|
38
|
+
* @property {(file: string) => Promise<string>} [readFile] - returns file contents (utf8).
|
|
39
|
+
* @property {() => string} [homedir]
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run a command with a fixed argv and NO shell. Returns stdout.
|
|
44
|
+
* @param {string} cmd
|
|
45
|
+
* @param {string[]} args
|
|
46
|
+
* @returns {Promise<string>}
|
|
47
|
+
*/
|
|
48
|
+
function defaultExecFile(cmd, args) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
// No `encoding` option → stdout is a utf8 string; no shell → args are passed literally.
|
|
51
|
+
_execFile(cmd, args, { timeout: 10_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
|
52
|
+
if (err) reject(err);
|
|
53
|
+
else resolve(stdout);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read THIS machine's Claude Code credential and return the raw JSON string.
|
|
60
|
+
*
|
|
61
|
+
* Strategy: on macOS try the Keychain first (where recent Claude Code stores it), then fall back to
|
|
62
|
+
* ~/.claude/.credentials.json (older macOS installs, and the canonical location on Linux/Windows).
|
|
63
|
+
* If nothing is found, throw a LocalCredentialError with a recommendation.
|
|
64
|
+
*
|
|
65
|
+
* @param {CredentialDeps} [deps]
|
|
66
|
+
* @returns {Promise<string>}
|
|
67
|
+
*/
|
|
68
|
+
export async function readLocalCredentialRaw(deps = {}) {
|
|
69
|
+
const platform = deps.platform ?? process.platform;
|
|
70
|
+
const execFile = deps.execFile ?? defaultExecFile;
|
|
71
|
+
const readFile = deps.readFile ?? ((/** @type {string} */ file) => _readFile(file, "utf8"));
|
|
72
|
+
const homedir = deps.homedir ?? _homedir;
|
|
73
|
+
|
|
74
|
+
// 1) macOS Keychain (fixed argv — no shell, no interpolation).
|
|
75
|
+
if (platform === "darwin") {
|
|
76
|
+
try {
|
|
77
|
+
const out = await execFile("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"]);
|
|
78
|
+
const trimmed = out.trim();
|
|
79
|
+
if (trimmed) return trimmed;
|
|
80
|
+
} catch {
|
|
81
|
+
// No keychain entry (or `security` unavailable) — fall through to the file.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2) ~/.claude/.credentials.json — canonical on Linux/Windows, and the older macOS location.
|
|
86
|
+
const file = path.join(homedir(), ".claude", ".credentials.json");
|
|
87
|
+
try {
|
|
88
|
+
const content = await readFile(file);
|
|
89
|
+
const trimmed = content.trim();
|
|
90
|
+
if (trimmed) return trimmed;
|
|
91
|
+
} catch {
|
|
92
|
+
// Fall through to the appropriate error below.
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3) Nothing found — give a clear, OS-aware reason + recommendation.
|
|
96
|
+
const supported = platform === "darwin" || platform === "linux" || platform === "win32";
|
|
97
|
+
if (!supported) {
|
|
98
|
+
throw new LocalCredentialError(
|
|
99
|
+
`Unsupported OS "${String(platform)}" — can't auto-read the Claude Code credential here.`,
|
|
100
|
+
"Add the account by pasting its token instead (the manual option below).",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
throw new LocalCredentialError(
|
|
104
|
+
"Couldn't find a Claude Code credential on this machine.",
|
|
105
|
+
"Make sure Claude Code is installed and signed in on this machine (run `claude` and log in), then try again — or add the account by pasting its token.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract just the OAuth tokens from a raw credential blob. Accepts the full
|
|
111
|
+
* `{ claudeAiOauth: {...} }` wrapper or a bare `{ accessToken, ... }` object. Missing
|
|
112
|
+
* refreshToken/expiresAt default to null/0 rather than being invented.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} raw
|
|
115
|
+
* @returns {ExtractedTokens}
|
|
116
|
+
*/
|
|
117
|
+
export function extractTokens(raw) {
|
|
118
|
+
let obj;
|
|
119
|
+
try {
|
|
120
|
+
obj = JSON.parse(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new LocalCredentialError(
|
|
123
|
+
"The Claude Code credential wasn't valid JSON.",
|
|
124
|
+
"Re-run the login for this account in Claude Code, then try again.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const oauth = obj && typeof obj === "object" && obj.claudeAiOauth ? obj.claudeAiOauth : obj;
|
|
128
|
+
const accessToken = oauth && typeof oauth === "object" ? oauth.accessToken : undefined;
|
|
129
|
+
if (typeof accessToken !== "string" || !accessToken) {
|
|
130
|
+
throw new LocalCredentialError(
|
|
131
|
+
"The Claude Code credential didn't contain an access token.",
|
|
132
|
+
"Sign in to Claude Code again for this account, then try again.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
accessToken,
|
|
137
|
+
refreshToken: typeof oauth.refreshToken === "string" ? oauth.refreshToken : null,
|
|
138
|
+
expiresAt: typeof oauth.expiresAt === "number" ? oauth.expiresAt : 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "how-much-claude",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Connect this machine's Claude Code account to How Much Claude (howmuchclaude.com) — a live usage monitor for your Claude limits across multiple accounts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"how-much-claude": "./connect.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"connect.mjs",
|
|
12
|
+
"credential-source.mjs",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"sync": "node sync.mjs",
|
|
20
|
+
"prepublishOnly": "node sync.mjs"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/SeraphKc/how-much-claude.git",
|
|
25
|
+
"directory": "cli"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://howmuchclaude.com",
|
|
28
|
+
"bugs": "https://github.com/SeraphKc/how-much-claude/issues",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"claude",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"usage",
|
|
33
|
+
"rate-limit",
|
|
34
|
+
"monitor",
|
|
35
|
+
"anthropic"
|
|
36
|
+
]
|
|
37
|
+
}
|