create-egregore 0.18.16 → 0.18.18

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/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) {
@@ -607,130 +653,218 @@ async function localFounderFlow(ui, ctx = {}) {
607
653
  // 2. Get user info
608
654
  const user = await getUser(githubToken);
609
655
 
610
- // 3. Where — we always have a gh token (the only local auth path), which
611
- // carries org read, so we can list the user's accounts and offer a picker.
612
- ui.section("Pick a home", "where should this Egregore live?");
613
- const s = ui.spinner("Checking your GitHub accounts...");
614
- const orgs = await listOrgs(githubToken);
615
- s.stop("Accounts loaded");
616
-
617
- let githubOrg, isOrg;
618
- if (orgs.length === 0) {
619
- githubOrg = user.login;
620
- isOrg = false;
621
- ui.info(`Using your personal account: ${ui.bold(githubOrg)}`);
622
- } else {
623
- const orgChoices = [{ label: user.login, description: "Personal account" }];
624
- for (const o of orgs) {
625
- orgChoices.push({ label: o.login, description: "Organization" });
656
+ // 3–6. Home → new/existing → naming → repo picker → repo names, as a
657
+ // step machine so esc walks BACKWARDS through the flow (field request:
658
+ // a wrong org pick shouldn't mean Ctrl-C and start over). Every answer
659
+ // lives in `st`, so a revisited step re-offers the previous choice.
660
+ // Creation (step 7 below) is the point of no return — no back from there.
661
+ const slugifyName = (s) => (s || "")
662
+ .toLowerCase()
663
+ .replace(/[^a-z0-9-]/g, "-")
664
+ .replace(/-+/g, "-")
665
+ .replace(/^-|-$/g, "");
666
+ const BACK = ui.BACK;
667
+ const SKIP = Symbol("step-skip"); // deterministic step — passes through in both directions
668
+
669
+ const st = {
670
+ orgs: null, // fetched once
671
+ githubOrg: null, isOrg: false,
672
+ isNewProject: null,
673
+ orgName: null, description: "", newProjectRepo: null,
674
+ selectedRepoNames: null, // raw picker selection (preserved across back)
675
+ selectedRepos: [], // enriched {name, base_branch}
676
+ repoName: null, memoryRepoName: null,
677
+ reposCache: null, // {org, repos, teamRepoCount}
678
+ };
679
+
680
+ const stepHome = async () => {
681
+ if (!st.orgs) {
682
+ ui.section("Pick a home", "where should this Egregore live?");
683
+ const s = ui.spinner("Checking your GitHub accounts...");
684
+ st.orgs = await listOrgs(githubToken);
685
+ s.stop("Accounts loaded");
686
+ }
687
+ if (st.orgs.length === 0) {
688
+ if (!st.githubOrg) {
689
+ st.githubOrg = user.login;
690
+ st.isOrg = false;
691
+ ui.info(`Using your personal account: ${ui.bold(st.githubOrg)}`);
692
+ }
693
+ return SKIP;
626
694
  }
627
695
  const orgChoice = await ui.choose(
628
696
  "Where should Egregore live?",
629
- orgChoices,
697
+ [
698
+ { label: user.login, description: "Personal account" },
699
+ ...st.orgs.map((o) => ({ label: o.login, description: "Organization" })),
700
+ ],
630
701
  "Don't see your org? Authorize it for your account on GitHub, then re-run.",
702
+ { escAction: "back" },
631
703
  );
632
- githubOrg = orgChoice.label;
633
- isOrg = githubOrg !== user.login;
634
- }
704
+ if (orgChoice === BACK) return BACK; // first step — driver clamps to a re-ask
705
+ st.githubOrg = orgChoice.label;
706
+ st.isOrg = st.githubOrg !== user.login;
707
+ return true;
708
+ };
635
709
 
636
- // 4. What — new or existing?
637
- // If the user came in via the in-repo detection and chose "install
638
- // alongside", they already told us they have an existing project — don't
639
- // re-ask. Go straight to the existing-project path.
640
- let isNewProject;
641
- if (ctx.prefilledRepo) {
642
- isNewProject = false;
643
- ui.info(`${ui.green("→")} existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
644
- } else {
710
+ const stepWorkType = async () => {
711
+ // In-repo detection already answered this — pass through both ways.
712
+ if (ctx.prefilledRepo) {
713
+ if (st.isNewProject === null) {
714
+ st.isNewProject = false;
715
+ ui.info(`${ui.green("→")} existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
716
+ }
717
+ return SKIP;
718
+ }
645
719
  const action = await ui.choose("What are you working on?", [
646
720
  { label: "Starting something new", description: "Create a project from scratch" },
647
721
  { label: "Existing project", description: "Connect repos I already have" },
648
- ]);
649
- isNewProject = action.label.startsWith("Starting");
650
- }
651
-
652
- // 5. Collect project info
653
- ui.section("Name it", "what's this Egregore called?");
654
- let orgName, description, selectedRepos = [], newProjectRepo = null;
655
-
656
- if (isNewProject) {
657
- orgName = await ui.prompt("name your Egregore (team or group name)", { default: githubOrg });
658
- description = await ui.prompt("what are you building? (one line)");
659
-
660
- // Derive the egregore + memory repo names now so we can validate the
661
- // optional project repo name against them before the user commits to it.
662
- // Matches the final derivation at line ~395 below.
663
- const slugifyRepo = (s) => s.toLowerCase()
664
- .replace(/[^a-z0-9-]/g, "-")
665
- .replace(/-+/g, "-")
666
- .replace(/^-|-$/g, "") || "egregore";
667
- const predictedRepoName = slugifyRepo(orgName);
668
- const predictedMemoryRepoName = `${predictedRepoName}-memory`;
669
-
670
- // Ask about project repo
671
- ui.note([
672
- "Your Egregore gets its own repo (memory + config) in your account.",
673
- "Have a code project too? Egregore can add a repo for it in your account.",
674
- ]);
722
+ ], undefined, { escAction: "back" });
723
+ if (action === BACK) return BACK;
724
+ st.isNewProject = action.label.startsWith("Starting");
725
+ return true;
726
+ };
675
727
 
