create-egregore 0.17.4 → 0.18.1

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 (106) hide show
  1. package/assets/egregore-launcher.js +1965 -0
  2. package/assets/egregore-launcher.sh +385 -0
  3. package/bin/cli.js +472 -81
  4. package/lib/api.js +2 -2
  5. package/lib/auth.js +255 -20
  6. package/lib/codex-runtime.js +268 -0
  7. package/lib/commands.js +31 -0
  8. package/lib/context.js +1 -1
  9. package/lib/local.js +616 -189
  10. package/lib/setup.js +822 -84
  11. package/lib/ui.js +780 -84
  12. package/package.json +12 -3
  13. package/runtime/codex/.codex/config.toml +15 -0
  14. package/runtime/codex/.codex/hooks/branch-guard.js +234 -0
  15. package/runtime/codex/.codex/hooks.json +17 -0
  16. package/runtime/codex/.codex/skills/activity/SKILL.md +70 -0
  17. package/runtime/codex/.codex/skills/add/SKILL.md +25 -0
  18. package/runtime/codex/.codex/skills/announce/SKILL.md +54 -0
  19. package/runtime/codex/.codex/skills/answer/SKILL.md +25 -0
  20. package/runtime/codex/.codex/skills/archive/SKILL.md +41 -0
  21. package/runtime/codex/.codex/skills/ask/SKILL.md +59 -0
  22. package/runtime/codex/.codex/skills/branch/SKILL.md +25 -0
  23. package/runtime/codex/.codex/skills/checkup/SKILL.md +41 -0
  24. package/runtime/codex/.codex/skills/commit/SKILL.md +25 -0
  25. package/runtime/codex/.codex/skills/contribute/SKILL.md +25 -0
  26. package/runtime/codex/.codex/skills/dark-mode/SKILL.md +25 -0
  27. package/runtime/codex/.codex/skills/dashboard/SKILL.md +59 -0
  28. package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +70 -0
  29. package/runtime/codex/.codex/skills/delete-user/SKILL.md +25 -0
  30. package/runtime/codex/.codex/skills/egregore-voice/SKILL.md +25 -0
  31. package/runtime/codex/.codex/skills/emissary/SKILL.md +41 -0
  32. package/runtime/codex/.codex/skills/env/SKILL.md +25 -0
  33. package/runtime/codex/.codex/skills/handoff/SKILL.md +67 -0
  34. package/runtime/codex/.codex/skills/harvest/SKILL.md +107 -0
  35. package/runtime/codex/.codex/skills/infra/SKILL.md +41 -0
  36. package/runtime/codex/.codex/skills/ingest/SKILL.md +25 -0
  37. package/runtime/codex/.codex/skills/ingest-google/SKILL.md +25 -0
  38. package/runtime/codex/.codex/skills/ingest-user-interview/SKILL.md +41 -0
  39. package/runtime/codex/.codex/skills/invite/SKILL.md +40 -0
  40. package/runtime/codex/.codex/skills/issue/SKILL.md +41 -0
  41. package/runtime/codex/.codex/skills/me/SKILL.md +25 -0
  42. package/runtime/codex/.codex/skills/meeting/SKILL.md +41 -0
  43. package/runtime/codex/.codex/skills/note/SKILL.md +25 -0
  44. package/runtime/codex/.codex/skills/onboarding/SKILL.md +41 -0
  45. package/runtime/codex/.codex/skills/pr/SKILL.md +25 -0
  46. package/runtime/codex/.codex/skills/project/SKILL.md +41 -0
  47. package/runtime/codex/.codex/skills/pull/SKILL.md +25 -0
  48. package/runtime/codex/.codex/skills/push/SKILL.md +25 -0
  49. package/runtime/codex/.codex/skills/quest/SKILL.md +77 -0
  50. package/runtime/codex/.codex/skills/quest-suggest/SKILL.md +25 -0
  51. package/runtime/codex/.codex/skills/reflect/SKILL.md +41 -0
  52. package/runtime/codex/.codex/skills/review-pr/SKILL.md +41 -0
  53. package/runtime/codex/.codex/skills/save/SKILL.md +62 -0
  54. package/runtime/codex/.codex/skills/setup/SKILL.md +25 -0
  55. package/runtime/codex/.codex/skills/summon/SKILL.md +41 -0
  56. package/runtime/codex/.codex/skills/sync-repos/SKILL.md +25 -0
  57. package/runtime/codex/.codex/skills/telegram-connect/SKILL.md +25 -0
  58. package/runtime/codex/.codex/skills/telemetry/SKILL.md +25 -0
  59. package/runtime/codex/.codex/skills/test/SKILL.md +41 -0
  60. package/runtime/codex/.codex/skills/the-spiral/SKILL.md +84 -0
  61. package/runtime/codex/.codex/skills/todo/SKILL.md +41 -0
  62. package/runtime/codex/.codex/skills/tui-design/SKILL.md +41 -0
  63. package/runtime/codex/.codex/skills/tutorial/SKILL.md +41 -0
  64. package/runtime/codex/.codex/skills/update/SKILL.md +25 -0
  65. package/runtime/codex/.codex/skills/view/SKILL.md +41 -0
  66. package/runtime/codex/.codex/skills/visual-explain/SKILL.md +41 -0
  67. package/runtime/codex/.codex/skills/wrap/SKILL.md +55 -0
  68. package/runtime/codex/.codex/spec-manifest.json +108 -0
  69. package/runtime/codex/AGENTS.md +40 -0
  70. package/runtime/codex/bin/activity-data.sh +177 -0
  71. package/runtime/codex/bin/agent.sh +669 -0
  72. package/runtime/codex/bin/codex-install-skills.sh +78 -0
  73. package/runtime/codex/bin/codex-render-spec.mjs +308 -0
  74. package/runtime/codex/bin/codex-session-start.sh +238 -0
  75. package/runtime/codex/bin/codex-skill-render.mjs +315 -0
  76. package/runtime/codex/bin/codex-sync-skills.sh +223 -0
  77. package/runtime/codex/bin/dashboard-data.sh +174 -0
  78. package/runtime/codex/bin/graph-batch.sh +60 -0
  79. package/runtime/codex/bin/graph-op.sh +380 -0
  80. package/runtime/codex/bin/graph-wal.sh +228 -0
  81. package/runtime/codex/bin/graph.sh +141 -0
  82. package/runtime/codex/bin/handoff-pr-backfill.sh +86 -0
  83. package/runtime/codex/bin/handoff-run.sh +431 -0
  84. package/runtime/codex/bin/handoff-save-egregore.sh +131 -0
  85. package/runtime/codex/bin/index-handoff.sh +306 -0
  86. package/runtime/codex/bin/lib/artifact-id.sh +46 -0
  87. package/runtime/codex/bin/lib/config.sh +71 -0
  88. package/runtime/codex/bin/lib/context.sh +463 -0
  89. package/runtime/codex/bin/lib/dashboard-artifact.sh +142 -0
  90. package/runtime/codex/bin/lib/ensure-develop.sh +183 -0
  91. package/runtime/codex/bin/lib/git-sync.sh +262 -0
  92. package/runtime/codex/bin/lib/greeting.sh +414 -0
  93. package/runtime/codex/bin/lib/hash.sh +13 -0
  94. package/runtime/codex/bin/lib/identity.sh +164 -0
  95. package/runtime/codex/bin/lib/permissions.sh +106 -0
  96. package/runtime/codex/bin/lib/time.sh +56 -0
  97. package/runtime/codex/bin/lib/worktree-links.sh +54 -0
  98. package/runtime/codex/bin/notify.sh +207 -0
  99. package/runtime/codex/bin/publish-artifact.sh +173 -0
  100. package/runtime/codex/bin/publish-references.sh +144 -0
  101. package/runtime/codex/bin/repo-state.sh +99 -0
  102. package/runtime/codex/bin/telemetry.sh +350 -0
  103. package/runtime/codex/bin/worktree-create.sh +118 -0
  104. package/runtime/codex/bin/worktree-remove.sh +50 -0
  105. package/runtime/codex/bin/worktree.sh +198 -0
  106. package/runtime/codex/manifest.json +155 -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;
