create-oke 0.6.0 → 0.7.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 (72) hide show
  1. package/README.md +23 -8
  2. package/package.json +3 -3
  3. package/src/ai-setup/apply.ts +214 -0
  4. package/src/ai-setup/catalog.ts +263 -0
  5. package/src/ai-setup/detect-ollama.ts +180 -0
  6. package/src/ai-setup/from-pref.ts +99 -0
  7. package/src/ai-setup/prompts.ts +649 -0
  8. package/src/ai-setup/recommend.ts +203 -0
  9. package/src/cli.test.ts +385 -28
  10. package/src/cli.ts +332 -29
  11. package/src/create-defaults.test.ts +92 -0
  12. package/src/create-defaults.ts +246 -0
  13. package/src/customize-flow.test.ts +82 -0
  14. package/src/customize-flow.ts +432 -0
  15. package/src/drivers-catalog.ts +217 -0
  16. package/src/scaffold.ts +97 -10
  17. package/src/templates.ts +14 -9
  18. package/src/transform.test.ts +105 -0
  19. package/src/transform.ts +227 -0
  20. package/src/wizard-select.ts +50 -0
  21. package/{template → templates/advanced}/.env.example +40 -2
  22. package/templates/advanced/README.md +32 -0
  23. package/templates/advanced/oke.config.ts +74 -0
  24. package/templates/advanced/package.json +18 -0
  25. package/templates/advanced/src/app.ts +30 -0
  26. package/templates/advanced/src/channels.ts +13 -0
  27. package/templates/advanced/src/core.ts +8 -0
  28. package/{template → templates/advanced}/src/flows/main/index.ts +9 -6
  29. package/templates/advanced/src/flows/main/shapes.ts +1 -0
  30. package/templates/advanced/src/flows/main/signals.ts +1 -0
  31. package/templates/advanced/src/flows/notes/index.ts +224 -0
  32. package/templates/advanced/src/flows/notes/shapes.ts +52 -0
  33. package/templates/advanced/src/flows/notes/signals.ts +13 -0
  34. package/templates/advanced/src/gates.ts +12 -0
  35. package/templates/advanced/src/locales/ar.ts +18 -0
  36. package/templates/advanced/src/locales/en.ts +22 -0
  37. package/templates/advanced/src/schema.decl.ts +13 -0
  38. package/templates/advanced/src/vault.ts +7 -0
  39. package/templates/advanced/tests/advanced.test.ts +42 -0
  40. package/templates/standard/.env.example +94 -0
  41. package/templates/standard/README.md +46 -0
  42. package/templates/standard/drizzle.config.ts +25 -0
  43. package/{template → templates/standard}/oke.config.ts +9 -9
  44. package/{template → templates/standard}/package.json +1 -1
  45. package/templates/standard/src/app.ts +30 -0
  46. package/templates/standard/src/channels.ts +13 -0
  47. package/{template → templates/standard}/src/core.ts +1 -0
  48. package/templates/standard/src/flows/main/index.ts +34 -0
  49. package/templates/standard/src/flows/main/shapes.ts +1 -0
  50. package/templates/standard/src/flows/main/signals.ts +1 -0
  51. package/templates/standard/src/flows/notes/index.ts +143 -0
  52. package/templates/standard/src/flows/notes/shapes.ts +26 -0
  53. package/templates/standard/src/flows/notes/signals.ts +13 -0
  54. package/templates/standard/src/gates.ts +12 -0
  55. package/templates/standard/src/locales/ar.ts +18 -0
  56. package/templates/standard/src/locales/en.ts +22 -0
  57. package/templates/standard/src/schema.decl.ts +13 -0
  58. package/templates/standard/src/vault.ts +7 -0
  59. package/templates/standard/tests/standard.test.ts +51 -0
  60. package/template/README.md +0 -77
  61. package/template/bun.lock +0 -845
  62. package/template/src/app.ts +0 -18
  63. package/template/src/channels.ts +0 -4
  64. package/template/src/flows/main/shapes.ts +0 -2
  65. package/template/src/flows/main/signals.ts +0 -4
  66. package/template/src/gates.ts +0 -4
  67. package/template/src/locales/ar.ts +0 -20
  68. package/template/src/locales/en.ts +0 -26
  69. package/template/src/schema.decl.ts +0 -12
  70. package/template/src/vault.ts +0 -4
  71. package/template/tests/standard.test.ts +0 -18
  72. /package/{template → templates/advanced}/drizzle.config.ts +0 -0