676
- while (true) {
677
- const projectName = await ui.prompt("code project repo name (Enter to skip)");
678
- if (!projectName) {
679
- newProjectRepo = null;
728
+ const stepNameProject = async () => {
729
+ ui.section("Name it", "what's this Egregore called?");
730
+ if (st.isNewProject) {
731
+ const name = await ui.promptOrBack("name your Egregore (team or group name)", { default: st.orgName || st.githubOrg });
732
+ if (name === BACK) return BACK;
733
+ st.orgName = name;
734
+ const desc = await ui.promptOrBack("what are you building? (one line)", { default: st.description || undefined });
735
+ if (desc === BACK) return BACK;
736
+ st.description = desc;
737
+
738
+ // Validate the optional project repo name against the names the
739
+ // Egregore/memory repos WILL get (editable later, derived for now).
740
+ const predictedRepoName = slugifyName(st.orgName) || "egregore";
741
+ const predictedMemoryRepoName = `${predictedRepoName}-memory`;
742
+ ui.note([
743
+ "Your Egregore gets its own repo (memory + config) in your account.",
744
+ "Have a code project too? Egregore can add a repo for it in your account.",
745
+ ]);
746
+ for (;;) {
747
+ const projectName = await ui.promptOrBack("code project repo name (Enter to skip)", { default: st.newProjectRepo || undefined });
748
+ if (projectName === BACK) return BACK;
749
+ if (!projectName) { st.newProjectRepo = null; break; }
750
+ const slug = slugifyName(projectName);
751
+ if (!slug) {
752
+ ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
753
+ continue;
754
+ }
755
+ if (slug === predictedRepoName) {
756
+ ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
757
+ ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
758
+ continue;
759
+ }
760
+ if (slug === predictedMemoryRepoName) {
761
+ ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
762
+ continue;
763
+ }
764
+ st.newProjectRepo = slug;
680
765
  break;
681
766
  }
682
- const slug = slugifyRepo(projectName);
683
- if (!slug) {
684
- ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
685
- continue;
686
- }
687
- if (slug === predictedRepoName) {
688
- ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
689
- ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
690
- continue;
691
- }
692
- if (slug === predictedMemoryRepoName) {
693
- ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
694
- continue;
695
- }
696
- newProjectRepo = slug;
697
- break;
767
+ } else {
768
+ const name = await ui.promptOrBack("what should we call this Egregore?", { default: st.orgName || st.githubOrg });
769
+ if (name === BACK) return BACK;
770
+ st.orgName = name;
771
+ st.description = "";
772
+ st.newProjectRepo = null;
698
773
  }
699
- } else {
700
- // "Existing project"
701
- orgName = await ui.prompt("what should we call this Egregore?", { default: githubOrg });
702
- description = "";
774
+ return true;
775
+ };
703
776
 
777
+ const stepRepos = async () => {
778
+ if (st.isNewProject) {
779
+ st.selectedRepoNames = null;
780
+ st.selectedRepos = [];
781
+ return SKIP;
782
+ }
704
783
  // Auto-adopt the user's current repo if it's under the chosen GitHub owner.
705
784
  const autoAdopt =
706
785
  ctx.prefilledRepo &&
707
786
  ctx.prefilledRepo.owner &&
708
- ctx.prefilledRepo.owner.toLowerCase() === githubOrg.toLowerCase()
787
+ ctx.prefilledRepo.owner.toLowerCase() === st.githubOrg.toLowerCase()
709
788
  ? ctx.prefilledRepo.name
710
789
  : null;
711
790
 
712
- // Multi-select repos to manage
713
- const s0 = ui.spinner("Loading repos...");
714
- 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)`);
791
+ // Multi-select repos to manage. The org-wide listing is capped at the
792
+ // 500 most recently updated repos, which buries a team's repos in a big
793
+ // org — so the user's team repos are loaded too and boosted to the top,
794
+ // and typing in the picker live-searches the WHOLE org (see
795
+ // searchOrgRepos), so no repo is unreachable. The load is cached per
796
+ // owner so back-navigation doesn't refetch.
797
+ if (!st.reposCache || st.reposCache.org !== st.githubOrg) {
798
+ const s0 = ui.spinner("Loading repos...");
799
+ const repos = [];
800
+ let teamRepoCount = 0;
801
+ try {
802
+ const [orgRepos, teams] = await Promise.all([
803
+ listOrgRepos(githubToken, st.githubOrg, st.isOrg),
804
+ st.isOrg ? listUserTeams(githubToken, st.githubOrg) : Promise.resolve([]),
805
+ ]);
806
+ const teamRepoLists = await Promise.all(
807
+ teams.map((t) => listTeamRepos(githubToken, st.githubOrg, t.slug).catch(() => [])),
808
+ );
809
+ const seen = new Set();
810
+ for (const list of teamRepoLists) {
811
+ for (const r of list) {
812
+ if (seen.has(r.name)) continue;
813
+ seen.add(r.name);
814
+ repos.push(r);
815
+ }
816
+ }
817
+ teamRepoCount = repos.length;
818
+ for (const r of orgRepos) {
819
+ if (seen.has(r.name)) continue;
820
+ seen.add(r.name);
821
+ repos.push(r);
822
+ }
823
+ s0.stop(teamRepoCount > 0 ? `Loaded repos — your teams' ${teamRepoCount} first` : "Loaded repos");
824
+ } catch {
825
+ s0.stop("Could not load repos");
826
+ }
827
+ st.reposCache = { org: st.githubOrg, repos, teamRepoCount };
828
+ }
829
+ const repos = st.reposCache.repos;
830
+
831
+ const filtered = autoAdopt ? repos.filter((r) => r.name !== autoAdopt) : repos;
832
+ if (autoAdopt) {
833
+ console.log("");
834
+ ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
835
+ }
836
+ const picked = await ui.multiSelect(
837
+ "Which other repos should Egregore manage?",
838
+ filtered,
839
+ {
840
+ remoteSearch: (term) => searchOrgRepos(githubToken, st.githubOrg, st.isOrg, term),
841
+ preselected: st.selectedRepoNames || [],
842
+ escAction: "back",
843
+ },
844
+ );
845
+ if (picked === BACK) return BACK;
846
+ let selectedRepos = picked;
847
+
848
+ // Non-interactive terminals get no live search — keep a typed-names
849
+ // escape hatch there, validated so a typo doesn't become a ghost repo.
850
+ if (!(ui.canInteract && ui.canInteract())) {
851
+ const extraRaw = await ui.prompt("any repos not listed? exact names, comma-separated (Enter to skip)");
852
+ for (const raw of (extraRaw || "").split(",")) {
853
+ const name = raw.trim();
854
+ if (!name || selectedRepos.includes(name)) continue;
855
+ if (await repoExists(githubToken, st.githubOrg, name)) {
856
+ selectedRepos.push(name);
857
+ ui.success(`Added ${name}`);
858
+ } else {
859
+ ui.warn(`No repo ${st.githubOrg}/${name} — skipping`);
722
860
  }
723
- selectedRepos = await ui.multiSelect("Which other repos should Egregore manage?", filtered);
724
- } else {
725
- ui.info("No repos found yet.");
726
861
  }
727
- } catch {
728
- s0.stop("Could not load repos");
729
862
  }
