@spinabot/brigade 1.2.2 → 1.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 +18 -4
- package/convex/schema.d.ts +6 -6
- package/convex/sessions.d.ts +4 -4
- package/convex/subagents.d.ts +4 -4
- package/dist/agents/agent-loop.d.ts.map +1 -1
- package/dist/agents/agent-loop.js +70 -10
- package/dist/agents/agent-loop.js.map +1 -1
- package/dist/agents/tools/manage-agent-tool.d.ts.map +1 -1
- package/dist/agents/tools/manage-agent-tool.js +2 -1
- package/dist/agents/tools/manage-agent-tool.js.map +1 -1
- package/dist/agents/tools/manage-provider-tool.d.ts.map +1 -1
- package/dist/agents/tools/manage-provider-tool.js +2 -1
- package/dist/agents/tools/manage-provider-tool.js.map +1 -1
- package/dist/agents/tools/org-tool.d.ts +15 -0
- package/dist/agents/tools/org-tool.d.ts.map +1 -1
- package/dist/agents/tools/org-tool.js +53 -6
- package/dist/agents/tools/org-tool.js.map +1 -1
- package/dist/agents/tools/registry.d.ts.map +1 -1
- package/dist/agents/tools/registry.js +3 -0
- package/dist/agents/tools/registry.js.map +1 -1
- package/dist/buildstamp.json +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +25 -6
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/login.d.ts +27 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +142 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/flows/web-setup.d.ts +2 -2
- package/dist/cli/flows/web-setup.js +2 -2
- package/dist/cli/program/build-program.d.ts.map +1 -1
- package/dist/cli/program/build-program.js +8 -0
- package/dist/cli/program/build-program.js.map +1 -1
- package/dist/core/auth-bridge.d.ts.map +1 -1
- package/dist/core/auth-bridge.js +75 -25
- package/dist/core/auth-bridge.js.map +1 -1
- package/dist/integrations/cli-login.d.ts +50 -0
- package/dist/integrations/cli-login.d.ts.map +1 -0
- package/dist/integrations/cli-login.js +114 -0
- package/dist/integrations/cli-login.js.map +1 -0
- package/dist/integrations/custom-provider.d.ts +21 -0
- package/dist/integrations/custom-provider.d.ts.map +1 -0
- package/dist/integrations/custom-provider.js +65 -0
- package/dist/integrations/custom-provider.js.map +1 -0
- package/dist/integrations/provider-discovery.d.ts +30 -0
- package/dist/integrations/provider-discovery.d.ts.map +1 -1
- package/dist/integrations/provider-discovery.js +155 -0
- package/dist/integrations/provider-discovery.js.map +1 -1
- package/dist/providers/catalog.d.ts +33 -0
- package/dist/providers/catalog.d.ts.map +1 -1
- package/dist/providers/catalog.js +91 -5
- package/dist/providers/catalog.js.map +1 -1
- package/dist/providers/validate-key.d.ts.map +1 -1
- package/dist/providers/validate-key.js +20 -6
- package/dist/providers/validate-key.js.map +1 -1
- package/dist/system-prompt/org/render-org-block.js +2 -2
- package/dist/system-prompt/org/render-org-block.js.map +1 -1
- package/dist/ui/onboard-storage-mode.js +24 -21
- package/dist/ui/onboard-storage-mode.js.map +1 -1
- package/dist/ui/onboarding.d.ts +18 -0
- package/dist/ui/onboarding.d.ts.map +1 -1
- package/dist/ui/onboarding.js +576 -43
- package/dist/ui/onboarding.js.map +1 -1
- package/package.json +6 -2
- package/scripts/assets/brigade-favicon.ico +0 -0
- package/scripts/assets/brigade-logo.webp +0 -0
- package/scripts/brand-oauth-page.mjs +92 -0
package/dist/ui/onboarding.js
CHANGED
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
* Uses Pi-TUI components — same components the chat UI uses, so the visual
|
|
13
13
|
* language is consistent across the app.
|
|
14
14
|
*/
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
15
16
|
import { getModels } from "@earendil-works/pi-ai";
|
|
17
|
+
// `getOAuthProvider` lives ONLY on the "./oauth" subpath export — the package's
|
|
18
|
+
// main "." entry (base + register-builtins) does NOT re-export the OAuth
|
|
19
|
+
// registry, so importing it from "@earendil-works/pi-ai" resolves to undefined.
|
|
20
|
+
// The package.json "exports" map exposes "./oauth", so this is the supported
|
|
21
|
+
// path (verified against node_modules @ pi-ai 0.79.9).
|
|
22
|
+
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
|
16
23
|
import { CancellableLoader, Input, SelectList, Text } from "@earendil-works/pi-tui";
|
|
17
|
-
import { upsertApiKeyProfile, upsertApiKeyRefProfile } from "../auth/profiles.js";
|
|
24
|
+
import { upsertApiKeyProfile, upsertApiKeyRefProfile, upsertOAuthProfile, upsertTokenProfile, } from "../auth/profiles.js";
|
|
18
25
|
import { DEFAULT_AGENT_ID, resolveAuthProfilesPath, resolveModelsPath } from "../config/paths.js";
|
|
19
26
|
import { saveConfig } from "../core/config.js";
|
|
27
|
+
import { readClaudeCliLogin, readCodexCliLogin } from "../integrations/cli-login.js";
|
|
28
|
+
import { writeCustomProviderToModelsJson } from "../integrations/custom-provider.js";
|
|
20
29
|
import { discoverOllamaModels, writeOllamaToModelsJson } from "../integrations/ollama.js";
|
|
21
30
|
import { findProvider, PROVIDERS, readProviderEnvKey, resolveProviderEnvVarSource, } from "../providers/catalog.js";
|
|
22
31
|
import { validateApiKeyOnline } from "../providers/validate-key.js";
|
|
@@ -24,8 +33,26 @@ import { renderBrandHeader } from "./brand.js";
|
|
|
24
33
|
import { brand, selectListTheme } from "./theme.js";
|
|
25
34
|
import { pickStorageMode } from "./onboard-storage-mode.js";
|
|
26
35
|
import { SearchableSelectList } from "./searchable-select.js";
|
|
27
|
-
import { listOpenRouterModels } from "../integrations/provider-discovery.js";
|
|
36
|
+
import { getCachedSubscriptionModels, listOpenRouterModels, prefetchSubscriptionModels, } from "../integrations/provider-discovery.js";
|
|
28
37
|
/* ────────────────────────────── public API ────────────────────────────── */
|
|
38
|
+
/**
|
|
39
|
+
* Strip terminal paste artifacts (bracketed-paste markers + stray control chars)
|
|
40
|
+
* from a typed/pasted value before use. `String.trim()` does NOT remove the
|
|
41
|
+
* `ESC[200~ … ESC[201~` wrapper some terminals add to a paste, so a valid key
|
|
42
|
+
* arrives as `<ESC>[200~sk-…` and fails validation. Keys / URLs / model ids are
|
|
43
|
+
* printable, so dropping control chars is safe.
|
|
44
|
+
*/
|
|
45
|
+
function sanitizePastedValue(value) {
|
|
46
|
+
const esc = String.fromCharCode(27); // ESC (0x1b) from a code point — no control byte in source
|
|
47
|
+
const unwrapped = value.split(`${esc}[200~`).join("").split(`${esc}[201~`).join("");
|
|
48
|
+
let cleaned = "";
|
|
49
|
+
for (const ch of unwrapped) {
|
|
50
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
51
|
+
if (code >= 0x20 && code !== 0x7f)
|
|
52
|
+
cleaned += ch; // drop C0 control chars + DEL
|
|
53
|
+
}
|
|
54
|
+
return cleaned.trim();
|
|
55
|
+
}
|
|
29
56
|
/**
|
|
30
57
|
* Resolve the model list for a provider. Pi's static `getModels()` only knows
|
|
31
58
|
* built-in catalogs (Anthropic, OpenAI, Google, etc.); custom providers we
|
|
@@ -36,6 +63,34 @@ import { listOpenRouterModels } from "../integrations/provider-discovery.js";
|
|
|
36
63
|
*/
|
|
37
64
|
async function getProviderModels(modelRegistry, providerId) {
|
|
38
65
|
const staticModels = (() => {
|
|
66
|
+
// Subscription providers (e.g. GitHub Copilot) are filtered to the
|
|
67
|
+
// account's enabled models by the registry's `modifyModels` after login —
|
|
68
|
+
// prefer the refreshed registry so the picker shows exactly what the plan
|
|
69
|
+
// allows. Falls through to the static catalog when the registry is empty
|
|
70
|
+
// (e.g. pre-login).
|
|
71
|
+
if (findProvider(providerId)?.subscription || findProvider(providerId)?.custom) {
|
|
72
|
+
// Live fetch (warmed at login by `prefetchSubscriptionModels`) is
|
|
73
|
+
// authoritative for the SET of models the account can use. Join the
|
|
74
|
+
// static catalog by id for richer metadata (cost, context window) where
|
|
75
|
+
// Pi knows the model; live-only ids pass through as the loose live shape.
|
|
76
|
+
const live = getCachedSubscriptionModels(providerId);
|
|
77
|
+
if (live && live.length > 0) {
|
|
78
|
+
let catalog = [];
|
|
79
|
+
try {
|
|
80
|
+
catalog = getModels(providerId);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* unknown provider — no catalog to join against */
|
|
84
|
+
}
|
|
85
|
+
const byId = new Map(catalog.map((m) => [m.id, m]));
|
|
86
|
+
return live.map((lm) => byId.get(lm.id) ?? lm);
|
|
87
|
+
}
|
|
88
|
+
const fromRegistry = modelRegistry
|
|
89
|
+
.getAll()
|
|
90
|
+
.filter((m) => m.provider === providerId);
|
|
91
|
+
if (fromRegistry.length > 0)
|
|
92
|
+
return fromRegistry;
|
|
93
|
+
}
|
|
39
94
|
try {
|
|
40
95
|
const fromCatalog = getModels(providerId);
|
|
41
96
|
if (fromCatalog && fromCatalog.length > 0)
|
|
@@ -112,7 +167,7 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
|
|
|
112
167
|
let modelId = "";
|
|
113
168
|
while (true) {
|
|
114
169
|
if (step === "provider") {
|
|
115
|
-
renderScreen(tui, "Step
|
|
170
|
+
renderScreen(tui, "Step 2 of 5 · Pick a provider");
|
|
116
171
|
provider = await pickProvider(tui); // throws "onboarding-cancelled" on Esc
|
|
117
172
|
step = "key";
|
|
118
173
|
continue;
|
|
@@ -131,6 +186,54 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
|
|
|
131
186
|
step = "model";
|
|
132
187
|
continue;
|
|
133
188
|
}
|
|
189
|
+
// CLI-login reuse — if the provider can adopt an already-logged-in
|
|
190
|
+
// vendor CLI's token on this machine (Claude Code, Codex), offer the
|
|
191
|
+
// one-keystroke "reuse this login" path FIRST. "other" means no CLI
|
|
192
|
+
// login present (or the user opted for a key / fresh login), so we
|
|
193
|
+
// fall through to the subscription / key path below.
|
|
194
|
+
if (providerInfo?.cliLogin) {
|
|
195
|
+
const r = await ensureCliLogin(tui, authStorage, providerInfo);
|
|
196
|
+
if (r === "ok") {
|
|
197
|
+
modelRegistry.refresh();
|
|
198
|
+
step = "model";
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (r === "back") {
|
|
202
|
+
step = "provider";
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// r === "other" → fall through to the subscription/key path below
|
|
206
|
+
}
|
|
207
|
+
// Subscription providers (Claude Pro/Max, ChatGPT Plus/Pro, GitHub
|
|
208
|
+
// Copilot) log in through a browser OAuth flow instead of pasting an
|
|
209
|
+
// API key. The credential lands under the catalog `id` (which equals
|
|
210
|
+
// the oauthProviderId for these), so Pi routes their models to the
|
|
211
|
+
// right provider.
|
|
212
|
+
// Codex + Copilot model menus come from Pi's bundled catalog
|
|
213
|
+
// automatically; Copilot is further filtered to the account's enabled
|
|
214
|
+
// models via the `availableModelIds` persisted on login.
|
|
215
|
+
if (providerInfo?.subscription) {
|
|
216
|
+
const result = await ensureSubscriptionLogin(tui, authStorage, providerInfo);
|
|
217
|
+
if (result === "back") {
|
|
218
|
+
step = "provider";
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
modelRegistry.refresh();
|
|
222
|
+
step = "model";
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// Custom (catalog-defined) providers — a key + a known
|
|
226
|
+
// Anthropic-compatible endpoint (GLM, Kimi, Qwen, MiniMax, DeepSeek).
|
|
227
|
+
// Paste the key, register the endpoint + models into models.json, done.
|
|
228
|
+
if (providerInfo?.custom && providerInfo.baseUrl) {
|
|
229
|
+
const r = await ensureCustomProvider(tui, authStorage, modelRegistry, providerInfo);
|
|
230
|
+
if (r === "back") {
|
|
231
|
+
step = "provider";
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
step = "model";
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
134
237
|
const result = await ensureApiKey(tui, authStorage, provider, {
|
|
135
238
|
noEnvDetect: opts.noEnvDetect,
|
|
136
239
|
secretInputMode: opts.secretInputMode,
|
|
@@ -144,8 +247,8 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
|
|
|
144
247
|
continue;
|
|
145
248
|
}
|
|
146
249
|
// step === "model"
|
|
147
|
-
renderScreen(tui, "Step
|
|
148
|
-
const result = await pickModel(tui, modelRegistry, provider);
|
|
250
|
+
renderScreen(tui, "Step 4 of 5 · Default model");
|
|
251
|
+
const result = await pickModel(tui, modelRegistry, findProvider(provider)?.providerId ?? provider);
|
|
149
252
|
if (result === "back") {
|
|
150
253
|
step = "provider"; // go all the way back so they can change provider too
|
|
151
254
|
continue;
|
|
@@ -157,13 +260,17 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
|
|
|
157
260
|
// agent's identity is left for the agent itself to discover via
|
|
158
261
|
// BOOTSTRAP.md on first turn. Workspace scaffolding still happens at
|
|
159
262
|
// agent boot via `buildAgent → seedDefaultPrompts`.
|
|
160
|
-
|
|
161
|
-
//
|
|
263
|
+
// A picker entry may resolve to a different Pi provider for routing — e.g.
|
|
264
|
+
// "Claude Code" (subscription) stores under and routes through "anthropic".
|
|
265
|
+
// Persist the REAL provider id so the runtime resolves the model + credential.
|
|
266
|
+
const effectiveProvider = findProvider(provider)?.providerId ?? provider;
|
|
267
|
+
await saveConfig({ defaultProvider: effectiveProvider, defaultModelId: modelId });
|
|
268
|
+
// Step 5 of 5 — web-search backend. Same Pi-TUI components, same brand
|
|
162
269
|
// header. Re-runnable via `brigade onboard web`.
|
|
163
270
|
try {
|
|
164
271
|
const { runWebSetupStep } = await import("../cli/flows/web-setup.js");
|
|
165
272
|
await runWebSetupStep(tui, {
|
|
166
|
-
stepLabel: "Step
|
|
273
|
+
stepLabel: "Step 5 of 5 · Web search",
|
|
167
274
|
secretInputMode: opts.secretInputMode,
|
|
168
275
|
});
|
|
169
276
|
}
|
|
@@ -175,7 +282,7 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
|
|
|
175
282
|
renderDone(tui, provider, modelId);
|
|
176
283
|
await delay(900);
|
|
177
284
|
clear(tui);
|
|
178
|
-
return { provider, modelId, storage };
|
|
285
|
+
return { provider: effectiveProvider, modelId, storage };
|
|
179
286
|
}
|
|
180
287
|
/* ────────────────────────── screen scaffolding ────────────────────────── */
|
|
181
288
|
/** Wipe everything, render the chunky brand header, then a sub-header line. */
|
|
@@ -192,34 +299,81 @@ function renderScreen(tui, subheader) {
|
|
|
192
299
|
}
|
|
193
300
|
/* ────────────────────────────── steps ─────────────────────────────────── */
|
|
194
301
|
async function pickProvider(tui) {
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
302
|
+
// Build the picker LOGIN-FIRST. The ranking surfaces, in order:
|
|
303
|
+
// 0. Already connected — a vendor CLI login already on this machine
|
|
304
|
+
// (Claude Code / Codex) the user can reuse with no browser, no key.
|
|
305
|
+
// 1. Already connected — a key already in the user's environment.
|
|
306
|
+
// 2. Log in with a subscription (browser approval) — Claude Pro/Max,
|
|
307
|
+
// ChatGPT Plus/Pro, GitHub Copilot.
|
|
308
|
+
// 3. Use a coding-plan subscription key — GLM, Kimi, Qwen, MiniMax, DeepSeek.
|
|
309
|
+
// 4. Standard API-key providers.
|
|
310
|
+
// 5. Local (Ollama) and 6. bring-your-own endpoint.
|
|
311
|
+
// A type-to-filter box sits on top so the full list (18+) is searchable and
|
|
312
|
+
// nothing — including Anthropic at the top — ever hides below the fold.
|
|
313
|
+
const ranked = [];
|
|
202
314
|
for (const p of PROVIDERS) {
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
315
|
+
// A login the matching vendor CLI already minted on this machine?
|
|
316
|
+
let cliReady = false;
|
|
317
|
+
if (p.cliLogin) {
|
|
318
|
+
const cred = p.cliLogin.read === "claude" ? readClaudeCliLogin() : readCodexCliLogin();
|
|
319
|
+
cliReady = cred !== null;
|
|
320
|
+
}
|
|
206
321
|
const hasEnvKey = !!readProviderEnvKey(p);
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
322
|
+
const isSubscription = !!p.subscription;
|
|
323
|
+
const isCodingPlan = !!(p.custom && p.baseUrl);
|
|
324
|
+
const isLocal = p.local === true || p.noAuth === true;
|
|
325
|
+
const isBYO = p.custom === true && !p.baseUrl;
|
|
326
|
+
let rank;
|
|
327
|
+
let badge;
|
|
328
|
+
// A subscription provider stays "log in" (browser-first, multi-account) even
|
|
329
|
+
// when a CLI login is on disk — reuse is offered as a secondary option inside
|
|
330
|
+
// the flow. Only a pure CLI-login provider gets the rank-0 "reuse" treatment.
|
|
331
|
+
if (cliReady && !isSubscription) {
|
|
332
|
+
rank = 0;
|
|
333
|
+
badge = "logged in — reuse, no key";
|
|
334
|
+
}
|
|
335
|
+
else if (hasEnvKey) {
|
|
336
|
+
rank = 1;
|
|
337
|
+
badge = "detected — ready to use";
|
|
338
|
+
}
|
|
339
|
+
else if (isSubscription) {
|
|
340
|
+
rank = 2;
|
|
341
|
+
badge = "log in with your subscription";
|
|
342
|
+
}
|
|
343
|
+
else if (isCodingPlan) {
|
|
344
|
+
rank = 3;
|
|
345
|
+
badge = "use your coding-plan key";
|
|
346
|
+
}
|
|
347
|
+
else if (isLocal) {
|
|
348
|
+
rank = 5;
|
|
349
|
+
badge = "runs locally — no key";
|
|
350
|
+
}
|
|
351
|
+
else if (isBYO) {
|
|
352
|
+
rank = 6;
|
|
353
|
+
badge = "bring your own endpoint";
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
rank = 4;
|
|
357
|
+
badge = ""; // standard API-key provider — its description says enough
|
|
358
|
+
}
|
|
359
|
+
ranked.push({
|
|
360
|
+
rank,
|
|
361
|
+
item: {
|
|
362
|
+
value: p.id,
|
|
363
|
+
label: p.name,
|
|
364
|
+
description: badge ? `${p.description} · ${badge}` : p.description,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
218
367
|
}
|
|
219
|
-
|
|
220
|
-
|
|
368
|
+
// Stable sort (V8) keeps catalog order within a rank.
|
|
369
|
+
ranked.sort((a, b) => a.rank - b.rank);
|
|
370
|
+
const items = ranked.map((r) => r.item);
|
|
371
|
+
const list = new SearchableSelectList(items, 12, selectListTheme, {
|
|
221
372
|
minPrimaryColumnWidth: 18,
|
|
222
|
-
maxPrimaryColumnWidth:
|
|
373
|
+
maxPrimaryColumnWidth: 24,
|
|
374
|
+
formatHeader: (q, matchCount, total) => brand.dim(q.length > 0
|
|
375
|
+
? ` search: ${q}▌ (${matchCount}/${total} match${matchCount === 1 ? "" : "es"})`
|
|
376
|
+
: ` ${total} providers · type to filter · ↑↓ move · Enter select · Esc back`),
|
|
223
377
|
});
|
|
224
378
|
tui.addChild(list);
|
|
225
379
|
tui.setFocus(list);
|
|
@@ -285,8 +439,8 @@ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
|
|
|
285
439
|
// OPENROUTER_API_KEY, sk-o…52b5)?`. Single line, default = Yes.
|
|
286
440
|
// No explanatory paragraphs.
|
|
287
441
|
const envVar = provider.envVar ?? "the env var";
|
|
288
|
-
renderScreen(tui, `Step
|
|
289
|
-
tui.addChild(new Text(` ${brand.amber("?")}
|
|
442
|
+
renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
|
|
443
|
+
tui.addChild(new Text(` ${brand.amber("?")} We found a saved ${provider.name} key on this computer (${formatApiKeyPreview(envKey)}). Use it?`, 0, 0));
|
|
290
444
|
tui.addChild(new Text("", 0, 0));
|
|
291
445
|
const confirmList = new SelectList([
|
|
292
446
|
{ value: "yes", label: "Yes" },
|
|
@@ -313,13 +467,13 @@ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
|
|
|
313
467
|
// and let them paste their own. The typed-key loop below treats
|
|
314
468
|
// `lastError === null` as a clean first iteration, so no stale
|
|
315
469
|
// error text leaks in.
|
|
316
|
-
renderScreen(tui, `Step
|
|
470
|
+
renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
|
|
317
471
|
// Fall through to typed-key loop without `lastError` set.
|
|
318
472
|
// (Variable declared just below the env block.)
|
|
319
473
|
return await promptTypedKey(tui, authStorage, provider, providerId, null);
|
|
320
474
|
}
|
|
321
475
|
// User confirmed — verify the env key actually works before accepting.
|
|
322
|
-
tui.addChild(new Text(` ${brand.dim(`
|
|
476
|
+
tui.addChild(new Text(` ${brand.dim(`Checking your ${provider.name} key…`)}`, 0, 0));
|
|
323
477
|
const envLoader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), `Verifying ${provider.name}…`);
|
|
324
478
|
tui.addChild(envLoader);
|
|
325
479
|
tui.requestRender();
|
|
@@ -372,7 +526,7 @@ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
|
|
|
372
526
|
});
|
|
373
527
|
authStorage.set(providerId, { type: "api_key", key: envKey });
|
|
374
528
|
}
|
|
375
|
-
const pinShape = mode === "ref" ? "
|
|
529
|
+
const pinShape = mode === "ref" ? "the key already on this computer" : "your saved key";
|
|
376
530
|
tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} is already connected (using ${brand.white(pinShape)}).`, 0, 0));
|
|
377
531
|
tui.requestRender();
|
|
378
532
|
await delay(600);
|
|
@@ -382,7 +536,7 @@ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
|
|
|
382
536
|
// skip — drop into the typed-key path with the failure seeded so the
|
|
383
537
|
// user immediately sees WHY their env key didn't work and can paste a
|
|
384
538
|
// fresh one.
|
|
385
|
-
const staleReason = `
|
|
539
|
+
const staleReason = `That saved ${provider.name} key didn't work: ${envCheck.reason}`;
|
|
386
540
|
return await promptTypedKey(tui, authStorage, provider, providerId, staleReason);
|
|
387
541
|
}
|
|
388
542
|
return await promptTypedKey(tui, authStorage, provider, providerId, null);
|
|
@@ -404,7 +558,7 @@ async function promptTypedKey(tui, authStorage, provider, providerId, seedError)
|
|
|
404
558
|
// stale loader frames don't pile up vertically.
|
|
405
559
|
let lastError = seedError;
|
|
406
560
|
while (true) {
|
|
407
|
-
renderScreen(tui, `Step
|
|
561
|
+
renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
|
|
408
562
|
if (lastError) {
|
|
409
563
|
tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
|
|
410
564
|
tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
|
|
@@ -421,7 +575,7 @@ async function promptTypedKey(tui, authStorage, provider, providerId, seedError)
|
|
|
421
575
|
let key;
|
|
422
576
|
try {
|
|
423
577
|
key = await new Promise((resolve, reject) => {
|
|
424
|
-
input.onSubmit = (value) => resolve(value
|
|
578
|
+
input.onSubmit = (value) => resolve(sanitizePastedValue(value));
|
|
425
579
|
input.onEscape = () => reject(new Error("back"));
|
|
426
580
|
});
|
|
427
581
|
}
|
|
@@ -522,7 +676,7 @@ function validateApiKey(providerId, key) {
|
|
|
522
676
|
async function ensureLocalOllama(tui, modelRegistry, baseUrl) {
|
|
523
677
|
let lastError = null;
|
|
524
678
|
while (true) {
|
|
525
|
-
renderScreen(tui, "Step
|
|
679
|
+
renderScreen(tui, "Step 3 of 5 · Connect Ollama");
|
|
526
680
|
if (lastError) {
|
|
527
681
|
tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
|
|
528
682
|
tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
|
|
@@ -576,6 +730,385 @@ async function ensureLocalOllama(tui, modelRegistry, baseUrl) {
|
|
|
576
730
|
return "ok";
|
|
577
731
|
}
|
|
578
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Subscription-login variant of `ensureApiKey`. For providers that carry a
|
|
735
|
+
* `subscription` descriptor (Anthropic, OpenAI Codex, GitHub Copilot) we run
|
|
736
|
+
* Pi's OAuth login flow instead of asking for an API key:
|
|
737
|
+
* 1. Confirm with the user (Enter to start the browser flow, Esc to go back).
|
|
738
|
+
* 2. Drive `oauthProvider.login(...)` — Pi does NOT open the browser, so our
|
|
739
|
+
* `onAuth` callback does (best-effort, per-platform).
|
|
740
|
+
* 3. On success, persist the returned credential to BOTH credential stores
|
|
741
|
+
* (auth-profiles.json via `upsertOAuthProfile` + auth.json via
|
|
742
|
+
* `authStorage.set`) so the wizard process and every future boot can use it.
|
|
743
|
+
*
|
|
744
|
+
* Modeled on `ensureLocalOllama`: a retry `while(true)` loop with a `lastError`
|
|
745
|
+
* line and Esc → "back". Any thrown error (including the user aborting the
|
|
746
|
+
* flow) is caught and surfaced inline so the user can retry or pick a different
|
|
747
|
+
* provider.
|
|
748
|
+
*/
|
|
749
|
+
export async function ensureSubscriptionLogin(tui, authStorage, provider) {
|
|
750
|
+
const sub = provider.subscription;
|
|
751
|
+
const oauthProvider = getOAuthProvider(sub.oauthProviderId);
|
|
752
|
+
if (!oauthProvider) {
|
|
753
|
+
// Pi build doesn't know this provider — fail cleanly back to the picker
|
|
754
|
+
// rather than crashing the wizard.
|
|
755
|
+
renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
|
|
756
|
+
tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(`${provider.name} sign-in isn't supported yet.`)}`, 0, 0));
|
|
757
|
+
tui.addChild(new Text(brand.dim(" Taking you back to choose another provider…"), 0, 0));
|
|
758
|
+
tui.requestRender();
|
|
759
|
+
await delay(900);
|
|
760
|
+
return "back";
|
|
761
|
+
}
|
|
762
|
+
let lastError = null;
|
|
763
|
+
while (true) {
|
|
764
|
+
renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
|
|
765
|
+
if (lastError) {
|
|
766
|
+
tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
|
|
767
|
+
tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
|
|
768
|
+
tui.addChild(new Text("", 0, 0));
|
|
769
|
+
}
|
|
770
|
+
tui.addChild(new Text(` ${brand.white(sub.label)}`, 0, 0));
|
|
771
|
+
tui.addChild(new Text(brand.dim(" We'll open your browser to sign in. Approve it there — we'll wait."), 0, 0));
|
|
772
|
+
tui.addChild(new Text(brand.dim(" Enter to start · Esc to go back"), 0, 0));
|
|
773
|
+
// Confirm-gate (mirrors ensureLocalOllama) so the user can Esc out BEFORE
|
|
774
|
+
// the browser opens. Just hits Enter to proceed, or Esc to rewind.
|
|
775
|
+
const confirm = new Input();
|
|
776
|
+
tui.addChild(confirm);
|
|
777
|
+
tui.setFocus(confirm);
|
|
778
|
+
tui.requestRender();
|
|
779
|
+
try {
|
|
780
|
+
await new Promise((resolve, reject) => {
|
|
781
|
+
confirm.onSubmit = () => resolve();
|
|
782
|
+
confirm.onEscape = () => reject(new Error("back"));
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
return "back";
|
|
787
|
+
}
|
|
788
|
+
tui.removeChild(confirm);
|
|
789
|
+
// Drive the OAuth flow. `controller` lets the callbacks (and an Esc) abort
|
|
790
|
+
// the in-flight login; Pi honors `signal` across its loopback wait.
|
|
791
|
+
const controller = new AbortController();
|
|
792
|
+
let creds;
|
|
793
|
+
try {
|
|
794
|
+
creds = await oauthProvider.login({
|
|
795
|
+
// Pi does NOT open the browser — we do. onAuth is fire-and-forget
|
|
796
|
+
// (void), so don't await here; just kick the browser + show the URL
|
|
797
|
+
// and a waiting spinner.
|
|
798
|
+
onAuth: (info) => {
|
|
799
|
+
tui.addChild(new Text(` ${brand.amber("→")} Opening your browser to sign in…`, 0, 0));
|
|
800
|
+
openSubscriptionBrowser(info.url);
|
|
801
|
+
tui.addChild(new Text("", 0, 0));
|
|
802
|
+
tui.addChild(new Text(" " + brand.amber(info.url), 0, 0));
|
|
803
|
+
tui.addChild(new Text(brand.dim(" If your browser didn't open, copy the link above. Paste the code here if asked."), 0, 0));
|
|
804
|
+
if (info.instructions)
|
|
805
|
+
tui.addChild(new Text(brand.dim(" " + info.instructions), 0, 0));
|
|
806
|
+
// Wire Escape to abort the in-flight login. The loader only
|
|
807
|
+
// receives key input (handleInput) while it holds focus, so set
|
|
808
|
+
// focus AND point onAbort at the login controller.
|
|
809
|
+
const waitLoaderAuth = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "Waiting for you to authorize…");
|
|
810
|
+
waitLoaderAuth.onAbort = () => controller.abort();
|
|
811
|
+
tui.addChild(waitLoaderAuth);
|
|
812
|
+
tui.setFocus(waitLoaderAuth);
|
|
813
|
+
tui.requestRender();
|
|
814
|
+
},
|
|
815
|
+
// Loopback-callback providers (anthropic, openai-codex) also let the
|
|
816
|
+
// user paste the redirect URL / code by hand — this races the local
|
|
817
|
+
// callback server internally. Resolve from an Input; Esc rejects to
|
|
818
|
+
// abort the whole login.
|
|
819
|
+
onManualCodeInput: () => new Promise((resolve, reject) => {
|
|
820
|
+
tui.addChild(new Text("", 0, 0));
|
|
821
|
+
tui.addChild(new Text(brand.dim(" Paste the code or redirect URL, then press Enter · Esc to cancel"), 0, 0));
|
|
822
|
+
const input = new Input();
|
|
823
|
+
tui.addChild(input);
|
|
824
|
+
tui.setFocus(input);
|
|
825
|
+
tui.requestRender();
|
|
826
|
+
input.onSubmit = (value) => resolve(sanitizePastedValue(value));
|
|
827
|
+
input.onEscape = () => reject(new Error("cancelled"));
|
|
828
|
+
}),
|
|
829
|
+
// Device-code providers (github-copilot): show the verification URL +
|
|
830
|
+
// user code, plus a waiting spinner while Pi polls for completion.
|
|
831
|
+
onDeviceCode: (info) => {
|
|
832
|
+
openSubscriptionBrowser(info.verificationUri);
|
|
833
|
+
tui.addChild(new Text("", 0, 0));
|
|
834
|
+
tui.addChild(new Text(` Go to ${brand.amber(info.verificationUri)} and enter code: ${brand.amber(info.userCode)}`, 0, 0));
|
|
835
|
+
tui.addChild(new Text(brand.dim(" If your browser didn't open, copy the link above."), 0, 0));
|
|
836
|
+
// Wire Escape to abort the in-flight login (device-code path). The
|
|
837
|
+
// loader only receives key input while focused, so set focus AND
|
|
838
|
+
// point onAbort at the login controller.
|
|
839
|
+
const waitLoaderDevice = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "Waiting for you to authorize…");
|
|
840
|
+
waitLoaderDevice.onAbort = () => controller.abort();
|
|
841
|
+
tui.addChild(waitLoaderDevice);
|
|
842
|
+
tui.setFocus(waitLoaderDevice);
|
|
843
|
+
tui.requestRender();
|
|
844
|
+
},
|
|
845
|
+
// Free-form prompt (rare). Render the message + an Input; honor
|
|
846
|
+
// allowEmpty so a blank submit is accepted when the provider allows it.
|
|
847
|
+
onPrompt: (p) => new Promise((resolve, reject) => {
|
|
848
|
+
tui.addChild(new Text("", 0, 0));
|
|
849
|
+
tui.addChild(new Text(` ${p.message}`, 0, 0));
|
|
850
|
+
const input = new Input();
|
|
851
|
+
tui.addChild(input);
|
|
852
|
+
tui.setFocus(input);
|
|
853
|
+
tui.requestRender();
|
|
854
|
+
input.onSubmit = (value) => {
|
|
855
|
+
const v = value.trim();
|
|
856
|
+
if (!v && !p.allowEmpty)
|
|
857
|
+
return; // keep waiting for a value
|
|
858
|
+
resolve(v);
|
|
859
|
+
};
|
|
860
|
+
input.onEscape = () => reject(new Error("cancelled"));
|
|
861
|
+
}),
|
|
862
|
+
// Best-effort progress line.
|
|
863
|
+
onProgress: (msg) => {
|
|
864
|
+
tui.addChild(new Text(brand.dim(" " + msg), 0, 0));
|
|
865
|
+
tui.requestRender();
|
|
866
|
+
},
|
|
867
|
+
// REQUIRED for openai-codex: it calls onSelect FIRST (browser vs
|
|
868
|
+
// device-code) and throws if absent. Render a SelectList; resolve the
|
|
869
|
+
// chosen id, or undefined on cancel.
|
|
870
|
+
onSelect: (p) => new Promise((resolve) => {
|
|
871
|
+
tui.addChild(new Text("", 0, 0));
|
|
872
|
+
tui.addChild(new Text(` ${p.message}`, 0, 0));
|
|
873
|
+
const list = new SelectList(p.options.map((o) => ({ value: o.id, label: o.label })), Math.min(p.options.length, 6), selectListTheme, { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 28 });
|
|
874
|
+
tui.addChild(list);
|
|
875
|
+
tui.setFocus(list);
|
|
876
|
+
tui.requestRender();
|
|
877
|
+
list.onSelect = (item) => resolve(item.value);
|
|
878
|
+
list.onCancel = () => resolve(undefined);
|
|
879
|
+
}),
|
|
880
|
+
signal: controller.signal,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
controller.abort();
|
|
885
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
886
|
+
// "cancelled" / "back" are user-initiated aborts — surface a soft line
|
|
887
|
+
// and let them retry; anything else is a real failure. NEVER render
|
|
888
|
+
// the raw Pi reason (it can leak a URL / status / internal detail) —
|
|
889
|
+
// map it to a friendly, generic line. The soft-cancel branch also
|
|
890
|
+
// matches Pi's "Login cancelled" wording so a user abort reads clean.
|
|
891
|
+
const softCancel = /^login cancelled$/i.test(reason) || reason === "cancelled" || reason === "back";
|
|
892
|
+
lastError = softCancel
|
|
893
|
+
? "Login cancelled. Start again, or pick a different provider."
|
|
894
|
+
: "We couldn't finish signing you in. Check your connection and try again.";
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
// Persist to BOTH stores (NOT authStorage.login, which would only write
|
|
898
|
+
// auth.json): auth-profiles.json is the canonical credential store the
|
|
899
|
+
// agent boots from; auth.json is Pi's in-process mirror so the model
|
|
900
|
+
// picker that runs next can use the token immediately.
|
|
901
|
+
// Resolve to the real Pi provider for storage — e.g. the "claude-code"
|
|
902
|
+
// entry stores its OAuth credential under "anthropic".
|
|
903
|
+
const providerId = provider.providerId ?? provider.id;
|
|
904
|
+
// Preserve provider-specific extras the login returned — notably GitHub
|
|
905
|
+
// Copilot's `availableModelIds` (the exact models THIS account's plan
|
|
906
|
+
// enabled), which Pi's `modifyModels` uses to filter the model menu. Hand
|
|
907
|
+
// the whole credential to Pi's in-memory store and stash the extras in the
|
|
908
|
+
// profile metadata so they survive a reboot.
|
|
909
|
+
const { access, refresh, expires, ...extras } = creds;
|
|
910
|
+
upsertOAuthProfile(DEFAULT_AGENT_ID, {
|
|
911
|
+
provider: providerId,
|
|
912
|
+
access,
|
|
913
|
+
refresh,
|
|
914
|
+
expires,
|
|
915
|
+
metadata: Object.keys(extras).length > 0 ? extras : undefined,
|
|
916
|
+
});
|
|
917
|
+
authStorage.set(providerId, { type: "oauth", ...creds });
|
|
918
|
+
authStorage.reload();
|
|
919
|
+
// Warm the live model cache with THIS account's current models so the
|
|
920
|
+
// model picker (next step) shows exactly what the subscription enables,
|
|
921
|
+
// not just Pi's bundled snapshot. Best-effort — `prefetchSubscriptionModels`
|
|
922
|
+
// swallows its own errors, and the picker falls back to the catalog if the
|
|
923
|
+
// cache is empty (codex, or an unauthorized/failed fetch).
|
|
924
|
+
try {
|
|
925
|
+
await prefetchSubscriptionModels(providerId, creds.access);
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
/* best-effort — picker falls back to the catalog */
|
|
929
|
+
}
|
|
930
|
+
tui.addChild(new Text("", 0, 0));
|
|
931
|
+
tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} connected.`, 0, 0));
|
|
932
|
+
tui.requestRender();
|
|
933
|
+
await delay(600);
|
|
934
|
+
return "ok";
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Connect a subscription provider that ALSO has a vendor CLI login on disk
|
|
939
|
+
* (Claude Code / Codex). When such a login exists we present a choice that LEADS
|
|
940
|
+
* with browser sign-in — the right default when several people each use their own
|
|
941
|
+
* account — and offers reusing this machine's existing login as the convenience
|
|
942
|
+
* second option.
|
|
943
|
+
*
|
|
944
|
+
* Returns:
|
|
945
|
+
* - "ok" → reused the on-disk CLI login and persisted it
|
|
946
|
+
* - "back" → user pressed Esc; caller rewinds to the provider picker
|
|
947
|
+
* - "other" → no CLI login present, OR the user chose browser sign-in; caller
|
|
948
|
+
* falls through to the subscription (browser OAuth) / key path
|
|
949
|
+
*/
|
|
950
|
+
async function ensureCliLogin(tui, authStorage, provider) {
|
|
951
|
+
const cred = provider.cliLogin.read === "claude" ? readClaudeCliLogin() : readCodexCliLogin();
|
|
952
|
+
if (!cred)
|
|
953
|
+
return "other"; // no CLI login present on this machine
|
|
954
|
+
// A login is already on this machine — but LEAD with browser sign-in: it works
|
|
955
|
+
// for ANY account, which is what you want when different people each use their
|
|
956
|
+
// own subscription. Reuse is the second, convenience option.
|
|
957
|
+
renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
|
|
958
|
+
tui.addChild(new Text(` ${brand.amber(`How do you want to connect ${provider.name}?`)}`, 0, 0));
|
|
959
|
+
tui.addChild(new Text("", 0, 0));
|
|
960
|
+
const choiceList = new SelectList([
|
|
961
|
+
{ value: "login", label: "Log in with your account", description: "Opens your browser — works for any account" },
|
|
962
|
+
{ value: "reuse", label: "Reuse this machine's login", description: "The account already signed in here" },
|
|
963
|
+
], 2, selectListTheme, { minPrimaryColumnWidth: 26, maxPrimaryColumnWidth: 32 });
|
|
964
|
+
tui.addChild(choiceList);
|
|
965
|
+
tui.setFocus(choiceList);
|
|
966
|
+
tui.requestRender();
|
|
967
|
+
let choice;
|
|
968
|
+
try {
|
|
969
|
+
const picked = await new Promise((resolve, reject) => {
|
|
970
|
+
choiceList.onSelect = (item) => resolve(item.value);
|
|
971
|
+
choiceList.onCancel = () => reject(new Error("back"));
|
|
972
|
+
});
|
|
973
|
+
choice = picked === "reuse" ? "reuse" : "other";
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
return "back";
|
|
977
|
+
}
|
|
978
|
+
// Browser sign-in → caller runs the subscription (OAuth) login next.
|
|
979
|
+
if (choice === "other")
|
|
980
|
+
return "other";
|
|
981
|
+
// Reuse path — persist to BOTH stores (auth-profiles.json is canonical; the
|
|
982
|
+
// authStorage mirror lets the wizard's model picker use the credential now).
|
|
983
|
+
if (cred.type === "oauth") {
|
|
984
|
+
upsertOAuthProfile(DEFAULT_AGENT_ID, {
|
|
985
|
+
provider: cred.provider,
|
|
986
|
+
access: cred.access,
|
|
987
|
+
refresh: cred.refresh,
|
|
988
|
+
expires: cred.expires,
|
|
989
|
+
});
|
|
990
|
+
authStorage.set(cred.provider, {
|
|
991
|
+
type: "oauth",
|
|
992
|
+
access: cred.access,
|
|
993
|
+
refresh: cred.refresh,
|
|
994
|
+
// Pi's in-memory oauth shape requires a numeric `expires`. When the CLI
|
|
995
|
+
// file carried no expiry, seed 0 so Pi treats the access token as
|
|
996
|
+
// expired and refreshes via the refresh token on first use. The durable
|
|
997
|
+
// profile (above) keeps the real value (possibly undefined).
|
|
998
|
+
expires: cred.expires ?? 0,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
// Durable store keeps the type:"token" shape (upsertTokenProfile). Mirror
|
|
1003
|
+
// it into Pi's in-memory store as type:"oauth" for shape-consistency with
|
|
1004
|
+
// the refresh-capable path — a Claude credential with an access token but
|
|
1005
|
+
// no refresh token. expires:0 makes Pi treat the access token as expired
|
|
1006
|
+
// and refresh on first use when a refresh token later exists.
|
|
1007
|
+
upsertTokenProfile(DEFAULT_AGENT_ID, { provider: cred.provider, token: cred.token });
|
|
1008
|
+
// Pi's in-memory OAuthCredential requires a `refresh` string; this CLI
|
|
1009
|
+
// credential carries no refresh token, so seed "".
|
|
1010
|
+
authStorage.set(cred.provider, { type: "oauth", access: cred.token, refresh: "", expires: cred.expires ?? 0 });
|
|
1011
|
+
}
|
|
1012
|
+
authStorage.reload();
|
|
1013
|
+
// Warm the live model cache (best-effort — picker falls back to the catalog).
|
|
1014
|
+
try {
|
|
1015
|
+
await prefetchSubscriptionModels(cred.provider, cred.type === "oauth" ? cred.access : cred.token);
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
/* best-effort */
|
|
1019
|
+
}
|
|
1020
|
+
tui.addChild(new Text("", 0, 0));
|
|
1021
|
+
tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} connected (using your existing login).`, 0, 0));
|
|
1022
|
+
tui.requestRender();
|
|
1023
|
+
await delay(600);
|
|
1024
|
+
return "ok";
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Custom (catalog-defined) provider entry. For providers that carry a key + a
|
|
1028
|
+
* known Anthropic-compatible endpoint (GLM, Kimi, Qwen, MiniMax, DeepSeek): ask
|
|
1029
|
+
* for the key, persist it, register the endpoint + catalog models into
|
|
1030
|
+
* models.json, then refresh the registry so the model picker sees them.
|
|
1031
|
+
*
|
|
1032
|
+
* Returns:
|
|
1033
|
+
* - "ok" → key saved, provider registered
|
|
1034
|
+
* - "back" → user pressed Esc; caller rewinds to the provider picker
|
|
1035
|
+
*/
|
|
1036
|
+
async function ensureCustomProvider(tui, authStorage, modelRegistry, provider) {
|
|
1037
|
+
let lastError = null;
|
|
1038
|
+
while (true) {
|
|
1039
|
+
renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
|
|
1040
|
+
if (lastError) {
|
|
1041
|
+
tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
|
|
1042
|
+
tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
|
|
1043
|
+
tui.addChild(new Text("", 0, 0));
|
|
1044
|
+
}
|
|
1045
|
+
tui.addChild(new Text(` Paste your ${provider.name} key.`, 0, 0));
|
|
1046
|
+
tui.addChild(new Text(brand.dim(` Get one at ${provider.keyUrl}`), 0, 0));
|
|
1047
|
+
tui.addChild(new Text(brand.dim(" Enter to continue · Esc to go back"), 0, 0));
|
|
1048
|
+
tui.addChild(new Text("", 0, 0));
|
|
1049
|
+
const input = new Input();
|
|
1050
|
+
tui.addChild(input);
|
|
1051
|
+
tui.setFocus(input);
|
|
1052
|
+
tui.requestRender();
|
|
1053
|
+
let key;
|
|
1054
|
+
try {
|
|
1055
|
+
key = await new Promise((resolve, reject) => {
|
|
1056
|
+
input.onSubmit = (value) => resolve(sanitizePastedValue(value));
|
|
1057
|
+
input.onEscape = () => reject(new Error("back"));
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
return "back";
|
|
1062
|
+
}
|
|
1063
|
+
// Empty submit — show a one-line hint instead of silently re-rendering.
|
|
1064
|
+
if (!key) {
|
|
1065
|
+
lastError = "Please enter an API key.";
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
// Cheap LOCAL format check (length / whitespace / known prefix). Custom
|
|
1069
|
+
// endpoints vary, so we do NOT online-validate here — but an obviously
|
|
1070
|
+
// malformed key is rejected with feedback rather than persisted.
|
|
1071
|
+
const localCheck = validateApiKey(provider.id, key);
|
|
1072
|
+
if (!localCheck.ok) {
|
|
1073
|
+
lastError = localCheck.reason;
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
upsertApiKeyProfile(DEFAULT_AGENT_ID, { provider: provider.id, key });
|
|
1077
|
+
authStorage.set(provider.id, { type: "api_key", key });
|
|
1078
|
+
authStorage.reload();
|
|
1079
|
+
await writeCustomProviderToModelsJson(resolveModelsPath(DEFAULT_AGENT_ID), {
|
|
1080
|
+
id: provider.id,
|
|
1081
|
+
baseUrl: provider.baseUrl,
|
|
1082
|
+
api: provider.api,
|
|
1083
|
+
apiKey: key,
|
|
1084
|
+
models: provider.models ?? [],
|
|
1085
|
+
});
|
|
1086
|
+
modelRegistry.refresh();
|
|
1087
|
+
tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} connected.`, 0, 0));
|
|
1088
|
+
tui.requestRender();
|
|
1089
|
+
await delay(500);
|
|
1090
|
+
return "ok";
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Best-effort open the system browser at `url`. Detached + unref'd so the child
|
|
1095
|
+
* never keeps the wizard process alive, and every error is swallowed — a failed
|
|
1096
|
+
* launch just means the user copies the URL we printed above. NOT exported;
|
|
1097
|
+
* only the subscription-login flow uses it.
|
|
1098
|
+
*/
|
|
1099
|
+
function openSubscriptionBrowser(url) {
|
|
1100
|
+
try {
|
|
1101
|
+
const child = process.platform === "win32"
|
|
1102
|
+
? spawn("rundll32", ["url.dll,FileProtocolHandler", url], { detached: true, stdio: "ignore" })
|
|
1103
|
+
: process.platform === "darwin"
|
|
1104
|
+
? spawn("open", [url], { detached: true, stdio: "ignore" })
|
|
1105
|
+
: spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
1106
|
+
child.unref();
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
// Couldn't launch a browser — the URL is already on screen for manual use.
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
579
1112
|
async function pickModel(tui, modelRegistry, providerId) {
|
|
580
1113
|
const models = await getProviderModels(modelRegistry, providerId);
|
|
581
1114
|
if (models.length === 0) {
|
|
@@ -586,7 +1119,7 @@ async function pickModel(tui, modelRegistry, providerId) {
|
|
|
586
1119
|
tui.requestRender();
|
|
587
1120
|
try {
|
|
588
1121
|
const id = await new Promise((resolve, reject) => {
|
|
589
|
-
input.onSubmit = (value) => resolve(value
|
|
1122
|
+
input.onSubmit = (value) => resolve(sanitizePastedValue(value));
|
|
590
1123
|
input.onEscape = () => reject(new Error("back"));
|
|
591
1124
|
});
|
|
592
1125
|
return { modelId: id };
|