@hieplp/pi-account-switcher 0.2.1 → 0.2.3

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.
Files changed (56) hide show
  1. package/README.md +32 -22
  2. package/package.json +12 -8
  3. package/src/commands/accounts/add.ts +1 -1
  4. package/src/commands/accounts/edit.ts +2 -2
  5. package/src/commands/accounts/index.ts +3 -1
  6. package/src/commands/accounts/list.ts +1 -1
  7. package/src/commands/accounts/oauth.ts +1 -1
  8. package/src/commands/accounts/remove.ts +1 -1
  9. package/src/commands/accounts/shared/base.ts +34 -3
  10. package/src/commands/accounts/shared/prompts.ts +36 -12
  11. package/src/commands/accounts/switch.ts +6 -3
  12. package/src/commands/accounts/verify.ts +298 -0
  13. package/src/commands/base.ts +11 -5
  14. package/src/commands/index.ts +1 -1
  15. package/src/commands/models/add.ts +1 -1
  16. package/src/commands/models/index.ts +1 -1
  17. package/src/commands/models/list.ts +16 -6
  18. package/src/commands/models/remove.ts +2 -2
  19. package/src/commands/models/shared/base.ts +7 -3
  20. package/src/commands/models/shared/prompts.ts +1 -1
  21. package/src/commands/models/shared/select.ts +1 -1
  22. package/src/commands/providers/add.ts +1 -1
  23. package/src/commands/providers/edit.ts +1 -1
  24. package/src/commands/providers/index.ts +1 -1
  25. package/src/commands/providers/list.ts +5 -2
  26. package/src/commands/providers/remove.ts +1 -1
  27. package/src/commands/providers/shared/base.ts +1 -1
  28. package/src/commands/providers/shared/prompts.ts +24 -25
  29. package/src/commands/providers/shared/select.ts +1 -1
  30. package/src/commands/system/export.ts +1 -2
  31. package/src/commands/system/import.ts +9 -3
  32. package/src/commands/system/index.ts +1 -1
  33. package/src/commands/system/reset.ts +1 -1
  34. package/src/constants/commands.ts +5 -0
  35. package/src/constants/env.ts +1 -0
  36. package/src/constants/index.ts +1 -0
  37. package/src/constants/paths.ts +1 -1
  38. package/src/extension.ts +2 -4
  39. package/src/index.ts +1 -1
  40. package/src/runtime/account-switcher-runtime.ts +23 -13
  41. package/src/runtime/account-switcher.ts +2 -1
  42. package/src/runtime/index.ts +2 -4
  43. package/src/schemas/accounts.ts +1 -2
  44. package/src/schemas/providers.ts +2 -6
  45. package/src/services/models.ts +2 -2
  46. package/src/services/providers.ts +3 -9
  47. package/src/types/context.ts +1 -1
  48. package/src/types/index.ts +1 -1
  49. package/src/utils/accounts.ts +2 -2
  50. package/src/utils/commands.ts +10 -0
  51. package/src/utils/common.ts +15 -0
  52. package/src/utils/errors.ts +1 -3
  53. package/src/utils/filterable-selector.ts +123 -3
  54. package/src/utils/index.ts +1 -0
  55. package/src/utils/models.ts +4 -2
  56. package/src/utils/ui.ts +36 -2
package/README.md CHANGED
@@ -48,45 +48,55 @@ After installing, reload Pi and add your first account:
48
48
  /accounts:add
49
49
  ```
50
50
 
51
+ ### Local development command prefix
52
+
53
+ If you have the npm package installed and also run a local checkout, set `PI_ACCOUNT_SWITCHER_COMMAND_PREFIX` before launching Pi to avoid command-name collisions:
54
+
55
+ ```bash
56
+ PI_ACCOUNT_SWITCHER_COMMAND_PREFIX=dev pi -e ./src/extension.ts
57
+ ```
58
+
59
+ The local commands will be registered as `/dev:accounts:list`, `/dev:accounts:add`, etc. The prefix may include the trailing colon (`dev:`) or omit it (`dev`).
60
+
51
61
  ---
52
62
 
53
63
  ## Commands
54
64
 
55
65
  ### Accounts
56
66
 
57
- | Command | Description |
58
- |---|---|
59
- | `/accounts:add` | Add a new account interactively |
60
- | `/accounts:list` | List all accounts and activate the selected one |
61
- | `/accounts:switch` | Switch to another account within the current provider |
62
- | `/accounts:edit` | Edit label, provider, id, or credential source |
63
- | `/accounts:remove` | Delete an account |
64
- | `/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: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 |
65
75
 
