@phi-code-admin/phi-code 0.75.3 → 0.75.5

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.
@@ -1,144 +1,75 @@
1
1
  /**
2
- * Phi Init Extension - Interactive setup wizard for Phi Code
2
+ * Phi Init Extension - Interactive setup wizard for Phi Code (legacy `/phi-init`).
3
3
  *
4
- * Detects providers (API keys + local endpoints), then lets the user
5
- * manually assign models to each agent role (code, debug, plan, explore, test, review).
4
+ * Detects providers (API keys + local endpoints), then lets the user manually
5
+ * assign models to each agent role (code, debug, plan, explore, test, review).
6
6
  *
7
7
  * Creates ~/.phi/agent/ structure with routing, agents, and memory.
8
+ *
9
+ * Hardening (2026-05-15):
10
+ * - Uses the unified live-models registry (every provider is now refreshed
11
+ * against its `/v1/models` endpoint, with static fallback when offline).
12
+ * - The entire handler runs inside a defensive try/catch that surfaces errors
13
+ * as `ctx.ui.notify("error")` instead of letting the host TUI crash. A
14
+ * process-level `unhandledRejection` guard is installed once on first run
15
+ * so a stray promise rejection during model probing cannot terminate phi-code.
16
+ * - The API-key input flow now persists the key BEFORE any model enrichment
17
+ * so a network failure on /v1/models cannot lose the user's input.
8
18
  */
9
19
 
10
20
  import type { ExtensionAPI } from "phi-code";
11
- import { writeFile, mkdir, copyFile, readdir, access, readFile } from "node:fs/promises";
21
+ import { writeFile, mkdir, copyFile, readdir, readFile } from "node:fs/promises";
12
22
  import { join, resolve } from "node:path";
13
23
  import { homedir } from "node:os";
14
24
  import { existsSync } from "node:fs";
25
+ import { fetchLiveModels, pingProvider, toPersistedModel } from "./providers/live-models.js";
15
26
 
16
27
  // ─── Types ───────────────────────────────────────────────────────────────
17
28
 
18
29
  interface DetectedProvider {
30
+ id: string;
19
31
  name: string;
20
32
  envVar: string;
21
33
  baseUrl: string;
22
- models: string[];
34
+ api: string;
35
+ requiresApiKey: boolean;
23
36
  available: boolean;
24
- local?: boolean; // True for Ollama/LM Studio (models discovered at runtime)
37
+ models: string[];
38
+ /** True for Ollama/LM Studio (models discovered at runtime, no key required). */
39
+ local?: boolean;
25
40
  }
26
41
 
