@hieplp/pi-account-switcher 0.2.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.
Files changed (77) hide show
  1. package/INSTALL_AS_PI_PACKAGE.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +214 -0
  4. package/USAGE.md +446 -0
  5. package/package.json +53 -0
  6. package/src/commands/accounts/add.ts +63 -0
  7. package/src/commands/accounts/edit.ts +31 -0
  8. package/src/commands/accounts/index.ts +19 -0
  9. package/src/commands/accounts/list.ts +31 -0
  10. package/src/commands/accounts/oauth.ts +59 -0
  11. package/src/commands/accounts/remove.ts +40 -0
  12. package/src/commands/accounts/shared/base.ts +88 -0
  13. package/src/commands/accounts/shared/index.ts +3 -0
  14. package/src/commands/accounts/shared/prompts.ts +226 -0
  15. package/src/commands/accounts/shared/select.ts +37 -0
  16. package/src/commands/accounts/switch.ts +54 -0
  17. package/src/commands/base.ts +81 -0
  18. package/src/commands/index.ts +16 -0
  19. package/src/commands/models/add.ts +30 -0
  20. package/src/commands/models/index.ts +13 -0
  21. package/src/commands/models/list.ts +45 -0
  22. package/src/commands/models/remove.ts +41 -0
  23. package/src/commands/models/shared/base.ts +44 -0
  24. package/src/commands/models/shared/index.ts +3 -0
  25. package/src/commands/models/shared/prompts.ts +36 -0
  26. package/src/commands/models/shared/select.ts +37 -0
  27. package/src/commands/providers/add.ts +28 -0
  28. package/src/commands/providers/edit.ts +29 -0
  29. package/src/commands/providers/index.ts +15 -0
  30. package/src/commands/providers/list.ts +38 -0
  31. package/src/commands/providers/remove.ts +46 -0
  32. package/src/commands/providers/shared/base.ts +30 -0
  33. package/src/commands/providers/shared/index.ts +3 -0
  34. package/src/commands/providers/shared/prompts.ts +172 -0
  35. package/src/commands/providers/shared/select.ts +24 -0
  36. package/src/commands/system/index.ts +7 -0
  37. package/src/commands/system/reset.ts +36 -0
  38. package/src/constants/commands.ts +66 -0
  39. package/src/constants/config.ts +6 -0
  40. package/src/constants/index.ts +4 -0
  41. package/src/constants/paths.ts +8 -0
  42. package/src/constants/providers.ts +36 -0
  43. package/src/extension.ts +21 -0
  44. package/src/index.ts +20 -0
  45. package/src/runtime/account-switcher-runtime.ts +194 -0
  46. package/src/runtime/account-switcher.ts +32 -0
  47. package/src/runtime/index.ts +11 -0
  48. package/src/schemas/accounts.ts +50 -0
  49. package/src/schemas/common.ts +3 -0
  50. package/src/schemas/config.ts +7 -0
  51. package/src/schemas/index.ts +4 -0
  52. package/src/schemas/providers.ts +57 -0
  53. package/src/services/accounts.ts +116 -0
  54. package/src/services/index.ts +4 -0
  55. package/src/services/models.ts +27 -0
  56. package/src/services/pi-auth.ts +23 -0
  57. package/src/services/providers.ts +123 -0
  58. package/src/storage/accounts.ts +109 -0
  59. package/src/storage/index.ts +4 -0
  60. package/src/storage/paths.ts +8 -0
  61. package/src/storage/pi-auth.ts +37 -0
  62. package/src/storage/providers.ts +85 -0
  63. package/src/storage/state.ts +43 -0
  64. package/src/types/accounts.ts +37 -0
  65. package/src/types/config.ts +6 -0
  66. package/src/types/context.ts +3 -0
  67. package/src/types/index.ts +4 -0
  68. package/src/types/providers.ts +53 -0
  69. package/src/utils/accounts.ts +99 -0
  70. package/src/utils/common.ts +74 -0
  71. package/src/utils/errors.ts +16 -0
  72. package/src/utils/files.ts +25 -0
  73. package/src/utils/filterable-selector.ts +114 -0
  74. package/src/utils/index.ts +7 -0
  75. package/src/utils/models.ts +76 -0
  76. package/src/utils/providers.ts +49 -0
  77. package/src/utils/ui.ts +49 -0
