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/local.js CHANGED
@@ -6,19 +6,35 @@
6
6
  const fs = require("node:fs");
7
7
  const path = require("node:path");
8
8
  const { execFileSync } = require("node:child_process");
9
- const { deviceFlow, openBrowser } = require("./auth");
10
9
  const {
10
+ GH_INSTALL_URL,
11
+ ghCliExists,
12
+ ghInstallCommand,
13
+ ghInstallShellCommand,
14
+ openBrowser,
15
+ runGhInstallAndLogin,
16
+ tryGhAuthOrLogin,
17
+ } = require("./auth");
18
+ const { installCodexRuntime, reportCodexRuntimeSummary } = require("./codex-runtime");
19
+ const {
20
+ install,
11
21
  ghApi,
12
22
  acceptPendingInvitations,
13
23
  registerInstance,
14
- installShellAlias,
24
+ normalizeRuntime,
25
+ installLauncherBinary,
26
+ launchOrInstruct,
15
27
  embedToken,
16
28
  configureGitCredentials,
29
+ gitCloneEnv,
17
30
  initMemoryDirs,
18
31
  ensureDevelop,
32
+ ensurePersonOnboardingWitness,
33
+ ensureInstallerMemberRole,
19
34
  run,
20
35
  } = require("./setup");
21
36
  const { EgregoreAPI } = require("./api");
37
+ const { joinCommand } = require("./commands");
22
38
 
23
39
  // Template repo for new egregore instances (OSS)
24
40
  const TEMPLATE_OWNER = "egregore-labs";
@@ -38,6 +54,18 @@ async function listOrgs(token) {
38
54
  return data.map((o) => ({ login: o.login }));
39
55
  }
40
56
 
