create-oke 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +7 -5
  2. package/package.json +2 -3
  3. package/src/agents-md.ts +1 -1
  4. package/src/ai-setup/apply.ts +66 -23
  5. package/src/ai-setup/catalog.ts +1316 -35
  6. package/src/ai-setup/detect-ollama.ts +48 -0
  7. package/src/ai-setup/from-pref.ts +32 -0
  8. package/src/ai-setup/prompts.ts +430 -486
  9. package/src/ai-setup/recommend.ts +118 -101
  10. package/src/cli.test.ts +36 -7
  11. package/src/cli.ts +21 -16
  12. package/src/customize-flow.test.ts +43 -13
  13. package/src/customize-flow.ts +136 -61
  14. package/src/drivers-catalog.ts +29 -12
  15. package/src/local-okengine.test.ts +59 -0
  16. package/src/local-okengine.ts +137 -0
  17. package/src/scaffold.ts +1 -1
  18. package/src/transform.test.ts +54 -32
  19. package/src/transform.ts +46 -9
  20. package/src/wizard-select.ts +5 -6
  21. package/templates/advanced/.github/workflows/ci.yml +24 -0
  22. package/templates/advanced/.vscode/settings.json +15 -0
  23. package/templates/advanced/README.md +15 -4
  24. package/templates/advanced/drizzle.config.ts +6 -4
  25. package/templates/advanced/oke.config.ts +6 -2
  26. package/templates/advanced/package.json +5 -2
  27. package/templates/advanced/src/app.ts +2 -14
  28. package/templates/advanced/src/core/index.ts +12 -0
  29. package/templates/advanced/src/{core.ts → core/store.ts} +1 -1
  30. package/templates/advanced/src/db/migrations/.gitkeep +0 -0
  31. package/templates/{standard/src → advanced/src/db}/schema.decl.ts +1 -1
  32. package/templates/advanced/src/{seed → db/seed}/index.ts +2 -2
  33. package/templates/advanced/src/flows/notes/index.ts +2 -4
  34. package/templates/advanced/tests/advanced.test.ts +10 -9
  35. package/templates/advanced/tsconfig.json +23 -0
  36. package/templates/standard/.github/workflows/ci.yml +24 -0
  37. package/templates/standard/.vscode/settings.json +15 -0
  38. package/templates/standard/README.md +20 -9
  39. package/templates/standard/drizzle.config.ts +6 -4
  40. package/templates/standard/oke.config.ts +4 -0
  41. package/templates/standard/package.json +5 -2
  42. package/templates/standard/src/app.ts +2 -14
  43. package/templates/standard/src/core/index.ts +12 -0
  44. package/templates/standard/src/{core.ts → core/store.ts} +1 -1
  45. package/templates/standard/src/db/migrations/.gitkeep +0 -0
  46. package/templates/{advanced/src → standard/src/db}/schema.decl.ts +1 -1
  47. package/templates/standard/src/{seed → db/seed}/index.ts +2 -2
  48. package/templates/standard/src/flows/notes/index.ts +2 -4
  49. package/templates/standard/tests/standard.test.ts +13 -10
  50. package/templates/standard/tsconfig.json +23 -0
  51. /package/templates/advanced/src/{channels.ts → core/channels.ts} +0 -0
  52. /package/templates/advanced/src/{gates.ts → core/gates.ts} +0 -0
  53. /package/templates/advanced/src/{vault.ts → core/vault.ts} +0 -0
  54. /package/templates/standard/src/{channels.ts → core/channels.ts} +0 -0
  55. /package/templates/standard/src/{gates.ts → core/gates.ts} +0 -0
  56. /package/templates/standard/src/{vault.ts → core/vault.ts} +0 -0
@@ -1,43 +1,44 @@
1
1
  /**
2
- * Interactive Clack prompts for `oke ai setup`.
2
+ * Interactive Clack prompts for `oke ai setup` / create-oke AI wizard.
3
3
  */
4
4
 
5
- import { isCancel, note, select, text } from "@clack/prompts";
5
+ import { isCancel, note, password, select, text } from "@clack/prompts";
6
6
  import {
7
- CHAT_MODELS,
8
7
  CLOUD_PROVIDERS,
9
- EMBED_MODELS,
10
- VISION_MODELS,
8
+ MODEL_TIERS,
11
9
  cloudChatModels,
10
+ llamaCppModelsForTier,
11
+ modelsForTier,
12
12
  recommendCloudChat,
13
13
  recommendForRole,
14
+ recommendForTier,
15
+ recommendLlamaCppForTier,
14
16
  type CatalogModel,
15
- type CloudModel,
17
+ type ModelTier,
16
18
  } from "./catalog.ts";
17
19
  import type { AiSetupApplyInput } from "./apply.ts";
20
+ import { LLAMA_CPP_IMAGE } from "../drivers-catalog.ts";
18
21
  import {
22
+ detectMachineInfo,
19
23
  detectOllama,
20
24
  detectTotalRamGb,
21
25
  isInstalled,
22
26
  type OllamaDetectResult,
23
27
  } from "./detect-ollama.ts";
24
28
  import {
25
- OS_HEADROOM_GB,
26
- fittingChatModels,
27
- formatMachineSummary,
28
- isTightFit,
29
- modelFitsComfortably,
30
- modelFitsOnMachine,
31
- recommendChatForNeeds,
32
- recommendVisionForNeeds,
33
- type AiNeeds,
34
- type AiPriority,
35
- type AiUseCase,
29
+ formatLlamaCppBanner,
30
+ formatModelRow,
31
+ formatModelTableHeader,
32
+ formatOllamaBanner,
33
+ suggestTierForRam,
36
34
  } from "./recommend.ts";