730
863
  if (autoAdopt && !selectedRepos.includes(autoAdopt)) {
731
864
  selectedRepos.unshift(autoAdopt);
732
865
  }
733
866
  if (autoAdopt) skipRepos.add(autoAdopt);
867
+ st.selectedRepoNames = [...selectedRepos];
734
868
 
735
869
  // Detect default branch for each selected repo and convert to {name, base_branch} format
736
870
  if (selectedRepos.length > 0) {
@@ -738,14 +872,13 @@ async function localFounderFlow(ui, ctx = {}) {
738
872
  const reposWithBranch = [];
739
873
  for (const repoName of selectedRepos) {
740
874
  try {
741
- const info = await detectRepoBranches(githubToken, githubOrg, repoName);
875
+ const info = await detectRepoBranches(githubToken, st.githubOrg, repoName);
742
876
  reposWithBranch.push({ name: repoName, base_branch: info ? info.defaultBranch : "main" });
743
877
  } catch {
744
878
  reposWithBranch.push({ name: repoName, base_branch: "main" });
745
879
  }
746
880
  }
747
881
  s0b.stop("Checked branches");
748
- // Replace flat list with enriched objects
749
882
  selectedRepos = reposWithBranch;
750
883
  console.log("");
751
884
  ui.info("Detected branch setup:");
@@ -754,19 +887,76 @@ async function localFounderFlow(ui, ctx = {}) {
754
887
  }
755
888
  console.log("");
756
889
  }
890
+ st.selectedRepos = selectedRepos;
891
+ return true;
892
+ };
893
+
894
+ const stepRepoNames = async () => {
895
+ // Derived names shown as editable prompts — creating repos with silently
896
+ // derived names was a repeated field complaint.
897
+ const derivedSlug = slugifyName(st.orgName) || "egregore";
898
+ console.log("");
899
+ ui.note([
900
+ `Two repos will be created in ${ui.bold(st.githubOrg)}: the Egregore repo`,
901
+ "(config + skills) and its shared-memory repo. Enter keeps the defaults.",
902
+ ]);
903
+ for (;;) {
904
+ const answer = await ui.promptOrBack("Egregore repo name", { default: st.repoName || derivedSlug });
905
+ if (answer === BACK) return BACK;
906
+ const candidate = slugifyName(answer);
907
+ if (!candidate) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
908
+ if (st.newProjectRepo && candidate === st.newProjectRepo) { ui.warn(`"${candidate}" is already your project repo. Pick a different name.`); continue; }
909
+ st.repoName = candidate;
910
+ break;
911
+ }
912
+ for (;;) {
913
+ const answer = await ui.promptOrBack("memory repo name", { default: st.memoryRepoName || `${st.repoName}-memory` });
914
+ if (answer === BACK) return BACK;
915
+ const candidate = slugifyName(answer);
916
+ if (!candidate) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
917
+ if (candidate === st.repoName) { ui.warn("Memory repo can't share the Egregore repo's name."); continue; }
918
+ if (st.newProjectRepo && candidate === st.newProjectRepo) { ui.warn(`"${candidate}" is already your project repo. Pick a different name.`); continue; }
919
+ st.memoryRepoName = candidate;
920
+ break;
921
+ }
922
+ return true;
923
+ };
924
+
925
+ // Direction-aware driver: BACK rewinds one step; SKIP (deterministic
926
+ // steps) passes through in whichever direction we're moving, so backing
927
+ // over an auto-answered step doesn't bounce the user forward again.
928
+ const steps = [stepHome, stepWorkType, stepNameProject, stepRepos, stepRepoNames];
929
+ let stepIdx = 0;
930
+ let stepDir = 1;
931
+ while (stepIdx < steps.length) {
932
+ const result = await steps[stepIdx]();
933
+ if (result === BACK) {
934
+ stepDir = -1;
935
+ stepIdx = Math.max(0, stepIdx - 1);
936
+ continue;
937
+ }
938
+ if (result === SKIP) {
939
+ stepIdx += stepDir;
940
+ if (stepIdx < 0) { stepIdx = 0; stepDir = 1; }
941
+ continue;
942
+ }
943
+ stepDir = 1;
944
+ stepIdx++;
757
945
  }
