pi-connect 0.1.1 → 0.1.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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # pi-connect
2
2
 
3
- ![pi-connect screenshot](https://raw.githubusercontent.com/hk-vk/pi-connect/main/assets/screenshot.png)
3
+ ![pi-connect screenshot](https://raw.githubusercontent.com/hk-vk/pi-connect/main/assets/screenshot.png?v=202603281825)
4
4
 
5
- **Unified OAuth & API key login for pi** OpenCode-inspired UI to connect 15+ providers from one `/connect` command.
5
+ Unified OAuth and API key login for pi with an OpenCode-inspired UI.
6
6
 
7
- Paste & save API keys, or login with OAuth, for providers supported by pi like Anthropic, OpenAI, OpenCode, OpenRouter, Gemini, Groq, and more.
7
+ Connect 15+ providers with one `/connect` command.
8
8
 
9
9
  Official pi providers list:
10
10
  - https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md
Binary file
package/index.ts CHANGED
@@ -1,28 +1,61 @@
1
1
  import { DynamicBorder, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { getEnvApiKey } from "@mariozechner/pi-ai";
3
- import { Container, SelectList, Text, type SelectItem } from "@mariozechner/pi-tui";
3
+ import { Container, fuzzyFilter, Input, Key, matchesKey, SelectList, Text, type SelectItem } from "@mariozechner/pi-tui";
4
4
  import { exec as execCb } from "node:child_process";
5
5
 
6
- const API_KEY_PROVIDERS: Record<string, { label: string; env?: string; hint?: string }> = {
7
- anthropic: { label: "Anthropic", env: "ANTHROPIC_API_KEY" },
8
- "azure-openai-responses": { label: "Azure OpenAI Responses", env: "AZURE_OPENAI_API_KEY" },
9
- openai: { label: "OpenAI", env: "OPENAI_API_KEY" },
10
- google: { label: "Google Gemini", env: "GEMINI_API_KEY" },
11
- mistral: { label: "Mistral", env: "MISTRAL_API_KEY" },
12
- groq: { label: "Groq", env: "GROQ_API_KEY" },
13
- cerebras: { label: "Cerebras", env: "CEREBRAS_API_KEY" },
14
- xai: { label: "xAI", env: "XAI_API_KEY" },
15
- openrouter: { label: "OpenRouter", env: "OPENROUTER_API_KEY" },
16
- "vercel-ai-gateway": { label: "Vercel AI Gateway", env: "AI_GATEWAY_API_KEY" },
17
- zai: { label: "ZAI", env: "ZAI_API_KEY" },
18
- opencode: { label: "OpenCode Zen", env: "OPENCODE_API_KEY" },
19
- "opencode-go": { label: "OpenCode Go", env: "OPENCODE_API_KEY" },
20
- huggingface: { label: "Hugging Face", env: "HF_TOKEN" },
21
- "kimi-coding": { label: "Kimi For Coding", env: "KIMI_API_KEY" },
22
- minimax: { label: "MiniMax", env: "MINIMAX_API_KEY" },
23
- "minimax-cn": { label: "MiniMax (China)", env: "MINIMAX_CN_API_KEY" },
6
+ const DISPLAY_NAME_OVERRIDES: Record<string, string> = {
7
+ anthropic: "Anthropic",
8
+ openai: "OpenAI",
9
+ google: "Google Gemini",
10
+ openrouter: "OpenRouter",
11
+ opencode: "OpenCode",
12
+ "opencode-go": "OpenCode Go",
13
+ groq: "Groq",
14
+ mistral: "Mistral",
15
+ cerebras: "Cerebras",
16
+ xai: "xAI",
17
+ zai: "ZAI",
18
+ huggingface: "Hugging Face",
19
+ "kimi-coding": "Kimi",
20
+ minimax: "MiniMax",
21
+ "minimax-cn": "MiniMax China",
22
+ "azure-openai-responses": "Azure OpenAI",
23
+ "vercel-ai-gateway": "Vercel AI Gateway",
24
+ "openai-codex": "ChatGPT",
25
+ "github-copilot": "Copilot",
26
+ "google-gemini-cli": "Gemini CLI",
27
+ "google-antigravity": "Antigravity",
28
+ "google-vertex": "Google Vertex",
29
+ "amazon-bedrock": "Amazon Bedrock"
30
+ };
31
+
32
+ const ENV_VAR_OVERRIDES: Record<string, string> = {
33
+ anthropic: "ANTHROPIC_API_KEY",
34
+ openai: "OPENAI_API_KEY",
35
+ google: "GEMINI_API_KEY",
36
+ openrouter: "OPENROUTER_API_KEY",
37
+ opencode: "OPENCODE_API_KEY",
38
+ "opencode-go": "OPENCODE_API_KEY",
39
+ groq: "GROQ_API_KEY",
40
+ mistral: "MISTRAL_API_KEY",
41
+ cerebras: "CEREBRAS_API_KEY",
42
+ xai: "XAI_API_KEY",
43
+ zai: "ZAI_API_KEY",
44
+ huggingface: "HF_TOKEN",
45
+ "kimi-coding": "KIMI_API_KEY",
46
+ minimax: "MINIMAX_API_KEY",
47
+ "minimax-cn": "MINIMAX_CN_API_KEY",
48
+ "azure-openai-responses": "AZURE_OPENAI_API_KEY",
49
+ "vercel-ai-gateway": "AI_GATEWAY_API_KEY"
24
50
  };
25
51
 
52
+ const OAUTH_ONLY_PROVIDERS = new Set([
53
+ "openai-codex",
54
+ "github-copilot",
55
+ "google-gemini-cli",
56
+ "google-antigravity"
57
+ ]);
58
+
26
59
  const PRIORITY: Record<string, number> = {
27
60
  anthropic: 0,
28
61
  openai: 1,
@@ -33,19 +66,16 @@ const PRIORITY: Record<string, number> = {
33
66
  openrouter: 6,
34
67
  opencode: 7,
35
68
  "opencode-go": 8,
36
- };
37
-
38
- const LABELS: Record<string, string> = {
39
- ...Object.fromEntries(Object.entries(API_KEY_PROVIDERS).map(([id, value]) => [id, value.label])),
40
- anthropic: "Anthropic",
41
- "openai-codex": "ChatGPT Plus/Pro (Codex)",
42
- "github-copilot": "GitHub Copilot",
43
- "google-gemini-cli": "Google Gemini CLI",
44
- "google-antigravity": "Google Antigravity",
69
+ groq: 9,
70
+ mistral: 10
45
71
  };
46
72
 
47
73
  function prettyProviderName(providerId: string): string {
48
- return LABELS[providerId] ?? providerId.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
74
+ return DISPLAY_NAME_OVERRIDES[providerId]
75
+ ?? providerId
76
+ .split("-")
77
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
78
+ .join(" ");
49
79
  }
50
80
 
51
81
  function openUrl(url: string): void {
@@ -57,58 +87,116 @@ function openUrl(url: string): void {
57
87
  execCb(command, () => {});
58
88
  }
59
89
 
90
+ function sortProviderIds(providerIds: string[]): string[] {
91
+ return [...providerIds].sort((a, b) => {
92
+ return (PRIORITY[a] ?? 99) - (PRIORITY[b] ?? 99)
93
+ || prettyProviderName(a).localeCompare(prettyProviderName(b));
94
+ });
95
+ }
96
+
97
+ function getRuntimeProviderIds(ctx: any): string[] {
98
+ const fromModels = ctx.modelRegistry.getAll().map((model: any) => model.provider);
99
+ const fromSavedAuth = ctx.modelRegistry.authStorage.list();
100
+ const fromOauth = ctx.modelRegistry.authStorage.getOAuthProviders().map((provider: any) => provider.id);
101
+ return [...new Set([...fromModels, ...fromSavedAuth, ...fromOauth])];
102
+ }
103
+
104
+ function getApiCapableProviderIds(ctx: any): string[] {
105
+ return sortProviderIds(
106
+ getRuntimeProviderIds(ctx).filter((providerId) => !OAUTH_ONLY_PROVIDERS.has(providerId))
107
+ );
108
+ }
109
+
60
110
  async function pickItem(ctx: any, title: string, subtitle: string | undefined, items: SelectItem[]): Promise<SelectItem | null> {
61
111
  return ctx.ui.custom<SelectItem | null>((tui, theme, _kb, done) => {
62
112
  const container = new Container();
63
-
64
- // Top border
65
113
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
66
-
67
- // Title (bold)
68
114
  container.addChild(new Text(theme.fg("text", theme.bold(title)), 1, 0));
115
+ if (subtitle) container.addChild(new Text(theme.fg("dim", subtitle), 1, 0));
69
116
 
70
- // Subtitle if provided (muted, smaller hint)
71
- if (subtitle) {
72
- container.addChild(new Text(theme.fg("dim", subtitle), 1, 0));
73
- }
74
-
75
- // Spacing before list
117
+ container.addChild(new Text(theme.fg("muted", "Search"), 1, 0));
118
+ const searchInput = new Input();
119
+ searchInput.focused = true;
120
+ container.addChild(searchInput);
76
121
  container.addChild(new Text("", 0, 0));
77
122
 
78
- // Calculate visible items based on terminal height
79
- const maxVisible = Math.max(6, Math.min(items.length, Math.floor((tui.terminal.rows - 12) / 2)));
123
+ const maxVisible = Math.max(6, Math.min(items.length, Math.floor((tui.terminal.rows - 14) / 2)));
124
+ const listContainer = new Container();
125
+ container.addChild(listContainer);
80
126
 
81
- const list = new SelectList(items, maxVisible, {
82
- selectedPrefix: (t) => theme.fg("accent", t),
83
- selectedText: (t) => theme.fg("accent", theme.bold(t)),
84
- description: (t) => theme.fg("muted", t),
85
- scrollInfo: (t) => theme.fg("dim", t),
86
- noMatch: (t) => theme.fg("warning", t),
87
- });
88
- list.onSelect = (item) => done(item);
89
- list.onCancel = () => done(null);
90
- container.addChild(list);
127
+ let list: SelectList;
91
128
 
92
- // Spacing before legend
93
- container.addChild(new Text("", 0, 0));
129
+ const sectionOauth = items.find((item) => item.value === "__section_oauth");
130
+ const sectionApi = items.find((item) => item.value === "__section_api");
131
+ const itemTheme = {
132
+ selectedPrefix: (t: string) => theme.fg("accent", t),
133
+ selectedText: (t: string) => theme.fg("accent", theme.bold(t)),
134
+ description: (t: string) => theme.fg("muted", t),
135
+ scrollInfo: (t: string) => theme.fg("dim", t),
136
+ noMatch: (t: string) => theme.fg("warning", t),
137
+ };
94
138
 
95
- // Legend for status icons - on ONE clean line
96
- container.addChild(new Text(
97
- `${theme.fg("success", "●")} connected ${theme.fg("warning", "◌")} env ${theme.fg("muted", "○")} new`,
98
- 1, 0
99
- ));
139
+ const rebuildList = () => {
140
+ const query = searchInput.getValue().trim();
141
+ const normalItems = items.filter((item) => !item.value.startsWith("__section_"));
142
+ const filtered = query
143
+ ? fuzzyFilter(normalItems, query, (item) => `${item.label} ${item.description ?? ""}`)
144
+ : normalItems;
145
+
146
+ const oauthItems = filtered.filter((item) => item.value.startsWith("oauth:"));
147
+ const apiItems = filtered.filter((item) => item.value.startsWith("api:"));
148
+
149
+ const displayItems: SelectItem[] = [];
150
+ if (oauthItems.length > 0 && sectionOauth) displayItems.push(sectionOauth);
151
+ displayItems.push(...oauthItems);
152
+ if (apiItems.length > 0 && sectionApi) displayItems.push(sectionApi);
153
+ displayItems.push(...apiItems);
154
+
155
+ const finalItems = displayItems.length > 0
156
+ ? displayItems
157
+ : [{ value: "__empty", label: "No matching providers", description: "Try a different search" }];
158
+
159
+ list = new SelectList(finalItems, maxVisible, itemTheme);
160
+ list.onSelect = (item) => {
161
+ if (item.value.startsWith("__section_") || item.value === "__empty") return;
162
+ done(item);
163
+ };
164
+ list.onCancel = () => done(null);
165
+
166
+ listContainer.clear();
167
+ listContainer.addChild(list);
168
+ };
100
169
 
101
- // Keyboard hints
102
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel"), 1, 0));
170
+ rebuildList();
103
171
 
104
- // Bottom border
172
+ container.addChild(new Text("", 0, 0));
173
+ container.addChild(new Text(`${theme.fg("success", "●")} connected ${theme.fg("warning", "◌")} env ${theme.fg("muted", "○")} new`, 1, 0));
174
+ container.addChild(new Text(theme.fg("dim", "type to search • ↑↓ navigate • Enter select • Esc cancel/clear"), 1, 0));
105
175
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
106
176
 
107
177
  return {
108
178
  render: (w) => container.render(w),
109
179
  invalidate: () => container.invalidate(),
110
180
  handleInput: (data) => {
111
- list.handleInput(data);
181
+ if (matchesKey(data, Key.escape)) {
182
+ if (searchInput.getValue()) {
183
+ searchInput.setValue("");
184
+ rebuildList();
185
+ tui.requestRender();
186
+ return;
187
+ }
188
+ done(null);
189
+ return;
190
+ }
191
+
192
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down) || matchesKey(data, Key.enter) || matchesKey(data, Key.return) || matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
193
+ list.handleInput(data);
194
+ tui.requestRender();
195
+ return;
196
+ }
197
+
198
+ searchInput.handleInput(data);
199
+ rebuildList();
112
200
  tui.requestRender();
113
201
  },
114
202
  };
@@ -119,54 +207,47 @@ export default function piConnectExtension(pi: ExtensionAPI) {
119
207
  async function chooseProvider(ctx: any) {
120
208
  const authStorage = ctx.modelRegistry.authStorage;
121
209
  const oauthProviders = authStorage.getOAuthProviders();
122
- const modelProviderIds = new Set(ctx.modelRegistry.getAll().map((model) => model.provider));
123
-
124
- const apiProviders = Object.keys(API_KEY_PROVIDERS)
125
- .filter((providerId) => modelProviderIds.has(providerId) || authStorage.has(providerId) || !!getEnvApiKey(providerId))
126
- .sort((a, b) => (PRIORITY[a] ?? 99) - (PRIORITY[b] ?? 99) || prettyProviderName(a).localeCompare(prettyProviderName(b)));
210
+ const apiProviderIds = getApiCapableProviderIds(ctx);
127
211
 
128
212
  const statusIcon = (providerId: string) => {
129
- if (authStorage.has(providerId)) return ctx.ui.theme.fg("success", "●");
130
- if (getEnvApiKey(providerId)) return ctx.ui.theme.fg("warning", "◌");
131
- return ctx.ui.theme.fg("muted", "○");
213
+ if (authStorage.has(providerId)) return "●";
214
+ if (getEnvApiKey(providerId)) return "◌";
215
+ return "○";
132
216
  };
133
217
 
134
218
  const items: SelectItem[] = [];
135
219
 
136
- // OAuth section
137
220
  if (oauthProviders.length > 0) {
138
221
  items.push({
139
222
  value: "__section_oauth",
140
- label: ctx.ui.theme.bold("OAuth providers"),
141
- description: "login via browser subscription",
223
+ label: "OAuth providers",
224
+ description: "login via browser"
142
225
  });
143
226
  for (const provider of oauthProviders) {
144
227
  items.push({
145
228
  value: `oauth:${provider.id}`,
146
229
  label: `${statusIcon(provider.id)} ${provider.name}`,
147
- description: "subscription",
230
+ description: "OAuth"
148
231
  });
149
232
  }
150
233
  }
151
234
 
152
- // API key section
153
- if (apiProviders.length > 0) {
235
+ if (apiProviderIds.length > 0) {
154
236
  items.push({
155
237
  value: "__section_api",
156
- label: ctx.ui.theme.bold("API key providers"),
157
- description: "paste your key directly",
238
+ label: "API key providers",
239
+ description: "paste and save key"
158
240
  });
159
- for (const providerId of apiProviders) {
160
- const envName = API_KEY_PROVIDERS[providerId]?.env;
241
+ for (const providerId of apiProviderIds) {
161
242
  items.push({
162
243
  value: `api:${providerId}`,
163
244
  label: `${statusIcon(providerId)} ${prettyProviderName(providerId)}`,
164
- description: envName || "key",
245
+ description: ENV_VAR_OVERRIDES[providerId] ?? "API key"
165
246
  });
166
247
  }
167
248
  }
168
249
 
169
- const selected = await pickItem(ctx, "Connect provider", "OAuth first, API key providers below", items);
250
+ const selected = await pickItem(ctx, "Connect provider", "Unified OAuth and API key login", items);
170
251
  if (!selected || selected.value.startsWith("__section_")) return;
171
252
 
172
253
  const [kind, providerId] = selected.value.split(":", 2);
@@ -182,11 +263,9 @@ export default function piConnectExtension(pi: ExtensionAPI) {
182
263
 
183
264
  async function promptApiKey(providerId: string, ctx: any) {
184
265
  const authStorage = ctx.modelRegistry.authStorage;
185
- const provider = API_KEY_PROVIDERS[providerId];
186
- const label = prettyProviderName(providerId);
187
- const prompt = provider?.env
188
- ? `${label} API key (${provider.env})`
189
- : `${label} API key`;
266
+ const prompt = ENV_VAR_OVERRIDES[providerId]
267
+ ? `${prettyProviderName(providerId)} API key (${ENV_VAR_OVERRIDES[providerId]})`
268
+ : `${prettyProviderName(providerId)} API key`;
190
269
  const value = await ctx.ui.input(prompt, "Paste API key");
191
270
  if (!value) {
192
271
  ctx.ui.notify("Cancelled", "info");
@@ -203,70 +282,67 @@ export default function piConnectExtension(pi: ExtensionAPI) {
203
282
  openUrl(url);
204
283
  ctx.ui.notify(instructions ? `${instructions}\n${url}` : url, "info");
205
284
  },
206
- onPrompt: async ({ message, placeholder }) => {
207
- return (await ctx.ui.input(message, placeholder)) ?? "";
208
- },
209
- onManualCodeInput: async () => {
210
- return (await ctx.ui.input("Paste the callback URL or code", "code or redirect URL")) ?? "";
211
- },
212
- onProgress: (message) => {
213
- ctx.ui.notify(message, "info");
214
- },
285
+ onPrompt: async ({ message, placeholder }) => (await ctx.ui.input(message, placeholder)) ?? "",
286
+ onManualCodeInput: async () => (await ctx.ui.input("Paste the callback URL or code", "code or redirect URL")) ?? "",
287
+ onProgress: (message) => ctx.ui.notify(message, "info"),
215
288
  });
216
289
  ctx.ui.notify(`Connected ${prettyProviderName(providerId)}`, "info");
217
290
  }
218
291
 
219
292
  pi.registerCommand("connect", {
220
- description: "Connect a provider with OAuth or an API key",
293
+ description: "Connect any OAuth or API key provider from one unified UI",
221
294
  handler: async (args, ctx) => {
222
- const providerId = args.trim();
295
+ const providerId = args.trim().toLowerCase();
223
296
  if (!providerId) {
224
297
  await chooseProvider(ctx);
225
298
  return;
226
299
  }
227
300
 
228
- const normalized = providerId.toLowerCase();
229
- const oauthIds = new Set(ctx.modelRegistry.authStorage.getOAuthProviders().map((provider) => provider.id));
230
- if (oauthIds.has(normalized)) {
231
- if (API_KEY_PROVIDERS[normalized]) {
232
- const method = await pickItem(ctx, prettyProviderName(normalized), "Choose how to connect", [
233
- { value: "oauth", label: `${ctx.ui.theme.bold("OAuth")}`, description: "Subscription login" },
234
- { value: "api", label: `${ctx.ui.theme.bold("API key")}`, description: "Paste a provider key" },
235
- ]);
236
- if (!method) return;
237
- if (method.value === "oauth") {
238
- await loginWithOAuth(normalized, ctx);
239
- return;
240
- }
241
- } else {
242
- await loginWithOAuth(normalized, ctx);
301
+ const oauthIds = new Set(ctx.modelRegistry.authStorage.getOAuthProviders().map((provider: any) => provider.id));
302
+ const apiIds = new Set(getApiCapableProviderIds(ctx));
303
+
304
+ if (oauthIds.has(providerId) && apiIds.has(providerId)) {
305
+ const method = await pickItem(ctx, prettyProviderName(providerId), "Choose how to connect", [
306
+ { value: "oauth", label: "OAuth", description: "browser login" },
307
+ { value: "api", label: "API key", description: "paste and save key" },
308
+ ]);
309
+ if (!method) return;
310
+ if (method.value === "oauth") {
311
+ await loginWithOAuth(providerId, ctx);
243
312
  return;
244
313
  }
314
+ await promptApiKey(providerId, ctx);
315
+ return;
316
+ }
317
+
318
+ if (oauthIds.has(providerId)) {
319
+ await loginWithOAuth(providerId, ctx);
320
+ return;
245
321
  }
246
322
 
247
- await promptApiKey(normalized, ctx);
323
+ await promptApiKey(providerId, ctx);
248
324
  },
249
325
  });
250
326
 
251
327
  pi.registerCommand("disconnect", {
252
- description: "Remove saved provider credentials",
328
+ description: "Remove a saved provider credential",
253
329
  handler: async (_args, ctx) => {
254
330
  const authStorage = ctx.modelRegistry.authStorage;
255
- const providers = authStorage.list().sort((a, b) => prettyProviderName(a).localeCompare(prettyProviderName(b)));
331
+ const providers = sortProviderIds(authStorage.list());
256
332
  if (providers.length === 0) {
257
333
  ctx.ui.notify("No saved credentials", "info");
258
334
  return;
259
335
  }
260
336
  const items: SelectItem[] = providers.map((providerId) => ({
261
337
  value: providerId,
262
- label: `${ctx.ui.theme.fg("success", "●")} ${ctx.ui.theme.bold(prettyProviderName(providerId))}`,
263
- description: "Connected",
338
+ label: `● ${prettyProviderName(providerId)}`,
339
+ description: "Connected"
264
340
  }));
265
341
  const selected = await pickItem(ctx, "Disconnect provider", "Remove a saved credential", items);
266
- const providerId = selected?.value;
267
- if (!providerId) return;
268
- authStorage.remove(providerId);
269
- ctx.ui.notify(`Removed ${prettyProviderName(providerId)}`, "info");
342
+ const selectedProviderId = selected?.value;
343
+ if (!selectedProviderId) return;
344
+ authStorage.remove(selectedProviderId);
345
+ ctx.ui.notify(`Removed ${prettyProviderName(selectedProviderId)}`, "info");
270
346
  },
271
347
  });
272
348
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-connect",
3
- "version": "0.1.1",
4
- "description": "Unified OAuth & API key login for pi OpenCode-inspired UI to connect 15+ providers from one /connect command.",
3
+ "version": "0.1.3",
4
+ "description": "Unified OAuth and API key login for pi with an OpenCode-inspired UI. Connect 15+ providers with one /connect command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [