create-egregore 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/assets/egregore-launcher.js +1965 -0
  2. package/assets/egregore-launcher.sh +385 -0
  3. package/bin/cli.js +472 -81
  4. package/lib/api.js +2 -2
  5. package/lib/auth.js +255 -20
  6. package/lib/codex-runtime.js +268 -0
  7. package/lib/commands.js +31 -0
  8. package/lib/context.js +1 -1
  9. package/lib/local.js +616 -189
  10. package/lib/setup.js +822 -84
  11. package/lib/ui.js +780 -84
  12. package/package.json +11 -3
  13. package/runtime/codex/.codex/config.toml +15 -0
  14. package/runtime/codex/.codex/hooks/branch-guard.js +234 -0
  15. package/runtime/codex/.codex/hooks.json +17 -0
  16. package/runtime/codex/.codex/skills/activity/SKILL.md +70 -0
  17. package/runtime/codex/.codex/skills/add/SKILL.md +25 -0
  18. package/runtime/codex/.codex/skills/announce/SKILL.md +54 -0
  19. package/runtime/codex/.codex/skills/answer/SKILL.md +25 -0
  20. package/runtime/codex/.codex/skills/archive/SKILL.md +41 -0
  21. package/runtime/codex/.codex/skills/ask/SKILL.md +59 -0
  22. package/runtime/codex/.codex/skills/branch/SKILL.md +25 -0
  23. package/runtime/codex/.codex/skills/checkup/SKILL.md +41 -0
  24. package/runtime/codex/.codex/skills/comment/SKILL.md +25 -0
  25. package/runtime/codex/.codex/skills/commit/SKILL.md +25 -0
  26. package/runtime/codex/.codex/skills/contribute/SKILL.md +25 -0
  27. package/runtime/codex/.codex/skills/dark-mode/SKILL.md +25 -0
  28. package/runtime/codex/.codex/skills/dashboard/SKILL.md +59 -0
  29. package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +70 -0
  30. package/runtime/codex/.codex/skills/delete-user/SKILL.md +25 -0
  31. package/runtime/codex/.codex/skills/edit/SKILL.md +25 -0
  32. package/runtime/codex/.codex/skills/edit-process/SKILL.md +25 -0
  33. package/runtime/codex/.codex/skills/egregore-voice/SKILL.md +25 -0
  34. package/runtime/codex/.codex/skills/emissary/SKILL.md +41 -0
  35. package/runtime/codex/.codex/skills/env/SKILL.md +25 -0
  36. package/runtime/codex/.codex/skills/handoff/SKILL.md +67 -0
  37. package/runtime/codex/.codex/skills/harvest/SKILL.md +107 -0
  38. package/runtime/codex/.codex/skills/infra/SKILL.md +41 -0
  39. package/runtime/codex/.codex/skills/ingest/SKILL.md +25 -0
  40. package/runtime/codex/.codex/skills/ingest-google/SKILL.md +25 -0
  41. package/runtime/codex/.codex/skills/ingest-user-interview/SKILL.md +41 -0
  42. package/runtime/codex/.codex/skills/invite/SKILL.md +40 -0
  43. package/runtime/codex/.codex/skills/issue/SKILL.md +41 -0
  44. package/runtime/codex/.codex/skills/me/SKILL.md +25 -0
  45. package/runtime/codex/.codex/skills/meeting/SKILL.md +41 -0
  46. package/runtime/codex/.codex/skills/note/SKILL.md +25 -0
  47. package/runtime/codex/.codex/skills/onboarding/SKILL.md +41 -0
  48. package/runtime/codex/.codex/skills/pr/SKILL.md +25 -0
  49. package/runtime/codex/.codex/skills/project/SKILL.md +41 -0
  50. package/runtime/codex/.codex/skills/pull/SKILL.md +25 -0
  51. package/runtime/codex/.codex/skills/push/SKILL.md +25 -0
  52. package/runtime/codex/.codex/skills/quest/SKILL.md +77 -0
  53. package/runtime/codex/.codex/skills/quest-suggest/SKILL.md +25 -0
  54. package/runtime/codex/.codex/skills/reflect/SKILL.md +41 -0
  55. package/runtime/codex/.codex/skills/review-pr/SKILL.md +41 -0
  56. package/runtime/codex/.codex/skills/save/SKILL.md +62 -0
  57. package/runtime/codex/.codex/skills/setup/SKILL.md +25 -0
  58. package/runtime/codex/.codex/skills/summon/SKILL.md +41 -0
  59. package/runtime/codex/.codex/skills/sync-repos/SKILL.md +25 -0
  60. package/runtime/codex/.codex/skills/telegram-connect/SKILL.md +25 -0
  61. package/runtime/codex/.codex/skills/telemetry/SKILL.md +25 -0
  62. package/runtime/codex/.codex/skills/test/SKILL.md +41 -0
  63. package/runtime/codex/.codex/skills/the-spiral/SKILL.md +84 -0
  64. package/runtime/codex/.codex/skills/todo/SKILL.md +41 -0
  65. package/runtime/codex/.codex/skills/tui-design/SKILL.md +41 -0
  66. package/runtime/codex/.codex/skills/tutorial/SKILL.md +41 -0
  67. package/runtime/codex/.codex/skills/update/SKILL.md +25 -0
  68. package/runtime/codex/.codex/skills/view/SKILL.md +41 -0
  69. package/runtime/codex/.codex/skills/visual-explain/SKILL.md +41 -0
  70. package/runtime/codex/.codex/skills/wrap/SKILL.md +55 -0
  71. package/runtime/codex/.codex/spec-manifest.json +108 -0
  72. package/runtime/codex/AGENTS.md +328 -0
  73. package/runtime/codex/bin/activity-data.sh +180 -0
  74. package/runtime/codex/bin/agent.sh +669 -0
  75. package/runtime/codex/bin/codex-install-skills.sh +78 -0
  76. package/runtime/codex/bin/codex-render-spec.mjs +308 -0
  77. package/runtime/codex/bin/codex-session-start.sh +238 -0
  78. package/runtime/codex/bin/codex-skill-render.mjs +315 -0
  79. package/runtime/codex/bin/codex-sync-skills.sh +223 -0
  80. package/runtime/codex/bin/dashboard-data.sh +178 -0
  81. package/runtime/codex/bin/graph-batch.sh +60 -0
  82. package/runtime/codex/bin/graph-op.sh +397 -0
  83. package/runtime/codex/bin/graph-wal.sh +228 -0
  84. package/runtime/codex/bin/graph.sh +141 -0
  85. package/runtime/codex/bin/handoff-pr-backfill.sh +86 -0
  86. package/runtime/codex/bin/handoff-run.sh +569 -0
  87. package/runtime/codex/bin/handoff-save-egregore.sh +131 -0
  88. package/runtime/codex/bin/index-handoff.sh +306 -0
  89. package/runtime/codex/bin/lib/artifact-id.sh +46 -0
  90. package/runtime/codex/bin/lib/config.sh +71 -0
  91. package/runtime/codex/bin/lib/context.sh +511 -0
  92. package/runtime/codex/bin/lib/dashboard-artifact.sh +142 -0
  93. package/runtime/codex/bin/lib/edit-loopback.mjs +652 -0
  94. package/runtime/codex/bin/lib/ensure-develop.sh +183 -0
  95. package/runtime/codex/bin/lib/git-sync.sh +262 -0
  96. package/runtime/codex/bin/lib/greeting.sh +432 -0
  97. package/runtime/codex/bin/lib/hash.sh +13 -0
  98. package/runtime/codex/bin/lib/identity.sh +164 -0
  99. package/runtime/codex/bin/lib/permissions.sh +106 -0
  100. package/runtime/codex/bin/lib/time.sh +56 -0
  101. package/runtime/codex/bin/lib/worktree-links.sh +54 -0
  102. package/runtime/codex/bin/notify.sh +207 -0
  103. package/runtime/codex/bin/publish-artifact.sh +187 -0
  104. package/runtime/codex/bin/publish-references.sh +144 -0
  105. package/runtime/codex/bin/repo-state.sh +99 -0
  106. package/runtime/codex/bin/telemetry.sh +350 -0
  107. package/runtime/codex/bin/worktree-create.sh +115 -0
  108. package/runtime/codex/bin/worktree-remove.sh +50 -0
  109. package/runtime/codex/bin/worktree.sh +199 -0
  110. package/runtime/codex/manifest.json +162 -0
