tokenmaxxing 0.19.0 → 0.21.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 +34 -23
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexswitch.ts +15 -1
- package/src/cli/config.ts +10 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +650 -78
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +134 -18
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +10 -2
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +114 -42
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +583 -76
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +127 -21
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +79 -37
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +61 -7
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/cli/doctor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `tokenmaxxing doctor` - verify the supervisor +
|
|
1
|
+
// `tokenmaxxing doctor` - verify the supervisor + four settings entries survived
|
|
2
2
|
// and the pool is healthy.
|
|
3
3
|
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -55,7 +55,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
55
55
|
if (org) check(org.organization_uuid === active.organizationUuid, `live credential identity matches active (${active.email})`, `token belongs to ${org.organization_name} - run \`tokenmaxxing switch\``);
|
|
56
56
|
else console.log(c.dim(` - live credential identity unverifiable (access token expired)`));
|
|
57
57
|
} catch (e) {
|
|
58
|
-
check(false, `live credential identity matches active (${active.email})`,
|
|
58
|
+
check(false, `live credential identity matches active (${active.email})`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -68,7 +68,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
68
68
|
if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - run \`tokenmaxxing auth ${a.label}\``);
|
|
69
69
|
else console.log(c.dim(` - ${a.email} identity unverifiable (access token expired)`));
|
|
70
70
|
} catch (e) {
|
|
71
|
-
check(false, `parked credential identity matches ${a.email}`,
|
|
71
|
+
check(false, `parked credential identity matches ${a.email}`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
if (a.needsReauth) check(false, `${a.email} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
|
package/src/cli/init.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// `tokenmaxxing init` - import the account you're already on (no prompts), then
|
|
2
|
-
// install the supervisor + the
|
|
2
|
+
// install the supervisor + the four settings entries.
|
|
3
3
|
|
|
4
4
|
import { mkdirSync } from "node:fs";
|
|
5
5
|
import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
6
|
import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
|
|
7
7
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { withClaudeRefreshLock } from "../lib/claudelock.ts";
|
|
9
|
+
import { loadAccounts, saveAccounts, loadConfig, pinBinOverride } from "../lib/state.ts";
|
|
10
|
+
import { withLock } from "../lib/lock.ts";
|
|
9
11
|
import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
|
|
10
12
|
import { resolveVerifiedClaude } from "../lib/claudebin.ts";
|
|
11
13
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
@@ -46,6 +48,13 @@ export function printUsage(): void {
|
|
|
46
48
|
|
|
47
49
|
export async function cmdInit(): Promise<number> {
|
|
48
50
|
mkdirSync(paths.home, { recursive: true });
|
|
51
|
+
// Fail fast on a broken merged config BEFORE installing or claiming a
|
|
52
|
+
// successful repair: pinBinOverride writes sparsely without validating, so
|
|
53
|
+
// without this gate a re-init printed success while hooks, switching, and
|
|
54
|
+
// the statusline kept throwing on every loadConfig until the file was
|
|
55
|
+
// hand-repaired (bugbot review catch, PR #33). The throw carries the
|
|
56
|
+
// fix-or-remove recovery message.
|
|
57
|
+
loadConfig();
|
|
49
58
|
|
|
50
59
|
// Already initialized → repair install ONLY. Never re-import: ~/.claude.json's
|
|
51
60
|
// oauthAccount can drift from the live keychain cred (after swaps / concurrent
|
|
@@ -56,10 +65,9 @@ export async function cmdInit(): Promise<number> {
|
|
|
56
65
|
// repair the claudeBin pin too - hooks run with claude's PATH and must
|
|
57
66
|
// never have to guess which binary is the real claude. Verified pinning:
|
|
58
67
|
// a pin that fails --version (or loops back into the wrapper) is replaced
|
|
59
|
-
// by a fresh PATH scan instead of being re-saved.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
saveConfig(cfg);
|
|
68
|
+
// by a fresh PATH scan instead of being re-saved. Sparse write: only the
|
|
69
|
+
// pin lands in the file, never the merged config.
|
|
70
|
+
pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
|
|
63
71
|
const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
|
|
64
72
|
console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
|
|
65
73
|
reportTimer(out);
|
|
@@ -96,8 +104,16 @@ export async function cmdInit(): Promise<number> {
|
|
|
96
104
|
// keychain credential, and importing on drifted state parks a mislabeled blob.
|
|
97
105
|
let creds = blob.claudeAiOauth;
|
|
98
106
|
if (isAccessTokenExpiring(creds)) {
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
await withClaudeRefreshLock(async (lock) => {
|
|
108
|
+
// re-read inside the lock: a running claude may have rotated it already.
|
|
109
|
+
const raw2 = await readItem(liveTarget());
|
|
110
|
+
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
111
|
+
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
112
|
+
creds = isAccessTokenExpiring(current) ? await refreshCredential(current) : current;
|
|
113
|
+
if (creds === current) return;
|
|
114
|
+
if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
|
|
115
|
+
await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
|
|
116
|
+
});
|
|
101
117
|
}
|
|
102
118
|
const org = await fetchTokenOrg(creds.accessToken);
|
|
103
119
|
if (org.organization_uuid !== oauthAccount.organizationUuid) {
|
|
@@ -108,30 +124,35 @@ export async function cmdInit(): Promise<number> {
|
|
|
108
124
|
|
|
109
125
|
const uuid = oauthAccount.accountUuid;
|
|
110
126
|
const keychainItem = credItemFor(uuid);
|
|
111
|
-
|
|
127
|
+
// Park + index update under the tokenmaxxing flock, like every sibling
|
|
128
|
+
// index writer (add/auth/rm/status/rename): unlocked, a concurrent `xx add`
|
|
129
|
+
// completing between this path's load and save had its just-registered
|
|
130
|
+
// account silently clobbered out of the index (closing-review catch).
|
|
131
|
+
const account = await withLock(paths.lockFile, async () => {
|
|
132
|
+
await writeItem(parkedTarget(keychainItem), JSON.stringify({ claudeAiOauth: creds })); // park a small backup
|
|
133
|
+
const idx = loadAccounts();
|
|
134
|
+
const existing = idx.accounts.find((a) => a.accountUuid === uuid);
|
|
135
|
+
const imported: Account = {
|
|
136
|
+
accountUuid: uuid,
|
|
137
|
+
email: oauthAccount.emailAddress,
|
|
138
|
+
organizationUuid: oauthAccount.organizationUuid,
|
|
139
|
+
label: existing?.label ?? oauthAccount.emailAddress,
|
|
140
|
+
keychainItem,
|
|
141
|
+
oauthAccount,
|
|
142
|
+
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
143
|
+
subscriptionType: blob.claudeAiOauth.subscriptionType,
|
|
144
|
+
rateLimitTier: blob.claudeAiOauth.rateLimitTier,
|
|
145
|
+
needsReauth: false,
|
|
146
|
+
};
|
|
147
|
+
if (existing) Object.assign(existing, imported);
|
|
148
|
+
else idx.accounts.push(imported);
|
|
149
|
+
idx.activeAccountUuid = uuid;
|
|
150
|
+
saveAccounts(idx);
|
|
151
|
+
return imported;
|
|
152
|
+
});
|
|
112
153
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const account: Account = {
|
|
116
|
-
accountUuid: uuid,
|
|
117
|
-
email: oauthAccount.emailAddress,
|
|
118
|
-
organizationUuid: oauthAccount.organizationUuid,
|
|
119
|
-
label: existing?.label ?? oauthAccount.emailAddress,
|
|
120
|
-
keychainItem,
|
|
121
|
-
oauthAccount,
|
|
122
|
-
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
123
|
-
subscriptionType: blob.claudeAiOauth.subscriptionType,
|
|
124
|
-
rateLimitTier: blob.claudeAiOauth.rateLimitTier,
|
|
125
|
-
needsReauth: false,
|
|
126
|
-
};
|
|
127
|
-
if (existing) Object.assign(existing, account);
|
|
128
|
-
else idx.accounts.push(account);
|
|
129
|
-
idx.activeAccountUuid = uuid;
|
|
130
|
-
saveAccounts(idx);
|
|
131
|
-
|
|
132
|
-
const cfg = loadConfig();
|
|
133
|
-
cfg.claudeBin = resolveVerifiedClaude();
|
|
134
|
-
saveConfig(cfg);
|
|
154
|
+
// Sparse write: only the pin lands in the file, never the merged config.
|
|
155
|
+
pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
|
|
135
156
|
|
|
136
157
|
const out = installSupervisor();
|
|
137
158
|
|
|
@@ -142,8 +163,9 @@ export async function cmdInit(): Promise<number> {
|
|
|
142
163
|
console.log();
|
|
143
164
|
ensurePathAhead();
|
|
144
165
|
}
|
|
166
|
+
const poolSize = loadAccounts().accounts.length;
|
|
145
167
|
console.log();
|
|
146
|
-
console.log(` pool ready (${
|
|
168
|
+
console.log(` pool ready (${poolSize} account${poolSize === 1 ? "" : "s"})`);
|
|
147
169
|
printUsage();
|
|
148
170
|
return 0;
|
|
149
171
|
}
|
package/src/cli/onboard.ts
CHANGED
|
@@ -10,13 +10,13 @@ import { join } from "node:path";
|
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { readItem, deleteItem, isolatedTarget } from "../lib/credstore.ts";
|
|
12
12
|
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
13
|
-
import { probeUsage, FullUsageSchema } from "../lib/usage.ts";
|
|
13
|
+
import { CRED_ENV_OVERRIDES, probeUsage, FullUsageSchema } from "../lib/usage.ts";
|
|
14
14
|
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
15
15
|
import { paths } from "../lib/paths.ts";
|
|
16
16
|
import { CredentialBlobSchema, OAuthAccountSchema } from "../lib/types.ts";
|
|
17
17
|
import { c } from "./render.ts";
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const HarvestedLoginSchema = z.object({
|
|
20
20
|
/** the raw isolated credential blob (park it via claudeAiOauthOnly). */
|
|
21
21
|
blobRaw: z.string(),
|
|
22
22
|
blob: CredentialBlobSchema,
|
|
@@ -46,6 +46,15 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
46
46
|
rmSync(onboardDir, { recursive: true, force: true });
|
|
47
47
|
mkdirSync(onboardDir, { recursive: true });
|
|
48
48
|
const iso = isolatedTarget(onboardDir);
|
|
49
|
+
// Reap a PREVIOUS run's stranded isolated credential too: on macOS the
|
|
50
|
+
// isolated store is a namespaced KEYCHAIN item, not a file in onboardDir,
|
|
51
|
+
// so the rmSync above never touched it - a signal-killed harvest (Ctrl-C
|
|
52
|
+
// after /login, before the ~400ms poll) left a live credential in the
|
|
53
|
+
// keychain that the next run's spawned claude would silently reuse as an
|
|
54
|
+
// already-authenticated session (closing-review catch). deleteItem on a
|
|
55
|
+
// missing item is a no-op; on Linux iso is a file inside onboardDir and the
|
|
56
|
+
// rmSync already covered it.
|
|
57
|
+
await deleteItem(iso);
|
|
49
58
|
const cjPath = join(onboardDir, ".claude.json");
|
|
50
59
|
const real = resolveRealClaude();
|
|
51
60
|
|
|
@@ -54,10 +63,7 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
54
63
|
// keychain lookup (verified 2.1.205) - the onboard session must authenticate
|
|
55
64
|
// only via the /login the user performs inside it.
|
|
56
65
|
const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
|
|
57
|
-
delete env
|
|
58
|
-
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
59
|
-
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
60
|
-
delete env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
66
|
+
for (const key of CRED_ENV_OVERRIDES) delete env[key];
|
|
61
67
|
const p = Bun.spawn([real], {
|
|
62
68
|
stdin: "inherit",
|
|
63
69
|
stdout: "inherit",
|
|
@@ -65,48 +71,59 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
65
71
|
env,
|
|
66
72
|
});
|
|
67
73
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
// The finally makes the "always destroyed before returning" header true for
|
|
75
|
+
// every non-signal exit - INCLUDING a failure while the login session is
|
|
76
|
+
// still being polled (review catch, PR #31): an exception must not strand a
|
|
77
|
+
// plaintext credential on disk or leave the spawned claude running. (An
|
|
78
|
+
// interactive Ctrl-C is reaped by the next run's cleanup-first, which
|
|
79
|
+
// deletes the isolated keychain item as well as the dir.)
|
|
80
|
+
try {
|
|
81
|
+
// Auto-exit (#17): watch for a completed login - identity written AND the
|
|
82
|
+
// isolated credential present - then SIGTERM claude. No manual /exit.
|
|
83
|
+
let exited = false;
|
|
84
|
+
const onExit = p.exited.then(() => { exited = true; });
|
|
85
|
+
while (!exited) {
|
|
86
|
+
await Bun.sleep(400);
|
|
87
|
+
if (identityReady(cjPath) && (await readItem(iso))) {
|
|
88
|
+
p.kill();
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
77
91
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
await p.exited;
|
|
93
|
+
await onExit;
|
|
94
|
+
// restore promptly so the harvest's own output renders on a sane terminal;
|
|
95
|
+
// the finally's second restore is an idempotent stty and covers the
|
|
96
|
+
// thrown-mid-poll path.
|
|
97
|
+
restoreTermios(savedTermios);
|
|
82
98
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const blobRaw = await readItem(iso);
|
|
89
|
-
if (!blobRaw || !identityReady(cjPath)) {
|
|
90
|
-
console.error(c.red("no login detected in the isolated session - nothing changed."));
|
|
91
|
-
await cleanup();
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
99
|
+
const blobRaw = await readItem(iso);
|
|
100
|
+
if (!blobRaw || !identityReady(cjPath)) {
|
|
101
|
+
console.error(c.red("no login detected in the isolated session - nothing changed."));
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
94
104
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
105
|
+
let blob, oauthAccount;
|
|
106
|
+
try {
|
|
107
|
+
blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
|
|
108
|
+
oauthAccount = OAuthAccountSchema.parse(JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount);
|
|
109
|
+
} catch {
|
|
110
|
+
console.error(c.red("could not parse the onboarded account's credential/identity."));
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
104
113
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
// Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
|
|
115
|
+
console.log(c.dim("sampling usage..."));
|
|
116
|
+
const sampled = await probeUsage(onboardDir);
|
|
117
|
+
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
109
118
|
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
return { blobRaw, blob, oauthAccount, sampled };
|
|
120
|
+
} finally {
|
|
121
|
+
if (p.exitCode === null) {
|
|
122
|
+
p.kill();
|
|
123
|
+
await p.exited;
|
|
124
|
+
}
|
|
125
|
+
restoreTermios(savedTermios);
|
|
126
|
+
await deleteItem(iso);
|
|
127
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
112
129
|
}
|
package/src/cli/render.ts
CHANGED
|
@@ -98,19 +98,3 @@ export function fmtAgo(epochMs: number, now = Date.now()): string {
|
|
|
98
98
|
return `${m}m ago`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/** Compact time-until-reset for the statusLine: the largest unit only ("6d",
|
|
102
|
-
* "2h", "45m"), floored to "1m" so a live window never reads as zero, and ""
|
|
103
|
-
* once the reset has passed (the window is simply empty again). Non-empty
|
|
104
|
-
* output always ends in a unit letter, so the digit-leading used-percent glued
|
|
105
|
-
* after it stays parseable. */
|
|
106
|
-
export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
|
|
107
|
-
if (epochMs == null) return "";
|
|
108
|
-
const dsec = Math.round((epochMs - now) / 1000);
|
|
109
|
-
if (dsec <= 0) return "";
|
|
110
|
-
const d = Math.floor(dsec / 86400);
|
|
111
|
-
const h = Math.floor((dsec % 86400) / 3600);
|
|
112
|
-
const m = Math.floor((dsec % 3600) / 60);
|
|
113
|
-
if (d > 0) return `${d}d`;
|
|
114
|
-
if (h > 0) return `${h}h`;
|
|
115
|
-
return `${Math.max(m, 1)}m`;
|
|
116
|
-
}
|
package/src/cli/rm.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { deleteItem, isolatedTarget, liveTarget, parkedTarget, readItem } from "../lib/credstore.ts";
|
|
6
|
+
import { fetchTokenOrg } from "../lib/oauth.ts";
|
|
4
7
|
import { withLock } from "../lib/lock.ts";
|
|
5
|
-
import { paths } from "../lib/paths.ts";
|
|
8
|
+
import { credItemFor, paths } from "../lib/paths.ts";
|
|
6
9
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
10
|
+
import { CredentialBlobSchema } from "../lib/types.ts";
|
|
7
11
|
import { findAccount } from "./rename.ts";
|
|
8
12
|
import { c } from "./render.ts";
|
|
9
13
|
|
|
@@ -24,7 +28,41 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
24
28
|
console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
|
|
25
29
|
return 1;
|
|
26
30
|
}
|
|
31
|
+
// The label drifts (manual /login); the token cannot lie. Removing the
|
|
32
|
+
// account whose credential is actually LIVE would destroy its only backup
|
|
33
|
+
// and leave the next swap refusing over an unpooled credential. Fail
|
|
34
|
+
// CLOSED (review catch, PR #31): when the live owner cannot be verified -
|
|
35
|
+
// unparsable blob, expired token, roles outage - refuse rather than trust
|
|
36
|
+
// the stale label; rm is destructive and can wait. The check is read-only:
|
|
37
|
+
// an expired bearer simply fails the roles call, nothing is ever rotated.
|
|
38
|
+
const live = await readItem(liveTarget());
|
|
39
|
+
if (live != null) {
|
|
40
|
+
let liveOrg: string;
|
|
41
|
+
try {
|
|
42
|
+
const liveCreds = CredentialBlobSchema.parse(JSON.parse(live)).claudeAiOauth;
|
|
43
|
+
liveOrg = (await fetchTokenOrg(liveCreds.accessToken)).organization_uuid;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error(c.red(`cannot verify which account the LIVE credential belongs to (${e instanceof Error ? e.message : String(e)}) - refusing to remove while the live owner is unknown; repair the live credential or retry once the roles endpoint is reachable.`));
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
if (liveOrg === a.organizationUuid) {
|
|
49
|
+
console.error(c.red(`${a.email}'s credential is currently LIVE (the active label is stale - a manual /login drifted it); run \`tokenmaxxing switch\` to move off it first.`));
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
27
53
|
await deleteItem(parkedTarget(a.keychainItem));
|
|
54
|
+
// Sweep sample-probe residue too: a probe killed mid-run strands the
|
|
55
|
+
// account's isolated credential (macOS: a namespaced keychain item), and
|
|
56
|
+
// once the account leaves the pool nothing would ever probe-and-heal it
|
|
57
|
+
// again (closing-review critic gap). deleteItem on a missing item is a
|
|
58
|
+
// no-op. Hard delete, not trash, on purpose: the sample dir is a
|
|
59
|
+
// throwaway CLAUDE_CONFIG_DIR that can hold PLAINTEXT credential material
|
|
60
|
+
// on Linux - the credential-dir cleanup exception (owner 2026-07-16, same
|
|
61
|
+
// rule sample.ts and onboard.ts follow); trashing would move credentials
|
|
62
|
+
// into the Trash folder.
|
|
63
|
+
const sampleDir = join(paths.sampleDir, credItemFor(a.accountUuid));
|
|
64
|
+
await deleteItem(isolatedTarget(sampleDir));
|
|
65
|
+
rmSync(sampleDir, { recursive: true, force: true });
|
|
28
66
|
idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
|
|
29
67
|
saveAccounts(idx);
|
|
30
68
|
console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
|