37
35
 
38
36
  /** Provider menu value. */
39
37
  export type AiSetupProvider =
38
+ | "llama-cpp"
40
39
  | "ollama"
40
+ | "vllm"
41
+ | "sglang"
41
42
  | "openai"
42
43
  | "anthropic"
43
44
  | "gemini"
@@ -45,7 +46,7 @@ export type AiSetupProvider =
45
46
  | "openrouter"
46
47
  | "custom";
47
48
 
48
- /** Sentinel for Back in AI setup selects. */
49
+ /** Sentinel for Back in AI setup selects. */
49
50
  const BACK = "__back__" as const;
50
51
  type Back = typeof BACK;
51
52
 
@@ -62,7 +63,6 @@ export async function askAiSetup(
62
63
  readonly ramGb?: number | null;
63
64
  } = {},
64
65
  ): Promise<AiSetupApplyInput | null> {
65
- // Treat mock as "ask again" — create-oke may pass mock when only docker is Ollama.
66
66
  const rawProvider = options.provider?.trim() ?? "";
67
67
  let provider: AiSetupProvider | undefined =
68
68
  rawProvider === "" || rawProvider === "mock" ? undefined : (rawProvider as AiSetupProvider);
@@ -71,369 +71,347 @@ export async function askAiSetup(
71
71
  const value = await select({
72
72
  message: "AI Provider",
73
73
  options: [
74
+ {
75
+ value: "llama-cpp",
76
+ label: "llama.cpp (Local)",
77
+ hint: "default · Docker Hub ai/ · recommend for your RAM",
78
+ },
74
79
  {
75
80
  value: "ollama",
76
- label: "Ollama (Local)",
81
+ label: "Ollama (Local)",
77
82
  hint: "detect models · recommend for your RAM",
78
83
  },
79
- { value: "openai", label: "◈ OpenAI" },
80
- { value: "anthropic", label: "◉ Anthropic" },
81
- { value: "gemini", label: "◇ Gemini", hint: "OpenAI-compatible proxy URL" },
82
- { value: "lmstudio", label: "▣ LM Studio" },
83
- { value: "openrouter", label: "⇄ OpenRouter" },
84
- { value: "custom", label: "⋯ Custom OpenAI Compatible" },
84
+ {
85
+ value: "vllm",
86
+ label: "vLLM (self-hosted GPU)",
87
+ hint: "multi-user / production concurrency",
88
+ },
89
+ {
90
+ value: "sglang",
91
+ label: "SGLang (self-hosted GPU)",
92
+ hint: "structured / agent workloads",
93
+ },
94
+ { value: "openai", label: "OpenAI" },
95
+ { value: "anthropic", label: "Anthropic" },
96
+ { value: "gemini", label: "Gemini", hint: "OpenAI-compatible proxy URL" },
97
+ { value: "lmstudio", label: "LM Studio" },
98
+ { value: "openrouter", label: "OpenRouter" },
99
+ { value: "custom", label: "Custom OpenAI Compatible" },
85
100
  ],
86
101
  });
87
102
  if (isCancel(value)) return null;
88
103
  provider = String(value) as AiSetupProvider;
89
104
  }
90
105
 
106
+ if (provider === "llama-cpp") {
107
+ return askLlamaCppPath({
108
+ ramGb: options.ramGb === undefined ? detectTotalRamGb() : options.ramGb,
109
+ });
110
+ }
91
111
  if (provider === "ollama") {
92
112
  return askOllamaPath({
93
113
  detect: options.detect ?? detectOllama,
94
114
  ramGb: options.ramGb === undefined ? detectTotalRamGb() : options.ramGb,
95
115
  });
96
116
  }
117
+ if (provider === "vllm" || provider === "sglang") {
118
+ return askSelfHostedGpuPath(provider);
119
+ }
97
120
 
98
121
  return askCloudPath(provider);
99
122
  }
100
123
 
101
124
  /**
102
- * Ollama path — needs quiz detect panel pick mode (with Back).
125
+ * llama.cpp path — bannerSelect model (tier) / Manual (Docker Hub `ai/`).
103
126
  *
104
- * @param options - Detect / RAM
127
+ * @param options - RAM override
105
128
  */