27
42
  interface RoutingConfig {
28
- routes: Record<string, {
29
- description: string;
30
- keywords: string[];
31
- preferredModel: string;
32
- fallback: string;
33
- agent: string;
34
- }>;
35
- default: { model: string; agent: string | null };
36
- }
37
-
38
- // ─── Dynamic Model Specs via OpenRouter ──────────────────────────────────
39
-
40
- interface ModelSpec {
41
- contextWindow: number;
42
- maxTokens: number;
43
- reasoning: boolean;
44
- }
45
-
46
- // Cache for OpenRouter model data (fetched once per session)
47
- let openRouterCache: Map<string, ModelSpec> | null = null;
48
-
49
- /**
50
- * Fetch model specs from OpenRouter's free API (no key needed).
51
- * Returns a map of model base name → specs.
52
- * Falls back to conservative defaults if unreachable.
53
- */
54
- async function fetchModelSpecs(): Promise<Map<string, ModelSpec>> {
55
- if (openRouterCache) return openRouterCache;
56
-
57
- const cache = new Map<string, ModelSpec>();
58
-
59
- try {
60
- const controller = new AbortController();
61
- const timeout = setTimeout(() => controller.abort(), 5000);
62
- const res = await fetch("https://openrouter.ai/api/v1/models", {
63
- signal: controller.signal,
64
- });
65
- clearTimeout(timeout);
66
-
67
- if (res.ok) {
68
- const data = await res.json() as any;
69
- for (const m of (data.data || [])) {
70
- const contextLength = m.context_length || 128000;
71
- const maxOutput = m.top_provider?.max_completion_tokens || Math.min(contextLength, 16384);
72
- const hasReasoning = (m.supported_parameters || []).includes("reasoning");
73
-
74
- // Store by full ID and by base name (for fuzzy matching)
75
- const spec: ModelSpec = {
76
- contextWindow: contextLength,
77
- maxTokens: typeof maxOutput === "number" ? maxOutput : 16384,
78
- reasoning: hasReasoning,
79
- };
80
-
81
- cache.set(m.id, spec);
82
- // Also store by short name (e.g., "qwen3.5-plus" from "qwen/qwen3.5-plus-02-15")
83
- const parts = m.id.split("/");
84
- if (parts.length > 1) {
85
- cache.set(parts[1], spec);
86
- }
87
- }
43
+ routes: Record<
44
+ string,
45
+ {
46
+ description: string;
47
+ keywords: string[];
48
+ preferredModel: string;
49
+ fallback: string;
50
+ agent: string;
88
51
  }
89
- } catch {
90
- // OpenRouter unreachable cache stays empty, fallback used
91
- }
92
-
93
- openRouterCache = cache;
94
- return cache;
52
+ >;
53
+ default: { model: string; agent: string | null };
95
54
  }
96
55
 
97
- /**
98
- * Get model spec by ID. Tries OpenRouter cache first with fuzzy matching,
99
- * then falls back to conservative defaults.
100
- */
101
- async function getModelSpec(id: string): Promise<ModelSpec> {
102
- const cache = await fetchModelSpecs();
103
-
104
- // Exact match
105
- if (cache.has(id)) return cache.get(id)!;
56
+ // ─── One-time global unhandledRejection guard ────────────────────────────
106
57
 
107
- // Try common prefixed variants
108
- const prefixes = ["qwen/", "moonshotai/", "z-ai/", "minimax/", "openai/", "anthropic/", "google/"];
109
- for (const prefix of prefixes) {
110
- const key = prefix + id;
111
- if (cache.has(key)) return cache.get(key)!;
112
- }
113
-
114
- // Fuzzy: find by base name inclusion
115
- const lower = id.toLowerCase().replace(/[-_.]/g, "");
116
- for (const [key, spec] of cache) {
117
- const keyLower = key.toLowerCase().replace(/[-_.]/g, "");
118
- if (keyLower.includes(lower) || lower.includes(keyLower.split("/").pop() || "")) {
119
- return spec;
58
+ let unhandledRejectionGuardInstalled = false;
59
+ function installUnhandledRejectionGuard(): void {
60
+ if (unhandledRejectionGuardInstalled) return;
61
+ unhandledRejectionGuardInstalled = true;
62
+ process.on("unhandledRejection", (reason) => {
63
+ // Swallow async failures from extension wizards so they cannot terminate the TUI.
64
+ // The wizard itself reports the failure via ctx.ui.notify in its own try/catch.
65
+ const message = reason instanceof Error ? reason.message : String(reason);
66
+ try {
67
+ // Best effort logging process.stderr survives TUI shutdown unlike console.
68
+ process.stderr.write(`[phi-init] swallowed unhandledRejection: ${message}\n`);
69
+ } catch {
70
+ // no-op
120
71
  }
121
- }
122
-
123
- // Conservative fallback
124
- return { contextWindow: 128000, maxTokens: 16384, reasoning: true };
125
- }
126
-
127
- /**
128
- * Synchronous fallback for non-async contexts.
129
- * Uses cached data if available, otherwise returns defaults.
130
- */
131
- function getModelSpecSync(id: string): ModelSpec {
132
- if (!openRouterCache) return { contextWindow: 128000, maxTokens: 16384, reasoning: true };
133
-
134
- if (openRouterCache.has(id)) return openRouterCache.get(id)!;
135
-
136
- const prefixes = ["qwen/", "moonshotai/", "z-ai/", "minimax/", "openai/", "anthropic/", "google/"];
137
- for (const prefix of prefixes) {
138
- if (openRouterCache.has(prefix + id)) return openRouterCache.get(prefix + id)!;
139
- }
140
-
141
- return { contextWindow: 128000, maxTokens: 16384, reasoning: true };
72
+ });
142
73
  }
143
74
 
144
75
  // ─── Provider Detection ──────────────────────────────────────────────────
@@ -146,115 +77,132 @@ function getModelSpecSync(id: string): ModelSpec {
146
77
  function detectProviders(): DetectedProvider[] {
147
78
  const providers: DetectedProvider[] = [
148
79
  {
80
+ id: "alibaba-codingplan",
149
81
  name: "Alibaba Coding Plan",
150
82
  envVar: "ALIBABA_CODING_PLAN_KEY",
151
83
  baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
152
- models: ["qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-plus", "qwen3-coder-next", "kimi-k2.5", "glm-5", "glm-4.7", "MiniMax-M2.5"],
84
+ api: "openai-completions",
85
+ requiresApiKey: true,
153
86
  available: false,
87
+ models: [],
154
88
  },
155
89
  {
90
+ id: "opencode-go",
156
91
  name: "OpenCode Go",
157
92
  envVar: "OPENCODE_GO_API_KEY",
158
93
  baseUrl: "https://opencode.ai/zen/go/v1",
159
- models: ["kimi-k2.6", "kimi-k2.5", "qwen3-coder", "qwen3-coder-plus", "glm-4.6", "glm-5", "deepseek-v3", "minimax-m2", "MiniMax-M2.5", "moonshotai-kimi-thinking", "z-ai-glm-4.7", "gpt-oss-120b"],
94
+ api: "openai-completions",
95
+ requiresApiKey: true,
160
96
  available: false,
97
+ models: [],
161
98
  },
162
99
  {
100
+ id: "openai",
163
101
  name: "OpenAI",
164
102
  envVar: "OPENAI_API_KEY",
165
103
  baseUrl: "https://api.openai.com/v1",
166
- models: ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini"],
104
+ api: "openai-completions",
105
+ requiresApiKey: true,
167
106
  available: false,
107
+ models: [],
168
108
  },
169
109
  {
110
+ id: "anthropic",
170
111
  name: "Anthropic",
171
112
  envVar: "ANTHROPIC_API_KEY",
172
113
  baseUrl: "https://api.anthropic.com/v1",
173
- models: ["claude-sonnet-4-20250514", "claude-3-5-haiku-20241022"],
114
+ api: "anthropic-messages",
115
+ requiresApiKey: true,
174
116
  available: false,
117
+ models: [],
175
118
  },
176
119
  {
177
- name: "Google",
120
+ id: "google",
121
+ name: "Google Gemini",
178
122
  envVar: "GOOGLE_API_KEY",
179
123
  baseUrl: "https://generativelanguage.googleapis.com/v1beta",
180
- models: ["gemini-2.5-pro", "gemini-2.5-flash"],
124
+ api: "google",
125
+ requiresApiKey: true,
181
126
  available: false,
127
+ models: [],
182
128
  },
183
129
  {
130
+ id: "openrouter",
184
131
  name: "OpenRouter",
185
132
  envVar: "OPENROUTER_API_KEY",
186
133
  baseUrl: "https://openrouter.ai/api/v1",
187
- models: [],
134
+ api: "openai-completions",
135
+ requiresApiKey: true,
188
136
  available: false,
137
+ models: [],
189
138
  },
190
139
  {
140
+ id: "groq",
191
141
  name: "Groq",
192
142
  envVar: "GROQ_API_KEY",
193
143
  baseUrl: "https://api.groq.com/openai/v1",
194
- models: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
144
+ api: "openai-completions",
145
+ requiresApiKey: true,
195
146
  available: false,
147
+ models: [],
196
148
  },
197
149
  {
150
+ id: "ollama",
198
151
  name: "Ollama",
199
152
  envVar: "OLLAMA",
200
153
  baseUrl: "http://localhost:11434/v1",
201
- models: [], // Discovered at runtime
154
+ api: "openai-completions",
155
+ requiresApiKey: false,
202
156
  available: false,
157
+ models: [],
203
158
  local: true,
204
159
  },
205
160
  {
161
+ id: "lm-studio",
206
162
  name: "LM Studio",
207
163
  envVar: "LM_STUDIO",
208
164
  baseUrl: "http://localhost:1234/v1",
209
- models: [], // Discovered at runtime
165
+ api: "openai-completions",
166
+ requiresApiKey: false,
210
167
  available: false,
168
+ models: [],
211
169
  local: true,
212
170
  },
213
171
  ];
214
172
 
215
173
  for (const p of providers) {
216
- if (p.local) {
217
- // Local providers: check if server is running by probing the URL
218
- p.available = false; // Will be checked async in detectLocalProviders()
219
- } else {
220
- p.available = !!process.env[p.envVar];
174
+ if (!p.local && process.env[p.envVar]) {
175
+ p.available = true;
221
176
  }
222
177
  }
223
-
224
178
  return providers;
225
179
  }
226
180
 
227
181
  /**
228
- * Detect local providers (Ollama, LM Studio) by probing their endpoints
229
- * and fetching available models dynamically.
182
+ * Probe local providers (Ollama, LM Studio) and live-fetch their model list.
183
+ * Failures are silent local servers are optional.
230
184
  */
231
185
  async function detectLocalProviders(providers: DetectedProvider[]): Promise<void> {
232
- for (const p of providers) {
233
- if (!p.local) continue;
234
- try {
235
- const controller = new AbortController();
236
- const timeout = setTimeout(() => controller.abort(), 3000);
237
- const res = await fetch(`${p.baseUrl}/models`, {
238
- signal: controller.signal,
239
- headers: { Authorization: `Bearer ${p.envVar === "OLLAMA" ? "ollama" : "lm-studio"}` },
240
- });
241
- clearTimeout(timeout);
242
- if (res.ok) {
243
- const data = await res.json() as any;
244
- const models = (data.data || []).map((m: any) => m.id).filter(Boolean);
245
- if (models.length > 0) {
246
- p.models = models;
186
+ await Promise.all(
187
+ providers
188
+ .filter((p) => p.local)
189
+ .map(async (p) => {
190
+ const result = await fetchLiveModels(p.id, { forceRefresh: true, timeoutMs: 2_500 });
191
+ if (result.models.length > 0 && result.source !== "fallback") {
192
+ p.models = result.models.map((m) => m.id);
247
193
  p.available = true;
248
194
  }
249
- }
250
- } catch {
251
- // Server not running — that's fine
252
- }
253
- }
195
+ }),
196
+ );
254
197
  }
255
198
 
256
199
  function getAllAvailableModels(providers: DetectedProvider[]): string[] {
257
- return providers.filter(p => p.available).flatMap(p => p.models);
200
+ const ids = new Set<string>();
201
+ for (const p of providers) {
202
+ if (!p.available) continue;
203
+ for (const id of p.models) ids.add(id);
204
+ }
205
+ return [...ids];
258
206
  }
259
207
 
260
208
  // ─── Routing Presets ─────────────────────────────────────────────────────
@@ -277,7 +225,9 @@ const KEYWORDS: Record<string, string[]> = {
277
225
  review: ["review", "audit", "quality", "security", "improve", "optimize"],
278
226
  };
279
227
 
280
- function createRouting(assignments: Record<string, { preferred: string; fallback: string }>): RoutingConfig {
228
+ function createRouting(
229
+ assignments: Record<string, { preferred: string; fallback: string }>,
230
+ ): RoutingConfig {
281
231
  const routes: RoutingConfig["routes"] = {};
282
232
  for (const role of TASK_ROLES) {
283
233
  const assignment = assignments[role.key];
@@ -298,35 +248,37 @@ function createRouting(assignments: Record<string, { preferred: string; fallback
298
248
  // ─── Extension ───────────────────────────────────────────────────────────
299
249
 
300
250
  export default function initExtension(pi: ExtensionAPI) {
251
+ installUnhandledRejectionGuard();
252
+
301
253
  const phiDir = join(homedir(), ".phi");
302
254
  const agentDir = join(phiDir, "agent");
303
255
  const agentsDir = join(agentDir, "agents");
304
256
  const memoryDir = join(phiDir, "memory");
305
-
306
- /**
307
- * Create all necessary directories
308
- */
309
- async function ensureDirs() {
310
- for (const dir of [agentDir, agentsDir, join(agentDir, "skills"), join(agentDir, "extensions"), memoryDir, join(memoryDir, "ontology")]) {
257
+ const modelsJsonPath = join(agentDir, "models.json");
258
+
259
+ async function ensureDirs(): Promise<void> {
260
+ for (const dir of [
261
+ agentDir,
262
+ agentsDir,
263
+ join(agentDir, "skills"),
264
+ join(agentDir, "extensions"),
265
+ memoryDir,
266
+ join(memoryDir, "ontology"),
267
+ ]) {
311
268
  await mkdir(dir, { recursive: true });
312
269
  }
313
270
  }
314
271
 
315
- /**
316
- * Copy bundled agent definitions to user directory
317
- */
318
- async function copyBundledAgents() {
272
+ async function copyBundledAgents(): Promise<void> {
319
273
  const bundledDir = resolve(join(__dirname, "..", "..", "..", "agents"));
320
274
  if (!existsSync(bundledDir)) return;
321
-
322
275
  try {
323
276
  const files = await readdir(bundledDir);
324
277
  for (const file of files) {
325
- if (file.endsWith(".md")) {
326
- const dest = join(agentsDir, file);
327
- if (!existsSync(dest)) {
328
- await copyFile(join(bundledDir, file), dest);
329
- }
278
+ if (!file.endsWith(".md")) continue;
279
+ const dest = join(agentsDir, file);
280
+ if (!existsSync(dest)) {
281
+ await copyFile(join(bundledDir, file), dest);
330
282
  }
331
283
  }
332
284
  } catch {
@@ -334,14 +286,12 @@ export default function initExtension(pi: ExtensionAPI) {
334
286
  }
335
287
  }
336
288
 
337
- /**
338
- * Create AGENTS.md template
339
- */
340
- async function createAgentsTemplate() {
289
+ async function createAgentsTemplate(): Promise<void> {
341
290
  const agentsMdPath = join(memoryDir, "AGENTS.md");
342
- if (existsSync(agentsMdPath)) return; // Don't overwrite
343
-
344
- await writeFile(agentsMdPath, `# AGENTS.md — Persistent Instructions
291
+ if (existsSync(agentsMdPath)) return;
292
+ await writeFile(
293
+ agentsMdPath,
294
+ `# AGENTS.md — Persistent Instructions
345
295
 
346
296
  This file is loaded at the start of every session. Use it to store:
347
297
  - Project conventions and rules
@@ -367,280 +317,324 @@ This file is loaded at the start of every session. Use it to store:
367
317
  ---
368
318
 
369
319
  _Edit this file to customize Phi Code's behavior for your project._
370
- `, "utf-8");
320
+ `,
321
+ "utf-8",
322
+ );
371
323
  }
372
324
 
373
- // ─── MODE: Auto ──────────────────────────────────────────────────
325
+ // ─── Persistence helper (single source of truth for models.json writes) ──
374
326
 
375
- /**
376
- * Manual mode is the only setup mode.
377
- * User assigns each model to each agent role interactively.
378
- */
379
- // ─── MODE: Manual ────────────────────────────────────────────────
327
+ async function readModelsConfig(): Promise<{ providers: Record<string, any> }> {
328
+ try {
329
+ const raw = await readFile(modelsJsonPath, "utf-8");
330
+ const parsed = JSON.parse(raw) as { providers?: Record<string, any> };
331
+ return { providers: parsed.providers ?? {} };
332
+ } catch {
333
+ return { providers: {} };
334
+ }
335
+ }
336
+
337
+ async function writeModelsConfig(config: { providers: Record<string, any> }): Promise<void> {
338
+ await mkdir(agentDir, { recursive: true });
339
+ await writeFile(modelsJsonPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
340
+ }
341
+
342
+ async function persistProviderKey(
343
+ provider: DetectedProvider,
344
+ apiKey: string,
345
+ ): Promise<void> {
346
+ const config = await readModelsConfig();
347
+ const existing = config.providers[provider.id] ?? {};
348
+ config.providers[provider.id] = {
349
+ ...existing,
350
+ baseUrl: provider.baseUrl,
351
+ api: provider.api,
352
+ apiKey,
353
+ };
354
+ await writeModelsConfig(config);
355
+ }
356
+
357
+ async function persistProviderModels(
358
+ provider: DetectedProvider,
359
+ models: ReturnType<typeof toPersistedModel>[],
360
+ ): Promise<void> {
361
+ const config = await readModelsConfig();
362
+ const existing = config.providers[provider.id] ?? {};
363
+ config.providers[provider.id] = {
364
+ ...existing,
365
+ baseUrl: provider.baseUrl,
366
+ api: provider.api,
367
+ models,
368
+ };
369
+ await writeModelsConfig(config);
370
+ }
380
371
 
381
- async function manualMode(availableModels: string[], ctx: any): Promise<Record<string, { preferred: string; fallback: string }>> {
382
- ctx.ui.notify("🎛️ Manual mode: assign a model to each task category.\n", "info");
372
+ // ─── Manual model assignment (one model per agent role) ──────────────
383
373
 
374
+ async function manualMode(
375
+ availableModels: string[],
376
+ ctx: any,
377
+ ): Promise<Record<string, { preferred: string; fallback: string }>> {
378
+ ctx.ui.notify("Manual mode: assign a model to each task category.\n", "info");
384
379
  const modelOptions = ["default (use current model)", ...availableModels];
385
380
  const assignments: Record<string, { preferred: string; fallback: string }> = {};
386
381
 
387
382
  for (const role of TASK_ROLES) {
388
- // Primary model selection
389
- const chosen = await ctx.ui.select(
390
- `${role.label} — ${role.desc}`,
391
- modelOptions,
392
- );
393
- const preferredModel = (chosen && chosen !== modelOptions[0]) ? chosen : "default";
383
+ const chosen = await ctx.ui.select(`${role.label} — ${role.desc}`, modelOptions);
384
+ const preferredModel = chosen && chosen !== modelOptions[0] ? chosen : "default";
394
385
 
395
- // Fallback model selection
396
- const fallbackOptions = modelOptions.filter(m => m !== chosen);
397
- const fallbackChoice = await ctx.ui.select(
398
- `Fallback for ${role.label}`,
399
- fallbackOptions,
400
- );
401
- const fallback = (fallbackChoice && fallbackChoice !== modelOptions[0]) ? fallbackChoice : "default";
386
+ const fallbackOptions = modelOptions.filter((m) => m !== chosen);
387
+ const fallbackChoice = await ctx.ui.select(`Fallback for ${role.label}`, fallbackOptions);
388
+ const fallback = fallbackChoice && fallbackChoice !== modelOptions[0] ? fallbackChoice : "default";
402
389
 
403
390
  assignments[role.key] = { preferred: preferredModel, fallback };
404
- ctx.ui.notify(` ${role.label}: ${preferredModel} (fallback: ${fallback})`, "info");
391
+ ctx.ui.notify(` ${role.label}: ${preferredModel} (fallback: ${fallback})`, "info");
405
392
  }
406
393
 
407
- // Default model
408
394
  const defaultChoice = await ctx.ui.select("Default model (for general tasks)", modelOptions);
409
- let defaultModel = (defaultChoice && defaultChoice !== modelOptions[0]) ? defaultChoice : "default";
395
+ const defaultModel = defaultChoice && defaultChoice !== modelOptions[0] ? defaultChoice : "default";
410
396
  assignments["default"] = { preferred: defaultModel, fallback: availableModels[0] || "default" };
411
-
412
397
  return assignments;
413
398
  }
414
399
 
415
- // ─── Command ─────────────────────────────────────────────────────
400
+ // ─── Per-provider configuration step ─────────────────────────────────
401
+
402
+ async function configureProvider(provider: DetectedProvider, ctx: any): Promise<void> {
403
+ if (provider.local) {
404
+ const port = provider.id === "ollama" ? 11434 : 1234;
405
+ const result = await fetchLiveModels(provider.id, { forceRefresh: true, timeoutMs: 2_500 });
406
+ if (result.source === "live" && result.models.length > 0) {
407
+ provider.models = result.models.map((m) => m.id);
408
+ provider.available = true;
409
+ ctx.ui.notify(
410
+ `${provider.name} is running with ${provider.models.length} model(s).\n`,
411
+ "info",
412
+ );
413
+ } else {
414
+ ctx.ui.notify(
415
+ `${provider.name} not reachable on port ${port}. Start it and re-run \`/phi-init\`.\n`,
416
+ "warning",
417
+ );
418
+ }
419
+ return;
420
+ }
416
421
 
417
- pi.registerCommand("phi-init", {
418
- description: "Initialize Phi Code (legacy alias — prefer /setup for the refined wizard)",
419
- handler: async (args, ctx) => {
422
+ // Cloud provider — API key required.
423
+ ctx.ui.notify(
424
+ `\n${provider.name}\nNote: the key you type will be visible on screen. Stored in ${modelsJsonPath} (chmod 0600 on Unix).`,
425
+ "info",
426
+ );
427
+
428
+ const apiKey = await ctx.ui.input(
429
+ `Enter your ${provider.name} API key`,
430
+ "Paste your key here",
431
+ );
432
+
433
+ if (apiKey === undefined) {
434
+ ctx.ui.notify("Cancelled. No key saved.", "warning");
435
+ return;
436
+ }
437
+ const trimmed = apiKey.trim();
438
+ if (trimmed.length < 5) {
439
+ ctx.ui.notify("Invalid API key (too short). Skipped.\n", "error");
440
+ return;
441
+ }
442
+
443
+ // Persist the key FIRST. Any subsequent failure during live-fetch must
444
+ // not cause the user to lose what they just typed.
445
+ try {
446
+ await persistProviderKey(provider, trimmed);
447
+ process.env[provider.envVar] = trimmed;
448
+ } catch (err) {
420
449
  ctx.ui.notify(
421
- "NOTE: `/phi-init` is the legacy wizard. The refined replacement is `/setup` " +
422
- "(richer flow: Alibaba dual-endpoint, OpenCode Go auto-fetch, ping validation, " +
423
- "separate chat/orchestration assignments, hot-reload integration). " +
424
- "This legacy command still works for backwards compatibility.",
425
- "info",
450
+ `Failed to write ${modelsJsonPath}: ${err instanceof Error ? err.message : String(err)}`,
451
+ "error",
426
452
  );
427
- try {
428
- ctx.ui.notify("╔══════════════════════════════════════╗", "info");
429
- ctx.ui.notify("║ φ Phi Code Setup Wizard ║", "info");
430
- ctx.ui.notify("╚══════════════════════════════════════╝\n", "info");
453
+ return;
454
+ }
431
455
 
432
- // Pre-fetch model specs from OpenRouter (async, cached)
433
- ctx.ui.notify("🔍 Fetching model specs from OpenRouter...", "info");
434
- await fetchModelSpecs();
456
+ // Optional ping informational only, never fatal.
457
+ ctx.ui.setStatus?.("phi-init-ping", `Pinging ${provider.name}...`);
458
+ const ping = await pingProvider(provider.id, trimmed, 5_000).catch((err) => ({
459
+ ok: false,
460
+ error: err instanceof Error ? err.message : String(err),
461
+ }));
462
+ ctx.ui.setStatus?.("phi-init-ping", undefined);
463
+ if (ping.ok) {
464
+ ctx.ui.notify(`${provider.name} ping OK (200).`, "info");
465
+ } else {
466
+ ctx.ui.notify(
467
+ `${provider.name} ping failed: ${ping.error ?? "unknown"}. Key saved anyway — you can retry with \`/keys test ${provider.id}\`.`,
468
+ "warning",
469
+ );
470
+ }
435
471
 
436
- // 1. Detect providers
437
- ctx.ui.notify("🔍 Detecting providers...\n", "info");
472
+ // Fetch live model list (with fallback) and persist it.
473
+ ctx.ui.setStatus?.("phi-init-fetch", `Fetching ${provider.name} models...`);
474
+ const live = await fetchLiveModels(provider.id, {
475
+ apiKey: trimmed,
476
+ forceRefresh: true,
477
+ timeoutMs: 6_000,
478
+ });
479
+ ctx.ui.setStatus?.("phi-init-fetch", undefined);
480
+
481
+ const persistedModels = live.models.map(toPersistedModel);
482
+ try {
483
+ await persistProviderModels(provider, persistedModels);
484
+ } catch (err) {
485
+ ctx.ui.notify(
486
+ `Failed to write provider models to ${modelsJsonPath}: ${err instanceof Error ? err.message : String(err)}`,
487
+ "error",
488
+ );
489
+ return;
490
+ }
491
+
492
+ provider.models = persistedModels.map((m) => m.id);
493
+ provider.available = true;
494
+ const masked = `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
495
+ ctx.ui.notify(
496
+ `${provider.name} configured (${masked}) — ${persistedModels.length} models (source: ${live.source}${live.error ? `, ${live.error}` : ""}).\n`,
497
+ "info",
498
+ );
499
+ }
500
+
501
+ // ─── Command ─────────────────────────────────────────────────────
502
+
503
+ pi.registerCommand("phi-init", {
504
+ description: "Initialize Phi Code (legacy alias — prefer /setup for the refined wizard)",
505
+ handler: async (_args, ctx) => {
506
+ try {
507
+ ctx.ui.notify(
508
+ "NOTE: `/phi-init` is the legacy wizard. The refined replacement is `/setup` " +
509
+ "(richer flow: Alibaba dual-endpoint, OpenCode Go auto-fetch, ping validation, " +
510
+ "separate chat/orchestration assignments, hot-reload integration). " +
511
+ "This legacy command still works for backwards compatibility.",
512
+ "info",
513
+ );
514
+
515
+ ctx.ui.notify(" Phi Code Setup Wizard", "info");
516
+
517
+ // 1. Detect providers (env vars + local servers + previously saved keys)
518
+ ctx.ui.notify("Detecting providers...\n", "info");
438
519
  const providers = detectProviders();
439
520
 
440
- // Also check models.json for previously configured providers
441
- const modelsJsonPath = join(agentDir, "models.json");
442
- try {
443
- const mjContent = await readFile(modelsJsonPath, "utf-8");
444
- const mjConfig = JSON.parse(mjContent);
445
- if (mjConfig.providers) {
446
- for (const [id, config] of Object.entries<any>(mjConfig.providers)) {
447
- // Mark provider as available if it has an API key in models.json
448
- if (config.apiKey) {
449
- const match = providers.find(p =>
450
- id.includes(p.name.toLowerCase().split(" ")[0]) ||
451
- p.name.toLowerCase().replace(/\s+/g, "-") === id
452
- );
453
- if (match) {
454
- match.available = true;
455
- if (config.models?.length > 0) {
456
- match.models = config.models.map((m: any) => m.id || m);
457
- }
458
- }
459
- }
521
+ // Merge in any previously saved providers from models.json.
522
+ const savedConfig = await readModelsConfig();
523
+ for (const [id, config] of Object.entries(savedConfig.providers)) {
524
+ const match = providers.find((p) => p.id === id);
525
+ if (!match) continue;
526
+ if (config?.apiKey) {
527
+ match.available = true;
528
+ if (Array.isArray(config.models) && config.models.length > 0) {
529
+ match.models = config.models.map((m: any) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
460
530
  }
461
531
  }
462
- } catch { /* no models.json yet */ }
532
+ }
463
533
 
464
534
  // Probe local providers (Ollama, LM Studio)
465
535
  await detectLocalProviders(providers);
466
536
 
467
- let available = providers.filter(p => p.available);
468
- const cloudConfigured = available.filter(p => !p.local);
469
-
470
- // Always show provider status and offer to add/change
471
- ctx.ui.notify("**Provider Status:**", "info");
537
+ ctx.ui.notify("Provider Status:", "info");
472
538
  for (const p of providers) {
473
- const status = p.available ? "" : "";
539
+ const status = p.available ? "[ok]" : "[--]";
474
540
  const tag = p.local ? " (local)" : "";
475
541
  const modelCount = p.available ? ` — ${p.models.length} model(s)` : "";
476
542
  ctx.ui.notify(` ${status} ${p.name}${tag}${modelCount}`, "info");
477
543
  }
478
544
 
479
- // No warning needed — the wizard itself handles configuration
480
-
481
- // Provider configuration loop — add as many providers as needed
545
+ // Provider configuration loop
482
546
  let addingProviders = true;
483
547
  while (addingProviders) {
484
548
  const providerOptions = [
485
549
  "Done — continue with current providers",
486
- ...providers.map(p => {
487
- const status = p.available ? "" : "";
550
+ ...providers.map((p) => {
551
+ const status = p.available ? "[ok]" : "[--]";
488
552
  const tag = p.local ? " (local)" : "";
489
553
  const modelCount = p.available ? ` (${p.models.length} models)` : "";
490
554
  return `${status} ${p.name}${tag}${modelCount}`;
491
555
  }),
492
556
  ];
493
- const addProvider = await ctx.ui.select("Configure a provider (add multiple!):", providerOptions);
557
+ const addProvider = await ctx.ui.select(
558
+ "Configure a provider (add multiple!):",
559
+ providerOptions,
560
+ );
494
561
 
495
562
  const choiceIdx = providerOptions.indexOf(addProvider ?? "");
496
- if (choiceIdx <= 0) { // 0 = Done, or cancelled
563
+ if (choiceIdx <= 0) {
497
564
  addingProviders = false;
498
565
  break;
499
566
  }
500
567
 
501
568
  const chosen = providers[choiceIdx - 1];
502
-
503
- if (chosen.local) {
504
- const port = chosen.name === "Ollama" ? 11434 : 1234;
505
- if (!chosen.available) {
506
- ctx.ui.notify(`\n💡 **${chosen.name}** — make sure it's running on port ${port}.`, "info");
507
- ctx.ui.notify("Then restart phi and run `/phi-init` again.\n", "info");
508
- } else {
509
- ctx.ui.notify(`\n✅ **${chosen.name}** is running with ${chosen.models.length} model(s).\n`, "info");
510
- }
511
- } else {
512
- // Cloud provider — choose auth method
513
- const supportsOAuth = ["openai", "anthropic", "google"].includes(
514
- chosen.name.toLowerCase().split(" ")[0]
569
+ try {
570
+ await configureProvider(chosen, ctx);
571
+ } catch (err) {
572
+ // Never bubble up — keep the wizard alive.
573
+ ctx.ui.notify(
574
+ `Provider configuration failed: ${err instanceof Error ? err.message : String(err)}`,
575
+ "error",
515
576
  );
516
-
517
- let authMethod = "api-key";
518
- if (supportsOAuth) {
519
- const authChoice = await ctx.ui.select(
520
- `How to authenticate with ${chosen.name}?`,
521
- ["API Key (paste your key)", "OAuth (browser login via /login)"]
522
- );
523
- if (authChoice?.includes("OAuth")) {
524
- authMethod = "oauth";
525
- }
526
- }
527
-
528
- if (authMethod === "oauth") {
529
- ctx.ui.notify(`\n🔐 **${chosen.name}** — Use \`/login\` after setup to authenticate via OAuth.`, "info");
530
- ctx.ui.notify("OAuth opens a browser window for secure login.\n", "info");
531
- // Mark as available for model assignment (auth will be done via /login)
532
- chosen.available = true;
533
- } else {
534
- // API Key method
535
- ctx.ui.notify(`\n🔑 **${chosen.name}**`, "info");
536
-
537
- const apiKey = await ctx.ui.input(
538
- `Enter your ${chosen.name} API key`,
539
- "Paste your key here"
540
- );
541
-
542
- if (!apiKey || apiKey.trim().length < 5) {
543
- ctx.ui.notify("❌ Invalid API key. Skipped.\n", "error");
544
- } else {
545
- // Save to models.json (merges with existing)
546
- let modelsConfig: any = { providers: {} };
547
- try {
548
- const existing = await readFile(modelsJsonPath, "utf-8");
549
- modelsConfig = JSON.parse(existing);
550
- if (!modelsConfig.providers) modelsConfig.providers = {};
551
- } catch { /* new file */ }
552
-
553
- const providerId = chosen.name.toLowerCase().replace(/\s+/g, "-");
554
- modelsConfig.providers[providerId] = {
555
- baseUrl: chosen.baseUrl,
556
- api: "openai-completions",
557
- apiKey: apiKey.trim(),
558
- models: await Promise.all(chosen.models.map(async (id: string) => {
559
- const spec = await getModelSpec(id);
560
- return {
561
- id,
562
- name: id,
563
- reasoning: spec.reasoning,
564
- input: ["text"],
565
- contextWindow: spec.contextWindow,
566
- maxTokens: spec.maxTokens,
567
- };
568
- })),
569
- };
570
-
571
- await writeFile(modelsJsonPath, JSON.stringify(modelsConfig, null, 2), "utf-8");
572
- process.env[chosen.envVar] = apiKey.trim();
573
- chosen.available = true;
574
-
575
- const masked = apiKey.trim().substring(0, 6) + "..." + apiKey.trim().slice(-4);
576
- ctx.ui.notify(`✅ **${chosen.name}** configured (${masked})`, "info");
577
- ctx.ui.notify(` ${chosen.models.length} models added to \`models.json\`\n`, "info");
578
- }
579
- }
580
577
  }
581
- } // end while (addingProviders)
582
-
583
- // Re-check available after potential additions
584
- available = providers.filter(p => p.available);
578
+ }
585
579
 
580
+ const available = providers.filter((p) => p.available);
586
581
  if (available.length === 0) {
587
- ctx.ui.notify("\n❌ No providers available. Run `/phi-init` again after setting up a provider.", "error");
582
+ ctx.ui.notify(
583
+ "No providers available. Run `/phi-init` again after setting up a provider.",
584
+ "error",
585
+ );
588
586
  return;
589
587
  }
590
588
 
591
589
  const allModels = getAllAvailableModels(providers);
592
- ctx.ui.notify(`\n✅ **${allModels.length} models** available from ${available.length} provider(s).\n`, "info");
593
-
594
- // 2. Assign models to agents (manual)
595
- ctx.ui.notify(`\n📋 **Assign a model to each agent role:**\n`, "info");
590
+ ctx.ui.notify(
591
+ `\n${allModels.length} models available from ${available.length} provider(s).\n`,
592
+ "info",
593
+ );
596
594
 
595
+ // 2. Assign models to agents
596
+ ctx.ui.notify("Assign a model to each agent role:\n", "info");
597
597
  const assignments = await manualMode(allModels, ctx);
598
598
 
599
- // 4. Create directory structure
600
- ctx.ui.notify("\n📁 Creating directories...", "info");
599
+ // 3. Persist everything
600
+ ctx.ui.notify("Creating directories...", "info");
601
601
  await ensureDirs();
602
602
 
603
- // 5. Write routing config
604
- ctx.ui.notify("🔀 Writing routing configuration...", "info");
603
+ ctx.ui.notify("Writing routing configuration...", "info");
605
604
  const routing = createRouting(assignments);
606
- await writeFile(join(agentDir, "routing.json"), JSON.stringify(routing, null, 2), "utf-8");
605
+ await writeFile(
606
+ join(agentDir, "routing.json"),
607
+ JSON.stringify(routing, null, 2),
608
+ "utf-8",
609
+ );
607
610
 
608
- // 6. Copy bundled agents
609
- ctx.ui.notify("🤖 Setting up sub-agents...", "info");
611
+ ctx.ui.notify("Setting up sub-agents...", "info");
610
612
  await copyBundledAgents();
611
613
 
612
- // 7. Create AGENTS.md template
613
- ctx.ui.notify("📝 Creating memory template...", "info");
614
+ ctx.ui.notify("Creating memory template...", "info");
614
615
  await createAgentsTemplate();
615
616
 
616
- // 8. Summary
617
- ctx.ui.notify("\n╔══════════════════════════════════════╗", "info");
618
- ctx.ui.notify("║ ✅ Setup Complete! ║", "info");
619
- ctx.ui.notify("╚══════════════════════════════════════╝\n", "info");
620
-
621
- ctx.ui.notify("**Configuration:**", "info");
622
- ctx.ui.notify(` 📁 Config: ${agentDir}`, "info");
623
- ctx.ui.notify(` 📁 Memory: ${memoryDir}`, "info");
624
- ctx.ui.notify(` 🤖 Agents: ${agentsDir}`, "info");
625
-
626
- ctx.ui.notify("\n**Model Assignments:**", "info");
617
+ ctx.ui.notify("\n Setup Complete!\n", "info");
618
+ ctx.ui.notify("Configuration:", "info");
619
+ ctx.ui.notify(` Config: ${agentDir}`, "info");
620
+ ctx.ui.notify(` Memory: ${memoryDir}`, "info");
621
+ ctx.ui.notify(` Agents: ${agentsDir}`, "info");
622
+ ctx.ui.notify("\nModel Assignments:", "info");
627
623
  for (const role of TASK_ROLES) {
628
624
  const a = assignments[role.key];
629
625
  ctx.ui.notify(` ${role.label}: \`${a.preferred}\` (fallback: \`${a.fallback}\`)`, "info");
630
626
  }
631
627
  ctx.ui.notify(` Default: \`${assignments["default"].preferred}\``, "info");
632
-
633
- ctx.ui.notify("\n**Next steps:**", "info");
634
- ctx.ui.notify(" Edit `~/.phi/memory/AGENTS.md` with your project instructions", "info");
635
- ctx.ui.notify(" Run `/agents` to see available sub-agents", "info");
636
- ctx.ui.notify(" Run `/skills` to see available skills", "info");
637
- ctx.ui.notify(" Run `/benchmark all` to test model performance", "info");
638
- ctx.ui.notify(" • Start coding! 🚀\n", "info");
639
-
628
+ ctx.ui.notify("\nNext steps:", "info");
629
+ ctx.ui.notify(" - Edit `~/.phi/memory/AGENTS.md` with your project instructions", "info");
630
+ ctx.ui.notify(" - Run `/agents` to see available sub-agents", "info");
631
+ ctx.ui.notify(" - Run `/skills` to see available skills", "info");
632
+ ctx.ui.notify(" - Run `/models refresh` to re-fetch the model catalog", "info");
633
+ ctx.ui.notify(" - Start coding!\n", "info");
640
634
  } catch (error) {
641
- ctx.ui.notify(`❌ Setup failed: ${error}`, "error");
635
+ const message = error instanceof Error ? error.message : String(error);
636
+ ctx.ui.notify(`Setup failed: ${message}`, "error");
642
637
  }
643
638
  },
644
639
  });
645
-
646
640
  }