@hieplp/pi-account-switcher 0.2.3 → 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.
@@ -0,0 +1,50 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountSwitcherContext } from "@/types";
4
+ import { errorUtil } from "@/utils";
5
+ import { AccountCommand } from "./shared";
6
+
7
+ export const useSubagentAccountCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
8
+ new SubagentAccountCommand(pi, runtime).register();
9
+ };
10
+
11
+ class SubagentAccountCommand extends AccountCommand {
12
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
13
+ super(pi, runtime, {
14
+ name: "accounts:subagent",
15
+ description: "Set the account to use for the next spawned subagent (one-shot or persistent)",
16
+ });
17
+ }
18
+
19
+ async handler(ctx: AccountSwitcherContext, args?: string): Promise<void> {
20
+ try {
21
+ await this.runtime.load();
22
+ const accounts = this.runtime.getAccounts();
23
+
24
+ if (accounts.length === 0) {
25
+ ctx.ui.notify("No accounts configured.", "info");
26
+ return;
27
+ }
28
+
29
+ // Interactive: pick an account
30
+ const account = await this.pickGroupedAccount(ctx, accounts, "Account for subagent");
31
+ if (!account) return;
32
+
33
+ // Ask about oneshot (default: yes)
34
+ const oneshot = await ctx.ui.confirm(
35
+ "Apply to next subagent only?",
36
+ "Yes = one-shot (next subagent only). No = persistent (all subagents until changed).",
37
+ );
38
+
39
+ const varName = oneshot !== false ? "PI_ACCOUNT_SWITCHER_NEXT_ID" : "PI_ACCOUNT_SWITCHER_ACTIVE_ID";
40
+ process.env[varName] = account.id;
41
+
42
+ ctx.ui.notify(
43
+ `Subagent account set to: ${account.label} (${oneshot !== false ? "next subagent only" : "persistent"}).`,
44
+ "info",
45
+ );
46
+ } catch (e) {
47
+ ctx.ui.notify(`Failed to set subagent account: ${errorUtil.format(e)}`, "error");
48
+ }
49
+ }
50
+ }
@@ -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 { commandUtil, errorUtil, providerUtil } from "@/utils";
5
+ import { errorUtil } from "@/utils";
6
6
  import { AccountCommand } from "./shared";
7
7
 
8
8
  export const useSwitchAccountCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
@@ -14,38 +14,30 @@ class SwitchAccountCommand extends AccountCommand {
14
14
  super(pi, runtime, COMMANDS.accounts.switch);
15
15
  }
16
16
 
