@hieplp/pi-account-switcher 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -10
- package/USAGE.md +80 -40
- package/package.json +3 -2
- package/src/commands/accounts/add.ts +9 -1
- package/src/commands/accounts/edit.ts +15 -1
- package/src/commands/accounts/index.ts +8 -2
- package/src/commands/accounts/list.ts +43 -25
- package/src/commands/accounts/peers.ts +59 -0
- package/src/commands/accounts/set-subagent-account.ts +64 -0
- package/src/commands/accounts/shared/prompts.ts +35 -10
- package/src/commands/accounts/subagent.ts +50 -0
- package/src/commands/accounts/switch.ts +16 -24
- package/src/commands/dirs/dirs.ts +176 -0
- package/src/commands/dirs/index.ts +9 -0
- package/src/commands/index.ts +2 -0
- package/src/constants/commands.ts +5 -1
- package/src/constants/providers.ts +60 -5
- package/src/index.ts +8 -2
- package/src/runtime/account-switcher-runtime.ts +99 -31
- package/src/runtime/account-switcher.ts +2 -1
- package/src/schemas/accounts.ts +1 -0
- package/src/schemas/config.ts +3 -1
- package/src/services/accounts.ts +53 -6
- package/src/storage/accounts.ts +27 -9
- package/src/storage/state.ts +122 -4
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +5 -1
- package/src/utils/accounts.ts +52 -0
- package/src/utils/common.ts +27 -0
- package/src/utils/filterable-selector.ts +10 -1
- package/src/utils/providers.ts +3 -2
|
@@ -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 {
|
|
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
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
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
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
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,
|
|
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);
|
|
@@ -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;
|
package/src/commands/index.ts
CHANGED
|
@@ -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: "
|
|
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 = [
|
|
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: ["
|
|
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/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 "
|
|
4
|
-
import { registerAllCommands } from "
|
|
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
|
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import AccountSwitcher from "./account-switcher";
|
|
1
|
+
import type AccountSwitcher from "./account-switcher";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { ACCOUNTS_PATH, PROVIDERS_PATH, STATE_PATH } from "@/constants";
|
|
3
4
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
4
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import type { AccountConfig, AccountSwitcherContext, PiAuthEntry, ProviderConfig } from "@/types";
|
|
6
7
|
import type { AccountService, ModelService, PiAuthService, ProviderService } from "@/services";
|
|
7
8
|
import { useAccountService, useModelService, usePiAuthService, useProviderService } from "@/services";
|
|
8
|
-
import { accountUtil, modelUtil, providerUtil, uiUtil } from "@/utils";
|
|
9
|
+
import { accountUtil, findLongestMatchingDir, modelUtil, providerUtil, uiUtil } from "@/utils";
|
|
9
10
|
|
|
10
11
|
function resolveAuthProvider(account: AccountConfig, providers: ProviderConfig[]): string {
|
|
11
12
|
if (account.piAuth?.provider) return account.piAuth.provider;
|
|
@@ -22,58 +23,117 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
|
|
|
22
23
|
private modelService: ModelService;
|
|
23
24
|
private piAuthService: PiAuthService;
|
|
24
25
|
private providerService: ProviderService;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
private lastStatusLabel: string | undefined;
|
|
27
|
+
private sessionKey: string | undefined;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly pi: Pick<ExtensionAPI, "registerProvider" | "setModel">,
|
|
31
|
+
private readonly paths?: { accounts: string; providers: string; state: string },
|
|
32
|
+
) {
|
|
33
|
+
this.providerService = useProviderService(this.pi as ExtensionAPI, paths?.providers ?? PROVIDERS_PATH);
|
|
34
|
+
this.accountService = useAccountService(paths?.accounts ?? ACCOUNTS_PATH, paths?.state ?? STATE_PATH);
|
|
29
35
|
this.modelService = useModelService(this.pi);
|
|
30
36
|
this.piAuthService = usePiAuthService();
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
/** Derive a stable session key from the session manager. */
|
|
40
|
+
private getSessionKey(ctx: AccountSwitcherContext): string {
|
|
41
|
+
try {
|
|
42
|
+
const sm = (ctx as unknown as Record<string, unknown>).sessionManager as Record<string, unknown> | undefined;
|
|
43
|
+
const sessionFile = typeof sm?.getSessionFile === "function" ? (sm.getSessionFile as () => string)() : undefined;
|
|
44
|
+
if (sessionFile) return createHash("sha256").update(sessionFile).digest("hex").slice(0, 12);
|
|
45
|
+
} catch {
|
|
46
|
+
/* fall through */
|
|
47
|
+
}
|
|
48
|
+
return "default";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Find the account whose dirs contain the longest prefix of cwd. */
|
|
52
|
+
private findAccountForCwd(cwd: string | undefined): AccountConfig | undefined {
|
|
53
|
+
if (!cwd) return undefined;
|
|
54
|
+
const id = findLongestMatchingDir(this.accountService.getAccounts(), cwd);
|
|
55
|
+
return id ? this.accountService.getAccounts().find((a) => a.id === id) : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
// ===============================================================================================
|
|
34
59
|
// Core
|
|
35
60
|
// ===============================================================================================
|
|
36
61
|
|
|
37
62
|
async init(ctx: AccountSwitcherContext): Promise<void> {
|
|
63
|
+
this.sessionKey = this.getSessionKey(ctx);
|
|
64
|
+
this.accountService.setSessionKey(this.sessionKey);
|
|
38
65
|
await this.load();
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
67
|
+
// Cascade:
|
|
68
|
+
// 0. PI_ACCOUNT_SWITCHER_ACTIVE_ID env var (from parent process)
|
|
69
|
+
// 1. Session key state (handled inside accountService.load())
|
|
70
|
+
// 2. CWD-based auto-select via dirs
|
|
71
|
+
// 3. defaultAccountId from config
|
|
72
|
+
let selected: AccountConfig | undefined;
|
|
73
|
+
|
|
74
|
+
// Step 0: env var from parent process (for subagent inheritance)
|
|
75
|
+
// PI_ACCOUNT_SWITCHER_NEXT_ID is a one-shot override (consumed after first read)
|
|
76
|
+
// PI_ACCOUNT_SWITCHER_ACTIVE_ID is the persistent inheritance from the parent
|
|
77
|
+
const nextId = process.env.PI_ACCOUNT_SWITCHER_NEXT_ID;
|
|
78
|
+
if (nextId) {
|
|
79
|
+
delete process.env.PI_ACCOUNT_SWITCHER_NEXT_ID;
|
|
80
|
+
selected = this.accountService.getAccounts().find((a) => a.id === nextId);
|
|
81
|
+
}
|
|
82
|
+
if (!selected) {
|
|
83
|
+
const envId = process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID;
|
|
84
|
+
if (envId) {
|
|
85
|
+
selected = this.accountService.getAccounts().find((a) => a.id === envId);
|
|
86
|
+
}
|
|
49
87
|
}
|
|
50
88
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
if (model) await this.modelService.applyModel(model, ctx);
|
|
89
|
+
if (!selected) {
|
|
90
|
+
// Step 1: session key state (restored by accountService.load())
|
|
91
|
+
selected = this.accountService.getActiveAccount();
|
|
92
|
+
}
|
|
93
|
+
if (!selected) {
|
|
94
|
+
// Step 2: CWD-based auto-select via dirs
|
|
95
|
+
selected = this.findAccountForCwd(ctx.cwd);
|
|
96
|
+
}
|
|
97
|
+
if (!selected) {
|
|
98
|
+
// Step 3: defaultAccountId from config
|
|
99
|
+
const defaultId = await this.accountService.getDefaultAccountId();
|
|
100
|
+
if (defaultId) {
|
|
101
|
+
selected = this.accountService.getAccounts().find((a) => a.id === defaultId);
|
|
65
102
|
}
|
|
66
103
|
}
|
|
104
|
+
if (selected) {
|
|
105
|
+
const providers = this.providerService.getProviders();
|
|
106
|
+
await this.applyProviderApiKey(selected, providers);
|
|
107
|
+
await this.accountService.activateAccount(selected, ctx, resolveAuthProvider(selected, providers));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
uiUtil.setAccountStatus(ctx.ui, selected?.label);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
refreshStatus(ctx: AccountSwitcherContext): void {
|
|
114
|
+
const active = this.accountService.getActiveAccount();
|
|
115
|
+
const label = active?.label;
|
|
116
|
+
if (label === this.lastStatusLabel) return;
|
|
117
|
+
this.lastStatusLabel = label;
|
|
118
|
+
uiUtil.setAccountStatus(ctx.ui, label);
|
|
67
119
|
}
|
|
68
120
|
|
|
69
121
|
async load(): Promise<void> {
|
|
70
|
-
await this.accountService.load();
|
|
71
122
|
await this.providerService.load();
|
|
123
|
+
await this.accountService.load();
|
|
72
124
|
}
|
|
73
125
|
|
|
74
126
|
async onModelSelect(provider: string, ctx: AccountSwitcherContext): Promise<void> {
|
|
75
|
-
const
|
|
127
|
+
const providers = this.providerService.getProviders();
|
|
128
|
+
const normalizedProvider = providerUtil.normalizeProviderWithCustom(provider, providers);
|
|
76
129
|
const activeAccount = this.accountService.getActiveAccount();
|
|
130
|
+
|
|
131
|
+
// If the active account already belongs to this provider, keep it.
|
|
132
|
+
if (activeAccount && resolveAccountProvider(activeAccount, providers) === normalizedProvider) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const matchingAccount = this.findAccountsByProvider(provider)[0];
|
|
77
137
|
if (matchingAccount && matchingAccount.id !== activeAccount?.id) {
|
|
78
138
|
await this.activateAccount(matchingAccount, ctx);
|
|
79
139
|
}
|
|
@@ -130,6 +190,9 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
|
|
|
130
190
|
const providerApiKey = await this.applyProviderApiKey(account, providers);
|
|
131
191
|
const result = await this.accountService.activateAccount(account, ctx, resolveAuthProvider(account, providers));
|
|
132
192
|
|
|
193
|
+
// Persist the active account ID for subagent (cross-process) inheritance
|
|
194
|
+
process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID = account.id;
|
|
195
|
+
|
|
133
196
|
// piAuth accounts authenticate via a separate provider (e.g. github-copilot),
|
|
134
197
|
// so use that for model lookup rather than the account's own provider field.
|
|
135
198
|
const accountProvider = resolveAccountProvider(account, providers);
|
|
@@ -141,6 +204,11 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
|
|
|
141
204
|
if (accountProvider !== currentProvider) {
|
|
142
205
|
const model = await modelUtil.pickModel(ctx, account, providers, accountProvider);
|
|
143
206
|
if (model) await this.applyModel(model, ctx);
|
|
207
|
+
} else {
|
|
208
|
+
// Same provider — persist current model for full session tracking
|
|
209
|
+
if (ctx.model) {
|
|
210
|
+
await this.accountService.saveActiveModel(ctx.model.id, ctx.model.provider);
|
|
211
|
+
}
|
|
144
212
|
}
|
|
145
213
|
|
|
146
214
|
return providerApiKey ? `provider apiKey (${providerApiKey})` : result;
|
|
@@ -4,6 +4,7 @@ import type { AccountConfig, AccountSwitcherContext, PiAuthEntry, ProviderConfig
|
|
|
4
4
|
export default interface AccountSwitcher {
|
|
5
5
|
// Core
|
|
6
6
|
init(ctx: AccountSwitcherContext): Promise<void>;
|
|
7
|
+
refreshStatus(ctx: AccountSwitcherContext): void;
|
|
7
8
|
load(): Promise<void>;
|
|
8
9
|
onModelSelect(provider: string, ctx: AccountSwitcherContext): Promise<void>;
|
|
9
10
|
|
|
@@ -27,7 +28,7 @@ export default interface AccountSwitcher {
|
|
|
27
28
|
// Provider
|
|
28
29
|
getProviders(): ProviderConfig[];
|
|
29
30
|
registerProvider(provider: ProviderConfig): void;
|
|
30
|
-
addProvider(
|
|
31
|
+
addProvider(provider: ProviderConfig): Promise<void>;
|
|
31
32
|
editProvider(original: ProviderConfig, updated: ProviderConfig): Promise<void>;
|
|
32
33
|
removeProvider(provider: ProviderConfig): Promise<void>;
|
|
33
34
|
}
|
package/src/schemas/accounts.ts
CHANGED
|
@@ -30,6 +30,7 @@ export const accountSchema = z
|
|
|
30
30
|
env: z.record(z.string().min(1), secretSourceSchema).optional(),
|
|
31
31
|
providerApiKey: secretSourceSchema.optional(),
|
|
32
32
|
usesProviderApiKey: z.boolean().optional(),
|
|
33
|
+
dirs: z.array(z.string()).optional(),
|
|
33
34
|
piAuth: z
|
|
34
35
|
.object({
|
|
35
36
|
provider: z.string().min(1),
|
package/src/schemas/config.ts
CHANGED
|
@@ -4,4 +4,6 @@ import { accountSchema } from "./accounts";
|
|
|
4
4
|
export const configSchema = z.object({
|
|
5
5
|
accounts: z.array(accountSchema).default([]),
|
|
6
6
|
switchMode: z.literal("env").optional().default("env"),
|
|
7
|
-
|
|
7
|
+
defaultAccountId: z.string().optional(),
|
|
8
|
+
stateCleanupDays: z.number().int().min(1).optional().default(30),
|
|
9
|
+
});
|