create-egregore 0.17.3 → 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 +3 -3
  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 +618 -193
  10. package/lib/setup.js +837 -85
  11. package/lib/ui.js +780 -84
  12. package/package.json +17 -4
  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,11 +311,12 @@ 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
- // Directory name from fork URL (what git clone would use), e.g. "egregore-core"
319
+ // Directory name from fork URL (what git clone would use), e.g. "egregore"
116
320
  const forkDirName = fork_url.split("/").pop().replace(/\.git$/, "");
117
321
  const egregoreDir = path.join(base, forkDirName);
118
322
  const memoryDirName = memory_url
@@ -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 = [];
@@ -264,24 +482,59 @@ async function install(data, ui, targetDir, opts = {}) {
264
482
  }
265
483
  }
266
484
 
267
- // Done
268
- console.log("");
269
- ui.success(`Egregore is ready for ${ui.bold(org_name)}`);
270
- console.log("");
271
- ui.info(`Your workspace:`);
272
- ui.info(` ${ui.cyan(`./${forkDirName}/`)} — Your Egregore instance`);
273
- ui.info(` ${ui.cyan(`./${memoryDirName}/`)} — Shared knowledge`);
274
- for (const repoName of clonedRepos) {
275
- ui.info(` ${ui.cyan(`./${repoName}/`)} — Managed repo`);
276
- }
485
+ // Warm the npx cache for egregore-artifacts so the first /view or
486
+ // publish-artifact call doesn't pay a cold npm download (~5-10s).
487
+ // Background + detached — we don't care about completion, and any
488
+ // failure is silent. The first real use benefits if this finishes in
489
+ // time, otherwise it costs nothing.
490
+ try {
491
+ const { spawn } = require("child_process");
492
+ const warm = spawn("npx", ["-y", "egregore-artifacts@latest", "--help"], {
493
+ detached: true,
494
+ stdio: "ignore",
495
+ });
496
+ warm.unref();
497
+ } catch { /* ignore — cosmetic warmup */ }
498
+
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
+ ];
277
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
+ }
278
534
  console.log("");
279
- ui.info(`Join the Telegram group for notifications:`);
280
- ui.info(` ${ui.cyan(telegram_group_link)}`);
281
535
  }
282
- console.log("");
283
- ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
284
- console.log("");
536
+
537
+ await launchOrInstruct(path.join(base, forkDirName), alias, ui, opts);
285
538
  }
286
539
 
287
540
  /**
@@ -342,13 +595,28 @@ function toSSH(url) {
342
595
  return m ? `git@github.com:${m[1]}.git` : null;
343
596
  }
344
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
+
345
613
  /**
346
614
  * Clone a repo, trying SSH first then HTTPS (no token embedded).
347
615
  * The repo:invite-scoped OAuth token can't clone — we rely on the
348
616
  * user's own git credentials (SSH keys / gh CLI / credential helper).
349
617
  */
350
618
  function cloneRepo(httpsUrl, dir, timeout = 60000) {
351
- const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
619
+ const env = gitCloneEnv();
352
620
  const sshUrl = toSSH(httpsUrl);
353
621
 
354
622
  if (sshUrl) {
@@ -383,7 +651,145 @@ function configureGitCredentials(token) {
383
651
  }
384
652
  }
385
653
 
386
- 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) {
387
793
  const registryDir = path.join(os.homedir(), ".egregore");
388
794
  const registryFile = path.join(registryDir, "instances.json");
389
795
 
@@ -394,15 +800,28 @@ function registerInstance(slug, name, egregoreDir) {
394
800
  let instances = [];
395
801
  if (fs.existsSync(registryFile)) {
396
802
  try {
397
- 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
+ }
398
811
  } catch {
399
812
  instances = [];
400
813
  }
401
814
  }
402
815
 
403
- const entry = { slug, name, path: egregoreDir };
404
816
  // Dedup by path (not slug) so multiple instances from the same org can coexist
405
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
+ }
406
825
  if (idx >= 0) {
407
826
  instances[idx] = entry;
408
827
  } else {
@@ -412,6 +831,331 @@ function registerInstance(slug, name, egregoreDir) {
412
831
  fs.writeFileSync(registryFile, JSON.stringify(instances, null, 2) + "\n");
413
832
  }
414
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
+
415
1159
  async function installShellAlias(egregoreDir, ui) {
416
1160
  try {
417
1161
  // Detect shell profile
@@ -497,9 +1241,17 @@ module.exports = {
497
1241
  ghApi,
498
1242
  acceptPendingInvitations,
499
1243
  registerInstance,
1244
+ normalizeRuntime,
500
1245
  installShellAlias,
1246
+ installLauncherBinary,
1247
+ launchOrInstruct,
1248
+ buildOnboardingState,
1249
+ ensurePersonOnboardingWitness,
1250
+ ensureInstallerMemberRole,
501
1251
  embedToken,
502
1252
  configureGitCredentials,
1253
+ gitCloneEnv,
1254
+ assistantInstallCommand,
503
1255
  initMemoryDirs,
504
1256
  ensureDevelop,
505
1257
  run,