758
946
 
759
- // 6. Derive repo names from team name
760
- const nameSlug = orgName
761
- .toLowerCase()
762
- .replace(/[^a-z0-9-]/g, "-")
763
- .replace(/-+/g, "-")
764
- .replace(/^-|-$/g, "") || "egregore";
947
+ const githubOrg = st.githubOrg;
948
+ const isOrg = st.isOrg;
949
+ const isNewProject = st.isNewProject;
950
+ const orgName = st.orgName;
951
+ const description = st.description;
952
+ let newProjectRepo = st.newProjectRepo;
953
+ let selectedRepos = st.selectedRepos;
954
+ const repoName = st.repoName;
955
+ const memoryRepoName = st.memoryRepoName;
956
+ const nameSlug = slugifyName(orgName) || "egregore";
765
957
  // 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, "");
958
+ const ghPrefix = slugifyName(githubOrg);
767
959
  const slug = ghPrefix === nameSlug ? nameSlug : `${ghPrefix}-${nameSlug}`;
768
- const repoName = nameSlug;
769
- const memoryRepoName = `${nameSlug}-memory`;
770
960
 
771
961
  // Guard: if egregore repo already exists with a config, don't overwrite
772
962
  console.log("");
@@ -1169,7 +1359,11 @@ async function localFounderFlow(ui, ctx = {}) {
1169
1359
  ...pickable.map((m) => ({ label: m.login, description: `@${githubOrg} member` })),
1170
1360
  { label: "Type a username", description: "invite someone not in the list" },
1171
1361
  ];
1172
- const picked = await ui.choose(first ? "who should we invite?" : "invite someone else?", choices);
1362
+ const picked = await ui.choose(
1363
+ first ? "who should we invite?" : "invite someone else?",
1364
+ choices,
1365
+ first ? "Inviting is optional — the first option skips this step." : undefined,
1366
+ );
1173
1367
  if (picked.label === stopChoice.label) break;
1174
1368
  if (picked.label === "Type a username") {
1175
1369
  invUsername = await ui.prompt("GitHub username");
package/lib/ui.js CHANGED
@@ -226,6 +226,60 @@ function prompt(question, { default: defaultValue } = {}) {
226
226
  });
227
227
  }
228
228
 
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 });
235
+ return new Promise((resolve) => {
236
+ let buf = "";
237
+ 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}`);
241
+ const cleanup = () => {
242
+ process.stdin.setRawMode(false);
243
+ process.stdin.pause();
244
+ process.stdin.removeListener("keypress", onKey);
245
+ };
246
+ const onKey = (_str, key) => {
247
+ if (!key) return;
248
+ if (key.ctrl && key.name === "c") {
249
+ cleanup();
250
+ process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
251
+ process.exit(130);
252
+ }
253
+ if (key.name === "escape") {
254
+ cleanup();
255
+ process.stdout.write(`\r\x1b[2K${label}${DIM}← back${RESET}\n`);
256
+ resolve(BACK);
257
+ return;
258
+ }
259
+ if (key.name === "return") {
260
+ cleanup();
261
+ process.stdout.write("\n");
262
+ resolve(buf.trim() || (defaultValue ?? ""));
263
+ return;
264
+ }
265
+ if (key.name === "backspace") {
266
+ buf = buf.slice(0, -1);
267
+ render();
268
+ return;
269
+ }
270
+ if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
271
+ buf += _str;
272
+ render();
273
+ }
274
+ };
275
+ readline.emitKeypressEvents(process.stdin);
276
+ process.stdin.setRawMode(true);
277
+ process.stdin.resume();
278
+ process.stdin.on("keypress", onKey);
279
+ render();
280
+ });
281
+ }
282
+
229
283
  async function confirm(question, defaultYes = false) {
230
284
  const hint = defaultYes ? `${DIM}[Y/n]${RESET}` : `${DIM}[y/N]${RESET}`;
231
285
  const answer = await prompt(`${question} ${hint}`);
@@ -254,12 +308,37 @@ function visualRows(line, width = process.stdout.columns || 80) {
254
308
 
255
309
  // Render a picker frame and return { lines: number } so the next frame can
256
310
  // rewind and clear. Shared by choose + multiSelect.
257
- function renderPicker({ question, filterText, lines, help }) {
311
+ //
312
+ // `input` makes the type-to-search field a first-class affordance: the
313
+ // labelled input renders on the question line even when empty, and the REAL
314
+ // terminal cursor is parked right after the typed text so it blinks where
315
+ // keystrokes land — users shouldn't have to discover typing by accident.
316
+ let _parkedRows = 0;
317
+
318
+ function unparkCursor() {
319
+ if (_parkedRows > 0) {
320
+ process.stdout.write(`\x1b[${_parkedRows}B\r`);
321
+ _parkedRows = 0;
322
+ }
323
+ }
324
+
325
+ function renderPicker({ question, filterText, lines, help, input }) {
258
326
  const out = [];
259
- const filterLabel = filterText
260
- ? ` ${DIM}filter:${RESET} ${filterText}${CYAN}▌${RESET}`
261
- : "";
262
- out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET}${filterLabel ? " " + filterLabel : ""}`);
327
+ const width = process.stdout.columns || 80;
328
+ const label = (input && input.label) || "filter";
329
+ const showInput = !!input || !!filterText;
330
+
331
+ // Bare header (no ANSI, no cursor glyph) drives the park-position math.
332
+ const headerPlain = ` ◆ ${question} ${label}: ${filterText}`;
333
+ // Park only when the header fits one row — wrapped rows break the math.
334
+ const canPark = showInput && visualRows(headerPlain, width) === 1 && headerPlain.length + 1 < width;
335
+
336
+ if (showInput) {
337
+ const glyph = canPark ? "" : `${CYAN}▌${RESET}`;
338
+ out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}${label}:${RESET} ${filterText}${glyph}`);
339
+ } else {
340
+ out.push(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET}`);
341
+ }
263
342
  out.push("");