66
76
  ### Providers
67
77
 
68
- | Command | Description |
69
- |---|---|
70
- | `/providers:add` | Add a reusable custom provider |
71
- | `/providers:list` | List custom providers |
72
- | `/providers:edit` | Edit a custom provider |
78
+ | Command | Description |
79
+ | ------------------- | -------------------------------- |
80
+ | `/providers:add` | Add a reusable custom provider |
81
+ | `/providers:list` | List custom providers |
82
+ | `/providers:edit` | Edit a custom provider |
73
83
  | `/providers:remove` | Remove an unused custom provider |
74
84
 
75
85
  ### Models
76
86
 
77
- | Command | Description |
78
- |---|---|
79
- | `/models:list` | List all available models and switch to the selected one |
80
- | `/models:add` | Add a custom model config to the current provider |
81
- | `/models:remove` | Remove a custom model config |
87
+ | Command | Description |
88
+ | ---------------- | -------------------------------------------------------- |
89
+ | `/models:list` | List all available models and switch to the selected one |
90
+ | `/models:add` | Add a custom model config to the current provider |
91
+ | `/models:remove` | Remove a custom model config |
82
92
 
83
93
  ### System
84
94
 
85
- | Command | Description |
86
- |---|---|
87
- | `/system:reset` | Delete all accounts, providers, and state |
95
+ | Command | Description |
96
+ | ---------------- | -------------------------------------------------------- |
97
+ | `/system:reset` | Delete all accounts, providers, and state |
88
98
  | `/system:export` | Export all accounts, providers, and state to a JSON file |
89
- | `/system:import` | Import accounts, providers, and state from a JSON file |
99
+ | `/system:import` | Import accounts, providers, and state from a JSON file |
90
100
 
91
101
  ---
92
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hieplp/pi-account-switcher",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Pi extension for quickly switching between multiple accounts/API keys per provider.",
6
6
  "license": "MIT",
@@ -27,21 +27,24 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "typecheck": "tsc --noEmit",
30
- "test": "vitest run"
30
+ "test": "vitest run",
31
+ "format": "prettier --write .",
32
+ "format:check": "prettier --check ."
31
33
  },
32
34
  "dependencies": {
35
+ "@earendil-works/pi-tui": "^0.74.0",
33
36
  "@mariozechner/jiti": "^2.6.5",
34
- "@mariozechner/pi-tui": "^0.73.1",
35
37
  "zod": "^4.4.3"
36
38
  },
37
39
  "peerDependencies": {
38
- "@mariozechner/pi-ai": "*",
39
- "@mariozechner/pi-coding-agent": "*"
40
+ "@earendil-works/pi-ai": "*",
41
+ "@earendil-works/pi-coding-agent": "*"
40
42
  },
41
43
  "devDependencies": {
42
- "@mariozechner/pi-ai": "latest",
43
- "@mariozechner/pi-coding-agent": "latest",
44
+ "@earendil-works/pi-ai": "latest",
45
+ "@earendil-works/pi-coding-agent": "latest",
44
46
  "@types/node": "latest",
47
+ "prettier": "^3.8.3",
45
48
  "typescript": "latest",
46
49
  "vitest": "latest"
47
50
  },
@@ -49,5 +52,6 @@
49
52
  "extensions": [
50
53
  "./src/extension.ts"
51
54
  ]
52
- }
55
+ },
56
+ "packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1"
53
57
  }
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import type { AccountConfig, AccountSwitcherContext } from "@/types";
4
4
  import { COMMANDS } from "@/constants";
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import type { AccountSwitcherContext } from "@/types";
4
4
  import { COMMANDS } from "@/constants";
@@ -19,7 +19,7 @@ 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();
22
+ const updated = await new AccountConfigBuilder(ctx.ui, this.runtime.getProviders(), original).collect(true);
23
23
  if (!updated) return;
24
24
 
25
25
  await this.runtime.editAccount(original, updated);
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ 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";
@@ -6,6 +6,7 @@ import { useListAccountsCommand } from "./list";
6
6
  import { useOAuthImportCommand } from "./oauth";
7
7
  import { useRemoveAccountCommand } from "./remove";
