pi-multi-accounts 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +36 -4
- package/src/accounts.ts +203 -0
- package/src/claude.ts +530 -0
- package/src/codex.ts +319 -0
- package/src/index.ts +11 -0
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,113 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-multi-accounts
|
|
2
2
|
|
|
3
|
-
|
|
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`. Use `/account` to select a provider for this session, or pick its 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 login` | Ask which account type, then run the matching login below |
|
|
31
|
+
| `/account login claude <name>`| Log in to a Claude Pro/Max account in the browser and save it as `<name>` |
|
|
32
|
+
| `/account login codex <name>` | Import `~/.codex-<name>` (a Codex CLI login) as provider `openai-codex-<name>` |
|
|
33
|
+
| `/account` | Pick a Claude account or a registered Codex provider |
|
|
34
|
+
| `/account <name/id>` | Use an exact saved Claude name or registered Codex provider ID |
|
|
35
|
+
| `/account save <name>` | Save the current built-in Anthropic `/login` under `<name>` |
|
|
36
|
+
| `/account list` | List Claude accounts (● active, ○ inactive) and Codex providers (login state) |
|
|
37
|
+
| `/account remove <name>` | Forget a saved Claude account (`auth.json` is not changed) |
|
|
38
|
+
| `/account help` | Show command help |
|
|
39
|
+
|
|
40
|
+
Use `/account login` instead of Pi's built-in `/login` for this extension's accounts. It explains every step in the session, saves the Claude login under a name, and registers new Codex homes without `/reload`. The built-in `/login` still works; adopt its Claude login with `/account save <name>`.
|
|
41
|
+
|
|
42
|
+
Save and remove work on **Claude accounts only**. Their arguments are literal Claude names, not provider qualifiers. `login` is a reserved name: a Claude account saved as `login` by an older version stays reachable with `/account use login` or `/account claude:login`.
|
|
43
|
+
|
|
44
|
+
Claude selection changes shared credentials. It does not change the session model. Codex selection changes only this session's model. It does not change Claude credentials or the default model for new sessions.
|
|
45
|
+
|
|
46
|
+
Use an unqualified name or ID when it matches exactly one provider. If a saved Claude name equals a registered Codex ID, use `/account claude:<name>` or `/account codex:<provider-id>`. Existing Claude names such as `openai-codex-team` remain valid. The picker identifies each provider and does not need qualifiers.
|
|
47
|
+
|
|
48
|
+
`/account use` and `/account switch` without a target open the same picker. Prefix completions include the aliases, saved names, registered Codex IDs, and qualified targets. Completions for `remove` and `rm` include only literal Claude names.
|
|
49
|
+
|
|
50
|
+
Aliases: `add` = `save`, `ls` = `list`, `rm` = `remove`, `use`/`switch` = switch.
|
|
51
|
+
|
|
52
|
+
The footer shows `claude: <name>` for the active saved account, or `claude: unsaved login` when the login in `auth.json` is not saved yet.
|
|
53
|
+
|
|
54
|
+
## Logging in to Claude accounts
|
|
55
|
+
|
|
56
|
+
1. `/account login claude work`. Pi prints a claude.ai login URL and waits; it does not open the browser itself. Sign in, and the login is saved as `work`. If the browser cannot reach `localhost`, paste the final redirect URL or code into the prompt.
|
|
57
|
+
2. `/account login claude personal` for the second account. claude.ai reuses the browser's signed-in session, so the URL in a normal window gives you `work` again. Instead, either:
|
|
58
|
+
- open the URL in a **private/incognito window**, or
|
|
59
|
+
- use a **different browser profile**, or
|
|
60
|
+
- **sign out of claude.ai** first.
|
|
61
|
+
|
|
62
|
+
If the browser still returns an account that is already saved, the command reports it and saves nothing. This check uses the Anthropic profile lookup; when that lookup fails, compare the emails with `/account list`.
|
|
63
|
+
3. A login is activated at once when `auth.json` has no Claude login or holds the same account. Otherwise you are asked whether to switch, because a switch affects every running Pi session.
|
|
64
|
+
4. Run `/account list` and check that the emails differ.
|
|
65
|
+
|
|
66
|
+
## Codex accounts
|
|
67
|
+
|
|
68
|
+
1. For each extra ChatGPT account, log in with the Codex CLI into its own folder: `CODEX_HOME=~/.codex-<name> codex login`. For a second account, finish that login in a private window, or sign out of chatgpt.com first.
|
|
69
|
+
2. In Pi, run `/account login codex <name>`. This imports the tokens from `~/.codex-<name>/auth.json`; no browser is involved. A folder created after Pi started is registered on the spot. Without `<name>`, the command lists the found homes. If the folder has no login yet, it prints the exact `codex login` command. (The built-in `/login` → **"OpenAI Codex (<name>)"** does the same import for already registered homes.)
|
|
70
|
+
3. Select the provider with `/account openai-codex-<name>`, or use the unified `/account` picker. `/account login codex` also offers to switch right away. The command keeps the current model ID if the target provider supports it. Otherwise, it asks you to choose a target model. Cancel leaves the session unchanged. Without dialog support, choose explicitly with `/model`, e.g. `openai-codex-<name>/<model>`.
|
|
71
|
+
|
|
72
|
+
The provider has the same models as the built-in `openai-codex`. The command reports success only after Pi accepts the model change. `/account list` shows each provider's auth path, login state, and current-session active marker.
|
|
73
|
+
|
|
74
|
+
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. `~/.codex-*` folders are discovered at load time; later ones are added by `/account login codex <name>` or `/reload`.
|
|
75
|
+
|
|
76
|
+
## How it works (Claude)
|
|
77
|
+
|
|
78
|
+
- Pi keeps a single `anthropic` entry in `~/.pi/agent/auth.json`. Switching replaces that entry and leaves other providers untouched.
|
|
79
|
+
- Saved logins live in `~/.pi/agent/claude-accounts.json` (mode `0600`).
|
|
80
|
+
- 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.
|
|
81
|
+
- 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.
|
|
82
|
+
- 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).
|
|
83
|
+
|
|
84
|
+
## Caveats
|
|
85
|
+
|
|
86
|
+
- `auth.json` is shared: a Claude switch affects **every running Pi session**. A Codex switch changes only the current session model.
|
|
87
|
+
- It relies on Pi internals (the `auth.json` format and its lock directory). Tested with Pi 0.99.2.
|
|
88
|
+
- The Anthropic profile endpoint is unofficial and may change.
|
|
89
|
+
- The OpenAI token endpoint and client id used for Codex refresh are unofficial and may change.
|
|
90
|
+
- 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.
|
|
91
|
+
- `claude-accounts.json` contains refresh tokens. Keep it private and never commit it.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm install
|
|
97
|
+
npm run check # typecheck + tests
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Layout:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
src/index.ts entry point: shares one registered Codex-home snapshot
|
|
104
|
+
src/accounts.ts /account command, typed entries, exact resolution and completions
|
|
105
|
+
src/claude.ts Claude credentials, status, validation and lifecycle sync
|
|
106
|
+
src/codex.ts Codex parallel providers and session-model selection
|
|
107
|
+
test/account.test.mjs node:test suite for /account (temp PI_CODING_AGENT_DIR and HOME, no network)
|
|
108
|
+
test/codex.test.mjs node:test suite for the Codex providers (stubbed fetch, no network)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-multi-accounts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
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.23.4 || ^24.22.1 || >=26.12.1"
|
|
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/accounts.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ClaudeProvider, SUBCOMMANDS } from "./claude.ts";
|
|
3
|
+
import { createCodexProvider } from "./codex.ts";
|
|
4
|
+
import type { CodexHome } from "./codex.ts";
|
|
5
|
+
|
|
6
|
+
export interface ClaudeEntry {
|
|
7
|
+
provider: "claude";
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
isActive: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CodexEntry {
|
|
15
|
+
provider: "codex";
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
authPath: string;
|
|
19
|
+
isLoggedIn: boolean;
|
|
20
|
+
isActive: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ClaudeStatus {
|
|
24
|
+
entries: ClaudeEntry[];
|
|
25
|
+
unsavedLogin?: { email?: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type AccountEntry = ClaudeEntry | CodexEntry;
|
|
29
|
+
|
|
30
|
+
function formatClaudeName(entry: ClaudeEntry): string {
|
|
31
|
+
return entry.email ? `${entry.name} <${entry.email}>` : entry.name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatPickerLabel(entry: AccountEntry): string {
|
|
35
|
+
const marker = entry.isActive ? "●" : "○";
|
|
36
|
+
if (entry.provider === "claude") {
|
|
37
|
+
return `[Claude] ${marker} ${formatClaudeName(entry)} (claude:${entry.id})`;
|
|
38
|
+
}
|
|
39
|
+
return `[Codex] ${marker} ${entry.name} (codex:${entry.id})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatList(claude: ClaudeStatus, codex: CodexEntry[]): string {
|
|
43
|
+
const lines = ["Claude accounts (credentials shared across Pi sessions):"];
|
|
44
|
+
for (const entry of claude.entries) {
|
|
45
|
+
const marker = entry.isActive ? "●" : "○";
|
|
46
|
+
lines.push(`[Claude] ${marker} ${formatClaudeName(entry)}`);
|
|
47
|
+
}
|
|
48
|
+
if (!claude.entries.length) lines.push("No saved accounts. Run /account login claude <name>.");
|
|
49
|
+
if (claude.unsavedLogin) {
|
|
50
|
+
const email = claude.unsavedLogin.email ? ` (${claude.unsavedLogin.email})` : "";
|
|
51
|
+
lines.push(`! unsaved login in auth.json${email} — /account save <name>`);
|
|
52
|
+
}
|
|
53
|
+
lines.push("", "Codex providers (model selection for this session only):");
|
|
54
|
+
for (const entry of codex) {
|
|
55
|
+
const state = entry.isLoggedIn ? "logged in" : `not logged in (/account login codex ${entry.name})`;
|
|
56
|
+
const marker = entry.isActive ? "●" : "○";
|
|
57
|
+
lines.push(` ${marker} ${entry.name} ${entry.id} ${entry.authPath} ${state}`);
|
|
58
|
+
}
|
|
59
|
+
if (!codex.length) lines.push("No registered Codex homes. Run /account login codex <name> to add one.");
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getTargetSuggestions(names: string[], ids: string[]): string[] {
|
|
64
|
+
const collisions = names.filter((name) => ids.includes(name));
|
|
65
|
+
return [
|
|
66
|
+
...names.filter((name) => !collisions.includes(name)),
|
|
67
|
+
...ids.filter((id) => !collisions.includes(id)),
|
|
68
|
+
...names.map((name) => `claude:${name}`),
|
|
69
|
+
...ids.map((id) => `codex:${id}`),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getCompletions(prefix: string, ids: string[]) {
|
|
74
|
+
let names: string[] = [];
|
|
75
|
+
try {
|
|
76
|
+
names = ClaudeProvider.getSavedNames();
|
|
77
|
+
} catch {
|
|
78
|
+
/* Keep static and registered Codex completions available for a corrupt store. */
|
|
79
|
+
}
|
|
80
|
+
const targets = getTargetSuggestions(names, ids);
|
|
81
|
+
const candidates = [
|
|
82
|
+
...SUBCOMMANDS,
|
|
83
|
+
...targets,
|
|
84
|
+
...targets.flatMap((target) => [`use ${target}`, `switch ${target}`]),
|
|
85
|
+
...names.flatMap((name) => [`remove ${name}`, `rm ${name}`]),
|
|
86
|
+
"login claude",
|
|
87
|
+
"login codex",
|
|
88
|
+
...ids.map((id) => `login codex ${id.replace(/^openai-codex-/, "")}`),
|
|
89
|
+
];
|
|
90
|
+
const matches = candidates.filter((candidate) => candidate.startsWith(prefix));
|
|
91
|
+
return matches.length ? matches.map((value) => ({ value, label: value })) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function registerAccounts(pi: ExtensionAPI, homes: readonly CodexHome[]): void {
|
|
95
|
+
const codex = createCodexProvider(pi, homes);
|
|
96
|
+
|
|
97
|
+
async function cmdList(ctx: ExtensionContext): Promise<void> {
|
|
98
|
+
const claude = await ClaudeProvider.getStatus(ctx);
|
|
99
|
+
ctx.ui.notify(formatList(claude, codex.getEntries(ctx)), "info");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function useEntry(entry: AccountEntry, ctx: ExtensionContext): Promise<void> {
|
|
103
|
+
if (entry.provider === "claude") await ClaudeProvider.useAccount(entry.id, ctx);
|
|
104
|
+
else await codex.useAccount(entry.id, ctx);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function cmdPick(ctx: ExtensionContext): Promise<void> {
|
|
108
|
+
if (!ctx.hasUI) return cmdList(ctx);
|
|
109
|
+
const claude = await ClaudeProvider.getStatus(ctx);
|
|
110
|
+
const entries: AccountEntry[] = [...claude.entries, ...codex.getEntries(ctx)];
|
|
111
|
+
if (!entries.length) return cmdList(ctx);
|
|
112
|
+
const labels = entries.map(formatPickerLabel);
|
|
113
|
+
const choice = await ctx.ui.select("Switch account or provider", labels);
|
|
114
|
+
const entry = entries.find((_entry, index) => labels[index] === choice);
|
|
115
|
+
if (entry) await useEntry(entry, ctx);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function cmdUse(target: string, ctx: ExtensionContext): Promise<void> {
|
|
119
|
+
if (!target) return cmdPick(ctx);
|
|
120
|
+
const separator = target.indexOf(":");
|
|
121
|
+
const qualifier = separator < 0 ? undefined : target.slice(0, separator);
|
|
122
|
+
const id = separator < 0 ? target : target.slice(separator + 1);
|
|
123
|
+
if (qualifier !== undefined && qualifier !== "claude" && qualifier !== "codex") {
|
|
124
|
+
throw new Error(`Unknown account qualifier "${qualifier}". Use claude: or codex:.`);
|
|
125
|
+
}
|
|
126
|
+
const isCodex = qualifier !== "claude" && codex.getIds().includes(id);
|
|
127
|
+
const isClaude = qualifier !== "codex" && ClaudeProvider.getSavedNames().includes(id);
|
|
128
|
+
if (isClaude && isCodex) {
|
|
129
|
+
throw new Error(`Ambiguous account "${id}". Use claude:${id} or codex:${id}.`);
|
|
130
|
+
}
|
|
131
|
+
if (isClaude) await ClaudeProvider.useAccount(id, ctx);
|
|
132
|
+
else if (isCodex) await codex.useAccount(id, ctx);
|
|
133
|
+
else throw new Error(`Unknown account "${target}". Run /account list; new Codex homes require /reload.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function cmdLogin(arg: string, ctx: ExtensionContext): Promise<void> {
|
|
137
|
+
const [first = "", ...rest] = arg.split(/\s+/).filter(Boolean);
|
|
138
|
+
let provider = first === "claude" || first === "codex" ? first : undefined;
|
|
139
|
+
const name = provider ? rest.join(" ") : arg;
|
|
140
|
+
if (!provider) {
|
|
141
|
+
if (!ctx.hasUI) {
|
|
142
|
+
ctx.ui.notify("Usage: /account login claude <name> | /account login codex <name>", "warning");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const claude = "Claude Pro/Max — browser login, saved as a switchable account";
|
|
146
|
+
const codex = "Codex (ChatGPT) — import a Codex CLI login as provider openai-codex-<name>";
|
|
147
|
+
const choice = await ctx.ui.select("Log in to which account type?", [claude, codex]);
|
|
148
|
+
if (!choice) return;
|
|
149
|
+
provider = choice === claude ? "claude" : "codex";
|
|
150
|
+
}
|
|
151
|
+
if (provider === "claude") await ClaudeProvider.login(name, ctx);
|
|
152
|
+
else await codex.login(name, ctx);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function handleCommand(args: string, ctx: ExtensionContext): Promise<void> {
|
|
156
|
+
const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
157
|
+
const arg = rest.join(" ");
|
|
158
|
+
switch (sub) {
|
|
159
|
+
case undefined: return cmdPick(ctx);
|
|
160
|
+
case "login": return cmdLogin(arg, ctx);
|
|
161
|
+
case "save":
|
|
162
|
+
case "add": return ClaudeProvider.saveAccount(arg, ctx);
|
|
163
|
+
case "remove":
|
|
164
|
+
case "rm": return ClaudeProvider.removeAccount(arg, ctx);
|
|
165
|
+
case "use":
|
|
166
|
+
case "switch": return cmdUse(arg, ctx);
|
|
167
|
+
case "list":
|
|
168
|
+
case "ls": return cmdList(ctx);
|
|
169
|
+
case "help": return ctx.ui.notify(HELP, "info");
|
|
170
|
+
default: return cmdUse(args.trim(), ctx);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
pi.registerCommand("account", {
|
|
175
|
+
description: "Unified account and provider management — /account help",
|
|
176
|
+
getArgumentCompletions: (prefix) => getCompletions(prefix, codex.getIds()),
|
|
177
|
+
handler: async (args, ctx) => {
|
|
178
|
+
try {
|
|
179
|
+
await handleCommand(args, ctx);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
ctx.ui.notify(`account: ${(err as Error).message}`, "error");
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const HELP = [
|
|
188
|
+
"/account login claude <name> log in to a Claude Pro/Max account in the browser and save it as <name>",
|
|
189
|
+
"/account login codex <name> import ~/.codex-<name> (made by `CODEX_HOME=~/.codex-<name> codex login`)",
|
|
190
|
+
"/account pick a Claude account or Codex provider",
|
|
191
|
+
"/account <name/id> use an exact saved Claude name or registered Codex ID",
|
|
192
|
+
"/account use|switch pick or use a target",
|
|
193
|
+
"/account claude:<name> choose Claude when a name collides with a Codex ID",
|
|
194
|
+
"/account codex:<id> choose Codex when an ID collides with a Claude name",
|
|
195
|
+
"/account save|add <name> save the current built-in /login as a Claude account (literal Claude name)",
|
|
196
|
+
"/account remove|rm <name> forget saved Claude login (literal Claude name)",
|
|
197
|
+
"/account list|ls list accounts, login states and auth paths",
|
|
198
|
+
"Another account of the same service? Open the login URL in a private window, or the browser reuses the signed-in account.",
|
|
199
|
+
"Claude changes shared credentials, not the session model. Save and remove are Claude-only.",
|
|
200
|
+
"Codex changes only this session's model, not Claude credentials or the default model.",
|
|
201
|
+
"Codex IDs: openai-codex-<name>.",
|
|
202
|
+
"Codex keeps the current model ID when supported; otherwise choose a model (or use /model without UI).",
|
|
203
|
+
].join("\n");
|
package/src/claude.ts
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
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. /account login claude work → browser login, saved as "work".
|
|
7
|
+
* 2. /account login claude personal → open the printed URL in a private window
|
|
8
|
+
* so claude.ai does not reuse "work".
|
|
9
|
+
* 3. /account work | /account personal | /account (picker) to switch.
|
|
10
|
+
* An existing built-in /login can still be adopted with /account save <name>.
|
|
11
|
+
*
|
|
12
|
+
* How it works: Pi keeps exactly one `anthropic` credential in auth.json and
|
|
13
|
+
* re-reads the file when its revision changes. This extension stores the other
|
|
14
|
+
* logins in ~/.pi/agent/claude-accounts.json (mode 0600) and swaps the
|
|
15
|
+
* `anthropic` entry in auth.json under the same `auth.json.lock` directory lock
|
|
16
|
+
* that Pi's proper-lockfile uses. All store writes also happen under that lock.
|
|
17
|
+
*
|
|
18
|
+
* Refresh tokens rotate: Pi refreshes the active token and writes the new one
|
|
19
|
+
* to auth.json. The extension syncs that rotated token back into the active
|
|
20
|
+
* slot (on session start, after each agent run, and inside every switch), so a
|
|
21
|
+
* stored slot never holds a dead refresh token.
|
|
22
|
+
*
|
|
23
|
+
* Note: auth.json is shared — switching affects every running Pi session.
|
|
24
|
+
*/
|
|
25
|
+
import type { AuthEvent, AuthPrompt, OAuthAuth, ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
|
26
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import type { ClaudeEntry, ClaudeStatus } from "./accounts.ts";
|
|
31
|
+
|
|
32
|
+
const PROVIDER = "anthropic";
|
|
33
|
+
const AGENT_DIR = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
34
|
+
const AUTH_PATH = join(AGENT_DIR, "auth.json");
|
|
35
|
+
const STORE_PATH = join(AGENT_DIR, "claude-accounts.json");
|
|
36
|
+
const LOCK_PATH = `${AUTH_PATH}.lock`;
|
|
37
|
+
const LOCK_STALE_MS = 30_000;
|
|
38
|
+
const LOCK_TIMEOUT_MS = 20_000; // a Pi token refresh can hold the lock ~15 s
|
|
39
|
+
const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
40
|
+
const STATUS_KEY = "claude-account";
|
|
41
|
+
export const SUBCOMMANDS = ["login", "save", "add", "list", "ls", "remove", "rm", "use", "switch", "help"];
|
|
42
|
+
const LOGIN_HINT =
|
|
43
|
+
"Adding another Claude account? Open the URL in a private/incognito window or another browser profile " +
|
|
44
|
+
"(or sign out of claude.ai first); otherwise claude.ai logs in the account that is already signed in.";
|
|
45
|
+
|
|
46
|
+
interface OAuthCred {
|
|
47
|
+
type: "oauth";
|
|
48
|
+
refresh: string;
|
|
49
|
+
access: string;
|
|
50
|
+
expires: number;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Identity {
|
|
55
|
+
accountId?: string;
|
|
56
|
+
email?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Slot extends Identity {
|
|
60
|
+
cred: OAuthCred;
|
|
61
|
+
savedAt: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Store {
|
|
65
|
+
active?: string;
|
|
66
|
+
accounts: Record<string, Slot>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------- file helpers ----------
|
|
70
|
+
|
|
71
|
+
/** Missing file → undefined. Corrupt file → throw (never silently wipe data). */
|
|
72
|
+
function readJsonStrict<T>(path: string): T | undefined {
|
|
73
|
+
if (!existsSync(path)) return undefined;
|
|
74
|
+
const text = readFileSync(path, "utf-8").replace(/^\uFEFF/, "");
|
|
75
|
+
if (!text.trim()) return undefined;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(text) as T;
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error(`${path} is not valid JSON; fix or remove it.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeAtomic(path: string, data: unknown): void {
|
|
84
|
+
mkdirSync(AGENT_DIR, { recursive: true, mode: 0o700 });
|
|
85
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
86
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
87
|
+
renameSync(tmp, path);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadStore(): Store {
|
|
91
|
+
const s = readJsonStrict<Store>(STORE_PATH) ?? { accounts: {} };
|
|
92
|
+
if (!s.accounts || typeof s.accounts !== "object") s.accounts = {};
|
|
93
|
+
return s;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isOAuth(c: unknown): c is OAuthCred {
|
|
97
|
+
const o = c as OAuthCred | undefined;
|
|
98
|
+
return !!o && o.type === "oauth" && typeof o.refresh === "string" && typeof o.access === "string";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function readAuth(): Record<string, unknown> {
|
|
102
|
+
return readJsonStrict<Record<string, unknown>>(AUTH_PATH) ?? {};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Locked read-modify-write of auth.json; other providers' entries are kept. */
|
|
106
|
+
export function mutateAuth(fn: (auth: Record<string, unknown>) => void): Promise<void> {
|
|
107
|
+
return withAuthLock(() => {
|
|
108
|
+
const auth = readAuth();
|
|
109
|
+
fn(auth);
|
|
110
|
+
writeAtomic(AUTH_PATH, auth);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readCurrentCred(): OAuthCred | undefined {
|
|
115
|
+
const c = readAuth()[PROVIDER];
|
|
116
|
+
return isOAuth(c) ? c : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Same lock directory as proper-lockfile (`auth.json.lock`), so Pi and we
|
|
123
|
+
* serialize. `fn` must be synchronous: nothing can interleave while we hold it.
|
|
124
|
+
*/
|
|
125
|
+
async function withAuthLock<T>(fn: () => T): Promise<T> {
|
|
126
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
127
|
+
for (;;) {
|
|
128
|
+
if (Date.now() > deadline) throw new Error("auth.json is locked by another process; try again.");
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(LOCK_PATH);
|
|
131
|
+
break;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if ((err as { code?: string }).code !== "EEXIST") throw err;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
if (Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_STALE_MS) {
|
|
137
|
+
rmdirSync(LOCK_PATH);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if ((err as { code?: string }).code === "ENOENT") continue; // lock vanished — retry now
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
await sleep(50 + Math.random() * 100);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
return fn();
|
|
148
|
+
} finally {
|
|
149
|
+
try {
|
|
150
|
+
rmdirSync(LOCK_PATH);
|
|
151
|
+
} catch {
|
|
152
|
+
/* already gone */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Locked read-modify-write of the store. */
|
|
158
|
+
function mutateStore<T>(fn: (store: Store) => T): Promise<T> {
|
|
159
|
+
return withAuthLock(() => {
|
|
160
|
+
const store = loadStore();
|
|
161
|
+
const result = fn(store);
|
|
162
|
+
writeAtomic(STORE_PATH, store);
|
|
163
|
+
return result;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------- identity ----------
|
|
168
|
+
|
|
169
|
+
/** Best effort: ask Anthropic who owns this token. Undefined on any failure. */
|
|
170
|
+
async function fetchProfile(cred: OAuthCred): Promise<Identity | undefined> {
|
|
171
|
+
if (!cred.access || cred.expires <= Date.now() + 30_000) return undefined;
|
|
172
|
+
try {
|
|
173
|
+
const res = await fetch(PROFILE_URL, {
|
|
174
|
+
headers: {
|
|
175
|
+
Authorization: `Bearer ${cred.access}`,
|
|
176
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
177
|
+
"Content-Type": "application/json",
|
|
178
|
+
},
|
|
179
|
+
signal: AbortSignal.timeout(5_000),
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok) return undefined;
|
|
182
|
+
const body = (await res.json()) as { account?: { uuid?: string; email_address?: string; email?: string } };
|
|
183
|
+
const accountId = body.account?.uuid;
|
|
184
|
+
const email = body.account?.email_address ?? body.account?.email;
|
|
185
|
+
return accountId || email ? { accountId, email } : undefined;
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function findByToken(store: Store, cred: OAuthCred): string | undefined {
|
|
192
|
+
return Object.keys(store.accounts).find((n) => store.accounts[n].cred.refresh === cred.refresh);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function findByIdentity(store: Store, id?: Identity): string | undefined {
|
|
196
|
+
if (!id) return undefined;
|
|
197
|
+
return Object.keys(store.accounts).find((n) => {
|
|
198
|
+
const s = store.accounts[n];
|
|
199
|
+
return id.accountId ? s.accountId === id.accountId : !!id.email && s.email === id.email;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- sync ----------
|
|
204
|
+
|
|
205
|
+
type SyncResult =
|
|
206
|
+
| { kind: "none" } // no anthropic OAuth login in auth.json
|
|
207
|
+
| { kind: "matched"; name: string; cred: OAuthCred } // auth.json belongs to a stored slot
|
|
208
|
+
| {
|
|
209
|
+
kind: "unknown"; // no slot owns this login
|
|
210
|
+
cred: OAuthCred;
|
|
211
|
+
profile?: Identity; // undefined → identity could not be checked
|
|
212
|
+
probable?: string; // profile unknown: likely the active slot after a token rotation
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Reconcile auth.json with the store. Copies a rotated token back into its
|
|
217
|
+
* slot when ownership is certain (same refresh token, or same account id).
|
|
218
|
+
*/
|
|
219
|
+
async function sync(): Promise<SyncResult> {
|
|
220
|
+
const cred = readCurrentCred();
|
|
221
|
+
if (!cred) return { kind: "none" };
|
|
222
|
+
const store = loadStore();
|
|
223
|
+
|
|
224
|
+
let name = findByToken(store, cred);
|
|
225
|
+
let profile: Identity | undefined;
|
|
226
|
+
if (!name) {
|
|
227
|
+
profile = await fetchProfile(cred);
|
|
228
|
+
name = findByIdentity(store, profile);
|
|
229
|
+
}
|
|
230
|
+
if (!name) {
|
|
231
|
+
const probable = !profile && store.active && store.accounts[store.active] ? store.active : undefined;
|
|
232
|
+
return { kind: "unknown", cred, profile, probable };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const slot = store.accounts[name];
|
|
236
|
+
if (slot.cred.refresh !== cred.refresh || slot.cred.access !== cred.access || store.active !== name) {
|
|
237
|
+
const owner = name;
|
|
238
|
+
await mutateStore((s) => {
|
|
239
|
+
const now = readCurrentCred();
|
|
240
|
+
if (!s.accounts[owner] || now?.refresh !== cred.refresh) return; // changed meanwhile; next sync handles it
|
|
241
|
+
s.accounts[owner].cred = cred;
|
|
242
|
+
if (profile?.accountId) s.accounts[owner].accountId = profile.accountId;
|
|
243
|
+
if (profile?.email) s.accounts[owner].email = profile.email;
|
|
244
|
+
s.active = owner;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return { kind: "matched", name, cred };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function label(name: string, slot: Slot): string {
|
|
251
|
+
return slot.email ? `${name} <${slot.email}>` : name;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function updateStatus(ctx: ExtensionContext, result: SyncResult): void {
|
|
255
|
+
if (!ctx.hasUI) return;
|
|
256
|
+
const text =
|
|
257
|
+
result.kind === "matched" ? `claude: ${result.name}` : result.kind === "unknown" ? "claude: unsaved login" : undefined;
|
|
258
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------- Claude adapter ----------
|
|
262
|
+
|
|
263
|
+
export const ClaudeProvider = {
|
|
264
|
+
getStatus: overview,
|
|
265
|
+
getSavedNames: () => Object.keys(loadStore().accounts),
|
|
266
|
+
useAccount: cmdUse,
|
|
267
|
+
saveAccount: cmdSave,
|
|
268
|
+
removeAccount: cmdRemove,
|
|
269
|
+
login: cmdLogin,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
function isValidName(name: string): boolean {
|
|
273
|
+
return /^[\w.-]+$/.test(name) && !SUBCOMMANDS.includes(name);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const NAME_RULES = `letters, digits, . _ -; not ${SUBCOMMANDS.join("/")}`;
|
|
277
|
+
|
|
278
|
+
async function cmdSave(name: string, ctx: ExtensionContext): Promise<void> {
|
|
279
|
+
if (!isValidName(name)) {
|
|
280
|
+
ctx.ui.notify(`Usage: /account save <name> (${NAME_RULES})`, "warning");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const cred = readCurrentCred();
|
|
284
|
+
if (!cred) {
|
|
285
|
+
ctx.ui.notify("No Anthropic OAuth login in auth.json. Run /account login claude <name> to log in and save it.", "warning");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const store = loadStore();
|
|
289
|
+
const owner = findByToken(store, cred);
|
|
290
|
+
if (owner && owner !== name) {
|
|
291
|
+
ctx.ui.notify(`This login is already saved as "${owner}".`, "warning");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const profile = await fetchProfile(cred);
|
|
295
|
+
const dup = findByIdentity(store, profile);
|
|
296
|
+
if (dup && dup !== name) {
|
|
297
|
+
ctx.ui.notify(`This Claude account is already saved as "${dup}" — updated that slot instead.`, "info");
|
|
298
|
+
name = dup;
|
|
299
|
+
} else if (store.accounts[name] && owner !== name) {
|
|
300
|
+
const ok = await ctx.ui.confirm("Overwrite account?", `"${name}" already exists. Replace it with the current login?`);
|
|
301
|
+
if (!ok) return;
|
|
302
|
+
}
|
|
303
|
+
const target = name;
|
|
304
|
+
const slot = await mutateStore((s) => {
|
|
305
|
+
const now = readCurrentCred();
|
|
306
|
+
if (now?.refresh !== cred.refresh) throw new Error("auth.json changed while saving; run the command again.");
|
|
307
|
+
s.accounts[target] = { cred: now, accountId: profile?.accountId, email: profile?.email, savedAt: Date.now() };
|
|
308
|
+
s.active = target;
|
|
309
|
+
return s.accounts[target];
|
|
310
|
+
});
|
|
311
|
+
ctx.ui.notify(`Saved current Anthropic login as "${label(target, slot)}".`, "info");
|
|
312
|
+
updateStatus(ctx, { kind: "matched", name: target, cred });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function cmdUse(name: string, ctx: ExtensionContext): Promise<void> {
|
|
316
|
+
const known = loadStore().accounts[name];
|
|
317
|
+
if (!known) {
|
|
318
|
+
ctx.ui.notify(`Unknown account "${name}". Saved: ${Object.keys(loadStore().accounts).join(", ") || "(none)"}`, "error");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const before = await sync();
|
|
323
|
+
if (before.kind === "matched" && before.name === name) {
|
|
324
|
+
ctx.ui.notify(`Already using "${label(name, known)}".`, "info");
|
|
325
|
+
updateStatus(ctx, before);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Who owns the token now in auth.json? It must be saved before we overwrite it.
|
|
330
|
+
let owner: string | undefined = before.kind === "matched" ? before.name : undefined;
|
|
331
|
+
if (before.kind === "unknown") {
|
|
332
|
+
const who = before.profile?.email ? ` (${before.profile.email})` : "";
|
|
333
|
+
if (before.probable) {
|
|
334
|
+
const keep = `Keep it as "${before.probable}" (a refreshed token)`;
|
|
335
|
+
const drop = "Discard it (it is a different, unsaved login)";
|
|
336
|
+
const choice = await ctx.ui.select(`The current login${who} could not be identified`, [keep, drop]);
|
|
337
|
+
if (!choice) return;
|
|
338
|
+
owner = choice === keep ? before.probable : undefined;
|
|
339
|
+
} else {
|
|
340
|
+
const ok = await ctx.ui.confirm(
|
|
341
|
+
"Unsaved login",
|
|
342
|
+
`The current Anthropic login${who} is not saved. Switching removes it from auth.json. ` +
|
|
343
|
+
"Continue? (Cancel, then /account save <name> to keep it.)",
|
|
344
|
+
);
|
|
345
|
+
if (!ok) return;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const seen = before.kind === "none" ? undefined : before.cred.refresh;
|
|
350
|
+
const slot = await mutateStore((s) => {
|
|
351
|
+
const target = s.accounts[name];
|
|
352
|
+
if (!target) throw new Error(`Account "${name}" was removed meanwhile.`);
|
|
353
|
+
const auth = readAuth();
|
|
354
|
+
const now = isOAuth(auth[PROVIDER]) ? auth[PROVIDER] : undefined;
|
|
355
|
+
if (now?.refresh !== seen && !owner) {
|
|
356
|
+
throw new Error("auth.json changed during the switch; run the command again.");
|
|
357
|
+
}
|
|
358
|
+
// Save the outgoing token (it may have rotated since sync) into its slot.
|
|
359
|
+
if (now && owner && s.accounts[owner]) s.accounts[owner].cred = now;
|
|
360
|
+
auth[PROVIDER] = target.cred;
|
|
361
|
+
writeAtomic(AUTH_PATH, auth);
|
|
362
|
+
s.active = name;
|
|
363
|
+
return target;
|
|
364
|
+
});
|
|
365
|
+
ctx.ui.notify(`Switched Anthropic account to "${label(name, slot)}".`, "info");
|
|
366
|
+
updateStatus(ctx, { kind: "matched", name, cred: slot.cred });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Bridges Pi's provider login prompts to extension dialogs. The login URL is
|
|
371
|
+
* shown, never opened: a second account needs a private browser window.
|
|
372
|
+
*/
|
|
373
|
+
function createInteraction(ctx: ExtensionContext, controller: AbortController): ProviderAuthInteraction {
|
|
374
|
+
const dismissed = (signal: AbortSignal): never => {
|
|
375
|
+
// The flow itself withdrew the prompt (e.g. the browser callback won the race).
|
|
376
|
+
if (signal.aborted) throw new Error("Login prompt dismissed.");
|
|
377
|
+
controller.abort();
|
|
378
|
+
throw new Error("Login cancelled.");
|
|
379
|
+
};
|
|
380
|
+
return {
|
|
381
|
+
signal: controller.signal,
|
|
382
|
+
notify(event: AuthEvent) {
|
|
383
|
+
if (event.type === "auth_url") {
|
|
384
|
+
ctx.ui.notify(["Open this URL to log in to Claude:", event.url, "", LOGIN_HINT].join("\n"), "info");
|
|
385
|
+
} else if (event.type === "device_code") {
|
|
386
|
+
ctx.ui.notify(`Open ${event.verificationUri} and enter the code ${event.userCode}.`, "info");
|
|
387
|
+
} else {
|
|
388
|
+
ctx.ui.notify(event.message, "info");
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
async prompt(prompt: AuthPrompt) {
|
|
392
|
+
const signal = prompt.signal ? AbortSignal.any([prompt.signal, controller.signal]) : controller.signal;
|
|
393
|
+
if (prompt.type === "select") {
|
|
394
|
+
const labels = prompt.options.map((option) => option.label);
|
|
395
|
+
const choice = await ctx.ui.select(prompt.message, labels, { signal });
|
|
396
|
+
const option = prompt.options.find((_option, index) => labels[index] === choice);
|
|
397
|
+
return option ? option.id : dismissed(signal);
|
|
398
|
+
}
|
|
399
|
+
const value = await ctx.ui.input(prompt.message, prompt.placeholder, { signal });
|
|
400
|
+
return value ?? dismissed(signal);
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Runs Pi's Anthropic OAuth flow. Undefined when the user cancels. */
|
|
406
|
+
async function runOAuthLogin(oauth: OAuthAuth, ctx: ExtensionContext): Promise<OAuthCred | undefined> {
|
|
407
|
+
const controller = new AbortController();
|
|
408
|
+
try {
|
|
409
|
+
return await oauth.login(createInteraction(ctx, controller));
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (controller.signal.aborted) return undefined;
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Log in to Claude in the browser and save the result as `name`, no built-in /login needed. */
|
|
417
|
+
async function cmdLogin(name: string, ctx: ExtensionContext): Promise<void> {
|
|
418
|
+
if (!ctx.hasUI) {
|
|
419
|
+
ctx.ui.notify("/account login claude needs the interactive UI (browser login with prompts).", "error");
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (!name) {
|
|
423
|
+
name = (await ctx.ui.input("Name for this Claude account", "e.g. work"))?.trim() ?? "";
|
|
424
|
+
if (!name) return;
|
|
425
|
+
}
|
|
426
|
+
if (!isValidName(name)) {
|
|
427
|
+
ctx.ui.notify(`Usage: /account login claude <name> (${NAME_RULES})`, "warning");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const oauth = ctx.modelRegistry.getProvider(PROVIDER)?.auth.oauth;
|
|
431
|
+
if (!oauth) throw new Error("This Pi build has no Anthropic (Claude Pro/Max) OAuth login.");
|
|
432
|
+
const existing = loadStore().accounts[name];
|
|
433
|
+
if (existing) {
|
|
434
|
+
const ok = await ctx.ui.confirm("Replace account?", `"${label(name, existing)}" already exists. Log in again and replace it?`);
|
|
435
|
+
if (!ok) return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const cred = await runOAuthLogin(oauth, ctx);
|
|
439
|
+
if (!cred) {
|
|
440
|
+
ctx.ui.notify("Login cancelled. Nothing changed.", "info");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const profile = await fetchProfile(cred);
|
|
444
|
+
const dup = findByIdentity(loadStore(), profile);
|
|
445
|
+
if (dup && dup !== name) {
|
|
446
|
+
const who = profile?.email ? ` (${profile.email})` : "";
|
|
447
|
+
ctx.ui.notify(
|
|
448
|
+
`This is the same Claude account as "${dup}"${who}: the browser reused its claude.ai session. Nothing was saved. ` +
|
|
449
|
+
`Run /account login claude ${name} again and open the URL in a private window, or sign out of claude.ai first.`,
|
|
450
|
+
"warning",
|
|
451
|
+
);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Activate at once when nothing in auth.json would be lost: no login, or this same account.
|
|
456
|
+
const before = await sync();
|
|
457
|
+
const replacesCurrent =
|
|
458
|
+
before.kind === "none" ||
|
|
459
|
+
(before.kind === "matched" && before.name === name) ||
|
|
460
|
+
(before.kind === "unknown" && !!profile?.accountId && before.profile?.accountId === profile.accountId);
|
|
461
|
+
const seen = before.kind === "none" ? undefined : before.cred.refresh;
|
|
462
|
+
const slot: Slot = { cred, accountId: profile?.accountId, email: profile?.email, savedAt: Date.now() };
|
|
463
|
+
const activated = await mutateStore((s) => {
|
|
464
|
+
s.accounts[name] = slot;
|
|
465
|
+
if (!replacesCurrent || readCurrentCred()?.refresh !== seen) return false;
|
|
466
|
+
const auth = readAuth();
|
|
467
|
+
auth[PROVIDER] = cred;
|
|
468
|
+
writeAtomic(AUTH_PATH, auth);
|
|
469
|
+
s.active = name;
|
|
470
|
+
return true;
|
|
471
|
+
});
|
|
472
|
+
if (activated) {
|
|
473
|
+
ctx.ui.notify(`Logged in and switched Claude to "${label(name, slot)}".`, "info");
|
|
474
|
+
updateStatus(ctx, { kind: "matched", name, cred });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
ctx.ui.notify(`Logged in and saved Claude account "${label(name, slot)}".`, "info");
|
|
478
|
+
const ok = await ctx.ui.confirm("Switch account?", `Switch Claude to "${name}" now? This affects every running Pi session.`);
|
|
479
|
+
if (ok) await cmdUse(name, ctx);
|
|
480
|
+
else ctx.ui.notify(`Run /account ${name} to switch later.`, "info");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function cmdRemove(name: string, ctx: ExtensionContext): Promise<void> {
|
|
484
|
+
if (!loadStore().accounts[name]) {
|
|
485
|
+
ctx.ui.notify(`Unknown account "${name}".`, "error");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const ok = await ctx.ui.confirm("Remove account?", `Forget saved login "${name}"? (auth.json is not changed.)`);
|
|
489
|
+
if (!ok) return;
|
|
490
|
+
await mutateStore((s) => {
|
|
491
|
+
delete s.accounts[name];
|
|
492
|
+
if (s.active === name) s.active = undefined;
|
|
493
|
+
});
|
|
494
|
+
ctx.ui.notify(`Removed "${name}".`, "info");
|
|
495
|
+
updateStatus(ctx, await sync());
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function overview(ctx: ExtensionContext): Promise<ClaudeStatus> {
|
|
499
|
+
const result = await sync();
|
|
500
|
+
updateStatus(ctx, result);
|
|
501
|
+
const store = loadStore();
|
|
502
|
+
const active = result.kind === "matched" ? result.name : undefined;
|
|
503
|
+
const entries: ClaudeEntry[] = Object.keys(store.accounts).map((name) => ({
|
|
504
|
+
provider: "claude",
|
|
505
|
+
id: name,
|
|
506
|
+
name,
|
|
507
|
+
email: store.accounts[name].email,
|
|
508
|
+
isActive: name === active,
|
|
509
|
+
}));
|
|
510
|
+
const unsavedLogin = result.kind === "unknown" ? { email: result.profile?.email } : undefined;
|
|
511
|
+
return { entries, unsavedLogin };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function registerClaude(pi: ExtensionAPI): void {
|
|
515
|
+
const quietSync = async (ctx: ExtensionContext, announce: boolean) => {
|
|
516
|
+
try {
|
|
517
|
+
const result = await sync();
|
|
518
|
+
updateStatus(ctx, result);
|
|
519
|
+
if (announce && result.kind === "unknown" && !result.probable && ctx.hasUI) {
|
|
520
|
+
const who = result.profile?.email ? ` (${result.profile.email})` : "";
|
|
521
|
+
ctx.ui.notify(`Unsaved Anthropic login${who}. Run /account save <name> to keep it.`, "info");
|
|
522
|
+
}
|
|
523
|
+
} catch {
|
|
524
|
+
/* never break the session over account bookkeeping */
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
pi.on("session_start", (_e, ctx) => quietSync(ctx, true));
|
|
529
|
+
pi.on("agent_end", (_e, ctx) => quietSync(ctx, false));
|
|
530
|
+
}
|
package/src/codex.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
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: `CODEX_HOME=~/.codex-<name> codex login`, then `/account login codex
|
|
12
|
+
* <name>` imports the tokens from the file (no browser) and registers the
|
|
13
|
+
* provider when the home is new. Then pick it with /account or /model.
|
|
14
|
+
*
|
|
15
|
+
* The Codex auth file stays the source of truth: on refresh the extension
|
|
16
|
+
* first adopts newer tokens from the file (e.g. refreshed by
|
|
17
|
+
* `CODEX_HOME=~/.codex-<name> codex`), otherwise it refreshes with the file's
|
|
18
|
+
* refresh token and writes the rotated tokens back, so Pi and the Codex CLI
|
|
19
|
+
* keep sharing one valid login.
|
|
20
|
+
*/
|
|
21
|
+
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
|
|
22
|
+
import type { ExtensionAPI, ProviderModelConfig, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { mutateAuth, readAuth } from "./claude.ts";
|
|
27
|
+
import type { CodexEntry } from "./accounts.ts";
|
|
28
|
+
|
|
29
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
30
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
31
|
+
const BASE_URL = "https://chatgpt.com/backend-api";
|
|
32
|
+
const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
33
|
+
const EXPIRY_SKEW_MS = 5 * 60_000;
|
|
34
|
+
const HOME_DIR_PATTERN = /^\.codex-([a-z0-9][a-z0-9_-]*)$/i;
|
|
35
|
+
|
|
36
|
+
interface CodexAuthFile {
|
|
37
|
+
auth_mode?: string;
|
|
38
|
+
tokens?: {
|
|
39
|
+
id_token?: string;
|
|
40
|
+
access_token?: string;
|
|
41
|
+
refresh_token?: string;
|
|
42
|
+
account_id?: string;
|
|
43
|
+
};
|
|
44
|
+
last_refresh?: string;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CodexCred {
|
|
49
|
+
access: string;
|
|
50
|
+
refresh: string;
|
|
51
|
+
expires: number;
|
|
52
|
+
accountId: string;
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CodexHome {
|
|
57
|
+
name: string;
|
|
58
|
+
dir: string;
|
|
59
|
+
authPath: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function jwtClaims(token: string): Record<string, unknown> {
|
|
63
|
+
const payload = token.split(".")[1];
|
|
64
|
+
if (!payload) throw new Error("access token is not a JWT");
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error("Failed to parse JWT payload");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readAuthFile(path: string): CodexAuthFile {
|
|
73
|
+
// Tolerate non-breaking-space indentation left by copy/paste.
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(readFileSync(path, "utf8").replace(/\u00a0/g, " "));
|
|
76
|
+
} catch {
|
|
77
|
+
// Never surface the parser message: it can quote token characters.
|
|
78
|
+
throw new Error(`${path} is missing or not valid JSON`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function credFromTokens(access: string, refresh: string): CodexCred {
|
|
83
|
+
const claims = jwtClaims(access) as Record<string, unknown>;
|
|
84
|
+
const authClaim = claims[JWT_AUTH_CLAIM];
|
|
85
|
+
const accountId = typeof authClaim === "object" && authClaim !== null
|
|
86
|
+
? (authClaim as Record<string, unknown>).chatgpt_account_id
|
|
87
|
+
: undefined;
|
|
88
|
+
if (typeof accountId !== "string" || !accountId) throw new Error("access token has no chatgpt_account_id");
|
|
89
|
+
if (typeof claims.exp !== "number") throw new Error("access token has no exp claim");
|
|
90
|
+
return { access, refresh, expires: claims.exp * 1000, accountId };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function credFromFile(path: string): CodexCred {
|
|
94
|
+
const tokens = readAuthFile(path).tokens;
|
|
95
|
+
if (!tokens?.access_token || !tokens.refresh_token) {
|
|
96
|
+
throw new Error(`${path} has no ChatGPT tokens; run \`CODEX_HOME=${join(path, "..")} codex login\` first`);
|
|
97
|
+
}
|
|
98
|
+
return credFromTokens(tokens.access_token, tokens.refresh_token);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function writeBack(path: string, fresh: { access: string; refresh: string; idToken?: string; accountId: string }): void {
|
|
102
|
+
const file = readAuthFile(path);
|
|
103
|
+
file.tokens = {
|
|
104
|
+
...file.tokens,
|
|
105
|
+
access_token: fresh.access,
|
|
106
|
+
refresh_token: fresh.refresh,
|
|
107
|
+
...(fresh.idToken ? { id_token: fresh.idToken } : {}),
|
|
108
|
+
account_id: fresh.accountId,
|
|
109
|
+
};
|
|
110
|
+
file.last_refresh = new Date().toISOString();
|
|
111
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
112
|
+
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
113
|
+
renameSync(tmp, path);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isFresh(cred: CodexCred, now = Date.now()): boolean {
|
|
117
|
+
return cred.expires - now > EXPIRY_SKEW_MS;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when the auth file holds newer, still-valid tokens than `currentAccess`. */
|
|
121
|
+
export function shouldAdopt(fromFile: CodexCred, currentAccess: string, now = Date.now()): boolean {
|
|
122
|
+
return fromFile.access !== currentAccess && isFresh(fromFile, now);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function refreshCred(home: CodexHome, refreshToken: string, signal: AbortSignal): Promise<CodexCred> {
|
|
126
|
+
const response = await fetch(TOKEN_URL, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
129
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
|
|
130
|
+
signal,
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
// The Codex CLI may have rotated the single-use refresh token meanwhile.
|
|
134
|
+
const latest = credFromFile(home.authPath);
|
|
135
|
+
if (latest.refresh !== refreshToken && isFresh(latest)) return latest;
|
|
136
|
+
const body = (await response.json().catch(() => ({}))) as { error?: unknown };
|
|
137
|
+
const code = typeof body.error === "string" ? ` (${body.error})` : "";
|
|
138
|
+
throw new Error(
|
|
139
|
+
`OpenAI Codex (${home.name}) token refresh failed: HTTP ${response.status}${code}. ` +
|
|
140
|
+
`Run \`CODEX_HOME=${home.dir} codex login\`, then /account login codex ${home.name}.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const json = (await response.json()) as { access_token?: string; refresh_token?: string; id_token?: string };
|
|
144
|
+
if (!json.access_token) throw new Error(`OpenAI Codex (${home.name}) token refresh returned no access token`);
|
|
145
|
+
const cred = credFromTokens(json.access_token, json.refresh_token ?? refreshToken);
|
|
146
|
+
try {
|
|
147
|
+
writeBack(home.authPath, { ...cred, idToken: json.id_token });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// Pi still persists the rotated token; only the Codex CLI copy is stale.
|
|
150
|
+
console.warn(`pi-multi-accounts: could not update ${home.authPath}: ${(error as Error).message}`);
|
|
151
|
+
}
|
|
152
|
+
return cred;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function discoverHomes(root = homedir()): CodexHome[] {
|
|
156
|
+
return readdirSync(root, { withFileTypes: true })
|
|
157
|
+
.filter((entry) => entry.isDirectory() && HOME_DIR_PATTERN.test(entry.name))
|
|
158
|
+
.map((entry) => ({
|
|
159
|
+
name: (HOME_DIR_PATTERN.exec(entry.name)?.[1] ?? entry.name).toLowerCase(),
|
|
160
|
+
dir: join(root, entry.name),
|
|
161
|
+
authPath: join(root, entry.name, "auth.json"),
|
|
162
|
+
}))
|
|
163
|
+
.filter((home) => existsSync(home.authPath));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function codexModels(): ProviderModelConfig[] {
|
|
167
|
+
return getBuiltinModels("openai-codex").map(({ provider: _provider, ...model }) => ({ ...model }) as ProviderModelConfig);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function registerHome(pi: ExtensionAPI, home: CodexHome, models: ProviderModelConfig[]): void {
|
|
171
|
+
pi.registerProvider(`openai-codex-${home.name}`, {
|
|
172
|
+
name: `OpenAI Codex (${home.name})`,
|
|
173
|
+
baseUrl: BASE_URL,
|
|
174
|
+
api: "openai-codex-responses",
|
|
175
|
+
models,
|
|
176
|
+
oauth: {
|
|
177
|
+
name: `OpenAI Codex (${home.name})`,
|
|
178
|
+
isSubscription: true,
|
|
179
|
+
async login(callbacks) {
|
|
180
|
+
const cred = credFromFile(home.authPath);
|
|
181
|
+
callbacks.onProgress?.(`Imported ChatGPT login from ${home.authPath}`);
|
|
182
|
+
return cred;
|
|
183
|
+
},
|
|
184
|
+
async refreshToken(credentials, signal) {
|
|
185
|
+
// Adopt tokens the Codex CLI refreshed meanwhile; otherwise refresh ourselves.
|
|
186
|
+
const fromFile = credFromFile(home.authPath);
|
|
187
|
+
if (shouldAdopt(fromFile, credentials.access)) return fromFile;
|
|
188
|
+
return refreshCred(home, fromFile.refresh, signal);
|
|
189
|
+
},
|
|
190
|
+
getApiKey: (credentials) => credentials.access,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function registerCodex(pi: ExtensionAPI, homes = discoverHomes()): CodexHome[] {
|
|
196
|
+
const models = codexModels();
|
|
197
|
+
for (const home of homes) registerHome(pi, home, models);
|
|
198
|
+
return homes;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** One adapter per registration snapshot; /account login codex registers homes created later. */
|
|
202
|
+
export function createCodexProvider(pi: ExtensionAPI, homes: readonly CodexHome[]) {
|
|
203
|
+
const registeredHomes = [...homes];
|
|
204
|
+
const getProviderId = (home: CodexHome) => `openai-codex-${home.name}`;
|
|
205
|
+
const getIds = () => registeredHomes.map(getProviderId);
|
|
206
|
+
|
|
207
|
+
function getEntries(ctx: ExtensionContext): CodexEntry[] {
|
|
208
|
+
const auth = readAuth();
|
|
209
|
+
return registeredHomes.map((home) => {
|
|
210
|
+
const id = getProviderId(home);
|
|
211
|
+
const credential = auth[id] as { type?: unknown } | undefined;
|
|
212
|
+
return {
|
|
213
|
+
provider: "codex",
|
|
214
|
+
id,
|
|
215
|
+
name: home.name,
|
|
216
|
+
authPath: home.authPath,
|
|
217
|
+
isLoggedIn: credential?.type === "oauth",
|
|
218
|
+
isActive: ctx.model?.provider === id,
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function notifyLogin(id: string, ctx: ExtensionContext): void {
|
|
224
|
+
const home = registeredHomes.find((home) => getProviderId(home) === id);
|
|
225
|
+
ctx.ui.notify(`No configured auth for ${id}. Run /account login codex ${home?.name} first.`, "error");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function chooseModel(id: string, ctx: ExtensionContext) {
|
|
229
|
+
const models = ctx.modelRegistry.getAll().filter((model) => model.provider === id);
|
|
230
|
+
if (!models.length) {
|
|
231
|
+
ctx.ui.notify(`No registered models for ${id}. Run /reload.`, "error");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const available = models.filter((model) => ctx.modelRegistry.hasConfiguredAuth(model));
|
|
235
|
+
if (!available.length) return notifyLogin(id, ctx);
|
|
236
|
+
if (!ctx.hasUI) {
|
|
237
|
+
ctx.ui.notify(`Choose a model for ${id} explicitly with /model.`, "warning");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const labels = available.map((model) => `${model.name} (${model.id})`);
|
|
241
|
+
const choice = await ctx.ui.select(`Choose a model for ${id}`, labels);
|
|
242
|
+
return available.find((_model, index) => labels[index] === choice);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function useAccount(id: string, ctx: ExtensionContext): Promise<void> {
|
|
246
|
+
if (!getIds().includes(id)) {
|
|
247
|
+
ctx.ui.notify(`Unknown Codex provider "${id}". Run /account login codex <name> to add a home.`, "error");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const sameModel = ctx.model ? ctx.modelRegistry.find(id, ctx.model.id) : undefined;
|
|
251
|
+
const model = sameModel ?? await chooseModel(id, ctx);
|
|
252
|
+
if (!model) return;
|
|
253
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) return notifyLogin(id, ctx);
|
|
254
|
+
const hasChanged = await pi.setModel(model);
|
|
255
|
+
if (!hasChanged) return notifyLogin(id, ctx);
|
|
256
|
+
ctx.ui.notify(`Switched this session to ${id}/${model.id}.`, "info");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Existing ~/.codex-<name> by name, else the path `codex login` should create. */
|
|
260
|
+
function findHome(name: string): CodexHome {
|
|
261
|
+
const known = [...registeredHomes, ...discoverHomes()].find((home) => home.name === name);
|
|
262
|
+
const dir = join(homedir(), `.codex-${name}`);
|
|
263
|
+
return known ?? { name, dir, authPath: join(dir, "auth.json") };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Undefined when cancelled. */
|
|
267
|
+
async function pickName(ctx: ExtensionContext): Promise<string | undefined> {
|
|
268
|
+
const names = [...new Set([...registeredHomes, ...discoverHomes()].map((home) => home.name))];
|
|
269
|
+
const other = "Another ChatGPT account (new ~/.codex-<name>)";
|
|
270
|
+
const choice = names.length ? await ctx.ui.select("Log in which Codex home?", [...names, other]) : other;
|
|
271
|
+
if (choice !== other) return choice;
|
|
272
|
+
return (await ctx.ui.input("Name for the Codex home (~/.codex-<name>)", "e.g. work"))?.trim() || undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Import ~/.codex-<name>/auth.json into Pi, i.e. the built-in /login → "OpenAI Codex (<name>)". */
|
|
276
|
+
async function login(rawName: string, ctx: ExtensionContext): Promise<void> {
|
|
277
|
+
let name = rawName.toLowerCase();
|
|
278
|
+
if (!name && ctx.hasUI) {
|
|
279
|
+
name = (await pickName(ctx))?.toLowerCase() ?? "";
|
|
280
|
+
if (!name) return;
|
|
281
|
+
}
|
|
282
|
+
if (!HOME_DIR_PATTERN.test(`.codex-${name}`)) {
|
|
283
|
+
ctx.ui.notify("Usage: /account login codex <name> (letters, digits, _ -; starts with a letter or digit)", "warning");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const home = findHome(name);
|
|
287
|
+
if (!existsSync(home.authPath)) {
|
|
288
|
+
ctx.ui.notify(
|
|
289
|
+
[
|
|
290
|
+
`No ChatGPT login in ${home.authPath} yet. In a terminal, run:`,
|
|
291
|
+
` CODEX_HOME=${home.dir} codex login`,
|
|
292
|
+
"Adding another ChatGPT account? Finish that login in a private/incognito window (or sign out of chatgpt.com first); " +
|
|
293
|
+
"otherwise the browser logs in the account that is already signed in.",
|
|
294
|
+
`Then run /account login codex ${name} again.`,
|
|
295
|
+
].join("\n"),
|
|
296
|
+
"warning",
|
|
297
|
+
);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const cred = credFromFile(home.authPath);
|
|
301
|
+
const id = getProviderId(home);
|
|
302
|
+
if (!getIds().includes(id)) {
|
|
303
|
+
registerHome(pi, home, codexModels());
|
|
304
|
+
registeredHomes.push(home);
|
|
305
|
+
}
|
|
306
|
+
await mutateAuth((auth) => {
|
|
307
|
+
auth[id] = { type: "oauth", ...cred };
|
|
308
|
+
});
|
|
309
|
+
await ctx.modelRegistry.refresh({ providers: [id], allowNetwork: false });
|
|
310
|
+
ctx.ui.notify(`Logged in ${id} with the ChatGPT login from ${home.authPath}.`, "info");
|
|
311
|
+
if (!ctx.hasUI) {
|
|
312
|
+
ctx.ui.notify(`Run /account ${id} to use it in this session.`, "info");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (await ctx.ui.confirm("Use it now?", `Switch this session to ${id}?`)) await useAccount(id, ctx);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { getIds, getEntries, useAccount, login };
|
|
319
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
import { registerAccounts } from "./accounts.ts";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
const codexHomes = registerCodex(pi);
|
|
9
|
+
registerClaude(pi);
|
|
10
|
+
registerAccounts(pi, codexHomes);
|
|
11
|
+
}
|