@hieplp/pi-account-switcher 0.2.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hieplp/pi-account-switcher",
3
- "version": "0.2.2",
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",
@@ -52,5 +52,6 @@
52
52
  "extensions": [
53
53
  "./src/extension.ts"
54
54
  ]
55
- }
55
+ },
56
+ "packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1"
56
57
  }
@@ -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);
@@ -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
  }
@@ -73,8 +73,14 @@ export abstract class BaseCommand implements Command {
73
73
  return providerUtil.normalizeProvider(active?.provider ?? "") === provider.id;
74
74
  }
75
75
 
76
- protected isActiveModel(ctx: AccountSwitcherContext, modelId: string): boolean {
77
- return ctx.model?.id === modelId;
76
+ protected isActiveModel(ctx: AccountSwitcherContext, modelId: string, provider?: string): boolean {
77
+ if (ctx.model?.id !== modelId) return false;
78
+ if (!provider) return true;
79
+ const providers = this.runtime.getProviders();
80
+ return (
81
+ providerUtil.normalizeProviderWithCustom(ctx.model.provider, providers) ===
82
+ providerUtil.normalizeProviderWithCustom(provider, providers)
83
+ );
78
84
  }
79
85
 
80
86
  abstract handler(ctx: AccountSwitcherContext, args?: string): Promise<void>;
@@ -2,7 +2,7 @@ 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 } from "@/utils";
5
+ import { errorUtil, providerUtil } from "@/utils";
6
6
  import { ModelCommand } from "./shared";
7
7
 
8
8
  export const useListModelsCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
