pi-connect 0.1.0 → 0.1.2

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,8 +1,10 @@
1
1
  # pi-connect
2
2
 
3
- **Unified OAuth & API key login for pi** — OpenCode-inspired UI to connect 15+ providers from one `/connect` command.
3
+ ![pi-connect screenshot](https://raw.githubusercontent.com/hk-vk/pi-connect/main/assets/screenshot.png?v=202603281825)
4
4
 
5
- Paste & save API keys, or login with OAuth, for providers supported by pi like Anthropic, OpenAI, OpenCode, OpenRouter, Gemini, Groq, and more.
5
+ Unified OAuth and API key login for pi with an OpenCode-inspired UI.
6
+
7
+ Connect 15+ providers with one `/connect` command.
6
8
 
7
9
  Official pi providers list:
8
10
  - https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md
Binary file
package/index.ts CHANGED
@@ -3,26 +3,59 @@ import { getEnvApiKey } from "@mariozechner/pi-ai";
3
3
  import { Container, 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,27 +87,35 @@ 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));
69
-
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
115
+ if (subtitle) container.addChild(new Text(theme.fg("dim", subtitle), 1, 0));
76
116
  container.addChild(new Text("", 0, 0));
77
117
 
78
- // Calculate visible items based on terminal height
79
118
  const maxVisible = Math.max(6, Math.min(items.length, Math.floor((tui.terminal.rows - 12) / 2)));
