pi-account-manager 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Henao
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,98 @@
1
- # Temporary Holding Version
1
+ # pi-account-manager
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
+ Use more than one Anthropic or OpenAI subscription in pi, and switch between them with `/model`.
4
+
5
+ Sign in as `anthropic@work` and `anthropic@personal`, and each account keeps its own login and its own usage.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:pi-account-manager
11
+ ```
12
+
13
+ Restart pi, or run `/reload`. You need pi 1.2.0 or newer, and pi warns you at startup if yours is older.
14
+
15
+ ## Add an account
16
+
17
+ ```text
18
+ /account add work
19
+ ```
20
+
21
+ Pick a provider. Pi puts `/login anthropic@work` in your editor, so press enter and sign in as usual.
22
+
23
+ That's it. `anthropic@work` now works like any other provider:
24
+
25
+ ```text
26
+ /model switch between accounts and their models
27
+ /login anthropic@work sign in again
28
+ /logout sign out of an account
29
+ ```
30
+
31
+ The first time you sign in, pick one of the account's models with `/model`.
32
+
33
+ ## Supported providers
34
+
35
+ | Choose | Alias | Signs in with |
36
+ | --- | --- | --- |
37
+ | Anthropic | `anthropic@<name>` | Your Claude subscription |
38
+ | OpenAI | `openai@<name>` | Sign in with ChatGPT |
39
+ | OpenAI Codex | `openai-codex@<name>` | The legacy Codex login |
40
+
41
+ Each account signs in on its own. A named account never uses pi's built-in login for that provider, or an API key.
42
+
43
+ ## Commands
44
+
45
+ ```text
46
+ /account list your accounts
47
+ /account add [name] add an account
48
+ /account remove [name|alias] remove an account
49
+ ```
50
+
51
+ `/account` shows each account and whether it's signed in:
52
+
53
+ ```text
54
+ NAME PROVIDER ALIAS ID STATUS
55
+ personal Anthropic anthropic@personal not authenticated
56
+ work Anthropic anthropic@work authenticated
57
+ work OpenAI openai@work authenticated
58
+ ```
59
+
60
+ `/account` only works in pi's interactive mode.
61
+
62
+ ## Start on an account by default
63
+
64
+ Name the account and model in `/settings`:
65
+
66
+ ```json
67
+ { "defaultProvider": "anthropic@work", "defaultModel": "claude-sonnet-5" }
68
+ ```
69
+
70
+ ## Remove an account
71
+
72
+ ```text
73
+ /account remove work
74
+ ```
75
+
76
+ This only forgets the name and never deletes your login. Before removing an account, sign out with `/logout` and switch to another model. `/account remove` tells you if either step is still needed.
77
+
78
+ ## Account names
79
+
80
+ Names can use 1 to 32 characters: lowercase letters, digits, `_` and `-`, starting with a letter or digit. Uppercase letters are lowercased. You can reuse a name across providers, so `anthropic@work` and `openai@work` can both exist.
81
+
82
+ ## Good to know
83
+
84
+ - **Anthropic billing:** the first time in a pi run that you use an `anthropic@<name>` account, pi warns that third-party usage is billed per token as extra usage, not against your Claude plan limits. To turn the warning off, set `{ "warnings": { "anthropicExtraUsage": false } }` in `/settings`.
85
+ - **Message threads:** Anthropic accounts use pi's message threads, kept separately for each account. That needs pi 1.5.1 or newer.
86
+ - **`models.json`:** named accounts follow the built-in provider's settings there, including any `headers`. An `Authorization` header there replaces the account's own login.
87
+ - **Where accounts live:** account names are kept in `~/.pi/agent/pi-account-manager/accounts.json`. Logins stay in pi's own `auth.json`, like any other provider.
88
+
89
+ ## Development
90
+
91
+ ```bash
92
+ npm ci
93
+ npm run check # type check and tests
94
+ ```
95
+
96
+ ## License
97
+
98
+ MIT
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
- "name": "pi-account-manager",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "pi-account-manager",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for managing multiple named OAuth accounts per provider",
5
+ "license": "MIT",
6
+ "author": "John Henao <johnhenaot@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/johnhenaot/pi-account-manager.git"
10
+ },
11
+ "keywords": [
12
+ "pi-package"
13
+ ],
14
+ "type": "module",
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "files": [
21
+ "src"
22
+ ],
23
+ "scripts": {
24
+ "prepublishOnly": "npm run check",
25
+ "test": "vitest --run",
26
+ "typecheck": "tsc --noEmit",
27
+ "check": "npm run typecheck && npm test"
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-ai": "*",
31
+ "@earendil-works/pi-coding-agent": "*"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@earendil-works/pi-ai": {
35
+ "optional": true
36
+ },
37
+ "@earendil-works/pi-coding-agent": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-ai": "1.5.2",
43
+ "@earendil-works/pi-coding-agent": "1.5.2",
44
+ "typescript": "^5.9.3",
45
+ "vitest": "^3.2.4"
46
+ },
47
+ "engines": {
48
+ "node": ">=22.19.0"
49
+ }
50
+ }
package/src/account.ts ADDED
@@ -0,0 +1,32 @@
1
+ export const PROVIDERS = {
2
+ anthropic: "Anthropic",
3
+ openai: "OpenAI",
4
+ "openai-codex": "OpenAI Codex",
5
+ } as const;
6
+
7
+ export type ProviderId = keyof typeof PROVIDERS;
8
+
9
+ export interface Account {
10
+ provider: ProviderId;
11
+ name: string;
12
+ }
13
+
14
+ export const errorMessage = (error: unknown): string =>
15
+ error instanceof Error ? error.message : String(error);
16
+
17
+ export const NAME_RULE = 'Use 1-32 characters from a-z, 0-9, "_" and "-", starting with a letter or digit.';
18
+
19
+ // ASCII-only: `.toLowerCase()` would fold non-ASCII such as "İ" and change identity.
20
+ export const normalizeName = (raw: string): string => raw.trim().replace(/[A-Z]/g, (c) => c.toLowerCase());
21
+
22
+ export const isValidName = (name: string): boolean => /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name);
23
+
24
+ export const isProviderId = (value: string): value is ProviderId => Object.hasOwn(PROVIDERS, value);
25
+
26
+ export const accountId = (account: Account): string => `${account.provider}@${account.name}`;
27
+
28
+ // Not `localeCompare`: ICU collation varies by platform, making the saved order vary too.
29
+ // Provider IDs sort the same as their labels, so this one order serves display as well.
30
+ const compare = (x: string, y: string): number => (x < y ? -1 : x > y ? 1 : 0);
31
+ export const sortAccounts = (accounts: readonly Account[]): Account[] =>
32
+ [...accounts].sort((a, b) => compare(a.provider, b.provider) || compare(a.name, b.name));
package/src/alias.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type { Api, Model, Provider, StreamOptions } from "@earendil-works/pi-ai";
2
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
3
+ import { type Account, accountId, type ProviderId, PROVIDERS } from "./account.ts";
4
+
5
+ export const bundledProvider = (provider: ProviderId): Provider | undefined =>
6
+ builtinProviders().find((candidate) => candidate.id === provider);
7
+
8
+ export function buildAlias(base: Provider | undefined, account: Account): Provider {
9
+ const id = accountId(account);
10
+ const oauth = base?.auth.oauth;
11
+ if (!base || !oauth) throw new Error(`provider "${account.provider}" has no OAuth flow.`);
12
+
13
+ const reprovision = (models: readonly Model<Api>[], provider: string) =>
14
+ models.map((model) => ({ ...model, provider }));
15
+
16
+ const isChatGPTSubscription = (model: Model<Api>, options: StreamOptions | undefined) =>
17
+ account.provider === "openai" && model.api === "openai-responses" &&
18
+ model.baseUrl === "https://api.openai.com/v1" &&
19
+ options?.apiKey !== undefined && !options.apiKey.startsWith("sk-");
20
+
21
+ const withoutMidConvoEffort = (model: Model<Api>) => ({
22
+ ...model,
23
+ compat: { ...model.compat, supportsMidConvoEffort: false },
24
+ });
25
+
26
+ const omitUnsupportedFields = (options: StreamOptions | undefined): StreamOptions["onPayload"] =>
27
+ async (payload, requestModel) => {
28
+ const params = payload as Record<string, unknown>;
29
+ delete params.max_output_tokens;
30
+ delete params.temperature;
31
+ delete params.prompt_cache_retention;
32
+ delete params.prompt_cache_options;
33
+ return options?.onPayload?.(payload, requestModel);
34
+ };
35
+
36
+ const streamRequest = <T extends StreamOptions | undefined>(model: Model<Api>, options: T) => {
37
+ if (account.provider === "anthropic" && model.api === "anthropic-messages") {
38
+ // Pi 49fe0a2 makes this the default for every provider; remove once MIN_PI includes it.
39
+ if ((model as Model<"anthropic-messages">).compat?.supportsMessageThreads === undefined &&
40
+ model.baseUrl.includes("api.anthropic.com")) {
41
+ model = { ...model, compat: { ...model.compat, supportsMessageThreads: true } };
42
+ }
43
+ // Keeps each account's thread and fallback state apart. Shortcut: Pi's session cleanup misses
44
+ // these scoped ids; Pi's 10k caps bound them. Track the ids if that ever matters.
45
+ if (options?.sessionId) options = { ...options, sessionId: `${options.sessionId}:${id}` };
46
+ }
47
+ return isChatGPTSubscription(model, options)
48
+ ? {
49
+ model: withoutMidConvoEffort(model),
50
+ options: { ...options, onPayload: omitUnsupportedFields(options) } satisfies T,
51
+ }
52
+ : { model, options };
53
+ };
54
+
55
+ return {
56
+ id,
57
+ name: `${PROVIDERS[account.provider]} (${account.name})`,
58
+ baseUrl: base.baseUrl,
59
+ headers: base.headers,
60
+ // Omitting `apiKey` is the isolation: the alias can only reach its own OAuth token,
61
+ // never the native key or an ambient credential. Exception (documented in the README):
62
+ // headers that models.json attaches to the native provider's OAuth still apply.
63
+ auth: { oauth },
64
+ getModels: () => reprovision(base.getModels(), id),
65
+ filterModels:
66
+ base.filterModels &&
67
+ ((models, credential) => {
68
+ const kept = new Set(
69
+ base.filterModels!(reprovision(models, base.id), credential).map((model) => model.id),
70
+ );
71
+ return models.filter((model) => kept.has(model.id));
72
+ }),
73
+ // The model keeps `provider: <alias id>`, which is what makes pi attribute every stream
74
+ // event, message, and session entry to this account.
75
+ stream: (model, context, options) => {
76
+ const request = streamRequest(model, options);
77
+ return base.stream(request.model, context, request.options);
78
+ },
79
+ streamSimple: (model, context, options) => {
80
+ const request = streamRequest(model, options);
81
+ return base.streamSimple(request.model, context, request.options);
82
+ },
83
+ };
84
+ }
package/src/index.ts ADDED
@@ -0,0 +1,234 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import { getAgentDir, SettingsManager, VERSION } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type Account,
5
+ accountId,
6
+ errorMessage,
7
+ isProviderId,
8
+ isValidName,
9
+ NAME_RULE,
10
+ normalizeName,
11
+ PROVIDERS,
12
+ sortAccounts,
13
+ } from "./account.ts";
14
+ import { buildAlias, bundledProvider } from "./alias.ts";
15
+ import { loadAccounts, saveAccounts } from "./state.ts";
16
+
17
+ export const ANTHROPIC_EXTRA_USAGE_WARNING =
18
+ "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
19
+
20
+ export const USAGE = [
21
+ "Usage:",
22
+ " /account list named accounts",
23
+ " /account list list named accounts",
24
+ " /account add [name] add a named account",
25
+ " /account remove [name|alias-id] remove a named account",
26
+ ].join("\n");
27
+
28
+ export const EMPTY_LIST = "No named accounts. Use /account add NAME.";
29
+
30
+ // A peer range cannot express this: pi supplies the bundled packages at load time, so the
31
+ // package manager never sees which pi binary runs.
32
+ export const MIN_PI = "1.2.0";
33
+ export const meetsMinimum = (version: string): boolean =>
34
+ version.localeCompare(MIN_PI, undefined, { numeric: true }) >= 0;
35
+
36
+ // On globalThis, not the closure: `/reload` re-runs the factory in the same process, and
37
+ // both warnings must fire at most once per process.
38
+ export const WARNED = Symbol.for("pi-account-manager.warned");
39
+ // SAFETY: only this extension's Symbol.for key is read or written on globalThis.
40
+ const warned = (): { anthropic?: true; version?: true } =>
41
+ ((globalThis as unknown as Record<symbol, object>)[WARNED] ??= {});
42
+
43
+ export function isAuthenticated(registry: ModelRegistry, id: string): boolean {
44
+ try {
45
+ return registry.getProviderAuthStatus(id)?.configured === true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function anthropicWarningEnabled(ctx: ExtensionContext): boolean {
52
+ try {
53
+ const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
54
+ return settings.getWarnings().anthropicExtraUsage !== false;
55
+ } catch {
56
+ // An unreadable setting must not silently disable the warning.
57
+ return true;
58
+ }
59
+ }
60
+
61
+ export function formatList(accounts: readonly Account[], registry: ModelRegistry): string {
62
+ if (accounts.length === 0) return EMPTY_LIST;
63
+ const rows = [
64
+ ["NAME", "PROVIDER", "ALIAS ID", "STATUS"],
65
+ ...accounts.map((account) => [
66
+ account.name,
67
+ PROVIDERS[account.provider],
68
+ accountId(account),
69
+ isAuthenticated(registry, accountId(account)) ? "authenticated" : "not authenticated",
70
+ ]),
71
+ ];
72
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
73
+ return rows.map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
74
+ }
75
+
76
+ export default async function (pi: ExtensionAPI) {
77
+ const agentDir = getAgentDir();
78
+ let snapshot: Account[] = [];
79
+ let loadError: string | undefined;
80
+
81
+ const registerAll = (base: (account: Account) => Parameters<typeof buildAlias>[0]) => {
82
+ try {
83
+ for (const account of snapshot) pi.registerProvider(buildAlias(base(account), account));
84
+ } catch (error) {
85
+ for (const account of snapshot) pi.unregisterProvider(accountId(account));
86
+ loadError = `Named accounts are unavailable: ${errorMessage(error)}`;
87
+ snapshot = [];
88
+ }
89
+ };
90
+
91
+ // Registered at load so startup model selection and `pi --list-models` see the aliases.
92
+ try {
93
+ snapshot = await loadAccounts(agentDir);
94
+ } catch (error) {
95
+ loadError = `Named accounts are unavailable: ${errorMessage(error)}`;
96
+ }
97
+ registerAll((account) => bundledProvider(account.provider));
98
+
99
+ const isOwnAnthropicAlias = (providerId: string | undefined): boolean =>
100
+ snapshot.some((account) => account.provider === "anthropic" && accountId(account) === providerId);
101
+
102
+ // Pi never warns for alias providers itself, and never emits model_select on restore.
103
+ const maybeWarnAnthropic = (ctx: ExtensionContext, providerId: string | undefined) => {
104
+ if (ctx.mode !== "tui" || warned().anthropic || !providerId || !isOwnAnthropicAlias(providerId)) return;
105
+ if (!isAuthenticated(ctx.modelRegistry, providerId) || !anthropicWarningEnabled(ctx)) return;
106
+ warned().anthropic = true;
107
+ ctx.ui.notify(ANTHROPIC_EXTRA_USAGE_WARNING, "warning");
108
+ };
109
+
110
+ pi.on("session_start", async (_event, ctx) => {
111
+ if (ctx.mode === "tui" && !warned().version && !meetsMinimum(VERSION)) {
112
+ warned().version = true;
113
+ ctx.ui.notify(
114
+ `pi-account-manager requires pi ${MIN_PI} or newer; this host is ${VERSION}. Named accounts may not work correctly.`,
115
+ "warning",
116
+ );
117
+ }
118
+
119
+ // Rebuilt against the effective registry, which includes any models.json overlay. Through
120
+ // `pi` rather than ctx.modelRegistry, because only that path refreshes the live model.
121
+ registerAll((account) => ctx.modelRegistry.getProvider(account.provider));
122
+
123
+ // A restored session assigns its model directly, emitting no model_select.
124
+ if (isOwnAnthropicAlias(ctx.model?.provider)) {
125
+ // Advisory only, so a refresh failure must not break session restore.
126
+ await ctx.modelRegistry.refresh().catch(() => {});
127
+ maybeWarnAnthropic(ctx, ctx.model?.provider);
128
+ }
129
+ });
130
+
131
+ // Pi reuses one ModelRuntime across /reload but hands the reload a fresh closure, so
132
+ // without this the previous instance's aliases stay registered forever.
133
+ pi.on("session_shutdown", (event, ctx) => {
134
+ if (event.reason !== "reload") return;
135
+ for (const account of snapshot) ctx.modelRegistry.unregisterProvider(accountId(account));
136
+ });
137
+
138
+ pi.on("model_select", (event, ctx) => maybeWarnAnthropic(ctx, event.model?.provider));
139
+
140
+ const add = async (ctx: ExtensionCommandContext, argument: string | undefined) => {
141
+ const entered = argument ?? (await ctx.ui.input("Account name", "e.g. work"));
142
+ if (entered === undefined) return;
143
+ const name = normalizeName(entered);
144
+ if (!isValidName(name)) {
145
+ ctx.ui.notify(`Invalid account name "${entered.trim()}". ${NAME_RULE}`, "error");
146
+ return;
147
+ }
148
+ const label = await ctx.ui.select("Provider", Object.values(PROVIDERS));
149
+ const provider = Object.keys(PROVIDERS).find((id) => PROVIDERS[id as keyof typeof PROVIDERS] === label);
150
+ if (!provider || !isProviderId(provider)) return;
151
+
152
+ const account: Account = { provider, name };
153
+ const id = accountId(account);
154
+ try {
155
+ // Re-read so an account another pi process added since startup is kept.
156
+ const accounts = await loadAccounts(agentDir);
157
+ if (accounts.some((existing) => accountId(existing) === id))
158
+ throw new Error(`already exists. Use /login ${id} to reauthenticate it.`);
159
+ if (ctx.modelRegistry.getProvider(id)) throw new Error(`provider ID "${id}" is already in use.`);
160
+ const alias = buildAlias(ctx.modelRegistry.getProvider(provider), account);
161
+ await saveAccounts(agentDir, [...accounts, account]);
162
+ snapshot = sortAccounts([...snapshot, account]);
163
+ try {
164
+ pi.registerProvider(alias);
165
+ } catch (error) {
166
+ throw new Error(`saved, but pi rejected the provider (${errorMessage(error)}). Run /reload.`);
167
+ }
168
+ } catch (error) {
169
+ ctx.ui.notify(`Cannot add ${id}: ${errorMessage(error)}`, "error");
170
+ return;
171
+ }
172
+
173
+ const login = `/login ${id}`;
174
+ // Not trimmed: a fresh pi editor is exactly "", so whitespace is a real draft.
175
+ if (ctx.ui.getEditorText() === "") {
176
+ ctx.ui.setEditorText(login);
177
+ ctx.ui.notify(`Added ${id}. Submit the prepared command to sign in.`, "info");
178
+ } else {
179
+ ctx.ui.notify(`Added ${id}. Run ${login} to sign in.`, "info");
180
+ }
181
+ };
182
+
183
+ const remove = async (ctx: ExtensionCommandContext, argument: string | undefined) => {
184
+ let matches = snapshot;
185
+ if (argument !== undefined) {
186
+ matches = snapshot.filter((account) => accountId(account) === argument);
187
+ if (matches.length === 0) matches = snapshot.filter((account) => account.name === normalizeName(argument));
188
+ }
189
+ if (matches.length === 0) {
190
+ ctx.ui.notify(argument === undefined ? EMPTY_LIST : `No named account matches "${argument}".`, "error");
191
+ return;
192
+ }
193
+ // No argument always asks, so a lone account is never removed without confirmation.
194
+ const id =
195
+ argument !== undefined && matches.length === 1
196
+ ? accountId(matches[0])
197
+ : await ctx.ui.select("Remove account", matches.map(accountId));
198
+ if (id === undefined) return;
199
+
200
+ const blockers: string[] = [];
201
+ if (isAuthenticated(ctx.modelRegistry, id)) blockers.push(`Run /logout for ${id} first.`);
202
+ if (ctx.model?.provider === id) blockers.push("Select another model with /model first.");
203
+ try {
204
+ if (blockers.length > 0) throw new Error(blockers.join("\n"));
205
+ const accounts = await loadAccounts(agentDir);
206
+ await saveAccounts(agentDir, accounts.filter((account) => accountId(account) !== id));
207
+ snapshot = snapshot.filter((account) => accountId(account) !== id);
208
+ pi.unregisterProvider(id);
209
+ } catch (error) {
210
+ ctx.ui.notify(`Cannot remove ${id}: ${errorMessage(error)}`, "error");
211
+ return;
212
+ }
213
+ ctx.ui.notify(`Removed ${id}. Its credential is untouched; use /logout to remove that.`, "info");
214
+ };
215
+
216
+ pi.registerCommand("account", {
217
+ description: "Manage named OAuth accounts",
218
+ handler: async (args, ctx) => {
219
+ if (ctx.mode !== "tui") {
220
+ ctx.ui.notify("/account is available in interactive mode only.", "error");
221
+ return;
222
+ }
223
+ const [command = "list", argument, extra] = args.trim().split(/\s+/).filter(Boolean);
224
+ const valid =
225
+ extra === undefined &&
226
+ (command === "add" || command === "remove" || (command === "list" && argument === undefined));
227
+ if (!valid) ctx.ui.notify(USAGE, "error");
228
+ else if (command === "list") ctx.ui.notify(loadError ?? formatList(snapshot, ctx.modelRegistry), loadError ? "error" : "info");
229
+ else if (loadError) ctx.ui.notify(`${loadError} Fix the account state, then run /reload.`, "error");
230
+ else if (command === "add") await add(ctx, argument);
231
+ else await remove(ctx, argument);
232
+ },
233
+ });
234
+ }
package/src/state.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { type Account, accountId, isProviderId, isValidName, sortAccounts } from "./account.ts";
4
+
5
+ export const statePath = (agentDir: string): string => join(agentDir, "pi-account-manager", "accounts.json");
6
+
7
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
8
+ typeof value === "object" && value !== null && !Array.isArray(value);
9
+
10
+ export function parseState(text: string): Account[] {
11
+ let raw: unknown;
12
+ try {
13
+ raw = JSON.parse(text);
14
+ } catch {
15
+ throw new Error("accounts.json is not valid JSON.");
16
+ }
17
+ if (!isRecord(raw) || raw.version !== 1 || !Array.isArray(raw.accounts))
18
+ throw new Error('accounts.json must be {"version": 1, "accounts": [...]}.');
19
+ const accounts: Account[] = [];
20
+ for (const entry of raw.accounts) {
21
+ const { provider, name } = isRecord(entry) ? entry : {};
22
+ if (typeof provider !== "string" || !isProviderId(provider))
23
+ throw new Error(`accounts.json has unsupported provider ${JSON.stringify(provider)}.`);
24
+ if (typeof name !== "string" || !isValidName(name))
25
+ throw new Error(`accounts.json has invalid account name ${JSON.stringify(name)}.`);
26
+ const account = { provider, name };
27
+ if (accounts.some((other) => accountId(other) === accountId(account)))
28
+ throw new Error(`accounts.json has duplicate account ${accountId(account)}.`);
29
+ accounts.push(account);
30
+ }
31
+ return sortAccounts(accounts);
32
+ }
33
+
34
+ export async function loadAccounts(agentDir: string): Promise<Account[]> {
35
+ try {
36
+ return parseState(await readFile(statePath(agentDir), "utf8"));
37
+ } catch (error) {
38
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ // shortcut: no lock, so two pi processes changing accounts at the same instant can lose one
44
+ // change; add a lock file if that ever happens in practice.
45
+ export async function saveAccounts(agentDir: string, accounts: readonly Account[]): Promise<void> {
46
+ const path = statePath(agentDir);
47
+ await mkdir(dirname(path), { recursive: true });
48
+ // Write-then-rename so a crash never leaves a half-written file.
49
+ const temp = `${path}.${process.pid}.tmp`;
50
+ await writeFile(temp, `${JSON.stringify({ version: 1, accounts: sortAccounts(accounts) }, null, 2)}\n`, "utf8");
51
+ await rename(temp, path);
52
+ }