@hieplp/pi-account-switcher 0.2.4 → 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/README.md CHANGED
@@ -64,14 +64,16 @@ The local commands will be registered as `/dev:accounts:list`, `/dev:accounts:ad
64
64
 
65
65
  ### Accounts
66
66
 
67
- | Command | Description |
68
- | ------------------ | --------------------------------------------------------------- |
69
- | `/accounts:add` | Add a new account interactively |
70
- | `/accounts:list` | List all accounts and activate the selected one |
71
- | `/accounts:switch` | Switch to another account within the current provider |
72
- | `/accounts:edit` | Edit label, provider, id, or credential source |
73
- | `/accounts:remove` | Delete an account |
74
- | `/accounts:oauth` | Import the current Pi `/login` OAuth session as a named account |
67
+ | Command | Description |
68
+ | -------------------- | --------------------------------------------------------------- |
69
+ | `/accounts:add` | Add a new account interactively |
70
+ | `/accounts:switch` | Switch to any account (interactive picker or by ID) |
71
+ | `/accounts:peers` | Switch to another account within the current provider |
72
+ | `/accounts:subagent` | Set account for the next spawned subagent |
73
+ | `/accounts:edit` | Edit label, provider, id, or credential source |
74
+ | `/accounts:remove` | Delete an account |
75
+ | `/accounts:oauth` | Import the current Pi `/login` OAuth session as a named account |
76
+ | `/accounts:dirs` | Manage working directories for CWD-based auto-select |
75
77
 
76
78
  ### Providers
77
79
 
@@ -133,12 +135,20 @@ Complete browser/device login, then:
133
135
  /accounts:oauth
134
136
  ```
135
137
 
136
- Give it a label like `Claude — Work`. Repeat for as many accounts as you need — each gets its own saved credentials. Switch between them any time with `/accounts:list`.
138
+ Give it a label like `Claude — Work`. Repeat for as many accounts as you need — each gets its own saved credentials. Switch between them any time with `/accounts:switch`.
137
139
 
138
140
  OAuth credentials are read from `~/.pi/agent/auth.json` and written back to Pi's live auth storage on switch.
139
141
 
140
142
  ---
141
143
 
144
+ ## Directory-based Auto-Select
145
+
146
+ The extension can automatically activate the right account based on your current working directory. Each account can list directory paths (`dirs`) — the longest prefix match wins. A `defaultAccountId` at the config level serves as the fallback. Use `/accounts:dirs` to manage directories interactively.
147
+
148
+ See **USAGE.md** for full details on the activation cascade, configuration, and examples.
149
+
150
+ ---
151
+
142
152
  ## Custom Providers
143
153
 
144
154
  Define a provider once, reuse it across accounts:
@@ -196,11 +206,13 @@ The export file contains all accounts, providers, and active-selection state as
196
206
  ```json
197
207
  {
198
208
  "switchMode": "env",
209
+ "defaultAccountId": "claude-work",
199
210
  "accounts": [
200
211
  {
201
212
  "id": "claude-work",
202
213
  "label": "Claude — Work",
203
214
  "provider": "anthropic",
215
+ "dirs": ["/home/user/Development/Work"],
204
216
  "env": {
205
217
  "ANTHROPIC_API_KEY": { "type": "env", "name": "ANTHROPIC_WORK_API_KEY" }
206
218
  }
@@ -209,6 +221,7 @@ The export file contains all accounts, providers, and active-selection state as
209
221
  "id": "openai-personal",
210
222
  "label": "OpenAI — Personal",
211
223
  "provider": "openai",
224
+ "dirs": ["/home/user/Projects/Client-A"],
212
225
  "env": {
213
226
  "OPENAI_API_KEY": { "type": "file", "path": "~/.keys/openai-personal.txt" }
214
227
  }
@@ -231,7 +244,22 @@ A plain string is treated as a literal; strings starting with `op://` are resolv
231
244
 
232
245
  ### State — `~/.pi/account-switcher/state.json`
233
246
 
