create-egregore 0.18.17 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,9 +238,105 @@ function prompt(question, { default: defaultValue } = {}) {
226
238
  });
227
239
  }
228
240
 
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 } = {}) {
249
+ return new Promise((resolve) => {
250
+ let buf = "";
251
+ const label = defaultValue
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
+ };
271
+ const cleanup = () => {
272
+ process.stdin.setRawMode(false);
273
+ process.stdin.pause();
274
+ process.stdin.removeListener("keypress", onKey);
275
+ };
276
+ const onKey = (_str, key) => {
277
+ if (!key) return;
278
+ if (key.ctrl && key.name === "c") {
279
+ cleanup();
280
+ clearHint();
281
+ process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
282
+ process.exit(130);
283
+ }
284
+ if (key.name === "up" || key.name === "escape") {
285
+ if (!back) return; // nothing to go back to — and esc never quits
286
+ cleanup();
287
+ clearHint();
288
+ process.stdout.write(`\r\x1b[2K${label}${DIM}← back${RESET}\n`);
289
+ resolve(BACK);
290
+ return;
291
+ }
292
+ if (key.name === "return") {
293
+ cleanup();
294
+ clearHint();
295
+ process.stdout.write("\n");
296
+ resolve(buf.trim() || (defaultValue ?? ""));
297
+ return;
298
+ }
299
+ if (key.name === "backspace") {
300
+ buf = buf.slice(0, -1);
301
+ render();
302
+ return;
303
+ }
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
+ }
310
+ }
311
+ };
312
+ readline.emitKeypressEvents(process.stdin);
313
+ process.stdin.setRawMode(true);
314
+ process.stdin.resume();
315
+ process.stdin.on("keypress", onKey);
316
+ render();
317
+ });
318
+ }
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
+
229
334
  async function confirm(question, defaultYes = false) {
230
- const hint = defaultYes ? `${DIM}[Y/n]${RESET}` : `${DIM}[y/N]${RESET}`;
231
- 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}`);
232
340
  if (!answer) return defaultYes;
233
341
  return /^y(es)?$/i.test(answer);
234
342
  }
@@ -289,7 +397,7 @@ function renderPicker({ question, filterText, lines, help, input }) {
289
397
  for (const line of lines) out.push(line);
290
398
  if (help) {
291
399
  out.push("");
292
- out.push(` ${DIM}${help}${RESET}`);
400
+ out.push(footerBand(help));
293
401
  }
294
402
  const text = out.join("\n") + "\n";
295
403
  process.stdout.write(text);
@@ -320,24 +428,37 @@ function filterOptions(options, filterText, getText) {
320
428
  return result;
321
429
  }
322
430
 
431
+ // ── back navigation ─────────────────────────────────────────────────
432
+ //
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.
440
+ const BACK = Symbol.for("egregore.ui.back");
441
+
323
442
  // ── choose: interactive single-select picker ────────────────────────
324
443
  //
325
444
  // options: [{ label, description }]
326
- // returns: the picked option (same object reference as in options[])
445
+ // returns: the picked option (same object reference as in options[]),
446
+ // or BACK when opts.escAction === "back" and the user pressed esc
327
447
  // keys:
328
448
  // ↑ ↓ navigate
329
449
  // enter select
330
450
  // type filter (case-insensitive substring match on label + desc)
331
451
  // backspace pop filter
332
- // esc cancel (returns the first option, or throws if opts.abortOnEsc)
452
+ // ← / esc go back with opts.escAction: "back" (esc never quits;
453
+ // a clean-state esc with no back target is a no-op)
333
454
  // ctrl-c abort process
334
455
 
335
- async function choose(question, options, hint) {
336
- if (!canInteract()) return legacyChoose(question, options, hint);
337
- return interactiveChoose(question, options, hint);
456
+ async function choose(question, options, hint, opts = {}) {
457
+ if (!canInteract()) return legacyChoose(question, options, hint, opts);
458
+ return interactiveChoose(question, options, hint, opts);
338
459
  }
339
460
 
340
- async function interactiveChoose(question, options, hint) {
461
+ async function interactiveChoose(question, options, hint, opts = {}) {
341
462
  return new Promise((resolve) => {
342
463
  let filterText = "";
343
464
  let visible = filterOptions(options, filterText, (o) => `${o.label} ${o.description || ""}`);
@@ -394,12 +515,13 @@ async function interactiveChoose(question, options, hint) {
394
515
  for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
395
516
  if (end < total) lines.push(` ${DIM}↓ ${total - end} more · type to filter${RESET}`);
396
517
  }
518
+ const backHint = opts.escAction === "back" ? ` · ${DIM}←${RESET} back` : "";
397
519
  if (!first) clearLines(lastLineCount);
398
520
  lastLineCount = renderPicker({
399
521
  question,
400
522
  filterText,
401
523
  lines,
402
- help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${DIM}esc${RESET} cancel`,
524
+ help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter${backHint} · ${DIM}ctrl-c${RESET} quit`,
403
525
  });
404
526
  };
405
527
 
@@ -416,14 +538,38 @@ async function interactiveChoose(question, options, hint) {
416
538
  resolve(value);
417
539
  };
418
540
 
541
+ const goBack = () => {
542
+ cleanup();
543
+ clearLines(lastLineCount);
544
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
545
+ resolve(BACK);
546
+ };
547
+
419
548
  const onKey = (_str, key) => {
420
549
  if (!key) return;
421
- if ((key.ctrl && key.name === "c") || key.name === "escape") {
550
+ if (key.name === "escape" && filterText) {
551
+ // fzf convention: first esc clears an active filter; only a clean
552
+ // state goes back / cancels.
553
+ filterText = "";
554
+ visible = filterOptions(options, filterText, (o) => `${o.label} ${o.description || ""}`);
555
+ cursor = firstEnabled();
556
+ render();
557
+ return;
558
+ }
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") {
563
+ goBack();
564
+ return;
565
+ }
566
+ if (key.ctrl && key.name === "c") {
422
567
  cleanup();
423
568
  unparkCursor();
424
569
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
425
570
  process.exit(130);
426
571
  }
572
+ if (key.name === "escape") return;
427
573
  if (key.name === "return") {
428
574
  if (visible.length > 0 && !visible[cursor].opt.disabled) finish(visible[cursor].opt);
429
575
  return;
@@ -462,7 +608,7 @@ async function interactiveChoose(question, options, hint) {
462
608
  });
463
609
  }
464
610
 
465
- async function legacyChoose(question, options, hint) {
611
+ async function legacyChoose(question, options, hint, opts = {}) {
466
612
  process.stdout.write(`\n ${CYAN}◆${RESET} ${BOLD}${question}${RESET}\n\n`);
467
613
  const labelWidth = Math.min(40, Math.max(...options.map((o) => o.label.length)));
468
614
  for (let i = 0; i < options.length; i++) {
@@ -473,8 +619,10 @@ async function legacyChoose(question, options, hint) {
473
619
  process.stdout.write(` ${DIM}${num})${RESET} ${BOLD}${paddedLabel}${RESET}${desc}\n`);
474
620
  }
475
621
  if (hint) process.stdout.write(`\n ${DIM}${hint}${RESET}\n`);
622
+ if (opts.escAction === "back") process.stdout.write(` ${DIM}type "back" to go back${RESET}\n`);
476
623
  while (true) {
477
624
  const answer = await prompt(`pick ${DIM}[1-${options.length}]${RESET}:`);
625
+ if (opts.escAction === "back" && /^(b|back)$/i.test(answer)) return BACK;
478
626
  if (!answer) { warn(`Enter a number between 1 and ${options.length}.`); continue; }
479
627
  const n = parseInt(answer, 10);
480
628
  if (n >= 1 && n <= options.length) {
@@ -506,7 +654,7 @@ async function legacyChoose(question, options, hint) {
506
654
  // ctrl-a toggle all (respecting filter)
507
655
  // enter confirm
508
656
  // type filter
509
- // esc confirm with current selection
657
+ // ← / esc go back with opts.escAction: "back" (esc never quits)
510
658
 
511
659
  async function multiSelect(question, options, opts = {}) {
512
660
  // Remote search can populate an empty pool, but only interactively —
@@ -523,7 +671,9 @@ async function interactiveMultiSelect(question, options, opts = {}) {
523
671
  return new Promise((resolve) => {
524
672
  let cursor = 0;
525
673
  let filterText = "";
526
- const picked = new Set();
674
+ // opts.preselected re-arms a previous selection — used when the step
675
+ // machine navigates back to the picker so choices aren't lost.
676
+ const picked = new Set((opts.preselected || []).filter((n) => options.some((o) => o.name === n)));
527
677
  // The pool grows as remote results merge in; seed order is preserved so
528
678
  // boosted entries (e.g. the user's team repos) stay on top when idle.
529
679
  const pool = [...options];
@@ -584,13 +734,14 @@ async function interactiveMultiSelect(question, options, opts = {}) {
584
734
  if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
585
735
 
586
736
  const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
737
+ const escHint = opts.escAction === "back" ? ` · ${DIM}←${RESET} back` : "";
587
738
  if (!first) clearLines(lastLineCount);
588
739
  lastLineCount = renderPicker({
589
740
  question,
590
741
  filterText,
591
742
  lines,
592
743
  input: { label: opts.remoteSearch ? "search" : "filter" },
593
- help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} confirm · ${searchHint}`,
744
+ help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} confirm · ${searchHint}${escHint}`,
594
745
  });
595
746
  };
596
747
 
@@ -661,12 +812,28 @@ async function interactiveMultiSelect(question, options, opts = {}) {
661
812
 
662
813
  const onKey = (_str, key) => {
663
814
  if (!key) return;
664
- if ((key.ctrl && key.name === "c") || key.name === "escape") {
815
+ if (key.name === "escape" && filterText) {
816
+ filterText = "";
817
+ refreshVisible();
818
+ cursor = 0;
819
+ searching = false;
820
+ render();
821
+ return;
822
+ }
823
+ if ((key.name === "escape" || key.name === "left") && opts.escAction === "back") {
824
+ cleanup();
825
+ clearLines(lastLineCount);
826
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
827
+ resolve(BACK);
828
+ return;
829
+ }
830
+ if (key.ctrl && key.name === "c") {
665
831
  cleanup();
666
832
  unparkCursor();
667
833
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
668
834
  process.exit(130);
669
835
  }
836
+ if (key.name === "escape") return; // esc never quits — ctrl-c aborts
670
837
  if (key.name === "return") return finish();
671
838
  if (key.name === "up" || (key.ctrl && key.name === "p")) {
672
839
  cursor = (cursor - 1 + visible.length) % Math.max(1, visible.length);
@@ -749,6 +916,286 @@ async function legacyMultiSelect(question, options) {
749
916
  return [...picked];
750
917
  }
751
918
 
919
+ // ── manifestForm: live-manifest form ────────────────────────────────
920
+ //
921
+ // A vertical form rendered as one visible block — the "what gets created"
922
+ // plan IS the UI. One field is active at a time; derived fields (repo
923
+ // names from the Egregore name) fill in live as earlier answers are typed,
924
+ // so the plan builds itself in front of the user. "Back" is cursor
925
+ // movement, not a hidden step-rewind: ↑ moves to the previous field, and
926
+ // at the first field resolves BACK to the caller's step machine.
927
+ //
928
+ // fields: [{
929
+ // key value key in the resolved object
930
+ // label row label (left column)
931
+ // note dim annotation (right column, e.g. "config + skills")
932
+ // hint header guidance shown while this field is active
933
+ // (falls back to the form-level subtitle)
934
+ // initial previous answer (revisit) — kept only while it differs
935
+ // from the live derive, so edits keep cascading
936
+ // derive(vals) live default computed from earlier fields
937
+ // normalize(raw) canonicalize input (e.g. slugify) before validate
938
+ // validate(v, vals) return an error string to reject, null to accept
939
+ // optional enter on empty = skip (value "")
940
+ // tree render with ├─/└─ branch glyphs (the repo plan rows)
941
+ // }]
942
+ //
943
+ // keys: enter accept+next (submit on last) · ↑ previous field (BACK at the
944
+ // first) · ↓ next · type/backspace edit · esc = silent alias of ↑ (esc
945
+ // never quits) · ctrl-c abort
946
+ //
947
+ // resolves { values } or BACK.
948
+ async function manifestForm({ title, subtitle, fields }) {
949
+ if (!canInteract()) {
950
+ // Non-interactive fallback: plain sequential prompts, same validation.
951
+ const values = {};
952
+ for (const f of fields) {
953
+ const def = (f.initial != null && f.initial !== "") ? f.initial : (f.derive ? f.derive(values) : "");
954
+ for (;;) {
955
+ const raw = await prompt(f.label, { default: def || undefined });
956
+ const val = f.normalize ? f.normalize(raw) : String(raw || "").trim();
957
+ if (!val && f.optional) { values[f.key] = ""; break; }
958
+ const err = f.validate ? f.validate(val, values) : null;
959
+ if (err) { warn(err); continue; }
960
+ values[f.key] = val;
961
+ break;
962
+ }
963
+ }
964
+ return { values };
965
+ }
966
+
967
+ return new Promise((resolve) => {
968
+ // entered[key] is set only when the user TYPED a value — accepting a
969
+ // derived ghost with enter leaves the field derived, so going back up
970
+ // and changing the name re-derives everything the user didn't override.
971
+ const entered = {};
972
+ let active = 0;
973
+ let buf = null; // null = untouched → the ghost (derive/initial) shows
974
+ let error = null;
975
+ let confirmed = -1; // highest field index confirmed with enter
976
+ let lastRows = 0;
977
+
978
+ for (let i = 0; i < fields.length; i++) {
979
+ const f = fields[i];
980
+ if (f.initial != null && f.initial !== "") entered[f.key] = f.initial;
981
+ }
982
+ // Revisit case: initial values that match what derivation would produce
983
+ // are dropped back to derived so the cascade stays live.
984
+ {
985
+ const vals = {};
986
+ for (const f of fields) {
987
+ const derived = f.derive ? f.derive(vals) : "";
988
+ if (entered[f.key] != null && entered[f.key] === derived) delete entered[f.key];
989
+ vals[f.key] = entered[f.key] != null ? entered[f.key] : derived;
990
+ }
991
+ if (Object.keys(entered).length > 0) confirmed = fields.length - 1;
992
+ }
993
+
994
+ // Resolve every field's current value in order; the active field's
995
+ // in-progress buffer feeds later derives so typing the name updates the
996
+ // repo rows on every keystroke.
997
+ const computeVals = () => {
998
+ const vals = {};
999
+ for (let i = 0; i < fields.length; i++) {
1000
+ const f = fields[i];
1001
+ let v;
1002
+ if (i === active && buf != null) v = f.normalize ? f.normalize(buf) : buf.trim();
1003
+ else if (entered[f.key] != null) v = entered[f.key];
1004
+ else v = f.derive ? f.derive(vals) : "";
1005
+ vals[f.key] = v || "";
1006
+ }
1007
+ return vals;
1008
+ };
1009
+
1010
+ const ghostFor = (i, vals) => {
1011
+ const f = fields[i];
1012
+ if (entered[f.key] != null) return entered[f.key];
1013
+ return f.derive ? f.derive(vals) : "";
1014
+ };
1015
+
1016
+ const labelW = Math.max(...fields.map((f) => f.label.length + (f.tree ? 3 : 0)));
1017
+ const lastTreeIdx = fields.reduce((acc, f, i) => (f.tree ? i : acc), -1);
1018
+
1019
+ const rowLine = (i, vals) => {
1020
+ const f = fields[i];
1021
+ const glyph = f.tree ? (i === lastTreeIdx ? "└─ " : "├─ ") : "";
1022
+ const labelText = pad(`${glyph}${f.label}`, labelW);
1023
+ const isActive = i === active;
1024
+ const isDone = i <= confirmed && !isActive;
1025
+ const mark = isActive ? `${CYAN}❯${RESET}` : (isDone ? `${GREEN}✓${RESET}` : " ");
1026
+ const note = f.note ? ` ${DIM}${f.note}${RESET}` : "";
1027
+ let value;
1028
+ if (isActive) {
1029
+ // No drawn caret — the REAL terminal cursor is parked here after
1030
+ // render, so it blinks where keystrokes land.
1031
+ if (buf != null) {
1032
+ value = buf;
1033
+ } else {
1034
+ const ghost = ghostFor(i, vals);
1035
+ value = ghost
1036
+ ? `${DIM}${ghost}${RESET}`
1037
+ : (f.optional ? `${DIM}(enter to skip)${RESET}` : "");
1038
+ }
1039
+ } else {
1040
+ const v = vals[f.key];
1041
+ if (!v) value = `${DIM}${f.optional && isDone ? "— skipped" : "·"}${RESET}`;
1042
+ else if (entered[f.key] != null || isDone) value = `${BOLD}${v}${RESET}`;
1043
+ else value = `${DIM}${v}${RESET}`;
1044
+ }
1045
+ const valueW = 26;
1046
+ return ` ${mark} ${DIM}${labelText}${RESET} ${pad(value, valueW)}${note}`;
1047
+ };
1048
+
1049
+ const hintLine = () => {
1050
+ const backTarget = active === 0 ? "back" : "previous";
1051
+ return footerBand(`enter ${active === fields.length - 1 ? "finish" : "next"} · ↑ ${backTarget} · type to edit · ctrl-c quit`);
1052
+ };
1053
+
1054
+ const render = (first = false) => {
1055
+ const vals = computeVals();
1056
+ const lines = [];
1057
+ // Header guidance is contextual: each field can carry its own hint
1058
+ // ("type your preferred Egregore name or hit enter…"), so the line
1059
+ // always says what to do RIGHT NOW instead of describing the form.
1060
+ const guidance = fields[active].hint || subtitle;
1061
+ lines.push(` ${CYAN}◆${RESET} ${BOLD}${title}${RESET}${guidance ? ` ${DIM}${guidance}${RESET}` : ""}`);
1062
+ lines.push("");
1063
+ for (let i = 0; i < fields.length; i++) lines.push(rowLine(i, vals));
1064
+ lines.push("");
1065
+ lines.push(error ? footerBand(`! ${error}`, 43) : hintLine());
1066
+ if (!first) clearLines(lastRows);
1067
+ const width = process.stdout.columns || 80;
1068
+ const text = lines.join("\n") + "\n";
1069
+ process.stdout.write(CURSOR_HIDE + text);
1070
+ lastRows = lines.reduce((sum, line) => sum + visualRows(line, width), 0);
1071
+ // Park the REAL terminal cursor at the active field's edit point so it
1072
+ // blinks there — the strongest "type here" affordance a terminal has.
1073
+ // Row math is visual-row aware (the header hint may wrap); the active
1074
+ // field row itself is short and assumed unwrapped.
1075
+ const activeLine = 2 + active;
1076
+ let rowsUp = 1; // the trailing newline after the block
1077
+ for (let i = activeLine + 1; i < lines.length; i++) rowsUp += visualRows(lines[i], width);
1078
+ const col = labelW + 9 + (buf != null ? visibleLen(buf) : 0);
1079
+ process.stdout.write(`\x1b[${rowsUp}A\x1b[${col}G${CURSOR_SHOW}`);
1080
+ _parkedRows = rowsUp; // clearLines() unparks before rewinding
1081
+ };
1082
+
1083
+ const cleanup = () => {
1084
+ process.stdin.setRawMode(false);
1085
+ process.stdin.pause();
1086
+ process.stdin.removeListener("keypress", onKey);
1087
+ process.stdout.write(CURSOR_SHOW);
1088
+ };
1089
+
1090
+ // Transcript receipt: the final plan stays in scrollback as a static
1091
+ // tree, so the record of what was named survives the redraw region.
1092
+ const receipt = (vals) => {
1093
+ const lines = [` ${CYAN}◆${RESET} ${BOLD}${title}${RESET}`];
1094
+ for (let i = 0; i < fields.length; i++) {
1095
+ const f = fields[i];
1096
+ if (!vals[f.key]) continue;
1097
+ const glyph = f.tree ? (i === lastTreeIdx ? "└─ " : "├─ ") : "";
1098
+ const note = f.note ? ` ${DIM}${f.note}${RESET}` : "";
1099
+ lines.push(` ${DIM}${pad(`${glyph}${f.label}`, labelW)}${RESET} ${pad(`${BOLD}${vals[f.key]}${RESET}`, 26)}${note}`);
1100
+ }
1101
+ return lines.join("\n") + "\n";
1102
+ };
1103
+
1104
+ const commitActive = () => {
1105
+ const f = fields[active];
1106
+ const vals = computeVals();
1107
+ const typed = buf != null && buf.trim() !== "";
1108
+ const value = vals[f.key];
1109
+ if (!value && !f.optional) {
1110
+ error = `${f.label} can't be empty.`;
1111
+ return false;
1112
+ }
1113
+ if (value && f.validate) {
1114
+ // Validate against the other fields' values, not our own in-progress one.
1115
+ const others = { ...vals };
1116
+ delete others[f.key];
1117
+ const err = f.validate(value, others);
1118
+ if (err) { error = err; return false; }
1119
+ }
1120
+ if (typed) entered[f.key] = value;
1121
+ else if (buf != null && buf.trim() === "" && f.optional) entered[f.key] = "";
1122
+ error = null;
1123
+ confirmed = Math.max(confirmed, active);
1124
+ return true;
1125
+ };
1126
+
1127
+ const onKey = (_str, key) => {
1128
+ if (!key) return;
1129
+ if (key.ctrl && key.name === "c") {
1130
+ cleanup();
1131
+ clearLines(lastRows);
1132
+ process.stdout.write(` ${DIM}(cancelled)${RESET}\n`);
1133
+ process.exit(130);
1134
+ }
1135
+ // ↑ = previous field; at the first field it leaves the form (BACK to
1136
+ // the previous wizard step). Esc is a silent alias — it never quits.
1137
+ if (key.name === "up" || key.name === "escape") {
1138
+ error = null;
1139
+ if (active === 0) {
1140
+ cleanup();
1141
+ clearLines(lastRows);
1142
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${title}${RESET} ${DIM}→ ← back${RESET}\n`);
1143
+ resolve(BACK);
1144
+ return;
1145
+ }
1146
+ buf = null;
1147
+ active -= 1;
1148
+ render();
1149
+ return;
1150
+ }
1151
+ if (key.name === "return" || key.name === "down") {
1152
+ if (key.name === "down" && active === fields.length - 1) return;
1153
+ if (!commitActive()) { render(); return; }
1154
+ if (active === fields.length - 1) {
1155
+ const vals = computeVals();
1156
+ cleanup();
1157
+ clearLines(lastRows);
1158
+ process.stdout.write(receipt(vals));
1159
+ resolve({ values: vals });
1160
+ return;
1161
+ }
1162
+ buf = null;
1163
+ active += 1;
1164
+ render();
1165
+ return;
1166
+ }
1167
+ if (key.name === "backspace") {
1168
+ // Backspace on an untouched field materializes the ghost for editing.
1169
+ if (buf == null) {
1170
+ const vals = computeVals();
1171
+ buf = ghostFor(active, vals).slice(0, -1);
1172
+ } else {
1173
+ buf = buf.slice(0, -1);
1174
+ }
1175
+ // Deleting everything un-touches the field — the derived suggestion
1176
+ // returns (and downstream derives recover) instead of leaving the
1177
+ // row stuck empty.
1178
+ if (buf === "") buf = null;
1179
+ error = null;
1180
+ render();
1181
+ return;
1182
+ }
1183
+ if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
1184
+ buf = (buf == null ? "" : buf) + _str;
1185
+ error = null;
1186
+ render();
1187
+ }
1188
+ };
1189
+
1190
+ readline.emitKeypressEvents(process.stdin);
1191
+ process.stdin.setRawMode(true);
1192
+ process.stdin.resume();
1193
+ process.stdin.on("keypress", onKey);
1194
+ process.stdout.write(CURSOR_HIDE);
1195
+ render(true);
1196
+ });
1197
+ }
1198
+
752
1199
  // ── commandBox ──────────────────────────────────────────────────────
753
1200
  //
754
1201
  // Preserved for back-compat; card() is the newer more-flexible version.
@@ -1009,7 +1456,7 @@ module.exports = {
1009
1456
  banner, bannerCompact, section, note, card,
1010
1457
  info, success, warn, error, step,
1011
1458
  dim, bold, cyan, green, yellow, red,
1012
- spinner, prompt, choose, multiSelect, confirm, canInteract,
1459
+ spinner, prompt, promptOrBack, manifestForm, choose, multiSelect, confirm, canInteract, BACK,
1013
1460
  commandBox,
1014
1461
  canShowScreens, welcomeScreen, summaryScreen,
1015
1462
  _renderWelcomeBody, _readPkgVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.18.17",
3
+ "version": "0.19.0",
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",
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.