@@ -116,9 +144,19 @@ async function putFileContent(token, owner, repo, filePath, content, message) {
116
144
 
117
145
  async function listOrgRepos(token, owner, isOrg) {
118
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.
119
157
  const baseUrl = isOrg
120
158
  ? `/orgs/${owner}/repos?per_page=100&sort=updated&type=all`
121
- : `/users/${owner}/repos?per_page=100&sort=updated&type=owner`;
159
+ : `/user/repos?per_page=100&sort=updated&affiliation=owner`;
122
160
 
123
161
  let allRepos = [];
124
162
  let page = 1;
@@ -131,6 +169,14 @@ async function listOrgRepos(token, owner, isOrg) {
131
169
  page++;
132
170
  }
133
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
+
134
180
  return allRepos
135
181
  .filter((r) => !r.archived && r.name !== "egregore" && !r.name.endsWith("-memory"))
136
182
  .map((r) => ({ name: r.name, language: r.language || "", description: r.description || "" }));
@@ -176,6 +222,7 @@ function cloneRepo(url, dir, token, ui, label) {
176
222
  stdio: "pipe",
177
223
  encoding: "utf-8",
178
224
  timeout: 60000,
225
+ env: gitCloneEnv(),
179
226
  });
180
227
  try {
181
228
  run(`git remote set-url origin ${url}`, { cwd: dir });
@@ -201,7 +248,7 @@ function toSSH(url) {
201
248
  * GIT_TERMINAL_PROMPT=0 prevents the hanging username/password prompt.
202
249
  */
203
250
  function cloneWithFallback(httpsUrl, dir, timeout = 60000) {
204
- const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
251
+ const env = gitCloneEnv();
205
252
  const sshUrl = toSSH(httpsUrl);
206
253
 
207
254
  // Try SSH first (most devs have SSH keys; GitHub no longer supports HTTPS password auth)
@@ -243,52 +290,263 @@ function createSymlink(egregoreDir, memoryDir, ui) {
243
290
  ui.success("Linked");
244
291
  }
245
292
 
246
- // GitHub App installation URL — grants Egregore access to repos the user selects
247
- 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
+ }
248
481
 
249
482
  // ── Flow 1: Founder (local mode) ───────────────────────────────────
250
483
 
251
484
  async function localFounderFlow(ui, ctx = {}) {
485
+ const preferredRuntime = normalizeRuntime(ctx.runtime);
252
486
  // Names of repos already on disk as the user's own workdir — skip clone/pull.
253
487
  const skipRepos = ctx.skipRepos instanceof Set ? new Set(ctx.skipRepos) : new Set();
254
- // 1. Install GitHub App — user picks which repos/accounts to grant access to
255
- ui.info("Let's set up Egregore.\n");
256
- ui.info("First, install the Egregore app on your GitHub account.");
257
- ui.info("You'll choose exactly which repos to share.\n");
258
- openBrowser(APP_INSTALL_URL);
259
- await ui.prompt("Press Enter when done...");
260
-
261
- // 2. GitHub auth (auto-approved after App install)
262
- 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
+
263
498
  let githubToken;
264
- try {
265
- githubToken = await deviceFlow(ui);
266
- ui.success("Authenticated with GitHub");
267
- } catch (err) {
268
- 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.`);
269
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
+ }
270
523
  }
271
524
 
272
525
  // 2. Get user info
273
526
  const user = await getUser(githubToken);
274
527
 
275
- // 3. Where — always show picker (personal first)
276
- 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?");
277
531
  const s = ui.spinner("Checking your GitHub accounts...");
278
532
  const orgs = await listOrgs(githubToken);
279
- s.stop("");
533
+ s.stop("Accounts loaded");
280
534
 
281
535
  let githubOrg, isOrg;
282
536
  if (orgs.length === 0) {
283
537
  githubOrg = user.login;
284
538
  isOrg = false;
285
- ui.info(`Using personal account: ${ui.bold(githubOrg)}`);
539
+ ui.info(`Using your personal account: ${ui.bold(githubOrg)}`);
286
540
  } else {
287
541
  const orgChoices = [{ label: user.login, description: "Personal account" }];
288
542
  for (const o of orgs) {
289
543
  orgChoices.push({ label: o.login, description: "Organization" });
290
544
  }
291
- 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
+ );
292
550
  githubOrg = orgChoice.label;
293
551
  isOrg = githubOrg !== user.login;
294
552
  }
@@ -300,8 +558,7 @@ async function localFounderFlow(ui, ctx = {}) {
300
558
  let isNewProject;
301
559
  if (ctx.prefilledRepo) {
302
560
  isNewProject = false;
303
- console.log("");
304
- 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.`);
305
562
  } else {
306
563
  const action = await ui.choose("What are you working on?", [
307
564
  { label: "Starting something new", description: "Create a project from scratch" },
@@ -311,28 +568,55 @@ async function localFounderFlow(ui, ctx = {}) {
311
568
  }
312
569
 
313
570
  // 5. Collect project info
571
+ ui.section("Name it", "what's this Egregore called?");
314
572
  let orgName, description, selectedRepos = [], newProjectRepo = null;
315
573
 
316
574
  if (isNewProject) {
317
- const nameInput = await ui.prompt("Name your Egregore (your team or group name):");
318
- orgName = nameInput || githubOrg;
319
-
320
- const descInput = await ui.prompt("What are you building? (one line)");
321
- 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`;
322
587
 
323
588
  // Ask about project repo
324
- console.log("");
325
- ui.info("Your Egregore is set up. It has its own repo for memory and config.");
326
- ui.info("If you also have a code project to work on, we can create a repo for it.");
327
- console.log("");
328
- const projectName = await ui.prompt("Code project repo name (Enter to skip):");
329
- if (projectName) {
330
- 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;
331
616
  }
332
617
  } else {
333
618
  // "Existing project"
334
- const nameInput = await ui.prompt(`What should we call this Egregore? [${githubOrg}]:`);
335
- orgName = nameInput || githubOrg;
619
+ orgName = await ui.prompt("what should we call this Egregore?", { default: githubOrg });
336
620
  description = "";
337
621
 
338
622
  // Auto-adopt the user's current repo if it's under the chosen GitHub owner.
@@ -412,7 +696,7 @@ async function localFounderFlow(ui, ctx = {}) {
412
696
  s1.stop(`${githubOrg}/${repoName} already has Egregore set up`);
413
697
  console.log("");
414
698
  ui.info("To join it:");
415
- ui.info(` ${ui.bold(`npx -y egregore-init@latest join ${githubOrg}/${repoName}`)}`);
699
+ ui.info(` ${ui.bold(joinCommand(githubOrg, repoName))}`);
416
700
  console.log("");
417
701
  ui.info("To set up a different one, choose a different team name.");
418
702
  process.exit(0);
@@ -501,8 +785,11 @@ async function localFounderFlow(ui, ctx = {}) {
501
785
  report_url: "https://xgksfrirtdumacvmfkzj.supabase.co",
502
786
  report_key: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inhna3NmcmlydGR1bWFjdm1ma3pqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5OTA1ODksImV4cCI6MjA4NjU2NjU4OX0.1UWNgER6nQazrayly1KDrswClURUkGKxeirZg5L8uk4",
503
787
  };
504
- try {
505
- 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(
506
793
  githubToken,
507
794
  githubOrg,
508
795
  repoName,
@@ -510,19 +797,28 @@ async function localFounderFlow(ui, ctx = {}) {
510
797
  JSON.stringify(egreConfig, null, 2) + "\n",
511
798
  `Configure egregore for ${orgName}`,
512
799
  );
513
- } 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
+ }
514
806
 
515
807
  // 8b. Register org with Egregore API (non-blocking — local mode works without it)
516
808
  // Respects DO_NOT_TRACK (https://consult.gov.uk/digital-communications/online-advertising/supporting_documents/DNT%20standard%20v10.pdf)
517
809
  if (!process.env.DO_NOT_TRACK && !process.env.EGREGORE_NO_TELEMETRY) {
518
810
  try {
519
811
  const api = new EgregoreAPI();
520
- 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
+ });
521
817
  } catch {}
522
818
  }
523
819
 
524
820
  // 9. Init memory dirs on remote (so joiners get the structure)
525
- 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"];
526
822
  for (const d of memDirs) {
527
823
  try {
528
824
  await putFileContent(githubToken, githubOrg, memoryRepoName, `${d}/.gitkeep`, "", `Initialize ${d}`);
@@ -539,10 +835,23 @@ async function localFounderFlow(ui, ctx = {}) {
539
835
  const totalSteps = 6 + selectedRepos.length;
540
836
  configureGitCredentials(githubToken);
541
837
 
838
+ ui.section("Install", "cloning repos, linking memory, wiring credentials");
839
+
542
840
  ui.step(1, totalSteps, "Cloning egregore...");
543
841
  cloneRepo(forkUrl, egregoreDir, githubToken, ui, "egregore");
544
842
  setGitIdentity(egregoreDir, user);
545
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
+ }
546
855
 
547
856
  ui.step(2, totalSteps, "Cloning shared memory...");
548
857
  cloneRepo(memoryUrl, memoryDir, githubToken, ui, "memory");
@@ -564,19 +873,43 @@ async function localFounderFlow(ui, ctx = {}) {
564
873
  fs.writeFileSync(path.join(egregoreDir, "egregore.json"), JSON.stringify(egreConfig, null, 2) + "\n");
565
874
 
566
875
  // 14. "What should we call you?" + write state
567
- console.log("");
568
- 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
+ );
569
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, "-");
570
889
  const state = {
571
890
  github_username: user.login,
572
891
  github_name: user.name,
573
892
  display_name: displayName || user.login,
893
+ name: slugifiedName,
894
+ member_role: memberRole,
574
895
  email: user.email,
575
896
  onboarding_complete: false,
576
897
  usage_type: "founder_group",
577
898
  org_setup: true,
578
899
  github_configured: true,
579
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"],
580
913
  };
581
914
  fs.writeFileSync(path.join(egregoreDir, ".egregore-state.json"), JSON.stringify(state, null, 2) + "\n");
582
915
 
@@ -625,14 +958,73 @@ async function localFounderFlow(ui, ctx = {}) {
625
958
  }
626
959
  }
627
960
 
628
- // 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
629
980
  ui.step(5 + selectedRepos.length, totalSteps, "Registering instance...");
630
- registerInstance(repoName, orgName, egregoreDir);
631
- 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
+ }
632
1027
 
633
- // 18. Optional invite
634
- console.log("");
635
- const invUsername = await ui.prompt("Invite a teammate? Enter their GitHub username (or press Enter to skip):");
636
1028
  if (invUsername) {
637
1029
  const invSpin = ui.spinner(`Inviting ${invUsername}...`);
638
1030
  try {
@@ -657,36 +1049,106 @@ async function localFounderFlow(ui, ctx = {}) {
657
1049
  const invPersonFile = path.join(memoryDir, "people", `${invUsername}.md`);
658
1050
  if (!fs.existsSync(invPersonFile)) {
659
1051
  fs.writeFileSync(invPersonFile, invPersonContent);
660
- try {
661
- run("git add -A", { cwd: memoryDir });
662
- run(`git commit -m "Invite ${invUsername}"`, { cwd: memoryDir });
663
- run("git push", { cwd: memoryDir });
664
- } catch {}
665
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);
666
1066
 
667
1067
  invSpin.stop(`Invited ${invUsername}`);
668
- console.log("");
669
- ui.info(`Tell them to run:`);
670
- ui.commandBox("command", `npx -y egregore-init@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);
671
1074
  } catch (err) {
672
1075
  invSpin.fail(`Could not invite ${invUsername}: ${err.message}`);
673
1076
  }
674
1077
  }
675
1078
 
676
- // 19. Done
677
- console.log("");
678
- ui.success(`Egregore is ready for ${ui.bold(orgName)}`);
679
- console.log("");
680
- ui.info("Your workspace:");
681
- ui.info(` ${ui.cyan(`./${repoName}/`)} — Your Egregore instance`);
682
- ui.info(` ${ui.cyan(`./${memoryRepoName}/`)} — Shared knowledge`);
683
- for (const entry of selectedRepos) {
684
- const mrName = typeof entry === "string" ? entry : entry.name;
685
- 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 {}
686
1098
  }
687
- console.log("");
688
- ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
689
- 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);
690
1152
  }
691
1153
 
692
1154
  // ── Flow 2: Join (local mode) ──────────────────────────────────────
@@ -714,22 +1176,43 @@ function parseJoinTarget(arg) {
714
1176
  return { orgLogin: parts[0], explicitRepo: parts[1] || null };
715
1177
  }
716
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
+
717
1186
  async function localJoinFlow(orgArg, ui, ctx = {}) {
718
1187
  const { orgLogin, explicitRepo } = parseJoinTarget(orgArg);
719
1188
  if (!orgLogin) {
720
- ui.error("Usage: npx create-egregore join <org>[/<repo>]");
1189
+ ui.error("Usage: npx -y create-egregore@latest join <org>[/<repo>]");
721
1190
  process.exit(1);
722
1191
  }
723
1192
 
724
- // 1. GitHub auth — OAuth with repo:invite scope (narrow: just invitation acceptance)
725
- 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)}.`);
726
1196
  let githubToken;
727
- try {
728
- githubToken = await deviceFlow(ui, { mode: "join" });
729
- ui.success("Authenticated with GitHub");
730
- } catch (err) {
731
- 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.`);
732
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
+ }
733
1216
  }