234
- Tracks the selected account and model across sessions. Restored automatically on `session_start`.
247
+ Tracks the selected account and model **per Pi session**. Each Pi session gets its own key — no global active-account state.
248
+
249
+ ```json
250
+ {
251
+ "sessions": {
252
+ "abc123": { "activeAccountId": "claude-work" },
253
+ "def456": { "activeAccountId": "openai-personal", "activeModelId": "gpt-4", "activeModelProvider": "openai" }
254
+ }
255
+ }
256
+ ```
257
+
258
+ When Pi starts, the extension derives a session key from Pi's session file and restores that session's saved state. Sessions with no saved state fall back to CWD-based auto-select or `defaultAccountId`.
259
+
260
+ Legacy flat-format state (`{ "activeAccountId": "..." }`) is automatically migrated to the session-keyed format on first load.
261
+
262
+ Session entries are automatically cleaned up: entries inactive for `stateCleanupDays` (default 30, configurable in `accounts.json`) are pruned, with a hard cap of 500 entries.
235
263
 
236
264
  ---
237
265
 
package/USAGE.md CHANGED
@@ -109,7 +109,7 @@ To add another OAuth account for the same provider, run `/login` again with the
109
109
  Switch OAuth accounts with:
110
110
 
111
111
  ```txt
112
- /accounts:list
112
+ /accounts:switch
113
113
  ```
114
114
 
115
115
  OAuth credentials are captured from Pi's auth file:
@@ -191,12 +191,14 @@ Example config:
191
191
 
192
192
  ```json
193
193
  {
194
- "switchMode": "env",
194
+ "defaultAccountId": "claude-work",
195
+ "stateCleanupDays": 30,
195
196
  "accounts": [
196
197
  {
197
198
  "id": "claude-work",
198
199
  "label": "Claude — Work",
199
200
  "provider": "anthropic",
201
+ "dirs": ["/home/user/Development/Work"],
200
202
  "env": {
201
203
  "ANTHROPIC_API_KEY": { "type": "env", "name": "ANTHROPIC_WORK_API_KEY" }
202
204
  }
@@ -273,26 +275,13 @@ A plain string is treated as a literal value, except strings beginning with `op:
273
275
 
274
276
  ## 9. Commands
275
277
 
276
- ### Pick account for current provider
277
-
278
- ```txt
279
- /accounts:list
280
- ```
281
-
282
- The extension tries to detect the current model provider and shows matching accounts.
283
-
284
- ### Pick account for a specific provider
285
-
286
- ```txt
287
- /accounts:list
288
- ```
289
-
290
- Useful if Pi cannot detect the active provider.
291
-
292
- ### List accounts
278
+ ### Switch accounts
293
279
 
294
280
  ```txt
295
- /accounts:list
281
+ /accounts:switch # interactive picker from all accounts
282
+ /accounts:switch <id> # activate by ID directly (agent-facing)
283
+ /accounts:peers # picker from same-provider accounts
284
+ /accounts:subagent # set account for next spawned subagent
296
285
  ```
297
286
 
298
287
  ### Import current Pi OAuth login
@@ -366,44 +355,102 @@ Typical usage:
366
355
  3. Later, switch accounts:
367
356
 
368
357
  ```txt
369
- /accounts:list
358
+ /accounts:switch
370
359
  ```
371
360
 
372
361
  Alternative manual config flow: edit `~/.pi/account-switcher/accounts.json` directly, then reload Pi:
373
362
 
374
- 6. If needed, reload Pi runtime:
363
+ 1. If needed, reload Pi runtime:
375
364
 
376
365
  ```txt
377
366
  /reload
378
367
  ```
379
368
 
380
- ## 11. State Persistence
369
+ ## 11. Directory-based Auto-Select
381
370
 