106
- async function askOllamaPath(options: {
107
- readonly detect: () => Promise<OllamaDetectResult>;
129
+ async function askLlamaCppPath(options: {
108
130
  readonly ramGb: number | null;
109
131
  }): Promise<AiSetupApplyInput | null> {
110
- const detected = await options.detect();
111
- const embedRec = recommendForRole("embed");
112
-
113
- // Quiz → panel → mode; ← Back from mode re-asks the quiz (same detect).
114
- for (;;) {
115
- const needs = await askAiNeedsQuiz();
116
- if (needs === null) return null;
117
-
118
- const recommended = recommendChatForNeeds(options.ramGb, needs);
119
- const visionRec = recommendVisionForNeeds(options.ramGb, needs.wantVision);
120
- const fitting = fittingChatModels(options.ramGb);
121
- const tooLarge = CHAT_MODELS.filter((m) => !fitting.some((f) => f.id === m.id));
122
- const comfortable = fitting.filter((m) => modelFitsComfortably(m, options.ramGb));
123
- const tight = fitting.filter((m) => isTightFit(m, options.ramGb));
124
-
125
- const panel = [
126
- formatMachineSummary(options.ramGb, recommended),
127
- "",
128
- "Detected local models",
129
- ...(detected.curatedInstalled.length > 0
130
- ? detected.curatedInstalled.map((m) => `✓ ${m.id}`)
131
- : ["(none from the curated list)"]),
132
- "",
133
- "Fits comfortably (leaves RAM for OS/IDE)",
134
- ...(comfortable.length > 0
135
- ? comfortable.map((m) => {
136
- const star = m.id === recommended.id ? " ⭐ for you" : "";
137
- const have = isInstalled(m.id, detected.installed) ? " · installed" : "";
138
- return `• ${m.id} — ≈${m.ramGb}GB-class${star}${have}`;
139
- })
140
- : ["(none — see tight fits below)"]),
141
- ];
142
- if (tight.length > 0) {
143
- panel.push(
144
- "",
145
- "Fits the tier but tight (little headroom)",
146
- ...tight.map((m) => {
147
- const star = m.id === recommended.id ? " ⭐ for you" : "";
148
- return `• ${m.id} — ≈${m.ramGb}GB-class${star}`;
149
- }),
150
- );
151
- }
152
- if (tooLarge.length > 0) {
153
- panel.push(
154
- "",
155
- `Too large for ~${options.ramGb ?? "?"}GB (needs a bigger machine tier)`,
156
- ...tooLarge.map((m) => `• ${m.id} — ≈${m.ramGb}GB-class`),
157
- );
158
- }
159
- panel.push(
160
- "",
161
- `Tip: Ollama download size ≪ machine RAM · keep ~${OS_HEADROOM_GB}GB free while running.`,
132
+ const machine = detectMachineInfo();
133
+ const ramGb = options.ramGb ?? machine.ramGb;
134
+ note(formatLlamaCppBanner({ ...machine, ramGb }), "llama.cpp");
135
+
136
+ mode: for (;;) {
137
+ const mode = await selectWithBack(
138
+ "How do you want to pick models?",
139
+ [
140
+ {
141
+ value: "select",
142
+ label: "Select model",
143
+ hint: "Ultra Fast · Fast · Balanced · Smart",
144
+ },
145
+ {
146
+ value: "manual",
147
+ label: "Manual model",
148
+ hint: "type any Docker Hub ai/ model id",
149
+ },
150
+ ],
151
+ "select",
152
+ false,
162
153
  );
163
- if (!detected.available) {
164
- panel.push("", "Ollama CLI/server not detected — https://ollama.com");
154
+ if (mode === null) return null;
155
+
156
+ if (mode === "manual") {
157
+ const id = await askOtherModelId("smollm2");
158
+ if (id === null) return null;
159
+ return finishLlamaCpp(id);
165
160
  }
166
- note(panel.join("\n"), "Ollama");
167
-
168
- // Inner mode loop — ← Back here returns to the quiz; Back from confirm stays here.
169
- for (;;) {
170
- const mode = await selectWithBack(
171
- "How do you want to pick models?",
172
- [
173
- {
174
- value: "recommended",
175
- label: "⭐ Use recommended for my answers",
176
- hint: `${recommended.label} · ≈${recommended.ramGb}GB-class (not your full ${options.ramGb ?? "?"}GB RAM)`,
177
- },
178
- {
179
- value: "installed",
180
- label: "✓ Use installed models",
181
- hint: detected.curatedInstalled.length === 0 ? "none detected" : "no download",
182
- },
183
- {
184
- value: "manual",
185
- label: "◆ Select manually",
186
- hint: "curated list · fit / tight labeled",
187
- },
188
- ],
189
- detected.curatedInstalled.some((m) => m.id === recommended.id)
190
- ? "installed"
191
- : "recommended",
161
+
162
+ tier: for (;;) {
163
+ const tierPick = await selectWithBack(
164
+ "Select model",
165
+ MODEL_TIERS.map((t) => ({
166
+ value: t.value,
167
+ label: t.label,
168
+ hint: t.hint,
169
+ })),
170
+ suggestTierForRam(ramGb),
192
171
  true,
193
172
  );
194
- if (mode === null) return null;
195
- if (mode === BACK) break; // outer loop → re-ask quiz
196
-
197
- if (mode === "recommended") {
198
- const pull = [recommended.id, ...(visionRec ? [visionRec.id] : []), embedRec.id].filter(
199
- (id) => detected.available && !isInstalled(id, detected.installed),
173
+ if (tierPick === null) return null;
174
+ if (tierPick === BACK) continue mode;
175
+
176
+ const tier = tierPick as ModelTier;
177
+ const recommended = recommendLlamaCppForTier(tier, ramGb);
178
+
179
+ how: for (;;) {
180
+ const how = await selectWithBack(
181
+ MODEL_TIERS.find((t) => t.value === tier)?.label ?? tier,
182
+ [
183
+ {
184
+ value: "recommended",
185
+ label: "Use recommended",
186
+ hint: formatModelRow(recommended),
187
+ },
188
+ {
189
+ value: "manual",
190
+ label: "Select manually",
191
+ hint: "up to 20 models in this tier",
192
+ },
193
+ ],
194
+ "recommended",
195
+ true,
200
196
  );
201
- if (pull.length > 0 && detected.available) {
202
- const doPull = await selectWithBack(
203
- `Download ${pull.join(", ")}?`,
204
- [
205
- {
206
- value: "yes",
207
- label: "✓ Yes, download",
208
- hint: `${recommended.label} · ≈${recommended.ramGb}GB-class machine (pull ≪ RAM)`,
209
- },
210
- { value: "no", label: "✗ No, write ids only", hint: "pull later yourself" },
211
- ],
212
- "yes",
213
- true,
214
- );
215
- if (doPull === null) return null;
216
- if (doPull === BACK) continue;
217
- if (doPull === "yes") await pullModels(pull);
218
- }
219
- return {
220
- driver: "ollama",
221
- baseUrl: detected.baseUrl,
222
- chatModel: recommended.id,
223
- visionModel: visionRec?.id ?? null,
224
- embedModel: embedRec.id,
225
- };
226
- }
197
+ if (how === null) return null;
198
+ if (how === BACK) continue tier;
227
199
 
228
- if (mode === "installed") {
229
- if (detected.curatedInstalled.length === 0) {
230
- note("No curated models installed — try recommended or manual.", "Ollama");
231
- continue;
200
+ if (how === "recommended") {
201
+ return finishLlamaCpp(recommended.id, recommended);
232
202
  }
233
- const chat =
234
- detected.curatedInstalled.find(
235
- (m) => m.role === "chat" && modelFitsComfortably(m, options.ramGb),
236
- ) ??
237
- detected.curatedInstalled.find(
238
- (m) => m.role === "chat" && modelFitsOnMachine(m, options.ramGb),
239
- ) ??
240
- detected.curatedInstalled.find((m) => m.role === "chat") ??
241
- detected.curatedInstalled[0]!;
242
- const vision = needs.wantVision
243
- ? (detected.curatedInstalled.find((m) => m.role === "vision" && m.id !== chat.id) ??
244
- visionRec)
245
- : null;
246
- const embed = detected.curatedInstalled.find((m) => m.role === "embed") ?? embedRec;
247
- return {
248
- driver: "ollama",
249
- baseUrl: detected.baseUrl,
250
- chatModel: chat.id,
251
- visionModel: vision?.id ?? null,
252
- embedModel: embed.id,
253
- };
254
- }
255
203
 
256
- const manual = await askManualOllama(detected, recommended, needs, options.ramGb, true);
257
- if (manual === null) return null;
258
- if (manual === BACK) continue;
259
- return manual;
204
+ const list = llamaCppModelsForTier(tier);
205
+ note(formatModelTableHeader(), "Select model");
206
+ const picked = await selectWithBack(
207
+ "Pick a model",
208
+ list.map((m) => ({
209
+ value: m.id,
210
+ label: formatModelRow(m),
211
+ hint: m.hint,
212
+ })),
213
+ recommended.id,
214
+ true,
215
+ );
216
+ if (picked === null) return null;
217
+ if (picked === BACK) continue how;
218
+ const model = list.find((m) => m.id === picked);
219
+ return finishLlamaCpp(picked, model);
220
+ }
260
221
  }
261
222
  }
262
223
  }
263
224
 
264
225
  /**
265
- * Short quiz use case + priority + vision.
226
+ * Build apply input for llama.cpp OpenAI-compatible + curated image pin.
266
227
  *
267
- * @returns Needs or null on cancel
228
+ * @param chatId - Docker Hub `ai/` model id (org prefix optional)
229
+ * @param catalog - Optional catalog row (modalities)
268
230
  */
269
- async function askAiNeedsQuiz(): Promise<AiNeeds | null> {
270
- const use = await selectWithBack(
271
- "What will you use local AI for most?",
272
- [
273
- { value: "coding", label: "◆ Coding & agents", hint: "Qwen-family tends to win" },
274
- { value: "general", label: "◎ General chat", hint: "Gemma / Llama" },
275
- { value: "reasoning", label: "◉ Reasoning", hint: "DeepSeek R1-class" },
276
- { value: "balanced", label: "★ Not sure — balanced", hint: "safe default for most devs" },
277
- ],
278
- "balanced",
279
- false,
280
- );
281
- if (use === null || use === BACK) return null;
282
-
283
- const priority = await selectWithBack(
284
- "Speed or quality?",
285
- [
286
- { value: "speed", label: "⚡ Speed", hint: "smaller model · 8GB-class" },
287
- { value: "balanced", label: "★ Balanced", hint: "best fit for 8–16GB machines" },
288
- { value: "quality", label: "◆ Quality", hint: "largest that still leaves RAM free" },
289
- ],
290
- "balanced",
291
- true,
292
- );
293
- if (priority === null) return null;
294
- if (priority === BACK) return askAiNeedsQuiz();
295
-
296
- const vision = await selectWithBack(
297
- "Need a vision model (images)?",
298
- [
299
- { value: "yes", label: "✓ Yes", hint: "adds a small VL model if RAM allows" },
300
- { value: "no", label: "✗ No", hint: "chat + embed only" },
301
- ],
302
- "no",
303
- true,
304
- );
305
- if (vision === null) return null;
306
- if (vision === BACK) return askAiNeedsQuiz();
307
-
231
+ function finishLlamaCpp(chatId: string, catalog?: CatalogModel): AiSetupApplyInput {
232
+ const id = chatId.replace(/^ai\//, "");
308
233
  return {
309
- useCase: use as AiUseCase,
310
- priority: priority as AiPriority,
311
- wantVision: vision === "yes",
234
+ driver: "openai-compatible",
235
+ baseUrl: process.env.OKE_AI_URL ?? "http://127.0.0.1:8080/v1",
236
+ chatModel: id,
237
+ visionModel: catalog?.modalities.includes("vision") ? id : null,
238
+ embedModel: null,
239
+ image: LLAMA_CPP_IMAGE,
312
240
  };
313
241
  }
314
242
 
315
243
  /**
316
- * Manual chat vision embed (models that fit listed first).
244
+ * vLLM / SGLang Hugging Face model path for self-hosted GPU inference.
317
245
  *
318
- * @param detected - Detect result
319
- * @param recommended - Recommended chat
320
- * @param needs - Quiz answers (vision optional)
321
- * @param ramGb - Machine RAM
322
- * @param allowBack - Offer ← Back to mode menu
246
+ * @param provider - vllm | sglang
323
247
  */
324
- async function askManualOllama(
325
- detected: OllamaDetectResult,
326
- recommended: CatalogModel,
327
- needs: AiNeeds,
328
- ramGb: number | null,
329
- allowBack: boolean,
330
- ): Promise<AiSetupApplyInput | Back | null> {
331
- const ordered = [
332
- ...fittingChatModels(ramGb),
333
- ...CHAT_MODELS.filter((m) => !modelFitsOnMachine(m, ramGb)),
334
- ];
335
- const seen = new Set<string>();
336
- const chatList = ordered.filter((m) => {
337
- if (seen.has(m.id)) return false;
338
- seen.add(m.id);
339
- return true;
248
+ async function askSelfHostedGpuPath(
249
+ provider: "vllm" | "sglang",
250
+ ): Promise<AiSetupApplyInput | null> {
251
+ const port = provider === "vllm" ? 8000 : 30000;
252
+ const image =
253
+ provider === "vllm" ? "vllm/vllm-openai:v0.26.0" : "lmsysorg/sglang:v0.5.16-runtime";
254
+ const model = await text({
255
+ message: "Hugging Face model id",
256
+ placeholder: "Qwen/Qwen3-0.6B",
257
+ initialValue: "Qwen/Qwen3-0.6B",
258
+ validate: (v) => {
259
+ if (!v?.trim()) return "Model id required";
260
+ return undefined;
261
+ },
340
262
  });
263
+ if (isCancel(model)) return null;
264
+ return {
265
+ driver: "openai-compatible",
266
+ baseUrl: process.env.OKE_AI_URL ?? `http://127.0.0.1:${port}/v1`,
267
+ chatModel: String(model).trim(),
268
+ visionModel: null,
269
+ embedModel: null,
270
+ image,
271
+ };
272
+ }
341
273
 
342
- let chat = recommended.id;
343
- let vision: string | null | undefined = needs.wantVision
344
- ? (recommendVisionForNeeds(ramGb, true)?.id ?? recommendForRole("vision").id)
345
- : null;
346
-
347
- while (true) {
348
- const chatPick = await pickModel(
349
- "Select Chat Model",
350
- chatList,
351
- chat,
352
- false,
274
+ /**
275
+ * Ollama path banner Select model (tier) / Manual model.
276
+ *
277
+ * @param options - Detect / RAM
278
+ */
279
+ async function askOllamaPath(options: {
280
+ readonly detect: () => Promise<OllamaDetectResult>;
281
+ readonly ramGb: number | null;
282
+ }): Promise<AiSetupApplyInput | null> {
283
+ const detected = await options.detect();
284
+ const machine = detectMachineInfo();
285
+ const ramGb = options.ramGb ?? machine.ramGb;
286
+ const detectedIds =
287
+ detected.installed.length > 0
288
+ ? detected.installed.slice(0, 12)
289
+ : detected.curatedInstalled.map((m) => m.id);
290
+
291
+ note(formatOllamaBanner({ ...machine, ramGb }, detectedIds), "Ollama");
292
+
293
+ mode: for (;;) {
294
+ const mode = await selectWithBack(
295
+ "How do you want to pick models?",
296
+ [
297
+ {
298
+ value: "select",
299
+ label: "Select model",
300
+ hint: "Ultra Fast · Fast · Balanced · Smart",
301
+ },
302
+ {
303
+ value: "manual",
304
+ label: "Manual model",
305
+ hint: "type any Ollama model id",
306
+ },
307
+ ],
308
+ "select",
353
309
  false,
354
- allowBack,
355
- ramGb,
356
310
  );
357
- if (chatPick === null) return null;
358
- if (chatPick === BACK) return BACK;
359
- if (typeof chatPick !== "string") continue;
360
- chat = chatPick;
361
-
362
- if (needs.wantVision) {
363
- const visionPick = await pickModel(
364
- "Vision Model",
365
- VISION_MODELS,
366
- vision ?? recommendForRole("vision").id,
367
- false,
368
- true,
369
- true,
370
- ramGb,
371
- );
372
- if (visionPick === null) return null;
373
- if (visionPick === BACK) continue;
374
- vision = visionPick === undefined ? null : visionPick;
375
- }
311
+ if (mode === null) return null;
376
312
 
377
- const embedPick = await pickModel(
378
- "Embedding Model",
379
- EMBED_MODELS,
380
- recommendForRole("embed").id,
381
- false,
382
- false,
383
- true,
384
- ramGb,
385
- );
386
- if (embedPick === null) return null;
387
- if (embedPick === BACK) continue;
388
- if (typeof embedPick !== "string") continue;
313
+ if (mode === "manual") {
314
+ const id = await askOtherModelId("gemma4:e4b");
315
+ if (id === null) return null;
316
+ return finishOllama(id, detected);
317
+ }
389
318
 
390
- const needed = [chat, ...(typeof vision === "string" ? [vision] : []), embedPick].filter(
391
- (id) => detected.available && !isInstalled(id, detected.installed),
392
- );
393
- if (needed.length > 0 && detected.available) {
394
- const doPull = await selectWithBack(
395
- `Download missing (${needed.join(", ")})?`,
396
- [
397
- { value: "yes", label: "✓ Yes", hint: "pull size ≪ machine RAM" },
398
- { value: "no", label: "✗ No" },
399
- ],
400
- "yes",
319
+ tier: for (;;) {
320
+ const tierPick = await selectWithBack(
321
+ "Select model",
322
+ MODEL_TIERS.map((t) => ({
323
+ value: t.value,
324
+ label: t.label,
325
+ hint: t.hint,
326
+ })),
327
+ suggestTierForRam(ramGb),
401
328
  true,
402
329
  );
403
- if (doPull === null) return null;
404
- if (doPull === BACK) continue;
405
- if (doPull === "yes") await pullModels(needed);
406
- }
330
+ if (tierPick === null) return null;
331
+ if (tierPick === BACK) continue mode;
332
+
333
+ const tier = tierPick as ModelTier;
334
+ const recommended = recommendForTier(tier, ramGb);
335
+
336
+ how: for (;;) {
337
+ const how = await selectWithBack(
338
+ MODEL_TIERS.find((t) => t.value === tier)?.label ?? tier,
339
+ [
340
+ {
341
+ value: "recommended",
342
+ label: "Use recommended",
343
+ hint: formatModelRow(recommended),
344
+ },
345
+ {
346
+ value: "manual",
347
+ label: "Select manually",
348
+ hint: "up to 10 models in this tier",
349
+ },
350
+ ],
351
+ "recommended",
352
+ true,
353
+ );
354
+ if (how === null) return null;
355
+ if (how === BACK) continue tier;
407
356
 
408
- return {
409
- driver: "ollama",
410
- baseUrl: detected.baseUrl,
411
- chatModel: chat,
412
- visionModel: typeof vision === "string" ? vision : null,
413
- embedModel: embedPick,
414
- };
357
+ if (how === "recommended") {
358
+ return finishOllama(recommended.id, detected, recommended);
359
+ }
360
+
361
+ const list = modelsForTier(tier);
362
+ note(formatModelTableHeader(), "Select model");
363
+ const picked = await selectWithBack(
364
+ "Pick a model",
365
+ list.map((m) => ({
366
+ value: m.id,
367
+ label: formatModelRow(m),
368
+ hint: isInstalled(m.id, detected.installed) ? "installed" : m.hint,
369
+ })),
370
+ recommended.id,
371
+ true,
372
+ );
373
+ if (picked === null) return null;
374
+ if (picked === BACK) continue how;
375
+ const model = list.find((m) => m.id === picked);
376
+ return finishOllama(picked, detected, model);
377
+ }
378
+ }
415
379
  }
416
380
  }
417
381
 
418
382
  /**
419
- * Select with optional trailing ← Back.
383
+ * Build apply input for Ollama — embed default, optional silent pull.
384
+ *
385
+ * @param chatId - Chat model id
386
+ * @param detected - Detect result
387
+ * @param catalog - Optional catalog row (modalities)
420
388
  */
421
- async function selectWithBack(
422
- message: string,
423
- options: readonly { value: string; label: string; hint?: string }[],
424
- initialValue: string,
425
- allowBack: boolean,
426
- ): Promise<string | Back | null> {
427
- const list = [...options, ...(allowBack ? [{ value: BACK, label: "← Back" }] : [])];
428
- const value = await select({ message, options: list, initialValue });
429
- if (isCancel(value)) return null;
430
- const picked = String(value);
431
- if (picked === BACK) return BACK;
432
- return picked;
389
+ async function finishOllama(
390
+ chatId: string,
391
+ detected: OllamaDetectResult,
392
+ catalog?: CatalogModel,
393
+ ): Promise<AiSetupApplyInput> {
394
+ const embed = recommendForRole("embed");
395
+ const visionModel = catalog?.modalities.includes("vision") ? chatId : null;
396
+
397
+ const needed = [chatId, embed.id].filter(
398
+ (id) => detected.available && !isInstalled(id, detected.installed),
399
+ );
400
+ if (needed.length > 0 && detected.available) {
401
+ await pullModels(needed, detected.baseUrl);
402
+ }
403
+
404
+ return {
405
+ driver: "ollama",
406
+ baseUrl: detected.baseUrl,
407
+ chatModel: chatId,
408
+ visionModel,
409
+ embedModel: embed.id,
410
+ };
433
411
  }
434
412
 
435
413
  /**
436
- * Cloud / openai-compatible path — curated models + Other (type id).
414
+ * Cloud / openai-compatible path — API token Select model / Manual.
437
415
  *
438
416
  * @param provider - Menu provider
439
417
  */
@@ -443,83 +421,136 @@ async function askCloudPath(provider: AiSetupProvider): Promise<AiSetupApplyInpu
443
421
 
444
422
  let baseUrl: string | undefined = meta?.baseUrl;
445
423
  if (provider === "custom" || provider === "gemini" || !baseUrl) {
446
- const urlValue = await text({
447
- message:
448
- provider === "gemini"
449
- ? "OpenAI-compatible base URL for Gemini"
450
- : "OpenAI-compatible base URL",
451
- placeholder: baseUrl ?? "https://api.example.com/v1",
452
- initialValue: baseUrl ?? "",
424
+ if (provider !== "anthropic") {
425
+ const urlValue = await text({
426
+ message:
427
+ provider === "gemini"
428
+ ? "OpenAI-compatible base URL for Gemini"
429
+ : "OpenAI-compatible base URL",
430
+ placeholder: baseUrl ?? "https://api.example.com/v1",
431
+ initialValue: baseUrl ?? "",
432
+ });
433
+ if (isCancel(urlValue)) return null;
434
+ const trimmed = String(urlValue).trim();
435
+ baseUrl = trimmed.length > 0 ? trimmed : undefined;
436
+ }
437
+ }
438
+
439
+ const apiKeyEnv = meta?.apiKeyEnv;
440
+ let apiKey: string | undefined;
441
+ if (apiKeyEnv) {
442
+ const token = await password({
443
+ message: `API token (${apiKeyEnv})`,
444
+ validate: (v) => {
445
+ if (!v?.trim()) return "API token is required";
446
+ return undefined;
447
+ },
453
448
  });
454
- if (isCancel(urlValue)) return null;
455
- const trimmed = String(urlValue).trim();
456
- baseUrl = trimmed.length > 0 ? trimmed : undefined;
449
+ if (isCancel(token)) return null;
450
+ apiKey = String(token).trim();
457
451
  }
458
452
 
459
- const models = cloudChatModels(provider);
460
- const chat = await pickCloudModel("Select Chat Model", models, recommendCloudChat(provider));
461
- if (chat === null) return null;
453
+ for (;;) {
454
+ const mode = await selectWithBack(
455
+ "How do you want to pick models?",
456
+ [
457
+ {
458
+ value: "select",
459
+ label: "Select model",
460
+ hint: "up to 10 latest curated models",
461
+ },
462
+ {
463
+ value: "manual",
464
+ label: "Manual model",
465
+ hint: "type any model id",
466
+ },
467
+ ],
468
+ "select",
469
+ false,
470
+ );
471
+ if (mode === null) return null;
472
+
473
+ if (mode === "manual") {
474
+ const id = await askOtherModelId(recommendCloudChat(provider));
475
+ if (id === null) return null;
476
+ return {
477
+ driver,
478
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
479
+ chatModel: id,
480
+ visionModel: null,
481
+ embedModel: null,
482
+ ...(apiKeyEnv ? { apiKeyEnv, ...(apiKey ? { apiKey } : {}) } : {}),
483
+ };
484
+ }
462
485
 
463
- const apiKeyEnv = driver === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
486
+ const models = cloudChatModels(provider);
487
+ if (models.length === 0) {
488
+ const id = await askOtherModelId(recommendCloudChat(provider));
489
+ if (id === null) return null;
490
+ return {
491
+ driver,
492
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
493
+ chatModel: id,
494
+ visionModel: null,
495
+ embedModel: null,
496
+ ...(apiKeyEnv ? { apiKeyEnv, ...(apiKey ? { apiKey } : {}) } : {}),
497
+ };
498
+ }
464
499
 
465
- return {
466
- driver,
467
- ...(baseUrl !== undefined ? { baseUrl } : {}),
468
- chatModel: chat,
469
- visionModel: null,
470
- embedModel: null,
471
- apiKeyEnv,
472
- };
473
- }
500
+ const initial = recommendCloudChat(provider);
501
+ const chat = await selectWithBack(
502
+ "Select model",
503
+ models.map((m) => ({
504
+ value: m.id,
505
+ label: m.label,
506
+ hint: m.hint,
507
+ })),
508
+ models.some((m) => m.id === initial) ? initial : models[0]!.id,
509
+ true,
510
+ );
511
+ if (chat === null) return null;
512
+ if (chat === BACK) continue;
474
513
 
475
- /**
476
- * Format a catalog row — for Ollama, annotate fit vs too-large.
477
- *
478
- * @param m - Catalog / cloud model
479
- * @param ramGb - Machine RAM (ollama only)
480
- * @param recommendedId - Id marked "for you"
481
- */
482
- function catalogOption(
483
- m: CatalogModel | CloudModel,
484
- ramGb: number | null = null,
485
- recommendedId?: string,
486
- ): { value: string; label: string; hint: string } {
487
- const isRec = m.recommended || m.id === recommendedId;
488
- if ("ramGb" in m && typeof m.ramGb === "number") {
489
- const onMachine = modelFitsOnMachine(m, ramGb);
490
- const comfortable = modelFitsComfortably(m, ramGb);
491
- const fitLabel = !onMachine
492
- ? "too large for this machine"
493
- : comfortable
494
- ? "fits comfortably"
495
- : "tight fit";
496
514
  return {
497
- value: m.id,
498
- label: isRec ? `⭐ ${m.label} (for you)` : ` ${m.label}`,
499
- hint: `${m.hint} · ${fitLabel}`,
515
+ driver,
516
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
517
+ chatModel: chat,
518
+ visionModel: null,
519
+ embedModel: null,
520
+ ...(apiKeyEnv ? { apiKeyEnv, ...(apiKey ? { apiKey } : {}) } : {}),
500
521
  };
501
522
  }
502
- return {
503
- value: m.id,
504
- label: isRec ? `⭐ ${m.label} (Recommended)` : ` ${m.label}`,
505
- hint: m.hint,
506
- };
507
523
  }
508
524
 
509
- const OTHER_MODEL = "__other__";
525
+ /**
526
+ * Select with optional trailing Back.
527
+ */
528
+ async function selectWithBack(
529
+ message: string,
530
+ options: readonly { value: string; label: string; hint?: string }[],
531
+ initialValue: string,
532
+ allowBack: boolean,
533
+ ): Promise<string | Back | null> {
534
+ const list = [...options, ...(allowBack ? [{ value: BACK, label: "Back" }] : [])];
535
+ const value = await select({ message, options: list, initialValue });
536
+ if (isCancel(value)) return null;
537
+ const picked = String(value);
538
+ if (picked === BACK) return BACK;
539
+ return picked;
540
+ }
510
541
 
511
542
  /**
512
- * Ask for a custom model id when the user picks Other.
543
+ * Ask for a custom model id.
513
544
  *
514
545
  * @param placeholder - Example id
515
546
  */
516
547
  async function askOtherModelId(placeholder: string): Promise<string | null> {
517
548
  const value = await text({
518
- message: "Model id",
549
+ message: "Model name",
519
550
  placeholder,
520
551
  initialValue: "",
521
552
  validate: (v) => {
522
- if (!v?.trim()) return "Model id is required";
553
+ if (!v?.trim()) return "Model name is required";
523
554
  return undefined;
524
555
  },
525
556
  });
@@ -528,122 +559,35 @@ async function askOtherModelId(placeholder: string): Promise<string | null> {
528
559
  }
529
560
 
530
561
  /**
531
- * Pick from a curated cloud list + Other (type any id).
532
- *
533
- * @param message - Prompt
534
- * @param models - Curated list
535
- * @param initial - Default id
536
- */
537
- async function pickCloudModel(
538
- message: string,
539
- models: readonly CloudModel[],
540
- initial: string,
541
- ): Promise<string | null> {
542
- const options: { value: string; label: string; hint?: string }[] = [
543
- ...models.map((m) => catalogOption(m)),
544
- {
545
- value: OTHER_MODEL,
546
- label: "⋯ Other…",
547
- hint: "type any model id",
548
- },
549
- ];
550
- const initialValue = models.some((m) => m.id === initial) ? initial : OTHER_MODEL;
551
- const value = await select({
552
- message,
553
- options,
554
- initialValue,
555
- });
556
- if (isCancel(value)) return null;
557
- const picked = String(value);
558
- if (picked === OTHER_MODEL) {
559
- return askOtherModelId(initial || "model-id");
560
- }
561
- return picked;
562
- }
563
-
564
- /**
565
- * Pick from a curated list, with optional Show all + Skip + Other + Back.
566
- *
567
- * @returns model id, null cancel, undefined skip, BACK
568
- */
569
- async function pickModel(
570
- message: string,
571
- models: readonly CatalogModel[],
572
- initial: string,
573
- showAllHint: boolean,
574
- allowSkip = false,
575
- allowBack = false,
576
- ramGb: number | null = null,
577
- ): Promise<string | Back | null | undefined> {
578
- const short = models.slice(0, 5);
579
- const options: { value: string; label: string; hint?: string }[] = short.map((m) =>
580
- catalogOption(m, ramGb, initial),
581
- );
582
- if (showAllHint && models.length > short.length) {
583
- options.push({ value: "__all__", label: "⋯ Show all models", hint: "full curated catalog" });
584
- }
585
- options.push({
586
- value: OTHER_MODEL,
587
- label: "⋯ Other…",
588
- hint: "type any model id",
589
- });
590
- if (allowSkip) {
591
- options.push({ value: "__skip__", label: "⊘ Skip", hint: "no vision model" });
592
- }
593
- if (allowBack) {
594
- options.push({ value: BACK, label: "← Back" });
595
- }
596
-
597
- const value = await select({
598
- message,
599
- options,
600
- initialValue: models.some((m) => m.id === initial) ? initial : short[0]?.id,
601
- });
602
- if (isCancel(value)) return null;
603
- const picked = String(value);
604
- if (picked === BACK) return BACK;
605
- if (picked === "__skip__") return undefined;
606
- if (picked === OTHER_MODEL) {
607
- return askOtherModelId(initial);
608
- }
609
- if (picked === "__all__") {
610
- const allOptions = [
611
- ...models.map((m) => catalogOption(m, ramGb, initial)),
612
- { value: OTHER_MODEL, label: "⋯ Other…", hint: "type any model id" },
613
- ...(allowSkip ? [{ value: "__skip__", label: "⊘ Skip", hint: "no vision model" }] : []),
614
- ...(allowBack ? [{ value: BACK, label: "← Back" }] : []),
615
- ];
616
- const allValue = await select({
617
- message,
618
- options: allOptions,
619
- initialValue: initial,
620
- });
621
- if (isCancel(allValue)) return null;
622
- const allPicked = String(allValue);
623
- if (allPicked === BACK) return BACK;
624
- if (allPicked === "__skip__") return undefined;
625
- if (allPicked === OTHER_MODEL) return askOtherModelId(initial);
626
- return allPicked;
627
- }
628
- return picked;
629
- }
630
-
631
- /**
632
- * Run `ollama pull` for each model with a spinner-friendly console log.
562
+ * Pull each model via the Ollama HTTP API (`POST /api/pull`).
633
563
  *
634
564
  * @param models - Model ids
565
+ * @param baseUrl - Ollama server base URL
635
566
  */
636
- async function pullModels(models: readonly string[]): Promise<void> {
567
+ async function pullModels(models: readonly string[], baseUrl: string): Promise<void> {
568
+ const base = baseUrl.replace(/\/+$/, "");
637
569
  for (const id of models) {
638
- console.log(`ollama pull ${id}…`);
639
- const proc = Bun.spawn(["ollama", "pull", id], {
640
- stdout: "inherit",
641
- stderr: "inherit",
642
- stdin: "inherit",
643
- });
644
- const code = await proc.exited;
645
- if (code !== 0) {
646
- console.error(`oke ai setup: ollama pull ${id} failed (continuing)`);
570
+ console.log(`ollama: pulling ${id} via ${base}/api/pull…`);
571
+ try {
572
+ const tags = await fetch(`${base}/api/tags`);
573
+ if (!tags.ok) {
574
+ throw new Error(`GET ${base}/api/tags → ${tags.status}`);
575
+ }
576
+ const res = await fetch(`${base}/api/pull`, {
577
+ method: "POST",
578
+ headers: { "content-type": "application/json" },
579
+ body: JSON.stringify({ model: id, stream: false }),
580
+ });
581
+ if (!res.ok) {
582
+ const body = await res.text().catch(() => "");
583
+ throw new Error(
584
+ `POST ${base}/api/pull → ${res.status}${body ? ` ${body.slice(0, 120)}` : ""}`,
585
+ );
586
+ }
587
+ await res.arrayBuffer();
588
+ } catch (err) {
589
+ const msg = err instanceof Error ? err.message : String(err);
590
+ console.error(`oke ai setup: pull ${id} failed (continuing) — ${msg}`);
647
591
  }
648
592
  }
649
593
  }