264
343
  for (const line of lines) out.push(line);
265
344
  if (help) {
@@ -268,11 +347,17 @@ function renderPicker({ question, filterText, lines, help }) {
268
347
  }
269
348
  const text = out.join("\n") + "\n";
270
349
  process.stdout.write(text);
271
- const width = process.stdout.columns || 80;
272
- return out.reduce((sum, line) => sum + visualRows(line, width), 0);
350
+ const rows = out.reduce((sum, line) => sum + visualRows(line, width), 0);
351
+ _parkedRows = 0;
352
+ if (canPark) {
353
+ process.stdout.write(`\x1b[${rows}A\x1b[${headerPlain.length + 1}G`);
354
+ _parkedRows = rows;
355
+ }
356
+ return rows;
273
357
  }
274
358
 
275
359
  function clearLines(n) {
360
+ unparkCursor();
276
361
  if (n <= 0) return;
277
362
  // Move up n lines and clear from cursor to end of screen.
278
363
  process.stdout.write(`\x1b[${n}A\x1b[0J`);
@@ -289,24 +374,33 @@ function filterOptions(options, filterText, getText) {
289
374
  return result;
290
375
  }
291
376
 
377
+ // ── back navigation ─────────────────────────────────────────────────
378
+ //
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.
383
+ const BACK = Symbol.for("egregore.ui.back");
384
+
292
385
  // ── choose: interactive single-select picker ────────────────────────
293
386
  //
294
387
  // options: [{ label, description }]
295
- // returns: the picked option (same object reference as in options[])
388
+ // returns: the picked option (same object reference as in options[]),
389
+ // or BACK when opts.escAction === "back" and the user pressed esc
296
390
  // keys:
297
391
  // ↑ ↓ navigate
298
392
  // enter select
299
393
  // type filter (case-insensitive substring match on label + desc)
300
394
  // backspace pop filter
301
- // esc cancel (returns the first option, or throws if opts.abortOnEsc)
395
+ // esc cancel — or go back with opts.escAction: "back"
302
396
  // ctrl-c abort process
303
397
 
304
- async function choose(question, options, hint) {
305
- if (!canInteract()) return legacyChoose(question, options, hint);
306
- return interactiveChoose(question, options, hint);
398
+ async function choose(question, options, hint, opts = {}) {
399
+ if (!canInteract()) return legacyChoose(question, options, hint, opts);
400
+ return interactiveChoose(question, options, hint, opts);
307
401
  }
308
402
 
309
- async function interactiveChoose(question, options, hint) {
403
+ async function interactiveChoose(question, options, hint, opts = {}) {
310
404
  return new Promise((resolve) => {
311
405
  let filterText = "";
312
406
  let visible = filterOptions(options, filterText, (o) => `${o.label} ${o.description || ""}`);
@@ -363,12 +457,13 @@ async function interactiveChoose(question, options, hint) {
363
457
  for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
364
458
  if (end < total) lines.push(` ${DIM}↓ ${total - end} more · type to filter${RESET}`);
365
459
  }
460
+ const escHint = opts.escAction === "back" ? `${DIM}esc${RESET} back` : `${DIM}esc${RESET} cancel`;
366
461
  if (!first) clearLines(lastLineCount);
367
462
  lastLineCount = renderPicker({
368
463
  question,
369
464
  filterText,
370
465
  lines,
371
- help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${DIM}esc${RESET} cancel`,
466
+ help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${escHint}`,
372
467
  });
373
468
  };
374
469
 
@@ -385,10 +480,31 @@ async function interactiveChoose(question, options, hint) {
385
480
  resolve(value);
386
481
  };
387
482
 
483
+ const goBack = () => {
484
+ cleanup();
485
+ clearLines(lastLineCount);
486
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
487
+ resolve(BACK);
488
+ };
489
+
388
490
  const onKey = (_str, key) => {
389
491
  if (!key) return;
492
+ if (key.name === "escape" && filterText) {
493
+ // fzf convention: first esc clears an active filter; only a clean
494
+ // state goes back / cancels.
495
+ filterText = "";
496
+ visible = filterOptions(options, filterText, (o) => `${o.label} ${o.description || ""}`);
497
+ cursor = firstEnabled();
498
+ render();
499
+ return;
500
+ }
501
+ if (key.name === "escape" && opts.escAction === "back") {
502
+ goBack();
503
+ return;
504
+ }
390
505
  if ((key.ctrl && key.name === "c") || key.name === "escape") {
391
506
  cleanup();
507
+ unparkCursor();
392
508
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
393
509
  process.exit(130);
394
510
  }
@@ -430,7 +546,7 @@ async function interactiveChoose(question, options, hint) {
430
546
  });
431
547
  }
432
548
 
433
- async function legacyChoose(question, options, hint) {
549
+ async function legacyChoose(question, options, hint, opts = {}) {
434
550
  process.stdout.write(`\n ${CYAN}◆${RESET} ${BOLD}${question}${RESET}\n\n`);
435
551
  const labelWidth = Math.min(40, Math.max(...options.map((o) => o.label.length)));
436
552
  for (let i = 0; i < options.length; i++) {
@@ -441,8 +557,10 @@ async function legacyChoose(question, options, hint) {
441
557
  process.stdout.write(` ${DIM}${num})${RESET} ${BOLD}${paddedLabel}${RESET}${desc}\n`);
442
558
  }
443
559
  if (hint) process.stdout.write(`\n ${DIM}${hint}${RESET}\n`);
560
+ if (opts.escAction === "back") process.stdout.write(` ${DIM}type "back" to go back${RESET}\n`);
444
561
  while (true) {
445
562
  const answer = await prompt(`pick ${DIM}[1-${options.length}]${RESET}:`);
563
+ if (opts.escAction === "back" && /^(b|back)$/i.test(answer)) return BACK;
446
564
  if (!answer) { warn(`Enter a number between 1 and ${options.length}.`); continue; }
447
565
  const n = parseInt(answer, 10);
448
566
  if (n >= 1 && n <= options.length) {
@@ -461,58 +579,146 @@ async function legacyChoose(question, options, hint) {
461
579
  // ── multiSelect: interactive multi-select picker ────────────────────
462
580
  //
463
581
  // options: [{ name, language?, description? }]
582
+ // opts.remoteSearch: async (term) => [{ name, language?, description? }]
583
+ // Hybrid search source: typing filters the local pool instantly AND
584
+ // (debounced) queries the remote source, merging new results into the
585
+ // pool. Lets a picker seeded with a bounded window (e.g. the 500 most
586
+ // recently updated repos of a huge org) reach everything the API can
587
+ // search — no pagination, no separate "type the exact name" prompt.
464
588
  // returns: array of picked names
465
589
  // keys:
466
590
  // ↑ ↓ navigate
467
591
  // space toggle current
468
- // a toggle all (respecting filter)
592
+ // ctrl-a toggle all (respecting filter)
469
593
  // enter confirm
470
594
  // type filter
471
595
  // esc confirm with current selection
472
596
 
473
- async function multiSelect(question, options) {
474
- if (options.length === 0) return [];
475
- if (!canInteract()) return legacyMultiSelect(question, options);
476
- return interactiveMultiSelect(question, options);
597
+ async function multiSelect(question, options, opts = {}) {
598
+ // Remote search can populate an empty pool, but only interactively —
599
+ // the legacy numbered fallback has no live search.
600
+ if (!canInteract()) return options.length === 0 ? [] : legacyMultiSelect(question, options);
601
+ if (options.length === 0 && !opts.remoteSearch) return [];
602
+ return interactiveMultiSelect(question, options, opts);
477
603
  }
478
604
 
479
- async function interactiveMultiSelect(question, options) {
605
+ const MULTISELECT_MAX_ROWS = 12;
606
+ const REMOTE_SEARCH_DEBOUNCE_MS = 300;
607
+
608
+ async function interactiveMultiSelect(question, options, opts = {}) {
480
609
  return new Promise((resolve) => {
481
610
  let cursor = 0;
482
611
  let filterText = "";
483
- const picked = new Set();
484
- let visible = filterOptions(options, filterText, (o) => `${o.name} ${o.description || ""} ${o.language || ""}`);
612
+ // opts.preselected re-arms a previous selection — used when the step
613
+ // machine navigates back to the picker so choices aren't lost.
614
+ const picked = new Set((opts.preselected || []).filter((n) => options.some((o) => o.name === n)));
615
+ // The pool grows as remote results merge in; seed order is preserved so
616
+ // boosted entries (e.g. the user's team repos) stay on top when idle.
617
+ const pool = [...options];
618
+ const poolNames = new Set(pool.map((o) => o.name));
619
+ const optText = (o) => `${o.name} ${o.description || ""} ${o.language || ""}`;
620
+ let visible = filterOptions(pool, filterText, optText);
485
621
  let lastLineCount = 0;
622
+ let searchTimer = null;
623
+ let searching = false;
486
624
 
487
- const nameWidth = Math.min(36, Math.max(...options.map((o) => (o.name || "").length)));
625
+ const nameWidth = () =>
626
+ Math.min(36, Math.max(12, ...pool.map((o) => (o.name || "").length)));
488
627
 
489
628
  const render = (first = false) => {
490
- const lines = visible.map((entry, i) => {
629
+ const w = nameWidth();
630
+ const renderRow = (entry, i) => {
491
631
  const { opt } = entry;
492
632
  const isCursor = i === cursor;
493
633
  const isPicked = picked.has(opt.name);
494
634
  const cursorMark = isCursor ? `${CYAN}❯${RESET}` : " ";
495
635
  const checkbox = isPicked ? `${GREEN}[✓]${RESET}` : `${DIM}[ ]${RESET}`;
496
- const name = isCursor ? `${BOLD}${CYAN}${pad(opt.name, nameWidth)}${RESET}` : pad(opt.name, nameWidth);
636
+ const name = isCursor ? `${BOLD}${CYAN}${pad(opt.name, w)}${RESET}` : pad(opt.name, w);
497
637
  const lang = opt.language ? ` ${DIM}(${opt.language})${RESET}` : "";
498
638
  const desc = opt.description ? ` ${DIM}${opt.description}${RESET}` : "";
499
639
  return ` ${cursorMark} ${checkbox} ${name}${lang}${desc}`;
500
- });
501
- if (visible.length === 0) {
502
- lines.push(` ${DIM}(no matches)${RESET}`);
640
+ };
641
+
642
+ // Bounded viewport that follows the cursor — a huge org must not dump
643
+ // hundreds of rows into the terminal. "↑/↓ N more" mark the remainder.
644
+ const lines = [];
645
+ const total = visible.length;
646
+ let start = 0;
647
+ if (total > MULTISELECT_MAX_ROWS) {
648
+ start = Math.min(
649
+ Math.max(0, cursor - Math.floor(MULTISELECT_MAX_ROWS / 2)),
650
+ total - MULTISELECT_MAX_ROWS,
651
+ );
652
+ }
653
+ const end = Math.min(total, start + MULTISELECT_MAX_ROWS);
654
+ if (start > 0) lines.push(` ${DIM}↑ ${start} more${RESET}`);
655
+ for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
656
+ if (end < total) lines.push(` ${DIM}↓ ${total - end} more${RESET}`);
657
+ if (total === 0) {
658
+ lines.push(searching ? ` ${DIM}searching…${RESET}` : ` ${DIM}(no matches)${RESET}`);
503
659
  }
660
+
504
661
  lines.push("");
505
- lines.push(` ${DIM}${picked.size} selected${RESET}`);
662
+ // Selected tray: picked names stay visible even when the current
663
+ // filter hides their rows — a selection must never look lost.
664
+ if (picked.size > 0) {
665
+ const names = [...picked].join(", ");
666
+ const maxW = Math.max(20, (process.stdout.columns || 80) - 20);
667
+ const shown = names.length > maxW ? names.slice(0, maxW - 1) + "…" : names;
668
+ lines.push(` ${GREEN}✓${RESET} ${DIM}selected (${picked.size}):${RESET} ${shown}`);
669
+ } else {
670
+ lines.push(` ${DIM}0 selected${RESET}`);
671
+ }
672
+ if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
673
+
674
+ const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
675
+ const escHint = opts.escAction === "back" ? ` · ${DIM}esc${RESET} back` : "";
506
676
  if (!first) clearLines(lastLineCount);
507
677
  lastLineCount = renderPicker({
508
678
  question,
509
679
  filterText,
510
680
  lines,
511
- help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}a${RESET} all · ${DIM}enter${RESET} confirm · type to filter`,
681
+ 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}`,
512
683
  });
513
684
  };
514
685
 
686
+ const refreshVisible = () => {
687
+ visible = filterOptions(pool, filterText, optText);
688
+ if (cursor >= visible.length) cursor = Math.max(0, visible.length - 1);
689
+ };
690
+
691
+ // Debounced remote search: merge results into the pool, ignore stale
692
+ // responses (the term changed while the request was in flight).
693
+ const scheduleRemoteSearch = () => {
694
+ if (!opts.remoteSearch) return;
695
+ if (searchTimer) clearTimeout(searchTimer);
696
+ const term = filterText.trim();
697
+ if (term.length < 2) { searching = false; return; }
698
+ searching = true;
699
+ searchTimer = setTimeout(() => {
700
+ Promise.resolve(opts.remoteSearch(term))
701
+ .then((results) => {
702
+ if (filterText.trim() !== term) return; // stale
703
+ for (const r of results || []) {
704
+ if (!r || !r.name || poolNames.has(r.name)) continue;
705
+ poolNames.add(r.name);
706
+ pool.push(r);
707
+ }
708
+ searching = false;
709
+ refreshVisible();
710
+ render();
711
+ })
712
+ .catch(() => {
713
+ if (filterText.trim() !== term) return;
714
+ searching = false; // network hiccup — local filter still works
715
+ render();
716
+ });
717
+ }, REMOTE_SEARCH_DEBOUNCE_MS);
718
+ };
719
+
515
720
  const cleanup = () => {
721
+ if (searchTimer) clearTimeout(searchTimer);
516
722
  process.stdin.setRawMode(false);
517
723
  process.stdin.pause();
518
724
  process.stdin.removeListener("keypress", onKey);
@@ -544,8 +750,24 @@ async function interactiveMultiSelect(question, options) {
544
750
 
545
751
  const onKey = (_str, key) => {
546
752
  if (!key) return;
753
+ if (key.name === "escape" && filterText) {
754
+ filterText = "";
755
+ refreshVisible();
756
+ cursor = 0;
757
+ searching = false;
758
+ render();
759
+ return;
760
+ }
761
+ if (key.name === "escape" && opts.escAction === "back") {
762
+ cleanup();
763
+ clearLines(lastLineCount);
764
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
765
+ resolve(BACK);
766
+ return;
767
+ }
547
768
  if ((key.ctrl && key.name === "c") || key.name === "escape") {
548
769
  cleanup();
770
+ unparkCursor();
549
771
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
550
772
  process.exit(130);
551
773
  }
@@ -565,22 +787,27 @@ async function interactiveMultiSelect(question, options) {
565
787
  render();
566
788
  return;
567
789
  }
568
- if (_str === "a" && !filterText) {
790
+ // ctrl-a, not bare "a": with live type-to-search, a bare letter must
791
+ // always go to the filter — typing "alpha-api" on an empty filter used
792
+ // to toggle-all on the first keystroke.
793
+ if (key.ctrl && key.name === "a") {
569
794
  toggleAll();
570
795
  render();
571
796
  return;
572
797
  }
573
798
  if (key.name === "backspace") {
574
799
  filterText = filterText.slice(0, -1);
575
- visible = filterOptions(options, filterText, (o) => `${o.name} ${o.description || ""} ${o.language || ""}`);
800
+ refreshVisible();
576
801
  cursor = 0;
802
+ scheduleRemoteSearch();
577
803
  render();
578
804
  return;
579
805
  }
580
806
  if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
581
807
  filterText += _str;
582
- visible = filterOptions(options, filterText, (o) => `${o.name} ${o.description || ""} ${o.language || ""}`);
808
+ refreshVisible();
583
809
  cursor = 0;
810
+ scheduleRemoteSearch();
584
811
  render();
585
812
  }
586
813
  };
@@ -886,7 +1113,7 @@ module.exports = {
886
1113
  banner, bannerCompact, section, note, card,
887
1114
  info, success, warn, error, step,
888
1115
  dim, bold, cyan, green, yellow, red,
889
- spinner, prompt, choose, multiSelect, confirm,
1116
+ spinner, prompt, promptOrBack, choose, multiSelect, confirm, canInteract, BACK,
890
1117
  commandBox,
891
1118
  canShowScreens, welcomeScreen, summaryScreen,
892
1119
  _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.18",
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 test/back-navigation.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
  },
@@ -162,3 +162,13 @@ PROJ_HASH=$(echo -n "$SCRIPT_DIR" | md5 2>/dev/null || echo -n "$SCRIPT_DIR" | m
162
162
  echo "$EGREGORE_SESSION_ID" > "$SESSION_ID_DIR/session-${PROJ_HASH}.id"
163
163
  # Also write project-local file (CLAUDE.md reads this — no glob needed)
164
164
  echo "$EGREGORE_SESSION_ID" > "$SCRIPT_DIR/.egregore-session-id"
165
+
166
+ # Model stamp hashes the git common-dir root because telemetry.sh resolves it that way.
167
+ MODEL_REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || MODEL_REPO_ROOT="$SCRIPT_DIR"
168
+ MODEL_PROJ_HASH=$(echo -n "$MODEL_REPO_ROOT" | md5 2>/dev/null || echo -n "$MODEL_REPO_ROOT" | md5sum 2>/dev/null | cut -d' ' -f1)
169
+ MODEL_STAMP_FILE="$SESSION_ID_DIR/session-model-${MODEL_PROJ_HASH}"
170
+ if [ -n "${CLAUDE_MODEL:-}" ]; then
171
+ echo "$CLAUDE_MODEL" > "$MODEL_STAMP_FILE"
172
+ else
173
+ rm -f "$MODEL_STAMP_FILE"
174
+ fi
@@ -98,6 +98,13 @@ _resolve_org() {
98
98
  fi
99
99
  }
100
100
 
101
+ _resolve_proj_hash() {
102
+ # Use git common dir so worktrees hash to the same path as the main checkout
103
+ local repo_root
104
+ repo_root=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || repo_root="$SCRIPT_DIR"
105
+ echo -n "$repo_root" | md5 2>/dev/null || echo -n "$repo_root" | md5sum 2>/dev/null | cut -d' ' -f1
106
+ }
107
+
101
108
  _resolve_session_id() {
102
109
  if [ -n "${EGREGORE_SESSION_ID:-}" ]; then
103
110
  echo "$EGREGORE_SESSION_ID"
@@ -106,11 +113,8 @@ _resolve_session_id() {
106
113
 
107
114
  # Fallback: read from file written by session-start.sh
108
115
  # (env vars from hooks don't propagate into the Claude Code agent)
109
- # Use git common dir so worktrees hash to the same path as the main checkout
110
- local repo_root
111
- repo_root=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || repo_root="$SCRIPT_DIR"
112
116
  local proj_hash
113
- proj_hash=$(echo -n "$repo_root" | md5 2>/dev/null || echo -n "$repo_root" | md5sum 2>/dev/null | cut -d' ' -f1)
117
+ proj_hash=$(_resolve_proj_hash)
114
118
  local sid_file="$HOME/.egregore/session-${proj_hash}.id"
115
119
  if [ -f "$sid_file" ]; then
116
120
  cat "$sid_file" 2>/dev/null || echo "unknown"
@@ -119,6 +123,45 @@ _resolve_session_id() {
119
123
  fi
120
124
  }
121
125
 
126
+ _resolve_model() {
127
+ local proj_hash
128
+ proj_hash=$(_resolve_proj_hash)
129
+ local model_file="$HOME/.egregore/session-model-${proj_hash}"
130
+ if [ ! -s "$model_file" ]; then
131
+ return 0
132
+ fi
133
+
134
+ local model=""
135
+ IFS= read -r model < "$model_file" || true
136
+ if [ -n "$model" ]; then
137
+ printf '%s\n' "$model"
138
+ fi
139
+ }
140
+
141
+ _stamp_model_if_absent() {
142
+ local compact_data="$1"
143
+ local has_model=""
144
+ has_model=$(printf '%s' "$compact_data" | jq -e 'has("model")' 2>/dev/null || true)
145
+ if [ "$has_model" != "false" ]; then
146
+ printf '%s' "$compact_data"
147
+ return 0
148
+ fi
149
+
150
+ local model=""
151
+ model=$(_resolve_model 2>/dev/null || true)
152
+ if [ -z "$model" ]; then
153
+ printf '%s' "$compact_data"
154
+ return 0
155
+ fi
156
+
157
+ local stamped_data=""
158
+ if stamped_data=$(printf '%s' "$compact_data" | jq -c --arg m "$model" 'if has("model") then . else . + {model:$m} end' 2>/dev/null); then
159
+ printf '%s' "$stamped_data"
160
+ else
161
+ printf '%s' "$compact_data"
162
+ fi
163
+ }
164
+
122
165
  # --- emit: append event to local JSONL buffer ---
123
166
  cmd_emit() {
124
167
  local event_type="${1:?Usage: telemetry.sh emit <type> <json>}"
@@ -129,12 +172,15 @@ cmd_emit() {
129
172
  return 0
130
173
  fi
131
174
 
175
+ local ts
176
+ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
177
+ # Compact data to single line — callers may pass pretty-printed JSON
178
+ local compact_data
179
+ compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
180
+ compact_data=$(_stamp_model_if_absent "$compact_data")
181
+
132
182
  # Debug mode: print to stderr instead of writing
133
183
  if _is_debug; then
134
- local ts
135
- ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
136
- local compact_data
137
- compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
138
184
  printf '{"ts":"%s","type":"%s","sid":"%s","org":"%s","user":"%s","data":%s}\n' \
139
185
  "$ts" "$event_type" "$(_resolve_session_id)" "$(_resolve_org)" "$(_resolve_user)" "$compact_data" >&2
140
186
  return 0
@@ -144,11 +190,6 @@ cmd_emit() {
144
190
  mkdir -p "$BUFFER_DIR"
145
191
 
146
192
  # Build event line (outside lock for minimal hold time)
147
- local ts
148
- ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
149
- # Compact data to single line — callers may pass pretty-printed JSON
150
- local compact_data
151
- compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
152
193
  local line
153
194
  line=$(printf '{"ts":"%s","type":"%s","sid":"%s","org":"%s","user":"%s","data":%s}' \
154
195
  "$ts" "$event_type" "$(_resolve_session_id)" "$(_resolve_org)" "$(_resolve_user)" "$compact_data")