create-egregore 0.18.16 → 0.18.17

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 (4) hide show
  1. package/lib/auth.js +31 -6
  2. package/lib/local.js +146 -31
  3. package/lib/ui.js +149 -26
  4. package/package.json +2 -2
package/lib/auth.js CHANGED
@@ -110,6 +110,26 @@ function explainNoGhHeadless(ui) {
110
110
  ui?.info?.(` Install for any OS: ${GH_INSTALL_URL}`);
111
111
  }
112
112
 
113
+ // Status text for the ACTIVE github.com account only. `gh auth status`
114
+ // writes to stderr on some versions; capture both streams. With multiple
115
+ // accounts the plain form lists every account (including broken ones), so
116
+ // try --active first (gh ≥ 2.61) and otherwise cut the active account's
117
+ // block out of the combined output.
118
+ function ghActiveStatusText() {
119
+ let run = spawnSync("gh", ["auth", "status", "--active", "--hostname", "github.com"], { stdio: "pipe", encoding: "utf-8" });
120
+ let text = `${run.stdout || ""}${run.stderr || ""}`;
121
+ if (run.error || /unknown flag/i.test(text)) {
122
+ run = spawnSync("gh", ["auth", "status", "--hostname", "github.com"], { stdio: "pipe", encoding: "utf-8" });
123
+ text = `${run.stdout || ""}${run.stderr || ""}`;
124
+ }
125
+ // Split into per-account blocks (each starts at a "... account <login>"
126
+ // line) and keep the one marked active. Single-account/older gh output has
127
+ // no "Active account" marker — return it whole.
128
+ const blocks = text.split(/\n(?=\s*[✓✗xX-]?\s*(?:Logged in to|Failed to log in to))/);
129
+ const active = blocks.find((b) => /Active account:\s*true/i.test(b));
130
+ return active || text;
131
+ }
132
+
113
133
  function tryGhAuth() {
114
134
  if (process.env.EGREGORE_SKIP_GH === "1") return null;
115
135
 
@@ -118,16 +138,21 @@ function tryGhAuth() {
118
138
 
119
139
  if (!ghCliExists()) return null;
120
140
 
121
- // gh auth status writes to stderr; capture both.
122
- const status = spawnSync("gh", ["auth", "status"], { stdio: "pipe", encoding: "utf-8" });
123
- if (status.status !== 0) return null;
124
- const statusText = `${status.stdout || ""}${status.stderr || ""}`;
125
-
126
- const tokenRun = spawnSync("gh", ["auth", "token"], { stdio: "pipe", encoding: "utf-8" });
141
+ // The token is the source of truth: `gh auth token` succeeds iff the ACTIVE
142
+ // account is usable. Do NOT gate on `gh auth status`'s exit code — on
143
+ // multi-account setups it is non-zero when ANY configured account has a
144
+ // revoked/expired token, even though the active account is fine, which used
145
+ // to hard-fail installs until the user logged the stale accounts out.
146
+ const tokenRun = spawnSync("gh", ["auth", "token", "--hostname", "github.com"], { stdio: "pipe", encoding: "utf-8" });
127
147
  if (tokenRun.status !== 0) return null;
128
148
  const token = (tokenRun.stdout || "").trim();
129
149
  if (!token) return null;
130
150
 
151
+ // Status output is metadata only (scopes + login). Prefer --active (gh ≥
152
+ // 2.61) so stale sibling accounts don't pollute the output; fall back to the
153
+ // plain form and pick the active account's block out of it.
154
+ const statusText = ghActiveStatusText();
155
+
131
156
  // Parse declared token scopes when gh exposes them. Some gh versions and
132
157
  // fine-grained auth paths do not print a classic Token scopes line. Treat
133
158
  // that as "unknown" and continue with the first-party gh token instead of
package/lib/local.js CHANGED
@@ -198,6 +198,63 @@ async function putFileContent(token, owner, repo, filePath, content, message) {
198
198
  return ghApi("PUT", `/repos/${owner}/${repo}/contents/${filePath}`, token, body);
199
199
  }
200
200
 
201
+ async function paginateRepos(token, baseUrl) {
202
+ let all = [];
203
+ let page = 1;
204
+ while (page <= 5) { // cap at 500 repos
205
+ const url = `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}page=${page}`;
206
+ const { status, data } = await ghApi("GET", url, token);
207
+ if (status !== 200 || !Array.isArray(data) || data.length === 0) break;
208
+ all = all.concat(data);
209
+ if (data.length < 100) break; // last page
210
+ page++;
211
+ }
212
+ return all;
213
+ }
214
+
215
+ function pickerRepoFilter(r) {
216
+ return !r.archived && r.name !== "egregore" && !r.name.endsWith("-memory");
217
+ }
218
+
219
+ function toPickerRepo(r) {
220
+ return { name: r.name, language: r.language || "", description: r.description || "" };
221
+ }
222
+
223
+ // Teams the user belongs to in this org. Big orgs bury a team's repos past
224
+ // the 500-most-recently-updated cap of the org-wide listing, so a team scope
225
+ // is often the only practical way to find them. Non-fatal on error.
226
+ async function listUserTeams(token, org) {
227
+ try {
228
+ const teams = await paginateRepos(token, "/user/teams?per_page=100");
229
+ const orgLc = org.toLowerCase();
230
+ return teams
231
+ .filter((t) => t.organization && (t.organization.login || "").toLowerCase() === orgLc)
232
+ .map((t) => ({ name: t.name, slug: t.slug, reposCount: t.repos_count }));
233
+ } catch {
234
+ return [];
235
+ }
236
+ }
237
+
238
+ async function listTeamRepos(token, org, teamSlug) {
239
+ const repos = await paginateRepos(token, `/orgs/${org}/teams/${teamSlug}/repos?per_page=100`);
240
+ return repos.filter(pickerRepoFilter).map(toPickerRepo);
241
+ }
242
+
243
+ // Live search across the WHOLE org/account via GitHub's search index —
244
+ // unlike the listing endpoints this isn't bounded to a recency window, so
245
+ // the picker can reach any repo the token can see. Non-fatal on error.
246
+ async function searchOrgRepos(token, owner, isOrg, term) {
247
+ try {
248
+ const qualifier = isOrg ? `org:${owner}` : `user:${owner}`;
249
+ const q = encodeURIComponent(`${qualifier} ${term} in:name archived:false`);
250
+ const { status, data } = await ghApi("GET", `/search/repositories?q=${q}&per_page=30`, token);
251
+ if (status !== 200 || !data || !Array.isArray(data.items)) return [];
252
+ return data.items.filter(pickerRepoFilter).map(toPickerRepo);
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
257
+
201
258
  async function listOrgRepos(token, owner, isOrg) {
202
259
  // Fetch all repos (public + private). Paginate to get beyond the first 100.
203
260
  //
@@ -214,16 +271,7 @@ async function listOrgRepos(token, owner, isOrg) {
214
271
  ? `/orgs/${owner}/repos?per_page=100&sort=updated&type=all`
215
272
  : `/user/repos?per_page=100&sort=updated&affiliation=owner`;
216
273
 
217
- let allRepos = [];
218
- let page = 1;
219
- while (page <= 5) { // cap at 500 repos
220
- const url = `${baseUrl}&page=${page}`;
221
- const { status, data } = await ghApi("GET", url, token);
222
- if (status !== 200 || !Array.isArray(data) || data.length === 0) break;
223
- allRepos = allRepos.concat(data);
224
- if (data.length < 100) break; // last page
225
- page++;
226
- }
274
+ let allRepos = await paginateRepos(token, baseUrl);
227
275
 
228
276
  // Personal /user/repos returns repos across all of the user's accounts
229
277
  // (their personal namespace + any orgs they're a member of). When the
@@ -233,9 +281,7 @@ async function listOrgRepos(token, owner, isOrg) {
233
281
  allRepos = allRepos.filter((r) => r.owner && r.owner.login === owner);
234
282
  }
235
283
 
236
- return allRepos
237
- .filter((r) => !r.archived && r.name !== "egregore" && !r.name.endsWith("-memory"))
238
- .map((r) => ({ name: r.name, language: r.language || "", description: r.description || "" }));
284
+ return allRepos.filter(pickerRepoFilter).map(toPickerRepo);
239
285
  }
240
286
 
241
287
  async function addCollaborator(token, owner, repo, username) {
@@ -709,24 +755,67 @@ async function localFounderFlow(ui, ctx = {}) {
709
755
  ? ctx.prefilledRepo.name
710
756
  : null;
711
757
 
712
- // Multi-select repos to manage
758
+ // Multi-select repos to manage. The org-wide listing is capped at the
759
+ // 500 most recently updated repos, which buries a team's repos in a big
760
+ // org — so the user's team repos are loaded too and boosted to the top,
761
+ // and typing in the picker live-searches the WHOLE org (see
762
+ // searchOrgRepos), so no repo is unreachable.
713
763
  const s0 = ui.spinner("Loading repos...");
764
+ let repos = [];
765
+ let teamRepoCount = 0;
714
766
  try {
715
- const repos = await listOrgRepos(githubToken, githubOrg, isOrg);
716
- s0.stop("Loaded repos");
717
- if (repos.length > 0) {
718
- const filtered = autoAdopt ? repos.filter((r) => r.name !== autoAdopt) : repos;
719
- if (autoAdopt) {
720
- console.log("");
721
- ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
767
+ const [orgRepos, teams] = await Promise.all([
768
+ listOrgRepos(githubToken, githubOrg, isOrg),
769
+ isOrg ? listUserTeams(githubToken, githubOrg) : Promise.resolve([]),
770
+ ]);
771
+ const teamRepoLists = await Promise.all(
772
+ teams.map((t) => listTeamRepos(githubToken, githubOrg, t.slug).catch(() => [])),
773
+ );
774
+ const seen = new Set();
775
+ for (const list of teamRepoLists) {
776
+ for (const r of list) {
777
+ if (seen.has(r.name)) continue;
778
+ seen.add(r.name);
779
+ repos.push(r);
722
780
  }
723
- selectedRepos = await ui.multiSelect("Which other repos should Egregore manage?", filtered);
724
- } else {
725
- ui.info("No repos found yet.");
726
781
  }
782
+ teamRepoCount = repos.length;
783
+ for (const r of orgRepos) {
784
+ if (seen.has(r.name)) continue;
785
+ seen.add(r.name);
786
+ repos.push(r);
787
+ }
788
+ s0.stop(teamRepoCount > 0 ? `Loaded repos — your teams' ${teamRepoCount} first` : "Loaded repos");
727
789
  } catch {
728
790
  s0.stop("Could not load repos");
729
791
  }
792
+
793
+ const filtered = autoAdopt ? repos.filter((r) => r.name !== autoAdopt) : repos;
794
+ if (autoAdopt) {
795
+ console.log("");
796
+ ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
797
+ }
798
+ selectedRepos = await ui.multiSelect(
799
+ "Which other repos should Egregore manage?",
800
+ filtered,
801
+ { remoteSearch: (term) => searchOrgRepos(githubToken, githubOrg, isOrg, term) },
802
+ );
803
+
804
+ // Non-interactive terminals get no live search — keep a typed-names
805
+ // escape hatch there, validated so a typo doesn't become a ghost repo.
806
+ if (!(ui.canInteract && ui.canInteract())) {
807
+ const extraRaw = await ui.prompt("any repos not listed? exact names, comma-separated (Enter to skip)");
808
+ for (const raw of (extraRaw || "").split(",")) {
809
+ const name = raw.trim();
810
+ if (!name || selectedRepos.includes(name)) continue;
811
+ if (await repoExists(githubToken, githubOrg, name)) {
812
+ selectedRepos.push(name);
813
+ ui.success(`Added ${name}`);
814
+ } else {
815
+ ui.warn(`No repo ${githubOrg}/${name} — skipping`);
816
+ }
817
+ }
818
+ }
730
819
  if (autoAdopt && !selectedRepos.includes(autoAdopt)) {
731
820
  selectedRepos.unshift(autoAdopt);
732
821
  }
@@ -756,17 +845,39 @@ async function localFounderFlow(ui, ctx = {}) {
756
845
  }
757
846
  }
758
847
 
759
- // 6. Derive repo names from team name
760
- const nameSlug = orgName
848
+ // 6. Derive repo names from team name — then let the founder edit them.
849
+ // Creating repos with silently derived names was a repeated field
850
+ // complaint: founders want to choose what lands in their org.
851
+ const slugifyName = (s) => (s || "")
761
852
  .toLowerCase()
762
853
  .replace(/[^a-z0-9-]/g, "-")
763
854
  .replace(/-+/g, "-")
764
- .replace(/^-|-$/g, "") || "egregore";
855
+ .replace(/^-|-$/g, "");
856
+ const nameSlug = slugifyName(orgName) || "egregore";
765
857
  // Org slug prefixed with GitHub owner — globally unique across all egregores
766
- const ghPrefix = githubOrg.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
858
+ const ghPrefix = slugifyName(githubOrg);
767
859
  const slug = ghPrefix === nameSlug ? nameSlug : `${ghPrefix}-${nameSlug}`;
768
- const repoName = nameSlug;
769
- const memoryRepoName = `${nameSlug}-memory`;
860
+
861
+ console.log("");
862
+ ui.note([
863
+ `Two repos will be created in ${ui.bold(githubOrg)}: the Egregore repo`,
864
+ "(config + skills) and its shared-memory repo. Enter keeps the defaults.",
865
+ ]);
866
+ let repoName;
867
+ for (;;) {
868
+ repoName = slugifyName(await ui.prompt("Egregore repo name", { default: nameSlug }));
869
+ if (!repoName) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
870
+ if (newProjectRepo && repoName === newProjectRepo) { ui.warn(`"${repoName}" is already your project repo. Pick a different name.`); continue; }
871
+ break;
872
+ }
873
+ let memoryRepoName;
874
+ for (;;) {
875
+ memoryRepoName = slugifyName(await ui.prompt("memory repo name", { default: `${repoName}-memory` }));
876
+ if (!memoryRepoName) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
877
+ if (memoryRepoName === repoName) { ui.warn("Memory repo can't share the Egregore repo's name."); continue; }
878
+ if (newProjectRepo && memoryRepoName === newProjectRepo) { ui.warn(`"${memoryRepoName}" is already your project repo. Pick a different name.`); continue; }
879
+ break;
880
+ }
770
881
 
771
882
  // Guard: if egregore repo already exists with a config, don't overwrite
772
883
  console.log("");
@@ -1169,7 +1280,11 @@ async function localFounderFlow(ui, ctx = {}) {
1169
1280
  ...pickable.map((m) => ({ label: m.login, description: `@${githubOrg} member` })),
1170
1281
  { label: "Type a username", description: "invite someone not in the list" },
1171
1282
  ];
1172
- const picked = await ui.choose(first ? "who should we invite?" : "invite someone else?", choices);
1283
+ const picked = await ui.choose(
1284
+ first ? "who should we invite?" : "invite someone else?",
1285
+ choices,
1286
+ first ? "Inviting is optional — the first option skips this step." : undefined,
1287
+ );
1173
1288
  if (picked.label === stopChoice.label) break;
1174
1289
  if (picked.label === "Type a username") {
1175
1290
  invUsername = await ui.prompt("GitHub username");
package/lib/ui.js CHANGED
@@ -254,12 +254,37 @@ function visualRows(line, width = process.stdout.columns || 80) {
254
254
 
255
255
  // Render a picker frame and return { lines: number } so the next frame can
256
256
  // rewind and clear. Shared by choose + multiSelect.
257
- function renderPicker({ question, filterText, lines, help }) {
257
+ //
258
+ // `input` makes the type-to-search field a first-class affordance: the
259
+ // labelled input renders on the question line even when empty, and the REAL
260
+ // terminal cursor is parked right after the typed text so it blinks where
261
+ // keystrokes land — users shouldn't have to discover typing by accident.
262
+ let _parkedRows = 0;
263
+
264
+ function unparkCursor() {
265
+ if (_parkedRows > 0) {
266
+ process.stdout.write(`\x1b[${_parkedRows}B\r`);
267
+ _parkedRows = 0;
268
+ }
269
+ }
270
+
271
+ function renderPicker({ question, filterText, lines, help, input }) {
258
272
  const out = [];
259
- const filterLabel = filterText
260
- ? ` ${DIM}filter:${RESET} ${filterText}${CYAN}▌${RESET}`
261
- : "";
262
- out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET}${filterLabel ? " " + filterLabel : ""}`);
273
+ const width = process.stdout.columns || 80;
274
+ const label = (input && input.label) || "filter";
275
+ const showInput = !!input || !!filterText;
276
+
277
+ // Bare header (no ANSI, no cursor glyph) drives the park-position math.
278
+ const headerPlain = ` ◆ ${question} ${label}: ${filterText}`;
279
+ // Park only when the header fits one row — wrapped rows break the math.
280
+ const canPark = showInput && visualRows(headerPlain, width) === 1 && headerPlain.length + 1 < width;
281
+
282
+ if (showInput) {
283
+ const glyph = canPark ? "" : `${CYAN}▌${RESET}`;
284
+ out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}${label}:${RESET} ${filterText}${glyph}`);
285
+ } else {
286
+ out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET}`);
287
+ }
263
288
  out.push("");
264
289
  for (const line of lines) out.push(line);
265
290
  if (help) {
@@ -268,11 +293,17 @@ function renderPicker({ question, filterText, lines, help }) {
268
293
  }
269
294
  const text = out.join("\n") + "\n";
270
295
  process.stdout.write(text);
271
- const width = process.stdout.columns || 80;
272
- return out.reduce((sum, line) => sum + visualRows(line, width), 0);
296
+ const rows = out.reduce((sum, line) => sum + visualRows(line, width), 0);
297
+ _parkedRows = 0;
298
+ if (canPark) {
299
+ process.stdout.write(`\x1b[${rows}A\x1b[${headerPlain.length + 1}G`);
300
+ _parkedRows = rows;
301
+ }
302
+ return rows;
273
303
  }
274
304
 
275
305
  function clearLines(n) {
306
+ unparkCursor();
276
307
  if (n <= 0) return;
277
308
  // Move up n lines and clear from cursor to end of screen.
278
309
  process.stdout.write(`\x1b[${n}A\x1b[0J`);
@@ -389,6 +420,7 @@ async function interactiveChoose(question, options, hint) {
389
420
  if (!key) return;
390
421
  if ((key.ctrl && key.name === "c") || key.name === "escape") {
391
422
  cleanup();
423
+ unparkCursor();
392
424
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
393
425
  process.exit(130);
394
426
  }
@@ -461,58 +493,143 @@ async function legacyChoose(question, options, hint) {
461
493
  // ── multiSelect: interactive multi-select picker ────────────────────
462
494
  //
463
495
  // options: [{ name, language?, description? }]
496
+ // opts.remoteSearch: async (term) => [{ name, language?, description? }]
497
+ // Hybrid search source: typing filters the local pool instantly AND
498
+ // (debounced) queries the remote source, merging new results into the
499
+ // pool. Lets a picker seeded with a bounded window (e.g. the 500 most
500
+ // recently updated repos of a huge org) reach everything the API can
501
+ // search — no pagination, no separate "type the exact name" prompt.
464
502
  // returns: array of picked names
465
503
  // keys:
466
504
  // ↑ ↓ navigate
467
505
  // space toggle current
468
- // a toggle all (respecting filter)
506
+ // ctrl-a toggle all (respecting filter)
469
507
  // enter confirm
470
508
  // type filter
471
509
  // esc confirm with current selection
472
510
 
473
- async function multiSelect(question, options) {
474
- if (options.length === 0) return [];
475
- if (!canInteract()) return legacyMultiSelect(question, options);
476
- return interactiveMultiSelect(question, options);
511
+ async function multiSelect(question, options, opts = {}) {
512
+ // Remote search can populate an empty pool, but only interactively —
513
+ // the legacy numbered fallback has no live search.
514
+ if (!canInteract()) return options.length === 0 ? [] : legacyMultiSelect(question, options);
515
+ if (options.length === 0 && !opts.remoteSearch) return [];
516
+ return interactiveMultiSelect(question, options, opts);
477
517
  }
478
518
 
479
- async function interactiveMultiSelect(question, options) {
519
+ const MULTISELECT_MAX_ROWS = 12;
520
+ const REMOTE_SEARCH_DEBOUNCE_MS = 300;
521
+
522
+ async function interactiveMultiSelect(question, options, opts = {}) {
480
523
  return new Promise((resolve) => {
481
524
  let cursor = 0;
482
525
  let filterText = "";
483
526
  const picked = new Set();
484
- let visible = filterOptions(options, filterText, (o) => `${o.name} ${o.description || ""} ${o.language || ""}`);
527
+ // The pool grows as remote results merge in; seed order is preserved so
528
+ // boosted entries (e.g. the user's team repos) stay on top when idle.
529
+ const pool = [...options];
530
+ const poolNames = new Set(pool.map((o) => o.name));
531
+ const optText = (o) => `${o.name} ${o.description || ""} ${o.language || ""}`;
532
+ let visible = filterOptions(pool, filterText, optText);
485
533
  let lastLineCount = 0;
534
+ let searchTimer = null;
535
+ let searching = false;
486
536
 
487
- const nameWidth = Math.min(36, Math.max(...options.map((o) => (o.name || "").length)));
537
+ const nameWidth = () =>
538
+ Math.min(36, Math.max(12, ...pool.map((o) => (o.name || "").length)));
488
539
 
489
540
  const render = (first = false) => {
490
- const lines = visible.map((entry, i) => {
541
+ const w = nameWidth();
542
+ const renderRow = (entry, i) => {
491
543
  const { opt } = entry;
492
544
  const isCursor = i === cursor;
493
545
  const isPicked = picked.has(opt.name);
494
546
  const cursorMark = isCursor ? `${CYAN}❯${RESET}` : " ";
495
547
  const checkbox = isPicked ? `${GREEN}[✓]${RESET}` : `${DIM}[ ]${RESET}`;
496
- const name = isCursor ? `${BOLD}${CYAN}${pad(opt.name, nameWidth)}${RESET}` : pad(opt.name, nameWidth);
548
+ const name = isCursor ? `${BOLD}${CYAN}${pad(opt.name, w)}${RESET}` : pad(opt.name, w);
497
549
  const lang = opt.language ? ` ${DIM}(${opt.language})${RESET}` : "";
498
550
  const desc = opt.description ? ` ${DIM}${opt.description}${RESET}` : "";
499
551
  return ` ${cursorMark} ${checkbox} ${name}${lang}${desc}`;
500
- });
501
- if (visible.length === 0) {
502
- lines.push(` ${DIM}(no matches)${RESET}`);
552
+ };
553
+
554
+ // Bounded viewport that follows the cursor — a huge org must not dump
555
+ // hundreds of rows into the terminal. "↑/↓ N more" mark the remainder.
556
+ const lines = [];
557
+ const total = visible.length;
558
+ let start = 0;
559
+ if (total > MULTISELECT_MAX_ROWS) {
560
+ start = Math.min(
561
+ Math.max(0, cursor - Math.floor(MULTISELECT_MAX_ROWS / 2)),
562
+ total - MULTISELECT_MAX_ROWS,
563
+ );
503
564
  }
565
+ const end = Math.min(total, start + MULTISELECT_MAX_ROWS);
566
+ if (start > 0) lines.push(` ${DIM}↑ ${start} more${RESET}`);
567
+ for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
568
+ if (end < total) lines.push(` ${DIM}↓ ${total - end} more${RESET}`);
569
+ if (total === 0) {
570
+ lines.push(searching ? ` ${DIM}searching…${RESET}` : ` ${DIM}(no matches)${RESET}`);
571
+ }
572
+
504
573
  lines.push("");
505
- lines.push(` ${DIM}${picked.size} selected${RESET}`);
574
+ // Selected tray: picked names stay visible even when the current
575
+ // filter hides their rows — a selection must never look lost.
576
+ if (picked.size > 0) {
577
+ const names = [...picked].join(", ");
578
+ const maxW = Math.max(20, (process.stdout.columns || 80) - 20);
579
+ const shown = names.length > maxW ? names.slice(0, maxW - 1) + "…" : names;
580
+ lines.push(` ${GREEN}✓${RESET} ${DIM}selected (${picked.size}):${RESET} ${shown}`);
581
+ } else {
582
+ lines.push(` ${DIM}0 selected${RESET}`);
583
+ }
584
+ if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
585
+
586
+ const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
506
587
  if (!first) clearLines(lastLineCount);
507
588
  lastLineCount = renderPicker({
508
589
  question,
509
590
  filterText,
510
591
  lines,
511
- help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}a${RESET} all · ${DIM}enter${RESET} confirm · type to filter`,
592
+ 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}`,
512
594
  });
513
595
  };
514
596
 
597
+ const refreshVisible = () => {
598
+ visible = filterOptions(pool, filterText, optText);
599
+ if (cursor >= visible.length) cursor = Math.max(0, visible.length - 1);
600
+ };
601
+
602
+ // Debounced remote search: merge results into the pool, ignore stale
603
+ // responses (the term changed while the request was in flight).
604
+ const scheduleRemoteSearch = () => {
605
+ if (!opts.remoteSearch) return;
606
+ if (searchTimer) clearTimeout(searchTimer);
607
+ const term = filterText.trim();
608
+ if (term.length < 2) { searching = false; return; }
609
+ searching = true;
610
+ searchTimer = setTimeout(() => {
611
+ Promise.resolve(opts.remoteSearch(term))
612
+ .then((results) => {
613
+ if (filterText.trim() !== term) return; // stale
614
+ for (const r of results || []) {
615
+ if (!r || !r.name || poolNames.has(r.name)) continue;
616
+ poolNames.add(r.name);
617
+ pool.push(r);
618
+ }
619
+ searching = false;
620
+ refreshVisible();
621
+ render();
622
+ })
623
+ .catch(() => {
624
+ if (filterText.trim() !== term) return;
625
+ searching = false; // network hiccup — local filter still works
626
+ render();
627
+ });
628
+ }, REMOTE_SEARCH_DEBOUNCE_MS);
629
+ };
630
+
515
631
  const cleanup = () => {
632
+ if (searchTimer) clearTimeout(searchTimer);
516
633
  process.stdin.setRawMode(false);
517
634
  process.stdin.pause();
518
635
  process.stdin.removeListener("keypress", onKey);
@@ -546,6 +663,7 @@ async function interactiveMultiSelect(question, options) {
546
663
  if (!key) return;
547
664
  if ((key.ctrl && key.name === "c") || key.name === "escape") {
548
665
  cleanup();
666
+ unparkCursor();
549
667
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
550
668
  process.exit(130);
551
669
  }
@@ -565,22 +683,27 @@ async function interactiveMultiSelect(question, options) {
565
683
  render();
566
684
  return;
567
685
  }
568
- if (_str === "a" && !filterText) {
686
+ // ctrl-a, not bare "a": with live type-to-search, a bare letter must
687
+ // always go to the filter — typing "alpha-api" on an empty filter used
688
+ // to toggle-all on the first keystroke.
689
+ if (key.ctrl && key.name === "a") {
569
690
  toggleAll();
570
691
  render();
571
692
  return;
572
693
  }
573
694
  if (key.name === "backspace") {
574
695
  filterText = filterText.slice(0, -1);
575
- visible = filterOptions(options, filterText, (o) => `${o.name} ${o.description || ""} ${o.language || ""}`);
696
+ refreshVisible();
576
697
  cursor = 0;
698
+ scheduleRemoteSearch();
577
699
  render();
578
700
  return;
579
701
  }
580
702
  if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
581
703
  filterText += _str;
582
- visible = filterOptions(options, filterText, (o) => `${o.name} ${o.description || ""} ${o.language || ""}`);
704
+ refreshVisible();
583
705
  cursor = 0;
706
+ scheduleRemoteSearch();
584
707
  render();
585
708
  }
586
709
  };
@@ -886,7 +1009,7 @@ module.exports = {
886
1009
  banner, bannerCompact, section, note, card,
887
1010
  info, success, warn, error, step,
888
1011
  dim, bold, cyan, green, yellow, red,
889
- spinner, prompt, choose, multiSelect, confirm,
1012
+ spinner, prompt, choose, multiSelect, confirm, canInteract,
890
1013
  commandBox,
891
1014
  canShowScreens, welcomeScreen, summaryScreen,
892
1015
  _renderWelcomeBody, _readPkgVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.18.16",
3
+ "version": "0.18.17",
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",
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",
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
  },