57
+ async function listOrgMembers(token, org) {
58
+ // Returns [{login}]. Only visible members (public or visible to the token).
59
+ // Non-fatal on error — caller falls back to a text prompt.
60
+ try {
61
+ const { status, data } = await ghApi("GET", `/orgs/${org}/members?per_page=100`, token);
62
+ if (status !== 200 || !Array.isArray(data)) return [];
63
+ return data.map((m) => ({ login: m.login }));
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+
41
69
  async function repoExists(token, owner, repo) {
42
70
  const { status } = await ghApi("GET", `/repos/${owner}/${repo}`, token);
43
71
  return status === 200;
@@ -86,9 +114,7 @@ async function findEgregoreRepo(token, orgLogin, explicitRepo) {
86
114
  if (explicitRepo) {
87
115
  if (await repoExists(token, orgLogin, explicitRepo)) return explicitRepo;
88
116
  } else {
89
- for (const candidate of ["egregore", "egregore-core"]) {
90
- if (await repoExists(token, orgLogin, candidate)) return candidate;
91
- }
117
+ if (await repoExists(token, orgLogin, "egregore")) return "egregore";
92
118
  }
93
119
  return null;
94
120
  }
@@ -118,9 +144,19 @@ async function putFileContent(token, owner, repo, filePath, content, message) {
118
144
 
119
145
  async function listOrgRepos(token, owner, isOrg) {
120
146
  // Fetch all repos (public + private). Paginate to get beyond the first 100.
147
+ //
148
+ // For ORGS: /orgs/{owner}/repos?type=all returns everything the token can
149
+ // see in that org (public + private + member-of) given `repo` scope.
150
+ //
151
+ // For PERSONAL: /users/{owner}/repos returns ONLY public repos — the
152
+ // endpoint doesn't honor the token's `repo` scope for private listings.
153
+ // The right endpoint for "this authenticated user's own repos" is
154
+ // /user/repos, which returns public + private + collaborator with `repo`
155
+ // scope. We filter affiliation=owner so we don't surface random collab
156
+ // repos in the founder's picker.
121
157
  const baseUrl = isOrg
122
158
  ? `/orgs/${owner}/repos?per_page=100&sort=updated&type=all`
123
- : `/users/${owner}/repos?per_page=100&sort=updated&type=owner`;
159
+ : `/user/repos?per_page=100&sort=updated&affiliation=owner`;
124
160
 
125
161
  let allRepos = [];
126
162
  let page = 1;
@@ -133,8 +169,16 @@ async function listOrgRepos(token, owner, isOrg) {
133
169
  page++;
134
170
  }
135
171
 
172
+ // Personal /user/repos returns repos across all of the user's accounts
173
+ // (their personal namespace + any orgs they're a member of). When the
174
+ // caller asked for the personal scope, narrow to repos owned by `owner`
175
+ // so org repos don't pollute the personal picker.
176
+ if (!isOrg) {
177
+ allRepos = allRepos.filter((r) => r.owner && r.owner.login === owner);
178
+ }
179
+
136
180
  return allRepos
137
- .filter((r) => !r.archived && r.name !== "egregore" && r.name !== "egregore-core" && !r.name.endsWith("-memory"))
181
+ .filter((r) => !r.archived && r.name !== "egregore" && !r.name.endsWith("-memory"))
138
182
  .map((r) => ({ name: r.name, language: r.language || "", description: r.description || "" }));
139
183
  }
140
184
 
@@ -178,6 +222,7 @@ function cloneRepo(url, dir, token, ui, label) {
178
222
  stdio: "pipe",
179
223
  encoding: "utf-8",
180
224
  timeout: 60000,
225
+ env: gitCloneEnv(),
181
226
  });
182
227
  try {
183
228
  run(`git remote set-url origin ${url}`, { cwd: dir });
@@ -203,7 +248,7 @@ function toSSH(url) {
203
248
  * GIT_TERMINAL_PROMPT=0 prevents the hanging username/password prompt.
204
249
  */
205
250
  function cloneWithFallback(httpsUrl, dir, timeout = 60000) {
206
- const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
251
+ const env = gitCloneEnv();
207
252
  const sshUrl = toSSH(httpsUrl);
208
253
 
209
254
  // Try SSH first (most devs have SSH keys; GitHub no longer supports HTTPS password auth)
@@ -245,52 +290,263 @@ function createSymlink(egregoreDir, memoryDir, ui) {
245
290
  ui.success("Linked");
246
291
  }
247
292
 
248
- // GitHub App installation URL — grants Egregore access to repos the user selects
249
- const APP_INSTALL_URL = "https://github.com/apps/egregore-labs/installations/new";
293
+ // Retry an async fn until it returns truthy, up to `attempts` tries with a
294
+ // fixed delay between them. Returns the last result (truthy on success).
295
+ async function retryAsync(fn, attempts = 2, delayMs = 1500) {
296
+ let result;
297
+ for (let i = 0; i < attempts; i++) {
298
+ try {
299
+ result = await fn();
300
+ } catch {
301
+ result = false;
302
+ }
303
+ if (result) return result;
304
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
305
+ }
306
+ return result;
307
+ }
308
+
309
+ // Shown when gh isn't installed. gh is GitHub's own CLI — it signs the user in
310
+ // locally so the installer can work with THEIR repos. No Egregore OAuth app, no
311
+ // third-party "requesting access" screen. Language stays ownership-first: this
312
+ // is their machine and their repos; Egregore isn't managing or holding anything.
313
+ function ghFallbackNote(ui) {
314
+ const lines = [
315
+ `Egregore sets up in your own GitHub, on your own machine — you own all of it.`,
316
+ `It signs in with GitHub CLI (${ui.cyan("gh")}), GitHub's own tool, so setup can see`,
317
+ `your repos and create your Egregore. No third-party access screen, nothing hosted by us.`,
318
+ ``,
319
+ `Install gh, sign in, then re-run — that's the whole setup:`,
320
+ ` 1. ${ui.bold(ghInstallCommand())}`,
321
+ ` 2. ${ui.bold("gh auth login")}`,
322
+ ` 3. ${ui.bold("npx -y create-egregore@latest")}`,
323
+ ``,
324
+ `${ui.dim("Install for any OS: " + GH_INSTALL_URL)}`,
325
+ ];
326
+ return lines;
327
+ }
328
+
329
+ function repoEntryName(entry) {
330
+ return typeof entry === "string" ? entry : entry && entry.name;
331
+ }
332
+
333
+ function yamlList(values) {
334
+ const clean = values.filter(Boolean);
335
+ if (clean.length === 0) return " []";
336
+ return clean.map((v) => `\n - ${String(v).replace(/\n/g, " ")}`).join("");
337
+ }
338
+
339
+ function inviteFilename(username) {
340
+ const safe = String(username || "invitee")
341
+ .toLowerCase()
342
+ .replace(/[^a-z0-9-]+/g, "-")
343
+ .replace(/^-|-$/g, "") || "invitee";
344
+ return `${new Date().toISOString().slice(0, 10)}-${safe}.md`;
345
+ }
346
+
347
+ function writeInviteArtifact(memoryDir, invite) {
348
+ const invitesDir = path.join(memoryDir, "invites");
349
+ fs.mkdirSync(invitesDir, { recursive: true });
350
+ const file = path.join(invitesDir, inviteFilename(invite.invitee));
351
+ const managedRepos = (invite.managedRepos || []).map(repoEntryName).filter(Boolean);
352
+ const lines = [
353
+ "---",
354
+ "type: egregore_invite",
355
+ `created_at: ${new Date().toISOString()}`,
356
+ `inviter: ${invite.inviter}`,
357
+ `invitee_github: ${invite.invitee}`,
358
+ `org: ${invite.githubOrg}`,
359
+ `repo: ${invite.githubOrg}/${invite.repoName}`,
360
+ `memory_repo: ${invite.memoryRepo}`,
361
+ `managed_repos:${yamlList(managedRepos)}`,
362
+ `invite_url: ${invite.inviteUrl || ""}`,
363
+ `fallback_command: ${invite.fallbackCommand}`,
364
+ `telegram_group_link: ${invite.telegramGroupLink || ""}`,
365
+ `delivery: ${invite.delivery || "copyable_share_block"}`,
366
+ "---",
367
+ "",
368
+ `# Invite: ${invite.invitee}`,
369
+ "",
370
+ `Fallback command: \`${invite.fallbackCommand}\``,
371
+ "",
372
+ ];
373
+ if (invite.inviteUrl) lines.push(`Invite URL: ${invite.inviteUrl}`, "");
374
+ if (invite.telegramGroupLink) lines.push(`Telegram: ${invite.telegramGroupLink}`, "");
375
+ fs.writeFileSync(file, lines.join("\n"));
376
+ return file;
377
+ }
378
+
379
+ function commitMemoryInvite(memoryDir, paths, username) {
380
+ if (!fs.existsSync(path.join(memoryDir, ".git"))) {
381
+ return { committed: false, pushed: false };
382
+ }
383
+ try {
384
+ for (const p of paths.filter(Boolean)) {
385
+ run(`git add ${JSON.stringify(path.relative(memoryDir, p))}`, { cwd: memoryDir });
386
+ }
387
+ run(`git commit -m "Invite ${username}"`, { cwd: memoryDir });
388
+ try {
389
+ run("git push", { cwd: memoryDir });
390
+ return { committed: true, pushed: true };
391
+ } catch {
392
+ return { committed: true, pushed: false };
393
+ }
394
+ } catch {
395
+ return { committed: false, pushed: false };
396
+ }
397
+ }
398
+
399
+ function telegramBotInviteLink(slug) {
400
+ return `https://t.me/Egregore_clbot?startgroup=org_${encodeURIComponent(slug || "egregore")}`;
401
+ }
402
+
403
+ function looksLikeUrl(value) {
404
+ return /^https?:\/\//i.test(String(value || "").trim());
405
+ }
406
+
407
+ async function maybeSetupTelegramGroup(ui, {
408
+ egregoreDir,
409
+ githubToken,
410
+ githubOrg,
411
+ repoName,
412
+ egreConfig,
413
+ statePath,
414
+ }) {
415
+ ui.section("Telegram group", "optional shared notifications before you invite teammates");
416
+ const shouldSetup = await ui.confirm("Set up a Telegram group now?", false);
417
+ const botLink = telegramBotInviteLink(egreConfig.slug);
418
+
419
+ let state = {};
420
+ try { state = JSON.parse(fs.readFileSync(statePath, "utf-8")); } catch {}
421
+ state.telegram_setup = state.telegram_setup || {};
422
+ state.telegram_setup.handled_by = "installer";
423
+ state.telegram_setup.bot_invite_link = botLink;
424
+ state.telegram_setup.skipped = !shouldSetup;
425
+
426
+ if (!shouldSetup) {
427
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
428
+ ui.info(`You can run ${ui.bold("/telegram-connect")} later. Invites will include the CLI join command only.`);
429
+ return { setup: false, groupLink: "" };
430
+ }
431
+
432
+ ui.note([
433
+ "Step 1: In Telegram, create a new group or open an existing team group.",
434
+ "Step 2: Add the Egregore bot to that group.",
435
+ "Step 3: Come back here and press Enter.",
436
+ ]);
437
+ ui.info(`Bot add link: ${ui.cyan(botLink)}`);
438
+ openBrowser(botLink);
439
+
440
+ await ui.prompt("Press Enter here after the bot is in the group");
441
+ ui.note([
442
+ "Step 4: In Telegram, copy a group invite link.",
443
+ "Paste it below so teammate invites include the Telegram group too.",
444
+ "If you skip this, Egregore will still work, but invites will not include Telegram.",
445
+ ]);
446
+
447
+ const groupLink = await ui.prompt("Telegram group join link (paste here, or Enter to skip)");
448
+ const cleaned = groupLink.trim();
449
+ let storedGroupLink = "";
450
+ if (cleaned && !looksLikeUrl(cleaned)) {
451
+ ui.warn("That does not look like a URL, so I will not store it.");
452
+ } else if (cleaned) {
453
+ storedGroupLink = cleaned;
454
+ egreConfig.telegram_group_link = cleaned;
455
+ state.telegram_group_link = cleaned;
456
+ state.telegram_setup.group_link = cleaned;
457
+
458
+ const configPath = path.join(egregoreDir, "egregore.json");
459
+ fs.writeFileSync(configPath, JSON.stringify(egreConfig, null, 2) + "\n");
460
+ try {
461
+ await putFileContent(
462
+ githubToken,
463
+ githubOrg,
464
+ repoName,
465
+ "egregore.json",
466
+ JSON.stringify(egreConfig, null, 2) + "\n",
467
+ "Configure Telegram group",
468
+ );
469
+ } catch {}
470
+ try {
471
+ run("git add egregore.json", { cwd: egregoreDir });
472
+ run('git commit -m "Configure Telegram group"', { cwd: egregoreDir });
473
+ run("git push", { cwd: egregoreDir });
474
+ } catch {}
475
+ }
476
+
477
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
478
+ ui.success(storedGroupLink ? "Telegram group recorded" : "Telegram bot added; no group link saved");
479
+ return { setup: true, groupLink: storedGroupLink };
480
+ }
250
481
 
251
482
  // ── Flow 1: Founder (local mode) ───────────────────────────────────
252
483
 
253
484
  async function localFounderFlow(ui, ctx = {}) {
485
+ const preferredRuntime = normalizeRuntime(ctx.runtime);
254
486
  // Names of repos already on disk as the user's own workdir — skip clone/pull.
255
487
  const skipRepos = ctx.skipRepos instanceof Set ? new Set(ctx.skipRepos) : new Set();
256
- // 1. Install GitHub App — user picks which repos/accounts to grant access to
257
- ui.info("Let's set up Egregore.\n");
258
- ui.info("First, install the Egregore app on your GitHub account.");
259
- ui.info("You'll choose exactly which repos to share.\n");
260
- openBrowser(APP_INSTALL_URL);
261
- await ui.prompt("Press Enter when done...");
262
-
263
- // 2. GitHub auth (auto-approved after App install)
264
- ui.info("");
488
+
489
+ // 1. GitHub auth — prefer the gh CLI. If gh is installed but logged out,
490
+ // run gh auth login instead of opening the Egregore App page. The App
491
+ // fallback is only for machines without gh.
492
+ ui.section("Connect your GitHub", "sign in to your own account — everything created is yours");
493
+ ui.note([
494
+ "Egregore builds in your GitHub and on your machine — you own all of it.",
495
+ "This CLI just helps you set it up. Nothing is hosted by us.",
496
+ ]);
497
+
265
498
  let githubToken;
266
- try {
267
- githubToken = await deviceFlow(ui);
268
- ui.success("Authenticated with GitHub");
269
- } catch (err) {
270
- ui.error(`GitHub auth failed: ${err.message}`);
499
+ // gh is the only local auth path: GitHub's own CLI, so no Egregore OAuth
500
+ // consent screen ever. We do NOT fall back to a third-party device flow —
501
+ // when gh is missing we guide the user to install it and re-run.
502
+ const ghAuth = tryGhAuthOrLogin(ui);
503
+ if (ghAuth) {
504
+ ui.success(`Using your ${ui.bold("gh")} CLI auth${ghAuth.user ? ` (${ghAuth.user})` : ""}`);
505
+ githubToken = ghAuth.token;
506
+ } else if (ghCliExists()) {
507
+ ui.error("GitHub CLI auth did not complete.");
508
+ ui.info(`Run ${ui.bold("gh auth login")} and then re-run this installer.`);
271
509
  process.exit(1);
510
+ } else {
511
+ // gh isn't installed. Offer to install it + sign in right here — Enter
512
+ // accepts. gh is GitHub's own CLI, so there's no Egregore consent screen.
513
+ ui.note(ghFallbackNote(ui));
514
+ const cmd = ghInstallShellCommand();
515
+ if (cmd) {
516
+ const ok = await ui.confirm(`Install GitHub CLI now and sign in? (${ui.bold(cmd)})`, true);
517
+ if (ok) githubToken = runGhInstallAndLogin(ui, cmd);
518
+ }
519
+ if (!githubToken) {
520
+ ui.info("No problem — run the steps above when ready, then re-run this.");
521
+ process.exit(1);
522
+ }
272
523
  }
273
524
 
274
525
  // 2. Get user info
275
526
  const user = await getUser(githubToken);
276
527
 
277
- // 3. Where — always show picker (personal first)
278
- console.log("");
528
+ // 3. Where — we always have a gh token (the only local auth path), which
529
+ // carries org read, so we can list the user's accounts and offer a picker.
530
+ ui.section("Pick a home", "where should this Egregore live?");
279
531
  const s = ui.spinner("Checking your GitHub accounts...");
280
532
  const orgs = await listOrgs(githubToken);
281
- s.stop("");
533
+ s.stop("Accounts loaded");
282
534
 
283
535
  let githubOrg, isOrg;
284
536
  if (orgs.length === 0) {
285
537
  githubOrg = user.login;
286
538
  isOrg = false;
287
- ui.info(`Using personal account: ${ui.bold(githubOrg)}`);
539
+ ui.info(`Using your personal account: ${ui.bold(githubOrg)}`);
288
540
  } else {
289
541
  const orgChoices = [{ label: user.login, description: "Personal account" }];
290
542
  for (const o of orgs) {
291
543
  orgChoices.push({ label: o.login, description: "Organization" });
292
544
  }
293
- const orgChoice = await ui.choose("Where do you want to set up Egregore?", orgChoices, "Don't see your org? Grant access on the GitHub authorization page and re-run.");
545
+ const orgChoice = await ui.choose(
546
+ "Where should Egregore live?",
547
+ orgChoices,
548
+ "Don't see your org? Authorize it for your account on GitHub, then re-run.",
549
+ );
294
550
  githubOrg = orgChoice.label;
295
551
  isOrg = githubOrg !== user.login;
296
552
  }
@@ -302,8 +558,7 @@ async function localFounderFlow(ui, ctx = {}) {
302
558
  let isNewProject;
303
559
  if (ctx.prefilledRepo) {
304
560
  isNewProject = false;
305
- console.log("");
306
- ui.info(`Existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
561
+ ui.info(`${ui.green("→")} existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
307
562
  } else {
308
563
  const action = await ui.choose("What are you working on?", [
309
564
  { label: "Starting something new", description: "Create a project from scratch" },
@@ -313,28 +568,55 @@ async function localFounderFlow(ui, ctx = {}) {
313
568
  }
314
569
 
315
570
  // 5. Collect project info
571
+ ui.section("Name it", "what's this Egregore called?");
316
572
  let orgName, description, selectedRepos = [], newProjectRepo = null;
317
573
 
318
574
  if (isNewProject) {
319
- const nameInput = await ui.prompt("Name your Egregore (your team or group name):");
320
- orgName = nameInput || githubOrg;
321
-
322
- const descInput = await ui.prompt("What are you building? (one line)");
323
- description = descInput || "";
575
+ orgName = await ui.prompt("name your Egregore (team or group name)", { default: githubOrg });
576
+ description = await ui.prompt("what are you building? (one line)");
577
+
578
+ // Derive the egregore + memory repo names now so we can validate the
579
+ // optional project repo name against them before the user commits to it.
580
+ // Matches the final derivation at line ~395 below.
581
+ const slugifyRepo = (s) => s.toLowerCase()
582
+ .replace(/[^a-z0-9-]/g, "-")
583
+ .replace(/-+/g, "-")
584
+ .replace(/^-|-$/g, "") || "egregore";
585
+ const predictedRepoName = slugifyRepo(orgName);
586
+ const predictedMemoryRepoName = `${predictedRepoName}-memory`;
324
587
 
325
588
  // Ask about project repo
326
- console.log("");
327
- ui.info("Your Egregore is set up. It has its own repo for memory and config.");
328
- ui.info("If you also have a code project to work on, we can create a repo for it.");
329
- console.log("");
330
- const projectName = await ui.prompt("Code project repo name (Enter to skip):");
331
- if (projectName) {
332
- newProjectRepo = projectName.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
589
+ ui.note([
590
+ "Your Egregore gets its own repo (memory + config) in your account.",
591
+ "Have a code project too? Egregore can add a repo for it in your account.",
592
+ ]);
593
+
594
+ while (true) {
595
+ const projectName = await ui.prompt("code project repo name (Enter to skip)");
596
+ if (!projectName) {
597
+ newProjectRepo = null;
598
+ break;
599
+ }
600
+ const slug = slugifyRepo(projectName);
601
+ if (!slug) {
602
+ ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
603
+ continue;
604
+ }
605
+ if (slug === predictedRepoName) {
606
+ ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
607
+ ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
608
+ continue;
609
+ }
610
+ if (slug === predictedMemoryRepoName) {
611
+ ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
612
+ continue;
613
+ }
614
+ newProjectRepo = slug;
615
+ break;
333
616
  }
334
617
  } else {
335
618
  // "Existing project"
336
- const nameInput = await ui.prompt(`What should we call this Egregore? [${githubOrg}]:`);
337
- orgName = nameInput || githubOrg;
619
+ orgName = await ui.prompt("what should we call this Egregore?", { default: githubOrg });
338
620
  description = "";
339
621
 
340
622
  // Auto-adopt the user's current repo if it's under the chosen GitHub owner.
@@ -414,7 +696,7 @@ async function localFounderFlow(ui, ctx = {}) {
414
696
  s1.stop(`${githubOrg}/${repoName} already has Egregore set up`);
415
697
  console.log("");
416
698
  ui.info("To join it:");
417
- ui.info(` ${ui.bold(`npx create-egregore join ${githubOrg}/${repoName}`)}`);
699
+ ui.info(` ${ui.bold(joinCommand(githubOrg, repoName))}`);
418
700
  console.log("");
419
701
  ui.info("To set up a different one, choose a different team name.");
420
702
  process.exit(0);
@@ -503,8 +785,11 @@ async function localFounderFlow(ui, ctx = {}) {
503
785
  report_url: "https://xgksfrirtdumacvmfkzj.supabase.co",
504
786
  report_key: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inhna3NmcmlydGR1bWFjdm1ma3pqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5OTA1ODksImV4cCI6MjA4NjU2NjU4OX0.1UWNgER6nQazrayly1KDrswClURUkGKxeirZg5L8uk4",
505
787
  };
506
- try {
507
- await putFileContent(
788
+ // Joiners resolve the org by reading egregore.json from the remote — if
789
+ // this push silently fails (observed: transient failure right after
790
+ // template generation), the org is unjoinable and the founder never knows.
791
+ const configPushed = await retryAsync(async () => {
792
+ const { status } = await putFileContent(
508
793
  githubToken,
509
794
  githubOrg,
510
795
  repoName,
@@ -512,19 +797,28 @@ async function localFounderFlow(ui, ctx = {}) {
512
797
  JSON.stringify(egreConfig, null, 2) + "\n",
513
798
  `Configure egregore for ${orgName}`,
514
799
  );
515
- } catch {}
800
+ return status < 300;
801
+ });
802
+ if (!configPushed) {
803
+ ui.warn(`Could not publish egregore.json to ${githubOrg}/${repoName} — teammates can't join until it's there.`);
804
+ ui.info(` Re-run this installer to retry, or push egregore.json from the cloned repo.`);
805
+ }
516
806
 
517
807
  // 8b. Register org with Egregore API (non-blocking — local mode works without it)
518
808
  // Respects DO_NOT_TRACK (https://consult.gov.uk/digital-communications/online-advertising/supporting_documents/DNT%20standard%20v10.pdf)
519
809
  if (!process.env.DO_NOT_TRACK && !process.env.EGREGORE_NO_TELEMETRY) {
520
810
  try {
521
811
  const api = new EgregoreAPI();
522
- await api.registerOrg(githubToken, { org_name: orgName, github_org: githubOrg, slug });
812
+ await api.registerOrg(githubToken, {
813
+ org_name: orgName,
814
+ github_org: githubOrg,
815
+ slug,
816
+ });
523
817
  } catch {}
524
818
  }
525
819
 
526
820
  // 9. Init memory dirs on remote (so joiners get the structure)
527
- const memDirs = ["people", "handoffs", "knowledge/decisions", "knowledge/patterns", "knowledge/findings", "quests", "wraps"];
821
+ const memDirs = ["people", "invites", "handoffs", "knowledge/questions", "knowledge/decisions", "knowledge/patterns", "knowledge/findings", "quests", "wraps"];
528
822
  for (const d of memDirs) {
529
823
  try {
530
824
  await putFileContent(githubToken, githubOrg, memoryRepoName, `${d}/.gitkeep`, "", `Initialize ${d}`);
@@ -541,10 +835,23 @@ async function localFounderFlow(ui, ctx = {}) {
541
835
  const totalSteps = 6 + selectedRepos.length;
542
836
  configureGitCredentials(githubToken);
543
837
 
838
+ ui.section("Install", "cloning repos, linking memory, wiring credentials");
839
+
544
840
  ui.step(1, totalSteps, "Cloning egregore...");
545
841
  cloneRepo(forkUrl, egregoreDir, githubToken, ui, "egregore");
546
842
  setGitIdentity(egregoreDir, user);
547
843
  ensureDevelop(egregoreDir);
844
+ // ensureDevelop only creates develop on the REMOTE (git branch, no checkout),
845
+ // so the clone is still on main. The egregore operates on develop, so switch
846
+ // to it now — otherwise local overlays (the codex runtime) get committed to
847
+ // main and are orphaned when session-start later checks out develop.
848
+ try {
849
+ execFileSync("git", ["-C", egregoreDir, "checkout", "develop"], { stdio: "pipe" });
850
+ } catch {
851
+ try {
852
+ execFileSync("git", ["-C", egregoreDir, "checkout", "-b", "develop", "origin/develop"], { stdio: "pipe" });
853
+ } catch {}
854
+ }
548
855
 
549
856
  ui.step(2, totalSteps, "Cloning shared memory...");
550
857
  cloneRepo(memoryUrl, memoryDir, githubToken, ui, "memory");
@@ -566,19 +873,43 @@ async function localFounderFlow(ui, ctx = {}) {
566
873
  fs.writeFileSync(path.join(egregoreDir, "egregore.json"), JSON.stringify(egreConfig, null, 2) + "\n");
567
874
 
568
875
  // 14. "What should we call you?" + write state
569
- console.log("");
570
- const displayName = await ui.prompt(`What should we call you? [${user.login}]:`);
876
+ ui.section("Your name", "this is how teammates will see you in handoffs");
877
+ const displayName = await ui.prompt("what should we call you?", { default: user.login });
878
+ const memberRole = await ensureInstallerMemberRole(
879
+ { usage_type: "founder_group", member_role: ctx.memberRole },
880
+ {},
881
+ ui
882
+ );
571
883
 
884
+ // 14b. Write onboarding-shortcut state so the /onboarding skill can skip
885
+ // ORIENT + INVITE-offer (the installer already collected display_name
886
+ // and ran the invite flow). See .claude/skills/onboarding/SKILL.md
887
+ // "Pre-captured check" blocks.
888
+ const slugifiedName = (displayName || user.login).toLowerCase().replace(/\s+/g, "-");
572
889
  const state = {
573
890
  github_username: user.login,
574
891
  github_name: user.name,
575
892
  display_name: displayName || user.login,
893
+ name: slugifiedName,
894
+ member_role: memberRole,
576
895
  email: user.email,
577
896
  onboarding_complete: false,
578
897
  usage_type: "founder_group",
579
898
  org_setup: true,
580
899
  github_configured: true,
581
900
  workspace_ready: true,
901
+ preferred_runtime: preferredRuntime,
902
+ // Pre-captured onboarding signals. Invite flag is set later in step 18
903
+ // after the user's answer; default to undefined here so the skill knows
904
+ // to wait, then the installer mutates this block on invite completion.
905
+ onboarding: {
906
+ phase: "orient",
907
+ started_at: new Date().toISOString(),
908
+ installer_captured: true,
909
+ role: "founder",
910
+ member_role: memberRole,
911
+ },
912
+ profile_fields_collected: ["name", "role"],
582
913
  };
583
914
  fs.writeFileSync(path.join(egregoreDir, ".egregore-state.json"), JSON.stringify(state, null, 2) + "\n");
584
915
 
@@ -627,14 +958,73 @@ async function localFounderFlow(ui, ctx = {}) {
627
958
  }
628
959
  }
629
960
 
630
- // 17. Register instance + shell alias
961
+ const codexRuntime = installCodexRuntime(egregoreDir);
962
+ reportCodexRuntimeSummary(codexRuntime, ui);
963
+
964
+ // 16b. Commit the Codex runtime into the repo so it's TRACKED — symmetric
965
+ // with .claude/ (which ships in the template). An untracked overlay can
966
+ // be lost to later git operations, and joiners would never get it. This
967
+ // way every Egregore supports BOTH assistants, persistently and for the
968
+ // whole team: pick Claude Code or Codex, now or later, and it just works.
969
+ try {
970
+ const toCommit = Array.isArray(codexRuntime.files) ? [...codexRuntime.files] : [];
971
+ if (fs.existsSync(path.join(egregoreDir, ".agents", "skills"))) toCommit.push(".agents/skills");
972
+ if (toCommit.length) {
973
+ execFileSync("git", ["add", "--", ...toCommit], { cwd: egregoreDir, stdio: "pipe" });
974
+ run('git commit -m "Add Codex runtime so this Egregore supports both assistants"', { cwd: egregoreDir });
975
+ run("git push", { cwd: egregoreDir });
976
+ }
977
+ } catch {}
978
+
979
+ // 17. Register instance + install launcher binary
631
980
  ui.step(5 + selectedRepos.length, totalSteps, "Registering instance...");
632
- registerInstance(repoName, orgName, egregoreDir);
633
- const alias = await installShellAlias(egregoreDir, ui);
981
+ registerInstance(repoName, orgName, egregoreDir, preferredRuntime);
982
+ const alias = await installLauncherBinary(egregoreDir, ui);
983
+
984
+ const telegramResult = await maybeSetupTelegramGroup(ui, {
985
+ egregoreDir,
986
+ githubToken,
987
+ githubOrg,
988
+ repoName,
989
+ egreConfig,
990
+ statePath: path.join(egregoreDir, ".egregore-state.json"),
991
+ });
992
+
993
+ // 18. Optional invite — picker for org members, text fallback otherwise.
994
+ ui.section("Bring someone in", "Egregore shines when it's shared");
995
+
996
+ let invUsername = "";
997
+ if (isOrg) {
998
+ const memberSpin = ui.spinner("Loading org members...");
999
+ const members = await listOrgMembers(githubToken, githubOrg);
1000
+ memberSpin.stop(members.length > 0 ? `Loaded ${members.length} members` : "No visible members");
1001
+
1002
+ // Filter out the current user — they can't invite themselves.
1003
+ const pickable = members
1004
+ .filter((m) => m.login !== user.login)
1005
+ .slice(0, 20);
1006
+
1007
+ if (pickable.length > 0) {
1008
+ const choices = [
1009
+ { label: "Skip — work solo for now", description: "I can always /invite later" },
1010
+ ...pickable.map((m) => ({ label: m.login, description: `@${githubOrg} member` })),
1011
+ { label: "Type a username", description: "invite someone not in the list" },
1012
+ ];
1013
+ const picked = await ui.choose("who should we invite?", choices);
1014
+ if (picked.label === "Skip — work solo for now") {
1015
+ invUsername = "";
1016
+ } else if (picked.label === "Type a username") {
1017
+ invUsername = await ui.prompt("GitHub username");
1018
+ } else {
1019
+ invUsername = picked.label;
1020
+ }
1021
+ } else {
1022
+ invUsername = await ui.prompt("teammate's GitHub username (or Enter to skip)");
1023
+ }
1024
+ } else {
1025
+ invUsername = await ui.prompt("teammate's GitHub username (or Enter to skip)");
1026
+ }
634
1027
 
635
- // 18. Optional invite
636
- console.log("");
637
- const invUsername = await ui.prompt("Invite a teammate? Enter their GitHub username (or press Enter to skip):");
638
1028
  if (invUsername) {
639
1029
  const invSpin = ui.spinner(`Inviting ${invUsername}...`);
640
1030
  try {
@@ -659,36 +1049,106 @@ async function localFounderFlow(ui, ctx = {}) {
659
1049
  const invPersonFile = path.join(memoryDir, "people", `${invUsername}.md`);
660
1050
  if (!fs.existsSync(invPersonFile)) {
661
1051
  fs.writeFileSync(invPersonFile, invPersonContent);
662
- try {
663
- run("git add -A", { cwd: memoryDir });
664
- run(`git commit -m "Invite ${invUsername}"`, { cwd: memoryDir });
665
- run("git push", { cwd: memoryDir });
666
- } catch {}
667
1052
  }
1053
+ const fallbackCommand = joinCommand(githubOrg, repoName);
1054
+ const inviteArtifact = writeInviteArtifact(memoryDir, {
1055
+ inviter: user.login,
1056
+ invitee: invUsername,
1057
+ githubOrg,
1058
+ repoName,
1059
+ memoryRepo: `https://github.com/${githubOrg}/${memoryRepoName}.git`,
1060
+ managedRepos: selectedRepos,
1061
+ fallbackCommand,
1062
+ telegramGroupLink: egreConfig.telegram_group_link || "",
1063
+ delivery: "copyable_share_block",
1064
+ });
1065
+ commitMemoryInvite(memoryDir, [invPersonFile, inviteArtifact], invUsername);
668
1066
 
669
1067
  invSpin.stop(`Invited ${invUsername}`);
670
- console.log("");
671
- ui.info(`Tell them to run:`);
672
- ui.commandBox("command", `npx create-egregore@latest join ${githubOrg}/${repoName}`);
1068
+ ui.info("send this to them:");
1069
+ const shareLines = [fallbackCommand];
1070
+ if (egreConfig.telegram_group_link) {
1071
+ shareLines.push(`Telegram: ${egreConfig.telegram_group_link}`);
1072
+ }
1073
+ ui.card("share", shareLines);
673
1074
  } catch (err) {
674
1075
  invSpin.fail(`Could not invite ${invUsername}: ${err.message}`);
675
1076
  }
676
1077
  }
677
1078
 
678
- // 19. Done
679
- console.log("");
680
- ui.success(`Egregore is ready for ${ui.bold(orgName)}`);
681
- console.log("");
682
- ui.info("Your workspace:");
683
- ui.info(` ${ui.cyan(`./${repoName}/`)} — Your Egregore instance`);
684
- ui.info(` ${ui.cyan(`./${memoryRepoName}/`)} — Shared knowledge`);
685
- for (const entry of selectedRepos) {
686
- const mrName = typeof entry === "string" ? entry : entry.name;
687
- ui.info(` ${ui.cyan(`./${mrName}/`)} — Managed repo`);
1079
+ // 18b. Record invite outcome in state so the /onboarding skill knows to
1080
+ // skip the INVITE-offer step. See SKILL.md "Pre-captured check".
1081
+ {
1082
+ const statePath = path.join(egregoreDir, ".egregore-state.json");
1083
+ try {
1084
+ const cur = JSON.parse(fs.readFileSync(statePath, "utf-8"));
1085
+ cur.onboarding = cur.onboarding || {};
1086
+ cur.onboarding.invite_skipped = !invUsername;
1087
+ cur.onboarding.invite_handled_by = "installer";
1088
+ cur.onboarding.phase = "complete";
1089
+ cur.onboarding.completed_at = cur.onboarding.completed_at || new Date().toISOString();
1090
+ cur.onboarding.role = cur.onboarding.role || "founder";
1091
+ cur.onboarding_complete = true;
1092
+ cur.profile_fields_collected = Array.isArray(cur.profile_fields_collected)
1093
+ ? Array.from(new Set([...cur.profile_fields_collected, "name"]))
1094
+ : ["name"];
1095
+ fs.writeFileSync(statePath, JSON.stringify(cur, null, 2) + "\n");
1096
+ ensurePersonOnboardingWitness(memoryDir, cur);
1097
+ } catch {}
688
1098
  }
689
- console.log("");
690
- ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
691
- console.log("");
1099
+
1100
+ // 19. Done — summary screen (alt-screen) if we can, inline otherwise.
1101
+ // Either way it doubles as the Insight-rendering moment that lets
1102
+ // /onboarding skip ORIENT on first Claude session.
1103
+ const workspaces = [
1104
+ { label: `./${repoName}/`, description: "your Egregore instance" },
1105
+ { label: `./${memoryRepoName}/`, description: "shared knowledge" },
1106
+ ...selectedRepos.map((entry) => {
1107
+ const mrName = typeof entry === "string" ? entry : entry.name;
1108
+ return { label: `./${mrName}/`, description: "managed repo" };
1109
+ }),
1110
+ ];
1111
+ const assistantName = preferredRuntime === "codex" ? "Codex" : "Claude Code";
1112
+ const setupSummary = {
1113
+ workspace: "created",
1114
+ invite: invUsername ? `sent to ${invUsername}` : "skipped",
1115
+ telegram: telegramResult.groupLink
1116
+ ? "connected"
1117
+ : telegramResult.setup
1118
+ ? "bot added; no join link saved"
1119
+ : "skipped",
1120
+ assistant: assistantName,
1121
+ command: "run egregore anytime",
1122
+ };
1123
+
1124
+ if (!ctx.noScreens && ui.canShowScreens()) {
1125
+ const result = await ui.summaryScreen({ orgName, workspaces, setup: setupSummary });
1126
+ if (result === "escape") {
1127
+ // User pressed esc: skip auto-launch, just return to shell.
1128
+ ctx.noAutolaunch = true;
1129
+ }
1130
+ } else {
1131
+ ui.section("You're done", `Egregore is ready for ${ui.bold(orgName)}`);
1132
+ ui.info(`${ui.green("●")} workspace ${setupSummary.workspace}`);
1133
+ ui.info(`${ui.green("●")} invite ${setupSummary.invite}`);
1134
+ ui.info(`${ui.green("●")} Telegram ${setupSummary.telegram}`);
1135
+ ui.info(`${ui.green("●")} assistant ${setupSummary.assistant}`);
1136
+ ui.info(`${ui.green("●")} next ${setupSummary.command}`);
1137
+ console.log("");
1138
+ for (const ws of workspaces) {
1139
+ ui.info(`${ui.green("●")} ${ui.cyan(ws.label)} ${ui.dim(ws.description)}`);
1140
+ }
1141
+ ui.note([
1142
+ "Every session leaves traces — decisions, patterns, context.",
1143
+ "Egregore makes those traces persistent and shared.",
1144
+ "",
1145
+ "You do your work. When you're done, you hand off what you",
1146
+ "learned. The next session — yours or someone else's — starts",
1147
+ "smarter.",
1148
+ ]);
1149
+ }
1150
+
1151
+ await launchOrInstruct(egregoreDir, alias, ui, ctx);
692
1152
  }
693
1153
 
694
1154
  // ── Flow 2: Join (local mode) ──────────────────────────────────────
@@ -716,22 +1176,43 @@ function parseJoinTarget(arg) {
716
1176
  return { orgLogin: parts[0], explicitRepo: parts[1] || null };
717
1177
  }
718
1178
 
1179
+ function normalizeMemoryUrl(memoryRepo, owner, fallbackName) {
1180
+ if (memoryRepo && /^https?:\/\//i.test(memoryRepo)) return memoryRepo;
1181
+ if (memoryRepo && /^git@/i.test(memoryRepo)) return memoryRepo;
1182
+ const repoName = memoryRepo || fallbackName;
1183
+ return `https://github.com/${owner}/${repoName}.git`;
1184
+ }
1185
+
719
1186
  async function localJoinFlow(orgArg, ui, ctx = {}) {
720
1187
  const { orgLogin, explicitRepo } = parseJoinTarget(orgArg);
721
1188
  if (!orgLogin) {
722
- ui.error("Usage: npx create-egregore join <org>[/<repo>]");
1189
+ ui.error("Usage: npx -y create-egregore@latest join <org>[/<repo>]");
723
1190
  process.exit(1);
724
1191
  }
725
1192
 
726
- // 1. GitHub auth — OAuth with repo:invite scope (narrow: just invitation acceptance)
727
- ui.info(`Joining ${ui.bold(orgLogin)}. First, sign in with GitHub.\n`);
1193
+ // 1. GitHub auth — prefer gh CLI. If gh is installed but logged out, run
1194
+ // gh auth login. Only no-gh machines use the device fallback.
1195
+ ui.info(`Joining ${ui.bold(orgLogin)}.`);
728
1196
  let githubToken;
729
- try {
730
- githubToken = await deviceFlow(ui, { mode: "join" });
731
- ui.success("Authenticated with GitHub");
732
- } catch (err) {
733
- ui.error(`GitHub auth failed: ${err.message}`);
1197
+ const ghAuth = tryGhAuthOrLogin(ui, { mode: "join" });
1198
+ if (ghAuth) {
1199
+ ui.success(`Using your ${ui.bold("gh")} CLI auth${ghAuth.user ? ` (${ghAuth.user})` : ""}`);
1200
+ githubToken = ghAuth.token;
1201
+ } else if (ghCliExists()) {
1202
+ ui.error("GitHub CLI auth did not complete.");
1203
+ ui.info(`Run ${ui.bold("gh auth login")} and then re-run this join command.`);
734
1204
  process.exit(1);
1205
+ } else {
1206
+ ui.note(ghFallbackNote(ui));
1207
+ const cmd = ghInstallShellCommand();
1208
+ if (cmd) {
1209
+ const ok = await ui.confirm(`Install GitHub CLI now and sign in? (${ui.bold(cmd)})`, true);
1210
+ if (ok) githubToken = runGhInstallAndLogin(ui, cmd);
1211
+ }
1212
+ if (!githubToken) {
1213
+ ui.info("No problem — run the steps above when ready, then re-run this.");
1214
+ process.exit(1);
1215
+ }
735
1216
  }
736
1217
 
737
1218
  // 2. Get user info
@@ -743,111 +1224,70 @@ async function localJoinFlow(orgArg, ui, ctx = {}) {
743
1224
  await acceptPendingInvitations(githubToken, orgLogin, ui);
744
1225
  s1.stop("Invitations accepted");
745
1226
 
746
- // 4. Determine repo name
1227
+ // 4. Resolve egregore.json before cloning. The shared install() helper owns
1228
+ // all local filesystem setup after this point.
747
1229
  const repoName = explicitRepo || "egregore";
748
-
749
- // 5. Clone repos using git credentials (SSH keys / gh CLI / credential helper)
750
- // The OAuth token only has repo:invite scope — cloning uses the joiner's own git setup.
751
- const base = ctx.targetDir || process.cwd();
752
- const egregoreDir = path.join(base, repoName);
753
-
754
- console.log("");
755
- const s2 = ui.spinner(`Cloning ${orgLogin}/${repoName}...`);
756
- if (cloneWithFallback(`https://github.com/${orgLogin}/${repoName}.git`, egregoreDir)) {
757
- s2.stop(`Cloned ${repoName}`);
758
- } else {
759
- s2.fail(`Could not clone ${orgLogin}/${repoName}`);
760
- ui.error("Make sure you have git credentials set up (SSH keys or gh auth login).");
761
- ui.error("Also verify the org admin has set up Egregore and invited you.");
762
- process.exit(1);
763
- }
764
- setGitIdentity(egregoreDir, user);
765
- ensureDevelop(egregoreDir);
766
-
767
- // 6. Read egregore.json from local clone
768
- const configPath = path.join(egregoreDir, "egregore.json");
769
- if (!fs.existsSync(configPath)) {
770
- ui.error("The repo has no egregore.json. Ask the admin to set up first.");
771
- process.exit(1);
772
- }
1230
+ const s2 = ui.spinner(`Reading ${orgLogin}/${repoName}/egregore.json...`);
773
1231
  let config;
774
1232
  try {
775
- config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
776
- } catch {
777
- ui.error("Invalid egregore.json");
1233
+ const raw = await getFileContent(githubToken, orgLogin, repoName, "egregore.json");
1234
+ if (!raw) throw new Error("missing");
1235
+ config = JSON.parse(raw);
1236
+ s2.stop("Resolved Egregore config");
1237
+ } catch (err) {
1238
+ s2.fail("Could not read egregore.json");
1239
+ ui.error(`Make sure ${orgLogin}/${repoName} exists and you accepted the GitHub invitation.`);
1240
+ ui.info(`Ask the admin to resend: ${joinCommand(orgLogin, repoName)}`);
778
1241
  process.exit(1);
779
1242
  }
780
1243
  ui.success(`Found ${ui.bold(config.org_name || orgLogin)}`);
781
1244
 
782
1245
  const orgName = config.org_name || orgLogin;
783
- const memoryRepoName = config.memory_repo
784
- ? config.memory_repo.split("/").pop().replace(/\.git$/, "")
785
- : `${orgLogin}-memory`;
786
1246
  const managedRepos = config.repos || [];
787
-
788
- // 7. Clone memory + managed repos
789
- const memoryUrl = config.memory_repo || `https://github.com/${orgLogin}/${memoryRepoName}.git`;
790
- const memoryDir = path.join(base, memoryRepoName);
791
-
792
- const totalSteps = 4 + managedRepos.length;
793
- // Store the invite token for later use (invitation acceptance, not cloning)
794
- const inviteToken = githubToken;
795
-
796
- // Clone memory using SSH-first fallback (same as main repo)
797
- ui.step(1, totalSteps, "Cloning shared memory...");
798
- if (cloneWithFallback(memoryUrl, memoryDir)) {
799
- ui.success("Cloned memory");
800
- } else {
801
- ui.warn("Could not clone memory repo — you may not have access yet.");
1247
+ const skipRepos = new Set();
1248
+ if (ctx.prefilledRepo && Array.isArray(managedRepos)) {
1249
+ for (const entry of managedRepos) {
1250
+ const name = repoEntryName(entry);
1251
+ if (name && name === ctx.prefilledRepo.name) skipRepos.add(name);
1252
+ }
802
1253
  }
803
- setGitIdentity(memoryDir, user);
804
-
805
- // Symlink
806
- ui.step(2, totalSteps, "Linking memory...");
807
- createSymlink(egregoreDir, memoryDir, ui);
808
-
809
- // Write .env — store invite token for session-start invitation checks
810
- ui.step(3, totalSteps, "Writing credentials...");
811
- fs.writeFileSync(path.join(egregoreDir, ".env"), `GITHUB_TOKEN=${inviteToken}\n`, { mode: 0o600 });
812
- ui.success("Credentials saved");
813
-
814
- // "What should we call you?" + write state
815
- console.log("");
816
- const displayName = await ui.prompt(`What should we call you? [${user.login}]:`);
817
-
818
- const state = {
1254
+ const memoryUrl = normalizeMemoryUrl(config.memory_repo, orgLogin, `${repoName}-memory`);
1255
+ const installData = {
1256
+ fork_url: `https://github.com/${orgLogin}/${repoName}.git`,
1257
+ memory_url: memoryUrl,
1258
+ github_token: githubToken,
1259
+ org_name: orgName,
1260
+ github_org: orgLogin,
819
1261
  github_username: user.login,
820
1262
  github_name: user.name,
821
- display_name: displayName || user.login,
822
- email: user.email,
823
- onboarding_complete: false,
1263
+ github_email: user.email,
1264
+ slug: config.slug || orgLogin,
1265
+ repos: managedRepos,
1266
+ repo_name: repoName,
1267
+ telegram_group_link: config.telegram_group_link,
824
1268
  usage_type: "joiner_group",
825
- org_setup: true,
826
- github_configured: true,
827
- workspace_ready: true,
1269
+ onboarding_role: "joiner",
1270
+ invite_handled_by: "installer",
828
1271
  };
829
- fs.writeFileSync(path.join(egregoreDir, ".egregore-state.json"), JSON.stringify(state, null, 2) + "\n");
830
-
831
- // Register + alias
832
- ui.step(4, totalSteps, "Registering instance...");
833
- registerInstance(repoName, orgName, egregoreDir);
834
- const alias = await installShellAlias(egregoreDir, ui);
835
1272
 
836
- // Clone managed repos using SSH-first fallback
837
- for (let i = 0; i < managedRepos.length; i++) {
838
- const entry = managedRepos[i];
839
- const mrName = typeof entry === "string" ? entry : entry.name;
840
- ui.step(5 + i, totalSteps, `Cloning ${mrName}...`);
841
- const repoDir = path.join(base, mrName);
842
- if (cloneWithFallback(`https://github.com/${orgLogin}/${mrName}.git`, repoDir)) {
843
- setGitIdentity(repoDir, user);
844
- ui.success(`Cloned ${mrName}`);
845
- } else {
846
- ui.warn(`Could not clone ${mrName} — you may not have access yet.`);
847
- }
1273
+ try {
1274
+ await install(installData, ui, ctx.targetDir, {
1275
+ noAutolaunch: ctx.noAutolaunch,
1276
+ noScreens: ctx.noScreens,
1277
+ skipRepos,
1278
+ flowVariant: ctx.flowVariant,
1279
+ runtime: ctx.runtime,
1280
+ });
1281
+ } catch (err) {
1282
+ ui.error(`Install failed: ${err.message}`);
1283
+ process.exit(1);
848
1284
  }
849
1285
 
850
- // 12. Check for welcome note
1286
+ const base = ctx.targetDir || process.cwd();
1287
+ const egregoreDir = path.join(base, repoName);
1288
+ const memoryDir = path.join(base, memoryUrl.split("/").pop().replace(/\.git$/, ""));
1289
+
1290
+ // 5. Check for welcome note after install() has cloned and linked memory.
851
1291
  const welcomeFile = path.join(memoryDir, "people", `${user.login}.md`);
852
1292
  if (fs.existsSync(welcomeFile)) {
853
1293
  try {
@@ -863,27 +1303,12 @@ async function localJoinFlow(orgArg, ui, ctx = {}) {
863
1303
  } catch {}
864
1304
  }
865
1305
 
866
- // 13. Show Telegram group link if configured
1306
+ // 6. Show Telegram group link if configured
867
1307
  if (config.telegram_group_link) {
868
1308
  console.log("");
869
1309
  ui.info("Join the team's Telegram group:");
870
1310
  ui.info(` ${ui.cyan(config.telegram_group_link)}`);
871
1311
  }
872
-
873
- // 14. Done
874
- console.log("");
875
- ui.success(`Joined ${ui.bold(orgName)}`);
876
- console.log("");
877
- ui.info("Your workspace:");
878
- ui.info(` ${ui.cyan(`./${repoName}/`)} — Egregore instance`);
879
- ui.info(` ${ui.cyan(`./${memoryRepoName}/`)} — Shared knowledge`);
880
- for (const entry of managedRepos) {
881
- const mrName = typeof entry === "string" ? entry : entry.name;
882
- ui.info(` ${ui.cyan(`./${mrName}/`)} — Managed repo`);
883
- }
884
- console.log("");
885
- ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
886
- console.log("");
887
1312
  }
888
1313
 
889
- module.exports = { localFounderFlow, localJoinFlow, parseJoinTarget, addCollaborator, getUser, repoExists, createRepo, waitForRepo };
1314
+ module.exports = { localFounderFlow, localJoinFlow, parseJoinTarget, addCollaborator, getUser, repoExists, createRepo, waitForRepo, retryAsync, ghFallbackNote };