382
- Selected accounts are saved at:
371
+ The extension can automatically activate the right account based on your current working directory.
372
+
373
+ ### `dirs` on accounts
374
+
375
+ Add directory paths to an account in `accounts.json`. The longest matching prefix of the current working directory wins. On tie, the first account in the array wins.
376
+
377
+ ```json
378
+ {
379
+ "id": "claude-work",
380
+ "label": "Claude — Work",
381
+ "provider": "anthropic",
382
+ "dirs": ["/home/user/Development/Work"],
383
+ "env": {
384
+ "ANTHROPIC_API_KEY": { "type": "env", "name": "ANTHROPIC_WORK_API_KEY" }
385
+ }
386
+ }
387
+ ```
388
+
389
+ ### `defaultAccountId` config fallback
390
+
391
+ If no session state exists and no directory matches, the extension falls back to `defaultAccountId` at the top of `accounts.json`:
392
+
393
+ ```json
394
+ {
395
+ "switchMode": "env",
396
+ "defaultAccountId": "claude-work",
397
+ "accounts": [...]
398
+ }
399
+ ```
400
+
401
+ ### Activation cascade (session start)
402
+
403
+ 1. **Saved session state** — account previously selected for this Pi session
404
+ 2. **CWD-based auto-select** — longest matching directory prefix
405
+ 3. **`defaultAccountId`** — config-level fallback
406
+ 4. **None** — no account activated until you pick one
407
+
408
+ ### Manage dirs with `/accounts:dirs`
409
+
410
+ The `/accounts:dirs` command opens an interactive wizard. When an active account and current working directory are detected, it offers **Auto-save** (one-step: saves current dir to active account) and **Manual** (pick account, then add via recursive directory browser or remove configured dirs).
411
+
412
+ Dirs are stored per account in `~/.pi/account-switcher/accounts.json`.
413
+
414
+ ---
415
+
416
+ ## 12. State Persistence
417
+
418
+ Selected accounts are saved **per Pi session** at:
383
419
 
384
420
  ```txt
385
421
  ~/.pi/account-switcher/state.json
386
422
  ```
387
423
 
388
- Example:
424
+ Each Pi session gets its own key — no global active-account state:
389
425
 
390
426
  ```json
391
427
  {
392
- "activeAccountId": "claude-work",
393
- "activeModelId": "claude-sonnet-4",
394
- "activeModelProvider": "anthropic"
428
+ "sessions": {
429
+ "abc123": { "activeAccountId": "claude-work" },
430
+ "def456": { "activeAccountId": "openai-personal", "activeModelId": "gpt-4", "activeModelProvider": "openai" }
431
+ }
395
432
  }
396
433
  ```
397
434
 
398
- On Pi session start, the extension restores the saved active account and model state.
435
+ On Pi session start, the extension derives a session key and restores that session's saved state. Sessions with no saved state fall back to CWD-based auto-select or `defaultAccountId`. Legacy flat-format state (`{ "activeAccountId": "..." }`) is automatically migrated on first load.
436
+
437
+ Session entries auto-accumulate as you use Pi. To prevent unbounded growth, the extension automatically prunes entries that haven't been active in `stateCleanupDays` days (default: 30). If there are still more than 500 entries after the TTL sweep, the oldest ones are removed.
438
+
439
+ Configure the TTL in `~/.pi/account-switcher/accounts.json`:
440
+
441
+ ```json
442
+ { "accounts": [...], "stateCleanupDays": 60 }
443
+ ```
444
+
445
+ Set to a higher value (e.g. 90) if you frequently resume sessions from weeks ago, or a lower value (e.g. 7) for tighter cleanup. Entries without a `lastActive` timestamp (from previous versions) are automatically timestamped on first write after upgrade.
399
446
 
400
- ## 12. Important Note About Credential Caching
447
+ ## 13. Important Note About Credential Caching
401
448
 
402
449
  The extension updates `process.env`, Pi's live runtime API-key overrides, and Pi's live OAuth auth storage when those hooks are available.
403
450
 
404
451
  If a provider still keeps old credentials cached, run `/reload` or restart Pi.
405
452
 
406
- ## 13. Troubleshooting
453
+ ## 14. Troubleshooting
407
454
 
408
455
  ### No accounts configured
409
456
 
@@ -414,17 +461,10 @@ Run `/accounts:add` to create one interactively, or create `~/.pi/account-switch
414
461
  Run explicitly:
415
462
 
416
463
  ```txt