734
1217
 
735
1218
  // 2. Get user info
@@ -741,111 +1224,70 @@ async function localJoinFlow(orgArg, ui, ctx = {}) {
741
1224
  await acceptPendingInvitations(githubToken, orgLogin, ui);
742
1225
  s1.stop("Invitations accepted");
743
1226
 
744
- // 4. Determine repo name
1227
+ // 4. Resolve egregore.json before cloning. The shared install() helper owns
1228
+ // all local filesystem setup after this point.
745
1229
  const repoName = explicitRepo || "egregore";
746
-
747
- // 5. Clone repos using git credentials (SSH keys / gh CLI / credential helper)
748
- // The OAuth token only has repo:invite scope — cloning uses the joiner's own git setup.
749
- const base = ctx.targetDir || process.cwd();
750
- const egregoreDir = path.join(base, repoName);
751
-
752
- console.log("");
753
- const s2 = ui.spinner(`Cloning ${orgLogin}/${repoName}...`);
754
- if (cloneWithFallback(`https://github.com/${orgLogin}/${repoName}.git`, egregoreDir)) {
755
- s2.stop(`Cloned ${repoName}`);
756
- } else {
757
- s2.fail(`Could not clone ${orgLogin}/${repoName}`);
758
- ui.error("Make sure you have git credentials set up (SSH keys or gh auth login).");
759
- ui.error("Also verify the org admin has set up Egregore and invited you.");
760
- process.exit(1);
761
- }
762
- setGitIdentity(egregoreDir, user);
763
- ensureDevelop(egregoreDir);
764
-
765
- // 6. Read egregore.json from local clone
766
- const configPath = path.join(egregoreDir, "egregore.json");
767
- if (!fs.existsSync(configPath)) {
768
- ui.error("The repo has no egregore.json. Ask the admin to set up first.");
769
- process.exit(1);
770
- }
1230
+ const s2 = ui.spinner(`Reading ${orgLogin}/${repoName}/egregore.json...`);
771
1231
  let config;
772
1232
  try {
773
- config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
774
- } catch {
775
- 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)}`);
776
1241
  process.exit(1);
777
1242
  }
778
1243
  ui.success(`Found ${ui.bold(config.org_name || orgLogin)}`);
779
1244
 
780
1245
  const orgName = config.org_name || orgLogin;
781
- const memoryRepoName = config.memory_repo
782
- ? config.memory_repo.split("/").pop().replace(/\.git$/, "")
783
- : `${orgLogin}-memory`;
784
1246
  const managedRepos = config.repos || [];
785
-
786
- // 7. Clone memory + managed repos
787
- const memoryUrl = config.memory_repo || `https://github.com/${orgLogin}/${memoryRepoName}.git`;
788
- const memoryDir = path.join(base, memoryRepoName);
789
-
790
- const totalSteps = 4 + managedRepos.length;
791
- // Store the invite token for later use (invitation acceptance, not cloning)
792
- const inviteToken = githubToken;
793
-
794
- // Clone memory using SSH-first fallback (same as main repo)
795
- ui.step(1, totalSteps, "Cloning shared memory...");
796
- if (cloneWithFallback(memoryUrl, memoryDir)) {
797
- ui.success("Cloned memory");
798
- } else {
799
- 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
+ }
800
1253
  }