80
-
81
119
  const list = new SelectList(items, maxVisible, {
82
120
  selectedPrefix: (t) => theme.fg("accent", t),
83
121
  selectedText: (t) => theme.fg("accent", theme.bold(t)),
@@ -89,19 +127,9 @@ async function pickItem(ctx: any, title: string, subtitle: string | undefined, i
89
127
  list.onCancel = () => done(null);
90
128
  container.addChild(list);
91
129
 
92
- // Spacing before legend
93
130
  container.addChild(new Text("", 0, 0));
94
-
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
- ));
100
-
101
- // Keyboard hints
131
+ container.addChild(new Text(`${theme.fg("success", "●")} connected ${theme.fg("warning", "◌")} env ${theme.fg("muted", "○")} new`, 1, 0));
102
132
  container.addChild(new Text(theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel"), 1, 0));
103
-
104
- // Bottom border
105
133
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
106
134
 
107
135
  return {
@@ -119,11 +147,7 @@ export default function piConnectExtension(pi: ExtensionAPI) {
119
147
  async function chooseProvider(ctx: any) {
120
148
  const authStorage = ctx.modelRegistry.authStorage;
121
149
  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)));
150
+ const apiProviderIds = getApiCapableProviderIds(ctx);
127
151
 
128
152
  const statusIcon = (providerId: string) => {
129
153
  if (authStorage.has(providerId)) return ctx.ui.theme.fg("success", "●");
@@ -133,40 +157,37 @@ export default function piConnectExtension(pi: ExtensionAPI) {
133
157
 
134
158
  const items: SelectItem[] = [];
135
159
 
136
- // OAuth section
137
160
  if (oauthProviders.length > 0) {
138
161
  items.push({
139
162
  value: "__section_oauth",
140
163
  label: ctx.ui.theme.bold("OAuth providers"),
141
- description: "login via browser subscription",
164
+ description: "login via browser"
142
165
  });
143
166
  for (const provider of oauthProviders) {
144
167
  items.push({
145
168
  value: `oauth:${provider.id}`,
146
169
  label: `${statusIcon(provider.id)} ${provider.name}`,
147
- description: "subscription",
170
+ description: "OAuth"
148
171
  });
149
172
  }
150
173
  }
151
174
 
152
- // API key section
153
- if (apiProviders.length > 0) {
175
+ if (apiProviderIds.length > 0) {
154
176
  items.push({
155
177
  value: "__section_api",
156
178
  label: ctx.ui.theme.bold("API key providers"),
157
- description: "paste your key directly",
179
+ description: "paste and save key"
158
180
  });
159
- for (const providerId of apiProviders) {
160
- const envName = API_KEY_PROVIDERS[providerId]?.env;
181
+ for (const providerId of apiProviderIds) {
161
182
  items.push({
162
183
  value: `api:${providerId}`,
163
184
  label: `${statusIcon(providerId)} ${prettyProviderName(providerId)}`,
164
- description: envName || "key",
185
+ description: ENV_VAR_OVERRIDES[providerId] ?? "API key"
165
186
  });
166
187
  }
167
188
  }
168
189
 
169
- const selected = await pickItem(ctx, "Connect provider", "OAuth first, API key providers below", items);
190
+ const selected = await pickItem(ctx, "Connect provider", "Unified OAuth and API key login", items);
170
191
  if (!selected || selected.value.startsWith("__section_")) return;
171
192
 
172
193
  const [kind, providerId] = selected.value.split(":", 2);
@@ -182,11 +203,9 @@ export default function piConnectExtension(pi: ExtensionAPI) {
182
203
 
183
204
  async function promptApiKey(providerId: string, ctx: any) {
184
205
  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`;
206
+ const prompt = ENV_VAR_OVERRIDES[providerId]
207
+ ? `${prettyProviderName(providerId)} API key (${ENV_VAR_OVERRIDES[providerId]})`
208
+ : `${prettyProviderName(providerId)} API key`;
190
209
  const value = await ctx.ui.input(prompt, "Paste API key");
191
210
  if (!value) {
192
211
  ctx.ui.notify("Cancelled", "info");
@@ -203,56 +222,53 @@ export default function piConnectExtension(pi: ExtensionAPI) {
203
222
  openUrl(url);
204
223
  ctx.ui.notify(instructions ? `${instructions}\n${url}` : url, "info");
205
224
  },
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
- },
225
+ onPrompt: async ({ message, placeholder }) => (await ctx.ui.input(message, placeholder)) ?? "",
226
+ onManualCodeInput: async () => (await ctx.ui.input("Paste the callback URL or code", "code or redirect URL")) ?? "",
227
+ onProgress: (message) => ctx.ui.notify(message, "info"),
215
228
  });
216
229
  ctx.ui.notify(`Connected ${prettyProviderName(providerId)}`, "info");
217
230
  }
218
231
 
219
232
  pi.registerCommand("connect", {
220
- description: "Connect a provider with OAuth or an API key",
233
+ description: "Connect any OAuth or API key provider from one unified UI",
221
234
  handler: async (args, ctx) => {
222
- const providerId = args.trim();
235
+ const providerId = args.trim().toLowerCase();
223
236
  if (!providerId) {
224
237
  await chooseProvider(ctx);
225
238
  return;
226
239
  }
227
240
 
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);
241
+ const oauthIds = new Set(ctx.modelRegistry.authStorage.getOAuthProviders().map((provider: any) => provider.id));
242
+ const apiIds = new Set(getApiCapableProviderIds(ctx));
243
+
244
+ if (oauthIds.has(providerId) && apiIds.has(providerId)) {
245
+ const method = await pickItem(ctx, prettyProviderName(providerId), "Choose how to connect", [
246
+ { value: "oauth", label: ctx.ui.theme.bold("OAuth"), description: "browser login" },
247
+ { value: "api", label: ctx.ui.theme.bold("API key"), description: "paste and save key" },
248
+ ]);
249
+ if (!method) return;
250
+ if (method.value === "oauth") {
251
+ await loginWithOAuth(providerId, ctx);
243
252
  return;
244
253
  }
254
+ await promptApiKey(providerId, ctx);
255
+ return;
256
+ }
257
+
258
+ if (oauthIds.has(providerId)) {
259
+ await loginWithOAuth(providerId, ctx);
260
+ return;
245
261
  }
246
262
 
247
- await promptApiKey(normalized, ctx);
263
+ await promptApiKey(providerId, ctx);
248
264
  },
249
265
  });
250
266
 
251
267
  pi.registerCommand("disconnect", {
252
- description: "Remove saved provider credentials",
268
+ description: "Remove a saved provider credential",
253
269
  handler: async (_args, ctx) => {
254
270
  const authStorage = ctx.modelRegistry.authStorage;
255
- const providers = authStorage.list().sort((a, b) => prettyProviderName(a).localeCompare(prettyProviderName(b)));
271
+ const providers = sortProviderIds(authStorage.list());
256
272
  if (providers.length === 0) {
257
273
  ctx.ui.notify("No saved credentials", "info");
258
274
  return;
@@ -260,13 +276,13 @@ export default function piConnectExtension(pi: ExtensionAPI) {
260
276
  const items: SelectItem[] = providers.map((providerId) => ({
261
277
  value: providerId,
262
278
  label: `${ctx.ui.theme.fg("success", "●")} ${ctx.ui.theme.bold(prettyProviderName(providerId))}`,
263
- description: "Connected",
279
+ description: "Connected"
264
280
  }));
265
281
  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");
282
+ const selectedProviderId = selected?.value;
283
+ if (!selectedProviderId) return;
284
+ authStorage.remove(selectedProviderId);
285
+ ctx.ui.notify(`Removed ${prettyProviderName(selectedProviderId)}`, "info");
270
286
  },
271
287
  });
272
288
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "pi-connect",
3
- "version": "0.1.0",
4
- "description": "Unified OAuth & API key login for pi OpenCode-inspired UI to connect 15+ providers from one /connect command.",
3
+ "version": "0.1.2",
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": [
8
8
  "pi-package",
9
9
  "pi-extension",
10
+ "pi",
10
11
  "oauth",
11
12
  "api-key",
12
13
  "provider-login",
@@ -26,7 +27,8 @@
26
27
  "files": [
27
28
  "index.ts",
28
29
  "README.md",
29
- "LICENSE"
30
+ "LICENSE",
31
+ "assets"
30
32
  ],
31
33
  "publishConfig": {
32
34
  "access": "public"
@@ -34,7 +36,8 @@
34
36
  "pi": {
35
37
  "extensions": [
36
38
  "./index.ts"
37
- ]
39
+ ],
40
+ "image": "https://raw.githubusercontent.com/hk-vk/pi-connect/main/assets/screenshot.png"
38
41
  },
39
42
  "peerDependencies": {
40
43
  "@mariozechner/pi-coding-agent": "*",