417
- /accounts:list
464
+ /accounts:switch
418
465
  ```
419
466
 
420
- Also check that account `provider` values match supported providers:
421
-
422
- - `anthropic` / `claude`
423
- - `openai`
424
- - `openai-codex` / `codex`
425
- - `google` / `gemini`
426
- - `xai`
427
- - `openrouter`
467
+ Also check that account `provider` values match a supported built-in provider or a custom provider added via `/providers:add`. Built-in providers include anthropic, openai, openai-codex, google, xai, openrouter, opencode, opencode-go, github-copilot, amazon-bedrock, and others. Use the `list_accounts` tool or check `@/constants/providers.ts` for the full list.
428
468
 
429
469
  ### Secret resolves empty
430
470
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hieplp/pi-account-switcher",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension for quickly switching between multiple accounts/API keys per provider.",
6
6
  "license": "MIT",
@@ -45,12 +45,13 @@
45
45
  "@earendil-works/pi-coding-agent": "latest",
46
46
  "@types/node": "latest",
47
47
  "prettier": "^3.8.3",
48
+ "typebox": "^1.2.14",
48
49
  "typescript": "latest",
49
50
  "vitest": "latest"
50
51
  },
51
52
  "pi": {
52
53
  "extensions": [
53
- "./src/extension.ts"
54
+ "./src/index.ts"
54
55
  ]
55
56
  },
56
57
  "packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1"
@@ -20,7 +20,15 @@ class AddAccountCommand extends AccountCommand {
20
20
  await this.runtime.load();
21
21
  const providers = this.runtime.getProviders();
22
22
 
23
- const account = await new AccountConfigBuilder(ctx.ui, providers).collect();
23
+ // Get all provider IDs registered in Pi from the model registry
24
+ const piModels = ctx.modelRegistry.getAll();
25
+ const piProviderIds = [...new Set(piModels.map((m) => m.provider))];
26
+
27
+ const getOAuthEntry = async (p: string) => {
28
+ return this.runtime.getPiAuthEntry(p);
29
+ };
30
+
31
+ const account = await new AccountConfigBuilder(ctx.ui, providers, piProviderIds, getOAuthEntry).collect();
24
32
  if (!account) return;
25
33
 
26
34
  await this.saveProvider(ctx, account);
@@ -19,7 +19,21 @@ class EditAccountCommand extends AccountCommand {
19
19
  const original = await this.loadAndSelectAccount(ctx, "Select account to edit");
20
20
  if (!original) return;
21
21
 
22
- const updated = await new AccountConfigBuilder(ctx.ui, this.runtime.getProviders(), original).collect(true);
22
+ // Pass dynamic providers from Pi so editing can also see all options
23
+ const piModels = ctx.modelRegistry.getAll();
24
+ const piProviderIds = [...new Set(piModels.map((m) => m.provider))];
25
+
26
+ const getOAuthEntry = async (p: string) => {
27
+ return this.runtime.getPiAuthEntry(p);
28
+ };
29
+
30
+ const updated = await new AccountConfigBuilder(
31
+ ctx.ui,
32
+ this.runtime.getProviders(),
33
+ piProviderIds,
34
+ getOAuthEntry,
35
+ original,
36
+ ).collect(true);
23
37
  if (!updated) return;
24
38
 
25
39
  await this.runtime.editAccount(original, updated);
@@ -2,19 +2,25 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import { useAddAccountCommand } from "./add";
4
4
  import { useEditAccountCommand } from "./edit";
5
- import { useListAccountsCommand } from "./list";
5
+ import { useListAccountsTool } from "./list";
6
6
  import { useOAuthImportCommand } from "./oauth";
7
7
  import { useRemoveAccountCommand } from "./remove";
8
8
  import { useSwitchAccountCommand } from "./switch";
9
+ import { useSubagentAccountCommand } from "./subagent";
10
+ import { usePeersCommand } from "./peers";
11
+ import { useSetSubagentAccountTool } from "./set-subagent-account";
9
12
  import { useVerifyAccountsCommand } from "./verify";
10
13
 
11
14
  const useAccountCommands = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
12
15
  useAddAccountCommand(pi, runtime);
13
16
  useEditAccountCommand(pi, runtime);
14
- useListAccountsCommand(pi, runtime);
17
+ useListAccountsTool(pi, runtime);
15
18
  useOAuthImportCommand(pi, runtime);
16
19
  useRemoveAccountCommand(pi, runtime);
17
20
  useSwitchAccountCommand(pi, runtime);
21
+ useSubagentAccountCommand(pi, runtime);
22
+ useSetSubagentAccountTool(pi, runtime);
23
+ usePeersCommand(pi, runtime);
18
24
  useVerifyAccountsCommand(pi, runtime);
19
25
  };
20
26
 
@@ -1,31 +1,49 @@
1
+ import { Type } from "typebox";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import type { AccountSwitcher } from "@/runtime";
3
- import type { AccountSwitcherContext } from "@/types";
4
- import { COMMANDS } from "@/constants";
5
- import { errorUtil } from "@/utils";
6
- import { AccountCommand } from "./shared";
4
+ import type { AccountConfig } from "@/types";
7
5
 
8
- export const useListAccountsCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
9
- new ListAccountsCommand(pi, runtime).register();
10
- };
11
-
12
- class ListAccountsCommand extends AccountCommand {
13
- constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
14
- super(pi, runtime, COMMANDS.accounts.list);
15
- }
16
-
17
- async handler(ctx: AccountSwitcherContext): Promise<void> {
18
- try {
19
- const accounts = await this.loadAccounts(ctx);
20
- if (!accounts) return;
6
+ /** Tool name for agent-facing account discovery */
7
+ export const LIST_ACCOUNTS_TOOL = "list_accounts";
21
8
 
22
- const account = await this.pickGroupedAccount(ctx, accounts, "Pick account to activate");
23
- if (!account) return;
9
+ /**
10
+ * Format a list of accounts as structured text for agent consumption.
11
+ * Output format: `id | label | provider | status` (one line per account, sorted by label).
12
+ */
13
+ export function formatAccountList(accounts: AccountConfig[], active?: AccountConfig): string {
14
+ if (accounts.length === 0) return "No accounts configured.";
24
15
 
25
- const applied = await this.runtime.activateAccount(account, ctx);
26
- ctx.ui.notify(`Switched to ${account.label} (${applied}).`, "info");
27
- } catch (error) {
28
- ctx.ui.notify(`Failed to list accounts: ${errorUtil.format(error)}`, "error");
29
- }
30
- }
16
+ const sorted = [...accounts].sort((a, b) => a.label.localeCompare(b.label));
17
+ return sorted
18
+ .map((account) => {
19
+ const status = active && active.id === account.id ? "active" : "inactive";
20
+ return `${account.id} | ${account.label} | ${account.provider} | ${status}`;
21
+ })
22
+ .join("\n");
31
23
  }
24
+
25
+ export const useListAccountsTool = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
26
+ pi.registerTool({
27
+ name: LIST_ACCOUNTS_TOOL,
28
+ label: "List Accounts",
29
+ description:
30
+ "List all configured accounts. Returns ID, label, provider, and active/inactive status for each account. " +
31
+ "Use the ID with accounts:switch <id> to activate a specific account.",
32
+ promptSnippet: "List my configured accounts",
33
+ promptGuidelines: [
34
+ "Use list_accounts when the user asks what accounts are configured or wants to switch accounts.",
35
+ "The output shows one line per account: id | label | provider | status.",
36
+ ],
37
+ parameters: Type.Object({}),
38
+ execute: async (_toolCallId, _params, _signal, _onUpdate, _ctx) => {
39
+ await runtime.load();
40
+ const accounts = runtime.getAccounts();
41
+ const active = runtime.getActiveAccount();
42
+ const output = formatAccountList(accounts, active);
43
+ return {
44
+ content: [{ type: "text", text: output }],
45
+ details: {},
46
+ };
47
+ },
48
+ });
49
+ };
@@ -0,0 +1,59 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountSwitcherContext, AccountConfig, ProviderConfig } from "@/types";
4
+ import { COMMANDS } from "@/constants";
5
+ import { commandUtil, errorUtil, providerUtil } from "@/utils";
6
+ import { AccountCommand } from "./shared";
7
+
8
+ /**
9
+ * Filter accounts to those sharing the same auth provider as the active account,
10
+ * excluding the active account itself.
11
+ */
12
+ export function filterPeers(accounts: AccountConfig[], active: AccountConfig): AccountConfig[] {
13
+ const normalize = (a: AccountConfig): string =>
14
+ providerUtil.normalizeProvider(a.piAuth?.provider ?? a.provider);
15
+
16
+ return accounts.filter((a) => normalize(a) === normalize(active) && a.id !== active.id);
17
+ }
18
+
19
+ export const usePeersCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
20
+ new PeersCommand(pi, runtime).register();
21
+ };
22
+
23
+ class PeersCommand extends AccountCommand {
24
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
25
+ super(pi, runtime, {
26
+ name: "accounts:peers",
27
+ description: "Switch to another account sharing the same provider",
28
+ });
29
+ }
30
+
31
+ async handler(ctx: AccountSwitcherContext): Promise<void> {
32
+ try {
33
+ await this.runtime.load();
34
+
35
+ const active = this.runtime.getActiveAccount();
36
+ if (!active) {
37
+ ctx.ui.notify(
38
+ `No active account. Use ${commandUtil.name("accounts:switch")} to activate one first.`,
39
+ "info",
40
+ );
41
+ return;
42
+ }
43
+
44
+ const peers = filterPeers(this.runtime.getAccounts(), active);
45
+ if (peers.length === 0) {
46
+ ctx.ui.notify(`No other accounts for provider "${active.provider}".`, "info");
47
+ return;
48
+ }
49
+
50
+ const account = await this.pickGroupedAccount(ctx, peers, `Switch account (${active.provider})`);
51
+ if (!account) return;
52
+
53
+ const applied = await this.runtime.activateAccount(account, ctx);
54
+ ctx.ui.notify(`Switched to ${account.label} (${applied}).`, "info");
55
+ } catch (e) {
56
+ ctx.ui.notify(`Failed to switch account: ${errorUtil.format(e)}`, "error");
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,64 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { AccountSwitcher } from "@/runtime";
4
+
5
+ export const SET_SUBAGENT_ACCOUNT_TOOL = "set_subagent_account";
6
+
7
+ export const useSetSubagentAccountTool = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
8
+ pi.registerTool({
9
+ name: SET_SUBAGENT_ACCOUNT_TOOL,
10
+ label: "Set Subagent Account",
11
+ description:
12
+ "Set the account for spawned subagents. Does not affect the current session. " +
13
+ "By default (oneshot=true), the override applies only to the next subagent and is consumed. " +
14
+ "With oneshot=false, the override persists until cleared. " +
15
+ "Provide the account ID from list_accounts. Pass empty string to clear.",
16
+ promptSnippet: "Set the account for subagents",
17
+ promptGuidelines: [
18
+ "Use set_subagent_account before spawning a subagent when it needs a specific account.",
19
+ "With oneshot=true (default): the next subagent uses this account, then reverts.",
20
+ "With oneshot=false: all subsequent subagents use this account until cleared.",
21
+ "Use list_accounts first to discover available account IDs.",
22
+ ],
23
+ parameters: Type.Object({
24
+ id: Type.String({ description: "Account ID to use for subagents. Empty string to clear." }),
25
+ oneshot: Type.Optional(Type.Boolean({ default: true, description: "If true (default), applies only to the next subagent. If false, persists until cleared." })),
26
+ }),
27
+ execute: async (_toolCallId, params: { id: string; oneshot?: boolean }, _signal, _onUpdate, _ctx) => {
28
+ const isOneShot = params.oneshot !== false;
29
+
30
+ if (!params.id) {
31
+ delete process.env.PI_ACCOUNT_SWITCHER_NEXT_ID;
32
+ delete process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID;
33
+ return {
34
+ content: [{ type: "text", text: "Subagent override cleared. Subagents will inherit the parent's active account." }],
35
+ details: {},
36
+ };
37
+ }
38
+
39
+ const accounts = runtime.getAccounts();
40
+ const match = accounts.find((a) => a.id === params.id);
41
+ if (!match) {
42
+ return {
43
+ content: [{ type: "text", text: `Account not found: "${params.id}". Use the list_accounts tool to see available accounts.` }],
44
+ isError: true,
45
+ details: {},
46
+ };
47
+ }
48
+
49
+ if (isOneShot) {
50
+ process.env.PI_ACCOUNT_SWITCHER_NEXT_ID = params.id;
51
+ return {
52
+ content: [{ type: "text", text: `One-shot set to: ${match.label} (${params.id}). The next subagent will use this account, then revert.` }],
53
+ details: {},
54
+ };
55
+ }
56
+
57
+ process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID = params.id;
58
+ return {
59
+ content: [{ type: "text", text: `Persistent override set to: ${match.label} (${params.id}). All subagents will use this account until cleared.` }],
60
+ details: {},
61
+ };
62
+ },
63
+ });
64
+ };
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import type { AccountConfig, ProviderConfig, SecretSource } from "@/types";
2
+ import type { AccountConfig, PiAuthEntry, ProviderConfig, SecretSource } from "@/types";
3
3
  import { commonUtil, providerUtil, uiUtil } from "@/utils";
4
- import { ACCOUNTS_PATH } from "@/constants";
4
+ import { ACCOUNTS_PATH, OAUTH_PROVIDER_IDS } from "@/constants";
5
5
 
6
6
  export const SECRET_SOURCE_CHOICES = {
7
7
  literal: "Paste API key now (stored in config)",
@@ -25,6 +25,8 @@ export class AccountConfigBuilder {
25
25
  constructor(
26
26
  private readonly ui: ExtensionUIContext,
27
27
  private readonly customProviders: ProviderConfig[] = [],
28
+ private readonly piProviderIds: string[] = [],
29
+ private readonly getOAuthEntry?: (provider: string) => Promise<PiAuthEntry | undefined>,
28
30
  original?: Partial<AccountConfig>,
29
31
  ) {
30
32
  this.prompt = uiUtil.prompt(ui);
@@ -37,7 +39,7 @@ export class AccountConfigBuilder {
37
39
  }
38
40
 
39
41
  async withProvider(): Promise<this> {
40
- const choices = providerUtil.providerChoices(this.customProviders);
42
+ const choices = providerUtil.providerChoices(this.customProviders, this.piProviderIds);
41
43
  const choice = await uiUtil.filteredSelect(this.ui, "Provider", choices);
42
44
  if (!choice) return this;
43
45
 
@@ -65,14 +67,22 @@ export class AccountConfigBuilder {
65
67
  }
66
68
 
67
69
  async withId(): Promise<this> {
68
- const suggested = commonUtil.slugify(this.config.label ?? "");
69
- const hint = this.config.id ?? suggested;
70
- const id = (await this.prompt("Account id", hint).asText()) || hint;
71
- if (!id) {
72
- throw new Error("Account id is required");
70
+ // Auto-generate ID from label in add mode to reduce UX friction
71
+ if (!this.config.id) {
72
+ const suggested = commonUtil.slugify(this.config.label ?? "");
73
+ if (suggested) {
74
+ this.config.id = suggested;
75
+ return this;
76
+ }
77
+ // Fallback: prompt when label produces empty slug
78
+ const id = (await this.prompt("Account id", "account").asText()) || "account";
79
+ if (!id) {
80
+ throw new Error("Account id is required");
81
+ }
82
+ this.config.id = id;
83
+ return this;
73
84
  }
74
- this.config.id = id;
75
-
85
+ // Edit mode: keep existing id
76
86
  return this;
77
87
  }
78
88
 
@@ -122,6 +132,21 @@ export class AccountConfigBuilder {
122
132
  }
123
133
  }
124
134
 
135
+ // OAuth-capable provider: check if Pi has stored credentials
136
+ if (!hasExistingCredentials && this.getOAuthEntry && (OAUTH_PROVIDER_IDS as readonly string[]).includes(provider)) {
137
+ const entry = await this.getOAuthEntry(provider);
138
+ if (entry) {
139
+ const useOAuth = await this.ui.confirm(
140
+ "Import Pi OAuth?",
141
+ `Pi has OAuth credentials for ${provider}. Import and use them for this account?`,
142
+ );
143
+ if (useOAuth) {
144
+ this.config.piAuth = { provider, entry };
145
+ return this;
146
+ }
147
+ }
148
+ }
149
+
125
150
  const envKeys = providerUtil.requiredEnvKeysForProvider(provider, this.customProviders);
126
151
  const envChoice = await this.ui.select("Credential env var", [
127
152
  ...envKeys,