@@ -0,0 +1,40 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ 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";
7
+
8
+ export const useRemoveAccountCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
9
+ new RemoveAccountCommand(pi, runtime).register();
10
+ };
11
+
12
+ class RemoveAccountCommand extends AccountCommand {
13
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
14
+ super(pi, runtime, COMMANDS.accounts.remove);
15
+ }
16
+
17
+ async handler(ctx: AccountSwitcherContext): Promise<void> {
18
+ try {
19
+ const accounts = await this.loadAccounts(ctx);
20
+ if (!accounts) return;
21
+
22
+ const removable = accounts.filter((a) => !this.isActiveAccount(a));
23
+ if (removable.length === 0) {
24
+ ctx.ui.notify("No removable accounts. Switch to another account first.", "info");
25
+ return;
26
+ }
27
+
28
+ const account = await this.pickGroupedAccount(ctx, removable, "Select account to remove");
29
+ if (!account) return;
30
+
31
+ const confirmed = await ctx.ui.confirm("Remove account?", `"${account.label}" will be permanently removed.`);
32
+ if (!confirmed) return;
33
+
34
+ await this.runtime.removeAccount(account);
35
+ ctx.ui.notify(`Account "${account.label}" removed.`, "info");
36
+ } catch (e) {
37
+ ctx.ui.notify(`Failed to remove account: ${errorUtil.format(e)}`, "error");
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,88 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountConfig, AccountSwitcherContext } from "@/types";
4
+ import { uiUtil } from "@/utils";
5
+ import { BaseCommand, type CommandMeta } from "../../base";
6
+ import { buildGroupedItems, formatAccountItem } from "./select";
7
+
8
+ const DUPLICATE_ID_OPTIONS = {
9
+ replace: "Replace existing account",
10
+ newId: "Enter a new id",
11
+ cancel: "Cancel",
12
+ } as const;
13
+
14
+ export abstract class AccountCommand extends BaseCommand {
15
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher, meta: CommandMeta) {
16
+ super(pi, runtime, meta);
17
+ }
18
+
19
+ protected async loadAccounts(ctx: AccountSwitcherContext): Promise<AccountConfig[] | undefined> {
20
+ await this.runtime.load();
21
+ const accounts = this.runtime.getAccounts();
22
+ if (accounts.length === 0) {
23
+ ctx.ui.notify("No accounts configured.", "info");
24
+ return undefined;
25
+ }
26
+ return accounts;
27
+ }
28
+
29
+ protected async loadAndSelectAccount(ctx: AccountSwitcherContext, label: string): Promise<AccountConfig | undefined> {
30
+ const accounts = await this.loadAccounts(ctx);
31
+ if (!accounts) return undefined;
32
+ return this.pickGroupedAccount(ctx, accounts, label);
33
+ }
34
+
35
+ protected async saveAccount(ctx: AccountSwitcherContext, account: AccountConfig): Promise<AccountConfig | undefined> {
36
+ let candidate = account;
37
+ while (true) {
38
+ const existing = this.runtime.findAccountById(candidate.id);
39
+ if (!existing) {
40
+ await this.runtime.addAccount(candidate);
41
+ return candidate;
42
+ }
43
+
44
+ const action = await ctx.ui.select(`Account id already exists: ${candidate.id}`, [
45
+ DUPLICATE_ID_OPTIONS.replace,
46
+ DUPLICATE_ID_OPTIONS.newId,
47
+ DUPLICATE_ID_OPTIONS.cancel,
48
+ ]);
49
+
50
+ if (DUPLICATE_ID_OPTIONS.replace === action) {
51
+ await this.runtime.editAccount(existing, candidate);
52
+ return candidate;
53
+ }
54
+
55
+ if (DUPLICATE_ID_OPTIONS.newId === action) {
56
+ const prompt = uiUtil.prompt(ctx.ui);
57
+ const nextId = await prompt("New account id", `${candidate.id}-2`).asText();
58
+ if (!nextId) return undefined;
59
+ candidate = { ...candidate, id: nextId };
60
+ continue;
61
+ }
62
+
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ protected async pickGroupedAccount(
68
+ ctx: AccountSwitcherContext,
69
+ accounts: AccountConfig[],
70
+ label = "Pick account",
71
+ ): Promise<AccountConfig | undefined> {
72
+ const items = buildGroupedItems(accounts, this.runtime.getProviders(), this.runtime.getActiveAccount()?.id);
73
+
74
+ const labels: string[] = [];
75
+ const values: Array<AccountConfig | null> = [];
76
+ for (const item of items) {
77
+ if (item.type === "header") {
78
+ labels.push(item.provider);
79
+ values.push(null);
80
+ continue;
81
+ }
82
+ labels.push(formatAccountItem(item));
83
+ values.push(item.account);
84
+ }
85
+
86
+ return this.pickGrouped(ctx, label, labels, values);
87
+ }
88
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./prompts";
2
+ export * from "./base";
3
+ export * from "./select";
@@ -0,0 +1,226 @@
1
+ import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountConfig, ProviderConfig, SecretSource } from "@/types";
3
+ import { commonUtil, providerUtil, uiUtil } from "@/utils";
4
+ import { ACCOUNTS_PATH } from "@/constants";
5
+
6
+ export const SECRET_SOURCE_CHOICES = {
7
+ literal: "Paste API key now (stored in config)",
8
+ env: "Read from existing environment variable",
9
+ file: "Read from file",
10
+ command: "Run shell command",
11
+ op: "1Password op reference",
12
+ } as const;
13
+
14
+ const SECRET_SOURCE_CHOICE_LABELS = Object.values(SECRET_SOURCE_CHOICES);
15
+
16
+ const LABEL_TO_SOURCE_TYPE = new Map(
17
+ (Object.entries(SECRET_SOURCE_CHOICES) as [keyof typeof SECRET_SOURCE_CHOICES, string][]).map(([k, v]) => [v, k]),
18
+ );
19
+
20
+ export class AccountConfigBuilder {
21
+ private readonly prompt: ReturnType<typeof uiUtil.prompt>;
22
+ private config: Partial<AccountConfig> = {};
23
+ private customProvider?: ProviderConfig;
24
+
25
+ constructor(
26
+ private readonly ui: ExtensionUIContext,
27
+ private readonly customProviders: ProviderConfig[] = [],
28
+ original?: Partial<AccountConfig>,
29
+ ) {
30
+ this.prompt = uiUtil.prompt(ui);
31
+ if (original) {
32
+ this.config = { ...original };
33
+ if (original.provider) {
34
+ this.customProvider = providerUtil.findProvider(original.provider, customProviders);
35
+ }
36
+ }
37
+ }
38
+
39
+ async withProvider(): Promise<this> {
40
+ const choice = await uiUtil.filteredSelect(this.ui, "Provider", providerUtil.providerChoices(this.customProviders));
41
+ if (!choice) return this;
42
+
43
+ const raw = choice === "custom" ? await this.prompt("Custom provider", "provider-id").asText() : choice;
44
+ const provider = providerUtil.normalizeProvider(raw ?? "");
45
+ if (!provider) throw new Error("Provider is required");
46
+
47
+ this.config.provider = provider;
48
+ this.customProvider = providerUtil.findProvider(provider, this.customProviders);
49
+
50
+ return this;
51
+ }
52
+
53
+ async withLabel(): Promise<this> {
54
+ const hint = this.config.label ?? `${this.config.provider ?? ""} — Work`;
55
+ const label = await this.prompt("Account label", hint).asText();
56
+ if (!label) {
57
+ if (!this.config.label) throw new Error("Account label is required");
58
+ return this;
59
+ }
60
+ this.config.label = label;
61
+
62
+ return this;
63
+ }
64
+
65
+ async withId(): Promise<this> {
66
+ const suggested = commonUtil.slugify(this.config.label ?? "");
67
+ const hint = this.config.id ?? suggested;
68
+ const id = (await this.prompt("Account id", hint).asText()) || hint;
69
+ if (!id) {
70
+ throw new Error("Account id is required");
71
+ }
72
+ this.config.id = id;
73
+
74
+ return this;
75
+ }
76
+
77
+ async withModel(): Promise<this> {
78
+ if (!this.customProvider) {
79
+ return this;
80
+ }
81
+
82
+ const modelIds = (this.customProvider.models ?? []).map((m) => m.id);
83
+ if (modelIds.length === 0) {
84
+ this.config.model = await this.prompt("Default model for this account (optional)", this.config.model).asText();
85
+ return this;
86
+ }
87
+
88
+ const choice = await uiUtil.filteredSelect(this.ui, "Default model for this account", [
89
+ "Use current model",
90
+ ...modelIds,
91
+ "custom",
92
+ ]);
93
+ if (!choice || choice === "Use current model") return this;
94
+
95
+ this.config.model = choice === "custom" ? await this.prompt("Model id", modelIds[0]).asText() : choice;
96
+
97
+ return this;
98
+ }
99
+
100
+ async withCredentials(): Promise<this> {
101
+ const { provider } = this.config;
102
+ if (!provider) return this;
103
+
104
+ if (this.customProvider) {
105
+ const apiKey = await this.promptForCustomProviderApiKey(this.customProvider);
106
+ if (apiKey || this.customProvider.apiKey) {
107
+ if (apiKey) {
108
+ this.config.providerApiKey = apiKey;
109
+ } else {
110
+ this.config.usesProviderApiKey = true;
111
+ }
112
+ return this;
113
+ }
114
+ }
115
+
116
+ const envKeys = providerUtil.requiredEnvKeysForProvider(provider, this.customProviders);
117
+ const envChoice = await this.ui.select("Credential env var", [...envKeys, "custom"]);
118
+ if (!envChoice) return this;
119
+
120
+ const envName = envChoice === "custom" ? await this.prompt("Env var name", "PROVIDER_API_KEY").asText() : envChoice;
121
+ if (!envName) throw new Error("Env var name is required");
122
+
123
+ const sourceLabel = await this.ui.select("How should Pi load this credential?", SECRET_SOURCE_CHOICE_LABELS);
124
+ if (!sourceLabel) return this;
125
+
126
+ const sourceType = LABEL_TO_SOURCE_TYPE.get(sourceLabel);
127
+ if (!sourceType) return this;
128
+
129
+ const source = await this.promptForSecretSource(sourceType);
130
+ if (source) {
131
+ this.config.env = { ...this.config.env, [envName]: source };
132
+ }
133
+
134
+ return this;
135
+ }
136
+
137
+ build(): AccountConfig {
138
+ const { id, label, provider } = this.config;
139
+ if (!id || !label || !provider) throw new Error("Account id, label, and provider are required");
140
+
141
+ return {
142
+ id,
143
+ label,
144
+ provider,
145
+ ...(this.config.model ? { model: this.config.model } : {}),
146
+ ...(this.config.env ? { env: this.config.env } : {}),
147
+ ...(this.config.providerApiKey ? { providerApiKey: this.config.providerApiKey } : {}),
148
+ ...(this.config.usesProviderApiKey ? { usesProviderApiKey: true } : {}),
149
+ ...(this.config.piAuth ? { piAuth: this.config.piAuth } : {}),
150
+ };
151
+ }
152
+
153
+ async collect(): Promise<AccountConfig | undefined> {
154
+ await this.withProvider();
155
+ if (!this.config.provider) return undefined;
156
+
157
+ await this.withLabel();
158
+ await this.withId();
159
+ await this.withModel();
160
+ await this.withCredentials();
161
+
162
+ if (!this.config.env && !this.config.providerApiKey && !this.config.usesProviderApiKey && !this.config.piAuth) {
163
+ this.ui.notify("No credentials configured. Account not saved.", "info");
164
+ return undefined;
165
+ }
166
+
167
+ return this.build();
168
+ }
169
+
170
+ private async promptForSecretSource(type: keyof typeof SECRET_SOURCE_CHOICES): Promise<SecretSource | undefined> {
171
+ switch (type) {
172
+ case "literal": {
173
+ const ok = await this.ui.confirm(
174
+ "Store API key in config?",
175
+ `This will write the API key to ${ACCOUNTS_PATH} as plain text. Continue?`,
176
+ );
177
+ if (!ok) return undefined;
178
+
179
+ const value = await this.prompt("API key", "sk-...").asText();
180
+ if (!value) throw new Error("API key is required");
181
+
182
+ return { type: "literal", value };
183
+ }
184
+ case "env": {
185
+ const name = await this.prompt("Source environment variable", "MY_API_KEY").asText();
186
+ if (!name) throw new Error("Source environment variable is required");
187
+
188
+ return { type: "env", name };
189
+ }
190
+ case "file": {
191
+ const path = await this.prompt("Secret file path", "~/.keys/provider-account.txt").asText();
192
+ if (!path) throw new Error("File path is required");
193
+
194
+ return { type: "file", path };
195
+ }
196
+ case "command": {
197
+ const command = await this.prompt("Command", "op read op://AI/Account/api-key").asText();
198
+ if (!command) throw new Error("Command is required");
199
+
200
+ return { type: "command", command };
201
+ }
202
+ case "op": {
203
+ const reference = await this.prompt("1Password reference", "op://AI/Account/api-key").asText();
204
+ if (!reference) throw new Error("1Password reference is required");
205
+
206
+ return { type: "op", reference };
207
+ }
208
+ }
209
+ }
210
+
211
+ private async promptForCustomProviderApiKey(provider: ProviderConfig): Promise<SecretSource | undefined> {
212
+ if (!provider.baseUrl && !provider.models?.length && !provider.apiKey) {
213
+ return undefined;
214
+ }
215
+
216
+ const hint = provider.apiKey ? "blank = provider apiKey" : "sk-...";
217
+ const value = await this.prompt("Account API key override (blank uses provider apiKey)", hint).asText();
218
+ if (!value) return undefined;
219
+
220
+ const ok = await this.ui.confirm(
221
+ "Store API key in account config?",
222
+ `This will write the API key to ${ACCOUNTS_PATH} as plain text. Continue?`,
223
+ );
224
+ return ok ? { type: "literal", value } : undefined;
225
+ }
226
+ }
@@ -0,0 +1,37 @@
1
+ import type { AccountConfig, ProviderConfig } from "@/types";
2
+ import { providerUtil } from "@/utils";
3
+
4
+ export type GroupedAccountItem =
5
+ | { type: "header"; provider: string }
6
+ | { type: "account"; account: AccountConfig; provider: string; active: boolean };
7
+
8
+ export function buildGroupedItems(
9
+ accounts: AccountConfig[],
10
+ providers: ProviderConfig[],
11
+ activeId: string | undefined,
12
+ ): GroupedAccountItem[] {
13
+ const sorted = [...accounts].sort((a, b) => {
14
+ const pa = providerUtil.normalizeProviderWithCustom(a.provider, providers);
15
+ const pb = providerUtil.normalizeProviderWithCustom(b.provider, providers);
16
+ return pa.localeCompare(pb) || a.label.localeCompare(b.label);
17
+ });
18
+
19
+ const items: GroupedAccountItem[] = [];
20
+ let previousProvider: string | undefined;
21
+
22
+ for (const account of sorted) {
23
+ const provider = providerUtil.normalizeProviderWithCustom(account.provider, providers);
24
+ if (previousProvider !== provider) {
25
+ items.push({ type: "header", provider });
26
+ previousProvider = provider;
27
+ }
28
+ items.push({ type: "account", account, provider, active: account.id === activeId });
29
+ }
30
+
31
+ return items;
32
+ }
33
+
34
+ export function formatAccountItem(item: Extract<GroupedAccountItem, { type: "account" }>): string {
35
+ const marker = item.active ? "✓" : "·";
36
+ return ` ${marker} ${item.account.label}`;
37
+ }
@@ -0,0 +1,54 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountSwitcherContext } from "@/types";
4
+ import { COMMANDS } from "@/constants";
5
+ import { errorUtil, providerUtil } from "@/utils";
6
+ import { AccountCommand } from "./shared";
7
+
8
+ export const useSwitchAccountCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
9
+ new SwitchAccountCommand(pi, runtime).register();
10
+ };
11
+
12
+ class SwitchAccountCommand extends AccountCommand {
13
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
14
+ super(pi, runtime, COMMANDS.accounts.switch);
15
+ }
16
+
17
+ async handler(ctx: AccountSwitcherContext): Promise<void> {
18
+ try {
19
+ await this.runtime.load();
20
+
21
+ const active = this.runtime.getActiveAccount();
22
+ if (!active) {
23
+ ctx.ui.notify("No active account. Use accounts:list to activate one first.", "info");
24
+ return;
25
+ }
26
+
27
+ const providers = this.runtime.getProviders();
28
+ const normalizedActive = providerUtil.normalizeProviderWithCustom(
29
+ active.piAuth?.provider ?? active.provider,
30
+ providers,
31
+ );
32
+ const peers = this.runtime
33
+ .getAccounts()
34
+ .filter(
35
+ (a) =>
36
+ providerUtil.normalizeProviderWithCustom(a.piAuth?.provider ?? a.provider, providers) ===
37
+ normalizedActive && a.id !== active.id,
38
+ );
39
+
40
+ if (peers.length === 0) {
41
+ ctx.ui.notify(`No other accounts for provider "${active.provider}".`, "info");
42
+ return;
43
+ }
44
+
45
+ const account = await this.pickGroupedAccount(ctx, peers, `Switch account (${active.provider})`);
46
+ if (!account) return;
47
+
48
+ const applied = await this.runtime.activateAccount(account, ctx);
49
+ ctx.ui.notify(`Switched to ${account.label} (${applied}).`, "info");
50
+ } catch (e) {
51
+ ctx.ui.notify(`Failed to switch account: ${errorUtil.format(e)}`, "error");
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,81 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountConfig, AccountSwitcherContext, ProviderConfig } from "@/types";
4
+ import { providerUtil, uiUtil } from "@/utils";
5
+
6
+ function deduplicateLabels(labels: string[]): string[] {
7
+ const seen = new Map<string, number>();
8
+ return labels.map((label) => {
9
+ const n = (seen.get(label) ?? 0) + 1;
10
+ seen.set(label, n);
11
+ return n > 1 ? `${label} (${n})` : label;
12
+ });
13
+ }
14
+
15
+ export interface CommandMeta {
16
+ readonly name: string;
17
+ readonly description: string;
18
+ }
19
+
20
+ export interface Command extends CommandMeta {
21
+ register(): void;
22
+ handler(ctx: AccountSwitcherContext): Promise<void>;
23
+ }
24
+
25
+ export abstract class BaseCommand implements Command {
26
+ readonly name: string;
27
+ readonly description: string;
28
+
29
+ constructor(
30
+ protected readonly pi: ExtensionAPI,
31
+ protected readonly runtime: AccountSwitcher,
32
+ meta: CommandMeta,
33
+ ) {
34
+ this.name = meta.name;
35
+ this.description = meta.description;
36
+ }
37
+
38
+ register(): void {
39
+ this.pi.registerCommand(this.name, {
40
+ description: this.description,
41
+ handler: (_, ctx) => this.handler(ctx),
42
+ });
43
+ }
44
+
45
+ protected async pickGrouped<T>(
46
+ ctx: AccountSwitcherContext,
47
+ label: string,
48
+ labels: string[],
49
+ values: Array<T | null>,
50
+ ): Promise<T | undefined> {
51
+ return uiUtil.filteredGroupedSelect(ctx.ui, label, labels, values);
52
+ }
53
+
54
+ protected async pick<T>(
55
+ ctx: AccountSwitcherContext,
56
+ label: string,
57
+ items: T[],
58
+ format: (item: T) => string,
59
+ ): Promise<T | undefined> {
60
+ const raw = items.map(format);
61
+ const labels = deduplicateLabels(raw);
62
+ const selected = await uiUtil.filteredSelect(ctx.ui, label, labels);
63
+ if (!selected) return undefined;
64
+ return items[labels.indexOf(selected)];
65
+ }
66
+
67
+ protected isActiveAccount(account: AccountConfig): boolean {
68
+ return this.runtime.getActiveAccount()?.id === account.id;
69
+ }
70
+
71
+ protected isActiveProvider(provider: ProviderConfig): boolean {
72
+ const active = this.runtime.getActiveAccount();
73
+ return providerUtil.normalizeProvider(active?.provider ?? "") === provider.id;
74
+ }
75
+
76
+ protected isActiveModel(ctx: AccountSwitcherContext, modelId: string): boolean {
77
+ return ctx.model?.id === modelId;
78
+ }
79
+
80
+ abstract handler(ctx: AccountSwitcherContext): Promise<void>;
81
+ }
@@ -0,0 +1,16 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import useProviderCommands from "./providers";
4
+ import useAccountCommands from "./accounts";
5
+ import useModelCommands from "./models";
6
+ import useSystemCommands from "./system";
7
+
8
+ export type { Command, CommandMeta } from "./base";
9
+ export { BaseCommand } from "./base";
10
+
11
+ export function registerAllCommands(pi: ExtensionAPI, runtime: AccountSwitcher) {
12
+ useAccountCommands(pi, runtime);
13
+ useProviderCommands(pi, runtime);
14
+ useModelCommands(pi, runtime);
15
+ useSystemCommands(pi, runtime);
16
+ }
@@ -0,0 +1,30 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountSwitcherContext } from "@/types";
4
+ import { COMMANDS } from "@/constants";
5
+ import { errorUtil } from "@/utils";
6
+ import { ModelCommand, ModelConfigBuilder } from "./shared";
7
+
8
+ export const useAddModelCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
9
+ new AddModelCommand(pi, runtime).register();
10
+ };
11
+
12
+ class AddModelCommand extends ModelCommand {
13
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
14
+ super(pi, runtime, COMMANDS.models.add);
15
+ }
16
+
17
+ async handler(ctx: AccountSwitcherContext): Promise<void> {
18
+ try {
19
+ const providerConfig = await this.loadProvider(ctx);
20
+ if (!providerConfig) return;
21
+
22
+ const model = await new ModelConfigBuilder(ctx.ui).collect();
23
+ const updated = { ...providerConfig, models: [...(providerConfig.models ?? []), model] };
24
+ await this.runtime.editProvider(providerConfig, updated);
25
+ ctx.ui.notify(`Added model "${model.id}" to provider "${providerConfig.id}".`, "info");
26
+ } catch (e) {
27
+ ctx.ui.notify(`Failed to add model: ${errorUtil.format(e)}`, "error");
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,13 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import { useListModelsCommand } from "./list";
4
+ import { useAddModelCommand } from "./add";
5
+ import { useRemoveModelCommand } from "./remove";
6
+
7
+ const useModelCommands = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
8
+ useListModelsCommand(pi, runtime);
9
+ useAddModelCommand(pi, runtime);
10
+ useRemoveModelCommand(pi, runtime);
11
+ };
12
+
13
+ export default useModelCommands;
@@ -0,0 +1,45 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountSwitcherContext } from "@/types";
4
+ import { COMMANDS } from "@/constants";
5
+ import { errorUtil } from "@/utils";
6
+ import { ModelCommand } from "./shared";
7
+
8
+ export const useListModelsCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
9
+ new ListModelsCommand(pi, runtime).register();
10
+ };
11
+
12
+ class ListModelsCommand extends ModelCommand {
13
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
14
+ super(pi, runtime, COMMANDS.models.list);
15
+ }
16
+
17
+ async handler(ctx: AccountSwitcherContext): Promise<void> {
18
+ try {
19
+ await this.runtime.load();
20
+
21
+ const provider = ctx.model?.provider;
22
+ if (!provider) {
23
+ ctx.ui.notify("No active model.", "info");
24
+ return;
25
+ }
26
+
27
+ const models = this.getModels(ctx, provider);
28
+ if (models.length === 0) {
29
+ ctx.ui.notify(`No models available for provider "${provider}".`, "info");
30
+ return;
31
+ }
32
+
33
+ const currentId = ctx.model?.id;
34
+ const model = await this.pick(ctx, `Models (${provider})`, models, (m) =>
35
+ m.id === currentId ? `${m.id} ✓` : m.id,
36
+ );
37
+ if (!model) return;
38
+
39
+ await this.runtime.applyModel(model, ctx);
40
+ ctx.ui.notify(`Switched to ${model.id}.`, "info");
41
+ } catch (e) {
42
+ ctx.ui.notify(`Failed to list models: ${errorUtil.format(e)}`, "error");
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,41 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountSwitcherContext } from "@/types";
4
+ import { COMMANDS } from "@/constants";
5
+ import { errorUtil } from "@/utils";
6
+ import { ModelCommand } from "./shared";
7
+
8
+ export const useRemoveModelCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
9
+ new RemoveModelCommand(pi, runtime).register();
10
+ };
11
+
12
+ class RemoveModelCommand extends ModelCommand {
13
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
14
+ super(pi, runtime, COMMANDS.models.remove);
15
+ }
16
+
17
+ async handler(ctx: AccountSwitcherContext): Promise<void> {
18
+ try {
19
+ const providerConfig = await this.loadProvider(ctx);
20
+ if (!providerConfig) return;
21
+
22
+ const removable = (providerConfig.models ?? []).filter((m) => !this.isActiveModel(ctx, m.id));
23
+ if (removable.length === 0) {
24
+ ctx.ui.notify(`Provider "${providerConfig.id}" has no removable model configs.`, "info");
25
+ return;
26
+ }
27
+
28
+ const model = await this.pick(ctx, "Remove model", removable, (m) => m.name ?? m.id);
29
+ if (!model) return;
30
+
31
+ const confirmed = await ctx.ui.confirm("Remove model?", `Remove "${model.id}" from "${providerConfig.id}"?`);
32
+ if (!confirmed) return;
33
+
34
+ const updated = { ...providerConfig, models: (providerConfig.models ?? []).filter((m) => m.id !== model.id) };
35
+ await this.runtime.editProvider(providerConfig, updated);
36
+ ctx.ui.notify(`Removed model "${model.id}" from provider "${providerConfig.id}".`, "info");
37
+ } catch (e) {
38
+ ctx.ui.notify(`Failed to remove model: ${errorUtil.format(e)}`, "error");
39
+ }
40
+ }
41
+ }