8
8
  import { useSwitchAccountCommand } from "./switch";
9
+ import { useVerifyAccountsCommand } from "./verify";
9
10
 
10
11
  const useAccountCommands = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
11
12
  useAddAccountCommand(pi, runtime);
@@ -14,6 +15,7 @@ const useAccountCommands = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
14
15
  useOAuthImportCommand(pi, runtime);
15
16
  useRemoveAccountCommand(pi, runtime);
16
17
  useSwitchAccountCommand(pi, runtime);
18
+ useVerifyAccountsCommand(pi, runtime);
17
19
  };
18
20
 
19
21
  export default useAccountCommands;
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import type { AccountSwitcherContext } from "@/types";
4
4
  import { COMMANDS } from "@/constants";
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import type { AccountConfig, AccountSwitcherContext } from "@/types";
4
4
  import { COMMANDS, OAUTH_PROVIDER_IDS, PI_AUTH_PATH } from "@/constants";
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import type { AccountSwitcherContext } from "@/types";
4
4
  import { COMMANDS } from "@/constants";
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import type { AccountConfig, AccountSwitcherContext } from "@/types";
4
4
  import { uiUtil } from "@/utils";
@@ -69,6 +69,31 @@ export abstract class AccountCommand extends BaseCommand {
69
69
  accounts: AccountConfig[],
70
70
  label = "Pick account",
71
71
  ): Promise<AccountConfig | undefined> {
72
+ const { labels, values } = this.buildGroupedAccountSelectItems(accounts, true);
73
+ return this.pickGrouped(ctx, label, labels, values);
74
+ }
75
+
76
+ protected async pickGroupedAccounts(
77
+ ctx: AccountSwitcherContext,
78
+ accounts: AccountConfig[],
79
+ label = "Pick accounts",
80
+ ): Promise<AccountConfig[] | undefined> {
81
+ const activeId = this.runtime.getActiveAccount()?.id;
82
+ const { labels, values } = this.buildGroupedAccountSelectItems(accounts, false);
83
+ const firstAccountIndex = values.findIndex((value) => value !== null);
84
+ const initialChecked = values.map((value, index) =>
85
+ activeId ? value?.id === activeId : firstAccountIndex !== -1 && index === firstAccountIndex,
86
+ );
87
+ return uiUtil.multiGroupedSelect(ctx.ui, label, labels, values, initialChecked);
88
+ }
89
+
90
+ private buildGroupedAccountSelectItems(
91
+ accounts: AccountConfig[],
92
+ includeActiveMarker: boolean,
93
+ ): {
94
+ labels: string[];
95
+ values: Array<AccountConfig | null>;
96
+ } {
72
97
  const items = buildGroupedItems(accounts, this.runtime.getProviders(), this.runtime.getActiveAccount()?.id);
73
98
 
74
99
  const labels: string[] = [];
@@ -79,10 +104,16 @@ export abstract class AccountCommand extends BaseCommand {
79
104
  values.push(null);
80
105
  continue;
81
106
  }
82
- labels.push(formatAccountItem(item));
107
+ labels.push(includeActiveMarker ? formatAccountItem(item) : this.formatMultiAccountItem(item));
83
108
  values.push(item.account);
84
109
  }
85
110
 
86
- return this.pickGrouped(ctx, label, labels, values);
111
+ return { labels, values };
112
+ }
113
+
114
+ private formatMultiAccountItem(
115
+ item: Extract<ReturnType<typeof buildGroupedItems>[number], { type: "account" }>,
116
+ ): string {
117
+ return item.active ? `${item.account.label} (active)` : item.account.label;
87
118
  }
88
119
  }
@@ -1,4 +1,4 @@
1
- import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountConfig, ProviderConfig, SecretSource } from "@/types";
3
3
  import { commonUtil, providerUtil, uiUtil } from "@/utils";
4
4
  import { ACCOUNTS_PATH } from "@/constants";
@@ -37,12 +37,14 @@ export class AccountConfigBuilder {
37
37
  }
38
38
 
