create-egregore 0.18.18 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ui.js CHANGED
@@ -209,7 +209,19 @@ function spinner(msg) {
209
209
 
210
210
  // ── prompts ─────────────────────────────────────────────────────────
211
211
 
212
- function prompt(question, { default: defaultValue } = {}) {
212
+ // Footer band — the same visual chrome as the launcher's status band:
213
+ // accent background (the banner's color slot), dark text, padded like a
214
+ // bar. Walks with the active prompt in this scrolling wizard; bg 43
215
+ // (yellow) marks errors/questions that need answering. Any ANSI inside
216
+ // the content is stripped — a RESET mid-line would kill the fill.
217
+ function footerBand(content, bg = 46) {
218
+ const plain = String(content).replace(/\x1b\[[0-9;]*m/g, "");
219
+ if (!COLOR) return ` ${plain}`;
220
+ const w = termWidth() - 3;
221
+ return ` ${esc(bg)}${BLACK}${pad(` ${plain}`, w)}${RESET}`;
222
+ }
223
+
224
+ function promptLegacy(question, { default: defaultValue } = {}) {
213
225
  return new Promise((resolve) => {
214
226
  const rl = readline.createInterface({
215
227
  input: process.stdin,
@@ -226,18 +238,36 @@ function prompt(question, { default: defaultValue } = {}) {
226
238
  });
227
239
  }
228
240
 
229
- // Line input that can step backwards: esc resolves BACK, enter resolves the
230
- // typed value (or the default). Raw-mode single-line editor — append +
231
- // backspace only, which covers the short names these prompts collect.
232
- // Falls back to the plain readline prompt when not interactive.
233
- function promptOrBack(question, { default: defaultValue } = {}) {
234
- if (!canInteract()) return prompt(question, { default: defaultValue });
241
+ // Shared single-line editor: the input line plus ONE dim hint line pinned
242
+ // below it — the dynamic footer every wizard prompt shares, instead of
243
+ // per-label "(esc back)" clutter. opts.back controls whether stepping
244
+ // backwards is possible: with back, ↑ resolves BACK (esc is a silent
245
+ // alias); without it, ↑/esc are no-ops. Esc NEVER quits either way —
246
+ // ctrl-c is the only abort. Accepts multi-char printable chunks so paste
247
+ // works (Telegram links, usernames).
248
+ function lineInput(question, { default: defaultValue, hint, back = false } = {}) {
235
249
  return new Promise((resolve) => {
236
250
  let buf = "";
237
251
  const label = defaultValue
238
- ? ` ${CYAN}?${RESET} ${question} ${DIM}[${defaultValue}]${RESET} ${DIM}(esc back)${RESET} `
239
- : ` ${CYAN}?${RESET} ${question} ${DIM}(esc back)${RESET} `;
240
- const render = () => process.stdout.write(`\r\x1b[2K${label}${buf}`);
252
+ ? ` ${CYAN}?${RESET} ${question} ${DIM}[${defaultValue}]${RESET} `
253
+ : ` ${CYAN}?${RESET} ${question} `;
254
+ const hintLine = footerBand(hint || (back ? "enter accept · ↑ back · ctrl-c quit" : "enter accept · ctrl-c quit"));
255
+ const labelCols = visibleLen(label);
256
+ const render = () => {
257
+ // Input line, a blank margin row, then the footer band — the band
258
+ // needs breathing room or it visually fuses with the input. The real
259
+ // cursor parks at the end of the typed text so keystrokes land where
260
+ // the eye is.
261
+ process.stdout.write(
262
+ `\r\x1b[2K${label}${buf}\n` +
263
+ `\x1b[2K\n` +
264
+ `\x1b[2K${hintLine}` +
265
+ `\x1b[2A\x1b[${labelCols + buf.length + 1}G`,
266
+ );
267
+ };
268
+ const clearHint = () => {
269
+ process.stdout.write(`\x1b[1B\r\x1b[2K\x1b[1B\r\x1b[2K\x1b[2A\x1b[${labelCols + buf.length + 1}G`);
270
+ };
241
271
  const cleanup = () => {
242
272
  process.stdin.setRawMode(false);
243
273
  process.stdin.pause();
@@ -247,17 +277,21 @@ function promptOrBack(question, { default: defaultValue } = {}) {
247
277
  if (!key) return;
248
278
  if (key.ctrl && key.name === "c") {
249
279
  cleanup();
280
+ clearHint();
250
281
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
251
282
  process.exit(130);
252
283
  }
253
- if (key.name === "escape") {
284
+ if (key.name === "up" || key.name === "escape") {
285
+ if (!back) return; // nothing to go back to — and esc never quits
254
286
  cleanup();
287
+ clearHint();
255
288
  process.stdout.write(`\r\x1b[2K${label}${DIM}← back${RESET}\n`);
256
289
  resolve(BACK);
257
290
  return;
258
291
  }
259
292
  if (key.name === "return") {
260
293
  cleanup();
294
+ clearHint();
261
295
  process.stdout.write("\n");
262
296
  resolve(buf.trim() || (defaultValue ?? ""));
263
297
  return;
@@ -267,9 +301,12 @@ function promptOrBack(question, { default: defaultValue } = {}) {
267
301
  render();
268
302
  return;
269
303
  }
270
- if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
271
- buf += _str;
272
- render();
304
+ if (_str && !key.ctrl && !key.meta) {
305
+ const clean = _str.replace(/[\x00-\x1f\x7f]/g, "");
306
+ if (clean) {
307
+ buf += clean;
308
+ render();
309
+ }
273
310
  }
274
311
  };
275
312
  readline.emitKeypressEvents(process.stdin);
@@ -280,9 +317,26 @@ function promptOrBack(question, { default: defaultValue } = {}) {
280
317
  });
281
318
  }
282
319
 
320
+ // Standard prompt — interactive sessions get the hint-line footer, pipes
321
+ // and CI keep the plain readline fallback.
322
+ function prompt(question, opts = {}) {
323
+ if (!canInteract()) return promptLegacy(question, opts);
324
+ return lineInput(question, { ...opts, back: false });
325
+ }
326
+
327
+ // Line input that can step backwards: ↑ resolves BACK (esc is a silent
328
+ // alias), enter resolves the typed value (or the default).
329
+ function promptOrBack(question, opts = {}) {
330
+ if (!canInteract()) return promptLegacy(question, opts);
331
+ return lineInput(question, { ...opts, back: true });
332
+ }
333
+
283
334
  async function confirm(question, defaultYes = false) {
284
- const hint = defaultYes ? `${DIM}[Y/n]${RESET}` : `${DIM}[y/N]${RESET}`;
285
- const answer = await prompt(`${question} ${hint}`);
335
+ const bracket = defaultYes ? `${DIM}[Y/n]${RESET}` : `${DIM}[y/N]${RESET}`;
336
+ const hint = `y yes · n no · enter ${defaultYes ? "yes" : "no"} · ctrl-c quit`;
337
+ const answer = canInteract()
338
+ ? await lineInput(`${question} ${bracket}`, { hint, back: false })
339
+ : await promptLegacy(`${question} ${bracket}`);
286
340
  if (!answer) return defaultYes;
287
341
  return /^y(es)?$/i.test(answer);
288
342
  }
@@ -343,7 +397,7 @@ function renderPicker({ question, filterText, lines, help, input }) {
343
397
  for (const line of lines) out.push(line);
344
398
  if (help) {
345
399
  out.push("");
346
- out.push(` ${DIM}${help}${RESET}`);
400
+ out.push(footerBand(help));
347
401
  }
348
402
  const text = out.join("\n") + "\n";
349
403
  process.stdout.write(text);
@@ -376,10 +430,13 @@ function filterOptions(options, filterText, getText) {
376
430
 
377
431
  // ── back navigation ─────────────────────────────────────────────────
378
432
  //
379
- // Multi-step flows (the founder setup) let the user step backwards with
380
- // esc. Pickers and prompts opt in via `escAction: "back"` — esc then
381
- // resolves to the BACK sentinel instead of aborting the process, and the
382
- // caller's step machine rewinds. Ctrl-C always remains the hard abort.
433
+ // Multi-step flows (the founder setup) let the user step backwards. The
434
+ // advertised keys are spatial — ↑ in text fields/forms, ← in choice lists
435
+ // (enter descends, ← ascends) — with esc as a silent alias everywhere.
436
+ // Esc NEVER quits: users' terminal reflex is that esc ejects them from the
437
+ // CLI, so no esc press may ever reach the shell. Pickers and prompts opt
438
+ // in via `escAction: "back"` — back keys then resolve to the BACK sentinel
439
+ // and the caller's step machine rewinds. Ctrl-C is the only hard abort.
383
440
  const BACK = Symbol.for("egregore.ui.back");
384
441
 
385
442
  // ── choose: interactive single-select picker ────────────────────────
@@ -392,7 +449,8 @@ const BACK = Symbol.for("egregore.ui.back");
392
449
  // enter select
393
450
  // type filter (case-insensitive substring match on label + desc)
394
451
  // backspace pop filter
395
- // esc cancel — or go back with opts.escAction: "back"
452
+ // ← / esc go back with opts.escAction: "back" (esc never quits;
453
+ // a clean-state esc with no back target is a no-op)
396
454
  // ctrl-c abort process
397
455
 
398
456
  async function choose(question, options, hint, opts = {}) {
@@ -457,13 +515,13 @@ async function interactiveChoose(question, options, hint, opts = {}) {
457
515
  for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
458
516
  if (end < total) lines.push(` ${DIM}↓ ${total - end} more · type to filter${RESET}`);
459
517
  }
460
- const escHint = opts.escAction === "back" ? `${DIM}esc${RESET} back` : `${DIM}esc${RESET} cancel`;
518
+ const backHint = opts.escAction === "back" ? ` · ${DIM}←${RESET} back` : "";
461
519
  if (!first) clearLines(lastLineCount);
462
520
  lastLineCount = renderPicker({
463
521
  question,
464
522
  filterText,
465
523
  lines,
466
- help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${escHint}`,
524
+ help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter${backHint} · ${DIM}ctrl-c${RESET} quit`,
467
525
  });
468
526
  };
469
527
 
@@ -498,16 +556,20 @@ async function interactiveChoose(question, options, hint, opts = {}) {
498
556
  render();
499
557
  return;
500
558
  }
501
- if (key.name === "escape" && opts.escAction === "back") {
559
+ // ← is the advertised back key in choice lists (enter descends, ←
560
+ // ascends); esc is a silent alias. Esc NEVER quits — a clean-state esc
561
+ // with no back target does nothing, ctrl-c is the only abort.
562
+ if ((key.name === "escape" || key.name === "left") && opts.escAction === "back") {
502
563
  goBack();
503
564
  return;
504
565
  }
505
- if ((key.ctrl && key.name === "c") || key.name === "escape") {
566
+ if (key.ctrl && key.name === "c") {
506
567
  cleanup();
507
568
  unparkCursor();
508
569
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
509
570
  process.exit(130);
510
571
  }
572
+ if (key.name === "escape") return;
511
573
  if (key.name === "return") {
512
574
  if (visible.length > 0 && !visible[cursor].opt.disabled) finish(visible[cursor].opt);
513
575
  return;
@@ -592,7 +654,7 @@ async function legacyChoose(question, options, hint, opts = {}) {
592
654
  // ctrl-a toggle all (respecting filter)
593
655
  // enter confirm
594
656
  // type filter
595
- // esc confirm with current selection
657
+ // ← / esc go back with opts.escAction: "back" (esc never quits)
596
658
 
597
659
  async function multiSelect(question, options, opts = {}) {
598
660
  // Remote search can populate an empty pool, but only interactively —
@@ -667,19 +729,24 @@ async function interactiveMultiSelect(question, options, opts = {}) {
667
729
  const shown = names.length > maxW ? names.slice(0, maxW - 1) + "…" : names;
668
730
  lines.push(` ${GREEN}✓${RESET} ${DIM}selected (${picked.size}):${RESET} ${shown}`);
669
731
  } else {
670
- lines.push(` ${DIM}0 selected${RESET}`);
732
+ // Nothing picked is a valid state — this picker never enforces a
733
+ // minimum. Say so, so an empty Enter doesn't read as "stuck".
734
+ lines.push(` ${DIM}0 selected — none required${RESET}`);
671
735
  }
672
736
  if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
673
737
 
674
738
  const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
675
- const escHint = opts.escAction === "back" ? ` · ${DIM}esc${RESET} back` : "";
739
+ const escHint = opts.escAction === "back" ? ` · ${DIM}←${RESET} back` : "";
740
+ // Enter always confirms the current selection, empty included — label it
741
+ // "skip" while nothing is picked so the exit is discoverable.
742
+ const enterHint = picked.size === 0 ? "skip" : "confirm";
676
743
  if (!first) clearLines(lastLineCount);
677
744
  lastLineCount = renderPicker({
678
745
  question,
679
746
  filterText,
680
747
  lines,
681
748
  input: { label: opts.remoteSearch ? "search" : "filter" },
682
- help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} confirm · ${searchHint}${escHint}`,
749
+ help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} ${enterHint} · ${searchHint}${escHint}`,
683
750
  });
684
751
  };
685
752
 
@@ -758,19 +825,20 @@ async function interactiveMultiSelect(question, options, opts = {}) {
758
825
  render();
759
826
  return;
760
827
  }
761
- if (key.name === "escape" && opts.escAction === "back") {
828
+ if ((key.name === "escape" || key.name === "left") && opts.escAction === "back") {
762
829
  cleanup();
763
830
  clearLines(lastLineCount);
764
831
  process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
765
832
  resolve(BACK);
766
833
  return;
767
834
  }
768
- if ((key.ctrl && key.name === "c") || key.name === "escape") {
835
+ if (key.ctrl && key.name === "c") {
769
836
  cleanup();
770
837
  unparkCursor();
771
838
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
772
839
  process.exit(130);
773
840
  }
841
+ if (key.name === "escape") return; // esc never quits — ctrl-c aborts
774
842
  if (key.name === "return") return finish();
775
843
  if (key.name === "up" || (key.ctrl && key.name === "p")) {
776
844
  cursor = (cursor - 1 + visible.length) % Math.max(1, visible.length);
@@ -853,6 +921,286 @@ async function legacyMultiSelect(question, options) {
853
921
  return [...picked];
854
922
  }
855
923
 
924
+ // ── manifestForm: live-manifest form ────────────────────────────────
925
+ //
926
+ // A vertical form rendered as one visible block — the "what gets created"
927
+ // plan IS the UI. One field is active at a time; derived fields (repo
928
+ // names from the Egregore name) fill in live as earlier answers are typed,
929
+ // so the plan builds itself in front of the user. "Back" is cursor
930
+ // movement, not a hidden step-rewind: ↑ moves to the previous field, and
931
+ // at the first field resolves BACK to the caller's step machine.
932
+ //
933
+ // fields: [{
934
+ // key value key in the resolved object
935
+ // label row label (left column)
936
+ // note dim annotation (right column, e.g. "config + skills")
937
+ // hint header guidance shown while this field is active
938
+ // (falls back to the form-level subtitle)
939
+ // initial previous answer (revisit) — kept only while it differs
940
+ // from the live derive, so edits keep cascading
941
+ // derive(vals) live default computed from earlier fields
942
+ // normalize(raw) canonicalize input (e.g. slugify) before validate
943
+ // validate(v, vals) return an error string to reject, null to accept
944
+ // optional enter on empty = skip (value "")
945
+ // tree render with ├─/└─ branch glyphs (the repo plan rows)
946
+ // }]
947
+ //
948
+ // keys: enter accept+next (submit on last) · ↑ previous field (BACK at the
949
+ // first) · ↓ next · type/backspace edit · esc = silent alias of ↑ (esc
950
+ // never quits) · ctrl-c abort
951
+ //
952
+ // resolves { values } or BACK.
953
+ async function manifestForm({ title, subtitle, fields }) {
954
+ if (!canInteract()) {
955
+ // Non-interactive fallback: plain sequential prompts, same validation.
956
+ const values = {};
957
+ for (const f of fields) {
958
+ const def = (f.initial != null && f.initial !== "") ? f.initial : (f.derive ? f.derive(values) : "");
959
+ for (;;) {
960
+ const raw = await prompt(f.label, { default: def || undefined });
961
+ const val = f.normalize ? f.normalize(raw) : String(raw || "").trim();
962
+ if (!val && f.optional) { values[f.key] = ""; break; }
963
+ const err = f.validate ? f.validate(val, values) : null;
964
+ if (err) { warn(err); continue; }
965
+ values[f.key] = val;
966
+ break;
967
+ }
968
+ }
969
+ return { values };
970
+ }
971
+
972
+ return new Promise((resolve) => {
973
+ // entered[key] is set only when the user TYPED a value — accepting a
974
+ // derived ghost with enter leaves the field derived, so going back up
975
+ // and changing the name re-derives everything the user didn't override.
976
+ const entered = {};
977
+ let active = 0;
978
+ let buf = null; // null = untouched → the ghost (derive/initial) shows
979
+ let error = null;
980
+ let confirmed = -1; // highest field index confirmed with enter
981
+ let lastRows = 0;
982
+
983
+ for (let i = 0; i < fields.length; i++) {
984
+ const f = fields[i];
985
+ if (f.initial != null && f.initial !== "") entered[f.key] = f.initial;
986
+ }
987
+ // Revisit case: initial values that match what derivation would produce
988
+ // are dropped back to derived so the cascade stays live.
989
+ {
990
+ const vals = {};
991
+ for (const f of fields) {
992
+ const derived = f.derive ? f.derive(vals) : "";
993
+ if (entered[f.key] != null && entered[f.key] === derived) delete entered[f.key];
994
+ vals[f.key] = entered[f.key] != null ? entered[f.key] : derived;
995
+ }
996
+ if (Object.keys(entered).length > 0) confirmed = fields.length - 1;
997
+ }
998
+
999
+ // Resolve every field's current value in order; the active field's
1000
+ // in-progress buffer feeds later derives so typing the name updates the
1001
+ // repo rows on every keystroke.
1002
+ const computeVals = () => {
1003
+ const vals = {};
1004
+ for (let i = 0; i < fields.length; i++) {
1005
+ const f = fields[i];
1006
+ let v;
1007
+ if (i === active && buf != null) v = f.normalize ? f.normalize(buf) : buf.trim();
1008
+ else if (entered[f.key] != null) v = entered[f.key];
1009
+ else v = f.derive ? f.derive(vals) : "";
1010
+ vals[f.key] = v || "";
1011
+ }
1012
+ return vals;
1013
+ };
1014
+
1015
+ const ghostFor = (i, vals) => {
1016
+ const f = fields[i];
1017
+ if (entered[f.key] != null) return entered[f.key];
1018
+ return f.derive ? f.derive(vals) : "";
1019
+ };
1020
+
1021
+ const labelW = Math.max(...fields.map((f) => f.label.length + (f.tree ? 3 : 0)));
1022
+ const lastTreeIdx = fields.reduce((acc, f, i) => (f.tree ? i : acc), -1);
1023
+
1024
+ const rowLine = (i, vals) => {
1025
+ const f = fields[i];
1026
+ const glyph = f.tree ? (i === lastTreeIdx ? "└─ " : "├─ ") : "";
1027
+ const labelText = pad(`${glyph}${f.label}`, labelW);
1028
+ const isActive = i === active;
1029
+ const isDone = i <= confirmed && !isActive;
1030
+ const mark = isActive ? `${CYAN}❯${RESET}` : (isDone ? `${GREEN}✓${RESET}` : " ");
1031
+ const note = f.note ? ` ${DIM}${f.note}${RESET}` : "";
1032
+ let value;
1033
+ if (isActive) {
1034
+ // No drawn caret — the REAL terminal cursor is parked here after
1035
+ // render, so it blinks where keystrokes land.
1036
+ if (buf != null) {
1037
+ value = buf;
1038
+ } else {
1039
+ const ghost = ghostFor(i, vals);
1040
+ value = ghost
1041
+ ? `${DIM}${ghost}${RESET}`
1042
+ : (f.optional ? `${DIM}(enter to skip)${RESET}` : "");
1043
+ }
1044
+ } else {
1045
+ const v = vals[f.key];
1046
+ if (!v) value = `${DIM}${f.optional && isDone ? "— skipped" : "·"}${RESET}`;
1047
+ else if (entered[f.key] != null || isDone) value = `${BOLD}${v}${RESET}`;
1048
+ else value = `${DIM}${v}${RESET}`;
1049
+ }
1050
+ const valueW = 26;
1051
+ return ` ${mark} ${DIM}${labelText}${RESET} ${pad(value, valueW)}${note}`;
1052
+ };
1053
+
1054
+ const hintLine = () => {
1055
+ const backTarget = active === 0 ? "back" : "previous";
1056
+ return footerBand(`enter ${active === fields.length - 1 ? "finish" : "next"} · ↑ ${backTarget} · type to edit · ctrl-c quit`);
1057
+ };
1058
+
1059
+ const render = (first = false) => {
1060
+ const vals = computeVals();
1061
+ const lines = [];
1062
+ // Header guidance is contextual: each field can carry its own hint
1063
+ // ("type your preferred Egregore name or hit enter…"), so the line
1064
+ // always says what to do RIGHT NOW instead of describing the form.
1065
+ const guidance = fields[active].hint || subtitle;
1066
+ lines.push(` ${CYAN}◆${RESET} ${BOLD}${title}${RESET}${guidance ? ` ${DIM}${guidance}${RESET}` : ""}`);
1067
+ lines.push("");
1068
+ for (let i = 0; i < fields.length; i++) lines.push(rowLine(i, vals));
1069
+ lines.push("");
1070
+ lines.push(error ? footerBand(`! ${error}`, 43) : hintLine());
1071
+ if (!first) clearLines(lastRows);
1072
+ const width = process.stdout.columns || 80;
1073
+ const text = lines.join("\n") + "\n";
1074
+ process.stdout.write(CURSOR_HIDE + text);
1075
+ lastRows = lines.reduce((sum, line) => sum + visualRows(line, width), 0);
1076
+ // Park the REAL terminal cursor at the active field's edit point so it
1077
+ // blinks there — the strongest "type here" affordance a terminal has.
1078
+ // Row math is visual-row aware (the header hint may wrap); the active
1079
+ // field row itself is short and assumed unwrapped.
1080
+ const activeLine = 2 + active;
1081
+ let rowsUp = 1; // the trailing newline after the block
1082
+ for (let i = activeLine + 1; i < lines.length; i++) rowsUp += visualRows(lines[i], width);
1083
+ const col = labelW + 9 + (buf != null ? visibleLen(buf) : 0);
1084
+ process.stdout.write(`\x1b[${rowsUp}A\x1b[${col}G${CURSOR_SHOW}`);
1085
+ _parkedRows = rowsUp; // clearLines() unparks before rewinding
1086
+ };
1087
+
1088
+ const cleanup = () => {
1089
+ process.stdin.setRawMode(false);
1090
+ process.stdin.pause();
1091
+ process.stdin.removeListener("keypress", onKey);
1092
+ process.stdout.write(CURSOR_SHOW);
1093
+ };
1094
+
1095
+ // Transcript receipt: the final plan stays in scrollback as a static
1096
+ // tree, so the record of what was named survives the redraw region.
1097
+ const receipt = (vals) => {
1098
+ const lines = [` ${CYAN}◆${RESET} ${BOLD}${title}${RESET}`];
1099
+ for (let i = 0; i < fields.length; i++) {
1100
+ const f = fields[i];
1101
+ if (!vals[f.key]) continue;
1102
+ const glyph = f.tree ? (i === lastTreeIdx ? "└─ " : "├─ ") : "";
1103
+ const note = f.note ? ` ${DIM}${f.note}${RESET}` : "";
1104
+ lines.push(` ${DIM}${pad(`${glyph}${f.label}`, labelW)}${RESET} ${pad(`${BOLD}${vals[f.key]}${RESET}`, 26)}${note}`);
1105
+ }
1106
+ return lines.join("\n") + "\n";
1107
+ };
1108
+
1109
+ const commitActive = () => {
1110
+ const f = fields[active];
1111
+ const vals = computeVals();
1112
+ const typed = buf != null && buf.trim() !== "";
1113
+ const value = vals[f.key];
1114
+ if (!value && !f.optional) {
1115
+ error = `${f.label} can't be empty.`;
1116
+ return false;
1117
+ }
1118
+ if (value && f.validate) {
1119
+ // Validate against the other fields' values, not our own in-progress one.
1120
+ const others = { ...vals };
1121
+ delete others[f.key];
1122
+ const err = f.validate(value, others);
1123
+ if (err) { error = err; return false; }
1124
+ }
1125
+ if (typed) entered[f.key] = value;
1126
+ else if (buf != null && buf.trim() === "" && f.optional) entered[f.key] = "";
1127
+ error = null;
1128
+ confirmed = Math.max(confirmed, active);
1129
+ return true;
1130
+ };
1131
+
1132
+ const onKey = (_str, key) => {
1133
+ if (!key) return;
1134
+ if (key.ctrl && key.name === "c") {
1135
+ cleanup();
1136
+ clearLines(lastRows);
1137
+ process.stdout.write(` ${DIM}(cancelled)${RESET}\n`);
1138
+ process.exit(130);
1139
+ }
1140
+ // ↑ = previous field; at the first field it leaves the form (BACK to
1141
+ // the previous wizard step). Esc is a silent alias — it never quits.
1142
+ if (key.name === "up" || key.name === "escape") {
1143
+ error = null;
1144
+ if (active === 0) {
1145
+ cleanup();
1146
+ clearLines(lastRows);
1147
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${title}${RESET} ${DIM}→ ← back${RESET}\n`);
1148
+ resolve(BACK);
1149
+ return;
1150
+ }
1151
+ buf = null;
1152
+ active -= 1;
1153
+ render();
1154
+ return;
1155
+ }
1156
+ if (key.name === "return" || key.name === "down") {
1157
+ if (key.name === "down" && active === fields.length - 1) return;
1158
+ if (!commitActive()) { render(); return; }
1159
+ if (active === fields.length - 1) {
1160
+ const vals = computeVals();
1161
+ cleanup();
1162
+ clearLines(lastRows);
1163
+ process.stdout.write(receipt(vals));
1164
+ resolve({ values: vals });
1165
+ return;
1166
+ }
1167
+ buf = null;
1168
+ active += 1;
1169
+ render();
1170
+ return;
1171
+ }
1172
+ if (key.name === "backspace") {
1173
+ // Backspace on an untouched field materializes the ghost for editing.
1174
+ if (buf == null) {
1175
+ const vals = computeVals();
1176
+ buf = ghostFor(active, vals).slice(0, -1);
1177
+ } else {
1178
+ buf = buf.slice(0, -1);
1179
+ }
1180
+ // Deleting everything un-touches the field — the derived suggestion
1181
+ // returns (and downstream derives recover) instead of leaving the
1182
+ // row stuck empty.
1183
+ if (buf === "") buf = null;
1184
+ error = null;
1185
+ render();
1186
+ return;
1187
+ }
1188
+ if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
1189
+ buf = (buf == null ? "" : buf) + _str;
1190
+ error = null;
1191
+ render();
1192
+ }
1193
+ };
1194
+
1195
+ readline.emitKeypressEvents(process.stdin);
1196
+ process.stdin.setRawMode(true);
1197
+ process.stdin.resume();
1198
+ process.stdin.on("keypress", onKey);
1199
+ process.stdout.write(CURSOR_HIDE);
1200
+ render(true);
1201
+ });
1202
+ }
1203
+
856
1204
  // ── commandBox ──────────────────────────────────────────────────────
857
1205
  //
858
1206
  // Preserved for back-compat; card() is the newer more-flexible version.
@@ -1113,7 +1461,7 @@ module.exports = {
1113
1461
  banner, bannerCompact, section, note, card,
1114
1462
  info, success, warn, error, step,
1115
1463
  dim, bold, cyan, green, yellow, red,
1116
- spinner, prompt, promptOrBack, choose, multiSelect, confirm, canInteract, BACK,
1464
+ spinner, prompt, promptOrBack, manifestForm, choose, multiSelect, confirm, canInteract, BACK,
1117
1465
  commandBox,
1118
1466
  canShowScreens, welcomeScreen, summaryScreen,
1119
1467
  _renderWelcomeBody, _readPkgVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.18.18",
3
+ "version": "0.19.2",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code or Codex.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "build:runtime": "node scripts/build-runtime-bundle.mjs",
14
- "test": "node scripts/build-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js test/multiselect-remote-search.test.js test/back-navigation.test.js",
14
+ "test": "node scripts/build-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js test/multiselect-remote-search.test.js test/back-navigation.test.js test/manifest-form.test.js test/launcher-delete.test.js",
15
15
  "test:e2e": "node --test test/e2e-local.test.js",
16
16
  "test:all": "node scripts/build-runtime-bundle.mjs --check && node --test test/"
17
17
  },
@@ -11,7 +11,7 @@
11
11
  {
12
12
  "section": "Identity & Upstream",
13
13
  "action": "keep",
14
- "sourceHash": "4acbf9bc2866",
14
+ "sourceHash": "11d07e3fcb58",
15
15
  "note": "Kept; /update, /contribute, /save tokens rendered as $skill tokens."
16
16
  },
17
17
  {
@@ -62,6 +62,12 @@
62
62
  "sourceHash": "d75dc9f6d88c",
63
63
  "note": "Kept verbatim."
64
64
  },
65
+ {
66
+ "section": "Loom Routing",
67
+ "action": "replace",
68
+ "sourceHash": "b6a6ca2bc2b7",
69
+ "note": "Loom delegation rides the Claude Code Agent tool; Codex has no subagent delegation — commands run inline. Preambles and bin/loom.sh calls marked skippable."
70
+ },
65
71
  {
66
72
  "section": "Git Workflow",
67
73
  "action": "keep",
@@ -63,7 +63,7 @@ This block is the Codex-native Egregore behavioral spec, rendered from CLAUDE.md
63
63
 
64
64
  ## Identity & Upstream
65
65
 
66
- This is an Egregore instance — a downstream fork of the upstream framework at `egregore-labs/egregore`. The framework (`bin/`, `.claude/skills/`, `.claude/hooks/`, `.claude/context/`, `CLAUDE.md`, `skills/`) is synced from upstream on every session start and via `$update`. It is not authored in this repo.
66
+ This is an Egregore instance — a downstream fork of the upstream framework at `egregore-labs/egregore`. The framework (`bin/`, `.claude/skills/`, `.claude/hooks/`, `.claude/context/`, `.claude/agents/`, `loom/`, `CLAUDE.md`, `skills/`) is synced from upstream on every session start and via `$update`. It is not authored in this repo.
67
67
 
68
68
  **Where changes belong:**
69
69
  - **Framework is outdated or a skill is missing:** run `$update` first. It pulls the latest from upstream.
@@ -198,6 +198,12 @@ Always use HTTPS for git operations — `github-auth.sh` handles credential stor
198
198
 
199
199
  ---
200
200
 
201
+ ## Loom Routing
202
+
203
+ Loom routes commands across model tiers on the Claude Code runtime (`loom/routes.json` + `bin/loom.sh` + a model-pinned executor subagent). Codex has no subagent delegation — every command runs inline in the current session. Ignore "Loom routing" preambles if you encounter them in a skill spec, and skip `bin/loom.sh` calls; `loom/` and `.claude/agents/` are framework files synced for the Claude runtime.
204
+
205
+ ---
206
+
201
207
  ## Git Workflow
202
208
 
203
209
  `develop` branch model with deferred, topic-based branching. Users never interact with git directly.
@@ -224,6 +224,8 @@ Codex has no structured question tool — render each batch as compact numbered
224
224
 
225
225
  **Rules:** Max 4 questions per batch. When choices aren't mutually exclusive, say "pick any that apply".`;
226
226
 
227
+ const LOOM_ROUTING_BODY = `Loom routes commands across model tiers on the Claude Code runtime (\`loom/routes.json\` + \`bin/loom.sh\` + a model-pinned executor subagent). Codex has no subagent delegation — every command runs inline in the current session. Ignore "Loom routing" preambles if you encounter them in a skill spec, and skip \`bin/loom.sh\` calls; \`loom/\` and \`.claude/agents/\` are framework files synced for the Claude runtime.`;
228
+
227
229
  const ISOLATION_BODY = `Sessions are confined to this project + memory + managed repos. On Codex this boundary is a standing instruction, not an enforced hook — hold it yourself.
228
230
 
229
231
  - **Never modify \`~/.egregore/instances.json\`** — managed by session-start.sh
@@ -266,6 +268,11 @@ const RULES = {
266
268
  'Onboarding': { action: 'keep', note: 'Kept; /onboarding rendered as $onboarding.' },
267
269
  'Transparency Beat': { action: 'keep', note: 'Kept verbatim.' },
268
270
  'Memory': { action: 'keep', note: 'Kept verbatim.' },
271
+ 'Loom Routing': {
272
+ action: 'replace',
273
+ body: LOOM_ROUTING_BODY,
274
+ note: 'Loom delegation rides the Claude Code Agent tool; Codex has no subagent delegation — commands run inline. Preambles and bin/loom.sh calls marked skippable.',
275
+ },
269
276
  'Git Workflow': {
270
277
  action: 'keep',
271
278
  note: 'Kept; slash tokens rendered as $skill tokens. Branch diagram unchanged (code fence).',