pi-multi-accounts 0.0.0-stage → 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nvminhtue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # pi-multi-accounts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [Pi](https://github.com/earendil-works/pi) extension for people with more than one subscription:
4
+
5
+ - **Claude**: keep **multiple Claude Pro/Max OAuth accounts** for Pi's built-in `/login` and switch between them with a single `/account` command. Pi stores exactly one `anthropic` credential; this extension saves additional logins next to it and swaps the active one on demand.
6
+ - **Codex**: use extra **ChatGPT (Codex) logins as parallel providers** (`openai-codex-<name>`) next to Pi's built-in `openai-codex`. Nothing is switched: pick the account's model with `/model`.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ # local checkout
12
+ pi install /absolute/path/to/pi-multi-accounts
13
+
14
+ # or from git
15
+ pi install git:github.com/<user>/pi-multi-accounts
16
+ ```
17
+
18
+ Then run `/reload` (or restart Pi).
19
+
20
+ ### Migrating from the standalone extensions / the old package
21
+
22
+ - Remove `~/.pi/agent/extensions/codex-accounts.ts` and `~/.pi/agent/extensions/claude-accounts.ts` if present. Otherwise the commands and providers are registered twice.
23
+ - Remove old `pi-claude-accounts` package entries from your Pi settings.
24
+ - `~/.pi/agent/claude-accounts.json` is kept: your saved Claude accounts continue to work.
25
+
26
+ ## Usage
27
+
28
+ | Command | What it does |
29
+ | ----------------------- | --------------------------------------------------- |
30
+ | `/account` | Pick a Claude account from a list and switch to it |
31
+ | `/account <name>` | Switch to the saved Claude account `<name>` |
32
+ | `/account save <name>` | Save the current Anthropic `/login` under `<name>` |
33
+ | `/account list` | List Claude accounts (● active, ○ inactive) and Codex providers (login state) |
34
+ | `/account remove <name>`| Forget a saved Claude account (`auth.json` is not changed) |
35
+ | `/account help` | Show command help |
36
+
37
+ Switch, save and remove work on **Claude accounts only**. Codex providers are only listed.
38
+
39
+ Aliases: `add` = `save`, `ls` = `list`, `rm` = `remove`, `use`/`switch` = switch.
40
+
41
+ The footer shows `claude: <name>` for the active saved account, or `claude: unsaved login` when the login in `auth.json` is not saved yet.
42
+
43
+ ## Logging in to a second account
44
+
45
+ 1. `/login` with your first Claude account, then `/account save work`.
46
+ 2. Run `/login` again. Pi reuses the browser's existing claude.ai session, so you would simply get the same account back. To avoid that, do one of:
47
+ - open the login URL Pi prints in a **private/incognito window**, or
48
+ - use a **different browser profile**, or
49
+ - **sign out of claude.ai** first.
50
+
51
+ Copy the login URL Pi prints into the private window and sign in with the second account.
52
+ 3. `/account save personal`.
53
+ 4. Run `/account list` and check that the emails differ.
54
+
55
+ ## Codex accounts
56
+
57
+ 1. For each extra ChatGPT account, log in with the Codex CLI into its own folder: `CODEX_HOME=~/.codex-<name> codex login`.
58
+ 2. In Pi, run `/login` and choose **"OpenAI Codex (<name>)"**. This imports the tokens from `~/.codex-<name>/auth.json`; no browser is involved.
59
+ 3. Pick the model with `/model`, e.g. `openai-codex-<name>/<model>`. The provider has the same models as the built-in `openai-codex`.
60
+
61
+ The Codex auth file stays the source of truth: on refresh the extension first adopts newer tokens found in the file (e.g. refreshed by the Codex CLI); otherwise it refreshes with the file's refresh token and writes the rotated tokens back. New `~/.codex-*` folders are discovered at load time, so run `/reload` after creating one.
62
+
63
+ ## How it works (Claude)
64
+
65
+ - Pi keeps a single `anthropic` entry in `~/.pi/agent/auth.json`. Switching replaces that entry and leaves other providers untouched.
66
+ - Saved logins live in `~/.pi/agent/claude-accounts.json` (mode `0600`).
67
+ - Writes happen under the same `auth.json.lock` directory lock that Pi's `proper-lockfile` uses, so Pi and the extension never write at the same time.
68
+ - Refresh tokens rotate. When Pi refreshes the active token, the extension copies the new one back into the right slot on `session_start`, after each `agent_end`, and during every switch.
69
+ - To tell whose token is whose, it asks `https://api.anthropic.com/api/oauth/profile` for the account id/email. If that lookup fails, it falls back gracefully (matching on the refresh token, or asking you when ownership is unclear).
70
+
71
+ ## Caveats
72
+
73
+ - `auth.json` is shared: switching affects **every running Pi session**.
74
+ - It relies on Pi internals (the `auth.json` format and its lock directory). Tested with Pi 0.99.2.
75
+ - The Anthropic profile endpoint is unofficial and may change.
76
+ - The OpenAI token endpoint and client id used for Codex refresh are unofficial and may change.
77
+ - The Codex CLI and Pi share each Codex refresh token (single use); if both refresh at once, one of them may need `codex login` and `/login` again.
78
+ - `claude-accounts.json` contains refresh tokens. Keep it private and never commit it.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ npm install
84
+ npm run check # typecheck + tests
85
+ ```
86
+
87
+ Layout:
88
+
89
+ ```text
90
+ src/index.ts entry point: registers Codex providers, then the /account command
91
+ src/claude.ts Claude account switching (no build step)
92
+ src/codex.ts Codex parallel providers
93
+ test/account.test.mjs node:test suite for /account (temp PI_CODING_AGENT_DIR and HOME, no network)
94
+ test/codex.test.mjs node:test suite for the Codex providers (stubbed fetch, no network)
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "pi-multi-accounts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Pi extension: switch between several Claude Pro/Max OAuth logins with /account, and use extra ChatGPT (Codex) logins as parallel openai-codex-<name> providers",
7
+ "keywords": ["pi-package", "pi", "claude", "anthropic", "openai", "codex", "chatgpt", "oauth", "multi-account"],
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/nvminhtue/pi-multi-accounts.git"
12
+ },
13
+ "pi": {
14
+ "extensions": ["./src/index.ts"]
15
+ },
16
+ "files": ["src", "README.md", "LICENSE"],
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "node --experimental-strip-types --test \"test/*.test.mjs\"",
20
+ "check": "npm run typecheck && npm test"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-ai": "*",
30
+ "@earendil-works/pi-coding-agent": "*"
31
+ },
32
+ "devDependencies": {
33
+ "@earendil-works/pi-ai": "0.99.2",
34
+ "@earendil-works/pi-coding-agent": "0.99.2",
35
+ "@types/node": "^22.0.0",
36
+ "typescript": "^5.6.0"
37
+ }
38
+ }
package/src/claude.ts ADDED
@@ -0,0 +1,467 @@
1
+ /**
2
+ * pi-multi-accounts, Claude part — keep several Anthropic (Claude Pro/Max) OAuth logins and
3
+ * switch between them.
4
+ *
5
+ * Flow:
6
+ * 1. /login → built-in Pi login (Anthropic). Pi writes auth.json.
7
+ * 2. /account save work → snapshot the current login under the name "work".
8
+ * 3. /logout, /login with the other Claude account, then /account save personal
9
+ * 4. /account work | /account personal | /account (picker) to switch.
10
+ *
11
+ * How it works: Pi keeps exactly one `anthropic` credential in auth.json and
12
+ * re-reads the file when its revision changes. This extension stores the other
13
+ * logins in ~/.pi/agent/claude-accounts.json (mode 0600) and swaps the
14
+ * `anthropic` entry in auth.json under the same `auth.json.lock` directory lock
15
+ * that Pi's proper-lockfile uses. All store writes also happen under that lock.
16
+ *
17
+ * Refresh tokens rotate: Pi refreshes the active token and writes the new one
18
+ * to auth.json. The extension syncs that rotated token back into the active
19
+ * slot (on session start, after each agent run, and inside every switch), so a
20
+ * stored slot never holds a dead refresh token.
21
+ *
22
+ * Note: auth.json is shared — switching affects every running Pi session.
23
+ */
24
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
25
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { codexRows, type CodexHome } from "./codex.ts";
29
+
30
+ const PROVIDER = "anthropic";
31
+ const AGENT_DIR = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
32
+ const AUTH_PATH = join(AGENT_DIR, "auth.json");
33
+ const STORE_PATH = join(AGENT_DIR, "claude-accounts.json");
34
+ const LOCK_PATH = `${AUTH_PATH}.lock`;
35
+ const LOCK_STALE_MS = 30_000;
36
+ const LOCK_TIMEOUT_MS = 20_000; // a Pi token refresh can hold the lock ~15 s
37
+ const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
38
+ const STATUS_KEY = "claude-account";
39
+ let codexHomes: CodexHome[] = [];
40
+ const SUBCOMMANDS = ["save", "add", "list", "ls", "remove", "rm", "use", "switch", "help"];
41
+
42
+ interface OAuthCred {
43
+ type: "oauth";
44
+ refresh: string;
45
+ access: string;
46
+ expires: number;
47
+ [key: string]: unknown;
48
+ }
49
+
50
+ interface Identity {
51
+ accountId?: string;
52
+ email?: string;
53
+ }
54
+
55
+ interface Slot extends Identity {
56
+ cred: OAuthCred;
57
+ savedAt: number;
58
+ }
59
+
60
+ interface Store {
61
+ active?: string;
62
+ accounts: Record<string, Slot>;
63
+ }
64
+
65
+ // ---------- file helpers ----------
66
+
67
+ /** Missing file → undefined. Corrupt file → throw (never silently wipe data). */
68
+ function readJsonStrict<T>(path: string): T | undefined {
69
+ if (!existsSync(path)) return undefined;
70
+ const text = readFileSync(path, "utf-8").replace(/^\uFEFF/, "");
71
+ if (!text.trim()) return undefined;
72
+ try {
73
+ return JSON.parse(text) as T;
74
+ } catch {
75
+ throw new Error(`${path} is not valid JSON; fix or remove it.`);
76
+ }
77
+ }
78
+
79
+ function writeAtomic(path: string, data: unknown): void {
80
+ mkdirSync(AGENT_DIR, { recursive: true, mode: 0o700 });
81
+ const tmp = `${path}.${process.pid}.tmp`;
82
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: 0o600 });
83
+ renameSync(tmp, path);
84
+ }
85
+
86
+ function loadStore(): Store {
87
+ const s = readJsonStrict<Store>(STORE_PATH) ?? { accounts: {} };
88
+ if (!s.accounts || typeof s.accounts !== "object") s.accounts = {};
89
+ return s;
90
+ }
91
+
92
+ function isOAuth(c: unknown): c is OAuthCred {
93
+ const o = c as OAuthCred | undefined;
94
+ return !!o && o.type === "oauth" && typeof o.refresh === "string" && typeof o.access === "string";
95
+ }
96
+
97
+ function readAuth(): Record<string, unknown> {
98
+ return readJsonStrict<Record<string, unknown>>(AUTH_PATH) ?? {};
99
+ }
100
+
101
+ function readCurrentCred(): OAuthCred | undefined {
102
+ const c = readAuth()[PROVIDER];
103
+ return isOAuth(c) ? c : undefined;
104
+ }
105
+
106
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
107
+
108
+ /**
109
+ * Same lock directory as proper-lockfile (`auth.json.lock`), so Pi and we
110
+ * serialize. `fn` must be synchronous: nothing can interleave while we hold it.
111
+ */
112
+ async function withAuthLock<T>(fn: () => T): Promise<T> {
113
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
114
+ for (;;) {
115
+ if (Date.now() > deadline) throw new Error("auth.json is locked by another process; try again.");
116
+ try {
117
+ mkdirSync(LOCK_PATH);
118
+ break;
119
+ } catch (err) {
120
+ if ((err as { code?: string }).code !== "EEXIST") throw err;
121
+ }
122
+ try {
123
+ if (Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_STALE_MS) {
124
+ rmdirSync(LOCK_PATH);
125
+ continue;
126
+ }
127
+ } catch (err) {
128
+ if ((err as { code?: string }).code === "ENOENT") continue; // lock vanished — retry now
129
+ throw err;
130
+ }
131
+ await sleep(50 + Math.random() * 100);
132
+ }
133
+ try {
134
+ return fn();
135
+ } finally {
136
+ try {
137
+ rmdirSync(LOCK_PATH);
138
+ } catch {
139
+ /* already gone */
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Locked read-modify-write of the store. */
145
+ function mutateStore<T>(fn: (store: Store) => T): Promise<T> {
146
+ return withAuthLock(() => {
147
+ const store = loadStore();
148
+ const result = fn(store);
149
+ writeAtomic(STORE_PATH, store);
150
+ return result;
151
+ });
152
+ }
153
+
154
+ // ---------- identity ----------
155
+
156
+ /** Best effort: ask Anthropic who owns this token. Undefined on any failure. */
157
+ async function fetchProfile(cred: OAuthCred): Promise<Identity | undefined> {
158
+ if (!cred.access || cred.expires <= Date.now() + 30_000) return undefined;
159
+ try {
160
+ const res = await fetch(PROFILE_URL, {
161
+ headers: {
162
+ Authorization: `Bearer ${cred.access}`,
163
+ "anthropic-beta": "oauth-2025-04-20",
164
+ "Content-Type": "application/json",
165
+ },
166
+ signal: AbortSignal.timeout(5_000),
167
+ });
168
+ if (!res.ok) return undefined;
169
+ const body = (await res.json()) as { account?: { uuid?: string; email_address?: string; email?: string } };
170
+ const accountId = body.account?.uuid;
171
+ const email = body.account?.email_address ?? body.account?.email;
172
+ return accountId || email ? { accountId, email } : undefined;
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ function findByToken(store: Store, cred: OAuthCred): string | undefined {
179
+ return Object.keys(store.accounts).find((n) => store.accounts[n].cred.refresh === cred.refresh);
180
+ }
181
+
182
+ function findByIdentity(store: Store, id?: Identity): string | undefined {
183
+ if (!id) return undefined;
184
+ return Object.keys(store.accounts).find((n) => {
185
+ const s = store.accounts[n];
186
+ return id.accountId ? s.accountId === id.accountId : !!id.email && s.email === id.email;
187
+ });
188
+ }
189
+
190
+ // ---------- sync ----------
191
+
192
+ type SyncResult =
193
+ | { kind: "none" } // no anthropic OAuth login in auth.json
194
+ | { kind: "matched"; name: string; cred: OAuthCred } // auth.json belongs to a stored slot
195
+ | {
196
+ kind: "unknown"; // no slot owns this login
197
+ cred: OAuthCred;
198
+ profile?: Identity; // undefined → identity could not be checked
199
+ probable?: string; // profile unknown: likely the active slot after a token rotation
200
+ };
201
+
202
+ /**
203
+ * Reconcile auth.json with the store. Copies a rotated token back into its
204
+ * slot when ownership is certain (same refresh token, or same account id).
205
+ */
206
+ async function sync(): Promise<SyncResult> {
207
+ const cred = readCurrentCred();
208
+ if (!cred) return { kind: "none" };
209
+ const store = loadStore();
210
+
211
+ let name = findByToken(store, cred);
212
+ let profile: Identity | undefined;
213
+ if (!name) {
214
+ profile = await fetchProfile(cred);
215
+ name = findByIdentity(store, profile);
216
+ }
217
+ if (!name) {
218
+ const probable = !profile && store.active && store.accounts[store.active] ? store.active : undefined;
219
+ return { kind: "unknown", cred, profile, probable };
220
+ }
221
+
222
+ const slot = store.accounts[name];
223
+ if (slot.cred.refresh !== cred.refresh || slot.cred.access !== cred.access || store.active !== name) {
224
+ const owner = name;
225
+ await mutateStore((s) => {
226
+ const now = readCurrentCred();
227
+ if (!s.accounts[owner] || now?.refresh !== cred.refresh) return; // changed meanwhile; next sync handles it
228
+ s.accounts[owner].cred = cred;
229
+ if (profile?.accountId) s.accounts[owner].accountId = profile.accountId;
230
+ if (profile?.email) s.accounts[owner].email = profile.email;
231
+ s.active = owner;
232
+ });
233
+ }
234
+ return { kind: "matched", name, cred };
235
+ }
236
+
237
+ function label(name: string, slot: Slot): string {
238
+ return slot.email ? `${name} <${slot.email}>` : name;
239
+ }
240
+
241
+ function updateStatus(ctx: ExtensionContext, result: SyncResult): void {
242
+ if (!ctx.hasUI) return;
243
+ const text =
244
+ result.kind === "matched" ? `claude: ${result.name}` : result.kind === "unknown" ? "claude: unsaved login" : undefined;
245
+ ctx.ui.setStatus(STATUS_KEY, text);
246
+ }
247
+
248
+ // ---------- commands ----------
249
+
250
+ async function cmdSave(name: string, ctx: ExtensionContext): Promise<void> {
251
+ if (!/^[\w.-]+$/.test(name) || SUBCOMMANDS.includes(name)) {
252
+ ctx.ui.notify(`Usage: /account save <name> (letters, digits, . _ -; not ${SUBCOMMANDS.join("/")})`, "warning");
253
+ return;
254
+ }
255
+ const cred = readCurrentCred();
256
+ if (!cred) {
257
+ ctx.ui.notify("No Anthropic OAuth login in auth.json. Run /login first.", "warning");
258
+ return;
259
+ }
260
+ const store = loadStore();
261
+ const owner = findByToken(store, cred);
262
+ if (owner && owner !== name) {
263
+ ctx.ui.notify(`This login is already saved as "${owner}".`, "warning");
264
+ return;
265
+ }
266
+ const profile = await fetchProfile(cred);
267
+ const dup = findByIdentity(store, profile);
268
+ if (dup && dup !== name) {
269
+ ctx.ui.notify(`This Claude account is already saved as "${dup}" — updated that slot instead.`, "info");
270
+ name = dup;
271
+ } else if (store.accounts[name] && owner !== name) {
272
+ const ok = await ctx.ui.confirm("Overwrite account?", `"${name}" already exists. Replace it with the current login?`);
273
+ if (!ok) return;
274
+ }
275
+ const target = name;
276
+ const slot = await mutateStore((s) => {
277
+ const now = readCurrentCred();
278
+ if (now?.refresh !== cred.refresh) throw new Error("auth.json changed while saving; run the command again.");
279
+ s.accounts[target] = { cred: now, accountId: profile?.accountId, email: profile?.email, savedAt: Date.now() };
280
+ s.active = target;
281
+ return s.accounts[target];
282
+ });
283
+ ctx.ui.notify(`Saved current Anthropic login as "${label(target, slot)}".`, "info");
284
+ updateStatus(ctx, { kind: "matched", name: target, cred });
285
+ }
286
+
287
+ async function cmdUse(name: string, ctx: ExtensionContext): Promise<void> {
288
+ if (!name) return cmdPick(ctx);
289
+ const known = loadStore().accounts[name];
290
+ if (!known) {
291
+ ctx.ui.notify(`Unknown account "${name}". Saved: ${Object.keys(loadStore().accounts).join(", ") || "(none)"}`, "error");
292
+ return;
293
+ }
294
+
295
+ const before = await sync();
296
+ if (before.kind === "matched" && before.name === name) {
297
+ ctx.ui.notify(`Already using "${label(name, known)}".`, "info");
298
+ updateStatus(ctx, before);
299
+ return;
300
+ }
301
+
302
+ // Who owns the token now in auth.json? It must be saved before we overwrite it.
303
+ let owner: string | undefined = before.kind === "matched" ? before.name : undefined;
304
+ if (before.kind === "unknown") {
305
+ const who = before.profile?.email ? ` (${before.profile.email})` : "";
306
+ if (before.probable) {
307
+ const keep = `Keep it as "${before.probable}" (a refreshed token)`;
308
+ const drop = "Discard it (it is a different, unsaved login)";
309
+ const choice = await ctx.ui.select(`The current login${who} could not be identified`, [keep, drop]);
310
+ if (!choice) return;
311
+ owner = choice === keep ? before.probable : undefined;
312
+ } else {
313
+ const ok = await ctx.ui.confirm(
314
+ "Unsaved login",
315
+ `The current Anthropic login${who} is not saved. Switching removes it from auth.json. ` +
316
+ "Continue? (Cancel, then /account save <name> to keep it.)",
317
+ );
318
+ if (!ok) return;
319
+ }
320
+ }
321
+
322
+ const seen = before.kind === "none" ? undefined : before.cred.refresh;
323
+ const slot = await mutateStore((s) => {
324
+ const target = s.accounts[name];
325
+ if (!target) throw new Error(`Account "${name}" was removed meanwhile.`);
326
+ const auth = readAuth();
327
+ const now = isOAuth(auth[PROVIDER]) ? auth[PROVIDER] : undefined;
328
+ if (now?.refresh !== seen && !owner) {
329
+ throw new Error("auth.json changed during the switch; run the command again.");
330
+ }
331
+ // Save the outgoing token (it may have rotated since sync) into its slot.
332
+ if (now && owner && s.accounts[owner]) s.accounts[owner].cred = now;
333
+ auth[PROVIDER] = target.cred;
334
+ writeAtomic(AUTH_PATH, auth);
335
+ s.active = name;
336
+ return target;
337
+ });
338
+ ctx.ui.notify(`Switched Anthropic account to "${label(name, slot)}".`, "info");
339
+ updateStatus(ctx, { kind: "matched", name, cred: slot.cred });
340
+ }
341
+
342
+ async function cmdRemove(name: string, ctx: ExtensionContext): Promise<void> {
343
+ if (!loadStore().accounts[name]) {
344
+ ctx.ui.notify(`Unknown account "${name}".`, "error");
345
+ return;
346
+ }
347
+ const ok = await ctx.ui.confirm("Remove account?", `Forget saved login "${name}"? (auth.json is not changed.)`);
348
+ if (!ok) return;
349
+ await mutateStore((s) => {
350
+ delete s.accounts[name];
351
+ if (s.active === name) s.active = undefined;
352
+ });
353
+ ctx.ui.notify(`Removed "${name}".`, "info");
354
+ updateStatus(ctx, await sync());
355
+ }
356
+
357
+ async function overview(ctx: ExtensionContext) {
358
+ const result = await sync();
359
+ updateStatus(ctx, result);
360
+ const store = loadStore();
361
+ const active = result.kind === "matched" ? result.name : undefined;
362
+ const names = Object.keys(store.accounts);
363
+ const rows = names.map((n) => `${n === active ? "●" : "○"} ${label(n, store.accounts[n])}`);
364
+ return { result, names, rows };
365
+ }
366
+
367
+ async function cmdList(ctx: ExtensionContext): Promise<void> {
368
+ const { result, rows } = await overview(ctx);
369
+ if (result.kind === "unknown") {
370
+ const who = result.profile?.email ? ` (${result.profile.email})` : "";
371
+ rows.push(`! unsaved login in auth.json${who} — /account save <name>`);
372
+ }
373
+ const claude = rows.length ? rows.join("\n") : "No saved accounts. Run /login, then /account save <name>.";
374
+ if (codexHomes.length === 0) return ctx.ui.notify(claude, "info");
375
+ const codex = [
376
+ "Codex providers (parallel; pick a model with /model; /account does not switch these):",
377
+ ...codexRows(codexHomes, readAuth()),
378
+ ];
379
+ ctx.ui.notify(["Claude accounts (switch with /account <name>):", claude, "", ...codex].join("\n"), "info");
380
+ }
381
+
382
+ async function cmdPick(ctx: ExtensionContext): Promise<void> {
383
+ if (!ctx.hasUI) return cmdList(ctx);
384
+ const { names, rows } = await overview(ctx);
385
+ if (!names.length) return cmdList(ctx);
386
+ const choice = await ctx.ui.select("Switch Claude account", rows);
387
+ if (!choice) return;
388
+ await cmdUse(names[rows.indexOf(choice)], ctx);
389
+ }
390
+
391
+ const HELP = [
392
+ "/account pick a Claude account to switch to",
393
+ "/account <name> switch to a saved Claude account",
394
+ "/account save <name> save the current Anthropic /login under <name>",
395
+ "/account list list saved Claude accounts and Codex providers",
396
+ "/account remove <name> forget a saved Claude account",
397
+ "Switch, save and remove work on Claude accounts only.",
398
+ "Codex: each ~/.codex-<name>/auth.json is the provider openai-codex-<name>;",
399
+ '/login "OpenAI Codex (<name>)" once, then pick its model with /model.',
400
+ ].join("\n");
401
+
402
+ export function registerClaude(pi: ExtensionAPI, homes: CodexHome[]): void {
403
+ codexHomes = homes;
404
+ pi.registerCommand("account", {
405
+ description: "Switch saved Claude accounts; list also shows Codex providers — /account help",
406
+ getArgumentCompletions: (prefix) => {
407
+ let names: string[] = [];
408
+ try {
409
+ names = Object.keys(loadStore().accounts);
410
+ } catch {
411
+ /* corrupt store: no name completions */
412
+ }
413
+ const [first] = prefix.split(/\s+/);
414
+ const withName = ["remove", "rm", "use", "switch"];
415
+ const pool =
416
+ prefix.includes(" ") && withName.includes(first)
417
+ ? names.map((n) => `${first} ${n}`)
418
+ : [...names, "save", "list", "remove", "use", "help"];
419
+ const items = pool.filter((v) => v.startsWith(prefix)).map((v) => ({ value: v, label: v }));
420
+ return items.length ? items : null;
421
+ },
422
+ handler: async (args, ctx) => {
423
+ const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
424
+ const arg = rest.join(" ");
425
+ try {
426
+ switch (sub) {
427
+ case undefined:
428
+ return await cmdPick(ctx);
429
+ case "save":
430
+ case "add":
431
+ return await cmdSave(arg, ctx);
432
+ case "list":
433
+ case "ls":
434
+ return await cmdList(ctx);
435
+ case "remove":
436
+ case "rm":
437
+ return await cmdRemove(arg, ctx);
438
+ case "use":
439
+ case "switch":
440
+ return await cmdUse(arg, ctx);
441
+ case "help":
442
+ return ctx.ui.notify(HELP, "info");
443
+ default:
444
+ return await cmdUse(sub, ctx);
445
+ }
446
+ } catch (err) {
447
+ ctx.ui.notify(`account: ${(err as Error).message}`, "error");
448
+ }
449
+ },
450
+ });
451
+
452
+ const quietSync = async (ctx: ExtensionContext, announce: boolean) => {
453
+ try {
454
+ const result = await sync();
455
+ updateStatus(ctx, result);
456
+ if (announce && result.kind === "unknown" && !result.probable && ctx.hasUI) {
457
+ const who = result.profile?.email ? ` (${result.profile.email})` : "";
458
+ ctx.ui.notify(`Unsaved Anthropic login${who}. Run /account save <name> to keep it.`, "info");
459
+ }
460
+ } catch {
461
+ /* never break the session over account bookkeeping */
462
+ }
463
+ };
464
+
465
+ pi.on("session_start", (_e, ctx) => quietSync(ctx, true));
466
+ pi.on("agent_end", (_e, ctx) => quietSync(ctx, false));
467
+ }
package/src/codex.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * pi-multi-accounts, Codex part — use extra ChatGPT (Codex) logins in parallel
3
+ * with the built-in `openai-codex` provider.
4
+ *
5
+ * Every ~/.codex-<name>/auth.json (Codex CLI format) becomes a separate Pi
6
+ * provider `openai-codex-<name>` with the same models as `openai-codex`, e.g.
7
+ * ~/.codex-huy → `openai-codex-huy/gpt-5.5`. Streaming reuses Pi's built-in
8
+ * `openai-codex-responses` implementation; the chatgpt-account-id header is
9
+ * derived from the access token, so each provider talks as its own account.
10
+ *
11
+ * Setup: /login → "OpenAI Codex (<name>)" imports the tokens from the file
12
+ * (no browser). Then pick the model with /model.
13
+ *
14
+ * The Codex auth file stays the source of truth: on refresh the extension
15
+ * first adopts newer tokens from the file (e.g. refreshed by
16
+ * `CODEX_HOME=~/.codex-<name> codex`), otherwise it refreshes with the file's
17
+ * refresh token and writes the rotated tokens back, so Pi and the Codex CLI
18
+ * keep sharing one valid login.
19
+ */
20
+ import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
21
+ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
22
+ import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
27
+ const TOKEN_URL = "https://auth.openai.com/oauth/token";
28
+ const BASE_URL = "https://chatgpt.com/backend-api";
29
+ const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
30
+ const EXPIRY_SKEW_MS = 5 * 60_000;
31
+ const HOME_DIR_PATTERN = /^\.codex-([a-z0-9][a-z0-9_-]*)$/i;
32
+
33
+ interface CodexAuthFile {
34
+ auth_mode?: string;
35
+ tokens?: {
36
+ id_token?: string;
37
+ access_token?: string;
38
+ refresh_token?: string;
39
+ account_id?: string;
40
+ };
41
+ last_refresh?: string;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ export interface CodexCred {
46
+ access: string;
47
+ refresh: string;
48
+ expires: number;
49
+ accountId: string;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ export interface CodexHome {
54
+ name: string;
55
+ dir: string;
56
+ authPath: string;
57
+ }
58
+
59
+ function jwtClaims(token: string): Record<string, any> {
60
+ const payload = token.split(".")[1];
61
+ if (!payload) throw new Error("access token is not a JWT");
62
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
63
+ }
64
+
65
+ function readAuthFile(path: string): CodexAuthFile {
66
+ // Tolerate non-breaking-space indentation left by copy/paste.
67
+ try {
68
+ return JSON.parse(readFileSync(path, "utf8").replace(/\u00a0/g, " "));
69
+ } catch {
70
+ // Never surface the parser message: it can quote token characters.
71
+ throw new Error(`${path} is missing or not valid JSON`);
72
+ }
73
+ }
74
+
75
+ export function credFromTokens(access: string, refresh: string): CodexCred {
76
+ const claims = jwtClaims(access);
77
+ const accountId = claims[JWT_AUTH_CLAIM]?.chatgpt_account_id;
78
+ if (typeof accountId !== "string" || !accountId) throw new Error("access token has no chatgpt_account_id");
79
+ if (typeof claims.exp !== "number") throw new Error("access token has no exp claim");
80
+ return { access, refresh, expires: claims.exp * 1000, accountId };
81
+ }
82
+
83
+ export function credFromFile(path: string): CodexCred {
84
+ const tokens = readAuthFile(path).tokens;
85
+ if (!tokens?.access_token || !tokens.refresh_token) {
86
+ throw new Error(`${path} has no ChatGPT tokens; run \`CODEX_HOME=${join(path, "..")} codex login\` first`);
87
+ }
88
+ return credFromTokens(tokens.access_token, tokens.refresh_token);
89
+ }
90
+
91
+ export function writeBack(path: string, fresh: { access: string; refresh: string; idToken?: string; accountId: string }): void {
92
+ const file = readAuthFile(path);
93
+ file.tokens = {
94
+ ...file.tokens,
95
+ access_token: fresh.access,
96
+ refresh_token: fresh.refresh,
97
+ ...(fresh.idToken ? { id_token: fresh.idToken } : {}),
98
+ account_id: fresh.accountId,
99
+ };
100
+ file.last_refresh = new Date().toISOString();
101
+ const tmp = `${path}.${process.pid}.tmp`;
102
+ writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
103
+ renameSync(tmp, path);
104
+ }
105
+
106
+ export function isFresh(cred: CodexCred, now = Date.now()): boolean {
107
+ return cred.expires - now > EXPIRY_SKEW_MS;
108
+ }
109
+
110
+ /** True when the auth file holds newer, still-valid tokens than `currentAccess`. */
111
+ export function shouldAdopt(fromFile: CodexCred, currentAccess: string, now = Date.now()): boolean {
112
+ return fromFile.access !== currentAccess && isFresh(fromFile, now);
113
+ }
114
+
115
+ export async function refreshCred(home: CodexHome, refreshToken: string, signal: AbortSignal): Promise<CodexCred> {
116
+ const response = await fetch(TOKEN_URL, {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
119
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
120
+ signal,
121
+ });
122
+ if (!response.ok) {
123
+ // The Codex CLI may have rotated the single-use refresh token meanwhile.
124
+ const latest = credFromFile(home.authPath);
125
+ if (latest.refresh !== refreshToken && isFresh(latest)) return latest;
126
+ const body = (await response.json().catch(() => ({}))) as { error?: unknown };
127
+ const code = typeof body.error === "string" ? ` (${body.error})` : "";
128
+ throw new Error(
129
+ `OpenAI Codex (${home.name}) token refresh failed: HTTP ${response.status}${code}. ` +
130
+ `Run \`CODEX_HOME=${home.dir} codex login\`, then /login again.`,
131
+ );
132
+ }
133
+ const json = (await response.json()) as { access_token?: string; refresh_token?: string; id_token?: string };
134
+ if (!json.access_token) throw new Error(`OpenAI Codex (${home.name}) token refresh returned no access token`);
135
+ const cred = credFromTokens(json.access_token, json.refresh_token ?? refreshToken);
136
+ try {
137
+ writeBack(home.authPath, { ...cred, idToken: json.id_token });
138
+ } catch (error) {
139
+ // Pi still persists the rotated token; only the Codex CLI copy is stale.
140
+ console.warn(`pi-multi-accounts: could not update ${home.authPath}: ${(error as Error).message}`);
141
+ }
142
+ return cred;
143
+ }
144
+
145
+ export function discoverHomes(root = homedir()): CodexHome[] {
146
+ return readdirSync(root, { withFileTypes: true })
147
+ .filter((entry) => entry.isDirectory() && HOME_DIR_PATTERN.test(entry.name))
148
+ .map((entry) => ({
149
+ name: (HOME_DIR_PATTERN.exec(entry.name)?.[1] ?? entry.name).toLowerCase(),
150
+ dir: join(root, entry.name),
151
+ authPath: join(root, entry.name, "auth.json"),
152
+ }))
153
+ .filter((home) => existsSync(home.authPath));
154
+ }
155
+
156
+ function codexModels(): ProviderModelConfig[] {
157
+ return getBuiltinModels("openai-codex").map(({ provider: _provider, ...model }) => ({ ...model }) as ProviderModelConfig);
158
+ }
159
+
160
+ export function registerCodex(pi: ExtensionAPI, homes = discoverHomes()): CodexHome[] {
161
+ const models = codexModels();
162
+ for (const home of homes) {
163
+ pi.registerProvider(`openai-codex-${home.name}`, {
164
+ name: `OpenAI Codex (${home.name})`,
165
+ baseUrl: BASE_URL,
166
+ api: "openai-codex-responses",
167
+ models,
168
+ oauth: {
169
+ name: `OpenAI Codex (${home.name})`,
170
+ isSubscription: true,
171
+ async login(callbacks) {
172
+ const cred = credFromFile(home.authPath);
173
+ callbacks.onProgress?.(`Imported ChatGPT login from ${home.authPath}`);
174
+ return cred;
175
+ },
176
+ async refreshToken(credentials, signal) {
177
+ // Adopt tokens the Codex CLI refreshed meanwhile; otherwise refresh ourselves.
178
+ const fromFile = credFromFile(home.authPath);
179
+ if (shouldAdopt(fromFile, credentials.access)) return fromFile;
180
+ return refreshCred(home, fromFile.refresh, signal);
181
+ },
182
+ getApiKey: (credentials) => credentials.access,
183
+ },
184
+ });
185
+ }
186
+ return homes;
187
+ }
188
+
189
+ /** Status lines for `/account list`: one per Codex home, by login state in Pi's auth.json. */
190
+ export function codexRows(homes: CodexHome[], auth: Record<string, unknown>): string[] {
191
+ return homes.map((home) => {
192
+ const id = `openai-codex-${home.name}`;
193
+ const entry = auth[id] as { type?: unknown } | undefined;
194
+ const state = entry?.type === "oauth" ? "logged in" : `not logged in (/login → "OpenAI Codex (${home.name})")`;
195
+ return ` ${home.name} ${id} ${home.authPath} ${state}`;
196
+ });
197
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** pi-multi-accounts — several Claude logins (/account) + extra Codex logins as parallel providers. */
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { registerClaude } from "./claude.ts";
4
+ import { registerCodex } from "./codex.ts";
5
+
6
+ export default function (pi: ExtensionAPI) {
7
+ const codexHomes = registerCodex(pi);
8
+ registerClaude(pi, codexHomes);
9
+ }