package/src/cli.ts CHANGED
@@ -1,19 +1,50 @@
1
1
  /**
2
- * `create-oke` CLI — one standard starter.
2
+ * `create-oke` CLI — standard|advanced Notes starters with recommended / reuse / customize.
3
3
  *
4
4
  * ```bash
5
- * bunx create-oke@latest <name> [--template standard]
5
+ * bunx create-oke@latest <name> [--template standard|advanced]
6
6
  * bunx create-oke@latest <name> --yes
7
7
  * bunx create-oke@latest # interactive when stdin is a TTY
8
8
  * ```
9
9
  */
10
10
 
11
- import { cancel, confirm, intro, isCancel, note, outro, spinner, text } from "@clack/prompts";
11
+ import {
12
+ cancel,
13
+ confirm,
14
+ intro,
15
+ isCancel,
16
+ log,
17
+ note,
18
+ outro,
19
+ select,
20
+ spinner,
21
+ text,
22
+ } from "@clack/prompts";
12
23
  import { basename, relative, resolve } from "node:path";
13
24
  import { existsSync, rmSync } from "node:fs";
14
25
  import { agentsMdContent } from "./agents-md.ts";
26
+ import {
27
+ createDefaultsPath,
28
+ readCreateDefaults,
29
+ writeCreateDefaults,
30
+ type CreateDefaults,
31
+ type EnvDriverPins,
32
+ } from "./create-defaults.ts";
15
33
  import { docsUrl } from "./docs-origin.ts";