17
- async handler(ctx: AccountSwitcherContext): Promise<void> {
17
+ async handler(ctx: AccountSwitcherContext, args?: string): Promise<void> {
18
18
  try {
19
19
  await this.runtime.load();
20
20
 
21
- const active = this.runtime.getActiveAccount();
22
- if (!active) {
23
- ctx.ui.notify(
24
- `No active account. Use ${commandUtil.name(COMMANDS.accounts.list.name)} to activate one first.`,
25
- "info",
26
- );
21
+ // With args: activate by ID directly (agent-facing, any provider)
22
+ if (args) {
23
+ const account = this.runtime.getAccounts().find((a) => a.id === args.trim());
24
+ if (!account) {
25
+ ctx.ui.notify(`Account not found: "${args.trim()}". Use the list_accounts tool to see available accounts.`, "error");
26
+ return;
27
+ }
28
+ const applied = await this.runtime.activateAccount(account, ctx);
29
+ ctx.ui.notify(`Switched to ${account.label} (${applied}).`, "info");
27
30
  return;
28
31
  }
29
32
 
30
- const providers = this.runtime.getProviders();
31
- const normalizedActive = providerUtil.normalizeProviderWithCustom(
32
- active.piAuth?.provider ?? active.provider,
33
- providers,
34
- );
35
- const peers = this.runtime
36
- .getAccounts()
37
- .filter(
38
- (a) =>
39
- providerUtil.normalizeProviderWithCustom(a.piAuth?.provider ?? a.provider, providers) ===
40
- normalizedActive && a.id !== active.id,
41
- );
42
-
43
- if (peers.length === 0) {
44
- ctx.ui.notify(`No other accounts for provider "${active.provider}".`, "info");
33
+ // Without args: interactive picker from all accounts
34
+ const accounts = this.runtime.getAccounts();
35
+ if (accounts.length === 0) {
36
+ ctx.ui.notify("No accounts configured. Use accounts:add to create one.", "info");
45
37
  return;
46
38
  }
47
39
 
48
- const account = await this.pickGroupedAccount(ctx, peers, `Switch account (${active.provider})`);
40
+ const account = await this.pickGroupedAccount(ctx, accounts, "Pick account to activate");
49
41
  if (!account) return;
50
42
 
51
43
  const applied = await this.runtime.activateAccount(account, ctx);
@@ -1,4 +1,4 @@
1
- import { completeSimple, type Api, type Model } from "@earendil-works/pi-ai";
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { AuthCredential, ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import type { AccountSwitcher } from "@/runtime";
4
4
  import type { AccountConfig, AccountSwitcherContext, ProviderConfig, SecretSource } from "@/types";
@@ -209,6 +209,7 @@ class VerifyAccountsCommand extends AccountCommand {
209
209
  try {
210
210
  ctx.ui.notify(`${prefix} ping: sending request via ${model.provider}/${model.id}...`, "info");
211
211
 
212
+ const { completeSimple } = await import("@earendil-works/pi-ai");
212
213
  const response = await completeSimple(
213
214
  model,
214
215
  {
@@ -0,0 +1,176 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountConfig, AccountSwitcherContext } from "@/types";
4
+ import { COMMANDS } from "@/constants";
5
+ import { BaseCommand } from "../base";
6
+ import { buildGroupedItems } from "../accounts/shared/select";
7
+ import { homedir } from "node:os";
8
+ import { join, dirname } from "node:path";
9
+ import type { Dirent } from "node:fs";
10
+
11
+ export const useDirsCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
12
+ new DirsCommand(pi, runtime).register();
13
+ };
14
+
15
+ class DirsCommand extends BaseCommand {
16
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
17
+ super(pi, runtime, COMMANDS.accounts.dirs);
18
+ }
19
+
20
+ async handler(ctx: AccountSwitcherContext): Promise<void> {
21
+ await this.runtime.load();
22
+ const accounts = this.runtime.getAccounts();
23
+ if (accounts.length === 0) {
24
+ ctx.ui.notify("No accounts configured.", "info");
25
+ return;
26
+ }
27
+
28
+ // Build entry options: auto-save if active account + CWD both exist
29
+ const activeAccount = this.runtime.getActiveAccount();
30
+ const canAutoSave = Boolean(ctx.cwd && activeAccount);
31
+ const entryOptions = canAutoSave
32
+ ? ["Auto-save current folder", "Select an account to configure"]
33
+ : ["Select an account to configure"];
34
+
35
+ const entry = await ctx.ui.select("Directory auto-select", entryOptions);
36
+ if (!entry) return;
37
+
38
+ if (entry === "Auto-save current folder") {
39
+ await this.autoSave(ctx, activeAccount!);
40
+ return;
41
+ }
42
+
43
+ // Manual: pick account → add/remove
44
+ await this.manualConfig(ctx, accounts);
45
+ }
46
+
47
+ private async autoSave(ctx: AccountSwitcherContext, account: AccountConfig): Promise<void> {
48
+ const cwd = ctx.cwd!;
49
+ const resolved = cwd;
50
+
51
+ // Check if already present
52
+ const current = this.runtime.getAccounts().find((a) => a.id === account.id) ?? account;
53
+ const dirs = current.dirs ?? [];
54
+
55
+ if (dirs.includes(resolved)) {
56
+ ctx.ui.notify(`Directory already configured for ${current.label}.`, "info");
57
+ return;
58
+ }
59
+
60
+ const updated: AccountConfig = { ...current, dirs: [...dirs, resolved] };
61
+ await this.runtime.editAccount(current, updated);
62
+ ctx.ui.notify(`Added dir: ${resolved}`, "info");
63
+ }
64
+
65
+ private async manualConfig(ctx: AccountSwitcherContext, accounts: AccountConfig[]): Promise<void> {
66
+ const account = await this.pickAccount(ctx, accounts);
67
+ if (!account) return;
68
+
69
+ await this.manageDirs(ctx, account);
70
+ }
71
+
72
+ private async pickAccount(
73
+ ctx: AccountSwitcherContext,
74
+ accounts: AccountConfig[],
75
+ ): Promise<AccountConfig | undefined> {
76
+ const items = buildGroupedItems(accounts, this.runtime.getProviders(), this.runtime.getActiveAccount()?.id);
77
+ const labels: string[] = [];
78
+ const values: Array<AccountConfig | null> = [];
79
+ for (const item of items) {
80
+ if (item.type === "header") {
81
+ labels.push(item.provider);
82
+ values.push(null);
83
+ continue;
84
+ }
85
+ labels.push(` ${this.isActiveAccount(item.account) ? `${item.account.label} (active)` : item.account.label}`);
86
+ values.push(item.account);
87
+ }
88
+ return this.pickGrouped(ctx, "Pick account to configure dirs", labels, values);
89
+ }
90
+
91
+ private async manageDirs(ctx: AccountSwitcherContext, account: AccountConfig): Promise<void> {
92
+ const current = this.runtime.getAccounts().find((a) => a.id === account.id) ?? account;
93
+ const dirs = current.dirs ?? [];
94
+ const dirsDisplay = dirs.length > 0 ? dirs.join(", ") : "(none)";
95
+ ctx.ui.notify(`Account: ${current.label} | Dirs: ${dirsDisplay}`, "info");
96
+
97
+ const options = ["Add directory", "Remove directory"];
98
+ const action = await ctx.ui.select("Directory auto-select", options);
99
+ if (!action) return;
100
+
101
+ if (action === "Add directory") {
102
+ const picked = await this.pickDirectory(ctx, homedir());
103
+ if (!picked) return;
104
+ const resolved = picked;
105
+ if (dirs.includes(resolved)) {
106
+ ctx.ui.notify(`Directory already configured for ${current.label}.`, "info");
107
+ return;
108
+ }
109
+ const updated: AccountConfig = { ...current, dirs: [...dirs, resolved] };
110
+ await this.runtime.editAccount(current, updated);
111
+ ctx.ui.notify(`Added dir: ${resolved}`, "info");
112
+ return;
113
+ }
114
+
115
+ if (action === "Remove directory") {
116
+ if (dirs.length === 0) {
117
+ ctx.ui.notify("No directories configured.", "info");
118
+ return;
119
+ }
120
+ const selected = await ctx.ui.select("Remove directory", dirs);
121
+ if (!selected) return;
122
+
123
+ const updatedDirs = dirs.filter((d) => d !== selected);
124
+ const updated: AccountConfig = { ...current, dirs: updatedDirs.length > 0 ? updatedDirs : undefined };
125
+ await this.runtime.editAccount(current, updated);
126
+ ctx.ui.notify(`Removed dir: ${selected}`, "info");
127
+ return;
128
+ }
129
+ }
130
+
131
+ private async pickDirectory(ctx: AccountSwitcherContext, startPath: string): Promise<string | undefined> {
132
+ let currentPath = startPath;
133
+ while (true) {
134
+ const entries = this.listDirectories(currentPath);
135
+ const displayPath = currentPath.replace(homedir(), "~");
136
+
137
+ const options: Array<{ label: string; value: string }> = [
138
+ { label: "✅ Select this directory", value: "__select__" },
139
+ { label: "⬆ ..", value: "__up__" },
140
+ ...entries.map((e) => ({ label: `${e}/`, value: e })),
141
+ ];
142
+
143
+ const labels = options.map((o) => o.label);
144
+ const selected = await ctx.ui.select(`Navigate: ${displayPath}`, labels);
145
+ if (!selected) return undefined;
146
+
147
+ const option = options.find((o) => o.label === selected);
148
+ if (!option) continue;
149
+
150
+ if (option.value === "__select__") return currentPath;
151
+ if (option.value === "__up__") {
152
+ const parent = dirname(currentPath);
153
+ if (parent === currentPath) continue; // at root, can't go up
154
+ currentPath = parent;
155
+ continue;
156
+ }
157
+ // Navigate into subdirectory
158
+ currentPath = join(currentPath, option.value);
159
+ }
160
+ }
161
+
162
+ private listDirectories(dirPath: string): string[] {
163
+ const result: string[] = [];
164
+ try {
165
+ const entries: Dirent[] = require("fs").readdirSync(dirPath, { withFileTypes: true });
166
+ for (const entry of entries) {
167
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
168
+ result.push(entry.name);
169
+ }
170
+ }
171
+ } catch {
172
+ /* permission denied, return what we have */
173
+ }
174
+ return result.sort((a, b) => a.localeCompare(b));
175
+ }
176
+ }
@@ -0,0 +1,9 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import { useDirsCommand } from "./dirs";
4
+
5
+ const useDirsCommands = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
6
+ useDirsCommand(pi, runtime);
7
+ };
8
+
9
+ export default useDirsCommands;
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import useProviderCommands from "./providers";
4
4
  import useAccountCommands from "./accounts";
5
+ import useDirsCommands from "./dirs";
5
6
  import useModelCommands from "./models";
6
7
  import useSystemCommands from "./system";
7
8
 
@@ -10,6 +11,7 @@ export { BaseCommand } from "./base";
10
11
 
11
12
  export function registerAllCommands(pi: ExtensionAPI, runtime: AccountSwitcher) {
12
13
  useAccountCommands(pi, runtime);
14
+ useDirsCommands(pi, runtime);
13
15
  useProviderCommands(pi, runtime);
14
16
  useModelCommands(pi, runtime);
15
17
  useSystemCommands(pi, runtime);
@@ -6,7 +6,7 @@ export const COMMANDS = {
6
6
  },
7
7
  list: {
8
8
  name: "accounts:list",
9
- description: "List configured accounts and activate the selected account",
9
+ description: "(agent tool: use list_accounts tool instead for structured output",
10
10
  },
11
11
  edit: {
12
12
  name: "accounts:edit",
@@ -29,6 +29,10 @@ export const COMMANDS = {
29
29
  description:
30
30
  "Verify secrets for one or all accounts without activating them (pass 'all'; add 'ping' to send a test request)",
31
31
  },
32
+ dirs: {
33
+ name: "accounts:dirs",
34
+ description: "Manage working directories for CWD-based auto-select",
35
+ },
32
36
  },
33
37
  providers: {
34
38
  add: {
@@ -14,11 +14,41 @@ export const OAUTH_PROVIDER_IDS = [
14
14
  "anthropic",
15
15
  "openai-codex",
16
16
  "github-copilot",
17
- "google-antigravity",
18
- "custom",
19
17
  ] as const;
20
18
 
21
- export const BUILT_IN_PROVIDER_IDS = ["anthropic", "openai", "openai-codex", "google", "xai", "openrouter"] as const;
19
+ export const BUILT_IN_PROVIDER_IDS = [
20
+ "amazon-bedrock",
21
+ "anthropic",
22
+ "azure-openai-responses",
23
+ "cerebras",
24
+ "cloudflare-ai-gateway",
25
+ "cloudflare-workers-ai",
26
+ "deepseek",
27
+ "fireworks",
28
+ "github-copilot",
29
+ "google",
30
+ "google-vertex",
31
+ "groq",
32
+ "huggingface",
33
+ "kimi-coding",
34
+ "minimax",
35
+ "minimax-cn",
36
+ "mistral",
37
+ "moonshotai",
38
+ "moonshotai-cn",
39
+ "openai",
40
+ "openai-codex",
41
+ "opencode",
42
+ "opencode-go",
43
+ "openrouter",
44
+ "vercel-ai-gateway",
45
+ "xai",
46
+ "xiaomi",
47
+ "xiaomi-token-plan-ams",
48
+ "xiaomi-token-plan-cn",
49
+ "xiaomi-token-plan-sgp",
50
+ "zai",
51
+ ] as const;
22
52
 
23
53
  export const PROVIDER_ALIASES: Record<string, string> = {
24
54
  claude: "anthropic",
@@ -27,10 +57,35 @@ export const PROVIDER_ALIASES: Record<string, string> = {
27
57
  };
28
58
 
29
59
  export const PROVIDER_ENV_KEYS: Record<string, string[]> = {
30
- anthropic: ["ANTHROPIC_API_KEY"],
60
+ anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
31
61
  openai: ["OPENAI_API_KEY"],
32
62
  "openai-codex": ["OPENAI_API_KEY"],
33
- google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
63
+ google: ["GEMINI_API_KEY"],
64
+ "google-vertex": ["GOOGLE_CLOUD_API_KEY"],
65
+ "azure-openai-responses": ["AZURE_OPENAI_API_KEY"],
66
+ deepseek: ["DEEPSEEK_API_KEY"],
67
+ groq: ["GROQ_API_KEY"],
68
+ cerebras: ["CEREBRAS_API_KEY"],
34
69
  xai: ["XAI_API_KEY"],
35
70
  openrouter: ["OPENROUTER_API_KEY"],
71
+ "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
72
+ zai: ["ZAI_API_KEY"],
73
+ mistral: ["MISTRAL_API_KEY"],
74
+ minimax: ["MINIMAX_API_KEY"],
75
+ "minimax-cn": ["MINIMAX_CN_API_KEY"],
76
+ moonshotai: ["MOONSHOT_API_KEY"],
77
+ "moonshotai-cn": ["MOONSHOT_API_KEY"],
78
+ huggingface: ["HF_TOKEN"],
79
+ fireworks: ["FIREWORKS_API_KEY"],
80
+ opencode: ["OPENCODE_API_KEY"],
81
+ "opencode-go": ["OPENCODE_API_KEY"],
82
+ "kimi-coding": ["KIMI_API_KEY"],
83
+ "cloudflare-workers-ai": ["CLOUDFLARE_API_KEY"],
84
+ "cloudflare-ai-gateway": ["CLOUDFLARE_API_KEY"],
85
+ xiaomi: ["XIAOMI_API_KEY"],
86
+ "xiaomi-token-plan-cn": ["XIAOMI_TOKEN_PLAN_CN_API_KEY"],
87
+ "xiaomi-token-plan-ams": ["XIAOMI_TOKEN_PLAN_AMS_API_KEY"],
88
+ "xiaomi-token-plan-sgp": ["XIAOMI_TOKEN_PLAN_SGP_API_KEY"],
89
+ "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
90
+ "amazon-bedrock": [],
36
91
  };
package/src/extension.ts CHANGED
@@ -1,15 +1,47 @@
1
1
  import { createJiti } from "@mariozechner/jiti";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { createRequire } from "node:module";
3
4
  import { dirname } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
 
6
7
  export default async function accountSwitcherBootstrap(pi: ExtensionAPI) {
7
8
  const srcDir = dirname(fileURLToPath(import.meta.url));
8
- const jiti = createJiti(import.meta.url, {
9
- alias: {
10
- "@": srcDir,
11
- },
12
- });
9
+
10
+ // Resolve @earendil-works/* from the pi loader's context so the same
11
+ // module instances are used (avoids duplicate singletons / instanceof mismatches).
12
+ const loaderRequire = createRequire(import.meta.url);
13
+ const resolveOrUndefined = (id: string): string | undefined => {
14
+ try {
15
+ return loaderRequire.resolve(id);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ };
20
+
21
+ const piAiEntry = resolveOrUndefined("@earendil-works/pi-ai");
22
+ const piCodingAgentEntry = resolveOrUndefined("@earendil-works/pi-coding-agent");
23
+ const piAgentCoreEntry = resolveOrUndefined("@earendil-works/pi-agent-core");
24
+ const piTuiEntry = resolveOrUndefined("@earendil-works/pi-tui");
25
+
26
+ const alias: Record<string, string> = { "@": srcDir };
27
+ if (piAiEntry) {
28
+ alias["@earendil-works/pi-ai"] = piAiEntry;
29
+ alias["@mariozechner/pi-ai"] = piAiEntry;
30
+ }
31
+ if (piCodingAgentEntry) {
32
+ alias["@earendil-works/pi-coding-agent"] = piCodingAgentEntry;
33
+ alias["@mariozechner/pi-coding-agent"] = piCodingAgentEntry;
34
+ }
35
+ if (piAgentCoreEntry) {
36
+ alias["@earendil-works/pi-agent-core"] = piAgentCoreEntry;
37
+ alias["@mariozechner/pi-agent-core"] = piAgentCoreEntry;
38
+ }
39
+ if (piTuiEntry) {
40
+ alias["@earendil-works/pi-tui"] = piTuiEntry;
41
+ alias["@mariozechner/pi-tui"] = piTuiEntry;
42
+ }
43
+
44
+ const jiti = createJiti(import.meta.url, { alias });
13
45
 
14
46
  const extension = await jiti.import<(pi: ExtensionAPI) => void | Promise<void>>("./index", {
15
47
  default: true,
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcherContext } from "@/types";
3
- import { useAccountSwitcher, type AccountSwitcher } from "./runtime";
4
- import { registerAllCommands } from "./commands";
3
+ import { useAccountSwitcher, type AccountSwitcher } from "@/runtime";
4
+ import { registerAllCommands } from "@/commands";
5
5
 
6
6
  async function accountSwitcher(pi: ExtensionAPI) {
7
7
  const runtime: AccountSwitcher = useAccountSwitcher(pi);
@@ -10,6 +10,12 @@ async function accountSwitcher(pi: ExtensionAPI) {
10
10
  await runtime.init(ctx as AccountSwitcherContext);
11
11
  });
12
12
 
13
+ // Re-assert account status on agent/turn lifecycle so it persists
14
+ // across TUI redraws, reloads, and powerline updates.
15
+ pi.on("agent_start", async (_, ctx) => {
16
+ runtime.refreshStatus(ctx as AccountSwitcherContext);
17
+ });
18
+
13
19
  pi.on("model_select", async (event, ctx) => {
14
20
  await runtime.onModelSelect(event.model.provider, ctx as AccountSwitcherContext);
15
21
  });