@@ -18,9 +18,16 @@ class ListModelsCommand extends ModelCommand {
18
18
  try {
19
19
  await this.runtime.load();
20
20
 
21
- const provider = ctx.model?.provider;
21
+ const providers = this.runtime.getProviders();
22
+ const activeAccount = this.runtime.getActiveAccount();
23
+ const activeProvider =
24
+ activeAccount?.piAuth?.provider ??
25
+ (activeAccount
26
+ ? (providerUtil.findProvider(activeAccount.provider, providers)?.piAuthProvider ?? activeAccount.provider)
27
+ : undefined);
28
+ const provider = activeProvider ?? ctx.model?.provider;
22
29
  if (!provider) {
23
- ctx.ui.notify("No active model.", "info");
30
+ ctx.ui.notify("No active account or model.", "info");
24
31
  return;
25
32
  }
26
33
 
@@ -30,8 +37,11 @@ class ListModelsCommand extends ModelCommand {
30
37
  return;
31
38
  }
32
39
 
33
- const currentId = ctx.model?.id;
34
- const model = await this.pick(ctx, `Models (${provider})`, models, (m) =>
40
+ const normalizedProvider = providerUtil.normalizeProviderWithCustom(provider, providers);
41
+ const currentBelongsToProvider =
42
+ ctx.model && providerUtil.normalizeProviderWithCustom(ctx.model.provider, providers) === normalizedProvider;
43
+ const currentId = currentBelongsToProvider ? ctx.model?.id : undefined;
44
+ const model = await this.pick(ctx, `Models (${normalizedProvider})`, models, (m) =>
35
45
  m.id === currentId ? `${m.id} ✓` : m.id,
36
46
  );
37
47
  if (!model) return;
@@ -19,7 +19,7 @@ class RemoveModelCommand extends ModelCommand {
19
19
  const providerConfig = await this.loadProvider(ctx);
20
20
  if (!providerConfig) return;
21
21
 
22
- const removable = (providerConfig.models ?? []).filter((m) => !this.isActiveModel(ctx, m.id));
22
+ const removable = (providerConfig.models ?? []).filter((m) => !this.isActiveModel(ctx, m.id, providerConfig.id));
23
23
  if (removable.length === 0) {
24
24
  ctx.ui.notify(`Provider "${providerConfig.id}" has no removable model configs.`, "info");
25
25
  return;
@@ -13,13 +13,17 @@ export abstract class ModelCommand extends BaseCommand {
13
13
  protected async loadProvider(ctx: AccountSwitcherContext): Promise<ProviderConfig | undefined> {
14
14
  await this.runtime.load();
15
15
 
16
- const provider = ctx.model?.provider;
16
+ // Prefer the active account's provider so that switching to an account whose
17
+ // provider has no models yet still targets the correct (new) provider.
18
+ const activeAccount = this.runtime.getActiveAccount();
19
+ const providers = this.runtime.getProviders();
20
+ const provider = activeAccount?.provider ?? ctx.model?.provider;
17
21
  if (!provider) {
18
22
  ctx.ui.notify("No active model. Use models:list to select one first.", "info");
19
23
  return undefined;
20
24
  }
21
25
 
22
- const config = providerUtil.findProvider(provider, this.runtime.getProviders());
26
+ const config = providerUtil.findProvider(provider, providers);
23
27
  if (!config) {
24
28
  ctx.ui.notify(`"${provider}" is a built-in provider. This command only works with custom providers.`, "info");
25
29
  return undefined;
@@ -48,22 +48,22 @@ export class ProviderConfigBuilder {
48
48
 
49
49
  async withId(): Promise<this> {
50
50
  const raw = await this.prompt("Provider id", this.defaults.id).asText();
51
- if (!raw) throw new Error("Provider id is required");
52
- this.config.id = providerUtil.normalizeProvider(raw);
51
+ this.config.id = providerUtil.normalizeProvider(raw || this.defaults.id);
52
+ if (!this.config.id) throw new Error("Provider id is required");
53
53
  return this;
54
54
  }
55
55
 
56
56
  async withLabel(): Promise<this> {
57
57
  const id = this.config.id ?? "";
58
- this.config.label = await this.prompt("Provider label", this.defaults.label ?? id).asText();
58
+ const hint = this.defaults.label ?? id;
59
+ this.config.label = (await this.prompt("Provider label", hint).asText()) ?? hint;
59
60
  return this;
60
61
  }
61
62
 
62
63
  async withBaseUrl(): Promise<this> {
63
- this.config.baseUrl = await this.prompt(
64
- "Base URL (blank for account-only provider)",
65
- this.defaults.baseUrl,
66
- ).asText();
64
+ this.config.baseUrl =
65
+ (await this.prompt("Base URL (blank for account-only provider)", this.defaults.baseUrl).asText()) ??
66
+ this.defaults.baseUrl;
67
67
  return this;
68
68
  }
69
69
 
@@ -78,26 +78,23 @@ export class ProviderConfigBuilder {
78
78
 
79
79
  async withApiKey(): Promise<this> {
80
80
  const id = this.config.id ?? "";
81
- this.config.apiKey = await this.prompt(
82
- "Pi apiKey env var/name or raw key",
83
- this.defaults.apiKey ?? `${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`,
84
- ).asText();
81
+ const hint = this.defaults.apiKey ?? `${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
82
+ this.config.apiKey =
83
+ (await this.prompt("Pi apiKey env var/name or raw key", hint).asText()) ?? this.defaults.apiKey;
85
84
  return this;
86
85
  }
87
86
 
88
87
  async withEnvKeys(): Promise<this> {
89
88
  const { apiKey } = this.config;
90
- this.config.envKeys = await this.prompt(
91
- "Env key suggestions (comma-separated)",
92
- (this.defaults.envKeys ?? (apiKey ? [apiKey] : [DEFAULTS.envKey])).join(", "),
93
- ).asCsv();
89
+ const defaultKeys = this.defaults.envKeys ?? (apiKey ? [apiKey] : [DEFAULTS.envKey]);
90
+ const csv = await this.prompt("Env key suggestions (comma-separated)", defaultKeys.join(", ")).asCsv();
91
+ this.config.envKeys = csv.length > 0 ? csv : defaultKeys;
94
92
  return this;
95
93
  }
96
94
 
97
95
  async withAliases(): Promise<this> {
98
- this.config.aliases = (
99
- await this.prompt("Aliases (comma-separated, optional)", this.defaults.aliases.join(", ")).asCsv()
100
- ).map(providerUtil.normalizeProvider);
96
+ const csv = await this.prompt("Aliases (comma-separated, optional)", this.defaults.aliases.join(", ")).asCsv();
97
+ this.config.aliases = csv.length > 0 ? csv.map(providerUtil.normalizeProvider) : this.defaults.aliases;
101
98
  return this;
102
99
  }
103
100
 
@@ -109,15 +106,16 @@ export class ProviderConfigBuilder {
109
106
  if (rawModels?.some((item) => typeof item !== "object" || item === null || Array.isArray(item))) {
110
107
  throw new Error("models must be an array of objects");
111
108
  }
112
- this.config.models = rawModels as ProviderConfig["models"];
109
+ this.config.models = (rawModels as ProviderConfig["models"]) ?? this.defaults.models;
113
110
  return this;
114
111
  }
115
112
 
116
113
  async withCompat(): Promise<this> {
117
- this.config.compat = await this.prompt(
118
- "Compat JSON object (optional)",
119
- this.defaults.compat ? JSON.stringify(this.defaults.compat) : "",
120
- ).asJsonRecord("compat");
114
+ this.config.compat =
115
+ (await this.prompt(
116
+ "Compat JSON object (optional)",
117
+ this.defaults.compat ? JSON.stringify(this.defaults.compat) : "",
118
+ ).asJsonRecord("compat")) ?? this.defaults.compat;
121
119
  return this;
122
120
  }
123
121
 
@@ -127,7 +125,8 @@ export class ProviderConfigBuilder {
127
125
  "Configure Pi OAuth provider id?",
128
126
  "Only choose yes if this provider maps to a Pi /login auth entry.",
129
127
  ))
130
- ? await this.prompt("Pi auth provider id", this.defaults.piAuthProvider ?? id).asText()
128
+ ? ((await this.prompt("Pi auth provider id", this.defaults.piAuthProvider ?? id).asText()) ??
129
+ this.defaults.piAuthProvider)
131
130
  : this.defaults.piAuthProvider;
132
131
  return this;
133
132
  }
@@ -13,6 +13,10 @@ function resolveAuthProvider(account: AccountConfig, providers: ProviderConfig[]
13
13
  return provider?.piAuthProvider ?? providerUtil.normalizeProvider(account.provider);
14
14
  }
15
15
 
16
+ function resolveAccountProvider(account: AccountConfig, providers: ProviderConfig[]): string {
17
+ return providerUtil.normalizeProviderWithCustom(resolveAuthProvider(account, providers), providers);
18
+ }
19
+
16
20
  export default class AccountSwitcherRuntime implements AccountSwitcher {
17
21
  private accountService: AccountService;
18
22
  private modelService: ModelService;
@@ -49,8 +53,16 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
49
53
  // leave Pi's default model selection untouched.
50
54
  const modelState = this.accountService.getActiveModelState();
51
55
  if (modelState) {
52
- const model = ctx.modelRegistry.find(modelState.provider, modelState.id);
53
- if (model) await this.modelService.applyModel(model, ctx);
56
+ const providers = this.providerService.getProviders();
57
+ const activeProvider = active ? resolveAccountProvider(active, providers) : undefined;
58
+ const savedProvider = providerUtil.normalizeProviderWithCustom(modelState.provider, providers);
59
+
60
+ // Only restore a saved model when it belongs to the active account's provider.
61
+ // Otherwise Pi can start with credentials for one provider and a model from another.
62
+ if (!activeProvider || savedProvider === activeProvider) {
63
+ const model = ctx.modelRegistry.find(modelState.provider, modelState.id);
64
+ if (model) await this.modelService.applyModel(model, ctx);
65
+ }
54
66
  }
55
67
  }
56
68
 
@@ -94,10 +106,7 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
94
106
  findAccountsByProvider(provider: string): AccountConfig[] {
95
107
  const providers = this.providerService.getProviders();
96
108
  const normalized = providerUtil.normalizeProviderWithCustom(provider, providers);
97
- return this.accountService.getAccounts().filter((a) => {
98
- const accountProvider = providerUtil.normalizeProviderWithCustom(resolveAuthProvider(a, providers), providers);
99
- return accountProvider === normalized;
100
- });
109
+ return this.accountService.getAccounts().filter((a) => resolveAccountProvider(a, providers) === normalized);
101
110
  }
102
111
 
103
112
  getActiveAccount(): AccountConfig | undefined {
@@ -123,17 +132,14 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
123
132
 
124
133
  // piAuth accounts authenticate via a separate provider (e.g. github-copilot),
125
134
  // so use that for model lookup rather than the account's own provider field.
126
- const accountProvider = providerUtil.normalizeProviderWithCustom(
127
- resolveAuthProvider(account, providers),
128
- providers,
129
- );
135
+ const accountProvider = resolveAccountProvider(account, providers);
130
136
  const currentProvider = ctx.model
131
137
  ? providerUtil.normalizeProviderWithCustom(ctx.model.provider, providers)
132
138
  : undefined;
133
139
 
134
140
  // Skip model selection if the active model already belongs to the same provider.
135
141
  if (accountProvider !== currentProvider) {
136
- const model = await modelUtil.pickModel(ctx, account, providers);
142
+ const model = await modelUtil.pickModel(ctx, account, providers, accountProvider);
137
143
  if (model) await this.applyModel(model, ctx);
138
144
  }
139
145
 
@@ -1,7 +1,22 @@
1
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
2
- import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, type Focusable } from "@earendil-works/pi-tui";
1
+ import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, type Focusable, type Component } from "@earendil-works/pi-tui";
3
2
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
4
3
 
4
+ /**
5
+ * Local implementation of DynamicBorder to avoid a hard runtime dependency
6
+ * on @earendil-works/pi-coding-agent (which is a peer dep not available in
7
+ * the pi agent's npm node_modules at extension load time).
8
+ */
9
+ class DynamicBorder implements Component {
10
+ private color: (str: string) => string;
11
+ constructor(color: (str: string) => string = (str) => str) {
12
+ this.color = color;
13
+ }
14
+ invalidate() {}
15
+ render(width: number): string[] {
16
+ return [this.color("─".repeat(Math.max(1, width)))];
17
+ }
18
+ }
19
+
5
20
  type Theme = { fg: (color: ThemeColor, text: string) => string; bold: (text: string) => string };
6
21
 
7
22
  export class FilterableMultiSelectComponent extends Container implements Focusable {
@@ -10,8 +10,10 @@ export const modelUtil = {
10
10
  ctx: AccountSwitcherContext,
11
11
  account: AccountConfig,
12
12
  providers: ProviderConfig[],
13
+ resolvedProvider?: string,
13
14
  ): Promise<ProviderModel | undefined> => {
14
- const accountProvider = normalizeProvider(account.piAuth?.provider ?? account.provider, providers);
15
+ const accountProvider =
16
+ resolvedProvider ?? normalizeProvider(account.piAuth?.provider ?? account.provider, providers);
15
17
  const candidates = getProviderModels(ctx, providers, accountProvider);
16
18
 
17
19
  if (candidates.length === 0) {