package/lib/setup.js CHANGED
@@ -3,10 +3,15 @@
3
3
  * Zero dependencies — uses node:child_process, node:fs, node:path, node:os.
4
4
  */
5
5
 
6
- const { execSync, execFileSync } = require("node:child_process");
6
+ const { execSync, execFileSync, spawnSync, spawn } = require("node:child_process");
7
7
  const fs = require("node:fs");
8
8
  const path = require("node:path");
9
9
  const os = require("node:os");
10
+ const {
11
+ hasCodexRuntimeSupport,
12
+ installCodexRuntime,
13
+ reportCodexRuntimeSummary,
14
+ } = require("./codex-runtime");
10
15
 
11
16
  const https = require("node:https");
12
17
 
@@ -57,7 +62,9 @@ function ghApi(method, apiPath, token, body) {
57
62
  function initMemoryDirs(memoryDir) {
58
63
  const dirs = [
59
64
  "people",
65
+ "invites",
60
66
  "handoffs",
67
+ "knowledge/questions",
61
68
  "knowledge/decisions",
62
69
  "knowledge/patterns",
63
70
  "knowledge/findings",
@@ -96,6 +103,202 @@ async function acceptPendingInvitations(githubToken, githubOrg, ui) {
96
103
  }
97
104
  }
98
105
 
106
+ // Normalize a git URL for equality comparison: strip protocol, .git suffix,
107
+ // SSH-vs-HTTPS distinction. github.com:org/repo / https://github.com/org/repo
108
+ // / git@github.com:org/repo all reduce to the same canonical form.
109
+ function normalizeGitUrl(url) {
110
+ if (!url) return "";
111
+ let u = url.trim().replace(/\.git$/i, "");
112
+ u = u.replace(/^git@([^:]+):/, "$1/");
113
+ u = u.replace(/^https?:\/\//i, "");
114
+ return u.toLowerCase();
115
+ }
116
+
117
+ function gitRemoteMatches(repoDir, expectedUrl) {
118
+ try {
119
+ const url = execFileSync("git", ["-C", repoDir, "remote", "get-url", "origin"], {
120
+ stdio: "pipe", encoding: "utf-8", timeout: 5000,
121
+ }).trim();
122
+ return normalizeGitUrl(url) === normalizeGitUrl(expectedUrl);
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ // True only when every artifact the installer would produce is already in
129
+ // place AND pointing at the right remotes. Used to short-circuit a re-run
130
+ // of the clone/symlink steps when the user is just rejoining what they
131
+ // already have on disk.
132
+ function isFullySetUp(egregoreDir, memoryDir, forkUrl, memoryUrl) {
133
+ if (!fs.existsSync(path.join(egregoreDir, ".git"))) return false;
134
+ if (!gitRemoteMatches(egregoreDir, forkUrl)) return false;
135
+ if (!fs.existsSync(path.join(memoryDir, ".git"))) return false;
136
+ if (!gitRemoteMatches(memoryDir, memoryUrl)) return false;
137
+ if (!fs.existsSync(path.join(egregoreDir, "memory"))) return false;
138
+ if (!fs.existsSync(path.join(egregoreDir, ".env"))) return false;
139
+ if (!fs.existsSync(path.join(egregoreDir, "egregore.json"))) return false;
140
+ return true;
141
+ }
142
+
143
+ function slugifyName(name) {
144
+ return String(name || "")
145
+ .trim()
146
+ .toLowerCase()
147
+ .replace(/[^a-z0-9-]+/g, "-")
148
+ .replace(/-+/g, "-")
149
+ .replace(/^-|-$/g, "");
150
+ }
151
+
152
+ function normalizeUsageType(data = {}) {
153
+ if (data.usage_type === "founder_group" || data.usage_type === "joiner_group") {
154
+ return data.usage_type;
155
+ }
156
+ if (data.onboarding_role === "founder" || data.role === "founder") return "founder_group";
157
+ return "joiner_group";
158
+ }
159
+
160
+ function mergeProfileFields(existing, additions = []) {
161
+ const fields = Array.isArray(existing) ? existing.filter(Boolean) : [];
162
+ if (!fields.includes("name")) fields.push("name");
163
+ for (const field of additions) {
164
+ if (field && !fields.includes(field)) fields.push(field);
165
+ }
166
+ return fields;
167
+ }
168
+
169
+ function personProfileContent(state = {}, opts = {}) {
170
+ const now = opts.onboardedAt || new Date().toISOString();
171
+ const username = state.github_username || opts.githubUsername || "unknown";
172
+ const displayName = state.display_name || state.github_name || username;
173
+ const role = state.member_role || state.onboarding?.member_role || opts.memberRole || "Member";
174
+ return [
175
+ `# ${displayName}`,
176
+ "",
177
+ `GitHub: ${username}`,
178
+ `Role: ${role}`,
179
+ `Joined: ${now.split("T")[0]}`,
180
+ `Onboarded: ${now}`,
181
+ "",
182
+ ].join("\n");
183
+ }
184
+
185
+ function ensurePersonOnboardingWitness(memoryDir, state = {}, opts = {}) {
186
+ const username = state.github_username || opts.githubUsername;
187
+ if (!memoryDir || !username) return null;
188
+ const peopleDir = path.join(memoryDir, "people");
189
+ fs.mkdirSync(peopleDir, { recursive: true });
190
+ const personFile = path.join(peopleDir, `${username}.md`);
191
+ const witness = personProfileContent(state, opts);
192
+ let next = witness;
193
+
194
+ if (fs.existsSync(personFile)) {
195
+ const current = fs.readFileSync(personFile, "utf-8");
196
+ if (/^Onboarded:/m.test(current)) return personFile;
197
+ if (/^---\s*$/m.test(current) && current.trimStart().startsWith("---")) {
198
+ const body = current.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/m)?.[1]?.trim();
199
+ next = body ? `${witness}\n${body}\n` : witness;
200
+ } else if (current.trimStart().startsWith("# ")) {
201
+ next = `${current.replace(/\s*$/, "")}\nOnboarded: ${opts.onboardedAt || new Date().toISOString()}\n`;
202
+ } else if (current.trim()) {
203
+ next = `${witness}\n${current.trim()}\n`;
204
+ }
205
+ }
206
+
207
+ fs.writeFileSync(personFile, next);
208
+ try {
209
+ const rel = path.relative(memoryDir, personFile);
210
+ run(`git add ${JSON.stringify(rel)}`, { cwd: memoryDir });
211
+ run(`git commit -m ${JSON.stringify(`Onboard ${username}`)}`, { cwd: memoryDir });
212
+ run("git push", { cwd: memoryDir });
213
+ } catch {}
214
+ return personFile;
215
+ }
216
+
217
+ async function ensureInstallerMemberRole(data = {}, existingState = {}, ui) {
218
+ const existing = data.member_role || existingState.member_role || existingState.onboarding?.member_role;
219
+ if (existing) return existing;
220
+ const usageType = normalizeUsageType(data);
221
+ const fallback = usageType === "founder_group" ? "Founder" : "Member";
222
+ if (!process.stdin.isTTY) return fallback;
223
+ console.log("");
224
+ const answer = await ui.prompt("What's your role here?", { default: fallback });
225
+ return answer || fallback;
226
+ }
227
+
228
+ function buildOnboardingState(existing = {}, data = {}, opts = {}) {
229
+ const state = { ...existing };
230
+ const preferredRuntime = normalizeRuntime(opts.runtime);
231
+ const githubUsername = data.github_username || state.github_username || "";
232
+ const githubName = data.github_name || state.github_name || githubUsername;
233
+ const displayName = data.display_name || state.display_name || githubName || githubUsername;
234
+ const usageType = normalizeUsageType(data);
235
+ const memberRole = data.member_role || state.member_role || state.onboarding?.member_role || "";
236
+ const requestedComplete =
237
+ opts.onboardingComplete === true ||
238
+ data.onboarding_complete === true ||
239
+ data.onboarding_complete === "true";
240
+ const alreadyComplete =
241
+ state.onboarding_complete === true ||
242
+ state.onboarding_complete === "true";
243
+ const shouldComplete = alreadyComplete || (requestedComplete && !!memberRole);
244
+
245
+ if (githubUsername) state.github_username = githubUsername;
246
+ if (githubName) state.github_name = githubName;
247
+ if (displayName) {
248
+ state.display_name = displayName;
249
+ state.name = state.name || slugifyName(displayName) || githubUsername;
250
+ }
251
+ if (memberRole) state.member_role = memberRole;
252
+ if (data.github_email || data.email) state.email = data.github_email || data.email;
253
+
254
+ state.onboarding_complete = shouldComplete;
255
+ state.usage_type = usageType;
256
+ state.org_setup = true;
257
+ state.github_configured = true;
258
+ state.workspace_ready = true;
259
+ if (data.transcript_sharing !== undefined) {
260
+ state.transcript_sharing = data.transcript_sharing;
261
+ }
262
+ if (opts.flowVariant) {
263
+ state.flow_variant = opts.flowVariant;
264
+ } else if (data.flow_variant) {
265
+ state.flow_variant = data.flow_variant;
266
+ }
267
+ state.preferred_runtime = preferredRuntime;
268
+ state.profile_fields_collected = mergeProfileFields(state.profile_fields_collected, memberRole ? ["role"] : []);
269
+ state.onboarding = state.onboarding || {};
270
+ if (memberRole) state.onboarding.member_role = memberRole;
271
+ if (state.onboarding_complete) {
272
+ state.onboarding.phase = "complete";
273
+ state.onboarding.completed_at = state.onboarding.completed_at || new Date().toISOString();
274
+ } else {
275
+ state.onboarding.phase = state.onboarding.phase || "orient";
276
+ state.onboarding.started_at = state.onboarding.started_at || new Date().toISOString();
277
+ }
278
+ state.onboarding.installer_captured = true;
279
+ state.onboarding.invite_handled_by =
280
+ data.invite_handled_by || state.onboarding.invite_handled_by || "installer";
281
+ if (data.invite_skipped !== undefined) {
282
+ state.onboarding.invite_skipped = !!data.invite_skipped;
283
+ } else if (usageType === "founder_group" && state.onboarding.invite_skipped === undefined) {
284
+ state.onboarding.invite_skipped = true;
285
+ }
286
+ if (data.onboarding_role || data.role) {
287
+ state.onboarding.role = data.onboarding_role || data.role;
288
+ }
289
+ return state;
290
+ }
291
+
292
+ async function ensureInstallerDisplayName(data, existingState, ui) {
293
+ if (data.display_name || existingState.display_name) return data.display_name || existingState.display_name;
294
+ const fallback = data.github_name || data.github_username;
295
+ if (!fallback) return "";
296
+ if (!process.stdin.isTTY) return fallback;
297
+ console.log("");
298
+ const answer = await ui.prompt(`What should we call you? [${fallback}]:`);
299
+ return answer || fallback;
300
+ }
301
+
99
302
  /**
100
303
  * Full local setup from claimed token data.
101
304
  *
@@ -108,9 +311,10 @@ async function acceptPendingInvitations(githubToken, githubOrg, ui) {
108
311
  * avoid clobbering uncommitted work or pushing branches to their origin.
109
312
  */
110
313
  async function install(data, ui, targetDir, opts = {}) {
111
- const { fork_url, memory_url, github_token, org_name, github_org, slug, api_key, repos = [], telegram_group_link, github_username, github_name, transcript_sharing } = data;
314
+ const { fork_url, memory_url, github_token, org_name, github_org, slug, api_key, repos = [], telegram_group_link, github_username, github_name } = data;
112
315
  const base = targetDir || process.cwd();
113
316
  const skipRepos = opts.skipRepos instanceof Set ? opts.skipRepos : new Set();
317
+ const preferredRuntime = normalizeRuntime(opts.runtime);
114
318
 
115
319
  // Directory name from fork URL (what git clone would use), e.g. "egregore"
116
320
  const forkDirName = fork_url.split("/").pop().replace(/\.git$/, "");
@@ -123,67 +327,80 @@ async function install(data, ui, targetDir, opts = {}) {
123
327
 
124
328
  const totalSteps = 5 + repos.length;
125
329
 
330
+ // Detect a complete prior install at this target so "Join existing" doesn't
331
+ // re-walk the 7-step setup for a user who already has everything wired up.
332
+ // We still rewrite credentials / state / register (steps 4–5) so tokens
333
+ // refresh and the registry entry is current — only the noisy clone+symlink
334
+ // steps are skipped. Falls through to the normal flow if anything is off.
335
+ const alreadyInstalled = isFullySetUp(egregoreDir, memoryDir, fork_url, memory_url);
336
+
126
337
  // Accept pending repo collaboration invitations (joiners get invited as collaborators)
338
+ if (github_token) configureGitCredentials(github_token);
127
339
  await acceptPendingInvitations(github_token, github_org, ui);
128
340
 
129
- // 1. Clone fork (SSH first, HTTPS fallback — no token embedding)
130
- ui.step(1, totalSteps, "Cloning egregore...");
131
- if (fs.existsSync(egregoreDir)) {
132
- ui.warn("egregore/ already exists — pulling latest");
133
- run("git pull", { cwd: egregoreDir });
341
+ if (alreadyInstalled) {
342
+ ui.success(`Already installed at ${ui.bold(egregoreDir)} — refreshing setup`);
343
+ console.log("");
134
344
  } else {
135
- if (!cloneRepo(fork_url, egregoreDir)) {
136
- throw new Error(`Command failed: git clone ${fork_url} ${egregoreDir}\nCloning into '${egregoreDir}'...\nMake sure you have SSH keys set up or run 'gh auth login' first.`);
345
+ // 1. Clone fork (SSH first, HTTPS fallback — no token embedding)
346
+ ui.step(1, totalSteps, "Cloning egregore...");
347
+ if (fs.existsSync(egregoreDir)) {
348
+ ui.warn("egregore/ already exists — pulling latest");
349
+ run("git pull", { cwd: egregoreDir });
350
+ } else {
351
+ if (!cloneRepo(fork_url, egregoreDir)) {
352
+ throw new Error(`Command failed: git clone ${fork_url} ${egregoreDir}\nCloning into '${egregoreDir}'...\nMake sure you have SSH keys set up or run 'gh auth login' first.`);
353
+ }
137
354
  }
138
- }
139
- try { run("git config credential.helper store", { cwd: egregoreDir }); } catch {}
140
- ensureDevelop(egregoreDir);
141
- ui.success("Cloned egregore");
355
+ try { run("git config credential.helper store", { cwd: egregoreDir }); } catch {}
356
+ ensureDevelop(egregoreDir);
357
+ ui.success("Cloned egregore");
142
358
 
143
- // Set repo-local git identity from GitHub user (not machine-global config)
144
- if (github_username) {
145
- try {
146
- run(`git config user.name "${github_name || github_username}"`, { cwd: egregoreDir });
147
- run(`git config user.email "${github_username}@users.noreply.github.com"`, { cwd: egregoreDir });
148
- } catch {
149
- // Non-fatal
359
+ // Set repo-local git identity from GitHub user (not machine-global config)
360
+ if (github_username) {
361
+ try {
362
+ run(`git config user.name "${github_name || github_username}"`, { cwd: egregoreDir });
363
+ run(`git config user.email "${github_username}@users.noreply.github.com"`, { cwd: egregoreDir });
364
+ } catch {
365
+ // Non-fatal
366
+ }
150
367
  }
151
- }
152
368
 
153
- // 2. Clone memory
154
- ui.step(2, totalSteps, "Cloning shared memory...");
155
- if (fs.existsSync(memoryDir)) {
156
- ui.warn(`${memoryDirName}/ already exists — pulling latest`);
157
- run("git pull", { cwd: memoryDir });
158
- } else {
159
- if (!cloneRepo(memory_url, memoryDir)) {
160
- ui.warn(`Could not clone memory repo — you may need SSH keys or 'gh auth login'`);
369
+ // 2. Clone memory
370
+ ui.step(2, totalSteps, "Cloning shared memory...");
371
+ if (fs.existsSync(memoryDir)) {
372
+ ui.warn(`${memoryDirName}/ already exists — pulling latest`);
373
+ run("git pull", { cwd: memoryDir });
374
+ } else {
375
+ if (!cloneRepo(memory_url, memoryDir)) {
376
+ ui.warn(`Could not clone memory repo — you may need SSH keys or 'gh auth login'`);
377
+ }
161
378
  }
162
- }
163
- try { run("git config credential.helper store", { cwd: memoryDir }); } catch {}
164
- if (github_username) {
165
- try {
166
- run(`git config user.name "${github_name || github_username}"`, { cwd: memoryDir });
167
- run(`git config user.email "${github_username}@users.noreply.github.com"`, { cwd: memoryDir });
168
- } catch {}
169
- }
170
- ui.success("Cloned memory");
379
+ try { run("git config credential.helper store", { cwd: memoryDir }); } catch {}
380
+ if (github_username) {
381
+ try {
382
+ run(`git config user.name "${github_name || github_username}"`, { cwd: memoryDir });
383
+ run(`git config user.email "${github_username}@users.noreply.github.com"`, { cwd: memoryDir });
384
+ } catch {}
385
+ }
386
+ ui.success("Cloned memory");
171
387
 
172
- // 3. Symlink (use junction on Windows — no admin required)
173
- ui.step(3, totalSteps, "Linking memory...");
174
- const symlinkTarget = path.join(egregoreDir, "memory");
175
- if (fs.existsSync(symlinkTarget)) {
176
- ui.warn("memory/ symlink already exists");
177
- } else {
178
- if (process.platform === "win32") {
179
- // Junctions require absolute paths on Windows
180
- fs.symlinkSync(path.resolve(memoryDir), symlinkTarget, "junction");
388
+ // 3. Symlink (use junction on Windows — no admin required)
389
+ ui.step(3, totalSteps, "Linking memory...");
390
+ const symlinkTarget = path.join(egregoreDir, "memory");
391
+ if (fs.existsSync(symlinkTarget)) {
392
+ ui.warn("memory/ symlink already exists");
181
393
  } else {
182
- const relPath = path.relative(egregoreDir, memoryDir);
183
- fs.symlinkSync(relPath, symlinkTarget);
394
+ if (process.platform === "win32") {
395
+ // Junctions require absolute paths on Windows
396
+ fs.symlinkSync(path.resolve(memoryDir), symlinkTarget, "junction");
397
+ } else {
398
+ const relPath = path.relative(egregoreDir, memoryDir);
399
+ fs.symlinkSync(relPath, symlinkTarget);
400
+ }
184
401
  }
402
+ ui.success("Linked");
185
403
  }
186
- ui.success("Linked");
187
404
 
188
405
  // 4. Write .env (secrets only — never committed to git)
189
406
  ui.step(4, totalSteps, "Writing credentials...");
@@ -199,19 +416,15 @@ async function install(data, ui, targetDir, opts = {}) {
199
416
  if (fs.existsSync(statePath)) {
200
417
  try { state = JSON.parse(fs.readFileSync(statePath, "utf-8")); } catch {}
201
418
  }
202
- if (github_username) {
203
- state.github_username = github_username;
204
- state.github_name = github_name || github_username;
205
- }
206
- state.onboarding_complete = false;
207
- state.usage_type = "joiner_group";
208
- state.org_setup = true;
209
- state.github_configured = true;
210
- state.workspace_ready = true;
211
- if (transcript_sharing !== undefined) {
212
- state.transcript_sharing = transcript_sharing;
213
- }
419
+ const displayName = await ensureInstallerDisplayName(data, state, ui);
420
+ const memberRole = await ensureInstallerMemberRole(data, state, ui);
421
+ state = buildOnboardingState(state, { ...data, display_name: displayName, member_role: memberRole }, {
422
+ runtime: preferredRuntime,
423
+ flowVariant: opts.flowVariant,
424
+ onboardingComplete: opts.onboardingComplete !== false,
425
+ });
214
426
  fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
427
+ try { ensurePersonOnboardingWitness(memoryDir, state); } catch {}
215
428
 
216
429
  // 4c. Ensure mode is set in egregore.json (connected mode for API-based setup)
217
430
  const configPath = path.join(egregoreDir, "egregore.json");
@@ -225,10 +438,15 @@ async function install(data, ui, targetDir, opts = {}) {
225
438
  } catch {}
226
439
  }
227
440
 
228
- // 5. Register instance + shell alias
441
+ // 4d. Overlay packaged Codex support before registering or launching. This
442
+ // makes cloned public templates usable even before the public repo syncs.
443
+ const codexRuntime = installCodexRuntime(egregoreDir);
444
+ reportCodexRuntimeSummary(codexRuntime, ui);
445
+
446
+ // 5. Register instance + install launcher binary
229
447
  ui.step(5, totalSteps, "Registering instance...");
230
- registerInstance(forkDirName, org_name, egregoreDir);
231
- const alias = await installShellAlias(egregoreDir, ui);
448
+ registerInstance(forkDirName, org_name, egregoreDir, preferredRuntime);
449
+ const alias = await installLauncherBinary(egregoreDir, ui);
232
450
 
233
451
  // 6+. Clone managed repos (if any)
234
452
  const clonedRepos = [];
@@ -278,24 +496,45 @@ async function install(data, ui, targetDir, opts = {}) {
278
496
  warm.unref();
279
497
  } catch { /* ignore — cosmetic warmup */ }
280
498
 
281
- // Done
282
- console.log("");
283
- ui.success(`Egregore is ready for ${ui.bold(org_name)}`);
284
- console.log("");
285
- ui.info(`Your workspace:`);
286
- ui.info(` ${ui.cyan(`./${forkDirName}/`)} — Your Egregore instance`);
287
- ui.info(` ${ui.cyan(`./${memoryDirName}/`)} — Shared knowledge`);
288
- for (const repoName of clonedRepos) {
289
- ui.info(` ${ui.cyan(`./${repoName}/`)} — Managed repo`);
290
- }
499
+ // Done — full-screen summary bookend if we can; inline workspace list
500
+ // otherwise. Same contract as localFounderFlow.
501
+ const workspaces = [
502
+ { label: `./${forkDirName}/`, description: "your Egregore instance" },
503
+ { label: `./${memoryDirName}/`, description: "shared knowledge" },
504
+ ...clonedRepos.map((rn) => ({ label: `./${rn}/`, description: "managed repo" })),
505
+ ];
291
506
  if (telegram_group_link) {
507
+ workspaces.push({ label: telegram_group_link, description: "telegram group" });
508
+ }
509
+ const usageType = normalizeUsageType(data);
510
+ const setupSummary = {
511
+ workspace: "created",
512
+ invite: usageType === "joiner_group" ? "accepted" : (data.invite_skipped ? "skipped" : "handled"),
513
+ telegram: telegram_group_link ? "connected" : "skipped",
514
+ assistant: runtimeDisplay(preferredRuntime),
515
+ command: "run egregore anytime",
516
+ };
517
+
518
+ const ui_ = require("./ui");
519
+ if (!opts.noScreens && ui_.canShowScreens()) {
520
+ const result = await ui_.summaryScreen({ orgName: org_name, workspaces, setup: setupSummary });
521
+ if (result === "escape") opts.noAutolaunch = true;
522
+ } else {
523
+ ui.section("You're done", `Egregore is ready for ${ui.bold(org_name)}`);
524
+ ui.info(`${ui.green("●")} workspace ${setupSummary.workspace}`);
525
+ ui.info(`${ui.green("●")} invite ${setupSummary.invite}`);
526
+ ui.info(`${ui.green("●")} Telegram ${setupSummary.telegram}`);
527
+ ui.info(`${ui.green("●")} assistant ${setupSummary.assistant}`);
528
+ ui.info(`${ui.green("●")} next ${setupSummary.command}`);
529
+ console.log("");
530
+ ui.info(`Your workspace:`);
531
+ for (const ws of workspaces) {
532
+ ui.info(` ${ui.cyan(ws.label)} ${ui.dim(ws.description || "")}`);
533
+ }
292
534
  console.log("");
293
- ui.info(`Join the Telegram group for notifications:`);
294
- ui.info(` ${ui.cyan(telegram_group_link)}`);
295
535
  }
296
- console.log("");
297
- ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
298
- console.log("");
536
+
537
+ await launchOrInstruct(path.join(base, forkDirName), alias, ui, opts);
299
538
  }
300
539
 
301
540
  /**
@@ -356,13 +595,28 @@ function toSSH(url) {
356
595
  return m ? `git@github.com:${m[1]}.git` : null;
357
596
  }
358
597
 
598
+ /**
599
+ * Environment for git clone/fetch during install. GIT_TERMINAL_PROMPT=0
600
+ * stops the hanging username/password prompt; BatchMode makes the SSH-first
601
+ * attempt fail fast on machines without keys instead of blocking the install
602
+ * on an interactive "Are you sure you want to continue connecting?" host-key
603
+ * prompt mid-spinner (fresh machines have no github.com in known_hosts).
604
+ */
605
+ function gitCloneEnv(base = process.env) {
606
+ return {
607
+ ...base,
608
+ GIT_TERMINAL_PROMPT: "0",
609
+ GIT_SSH_COMMAND: base.GIT_SSH_COMMAND || "ssh -oBatchMode=yes",
610
+ };
611
+ }
612
+
359
613
  /**
360
614
  * Clone a repo, trying SSH first then HTTPS (no token embedded).
361
615
  * The repo:invite-scoped OAuth token can't clone — we rely on the
362
616
  * user's own git credentials (SSH keys / gh CLI / credential helper).
363
617
  */
364
618
  function cloneRepo(httpsUrl, dir, timeout = 60000) {
365
- const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
619
+ const env = gitCloneEnv();
366
620
  const sshUrl = toSSH(httpsUrl);
367
621
 
368
622
  if (sshUrl) {
@@ -397,7 +651,145 @@ function configureGitCredentials(token) {
397
651
  }
398
652
  }
399
653
 
400
- function registerInstance(slug, name, egregoreDir) {
654
+ function normalizeRuntime(runtime) {
655
+ const raw = String(runtime || "claude-code").trim().toLowerCase();
656
+ if (raw === "claude" || raw === "claude-code" || raw === "claude_code") return "claude-code";
657
+ if (raw === "codex" || raw === "openai-codex" || raw === "openai_codex") return "codex";
658
+ throw new Error(`Unsupported Egregore runtime: ${runtime}`);
659
+ }
660
+
661
+ function runtimeDisplay(runtime) {
662
+ return normalizeRuntime(runtime) === "codex" ? "Codex" : "Claude Code";
663
+ }
664
+
665
+ function runtimeBinary(runtime) {
666
+ return normalizeRuntime(runtime) === "codex" ? "codex" : "claude";
667
+ }
668
+
669
+ // One-line install command for the chosen assistant — shown when the
670
+ // install finishes on a machine that doesn't have the assistant yet.
671
+ function assistantInstallCommand(runtime) {
672
+ return normalizeRuntime(runtime) === "codex"
673
+ ? "npm install -g @openai/codex"
674
+ : "npm install -g @anthropic-ai/claude-code";
675
+ }
676
+
677
+ function runtimeLaunchArgs(runtime, egregoreDir) {
678
+ if (normalizeRuntime(runtime) === "codex") {
679
+ return [
680
+ "--enable", "hooks",
681
+ "--sandbox", "danger-full-access",
682
+ "--ask-for-approval", "never",
683
+ "--cd", egregoreDir,
684
+ ];
685
+ }
686
+ return ["start"];
687
+ }
688
+
689
+ function startSpinner(label) {
690
+ if (!process.stdout.isTTY) {
691
+ console.log(` ${label}...`);
692
+ return () => {};
693
+ }
694
+ const frames = ["|", "/", "-", "\\"];
695
+ let i = 0;
696
+ process.stdout.write(` ${frames[i]} ${label}`);
697
+ const timer = setInterval(() => {
698
+ i = (i + 1) % frames.length;
699
+ process.stdout.write(`\r ${frames[i]} ${label}\x1b[K`);
700
+ }, 90);
701
+ return () => {
702
+ clearInterval(timer);
703
+ process.stdout.write("\r\x1b[K");
704
+ };
705
+ }
706
+
707
+ function runCapturedWithSpinner(cmd, args, options, label) {
708
+ return new Promise((resolve) => {
709
+ const stop = startSpinner(label);
710
+ let stdout = "";
711
+ let stderr = "";
712
+ let settled = false;
713
+ const child = spawn(cmd, args, {
714
+ cwd: options.cwd,
715
+ env: options.env || process.env,
716
+ stdio: ["ignore", "pipe", "pipe"],
717
+ });
718
+
719
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
720
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
721
+ child.on("error", (err) => {
722
+ if (settled) return;
723
+ settled = true;
724
+ stop();
725
+ resolve({ status: 1, stdout, stderr: stderr || String(err) });
726
+ });
727
+ child.on("close", (status) => {
728
+ if (settled) return;
729
+ settled = true;
730
+ stop();
731
+ resolve({ status, stdout, stderr });
732
+ });
733
+ });
734
+ }
735
+
736
+ async function renderCodexStartupCard(egregoreDir) {
737
+ const script = path.join(egregoreDir, "bin", "codex-session-start.sh");
738
+ if (fs.existsSync(script)) {
739
+ try {
740
+ const result = await runCapturedWithSpinner("bash", [script], {
741
+ cwd: egregoreDir,
742
+ }, "Preparing Codex startup");
743
+ if (result.stdout) process.stdout.write(result.stdout);
744
+ if (result.status !== 0 && result.stderr) process.stderr.write(result.stderr);
745
+ if (result.status === 0) return true;
746
+ } catch {}
747
+ }
748
+ return false;
749
+ }
750
+
751
+ function codexSupportMessage(egregoreDir) {
752
+ return [
753
+ "Codex assistant support is missing from this Egregore checkout.",
754
+ ` path: ${egregoreDir}`,
755
+ "",
756
+ "This checkout needs a newer Egregore core with:",
757
+ " bin/codex-session-start.sh",
758
+ " bin/codex-install-skills.sh",
759
+ " .codex/config.toml",
760
+ " .codex/skills/",
761
+ " .codex/hooks.json",
762
+ " .codex/hooks/",
763
+ "",
764
+ "Run npx -y create-egregore@latest to install the packaged Codex runtime, then rerun egregore.",
765
+ "Egregore workflows appear as slash commands. In Codex, invoke them with the matching $skill token or ask in natural language.",
766
+ ].join("\n");
767
+ }
768
+
769
+ function hasCodexCoreSupport(egregoreDir) {
770
+ return hasCodexRuntimeSupport(egregoreDir);
771
+ }
772
+
773
+ function installCodexSkills(egregoreDir) {
774
+ const script = path.join(egregoreDir, "bin", "codex-install-skills.sh");
775
+ if (!fs.existsSync(script)) return false;
776
+ try {
777
+ const result = spawnSync("bash", [script], {
778
+ cwd: egregoreDir,
779
+ encoding: "utf-8",
780
+ stdio: ["ignore", "ignore", "pipe"],
781
+ maxBuffer: 1024 * 1024,
782
+ });
783
+ return result.status === 0;
784
+ } catch {}
785
+ return false;
786
+ }
787
+
788
+ function commandExists(bin) {
789
+ return spawnSync("which", [bin], { stdio: "pipe" }).status === 0;
790
+ }
791
+
792
+ function registerInstance(slug, name, egregoreDir, runtime) {
401
793
  const registryDir = path.join(os.homedir(), ".egregore");
402
794
  const registryFile = path.join(registryDir, "instances.json");
403
795
 
@@ -408,15 +800,28 @@ function registerInstance(slug, name, egregoreDir) {
408
800
  let instances = [];
409
801
  if (fs.existsSync(registryFile)) {
410
802
  try {
411
- instances = JSON.parse(fs.readFileSync(registryFile, "utf-8"));
803
+ const parsed = JSON.parse(fs.readFileSync(registryFile, "utf-8"));
804
+ // Accept either the canonical [..] or the legacy/alt {instances: [..]}
805
+ // shape. Subsequent writes normalize back to a plain array.
806
+ if (Array.isArray(parsed)) {
807
+ instances = parsed;
808
+ } else if (parsed && Array.isArray(parsed.instances)) {
809
+ instances = parsed.instances;
810
+ }
412
811
  } catch {
413
812
  instances = [];
414
813
  }
415
814
  }
416
815
 
417
- const entry = { slug, name, path: egregoreDir };
418
816
  // Dedup by path (not slug) so multiple instances from the same org can coexist
419
817
  const idx = instances.findIndex((i) => i.path === egregoreDir);
818
+ const previous = idx >= 0 ? instances[idx] : {};
819
+ const entry = { ...previous, slug, name, path: egregoreDir };
820
+ if (runtime) {
821
+ entry.runtime = normalizeRuntime(runtime);
822
+ } else if (!entry.runtime && entry.preferred_runtime) {
823
+ entry.runtime = normalizeRuntime(entry.preferred_runtime);
824
+ }
420
825
  if (idx >= 0) {
421
826
  instances[idx] = entry;
422
827
  } else {
@@ -426,6 +831,331 @@ function registerInstance(slug, name, egregoreDir) {
426
831
  fs.writeFileSync(registryFile, JSON.stringify(instances, null, 2) + "\n");
427
832
  }
428
833
 
834
+ // ── Auto-launch helper ─────────────────────────────────────────────
835
+ //
836
+ // Replaces the legacy "open a new terminal and type <alias>" closing with an
837
+ // actual agent-runtime invocation in the egregore directory. The installer
838
+ // process waits for the runtime to exit, then propagates the exit code.
839
+ //
840
+ // ctx.noAutolaunch=true disables this (for CI / scripted installs) and falls
841
+ // back to printing the command the user should type.
842
+ // A child process can't change the parent shell's PATH, so right after install
843
+ // the launcher works in any NEW shell but not the current one (its rc hasn't
844
+ // re-loaded). Detect that and give the exact fix — avoids a confusing
845
+ // "egregore: command not found" when the user types it again in this shell.
846
+ function launcherNextStep(ui, aliasName) {
847
+ const binDir = path.join(os.homedir(), ".local", "bin");
848
+ const onPath = (process.env.PATH || "").split(path.delimiter).some((p) => {
849
+ try { return p && path.resolve(p) === binDir; } catch { return false; }
850
+ });
851
+ if (onPath) return `type ${ui.bold(aliasName)} in any terminal.`;
852
+ const shell = (process.env.SHELL || "").split("/").pop();
853
+ const rc = shell === "zsh" ? "~/.zshrc" : "~/.bashrc";
854
+ return `type ${ui.bold(aliasName)} in a new terminal — or run ${ui.bold("source " + rc)} in this one.`;
855
+ }
856
+
857
+ async function launchOrInstruct(egregoreDir, alias, ui, ctx = {}) {
858
+ const aliasName = (alias && alias.aliasName) || "egregore";
859
+ const runtime = normalizeRuntime(ctx.runtime);
860
+ const display = runtimeDisplay(runtime);
861
+ const binary = runtimeBinary(runtime);
862
+
863
+ if (ctx.noAutolaunch) {
864
+ ui.info(`Next: ${launcherNextStep(ui, aliasName)}`);
865
+ console.log("");
866
+ return;
867
+ }
868
+
869
+ if (!commandExists(binary)) {
870
+ const hasClaude = commandExists("claude");
871
+ const hasCodex = commandExists("codex");
872
+ // The user already chose `runtime` at the picker — lead with installing
873
+ // THAT one ("install Codex first"), then offer the other as an alternative.
874
+ const other = runtime === "codex" ? "claude-code" : "codex";
875
+ if (!hasClaude && !hasCodex) {
876
+ ui.warn(`Almost there — install ${display} to start sessions.`);
877
+ ui.info(` Install ${display} first:`);
878
+ ui.info(` ${ui.bold(assistantInstallCommand(runtime))}`);
879
+ ui.info(` ${ui.dim(`(or use ${runtimeDisplay(other)}: ${assistantInstallCommand(other)})`)}`);
880
+ ui.info(` Then ${launcherNextStep(ui, aliasName)}`);
881
+ console.log("");
882
+ return;
883
+ }
884
+ ui.warn(`Almost there — Egregore needs ${display} to run your sessions.`);
885
+ ui.info(` Install ${display} first:`);
886
+ ui.info(` ${ui.bold(assistantInstallCommand(runtime))}`);
887
+ ui.info(` Then ${launcherNextStep(ui, aliasName)}`);
888
+ if (runtime === "codex") ui.info(" Choose Codex when prompted.");
889
+ console.log("");
890
+ return;
891
+ }
892
+
893
+ ui.info(`Starting Egregore in ${ui.bold(display)} now — press ${ui.bold("Ctrl+D")} (or /exit) to return to your shell.`);
894
+ ui.info(`Next time, ${launcherNextStep(ui, aliasName)}`);
895
+ console.log("");
896
+
897
+ if (runtime === "codex") {
898
+ const codexRuntime = installCodexRuntime(egregoreDir);
899
+ reportCodexRuntimeSummary(codexRuntime, ui);
900
+ if (!hasCodexCoreSupport(egregoreDir)) {
901
+ ui.warn(codexSupportMessage(egregoreDir));
902
+ ui.info(`Run ${ui.bold(aliasName)} after updating the checkout.`);
903
+ console.log("");
904
+ return;
905
+ }
906
+ const skillsInstalled = installCodexSkills(egregoreDir);
907
+ const cardRendered = await renderCodexStartupCard(egregoreDir);
908
+ if (!skillsInstalled || !cardRendered) {
909
+ ui.warn(codexSupportMessage(egregoreDir));
910
+ ui.info(`Run ${ui.bold(aliasName)} after updating the checkout.`);
911
+ console.log("");
912
+ return;
913
+ }
914
+ }
915
+
916
+ const result = spawnSync(binary, runtimeLaunchArgs(runtime, egregoreDir), {
917
+ cwd: egregoreDir,
918
+ stdio: "inherit",
919
+ env: { ...process.env, EGREGORE_RUNTIME: runtime },
920
+ });
921
+ process.exit(result.status === null ? 1 : result.status);
922
+ }
923
+
924
+ // Launcher binary template path. We ship a Node-based TUI launcher as the
925
+ // primary; the shell script remains in the repo for back-compat but the
926
+ // installer prefers the .js variant because it provides the full-screen
927
+ // browser, better rendering, and richer keybinds.
928
+ const LAUNCHER_TEMPLATE_JS = path.join(__dirname, "..", "assets", "egregore-launcher.js");
929
+ const LAUNCHER_TEMPLATE_SH = path.join(__dirname, "..", "assets", "egregore-launcher.sh");
930
+
931
+ function readLauncherTemplate() {
932
+ // Prefer the Node launcher; fall back to bash only if the .js is missing.
933
+ if (fs.existsSync(LAUNCHER_TEMPLATE_JS)) return fs.readFileSync(LAUNCHER_TEMPLATE_JS, "utf-8");
934
+ return fs.readFileSync(LAUNCHER_TEMPLATE_SH, "utf-8");
935
+ }
936
+
937
+ function currentTemplatePath() {
938
+ return fs.existsSync(LAUNCHER_TEMPLATE_JS) ? LAUNCHER_TEMPLATE_JS : LAUNCHER_TEMPLATE_SH;
939
+ }
940
+
941
+ // Parse a semver from a version-stamp comment. Accepts both styles so we can
942
+ // upgrade a bash launcher to the Node launcher across a major line:
943
+ // # egregore-launcher-version: X.Y.Z (bash)
944
+ // // egregore-launcher-version: X.Y.Z (node)
945
+ function launcherVersion(contents) {
946
+ const m = contents.match(/egregore-launcher-version:\s*(\d+\.\d+\.\d+)/);
947
+ return m ? m[1] : null;
948
+ }
949
+
950
+ // Return 1 if a > b, -1 if a < b, 0 if equal. Both are semver strings "x.y.z".
951
+ function compareVersions(a, b) {
952
+ const pa = a.split(".").map(Number);
953
+ const pb = b.split(".").map(Number);
954
+ for (let i = 0; i < 3; i++) {
955
+ if (pa[i] !== pb[i]) return pa[i] > pb[i] ? 1 : -1;
956
+ }
957
+ return 0;
958
+ }
959
+
960
+ function isDirWritable(dir) {
961
+ try {
962
+ if (!fs.existsSync(dir)) return false;
963
+ fs.accessSync(dir, fs.constants.W_OK);
964
+ return true;
965
+ } catch {
966
+ return false;
967
+ }
968
+ }
969
+
970
+ function dirOnPath(dir) {
971
+ const pathEntries = (process.env.PATH || "").split(path.delimiter);
972
+ return pathEntries.some((p) => {
973
+ try {
974
+ return p && fs.existsSync(p) && fs.realpathSync(p) === fs.realpathSync(dir);
975
+ } catch {
976
+ return p === dir;
977
+ }
978
+ });
979
+ }
980
+
981
+ function appendLocalBinToRc(ui) {
982
+ // Ensure ~/.local/bin is on PATH for future shells by appending to rc files.
983
+ // Idempotent — only appends if the export line isn't already present.
984
+ const rcFiles = [".zshrc", ".bashrc", ".bash_profile"].map((f) => path.join(os.homedir(), f));
985
+ const exportLine = `export PATH="$HOME/.local/bin:$PATH"`;
986
+ let touched = [];
987
+ for (const rc of rcFiles) {
988
+ if (!fs.existsSync(rc)) continue;
989
+ try {
990
+ const content = fs.readFileSync(rc, "utf-8");
991
+ if (content.includes(`$HOME/.local/bin`) || content.includes(`${os.homedir()}/.local/bin`)) continue;
992
+ fs.appendFileSync(rc, `\n# Added by create-egregore\n${exportLine}\n`);
993
+ touched.push(path.basename(rc));
994
+ } catch {}
995
+ }
996
+ if (touched.length > 0) {
997
+ ui.info(`Added ~/.local/bin to PATH in: ${touched.join(", ")} (takes effect in new shells).`);
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * Install the `egregore` PATH binary. Writes a launcher script that reads
1003
+ * ~/.egregore/instances.json and launches the chosen instance.
1004
+ *
1005
+ * Placement preference:
1006
+ * 1. /usr/local/bin/egregore (writable without sudo)
1007
+ * 2. /opt/homebrew/bin/egregore (Apple Silicon Homebrew, writable without sudo)
1008
+ * 3. ~/.local/bin/egregore + PATH append (no sudo; works in new shells)
1009
+ *
1010
+ * If an `egregore` binary already exists at the chosen path:
1011
+ * - Same content (by byte match) → no-op (silent)
1012
+ * - Different content → keep existing, warn (don't clobber user customizations)
1013
+ *
1014
+ * Returns { aliasName: "egregore", binaryPath, location, replaced }
1015
+ */
1016
+ async function installLauncherBinary(egregoreDir, ui) {
1017
+ const template = readLauncherTemplate();
1018
+ const candidates = [
1019
+ { dir: "/usr/local/bin", location: "system" },
1020
+ { dir: "/opt/homebrew/bin", location: "system" },
1021
+ { dir: path.join(os.homedir(), ".local", "bin"), location: "user" },
1022
+ ];
1023
+
1024
+ let chosen = null;
1025
+ for (const c of candidates) {
1026
+ if (c.location === "user" && !fs.existsSync(c.dir)) {
1027
+ try {
1028
+ fs.mkdirSync(c.dir, { recursive: true });
1029
+ } catch {
1030
+ continue;
1031
+ }
1032
+ }
1033
+ if (isDirWritable(c.dir)) {
1034
+ chosen = c;
1035
+ break;
1036
+ }
1037
+ }
1038
+
1039
+ if (!chosen) {
1040
+ ui.warn("Could not find a writable bin directory. Install the launcher manually:");
1041
+ ui.info(` cp ${currentTemplatePath()} ~/.local/bin/egregore && chmod +x ~/.local/bin/egregore`);
1042
+ return { aliasName: "egregore", binaryPath: null, location: null, replaced: false };
1043
+ }
1044
+
1045
+ const binaryPath = path.join(chosen.dir, "egregore");
1046
+ let replaced = false;
1047
+
1048
+ if (fs.existsSync(binaryPath)) {
1049
+ try {
1050
+ const existing = fs.readFileSync(binaryPath, "utf-8");
1051
+ if (existing === template) {
1052
+ // Same content — silent no-op, still chmod for safety
1053
+ try { fs.chmodSync(binaryPath, 0o755); } catch {}
1054
+ if (chosen.location === "user" && !dirOnPath(chosen.dir)) appendLocalBinToRc(ui);
1055
+ removeShadowingEgregoreAliases(ui);
1056
+ return { aliasName: "egregore", binaryPath, location: chosen.location, replaced: false };
1057
+ }
1058
+
1059
+ // Different content — decide upgrade vs preserve via version stamps.
1060
+ const existingVer = launcherVersion(existing);
1061
+ const templateVer = launcherVersion(template);
1062
+
1063
+ if (existingVer && templateVer) {
1064
+ if (compareVersions(templateVer, existingVer) > 0) {
1065
+ // Template is newer — safe upgrade.
1066
+ fs.writeFileSync(binaryPath, template, { mode: 0o755 });
1067
+ ui.success(`Upgraded ${ui.dim("egregore")} at ${binaryPath} ${ui.dim(`(${existingVer} → ${templateVer})`)}`);
1068
+ if (chosen.location === "user" && !dirOnPath(chosen.dir)) appendLocalBinToRc(ui);
1069
+ removeShadowingEgregoreAliases(ui);
1070
+ return { aliasName: "egregore", binaryPath, location: chosen.location, replaced: true };
1071
+ }
1072
+ if (compareVersions(templateVer, existingVer) < 0) {
1073
+ // Template is older — don't downgrade. Warn lightly.
1074
+ ui.warn(`Existing ${binaryPath} (v${existingVer}) is newer than this installer's template (v${templateVer}) — leaving it alone.`);
1075
+ removeShadowingEgregoreAliases(ui);
1076
+ return { aliasName: "egregore", binaryPath, location: chosen.location, replaced: false };
1077
+ }
1078
+ // Equal stamps but different content → user edited the file. Don't clobber.
1079
+ ui.warn(`Existing ${binaryPath} has been edited locally — not overwriting.`);
1080
+ ui.info(` To reset: cp ${currentTemplatePath()} ${binaryPath}`);
1081
+ return { aliasName: "egregore", binaryPath, location: chosen.location, replaced: false };
1082
+ }
1083
+
1084
+ // No version stamp on existing. If it looks like one of OUR old
1085
+ // launchers (pre-0.4.1, before stamps existed), upgrade it. Otherwise
1086
+ // assume the user customized it and preserve.
1087
+ // Match both the bash (REGISTRY=…) and Node (path.join(os.homedir()…))
1088
+ // forms, plus a generic substring that's stable across versions.
1089
+ const looksLikeOurs =
1090
+ existing.includes(`.egregore/instances.json`) ||
1091
+ existing.includes(`egregore-launcher-version`);
1092
+ if (looksLikeOurs) {
1093
+ fs.writeFileSync(binaryPath, template, { mode: 0o755 });
1094
+ ui.success(`Upgraded ${ui.dim("egregore")} at ${binaryPath} ${ui.dim(`(pre-0.4.1 → ${templateVer})`)}`);
1095
+ if (chosen.location === "user" && !dirOnPath(chosen.dir)) appendLocalBinToRc(ui);
1096
+ removeShadowingEgregoreAliases(ui);
1097
+ return { aliasName: "egregore", binaryPath, location: chosen.location, replaced: true };
1098
+ }
1099
+ ui.warn(`Existing ${binaryPath} doesn't look like an egregore launcher — not overwriting.`);
1100
+ ui.info(` If you meant to install: rm ${binaryPath} and re-run create-egregore.`);
1101
+ return { aliasName: "egregore", binaryPath, location: chosen.location, replaced: false };
1102
+ } catch {
1103
+ // Fall through to write
1104
+ }
1105
+ }
1106
+
1107
+ try {
1108
+ fs.writeFileSync(binaryPath, template, { mode: 0o755 });
1109
+ replaced = false;
1110
+ } catch (err) {
1111
+ ui.warn(`Could not write launcher to ${binaryPath}: ${err.message}`);
1112
+ return { aliasName: "egregore", binaryPath: null, location: null, replaced: false };
1113
+ }
1114
+
1115
+ if (chosen.location === "user" && !dirOnPath(chosen.dir)) {
1116
+ appendLocalBinToRc(ui);
1117
+ }
1118
+
1119
+ removeShadowingEgregoreAliases(ui);
1120
+ ui.success(`Installed ${ui.dim("egregore")} at ${binaryPath}`);
1121
+ return { aliasName: "egregore", binaryPath, location: chosen.location, replaced };
1122
+ }
1123
+
1124
+ // Strip legacy `alias egregore=...` lines from common shell rc files. These
1125
+ // come from older `create-egregore` installs that shipped a shell alias
1126
+ // instead of a PATH binary. In zsh, aliases beat PATH lookups, so the alias
1127
+ // shadows the new launcher and `egregore` keeps running the alias's
1128
+ // `cd <dir> && claude start` even after the launcher is installed.
1129
+ // Idempotent: if no alias exists, nothing happens. Non-fatal on errors.
1130
+ function removeShadowingEgregoreAliases(ui) {
1131
+ const home = os.homedir();
1132
+ const targets = [
1133
+ { rcPath: path.join(home, ".zshrc"), prefix: "alias egregore=" },
1134
+ { rcPath: path.join(home, ".bashrc"), prefix: "alias egregore=" },
1135
+ { rcPath: path.join(home, ".bash_profile"), prefix: "alias egregore=" },
1136
+ { rcPath: path.join(home, ".profile"), prefix: "alias egregore=" },
1137
+ { rcPath: path.join(home, ".config", "fish", "config.fish"), prefix: "alias egregore " },
1138
+ ];
1139
+ const touched = [];
1140
+ for (const { rcPath, prefix } of targets) {
1141
+ if (!fs.existsSync(rcPath)) continue;
1142
+ try {
1143
+ const content = fs.readFileSync(rcPath, "utf-8");
1144
+ const lines = content.split("\n");
1145
+ const kept = lines.filter((l) => !l.startsWith(prefix));
1146
+ if (kept.length === lines.length) continue;
1147
+ fs.writeFileSync(rcPath, kept.join("\n"));
1148
+ touched.push(path.basename(rcPath));
1149
+ } catch {
1150
+ // Non-fatal — best effort. Worst case: the alias stays and the user
1151
+ // can remove it by hand.
1152
+ }
1153
+ }
1154
+ if (touched.length > 0 && ui) {
1155
+ ui.info(`Removed legacy ${ui.bold("egregore")} alias from ${touched.join(", ")} — start a new shell to use the launcher.`);
1156
+ }
1157
+ }
1158
+
429
1159
  async function installShellAlias(egregoreDir, ui) {
430
1160
  try {
431
1161
  // Detect shell profile
@@ -511,9 +1241,17 @@ module.exports = {
511
1241
  ghApi,
512
1242
  acceptPendingInvitations,
513
1243
  registerInstance,
1244
+ normalizeRuntime,
514
1245
  installShellAlias,
1246
+ installLauncherBinary,
1247
+ launchOrInstruct,
1248
+ buildOnboardingState,
1249
+ ensurePersonOnboardingWitness,
1250
+ ensureInstallerMemberRole,
515
1251
  embedToken,
516
1252
  configureGitCredentials,
1253
+ gitCloneEnv,
1254
+ assistantInstallCommand,
517
1255
  initMemoryDirs,
518
1256
  ensureDevelop,
519
1257
  run,