801
- setGitIdentity(memoryDir, user);
802
-
803
- // Symlink
804
- ui.step(2, totalSteps, "Linking memory...");
805
- createSymlink(egregoreDir, memoryDir, ui);
806
-
807
- // Write .env — store invite token for session-start invitation checks
808
- ui.step(3, totalSteps, "Writing credentials...");
809
- fs.writeFileSync(path.join(egregoreDir, ".env"), `GITHUB_TOKEN=${inviteToken}\n`, { mode: 0o600 });
810
- ui.success("Credentials saved");
811
-
812
- // "What should we call you?" + write state
813
- console.log("");
814
- const displayName = await ui.prompt(`What should we call you? [${user.login}]:`);
815
-
816
- 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,
817
1261
  github_username: user.login,
818
1262
  github_name: user.name,
819
- display_name: displayName || user.login,
820
- email: user.email,
821
- 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,
822
1268
  usage_type: "joiner_group",
823
- org_setup: true,
824
- github_configured: true,
825
- workspace_ready: true,
1269
+ onboarding_role: "joiner",
1270
+ invite_handled_by: "installer",
826
1271
  };
827
- fs.writeFileSync(path.join(egregoreDir, ".egregore-state.json"), JSON.stringify(state, null, 2) + "\n");
828
-
829
- // Register + alias
830
- ui.step(4, totalSteps, "Registering instance...");
831
- registerInstance(repoName, orgName, egregoreDir);
832
- const alias = await installShellAlias(egregoreDir, ui);
833
1272
 
