@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,74 @@
1
+ import { exec, execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execAsync = promisify(exec);
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export const commonUtil = {
8
+ unique: (values: string[]): string[] => {
9
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
10
+ },
11
+
12
+ isLikelyEnvKey: (value: string): boolean => {
13
+ return /^[A-Z][A-Z0-9_]*$/.test(value);
14
+ },
15
+
16
+ omitUndefined: <T extends Record<string, unknown>>(value: T): T => {
17
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
18
+ },
19
+
20
+ parseCsv: (value: string): string[] => {
21
+ return [
22
+ ...new Set(
23
+ value
24
+ .split(",")
25
+ .map((p) => p.trim())
26
+ .filter(Boolean),
27
+ ),
28
+ ];
29
+ },
30
+
31
+ blankToUndefined: (value: string | undefined): string | undefined => {
32
+ const trimmed = value?.trim();
33
+ return trimmed || undefined;
34
+ },
35
+
36
+ parseJsonArray: (value: string | undefined, field: string): unknown[] | undefined => {
37
+ const trimmed = value?.trim();
38
+ if (!trimmed) return undefined;
39
+ const parsed = JSON.parse(trimmed) as unknown;
40
+ if (!Array.isArray(parsed)) throw new Error(`${field} must be a JSON array`);
41
+ return parsed;
42
+ },
43
+
44
+ parseJsonRecord: (value: string | undefined, field: string): Record<string, unknown> | undefined => {
45
+ const trimmed = value?.trim();
46
+ if (!trimmed) return undefined;
47
+ const parsed = JSON.parse(trimmed) as unknown;
48
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
49
+ throw new Error(`${field} must be a JSON object`);
50
+ return parsed as Record<string, unknown>;
51
+ },
52
+
53
+ slugify: (value: string): string => {
54
+ return value
55
+ .toLowerCase()
56
+ .trim()
57
+ .replace(/[^a-z0-9]+/g, "-")
58
+ .replace(/^-+|-+$/g, "");
59
+ },
60
+
61
+ runCommand: async (command: string): Promise<string> => {
62
+ const { stdout } = await execAsync(command, { timeout: 15_000, maxBuffer: 1024 * 1024, env: process.env });
63
+ return stdout.trim();
64
+ },
65
+
66
+ runOpRead: async (reference: string): Promise<string> => {
67
+ const { stdout } = await execFileAsync("op", ["read", reference], {
68
+ timeout: 15_000,
69
+ maxBuffer: 1024 * 1024,
70
+ env: process.env,
71
+ });
72
+ return stdout.trim();
73
+ },
74
+ };
@@ -0,0 +1,16 @@
1
+ import z from "zod";
2
+
3
+ export const errorUtil = {
4
+ format: (error: unknown): string => {
5
+ if (error instanceof z.ZodError) {
6
+ return error.issues
7
+ .map((issue) => `${errorUtil.formatPath(issue.path)}: ${issue.message}`)
8
+ .join("; ");
9
+ }
10
+ return error instanceof Error ? error.message : String(error);
11
+ },
12
+
13
+ formatPath: (path: PropertyKey[]): string => {
14
+ return path.length > 0 ? path.join(".") : "root";
15
+ },
16
+ };
@@ -0,0 +1,25 @@
1
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ export const fileUtil = {
5
+ isMissingFileError: (error: unknown): boolean => {
6
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
7
+ },
8
+
9
+ expandHome: (path: string): string => {
10
+ if (path === "~" || path.startsWith("~/")) {
11
+ const home = process.env.HOME;
12
+ if (!home) throw new Error("HOME environment variable is not set");
13
+ return path === "~" ? home : `${home}${path.slice(1)}`;
14
+ }
15
+ return path;
16
+ },
17
+
18
+ writePrivateJson: async (path: string, value: unknown): Promise<void> => {
19
+ const dir = dirname(path);
20
+ await mkdir(dir, { recursive: true, mode: 0o700 });
21
+ await chmod(dir, 0o700);
22
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
23
+ await chmod(path, 0o600);
24
+ },
25
+ };
@@ -0,0 +1,114 @@
1
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
2
+ import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, type Focusable } from "@mariozechner/pi-tui";
3
+ import type { ThemeColor } from "@mariozechner/pi-coding-agent";
4
+
5
+ type Theme = { fg: (color: ThemeColor, text: string) => string; bold: (text: string) => string };
6
+
7
+ export class FilterableExtensionSelectorComponent extends Container implements Focusable {
8
+ private readonly searchInput: Input;
9
+ private readonly listContainer: Container;
10
+ private readonly theme: Theme;
11
+ private allOptions: string[];
12
+ private filteredOptions: string[];
13
+ private selectedIndex = 0;
14
+
15
+ _focused = false;
16
+ get focused() {
17
+ return this._focused;
18
+ }
19
+ set focused(value: boolean) {
20
+ this._focused = value;
21
+ this.searchInput.focused = value;
22
+ }
23
+
24
+ constructor(
25
+ title: string,
26
+ options: string[],
27
+ private readonly onDone: (selected: string | undefined) => void,
28
+ theme: Theme,
29
+ ) {
30
+ super();
31
+ this.theme = theme;
32
+ this.allOptions = options;
33
+ this.filteredOptions = options;
34
+
35
+ const borderColor = (str: string) => theme.fg("border", str);
36
+ this.addChild(new DynamicBorder(borderColor));
37
+ this.addChild(new Spacer(1));
38
+ this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
39
+ this.addChild(new Spacer(1));
40
+
41
+ this.searchInput = new Input();
42
+ this.searchInput.onSubmit = () => this.confirm();
43
+ this.addChild(this.searchInput);
44
+ this.addChild(new Spacer(1));
45
+
46
+ this.listContainer = new Container();
47
+ this.addChild(this.listContainer);
48
+ this.addChild(new Spacer(1));
49
+
50
+ const hint = (key: string, desc: string) => theme.fg("dim", key) + theme.fg("muted", ` ${desc}`);
51
+ this.addChild(
52
+ new Text(hint("↑↓", "navigate") + " " + hint("Enter", "select") + " " + hint("Esc", "cancel"), 1, 0),
53
+ );
54
+ this.addChild(new Spacer(1));
55
+ this.addChild(new DynamicBorder(borderColor));
56
+
57
+ this.updateList();
58
+ }
59
+
60
+ handleInput(keyData: string) {
61
+ const kb = getKeybindings();
62
+ if (kb.matches(keyData, "tui.select.up")) {
63
+ if (this.filteredOptions.length === 0) return;
64
+ this.selectedIndex = this.selectedIndex === 0 ? this.filteredOptions.length - 1 : this.selectedIndex - 1;
65
+ this.updateList();
66
+ } else if (kb.matches(keyData, "tui.select.down")) {
67
+ if (this.filteredOptions.length === 0) return;
68
+ this.selectedIndex = this.selectedIndex === this.filteredOptions.length - 1 ? 0 : this.selectedIndex + 1;
69
+ this.updateList();
70
+ } else if (kb.matches(keyData, "tui.select.confirm")) {
71
+ this.confirm();
72
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
73
+ this.onDone(undefined);
74
+ } else {
75
+ this.searchInput.handleInput(keyData);
76
+ this.applyFilter(this.searchInput.getValue());
77
+ }
78
+ }
79
+
80
+ private confirm() {
81
+ const option = this.filteredOptions[this.selectedIndex];
82
+ if (option) this.onDone(option);
83
+ }
84
+
85
+ private applyFilter(query: string) {
86
+ this.filteredOptions = query ? fuzzyFilter(this.allOptions, query, (o) => o) : this.allOptions;
87
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredOptions.length - 1));
88
+ this.updateList();
89
+ }
90
+
91
+ private updateList() {
92
+ this.listContainer.clear();
93
+ const maxVisible = 12;
94
+ const total = this.filteredOptions.length;
95
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
96
+ const end = Math.min(start + maxVisible, total);
97
+
98
+ for (let i = start; i < end; i++) {
99
+ const isSelected = i === this.selectedIndex;
100
+ const line = isSelected
101
+ ? this.theme.fg("accent", `→ ${this.filteredOptions[i]}`)
102
+ : ` ${this.filteredOptions[i]}`;
103
+ this.listContainer.addChild(new Text(line, 0, 0));
104
+ }
105
+
106
+ if (start > 0 || end < total) {
107
+ this.listContainer.addChild(new Text(this.theme.fg("muted", ` (${this.selectedIndex + 1}/${total})`), 0, 0));
108
+ }
109
+
110
+ if (total === 0) {
111
+ this.listContainer.addChild(new Text(this.theme.fg("muted", " No matches"), 0, 0));
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./files";
2
+ export * from "./errors";
3
+ export * from "./common";
4
+ export * from "./models";
5
+ export * from "./providers";
6
+ export * from "./ui";
7
+ export * from "./accounts";
@@ -0,0 +1,76 @@
1
+ import type { Api, Model } from "@mariozechner/pi-ai";
2
+ import type { AccountConfig, AccountSwitcherContext, ProviderConfig } from "@/types";
3
+ import { providerUtil } from "./providers";
4
+ import { uiUtil } from "./ui";
5
+
6
+ type ProviderModel = Model<Api>;
7
+
8
+ export const modelUtil = {
9
+ pickModel: async (
10
+ ctx: AccountSwitcherContext,
11
+ account: AccountConfig,
12
+ providers: ProviderConfig[],
13
+ ): Promise<ProviderModel | undefined> => {
14
+ const accountProvider = normalizeProvider(account.piAuth?.provider ?? account.provider, providers);
15
+ const candidates = getProviderModels(ctx, providers, accountProvider);
16
+
17
+ if (candidates.length === 0) {
18
+ ctx.ui.notify(`Account switched, but no ${accountProvider} models found. Use /model to select one.`, "warning");
19
+ return undefined;
20
+ }
21
+
22
+ return (
23
+ resolveConfiguredModel(account, candidates, ctx, accountProvider) ??
24
+ promptForModel(ctx, candidates, accountProvider)
25
+ );
26
+ },
27
+ };
28
+
29
+ function normalizeProvider(provider: string, providers: ProviderConfig[]): string {
30
+ return providerUtil.normalizeProviderWithCustom(provider, providers);
31
+ }
32
+
33
+ function getProviderModels(
34
+ ctx: AccountSwitcherContext,
35
+ providers: ProviderConfig[],
36
+ accountProvider: string,
37
+ ): ProviderModel[] {
38
+ const seen = new Set<string>();
39
+ const result: ProviderModel[] = [];
40
+
41
+ // getAvailable() first so preferred models appear before fallbacks from getAll()
42
+ for (const m of [...ctx.modelRegistry.getAvailable(), ...ctx.modelRegistry.getAll()]) {
43
+ const key = `${m.provider}/${m.id}`;
44
+ if (seen.has(key)) continue;
45
+ seen.add(key);
46
+ if (normalizeProvider(m.provider, providers) === accountProvider) result.push(m);
47
+ }
48
+
49
+ return result;
50
+ }
51
+
52
+ function resolveConfiguredModel(
53
+ account: AccountConfig,
54
+ candidates: ProviderModel[],
55
+ ctx: AccountSwitcherContext,
56
+ accountProvider: string,
57
+ ): ProviderModel | undefined {
58
+ if (account.model) {
59
+ return ctx.modelRegistry.find(accountProvider, account.model) ?? candidates.find((m) => m.id === account.model);
60
+ }
61
+ if (candidates.length === 1) return candidates[0];
62
+ return undefined;
63
+ }
64
+
65
+ async function promptForModel(
66
+ ctx: AccountSwitcherContext,
67
+ candidates: ProviderModel[],
68
+ accountProvider: string,
69
+ ): Promise<ProviderModel | undefined> {
70
+ const selectedId = await uiUtil.filteredSelect(
71
+ ctx.ui,
72
+ `Select model (${accountProvider})`,
73
+ candidates.map((m) => m.id),
74
+ );
75
+ return candidates.find((m) => m.id === selectedId);
76
+ }
@@ -0,0 +1,49 @@
1
+ import type { ProviderConfig } from "@/types";
2
+ import { BUILT_IN_PROVIDER_IDS, PROVIDER_ALIASES, PROVIDER_ENV_KEYS } from "@/constants";
3
+
4
+ export const providerUtil = {
5
+ normalizeProvider: (value: string): string => {
6
+ const p = value.toLowerCase().trim().replace(/\s+/g, "-");
7
+ return PROVIDER_ALIASES[p] ?? p;
8
+ },
9
+
10
+ normalizeProviderWithCustom: (provider: string, customProviders: ProviderConfig[] = []): string => {
11
+ const normalized = providerUtil.normalizeProvider(provider);
12
+ const custom = customProviders.find((candidate) => {
13
+ const names = [candidate.id, ...(candidate.aliases ?? [])].map(providerUtil.normalizeProvider);
14
+ return names.includes(normalized);
15
+ });
16
+ return custom ? providerUtil.normalizeProvider(custom.id) : normalized;
17
+ },
18
+
19
+ isBuiltInProviderId: (provider: string): boolean => {
20
+ return BUILT_IN_PROVIDER_IDS.includes(
21
+ providerUtil.normalizeProvider(provider) as (typeof BUILT_IN_PROVIDER_IDS)[number],
22
+ );
23
+ },
24
+
25
+ providerChoices: (customProviders: ProviderConfig[] = []): string[] => {
26
+ const customIds = customProviders.map((p) => providerUtil.normalizeProvider(p.id)).sort();
27
+ return [...BUILT_IN_PROVIDER_IDS, ...customIds, "custom"];
28
+ },
29
+
30
+ hasProvider: (provider: string, providers: ProviderConfig[]): boolean => {
31
+ return providers.some(
32
+ (c) =>
33
+ providerUtil.normalizeProvider(c.id) === provider ||
34
+ (c.aliases ?? []).map(providerUtil.normalizeProvider).includes(provider),
35
+ );
36
+ },
37
+
38
+ findProvider: (provider: string, providers: ProviderConfig[]): ProviderConfig | undefined => {
39
+ const normalized = providerUtil.normalizeProviderWithCustom(provider, providers);
40
+ return providers.find((c) => providerUtil.normalizeProvider(c.id) === normalized);
41
+ },
42
+
43
+ requiredEnvKeysForProvider: (provider: string, customProviders: ProviderConfig[] = []): string[] => {
44
+ const custom = providerUtil.findProvider(provider, customProviders);
45
+ return (
46
+ custom?.envKeys ?? PROVIDER_ENV_KEYS[providerUtil.normalizeProviderWithCustom(provider, customProviders)] ?? []
47
+ );
48
+ },
49
+ };
@@ -0,0 +1,49 @@
1
+ import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
+ import { commonUtil } from "./common.js";
3
+ import { FilterableExtensionSelectorComponent } from "./filterable-selector.js";
4
+
5
+ function deduplicateLabels(labels: string[]): string[] {
6
+ const seen = new Map<string, number>();
7
+ return labels.map((label) => {
8
+ const n = (seen.get(label) ?? 0) + 1;
9
+ seen.set(label, n);
10
+ return n > 1 ? `${label} (${n})` : label;
11
+ });
12
+ }
13
+
14
+ export const uiUtil = {
15
+ /** Returns a prompt builder for a single input, parsed as text, CSV, JSON array, or JSON record. */
16
+ prompt: (ui: ExtensionUIContext) => (title: string, hint?: string) => ({
17
+ asText: async () => commonUtil.blankToUndefined(await ui.input(title, hint)),
18
+ asCsv: async () => commonUtil.parseCsv((await ui.input(title, hint)) ?? ""),
19
+ asJsonArray: async (field: string) => commonUtil.parseJsonArray(await ui.input(title, hint), field),
20
+ asJsonRecord: async (field: string) => commonUtil.parseJsonRecord(await ui.input(title, hint), field),
21
+ }),
22
+
23
+ setAccountStatus: (ui: ExtensionUIContext, label: string | undefined): void => {
24
+ ui.setStatus("account", label ? `👤 ${label}` : undefined);
25
+ },
26
+
27
+ /** Like filteredSelect but skips items where the corresponding value is null (used for group headers). */
28
+ filteredGroupedSelect: async <T>(
29
+ ui: ExtensionUIContext,
30
+ title: string,
31
+ labels: string[],
32
+ values: Array<T | null>,
33
+ ): Promise<T | undefined> => {
34
+ const deduped = deduplicateLabels(labels);
35
+ while (true) {
36
+ const selected = await uiUtil.filteredSelect(ui, title, deduped);
37
+ if (selected === undefined) return undefined;
38
+ const value = values[deduped.indexOf(selected)];
39
+ if (value !== null) return value;
40
+ }
41
+ },
42
+
43
+ /** Show a selector using the custom filterable component. */
44
+ filteredSelect: (ui: ExtensionUIContext, title: string, options: string[]): Promise<string | undefined> => {
45
+ return ui.custom<string | undefined>(
46
+ (_tui, theme, _keybindings, done) => new FilterableExtensionSelectorComponent(title, options, done, theme),
47
+ );
48
+ },
49
+ };