tokenmaxxing 1.66.0 → 1.67.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/DESIGN.md +2 -1
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/cli/doctor.ts +5 -1
- package/src/cli/serve.ts +16 -9
- package/src/lib/claude.ts +3 -1
- package/src/lib/install.ts +143 -23
- package/src/main.ts +4 -2
package/DESIGN.md
CHANGED
|
@@ -42,8 +42,9 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
42
42
|
- Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
|
|
43
43
|
|
|
44
44
|
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last sample attempt is oldest (the direct no-spend usage GET), deletes leftover dead state files at the state root, and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
|
|
45
|
+
- A usage hub service (`com.tokenmaxxing.hub` launchd agent on macOS, `tokenmaxxing-hub.service` systemd user service on Linux) running `tokenmaxxing serve`, so the [usage hub](docs/content/docs/hub.mdx) is up in the background beside the check timer.
|
|
45
46
|
|
|
46
|
-
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process
|
|
47
|
+
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process in the switching path. The [usage hub](docs/content/docs/hub.mdx) that answers the CLIProxyAPI management subset for a dashboard from the cached figures runs outside the switching path, as a background service beside the check timer (`tokenmaxxing serve` runs the same server in the foreground). The `claude` binary, `~/.claude.json`, and Claude Code's default credential store are untouched; inside `~/.claude` only `settings.json` gets the five entries.
|
|
47
48
|
|
|
48
49
|
---
|
|
49
50
|
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ programs.tokenmaxxing.package = inputs.tokenmaxxing.packages.${pkgs.system}.defa
|
|
|
52
52
|
|
|
53
53
|
With either module, run `tokenmaxxing init` afterwards.
|
|
54
54
|
|
|
55
|
-
`init` verifies and pins the real `claude` binary, opens an isolated `claude` session for your first pooled account (run `/login` there, even if you are already signed in; the login you already have stays for sessions started outside the supervisor), installs the `claude` supervisor and the `xx` entry point, five `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a StopFailure hook, a SessionStart hook),
|
|
55
|
+
`init` verifies and pins the real `claude` binary, opens an isolated `claude` session for your first pooled account (run `/login` there, even if you are already signed in; the login you already have stays for sessions started outside the supervisor), installs the `claude` supervisor and the `xx` entry point, five `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a StopFailure hook, a SessionStart hook), the periodic check timer, and the usage hub service, and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
|
|
56
56
|
|
|
57
57
|
```sh
|
|
58
58
|
tokenmaxxing add # logs one in, in isolation, into its own store
|
|
@@ -63,7 +63,7 @@ claude # use claude as always; each session gets its own accoun
|
|
|
63
63
|
|
|
64
64
|
| command | what it does |
|
|
65
65
|
|---|---|
|
|
66
|
-
| `tokenmaxxing init` | verify and pin the real `claude`, log the first account in (isolated) + install supervisor, hooks, PATH line,
|
|
66
|
+
| `tokenmaxxing init` | verify and pin the real `claude`, log the first account in (isolated) + install supervisor, hooks, PATH line, check timer, and hub service |
|
|
67
67
|
| `tokenmaxxing init --codex` | same for codex: log in the first account (isolated), install codex supervisor + Stop hook |
|
|
68
68
|
| `tokenmaxxing init --grok` / `init --opencode-go` | pool the first grok login or opencode-go key, status-only: no supervisor, no switching |
|
|
69
69
|
| `tokenmaxxing add [--codex \| --grok \| --opencode-go]` | register an additional account in that pool (isolated login, harvested once into its own store) |
|
|
@@ -71,10 +71,10 @@ claude # use claude as always; each session gets its own accoun
|
|
|
71
71
|
| `tokenmaxxing status [--cached]` | every pool: accounts with 5h / weekly / per-model usage bars, live session counts, exhausted-until-reset; `--cached` renders the stored figures without sampling |
|
|
72
72
|
| `tokenmaxxing config` | the config path and the effective values; edit the file in an editor, a bad value fails the next load with the field name |
|
|
73
73
|
| `tokenmaxxing check` | fold fresh tees, sample up to three stale accounts, and self-update a Bun global install once a day; the periodic timer runs this every tick |
|
|
74
|
-
| `tokenmaxxing doctor` | verify the install: PATH order, `claudeBin`, the five settings entries, the timer, the credential identity of every Codex store and of every Claude store whose access token is still fresh (an expiring Claude token is reported as unverifiable, not as a failure), codex hook trust, setup-token age, and shell aliases that shadow `claude` |
|
|
74
|
+
| `tokenmaxxing doctor` | verify the install: PATH order, `claudeBin`, the five settings entries, the timer, the hub service, the credential identity of every Codex store and of every Claude store whose access token is still fresh (an expiring Claude token is reported as unverifiable, not as a failure), codex hook trust, setup-token age, and shell aliases that shadow `claude` |
|
|
75
75
|
| `tokenmaxxing rename [--codex \| --grok \| --opencode-go] <sel> <label>` / `rm [...] <sel>` | manage a pool (one email can hold both a claude and a codex account); `rm` is refused for an account with a running supervised session |
|
|
76
|
-
| `tokenmaxxing serve` | serve the CLIProxyAPI-compatible usage API on `http://localhost:8317` (`hub.port`) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows the quota of every pooled Claude and Codex account; the management key is the contents of `hub-key` in the state directory, minted on the first run. Details in [Usage hub](docs/content/docs/hub.mdx) |
|
|
77
|
-
| `tokenmaxxing uninstall [--yes]` | print the targets, then remove the shims, settings entries, codex Stop hook, check timer, and rc PATH line (accounts/stores kept); refused without `--yes` when `HOME` is the login home |
|
|
76
|
+
| `tokenmaxxing serve` | serve the CLIProxyAPI-compatible usage API on `http://localhost:8317` (`hub.port`) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows the quota of every pooled Claude and Codex account; the management key is the contents of `hub-key` in the state directory, minted on the first run, and `init` runs it as a background service beside the check timer. Details in [Usage hub](docs/content/docs/hub.mdx) |
|
|
77
|
+
| `tokenmaxxing uninstall [--yes]` | print the targets, then remove the shims, settings entries, codex Stop hook, check timer, hub service, and rc PATH line (accounts/stores kept); refused without `--yes` when `HOME` is the login home |
|
|
78
78
|
| `tokenmaxxing setup-token [--print \| rm <label\|uuid>]` | Cursor Cloud only: mint one `claude setup-token` per pooled account (a browser sign-in each) and print the `TOKENMAXXING_TOKENS` secret value; `--print` prints the stored set, `rm` drops one |
|
|
79
79
|
| `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
|
|
80
80
|
| `tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"` | on a Cursor Cloud VM: run `claude -p` on a setup token, rotate to the next token on a usage limit |
|
|
@@ -115,7 +115,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
115
115
|
|
|
116
116
|
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a usage sample stays fresh, for a seat's own sample and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick. `claudeBin`, `codexBin`, `grokBin`, and `opencodeBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, and `TOKENMAXXING_OPENCODE_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
|
|
117
117
|
|
|
118
|
-
State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer
|
|
118
|
+
State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer and usage hub units, and your shell rc. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
119
119
|
|
|
120
120
|
## Codex support
|
|
121
121
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.67.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/doctor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { verifyRealClaude } from "../lib/claudebin.ts";
|
|
3
3
|
import { checkSettings, installedBin } from "../lib/settings.ts";
|
|
4
|
-
import { checkTimerHealthy, codexStoreHookTrust, findClaudeShadowers, isBinDirAhead, shellRcPath, timerActivationHint } from "../lib/install.ts";
|
|
4
|
+
import { checkTimerHealthy, codexStoreHookTrust, findClaudeShadowers, hubServiceHealthy, isBinDirAhead, shellRcPath, skipImperativeHub, timerActivationHint } from "../lib/install.ts";
|
|
5
5
|
import { errorMessage } from "../lib/log.ts";
|
|
6
6
|
import { claudePool, codexPool, paths } from "../lib/paths.ts";
|
|
7
7
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
@@ -31,6 +31,10 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
31
31
|
check(s.stopFailureOk, "StopFailure hook installed in settings.json", "run `tokenmaxxing init`");
|
|
32
32
|
check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
|
|
33
33
|
check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
|
|
34
|
+
if (existsSync(paths.supervisorLink)) {
|
|
35
|
+
if (skipImperativeHub()) note("usage hub service is owned outside init (TOKENMAXXING_SKIP_HUB set) - not verified here");
|
|
36
|
+
else check(hubServiceHealthy(), "usage hub service active", "run `tokenmaxxing init` (it writes the unit and activates it)");
|
|
37
|
+
}
|
|
34
38
|
|
|
35
39
|
const idx = loadAccounts(claudePool);
|
|
36
40
|
check(idx.accounts.length > 0, "at least one account in the pool", "run `tokenmaxxing init`");
|
package/src/cli/serve.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { claude } from "../lib/claude.ts";
|
|
|
7
7
|
import { codex, observeCodex } from "../lib/codex.ts";
|
|
8
8
|
import { CODEX_USAGE_URL } from "../lib/codexusage.ts";
|
|
9
9
|
import { errorMessage, log } from "../lib/log.ts";
|
|
10
|
+
import { hubDeactivationHint } from "../lib/install.ts";
|
|
10
11
|
import { paths } from "../lib/paths.ts";
|
|
11
12
|
import { isExhausted, thresholdBars } from "../lib/picker.ts";
|
|
12
13
|
import type { Provider } from "../lib/provider.ts";
|
|
@@ -198,15 +199,21 @@ export async function cmdServe(args: string[]): Promise<number> {
|
|
|
198
199
|
}
|
|
199
200
|
const cfg = loadConfig();
|
|
200
201
|
const key = managementKey();
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
202
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
203
|
+
try {
|
|
204
|
+
server = Bun.serve({
|
|
205
|
+
hostname: "localhost",
|
|
206
|
+
port: cfg.hub.port,
|
|
207
|
+
fetch: (req) => handle(req, key),
|
|
208
|
+
error: (e) => {
|
|
209
|
+
log("hub.error", { err: errorMessage(e) });
|
|
210
|
+
return json({ error: "internal error" }, 500);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
} catch (e) {
|
|
214
|
+
emitError({ message: `cannot bind localhost:${cfg.hub.port}: ${errorMessage(e)} (another \`tokenmaxxing serve\` or the usage hub service may already hold the port - stop it first: ${hubDeactivationHint()})` });
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
210
217
|
console.log(`${c.green("✓")} serving the CLIProxyAPI-compatible usage API at http://localhost:${server.port}/v0/management`);
|
|
211
218
|
console.log(c.dim(`management key: the contents of ${paths.hubKeyFile}`));
|
|
212
219
|
log("hub.started", { port: server.port });
|
package/src/lib/claude.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { readItem, writeItem, deleteItem, isolatedTarget, readStore, storeTarget, claudeAiOauthOnly } from "./credstore.ts";
|
|
5
5
|
import { resolveRealClaude, resolveVerifiedClaude } from "./claudebin.ts";
|
|
6
6
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
7
|
-
import { ensurePathInRc, installSupervisor, managedShellRcSkipLines, shellRcPath, timerActivationHint } from "./install.ts";
|
|
7
|
+
import { ensurePathInRc, hubActivationHint, installSupervisor, managedShellRcSkipLines, shellRcPath, timerActivationHint } from "./install.ts";
|
|
8
8
|
import { withLock } from "./lock.ts";
|
|
9
9
|
import { errorMessage, log } from "./log.ts";
|
|
10
10
|
import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential, InvalidGrantError } from "./oauth.ts";
|
|
@@ -270,6 +270,8 @@ function install(): void {
|
|
|
270
270
|
console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/StopFailure/SessionStart hooks`);
|
|
271
271
|
if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every ${out.checkIntervalS}s)`);
|
|
272
272
|
else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
|
|
273
|
+
if (out.hubLoaded) console.log(`${c.green("✓")} usage hub service active`);
|
|
274
|
+
else console.log(c.yellow(`⚠ usage hub service written but not activated - run: ${hubActivationHint()}`));
|
|
273
275
|
if (!out.pathAhead) {
|
|
274
276
|
console.log();
|
|
275
277
|
ensurePathAhead();
|
package/src/lib/install.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { resolveRealClaude } from "./claudebin.ts";
|
|
|
9
9
|
import { loadConfig } from "./state.ts";
|
|
10
10
|
import { ErrnoSchema } from "./types.ts";
|
|
11
11
|
|
|
12
|
-
export type InstallOutcome = { claudeWrapper: string; installedBin: string; pathAhead: boolean; timerLoaded: boolean; checkIntervalS: number };
|
|
12
|
+
export type InstallOutcome = { claudeWrapper: string; installedBin: string; pathAhead: boolean; timerLoaded: boolean; hubLoaded: boolean; checkIntervalS: number };
|
|
13
13
|
|
|
14
14
|
export function isBinDirAhead(): boolean {
|
|
15
15
|
const dirs = (process.env.PATH ?? "").split(":");
|
|
@@ -39,6 +39,10 @@ export function skipImperativeTimer(): boolean {
|
|
|
39
39
|
return envFlag("TOKENMAXXING_SKIP_TIMER");
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function skipImperativeHub(): boolean {
|
|
43
|
+
return envFlag("TOKENMAXXING_SKIP_HUB");
|
|
44
|
+
}
|
|
45
|
+
|
|
42
46
|
function isNixStorePath(path: string): boolean {
|
|
43
47
|
return path === "/nix/store" || path.startsWith("/nix/store/");
|
|
44
48
|
}
|
|
@@ -103,6 +107,7 @@ export function installSupervisor(): InstallOutcome {
|
|
|
103
107
|
installedBin: target,
|
|
104
108
|
pathAhead: isBinDirAhead(),
|
|
105
109
|
timerLoaded: installCheckTimer(checkIntervalS),
|
|
110
|
+
hubLoaded: installHubService(),
|
|
106
111
|
checkIntervalS,
|
|
107
112
|
};
|
|
108
113
|
}
|
|
@@ -203,9 +208,11 @@ export function uninstallCodexSupervisor(): void {
|
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
const LAUNCHD_LABEL = "com.tokenmaxxing.check";
|
|
211
|
+
const LAUNCHD_HUB_LABEL = "com.tokenmaxxing.hub";
|
|
212
|
+
const HUB_UNIT = "tokenmaxxing-hub.service";
|
|
206
213
|
|
|
207
|
-
function launchdPlist(): string {
|
|
208
|
-
return join(paths.launchdAgentsDir, `${
|
|
214
|
+
function launchdPlist(label: string): string {
|
|
215
|
+
return join(paths.launchdAgentsDir, `${label}.plist`);
|
|
209
216
|
}
|
|
210
217
|
|
|
211
218
|
function launchdDomain(): string | null {
|
|
@@ -221,11 +228,19 @@ function run(cmd: string[]): boolean {
|
|
|
221
228
|
}
|
|
222
229
|
}
|
|
223
230
|
|
|
231
|
+
function systemdEnv(): string {
|
|
232
|
+
return `Environment="TOKENMAXXING_HOME=${paths.home.replaceAll("%", "%%")}"\n`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function launchdEnv(): string {
|
|
236
|
+
return ` <key>EnvironmentVariables</key><dict><key>TOKENMAXXING_HOME</key><string>${escape(paths.home)}</string></dict>\n`;
|
|
237
|
+
}
|
|
238
|
+
|
|
224
239
|
function installCheckTimer(intervalS: number): boolean {
|
|
225
240
|
if (skipImperativeTimer()) return true;
|
|
226
241
|
|
|
227
242
|
if (process.platform === "darwin") {
|
|
228
|
-
const plist = launchdPlist();
|
|
243
|
+
const plist = launchdPlist(LAUNCHD_LABEL);
|
|
229
244
|
writeFileAtomic(
|
|
230
245
|
plist,
|
|
231
246
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -235,7 +250,7 @@ function installCheckTimer(intervalS: number): boolean {
|
|
|
235
250
|
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
236
251
|
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string></array>
|
|
237
252
|
<key>StartInterval</key><integer>${intervalS}</integer>
|
|
238
|
-
<key>StandardOutPath</key><string>/dev/null</string>
|
|
253
|
+
${launchdEnv()} <key>StandardOutPath</key><string>/dev/null</string>
|
|
239
254
|
<key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
|
|
240
255
|
</dict>
|
|
241
256
|
</plist>
|
|
@@ -257,7 +272,7 @@ Description=tokenmaxxing account-switch check
|
|
|
257
272
|
[Service]
|
|
258
273
|
Type=oneshot
|
|
259
274
|
ExecStart=${exec}
|
|
260
|
-
`,
|
|
275
|
+
${systemdEnv()}`,
|
|
261
276
|
0o644,
|
|
262
277
|
);
|
|
263
278
|
writeFileAtomic(
|
|
@@ -283,16 +298,23 @@ WantedBy=timers.target
|
|
|
283
298
|
|
|
284
299
|
export function timerActivationHint(): string {
|
|
285
300
|
if (process.platform === "darwin") {
|
|
286
|
-
return `launchctl bootstrap gui/$(id -u) ${launchdPlist()}`;
|
|
301
|
+
return `launchctl bootstrap gui/$(id -u) ${launchdPlist(LAUNCHD_LABEL)}`;
|
|
287
302
|
}
|
|
288
303
|
return "systemctl --user daemon-reload && systemctl --user enable --now tokenmaxxing-check.timer";
|
|
289
304
|
}
|
|
290
305
|
|
|
306
|
+
export function hubActivationHint(): string {
|
|
307
|
+
if (process.platform === "darwin") {
|
|
308
|
+
return `launchctl bootstrap gui/$(id -u) ${launchdPlist(LAUNCHD_HUB_LABEL)}`;
|
|
309
|
+
}
|
|
310
|
+
return `systemctl --user daemon-reload && systemctl --user enable --now ${HUB_UNIT}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
291
313
|
export function checkTimerHealthy(): boolean {
|
|
292
314
|
if (skipImperativeTimer()) return true;
|
|
293
315
|
if (process.platform === "darwin") {
|
|
294
316
|
const domain = launchdDomain();
|
|
295
|
-
return existsSync(launchdPlist()) && domain != null && run(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`]);
|
|
317
|
+
return existsSync(launchdPlist(LAUNCHD_LABEL)) && domain != null && run(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`]);
|
|
296
318
|
}
|
|
297
319
|
return (
|
|
298
320
|
existsSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer")) &&
|
|
@@ -300,6 +322,15 @@ export function checkTimerHealthy(): boolean {
|
|
|
300
322
|
);
|
|
301
323
|
}
|
|
302
324
|
|
|
325
|
+
export function hubServiceHealthy(): boolean {
|
|
326
|
+
if (skipImperativeHub()) return true;
|
|
327
|
+
if (process.platform === "darwin") {
|
|
328
|
+
const domain = launchdDomain();
|
|
329
|
+
return existsSync(launchdPlist(LAUNCHD_HUB_LABEL)) && domain != null && run(["launchctl", "print", `${domain}/${LAUNCHD_HUB_LABEL}`]);
|
|
330
|
+
}
|
|
331
|
+
return existsSync(join(paths.systemdUserDir, HUB_UNIT)) && run(["systemctl", "--user", "is-active", "--quiet", HUB_UNIT]);
|
|
332
|
+
}
|
|
333
|
+
|
|
303
334
|
export function timerDeactivationHint(): string {
|
|
304
335
|
if (process.platform === "darwin") {
|
|
305
336
|
return `launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`;
|
|
@@ -307,11 +338,18 @@ export function timerDeactivationHint(): string {
|
|
|
307
338
|
return "systemctl --user disable --now tokenmaxxing-check.timer";
|
|
308
339
|
}
|
|
309
340
|
|
|
310
|
-
function
|
|
341
|
+
export function hubDeactivationHint(): string {
|
|
342
|
+
if (process.platform === "darwin") {
|
|
343
|
+
return `launchctl bootout gui/$(id -u)/${LAUNCHD_HUB_LABEL}`;
|
|
344
|
+
}
|
|
345
|
+
return `systemctl --user disable --now ${HUB_UNIT}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function launchdJobLoaded(label: string): "loaded" | "not-loaded" | "unavailable" {
|
|
311
349
|
const domain = launchdDomain();
|
|
312
350
|
if (domain == null) return "unavailable";
|
|
313
351
|
try {
|
|
314
|
-
const { exitCode } = Bun.spawnSync(["launchctl", "print", `${domain}/${
|
|
352
|
+
const { exitCode } = Bun.spawnSync(["launchctl", "print", `${domain}/${label}`], { stdout: "ignore", stderr: "ignore", timeout: 10_000 });
|
|
315
353
|
if (exitCode === 0) return "loaded";
|
|
316
354
|
return exitCode === 113 ? "not-loaded" : "unavailable";
|
|
317
355
|
} catch {
|
|
@@ -319,9 +357,9 @@ function launchdJobLoaded(): "loaded" | "not-loaded" | "unavailable" {
|
|
|
319
357
|
}
|
|
320
358
|
}
|
|
321
359
|
|
|
322
|
-
function
|
|
360
|
+
function systemdUnitActive(unit: string): "active" | "not-active" | "unavailable" {
|
|
323
361
|
try {
|
|
324
|
-
const proc = Bun.spawnSync(["systemctl", "--user", "is-active",
|
|
362
|
+
const proc = Bun.spawnSync(["systemctl", "--user", "is-active", unit], { stdout: "pipe", stderr: "ignore", timeout: 10_000 });
|
|
325
363
|
const state = proc.stdout.toString().trim();
|
|
326
364
|
if (state === "active" || state === "activating" || state === "reloading") return "active";
|
|
327
365
|
if (state === "inactive" || state === "failed" || state === "deactivating" || state === "unknown" || state === "maintenance") return "not-active";
|
|
@@ -333,16 +371,16 @@ function systemdTimerActive(): "active" | "not-active" | "unavailable" {
|
|
|
333
371
|
|
|
334
372
|
function removeTimerUnits(): void {
|
|
335
373
|
if (process.platform === "darwin") {
|
|
336
|
-
rmSync(launchdPlist(), { force: true });
|
|
374
|
+
rmSync(launchdPlist(LAUNCHD_LABEL), { force: true });
|
|
337
375
|
return;
|
|
338
376
|
}
|
|
339
377
|
rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer"), { force: true });
|
|
340
378
|
rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.service"), { force: true });
|
|
341
379
|
}
|
|
342
380
|
|
|
343
|
-
type
|
|
381
|
+
type UnitOutcome = "removed" | "skipped" | "still-loaded";
|
|
344
382
|
|
|
345
|
-
function uninstallCheckTimer(live: boolean):
|
|
383
|
+
function uninstallCheckTimer(live: boolean): UnitOutcome {
|
|
346
384
|
if (skipImperativeTimer()) return "skipped";
|
|
347
385
|
if (!live) {
|
|
348
386
|
removeTimerUnits();
|
|
@@ -350,18 +388,94 @@ function uninstallCheckTimer(live: boolean): TimerOutcome {
|
|
|
350
388
|
}
|
|
351
389
|
if (process.platform === "darwin") {
|
|
352
390
|
const domain = launchdDomain();
|
|
353
|
-
const loaded = launchdJobLoaded();
|
|
391
|
+
const loaded = launchdJobLoaded(LAUNCHD_LABEL);
|
|
354
392
|
const deactivated = loaded === "loaded" && domain != null ? run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]) : loaded === "not-loaded";
|
|
355
393
|
removeTimerUnits();
|
|
356
394
|
return deactivated ? "removed" : "still-loaded";
|
|
357
395
|
}
|
|
358
|
-
const active =
|
|
359
|
-
const deactivated = active
|
|
396
|
+
const active = systemdUnitActive("tokenmaxxing-check.timer");
|
|
397
|
+
const deactivated = active !== "unavailable" && (run(["systemctl", "--user", "disable", "--now", "tokenmaxxing-check.timer"]) || active === "not-active");
|
|
360
398
|
removeTimerUnits();
|
|
361
399
|
run(["systemctl", "--user", "daemon-reload"]);
|
|
362
400
|
return deactivated ? "removed" : "still-loaded";
|
|
363
401
|
}
|
|
364
402
|
|
|
403
|
+
function installHubService(): boolean {
|
|
404
|
+
if (skipImperativeHub()) return true;
|
|
405
|
+
|
|
406
|
+
if (process.platform === "darwin") {
|
|
407
|
+
const plist = launchdPlist(LAUNCHD_HUB_LABEL);
|
|
408
|
+
writeFileAtomic(
|
|
409
|
+
plist,
|
|
410
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
411
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
412
|
+
<plist version="1.0">
|
|
413
|
+
<dict>
|
|
414
|
+
<key>Label</key><string>${LAUNCHD_HUB_LABEL}</string>
|
|
415
|
+
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>serve</string></array>
|
|
416
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
417
|
+
<key>RunAtLoad</key><true/>
|
|
418
|
+
${launchdEnv()} <key>StandardOutPath</key><string>/dev/null</string>
|
|
419
|
+
<key>StandardErrorPath</key><string>${escape(join(paths.home, "hub.stderr.log"))}</string>
|
|
420
|
+
</dict>
|
|
421
|
+
</plist>
|
|
422
|
+
`,
|
|
423
|
+
0o644,
|
|
424
|
+
);
|
|
425
|
+
const domain = launchdDomain();
|
|
426
|
+
if (domain == null) return false;
|
|
427
|
+
run(["launchctl", "bootout", `${domain}/${LAUNCHD_HUB_LABEL}`]);
|
|
428
|
+
return run(["launchctl", "bootstrap", domain, plist]) || hubServiceHealthy();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const exec = `"${installedBin().replaceAll("%", "%%")}" serve`;
|
|
432
|
+
writeFileAtomic(
|
|
433
|
+
join(paths.systemdUserDir, HUB_UNIT),
|
|
434
|
+
`[Unit]
|
|
435
|
+
Description=tokenmaxxing usage hub
|
|
436
|
+
StartLimitIntervalSec=0
|
|
437
|
+
|
|
438
|
+
[Service]
|
|
439
|
+
ExecStart=${exec}
|
|
440
|
+
${systemdEnv()}Restart=on-failure
|
|
441
|
+
RestartSec=5
|
|
442
|
+
|
|
443
|
+
[Install]
|
|
444
|
+
WantedBy=default.target
|
|
445
|
+
`,
|
|
446
|
+
0o644,
|
|
447
|
+
);
|
|
448
|
+
return run(["systemctl", "--user", "daemon-reload"]) && run(["systemctl", "--user", "enable", "--now", HUB_UNIT]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function removeHubUnits(): void {
|
|
452
|
+
if (process.platform === "darwin") {
|
|
453
|
+
rmSync(launchdPlist(LAUNCHD_HUB_LABEL), { force: true });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
rmSync(join(paths.systemdUserDir, HUB_UNIT), { force: true });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function uninstallHubService(live: boolean): UnitOutcome {
|
|
460
|
+
if (skipImperativeHub()) return "skipped";
|
|
461
|
+
if (!live) {
|
|
462
|
+
removeHubUnits();
|
|
463
|
+
return "removed";
|
|
464
|
+
}
|
|
465
|
+
if (process.platform === "darwin") {
|
|
466
|
+
const domain = launchdDomain();
|
|
467
|
+
const loaded = launchdJobLoaded(LAUNCHD_HUB_LABEL);
|
|
468
|
+
const deactivated = loaded === "loaded" && domain != null ? run(["launchctl", "bootout", `${domain}/${LAUNCHD_HUB_LABEL}`]) : loaded === "not-loaded";
|
|
469
|
+
removeHubUnits();
|
|
470
|
+
return deactivated ? "removed" : "still-loaded";
|
|
471
|
+
}
|
|
472
|
+
const active = systemdUnitActive(HUB_UNIT);
|
|
473
|
+
const deactivated = active !== "unavailable" && (run(["systemctl", "--user", "disable", "--now", HUB_UNIT]) || active === "not-active");
|
|
474
|
+
removeHubUnits();
|
|
475
|
+
run(["systemctl", "--user", "daemon-reload"]);
|
|
476
|
+
return deactivated ? "removed" : "still-loaded";
|
|
477
|
+
}
|
|
478
|
+
|
|
365
479
|
export function shellRcPath(): string | null {
|
|
366
480
|
const override = optionalEnv("TOKENMAXXING_SHELL_RC");
|
|
367
481
|
if (override != null) return override;
|
|
@@ -445,7 +559,7 @@ export function removePathFromRc(rc: string): boolean {
|
|
|
445
559
|
return true;
|
|
446
560
|
}
|
|
447
561
|
|
|
448
|
-
export type UninstallOutcome = { timer:
|
|
562
|
+
export type UninstallOutcome = { timer: UnitOutcome; hub: UnitOutcome; pathLineRemoved: boolean };
|
|
449
563
|
|
|
450
564
|
export function loginHome(): string {
|
|
451
565
|
const cmd = process.platform === "darwin" ? ["id", "-P"] : ["getent", "passwd", String(process.getuid?.() ?? "")];
|
|
@@ -462,13 +576,18 @@ export function onLoginHome(): boolean {
|
|
|
462
576
|
export function uninstallTargets(live: boolean): string[] {
|
|
463
577
|
const timer =
|
|
464
578
|
process.platform === "darwin"
|
|
465
|
-
? `${launchdPlist()}${live ? ` (and launchctl bootout gui/${process.getuid?.() ?? "?"}/${LAUNCHD_LABEL})` : ""}`
|
|
579
|
+
? `${launchdPlist(LAUNCHD_LABEL)}${live ? ` (and launchctl bootout gui/${process.getuid?.() ?? "?"}/${LAUNCHD_LABEL})` : ""}`
|
|
466
580
|
: `${join(paths.systemdUserDir, "tokenmaxxing-check.timer")} and .service${live ? " (and systemctl --user disable --now tokenmaxxing-check.timer)" : ""}`;
|
|
581
|
+
const hub =
|
|
582
|
+
process.platform === "darwin"
|
|
583
|
+
? `${launchdPlist(LAUNCHD_HUB_LABEL)}${live ? ` (and launchctl bootout gui/${process.getuid?.() ?? "?"}/${LAUNCHD_HUB_LABEL})` : ""}`
|
|
584
|
+
: `${join(paths.systemdUserDir, HUB_UNIT)}${live ? ` (and systemctl --user disable --now ${HUB_UNIT})` : ""}`;
|
|
467
585
|
const rc = shellRcPath();
|
|
468
586
|
return [
|
|
469
587
|
`${paths.claudeSettings}: the hook and statusline entries`,
|
|
470
588
|
`${codexPaths.hooksJson}: the codex Stop hook entry`,
|
|
471
589
|
...(skipImperativeTimer() ? [] : [timer]),
|
|
590
|
+
...(skipImperativeHub() ? [] : [hub]),
|
|
472
591
|
paths.supervisorLink,
|
|
473
592
|
codexSupervisorLink(),
|
|
474
593
|
join(paths.binDir, "xx"),
|
|
@@ -477,14 +596,15 @@ export function uninstallTargets(live: boolean): string[] {
|
|
|
477
596
|
];
|
|
478
597
|
}
|
|
479
598
|
|
|
480
|
-
export function uninstallSupervisor(input: {
|
|
599
|
+
export function uninstallSupervisor(input: { live: boolean }): UninstallOutcome {
|
|
481
600
|
uninstallSettings();
|
|
482
|
-
const timer = uninstallCheckTimer(input.
|
|
601
|
+
const timer = uninstallCheckTimer(input.live);
|
|
602
|
+
const hub = uninstallHubService(input.live);
|
|
483
603
|
uninstallCodexSupervisor();
|
|
484
604
|
for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
|
|
485
605
|
if (existsSync(f)) rmSync(f, { force: true });
|
|
486
606
|
}
|
|
487
607
|
const rc = shellRcPath();
|
|
488
608
|
const pathLineRemoved = rc != null && removePathFromRc(rc);
|
|
489
|
-
return { timer, pathLineRemoved };
|
|
609
|
+
return { timer, hub, pathLineRemoved };
|
|
490
610
|
}
|
package/src/main.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { cmdSetupToken } from "./cli/setuptoken.ts";
|
|
|
26
26
|
import { cmdCloudRun } from "./cli/cloudrun.ts";
|
|
27
27
|
import { cmdCursorInit } from "./cli/cursorinit.ts";
|
|
28
28
|
import { cmdServe } from "./cli/serve.ts";
|
|
29
|
-
import { onLoginHome, timerDeactivationHint, uninstallSupervisor, uninstallTargets } from "./lib/install.ts";
|
|
29
|
+
import { hubDeactivationHint, onLoginHome, timerDeactivationHint, uninstallSupervisor, uninstallTargets } from "./lib/install.ts";
|
|
30
30
|
import { errorMessage } from "./lib/log.ts";
|
|
31
31
|
import { HOME } from "./lib/paths.ts";
|
|
32
32
|
import { c, emitError } from "./cli/render.ts";
|
|
@@ -185,15 +185,17 @@ async function main(): Promise<number> {
|
|
|
185
185
|
emitError({ message: `refused: HOME is the login home (${HOME}) - rerun with ${YES_FLAG} to remove these from the live install` });
|
|
186
186
|
return 2;
|
|
187
187
|
}
|
|
188
|
-
const out = uninstallSupervisor({
|
|
188
|
+
const out = uninstallSupervisor({ live });
|
|
189
189
|
const removed = [
|
|
190
190
|
"supervisor wrapper",
|
|
191
191
|
"settings entries",
|
|
192
192
|
...(out.timer === "removed" ? ["check timer"] : []),
|
|
193
|
+
...(out.hub === "removed" ? ["usage hub service"] : []),
|
|
193
194
|
...(out.pathLineRemoved ? ["rc PATH line"] : []),
|
|
194
195
|
];
|
|
195
196
|
console.log(`removed ${removed.join(", ")}`);
|
|
196
197
|
if (out.timer === "still-loaded") console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
|
|
198
|
+
if (out.hub === "still-loaded") console.log(c.yellow(`⚠ the usage hub job may still be loaded - run: ${hubDeactivationHint()}`));
|
|
197
199
|
if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
|
|
198
200
|
console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; grok: grok-stores/; opencode-go: opencode-go-stores/) - remove accounts with \`xx rm\` to delete their credentials`);
|
|
199
201
|
return 0;
|