39
39
  async withProvider(): Promise<this> {
40
- const choice = await uiUtil.filteredSelect(this.ui, "Provider", providerUtil.providerChoices(this.customProviders));
40
+ const choices = providerUtil.providerChoices(this.customProviders);
41
+ const choice = await uiUtil.filteredSelect(this.ui, "Provider", choices);
41
42
  if (!choice) return this;
42
43
 
43
44
  const raw = choice === "custom" ? await this.prompt("Custom provider", "provider-id").asText() : choice;
44
45
  const provider = providerUtil.normalizeProvider(raw ?? "");
45
- if (!provider) throw new Error("Provider is required");
46
+ // If provider is empty (user cancelled), return early. collect() will detect missing provider and return undefined.
47
+ if (!provider) return this;
46
48
 
47
49
  this.config.provider = provider;
48
50
  this.customProvider = providerUtil.findProvider(provider, this.customProviders);
@@ -101,21 +103,32 @@ export class AccountConfigBuilder {
101
103
  const { provider } = this.config;
102
104
  if (!provider) return this;
103
105
 
106
+ const hasExistingCredentials =
107
+ !!this.config.env || !!this.config.providerApiKey || !!this.config.usesProviderApiKey || !!this.config.piAuth;
108
+
104
109
  if (this.customProvider) {
105
110
  const apiKey = await this.promptForCustomProviderApiKey(this.customProvider);
106
- if (apiKey || this.customProvider.apiKey) {
107
- if (apiKey) {
108
- this.config.providerApiKey = apiKey;
109
- } else {
111
+ if (apiKey) {
112
+ this.config.providerApiKey = apiKey;
113
+ return this;
114
+ }
115
+ if (!hasExistingCredentials) {
116
+ if (this.customProvider.apiKey) {
110
117
  this.config.usesProviderApiKey = true;
118
+ return this;
111
119
  }
112
- return this;
120
+ } else {
121
+ // Has existing credentials — fall through to let user choose "keep current" or update
113
122
  }
114
123
  }
115
124
 
116
125
  const envKeys = providerUtil.requiredEnvKeysForProvider(provider, this.customProviders);
117
- const envChoice = await this.ui.select("Credential env var", [...envKeys, "custom"]);
118
- if (!envChoice) return this;
126
+ const envChoice = await this.ui.select("Credential env var", [
127
+ ...envKeys,
128
+ "custom",
129
+ ...(hasExistingCredentials ? ["keep current"] : []),
130
+ ]);
131
+ if (!envChoice || envChoice === "keep current") return this;
119
132
 
120
133
  const envName = envChoice === "custom" ? await this.prompt("Env var name", "PROVIDER_API_KEY").asText() : envChoice;
121
134
  if (!envName) throw new Error("Env var name is required");
@@ -150,7 +163,12 @@ export class AccountConfigBuilder {
150
163
  };
151
164
  }
152
165
 
153
- async collect(): Promise<AccountConfig | undefined> {
166
+ /**
167
+ * Collect account configuration interactively.
168
+ * @param isEdit - When true, allows keeping existing credentials without re-entering them.
169
+ * Empty input for most fields will preserve the current value.
170
+ */
171
+ async collect(isEdit = false): Promise<AccountConfig | undefined> {
154
172
  await this.withProvider();
155
173
  if (!this.config.provider) return undefined;
156
174
 
@@ -159,7 +177,13 @@ export class AccountConfigBuilder {
159
177
  await this.withModel();
160
178
  await this.withCredentials();
161
179
 
162
- if (!this.config.env && !this.config.providerApiKey && !this.config.usesProviderApiKey && !this.config.piAuth) {
180
+ if (
181
+ !isEdit &&
182
+ !this.config.env &&
183
+ !this.config.providerApiKey &&
184
+ !this.config.usesProviderApiKey &&
185
+ !this.config.piAuth
186
+ ) {
163
187
  this.ui.notify("No credentials configured. Account not saved.", "info");
164
188
  return undefined;
165
189
  }
@@ -1,8 +1,8 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import type { AccountSwitcherContext } from "@/types";
4
4
  import { COMMANDS } from "@/constants";
5
- import { errorUtil, providerUtil } from "@/utils";
5
+ import { commandUtil, errorUtil, providerUtil } from "@/utils";
6
6
  import { AccountCommand } from "./shared";
7
7
 
8
8
  export const useSwitchAccountCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
@@ -20,7 +20,10 @@ class SwitchAccountCommand extends AccountCommand {
20
20
 
21
21
  const active = this.runtime.getActiveAccount();
22
22
  if (!active) {
23
- ctx.ui.notify("No active account. Use accounts:list to activate one first.", "info");
23
+ ctx.ui.notify(
24
+ `No active account. Use ${commandUtil.name(COMMANDS.accounts.list.name)} to activate one first.`,
25
+ "info",
26
+ );
24
27
  return;
25
28
  }
26
29
 
@@ -0,0 +1,298 @@
1
+ import { completeSimple, type Api, type Model } from "@earendil-works/pi-ai";
2
+ import type { AuthCredential, ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { AccountSwitcher } from "@/runtime";
4
+ import type { AccountConfig, AccountSwitcherContext, ProviderConfig, SecretSource } from "@/types";
5
+ import { COMMANDS } from "@/constants";
6
+ import { accountUtil, commonUtil, errorUtil, providerUtil, uiUtil } from "@/utils";
7
+ import { AccountCommand } from "./shared";
8
+
9
+ export const useVerifyAccountsCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
10
+ new VerifyAccountsCommand(pi, runtime).register();
11
+ };
12
+
13
+ type VerifyTestPlan = {
14
+ secrets: boolean;
15
+ ping: boolean;
16
+ };
17
+
18
+ type CheckResult = {
19
+ ok: boolean;
20
+ line: string;
21
+ };
22
+
23
+ type AccountVerifyReport = {
24
+ account: AccountConfig;
25
+ ok: boolean;
26
+ lines: string[];
27
+ };
28
+
29
+ const TEST_OPTIONS = {
30
+ secrets: "Verify secrets",
31
+ ping: "Send ping",
32
+ } as const;
33
+
34
+ const MAX_PARALLEL_VERIFY = 3;
35
+
36
+ class VerifyAccountsCommand extends AccountCommand {
37
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
38
+ super(pi, runtime, COMMANDS.accounts.verify);
39
+ }
40
+
41
+ async handler(ctx: AccountSwitcherContext, args?: string): Promise<void> {
42
+ try {
43
+ const accounts = await this.loadAccounts(ctx);
44
+ if (!accounts) return;
45
+
46
+ const parts = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
47
+ const verifyAll = parts.includes("all");
48
+ const pingArg = parts.includes("ping") || parts.includes("probe") || parts.includes("connect");
49
+ const targets = verifyAll
50
+ ? accounts
51
+ : await this.pickGroupedAccounts(ctx, accounts, pingArg ? "Verify and ping accounts" : "Verify accounts");
52
+
53
+ if (!targets || targets.length === 0) return;
54
+
55
+ const plan = pingArg ? { secrets: true, ping: true } : await this.pickTestPlan(ctx);
56
+ if (!plan) return;
57
+
58
+ const selectedTests = this.formatSelectedTests(plan);
59
+ ctx.ui.notify(
60
+ `Testing ${targets.length} account${targets.length === 1 ? "" : "s"} (${selectedTests})...`,
61
+ "info",
62
+ );
63
+
64
+ const concurrency = plan.ping ? 1 : MAX_PARALLEL_VERIFY;
65
+ const reports = await commonUtil.runWithConcurrency(targets, concurrency, (account) =>
66
+ this.verifyAccount(ctx, account, plan),
67
+ );
68
+ const failed = reports.filter((report) => !report.ok).length;
69
+
70
+ ctx.ui.setEditorText(this.formatReport(plan, reports));
71
+ ctx.ui.notify(
72
+ `Finished testing ${targets.length} account${targets.length === 1 ? "" : "s"}${
73
+ failed ? ` — ${failed} failed` : " — all checks passed"
74
+ }.`,
75
+ failed ? "warning" : "info",
76
+ );
77
+ } catch (error) {
78
+ ctx.ui.notify(`accounts:verify failed: ${errorUtil.format(error)}`, "error");
79
+ }
80
+ }
81
+
82
+ private async pickTestPlan(ctx: AccountSwitcherContext): Promise<VerifyTestPlan | undefined> {
83
+ const selected = await uiUtil.multiSelect(
84
+ ctx.ui,
85
+ "What should be tested?",
86
+ [TEST_OPTIONS.secrets, TEST_OPTIONS.ping],
87
+ [true, false],
88
+ );
89
+
90
+ if (!selected) return undefined;
91
+
92
+ const plan = {
93
+ secrets: selected.includes(TEST_OPTIONS.secrets),
94
+ ping: selected.includes(TEST_OPTIONS.ping),
95
+ };
96
+
97
+ if (!plan.secrets && !plan.ping) {
98
+ ctx.ui.notify("No tests selected.", "info");
99
+ return undefined;
100
+ }
101
+
102
+ return plan;
103
+ }
104
+
105
+ private async verifyAccount(
106
+ ctx: AccountSwitcherContext,
107
+ account: AccountConfig,
108
+ plan: VerifyTestPlan,
109
+ ): Promise<AccountVerifyReport> {
110
+ const lines: string[] = [];
111
+ let ok = true;
112
+
113
+ if (plan.secrets) {
114
+ let anyChecked = false;
115
+
116
+ if (account.piAuth) {
117
+ lines.push("✓ secrets: using stored OAuth/piAuth credentials");
118
+ }
119
+
120
+ if (account.usesProviderApiKey && !account.providerApiKey) {
121
+ lines.push("✓ secrets: using provider config apiKey");
122
+ }
123
+
124
+ const secretChecks: Array<[string, SecretSource]> = [];
125
+ if (account.providerApiKey) secretChecks.push(["providerApiKey", account.providerApiKey]);
126
+ if (!account.piAuth && account.env) {
127
+ for (const [envName, source] of Object.entries(account.env)) secretChecks.push([envName, source]);
128
+ }
129
+
130
+ if (secretChecks.length > 0) {
131
+ const results = await Promise.all(secretChecks.map(([key, source]) => this.verifySecret(key, source)));
132
+ for (const result of results) {
133
+ lines.push(result.line);
134
+ ok &&= result.ok;
135
+ }
136
+ } else if (!account.piAuth && !account.usesProviderApiKey) {
137
+ lines.push("ℹ secrets: no secrets configured");
138
+ }
139
+ }
140
+
141
+ if (plan.ping) {
142
+ const result = await this.pingAccount(ctx, account);
143
+ lines.push(result.line);
144
+ ok &&= result.ok;
145
+ }
146
+
147
+ return { account, ok, lines };
148
+ }
149
+
150
+ private async pingAccount(ctx: AccountSwitcherContext, account: AccountConfig): Promise<CheckResult> {
151
+ const prefix = `[${account.label}]`;
152
+ const authProvider = this.resolveAuthProvider(account);
153
+ const model = this.resolveProbeModel(ctx, account, authProvider);
154
+ if (!model) {
155
+ return { ok: false, line: `✗ ping: skipped — no model found for provider ${authProvider}` };
156
+ }
157
+
158
+ const envBackup = new Map<string, string | undefined>();
159
+ let authBackup: AuthCredential | undefined;
160
+ let hadAuth = false;
161
+ let providerToRestore: ProviderConfig | undefined;
162
+
163
+ let requestAuth!: Awaited<ReturnType<typeof ctx.modelRegistry.getApiKeyAndHeaders>>;
164
+ try {
165
+ if (!account.piAuth && account.env) {
166
+ const resolved = await accountUtil.resolveAccountEnv(account);
167
+ for (const [envName, value] of resolved) {
168
+ envBackup.set(envName, process.env[envName]);
169
+ process.env[envName] = value;
170
+ }
171
+ }
172
+
173
+ if (account.piAuth) {
174
+ hadAuth = ctx.modelRegistry.authStorage.has(authProvider);
175
+ authBackup = ctx.modelRegistry.authStorage.get(authProvider);
176
+ ctx.modelRegistry.authStorage.set(authProvider, account.piAuth.entry);
177
+ ctx.modelRegistry.authStorage.reload();
178
+ }
179
+
180
+ if (account.providerApiKey) {
181
+ const provider = providerUtil.findProvider(account.provider, this.runtime.getProviders());
182
+ if (provider) {
183
+ providerToRestore = provider;
184
+ const apiKey = await accountUtil.resolveSecret(account.providerApiKey);
185
+ if (!apiKey) throw new Error("Resolved empty providerApiKey for ping");
186
+ this.runtime.registerProvider({ ...provider, apiKey });
187
+ }
188
+ }
189
+
190
+ requestAuth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
191
+ if (!requestAuth.ok) throw new Error(requestAuth.error);
192
+ } catch (err) {
193
+ return { ok: false, line: `✗ ping: unable to prepare credentials — ${errorUtil.format(err)}` };
194
+ } finally {
195
+ for (const [envName, previous] of envBackup) {
196
+ if (previous === undefined) delete process.env[envName];
197
+ else process.env[envName] = previous;
198
+ }
199
+ if (account.piAuth) {
200
+ if (hadAuth && authBackup) ctx.modelRegistry.authStorage.set(authProvider, authBackup);
201
+ else ctx.modelRegistry.authStorage.remove(authProvider);
202
+ ctx.modelRegistry.authStorage.reload();
203
+ }
204
+ if (providerToRestore) {
205
+ this.runtime.registerProvider(providerToRestore);
206
+ }
207
+ }
208
+
209
+ try {
210
+ ctx.ui.notify(`${prefix} ping: sending request via ${model.provider}/${model.id}...`, "info");
211
+
212
+ const response = await completeSimple(
213
+ model,
214
+ {
215
+ systemPrompt: "You are a health-check endpoint. Follow the user instruction exactly.",
216
+ messages: [
217
+ {
218
+ role: "user",
219
+ content: "Health check: reply with exactly OK.",
220
+ timestamp: Date.now(),
221
+ },
222
+ ],
223
+ },
224
+ {
225
+ apiKey: requestAuth.apiKey,
226
+ headers: requestAuth.headers,
227
+ maxTokens: 16,
228
+ timeoutMs: 30_000,
229
+ maxRetries: 0,
230
+ reasoning: "minimal",
231
+ },
232
+ );
233
+
234
+ if (response.stopReason === "error") throw new Error(response.errorMessage ?? "model returned an error");
235
+ const text = response.content.find((block) => block.type === "text")?.text?.trim();
236
+ return { ok: true, line: `✓ ping: OK via ${model.provider}/${model.id}${text ? ` — ${text}` : ""}` };
237
+ } catch (err) {
238
+ return { ok: false, line: `✗ ping: failed — ${errorUtil.format(err)}` };
239
+ }
240
+ }
241
+
242
+ private resolveAuthProvider(account: AccountConfig): string {
243
+ if (account.piAuth?.provider) return account.piAuth.provider;
244
+ const provider = providerUtil.findProvider(account.provider, this.runtime.getProviders());
245
+ return provider?.piAuthProvider ?? providerUtil.normalizeProvider(account.provider);
246
+ }
247
+
248
+ private resolveProbeModel(
249
+ ctx: AccountSwitcherContext,
250
+ account: AccountConfig,
251
+ authProvider: string,
252
+ ): Model<Api> | undefined {
253
+ const providers = this.runtime.getProviders();
254
+ const normalized = providerUtil.normalizeProviderWithCustom(authProvider, providers);
255
+ if (account.model) {
256
+ const configured = ctx.modelRegistry.find(authProvider, account.model);
257
+ if (configured) return configured;
258
+ }
259
+ if (ctx.model && providerUtil.normalizeProviderWithCustom(ctx.model.provider, providers) === normalized) {
260
+ return ctx.model;
261
+ }
262
+ return ctx.modelRegistry
263
+ .getAll()
264
+ .find((model) => providerUtil.normalizeProviderWithCustom(model.provider, providers) === normalized);
265
+ }
266
+
267
+ private async verifySecret(key: string, source: SecretSource): Promise<CheckResult> {
268
+ try {
269
+ const value = await accountUtil.resolveSecret(source);
270
+ if (!value) throw new Error("resolved to empty value");
271
+ return { ok: true, line: `✓ secrets: ${key} OK` };
272
+ } catch (err) {
273
+ return { ok: false, line: `✗ secrets: ${key} failed — ${errorUtil.format(err)}` };
274
+ }
275
+ }
276
+
277
+ private formatReport(plan: VerifyTestPlan, reports: AccountVerifyReport[]): string {
278
+ const selectedTests = this.formatSelectedTests(plan);
279
+ const passed = reports.filter((report) => report.ok).length;
280
+
281
+ return [
282
+ "Account verify results",
283
+ "",
284
+ `Tests: ${selectedTests}`,
285
+ `Accounts: ${reports.length} (${passed} passed, ${reports.length - passed} failed)`,
286
+ "",
287
+ ...reports.flatMap((report) => [
288
+ `${report.ok ? "✓" : "✗"} ${report.account.label}`,
289
+ ...report.lines.map((line) => ` ${line}`),
290
+ "",
291
+ ]),
292
+ ].join("\n");
293
+ }
294
+
295
+ private formatSelectedTests(plan: VerifyTestPlan): string {
296
+ return [plan.secrets ? "secrets" : undefined, plan.ping ? "ping" : undefined].filter(Boolean).join(" + ");
297
+ }
298
+ }