834
- // Clone managed repos using SSH-first fallback
835
- for (let i = 0; i < managedRepos.length; i++) {
836
- const entry = managedRepos[i];
837
- const mrName = typeof entry === "string" ? entry : entry.name;
838
- ui.step(5 + i, totalSteps, `Cloning ${mrName}...`);
839
- const repoDir = path.join(base, mrName);
840
- if (cloneWithFallback(`https://github.com/${orgLogin}/${mrName}.git`, repoDir)) {
841
- setGitIdentity(repoDir, user);
842
- ui.success(`Cloned ${mrName}`);
843
- } else {
844
- ui.warn(`Could not clone ${mrName} — you may not have access yet.`);
845
- }
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);
846
1284
  }
847
1285
 
848
- // 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.
849
1291
  const welcomeFile = path.join(memoryDir, "people", `${user.login}.md`);
850
1292
  if (fs.existsSync(welcomeFile)) {
851
1293
  try {
@@ -861,27 +1303,12 @@ async function localJoinFlow(orgArg, ui, ctx = {}) {
861
1303
  } catch {}
862
1304
  }
863
1305
 
864
- // 13. Show Telegram group link if configured
1306
+ // 6. Show Telegram group link if configured
865
1307
  if (config.telegram_group_link) {
866
1308
  console.log("");
867
1309
  ui.info("Join the team's Telegram group:");
868
1310
  ui.info(` ${ui.cyan(config.telegram_group_link)}`);
869
1311
  }
870
-
871
- // 14. Done
872
- console.log("");
873
- ui.success(`Joined ${ui.bold(orgName)}`);
874
- console.log("");
875
- ui.info("Your workspace:");
876
- ui.info(` ${ui.cyan(`./${repoName}/`)} — Egregore instance`);
877
- ui.info(` ${ui.cyan(`./${memoryRepoName}/`)} — Shared knowledge`);
878
- for (const entry of managedRepos) {
879
- const mrName = typeof entry === "string" ? entry : entry.name;
880
- ui.info(` ${ui.cyan(`./${mrName}/`)} — Managed repo`);
881
- }
882
- console.log("");
883
- ui.info(`Next: open a ${ui.bold("new terminal")} and type ${ui.bold(alias.aliasName)} to start.`);
884
- console.log("");
885
1312
  }
886
1313
 
887
- module.exports = { localFounderFlow, localJoinFlow, parseJoinTarget, addCollaborator, getUser, repoExists, createRepo, waitForRepo };
1314
+ module.exports = { localFounderFlow, localJoinFlow, parseJoinTarget, addCollaborator, getUser, repoExists, createRepo, waitForRepo, retryAsync, ghFallbackNote };