16
- import { scaffold, type ScaffoldResult, type ScaffoldSource } from "./scaffold.ts";
34
+ import { applyAiSetup, type AiSetupApplyInput } from "./ai-setup/apply.ts";
35
+ import {
36
+ aiPrefWithModels,
37
+ applyInputFromAiPref,
38
+ nonInteractiveAiApply,
39
+ } from "./ai-setup/from-pref.ts";
40
+ import { askAiSetup } from "./ai-setup/prompts.ts";
41
+ import { askCustomizeFlow } from "./customize-flow.ts";
42
+ import {
43
+ scaffold,
44
+ targetDirectoryBlockReason,
45
+ type ScaffoldResult,
46
+ type ScaffoldSource,
47
+ } from "./scaffold.ts";
17
48
  import {
18
49
  DEFAULT_TEMPLATE,
19
50
  TEMPLATE_PURPOSES,
@@ -22,6 +53,11 @@ import {
22
53
  type TemplateId,
23
54
  } from "./templates.ts";
24
55
  import { DEFAULT_SQL_DRIVER, SQL_DRIVERS, isSqlDriverId, type SqlDriverId } from "./transform.ts";
56
+ import { WIZARD_BACK } from "./wizard-select.ts";
57
+ export { WIZARD_BACK, selectWithBack, withBackOption, type WizardBack } from "./wizard-select.ts";
58
+
59
+ /** How AI setup is requested on the CLI. */
60
+ export type AiCliMode = "prompt" | "skip" | "force";
25
61
 
26
62
  /** Parsed CLI arguments. */
27
63
  export type CliArgs = {
@@ -43,15 +79,70 @@ export type CliArgs = {
43
79
  readonly install: boolean | undefined;
44
80
  /** Write `AGENTS.md` (default true). */
45
81
  readonly agentsMd: boolean;
82
+ /** AI setup after install. */
83
+ readonly ai: AiCliMode;
46
84
  readonly targetDir: string | undefined;
47
85
  };
48
86
 
87
+ /** Defaults-branch choice after project name. */
88
+ export type DefaultsBranch = "recommended" | "reuse" | "customize";
89
+
90
+ /** One Clack option for the recommended / reuse / customize branch. */
91
+ export type DefaultsBranchOption = {
92
+ readonly value: DefaultsBranch;
93
+ readonly label: string;
94
+ readonly hint: string;
95
+ };
96
+
97
+ /**
98
+ * Build the defaults-branch menu. **Reuse** appears only when previous
99
+ * settings exist for the selected template.
100
+ *
101
+ * @param hasPreviousForTemplate - Matching create-defaults on disk
102
+ * @param template - Selected starter
103
+ */
104
+ export function defaultsBranchOptions(
105
+ hasPreviousForTemplate: boolean,
106
+ template: TemplateId = "standard",
107
+ ): DefaultsBranchOption[] {
108
+ const options: DefaultsBranchOption[] = [
109
+ {
110
+ value: "recommended",
111
+ label: "★ Yes, use recommended defaults",
112
+ hint:
113
+ template === "advanced"
114
+ ? "Notes · docker-ready pins · .oke/mode docker"
115
+ : "Notes · local-first pins · .oke/mode local",
116
+ },
117
+ ];
118
+ if (hasPreviousForTemplate) {
119
+ options.push({
120
+ value: "reuse",
121
+ label: "↻ No, reuse previous settings",
122
+ hint: `~/.oke/create-defaults.json (${template})`,
123
+ });
124
+ }
125
+ options.push({
126
+ value: "customize",
127
+ label: "◆ No, customize settings",
128
+ hint:
129
+ template === "standard"
130
+ ? "local|docker · SQL · optional AI"
131
+ : "local|docker · all facets · optional AI",
132
+ });
133
+ return options;
134
+ }
135
+
49
136
  /** Answers collected by the interactive ask step (no clack types). */
50
137
  export type InteractiveAnswers = {
51
138
  readonly name: string;
52
139
  readonly choice: TemplateId;
53
140
  readonly installAndRun: boolean;
54
141
  readonly agentsMd: boolean;
142
+ /** `undefined` = recommended template defaults (no createDefaults apply). */
143
+ readonly createDefaults: CreateDefaults | undefined;
144
+ /** Model / env apply payload — written after scaffold, before install. */
145
+ readonly aiApply: AiSetupApplyInput | null;
55
146
  };
56
147
 
57
148
  /**
@@ -63,6 +154,9 @@ export type ScaffoldCallArgs = {
63
154
  readonly source: ScaffoldSource;
64
155
  readonly agentsMd: boolean;
65
156
  readonly sqlDriver: SqlDriverId;
157
+ readonly createDefaults?: CreateDefaults;
158
+ /** Apply `src/ai.ts` + `.env.local` after scaffold (before install). */
159
+ readonly aiApply?: AiSetupApplyInput | null;
66
160
  };
67
161
 
68
162
  /**
@@ -80,6 +174,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
80
174
  let yes = false;
81
175
  let install: boolean | undefined;
82
176
  let agentsMd = true;
177
+ let ai: AiCliMode = "prompt";
83
178
  let targetDir: string | undefined;
84
179
 
85
180
  for (let i = 0; i < argv.length; i++) {
@@ -108,6 +203,26 @@ export function parseArgs(argv: readonly string[]): CliArgs {
108
203
  agentsMd = true;
109
204
  continue;
110
205
  }
206
+ if (a === "--no-ai" || a === "--ai=skip") {
207
+ ai = "skip";
208
+ continue;
209
+ }
210
+ if (a === "--ai") {
211
+ const next = argv[i + 1];
212
+ if (next === "skip") {
213
+ i++;
214
+ ai = "skip";
215
+ continue;
216
+ }
217
+ ai = "force";
218
+ continue;
219
+ }
220
+ if (a.startsWith("--ai=")) {
221
+ const value = a.slice("--ai=".length);
222
+ if (value === "skip") ai = "skip";
223
+ else ai = "force";
224
+ continue;
225
+ }
111
226
  if (a === "--sql") {
112
227
  const next = argv[++i];
113
228
  if (!next || !isSqlDriverId(next)) {
@@ -172,6 +287,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
172
287
  yes,
173
288
  install,
174
289
  agentsMd,
290
+ ai,
175
291
  targetDir,
176
292
  };
177
293
  }
@@ -217,7 +333,7 @@ export function helpText(): string {
217
333
  return `create-oke — scaffold an okengine app
218
334
 
219
335
  Usage:
220
- bunx create-oke@latest <name> [--template standard]
336
+ bunx create-oke@latest <name> [--template standard|advanced]
221
337
  bunx create-oke@latest <name> --yes
222
338
  bunx create-oke@latest # interactive (TTY only)
223
339
 
@@ -231,22 +347,22 @@ Options:
231
347
  --no-install Skip bun install
232
348
  --agents-md Write AGENTS.md (default)
233
349
  --no-agents-md Skip AGENTS.md
350
+ --ai Configure AI in the wizard (models before install)
351
+ --no-ai, --ai skip Skip AI configuration
234
352
  -h, --help Show this help
235
353
 
236
354
  Template:
237
355
  ${templateLines}
238
356
 
239
- No telemetry. Bun only. On a TTY, a project name alone still opens the wizard
240
- (confirm name and install). Non-TTY / --yes / --template stay fully scriptable.
357
+ On a TTY: pick standard|advanced, then recommended defaults, customize
358
+ (local or docker first; optional other side; saved to ~/.oke/create-defaults.json),
359
+ or reuse when saved for that template. Non-TTY / --yes stay zero-prompt.
241
360
  `;
242
361
  }
243
362
 
244
363
  /**
245
364
  * Whether the CLI should open the interactive Clack flow.
246
365
  *
247
- * TTY humans get the wizard even when a name is pre-filled. Config flags
248
- * `--template` or `--yes` skips prompts for CI/agents.
249
- *
250
366
  * @param args - Parsed args
251
367
  * @param stdinIsTTY - `process.stdin.isTTY`
252
368
  */
@@ -270,8 +386,6 @@ export function sourceFromArgs(args: CliArgs): ScaffoldSource {
270
386
  /**
271
387
  * Map flag-driven {@link CliArgs} to scaffold call args.
272
388
  *
273
- * Requires a positional project name (and thus `targetDir`).
274
- *
275
389
  * @param args - Parsed CLI args with `name` set
276
390
  */
277
391
  export function scaffoldArgsFromCli(args: CliArgs): ScaffoldCallArgs {
@@ -284,51 +398,99 @@ export function scaffoldArgsFromCli(args: CliArgs): ScaffoldCallArgs {
284
398
  source: sourceFromArgs(args),
285
399
  agentsMd: args.agentsMd,
286
400
  sqlDriver: args.sqlDriver,
401
+ aiApply: args.ai === "force" ? nonInteractiveAiApply("ollama") : null,
287
402
  };
288
403
  }
289
404
 
290
405
  /**
291
406
  * Pure map from interactive answers → scaffold call args.
292
407
  *
293
- * Independent of clack — unit-tested against {@link scaffoldArgsFromCli}
294
- * for the standard template.
295
- *
296
408
  * @param answers - Collected interactive answers
409
+ * @param sqlDriverOverride - Optional `--sql` flag merge
297
410
  */
298
- export function scaffoldArgsFromAnswers(answers: InteractiveAnswers): ScaffoldCallArgs {
411
+ export function scaffoldArgsFromAnswers(
412
+ answers: InteractiveAnswers,
413
+ sqlDriverOverride?: SqlDriverId,
414
+ ): ScaffoldCallArgs {
299
415
  const targetDir = resolve(answers.name.trim());
300
416
  const name = basename(targetDir);
301
- // Interactive always keeps the dual-mode default (local sqlite ·
302
- // docker/prod postgres). Opt into postgres-everywhere with `--sql postgres`.
417
+ let createDefaults = answers.createDefaults;
418
+ let sqlDriver: SqlDriverId = DEFAULT_SQL_DRIVER;
419
+
420
+ if (createDefaults && sqlDriverOverride === "postgres") {
421
+ createDefaults = {
422
+ ...createDefaults,
423
+ drivers: {
424
+ ...createDefaults.drivers,
425
+ store: {
426
+ ...createDefaults.drivers.store,
427
+ sql: {
428
+ local: "postgres",
429
+ docker: "postgres",
430
+ test: createDefaults.drivers.store.sql.test,
431
+ prod: "postgres",
432
+ },
433
+ },
434
+ },
435
+ };
436
+ sqlDriver = "postgres";
437
+ } else if (!createDefaults && sqlDriverOverride) {
438
+ sqlDriver = sqlDriverOverride;
439
+ } else if (createDefaults?.drivers.store.sql.local === "postgres") {
440
+ sqlDriver = "postgres";
441
+ }
442
+
303
443
  return {
304
444
  name,
305
445
  targetDir,
306
446
  source: { kind: "template", id: answers.choice },
307
447
  agentsMd: answers.agentsMd,
308
- sqlDriver: DEFAULT_SQL_DRIVER,
448
+ sqlDriver,
449
+ ...(createDefaults !== undefined ? { createDefaults } : {}),
450
+ aiApply: answers.aiApply,
309
451
  };
310
452
  }
311
453
 
312
454
  /**
313
455
  * Ask step — clack prompts only. Returns answers or `null` on cancel.
314
456
  *
315
- * Injectable for tests; production uses {@link askInteractiveAnswers}.
316
- *
317
457
  * @param partial - Name already known (pre-filled in the prompt)
318
458
  */
319
459
  export type AskInteractiveFn = (partial: {
320
460
  readonly name?: string;
321
461
  readonly agentsMd?: boolean;
462
+ readonly sqlDriver?: SqlDriverId;
463
+ readonly ai?: AiCliMode;
464
+ readonly template?: TemplateId;
322
465
  }) => Promise<InteractiveAnswers | null>;
323
466
 
467
+ /** Injectable persistence seams for tests. */
468
+ export type CreateDefaultsIo = {
469
+ readonly path: string;
470
+ readonly read: () => CreateDefaults | null;
471
+ readonly write: (defaults: CreateDefaults) => void;
472
+ };
473
+
324
474
  /**
325
475
  * Collect interactive answers via `@clack/prompts`.
326
476
  *
327
477
  * @param partial - Optional pre-filled project name / agents-md default
478
+ * @param io - Persistence seams (tests)
328
479
  * @returns Answers, or `null` if the user cancelled
329
480
  */
330
481
  export async function askInteractiveAnswers(
331
- partial: { readonly name?: string; readonly agentsMd?: boolean } = {},
482
+ partial: {
483
+ readonly name?: string;
484
+ readonly agentsMd?: boolean;
485
+ readonly sqlDriver?: SqlDriverId;
486
+ readonly ai?: AiCliMode;
487
+ readonly template?: TemplateId;
488
+ } = {},
489
+ io: CreateDefaultsIo = {
490
+ path: createDefaultsPath(),
491
+ read: () => readCreateDefaults(),
492
+ write: (d) => writeCreateDefaults(d),
493
+ },
332
494
  ): Promise<InteractiveAnswers | null> {
333
495
  const nameValue = await text({
334
496
  message: "Project name",
@@ -336,12 +498,93 @@ export async function askInteractiveAnswers(
336
498
  initialValue: partial.name ?? "my-app",
337
499
  validate: (value) => {
338
500
  if (!value?.trim()) return "Project name is required";
501
+ const blocked = targetDirectoryBlockReason(resolve(value.trim()));
502
+ if (blocked) {
503
+ return blocked.replace(/^create-oke:\s*/, "");
504
+ }
339
505
  return undefined;
340
506
  },
341
507
  });
342
508
  if (isCancel(nameValue)) return null;
343
509
  const name = String(nameValue).trim();
344
510
 
511
+ const templateValue = await select({
512
+ message: "Starter template",
513
+ options: TEMPLATES.map((id) => ({
514
+ value: id,
515
+ label: id === "standard" ? "★ standard" : "◆ advanced",
516
+ hint: TEMPLATE_PURPOSES[id],
517
+ })),
518
+ initialValue: partial.template ?? DEFAULT_TEMPLATE,
519
+ });
520
+ if (isCancel(templateValue)) return null;
521
+ const template = templateValue as TemplateId;
522
+
523
+ let createDefaults: CreateDefaults | undefined;
524
+ let aiApply: AiSetupApplyInput | null = null;
525
+
526
+ for (;;) {
527
+ const previous = io.read();
528
+ const canReuse = previous !== null && previous.template === template;
529
+ const branchValue = await select({
530
+ message: "Would you like to use the recommended oke defaults?",
531
+ options: defaultsBranchOptions(canReuse, template),
532
+ });
533
+ if (isCancel(branchValue)) return null;
534
+ const branch = branchValue as DefaultsBranch;
535
+
536
+ if (branch === "recommended") {
537
+ createDefaults = undefined;
538
+ break;
539
+ }
540
+
541
+ if (branch === "reuse") {
542
+ const prev = io.read() ?? previous;
543
+ if (!prev || prev.template !== template) {
544
+ note(
545
+ "No saved settings for this template — customize or use recommended.",
546
+ "Previous settings",
547
+ );
548
+ continue;
549
+ }
550
+ createDefaults = prev;
551
+ if (prev.ai.enabled && partial.ai !== "skip") {
552
+ aiApply = applyInputFromAiPref(prev.ai);
553
+ if (!aiApply?.chatModel && prev.ai.provider && prev.ai.provider !== "mock") {
554
+ const picked = await askAiSetup({ provider: prev.ai.provider });
555
+ if (picked === null) return null;
556
+ aiApply = picked;
557
+ io.write({
558
+ ...prev,
559
+ ai: aiPrefWithModels(prev.ai, picked),
560
+ updatedAt: new Date().toISOString(),
561
+ });
562
+ }
563
+ }
564
+ note(`Reusing settings from ${io.path}`, "Previous settings");
565
+ break;
566
+ }
567
+
568
+ const customized = await askCustomizeFlow(template);
569
+ if (customized === null) return null;
570
+ if (customized === WIZARD_BACK) continue;
571
+ createDefaults = customized;
572
+ io.write(customized);
573
+ note(`Saved globally for next projects → ${io.path}`, "Defaults");
574
+ if (customized.ai.enabled && partial.ai !== "skip") {
575
+ aiApply = applyInputFromAiPref(customized.ai);
576
+ }
577
+ break;
578
+ }
579
+
580
+ if (partial.ai === "skip") {
581
+ aiApply = null;
582
+ } else if (partial.ai === "force" && !aiApply) {
583
+ const picked = await askAiSetup({});
584
+ if (picked === null) return null;
585
+ aiApply = picked;
586
+ }
587
+
345
588
  const agentsMd = partial.agentsMd ?? true;
346
589
 
347
590
  const installAndRunValue = await confirm({
@@ -352,12 +595,30 @@ export async function askInteractiveAnswers(
352
595
 
353
596
  return {
354
597
  name,
355
- choice: DEFAULT_TEMPLATE,
598
+ choice: template,
356
599
  installAndRun: Boolean(installAndRunValue),
357
600
  agentsMd,
601
+ createDefaults,
602
+ aiApply,
358
603
  };
359
604
  }
360
605
 
606
+ /**
607
+ * Provider passed to `oke ai setup` — prefer Ollama when either env uses it.
608
+ *
609
+ * @param localProvider - Menu id chosen for local
610
+ * @param pins - Resolved driver pins
611
+ */
612
+ export function aiSetupProviderFor(localProvider: string, pins: EnvDriverPins): string {
613
+ if (localProvider === "ollama" || pins.local === "ollama") return "ollama";
614
+ if (pins.docker === "ollama") return "ollama";
615
+ if (localProvider === "mock") {
616
+ if (pins.docker === "anthropic") return "anthropic";
617
+ if (pins.docker === "openai-compatible") return "openai";
618
+ }
619
+ return localProvider;
620
+ }
621
+
361
622
  /**
362
623
  * Resolve whether to run `bun install` after scaffold (non-interactive).
363
624
  *
@@ -373,7 +634,7 @@ export function shouldInstall(args: CliArgs): boolean {
373
634
  * Run the CLI.
374
635
  *
375
636
  * @param argv - Args after the binary
376
- * @param options - Test seams (stdin TTY, ask injection, skip real install/dev)
637
+ * @param options - Test seams
377
638
  * @returns Exit code
378
639
  */
379
640
  export async function run(
@@ -383,6 +644,7 @@ export async function run(
383
644
  readonly ask?: AskInteractiveFn;
384
645
  /** When false, skip spawning bun install / oke dev (tests). Default true. */
385
646
  readonly runPostScaffold?: boolean;
647
+ readonly createDefaultsIo?: CreateDefaultsIo;
386
648
  } = {},
387
649
  ): Promise<number> {
388
650
  let args: CliArgs;
@@ -400,7 +662,18 @@ export async function run(
400
662
 
401
663
  const stdinIsTTY = options.stdinIsTTY ?? process.stdin.isTTY;
402
664
  if (shouldPrompt(args, stdinIsTTY)) {
403
- return runInteractive(args, options.ask ?? askInteractiveAnswers, options);
665
+ const ask =
666
+ options.ask ??
667
+ ((partial) =>
668
+ askInteractiveAnswers(
669
+ partial,
670
+ options.createDefaultsIo ?? {
671
+ path: createDefaultsPath(),
672
+ read: () => readCreateDefaults(),
673
+ write: (d) => writeCreateDefaults(d),
674
+ },
675
+ ));
676
+ return runInteractive(args, ask, options);
404
677
  }
405
678
 
406
679
  if (args.name === undefined) {
@@ -410,15 +683,22 @@ export async function run(
410
683
  return 1;
411
684
  }
412
685
 
686
+ const cliScaffold = scaffoldArgsFromCli(args);
687
+ const earlyBlock = targetDirectoryBlockReason(cliScaffold.targetDir);
688
+ if (earlyBlock) {
689
+ console.error(earlyBlock);
690
+ return 1;
691
+ }
692
+
413
693
  if (args.yes) {
414
694
  const source = sourceFromArgs(args);
415
695
  console.log(
416
- `Using defaults: ${source.kind}=${source.id} sql=${args.sqlDriver} agents-md=${args.agentsMd} install=${shouldInstall(args)}`,
696
+ `Using defaults: ${source.kind}=${source.id} sql=${args.sqlDriver} agents-md=${args.agentsMd} install=${shouldInstall(args)} ai=${args.ai}`,
417
697
  );
418
698
  }
419
699
 
420
700
  return runScaffold({
421
- ...scaffoldArgsFromCli(args),
701
+ ...cliScaffold,
422
702
  interactive: false,
423
703
  install: shouldInstall(args),
424
704
  startDev: false,
@@ -440,14 +720,19 @@ async function runInteractive(
440
720
  ): Promise<number> {
441
721
  intro("create-oke");
442
722
 
443
- const answers = await ask({ name: args.name, agentsMd: args.agentsMd });
723
+ const answers = await ask({
724
+ name: args.name,
725
+ agentsMd: args.agentsMd,
726
+ sqlDriver: args.sqlDriverExplicit ? args.sqlDriver : undefined,
727
+ ai: args.ai,
728
+ });
444
729
  if (answers === null) {
445
730
  cancel("Cancelled.");
446
731
  return 1;
447
732
  }
448
733
 
449
734
  return runScaffold({
450
- ...scaffoldArgsFromAnswers(answers),
735
+ ...scaffoldArgsFromAnswers(answers, args.sqlDriverExplicit ? args.sqlDriver : undefined),
451
736
  interactive: true,
452
737
  install: answers.installAndRun,
453
738
  startDev: answers.installAndRun,
@@ -474,6 +759,8 @@ async function runScaffold(
474
759
  source,
475
760
  agentsMd,
476
761
  sqlDriver,
762
+ createDefaults,
763
+ aiApply,
477
764
  interactive,
478
765
  install,
479
766
  startDev,
@@ -507,9 +794,25 @@ async function runScaffold(
507
794
  source,
508
795
  writeAgentsMd: agentsMd,
509
796
  sqlDriver,
797
+ ...(createDefaults !== undefined ? { createDefaults } : {}),
510
798
  });
511
799
  if (spun) spun.stop("Scaffolded.");
512
800
 
801
+ // Models were chosen in the wizard — write env + src/ai.ts before install
802
+ // so `--no-install` still gets a complete AI project. Preserve per-env
803
+ // `drivers.ai` pins from customize; flatten only when pins were never written.
804
+ if (runPostScaffold && aiApply) {
805
+ try {
806
+ applyAiSetup(targetDir, aiApply, {
807
+ updateDrivers: createDefaults?.drivers.ai == null,
808
+ });
809
+ if (interactive) log.success("AI configured.");
810
+ } catch (err) {
811
+ if (interactive) log.warn("AI config write failed.");
812
+ console.error(err instanceof Error ? err.message : String(err));
813
+ }
814
+ }
815
+
513
816
  if (runPostScaffold && install) {
514
817
  const installSpun = interactive ? spinner() : undefined;
515
818
  installSpun?.start("Installing dependencies…");
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Persistence round-trip for ~/.oke/create-defaults.json
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import {
10
+ createDefaultsPath,
11
+ parseCreateDefaults,
12
+ readCreateDefaults,
13
+ toCreateDefaults,
14
+ writeCreateDefaults,
15
+ } from "./create-defaults.ts";
16
+ import { pinsLocalOnly, recommendedDefaults } from "./drivers-catalog.ts";
17
+
18
+ describe("createDefaultsPath", () => {
19
+ test("joins ~/.oke/create-defaults.json", () => {
20
+ expect(createDefaultsPath("/tmp/home")).toBe(join("/tmp/home", ".oke", "create-defaults.json"));
21
+ });
22
+ });
23
+
24
+ describe("parseCreateDefaults", () => {
25
+ test("rejects corrupt / wrong version", () => {
26
+ expect(parseCreateDefaults(null)).toBeNull();
27
+ expect(parseCreateDefaults({ version: 2 })).toBeNull();
28
+ expect(parseCreateDefaults({ version: 1, profile: "nope" })).toBeNull();
29
+ });
30
+
31
+ test("accepts recommended shape", () => {
32
+ const d = recommendedDefaults("local-only", "standard");
33
+ const parsed = parseCreateDefaults(d);
34
+ expect(parsed?.profile).toBe("local-only");
35
+ expect(parsed?.template).toBe("standard");
36
+ expect(parsed?.drivers.store.sql.local).toBe("sqlite");
37
+ expect(parsed?.ai.enabled).toBe(false);
38
+ });
39
+
40
+ test("older files without template default to standard", () => {
41
+ const d = recommendedDefaults("local-only", "standard");
42
+ const { template: _t, ...rest } = d;
43
+ const parsed = parseCreateDefaults(rest);
44
+ expect(parsed?.template).toBe("standard");
45
+ });
46
+ });
47
+
48
+ describe("read/write round-trip", () => {
49
+ test("write → read returns same drivers", () => {
50
+ const home = mkdtempSync(join(tmpdir(), "oke-create-defaults-"));
51
+ const path = createDefaultsPath(home);
52
+ try {
53
+ const original = toCreateDefaults({
54
+ template: "advanced",
55
+ profile: "docker-ready",
56
+ drivers: {
57
+ store: {
58
+ sql: pinsLocalOnly("libsql", "postgres", "memory"),
59
+ kv: pinsLocalOnly("redis", "redis", "memory"),
60
+ files: pinsLocalOnly("fs", "s3", "memory"),
61
+ index: null,
62
+ },
63
+ signal: pinsLocalOnly("memory", "redis", "memory"),
64
+ clock: pinsLocalOnly("file", "file", "frozen"),
65
+ vault: pinsLocalOnly("env", "openbao", "memory"),
66
+ channel: { email: pinsLocalOnly("console", "smtp", "console") },
67
+ ai: pinsLocalOnly("ollama", "ollama", "mock"),
68
+ },
69
+ ai: { enabled: true, provider: "ollama", driver: "ollama" },
70
+ });
71
+ writeCreateDefaults(original, path);
72
+ const loaded = readCreateDefaults(path);
73
+ expect(loaded).not.toBeNull();
74
+ expect(loaded!.template).toBe("advanced");
75
+ expect(loaded!.drivers.store.sql.local).toBe("libsql");
76
+ expect(loaded!.drivers.ai?.local).toBe("ollama");
77
+ expect(loaded!.ai.provider).toBe("ollama");
78
+ expect(loaded!.profile).toBe("docker-ready");
79
+ } finally {
80
+ rmSync(home, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ test("missing file → null", () => {
85
+ const home = mkdtempSync(join(tmpdir(), "oke-create-defaults-miss-"));
86
+ try {
87
+ expect(readCreateDefaults(createDefaultsPath(home))).toBeNull();
88
+ } finally {
89
+ rmSync(home, { recursive: true, force: true });
90
+ }
91
+ });
92
+ });