forgedock 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/forgedock.mjs CHANGED
@@ -1,40 +1,622 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import os from "os";
3
4
  import { fileURLToPath } from "url";
4
- import { dirname, join, relative } from "path";
5
- import { mkdir, symlink, readlink, lstat, readdir, stat } from "fs/promises";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
6
+ import {
7
+ mkdir,
8
+ symlink,
9
+ copyFile,
10
+ readlink,
11
+ lstat,
12
+ readdir,
13
+ stat,
14
+ writeFile,
15
+ unlink as fsUnlink,
16
+ } from "fs/promises";
6
17
  import {
7
18
  existsSync,
8
19
  appendFileSync,
20
+ chmodSync,
9
21
  readFileSync,
10
22
  writeFileSync,
11
23
  renameSync,
24
+ unlinkSync,
12
25
  } from "fs";
13
- import { execSync } from "child_process";
26
+ import { execSync, execFileSync } from "child_process";
27
+ import { createSign } from "crypto";
28
+ import {
29
+ BOLD,
30
+ GREEN,
31
+ YELLOW,
32
+ CYAN,
33
+ RED,
34
+ RESET,
35
+ bold,
36
+ dim,
37
+ green,
38
+ yellow,
39
+ cyan,
40
+ red,
41
+ box,
42
+ stepHeader,
43
+ select,
44
+ multiSelect,
45
+ confirm,
46
+ input,
47
+ createProgressBar,
48
+ spinner,
49
+ } from "./tui.mjs";
14
50
 
15
51
  const __filename = fileURLToPath(import.meta.url);
16
52
  const __dirname = dirname(__filename);
17
53
  const FORGE_HOME = dirname(__dirname);
18
54
  const COMMANDS_DIR = join(FORGE_HOME, "commands");
19
55
 
20
- if (!process.env.HOME) {
21
- console.error(
22
- "Error: HOME environment variable is not set. Cannot determine install location.",
23
- );
24
- process.exit(1);
25
- }
56
+ // Resolve home directory cross-platform: HOME (POSIX), USERPROFILE (Windows), os.homedir() fallback.
57
+ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
26
58
 
27
- const TARGET_DIR = join(process.env.HOME, ".claude", "commands");
59
+ const TARGET_DIR = join(HOME, ".claude", "commands");
28
60
 
29
61
  const args = process.argv.slice(2);
30
- const command = args[0] || "install";
62
+ const command = args[0];
63
+ const forceYes = args.includes("--yes") || args.includes("-y");
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Version — read dynamically from package.json
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function getVersion() {
70
+ try {
71
+ const pkg = JSON.parse(
72
+ readFileSync(join(FORGE_HOME, "package.json"), "utf-8"),
73
+ );
74
+ return pkg.version || "0.0.0";
75
+ } catch {
76
+ return "0.0.0";
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Install-mode marker — written to TARGET_DIR when copy-based install is used
82
+ // (Windows standard users cannot create symlinks without Developer Mode).
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Path to the install-mode marker file inside TARGET_DIR. */
86
+ const INSTALL_MODE_MARKER = join(TARGET_DIR, ".forgedock-install-mode");
87
+
88
+ /**
89
+ * Read the install-mode marker, returning its parsed content or null if absent/invalid.
90
+ * @returns {{ version: string, mode: string } | null}
91
+ */
92
+ function readInstallModeMarker() {
93
+ try {
94
+ return JSON.parse(readFileSync(INSTALL_MODE_MARKER, "utf-8"));
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Write (or overwrite) the install-mode marker with the current version.
102
+ * No-op on write failure — marker is best-effort.
103
+ */
104
+ async function writeInstallModeMarker() {
105
+ try {
106
+ await writeFile(
107
+ INSTALL_MODE_MARKER,
108
+ JSON.stringify({ version: getVersion(), mode: "copy" }),
109
+ "utf-8",
110
+ );
111
+ } catch {
112
+ // Best-effort — do not block install on marker write failure
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Remove the install-mode marker if it exists.
118
+ * No-op on failure.
119
+ */
120
+ async function removeInstallModeMarker() {
121
+ try {
122
+ await fsUnlink(INSTALL_MODE_MARKER);
123
+ } catch {
124
+ // Already absent — nothing to do
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Install state detection
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * @typedef {'fresh-install' | 'up-to-date' | 'update-available' | 'config-missing' | 'version-unknown'} InstallState
134
+ *
135
+ * @typedef {Object} InstallDetectionResult
136
+ * @property {InstallState} state
137
+ * @property {string | null} installedVersion - Version read from existing symlink target, or null
138
+ * @property {string} currentVersion - Version from the running package.json
139
+ */
140
+
141
+ /**
142
+ * Detect whether ForgeDock has been installed before and, if so, compare versions.
143
+ *
144
+ * Algorithm:
145
+ * 1. Check if TARGET_DIR exists and has at least one ForgeDock-managed symlink.
146
+ * 2. If symlinks exist, walk the symlink target to find the installed package.json
147
+ * and read its version.
148
+ * 3. Compare installed version vs. current (running) version.
149
+ * 4. If commands are installed but forge.yaml is absent → 'config-missing'.
150
+ *
151
+ * Always returns a safe result — never throws.
152
+ *
153
+ * @returns {Promise<InstallDetectionResult>}
154
+ */
155
+ async function detectInstallState() {
156
+ const currentVersion = getVersion();
157
+
158
+ // Safely probe TARGET_DIR for ForgeDock-managed symlinks
159
+ let installedVersion = null;
160
+ let hasSymlinks = false;
161
+
162
+ try {
163
+ const entries = await readdir(TARGET_DIR, { withFileTypes: true });
164
+ for (const entry of entries) {
165
+ if (!entry.name.endsWith(".md") && !entry.isDirectory()) continue;
166
+
167
+ const targetPath = join(TARGET_DIR, entry.name);
168
+ try {
169
+ const stats = await lstat(targetPath);
170
+ if (stats.isSymbolicLink()) {
171
+ const linkTarget = await readlink(targetPath);
172
+ // Only count symlinks that point into a ForgeDock commands directory
173
+ if (linkTarget.includes("commands") && linkTarget.endsWith(".md")) {
174
+ hasSymlinks = true;
175
+ // Derive the FORGE_HOME of the installed copy:
176
+ // linkTarget: /path/to/forge-home/commands/work-on.md
177
+ // dirname twice → /path/to/forge-home
178
+ const installedForgeHome = dirname(dirname(linkTarget));
179
+ const pkgPath = join(installedForgeHome, "package.json");
180
+ try {
181
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
182
+ installedVersion = pkg.version || null;
183
+ } catch {
184
+ // package.json missing or malformed — treat as version unknown
185
+ }
186
+ break; // One symlink is enough to establish install status
187
+ }
188
+ }
189
+ } catch {
190
+ // lstat/readlink failed — skip this entry
191
+ }
192
+ }
193
+ } catch {
194
+ // TARGET_DIR doesn't exist — definitively a fresh install
195
+ return { state: "fresh-install", installedVersion: null, currentVersion };
196
+ }
197
+
198
+ if (!hasSymlinks) {
199
+ // Check for copy-mode install (Windows: symlinks not available)
200
+ const marker = readInstallModeMarker();
201
+ if (marker) {
202
+ installedVersion = marker.version ?? null;
203
+ } else {
204
+ return { state: "fresh-install", installedVersion: null, currentVersion };
205
+ }
206
+ }
207
+
208
+ // Commands are installed — now determine sub-state
209
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
210
+ const hasConfig = existsSync(forgeYamlPath);
211
+
212
+ if (!hasConfig) {
213
+ return { state: "config-missing", installedVersion, currentVersion };
214
+ }
215
+
216
+ // Commands and config are present — but version may be unreadable
217
+ if (installedVersion === null) {
218
+ // package.json was missing or malformed — cannot determine installed version.
219
+ // Return a distinct state so the caller can inform the user and offer a resolution
220
+ // path (reinstall), rather than silently claiming "up to date".
221
+ return { state: "version-unknown", installedVersion: null, currentVersion };
222
+ }
223
+
224
+ // Both versions known — compare them
225
+ if (installedVersion !== currentVersion) {
226
+ return { state: "update-available", installedVersion, currentVersion };
227
+ }
228
+
229
+ return { state: "up-to-date", installedVersion, currentVersion };
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Splash screen — ASCII art logo in a Unicode box
234
+ // ---------------------------------------------------------------------------
235
+
236
+ const LOGO_LINES = [
237
+ `${bold(cyan(" ╔═╗╔═╗╦═╗╔═╗╔═╗╔╦╗╔═╗╔═╗╦╔═"))}`,
238
+ `${bold(cyan(" ╠╣ ║ ║╠╦╝║ ╦║╣ ║║║ ║║ ╠╩╗"))}`,
239
+ `${bold(cyan(" ╚ ╚═╝╩╚═╚═╝╚═╝═╩╝╚═╝╚═╝╩ ╩"))}`,
240
+ ];
241
+
242
+ function splash() {
243
+ const version = getVersion();
244
+ const tagline = dim("GitHub as a knowledge graph for AI agents");
245
+ const versionLine = dim(`v${version}`);
246
+
247
+ const content = [
248
+ "",
249
+ ...LOGO_LINES,
250
+ "",
251
+ ` ${tagline}`,
252
+ ` ${versionLine}`,
253
+ "",
254
+ ];
255
+
256
+ process.stdout.write("\n" + box(content, { padding: 2 }) + "\n");
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Step Orchestrator — manages a sequence of named steps
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /**
264
+ * @typedef {Object} Step
265
+ * @property {string} name - Display name for the step
266
+ * @property {() => Promise<void>} run - Async function to execute
267
+ * @property {boolean} [optional] - If true, failure offers skip option
268
+ */
269
+
270
+ class StepOrchestrator {
271
+ /**
272
+ * @param {Step[]} steps
273
+ */
274
+ constructor(steps) {
275
+ /** @type {Array<Step & { status: string }>} */
276
+ this.steps = steps.map((s) => ({ ...s, status: "pending" }));
277
+ this.currentIndex = 0;
278
+ this._aborted = false;
279
+
280
+ // SIGINT: clean exit — restore cursor, show final state
281
+ this._sigintHandler = () => {
282
+ this._aborted = true;
283
+ // Mark current step as failed if it was active
284
+ if (this.currentIndex < this.steps.length) {
285
+ this.steps[this.currentIndex].status = "failed";
286
+ }
287
+ this._renderSteps();
288
+ process.stdout.write("\n");
289
+ console.log(`${red("Aborted")} — exiting cleanly.`);
290
+ // Show cursor
291
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
292
+ // Restore terminal to cooked mode in case a select/multiSelect prompt was active
293
+ if (process.stdin.isTTY) {
294
+ try {
295
+ process.stdin.setRawMode(false);
296
+ } catch {
297
+ /* ignore */
298
+ }
299
+ }
300
+ process.exit(130);
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Render the current step list to stdout.
306
+ */
307
+ _renderSteps() {
308
+ const total = this.steps.length;
309
+ console.log("");
310
+ for (let i = 0; i < total; i++) {
311
+ const step = this.steps[i];
312
+ console.log(" " + stepHeader(i + 1, total, step.name, step.status));
313
+ }
314
+ console.log("");
315
+ }
316
+
317
+ /**
318
+ * Execute all steps in sequence.
319
+ * @returns {Promise<boolean>} true if all steps completed (or were skipped), false if aborted
320
+ */
321
+ async run() {
322
+ process.on("SIGINT", this._sigintHandler);
323
+
324
+ try {
325
+ this._renderSteps();
326
+
327
+ while (this.currentIndex < this.steps.length) {
328
+ if (this._aborted) return false;
329
+
330
+ const step = this.steps[this.currentIndex];
331
+ step.status = "active";
332
+ this._renderSteps();
333
+
334
+ try {
335
+ await step.run();
336
+ step.status = "done";
337
+ } catch (err) {
338
+ step.status = "failed";
339
+ this._renderSteps();
340
+
341
+ const handled = await this._handleFailure(step, err);
342
+ if (!handled) {
343
+ // Abort
344
+ this._aborted = true;
345
+ return false;
346
+ }
347
+ // Handled (retried successfully or skipped) — advance normally
348
+ }
349
+
350
+ this.currentIndex++;
351
+ }
352
+
353
+ // All done
354
+ this._renderSteps();
355
+ return true;
356
+ } finally {
357
+ process.removeListener("SIGINT", this._sigintHandler);
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Handle a step failure: offer retry/skip/abort.
363
+ * @returns {Promise<boolean>} true if recovered (retry succeeded or skipped), false if abort
364
+ */
365
+ async _handleFailure(step, err) {
366
+ console.log(` ${red("Error")}: ${err.message || String(err)}`);
367
+ console.log("");
368
+
369
+ // Non-TTY: no interactive recovery — abort
370
+ if (!process.stdin.isTTY) {
371
+ console.log(" Non-interactive environment — aborting.");
372
+ return false;
373
+ }
374
+
375
+ const choices = [{ label: "Retry", value: "retry" }];
376
+ if (step.optional) {
377
+ choices.push({ label: "Skip this step", value: "skip" });
378
+ }
379
+ choices.push({ label: "Abort", value: "abort" });
380
+
381
+ const action = await select("What would you like to do?", choices);
382
+
383
+ if (action === "retry") {
384
+ // Re-run the step
385
+ step.status = "active";
386
+ this._renderSteps();
387
+ try {
388
+ await step.run();
389
+ step.status = "done";
390
+ return true;
391
+ } catch (retryErr) {
392
+ step.status = "failed";
393
+ return this._handleFailure(step, retryErr);
394
+ }
395
+ } else if (action === "skip") {
396
+ step.status = "skipped";
397
+ return true;
398
+ } else {
399
+ // abort
400
+ return false;
401
+ }
402
+ }
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Prerequisites (unchanged logic, extracted for reuse)
407
+ // ---------------------------------------------------------------------------
408
+
409
+ function checkPrerequisites() {
410
+ const issues = [];
411
+ const warnings = [];
412
+
413
+ // Check gh CLI
414
+ try {
415
+ execSync("gh --version", { stdio: ["pipe", "pipe", "pipe"] });
416
+ } catch {
417
+ issues.push(
418
+ `${RED}✗${RESET} GitHub CLI (gh) is not installed.\n` +
419
+ ` Install: ${CYAN}https://cli.github.com${RESET}`,
420
+ );
421
+ }
422
+
423
+ // Check gh auth (only if gh is installed)
424
+ if (issues.length === 0) {
425
+ try {
426
+ execSync("gh auth status", { stdio: ["pipe", "pipe", "pipe"] });
427
+ } catch {
428
+ issues.push(
429
+ `${RED}✗${RESET} GitHub CLI is not authenticated.\n` +
430
+ ` Run: ${CYAN}gh auth login${RESET}`,
431
+ );
432
+ }
433
+ }
434
+
435
+ // Check Claude Code
436
+ try {
437
+ execSync("claude --version", { stdio: ["pipe", "pipe", "pipe"] });
438
+ } catch {
439
+ warnings.push(
440
+ `${YELLOW}!${RESET} Claude Code CLI not found on PATH.\n` +
441
+ ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
442
+ );
443
+ }
444
+
445
+ // Check Node version
446
+ const nodeVersion = parseInt(process.versions.node.split(".")[0], 10);
447
+ if (nodeVersion < 18) {
448
+ issues.push(
449
+ `${RED}✗${RESET} Node.js >= 18 required (found ${process.versions.node}).\n` +
450
+ ` Update: ${CYAN}https://nodejs.org${RESET}`,
451
+ );
452
+ }
453
+
454
+ if (issues.length > 0 || warnings.length > 0) {
455
+ console.log(`${BOLD}Preflight checks${RESET}`);
456
+ console.log("");
457
+ for (const issue of issues) console.log(` ${issue}`);
458
+ for (const warning of warnings) console.log(` ${warning}`);
459
+ console.log("");
460
+ }
461
+
462
+ if (issues.length > 0) {
463
+ console.log(
464
+ `${RED}${BOLD}Blocking issues found.${RESET} ForgeDock requires the GitHub CLI ` +
465
+ `with authentication to function.\nResolve the issues above, then re-run the command.\n`,
466
+ );
467
+ process.exit(1);
468
+ }
469
+
470
+ // Warnings are non-blocking — continue with install
471
+ return warnings.length === 0;
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Interactive prerequisite checklist (TUI flow)
476
+ // ---------------------------------------------------------------------------
477
+
478
+ /**
479
+ * Run the interactive prerequisite checklist with live spinner rendering.
480
+ *
481
+ * Each check runs individually with a spinner that resolves to ✓, ✗, or ⚠.
482
+ * Blocking checks (gh CLI, gh auth, Node.js) throw on failure, stopping the
483
+ * StepOrchestrator step. Warning checks (git, Claude Code) emit ⚠ but do NOT throw.
484
+ *
485
+ * @returns {Promise<void>}
486
+ */
487
+ async function runPrerequisiteChecklist() {
488
+ // ── 1. GitHub CLI ──────────────────────────────────────────────────────────
489
+ {
490
+ const s = spinner("Checking GitHub CLI…");
491
+ try {
492
+ const out = execSync("gh --version", {
493
+ encoding: "utf-8",
494
+ stdio: ["pipe", "pipe", "pipe"],
495
+ });
496
+ const match = out.match(/gh version (\d+\.\d+\.\d+)/);
497
+ const version = match ? `v${match[1]}` : "installed";
498
+ s.stop("success", `${green("[✓]")} GitHub CLI ${dim(version)}`);
499
+ } catch {
500
+ s.stop(
501
+ "fail",
502
+ `${red("[✗]")} GitHub CLI not found — install: ${cyan("https://cli.github.com")}`,
503
+ );
504
+ throw new Error(
505
+ "GitHub CLI (gh) is required. Install it from https://cli.github.com",
506
+ );
507
+ }
508
+ }
509
+
510
+ // ── 2. GitHub Auth ─────────────────────────────────────────────────────────
511
+ {
512
+ const s = spinner("Checking GitHub Auth…");
513
+ try {
514
+ // gh auth status writes to stderr; capture it via the error path too
515
+ let combined = "";
516
+ try {
517
+ combined = execSync("gh auth status", {
518
+ encoding: "utf-8",
519
+ stdio: ["pipe", "pipe", "pipe"],
520
+ });
521
+ } catch (err) {
522
+ combined = (err.stderr || "") + (err.stdout || "");
523
+ if (!/Logged in/.test(combined)) {
524
+ throw err;
525
+ }
526
+ }
527
+ // Parse "Logged in to github.com account USERNAME (keyring|oauth)" from stderr
528
+ const userMatch = combined.match(/account\s+(\S+)/);
529
+ const username = userMatch ? `@${userMatch[1]}` : "authenticated";
530
+ // Try to get primary org from gh api
531
+ let orgLabel = "";
532
+ try {
533
+ const orgsJson = execSync("gh api /user/orgs --jq '.[0].login'", {
534
+ encoding: "utf-8",
535
+ stdio: ["pipe", "pipe", "pipe"],
536
+ }).trim();
537
+ if (orgsJson && orgsJson !== "null" && orgsJson !== "") {
538
+ orgLabel = ` (${orgsJson})`;
539
+ }
540
+ } catch {
541
+ // Org lookup is best-effort — not required
542
+ }
543
+ s.stop(
544
+ "success",
545
+ `${green("[✓]")} GitHub Auth ${dim(`logged in as ${username}${orgLabel}`)}`,
546
+ );
547
+ } catch {
548
+ s.stop(
549
+ "fail",
550
+ `${red("[✗]")} GitHub Auth not authenticated — run: ${cyan("gh auth login")}`,
551
+ );
552
+ throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
553
+ }
554
+ }
555
+
556
+ // ── 3. Node.js ─────────────────────────────────────────────────────────────
557
+ {
558
+ const s = spinner("Checking Node.js…");
559
+ const nodeVersion = process.versions.node;
560
+ const nodeMajor = parseInt(nodeVersion.split(".")[0], 10);
561
+ if (nodeMajor < 18) {
562
+ s.stop(
563
+ "fail",
564
+ `${red("[✗]")} Node.js v${nodeVersion} (>= 18 required) — update: ${cyan("https://nodejs.org")}`,
565
+ );
566
+ throw new Error(
567
+ `Node.js >= 18 required (found v${nodeVersion}). Update at https://nodejs.org`,
568
+ );
569
+ }
570
+ s.stop(
571
+ "success",
572
+ `${green("[✓]")} Node.js ${dim(`v${nodeVersion} (>= 18 required)`)}`,
573
+ );
574
+ }
31
575
 
32
- const BOLD = "\x1b[1m";
33
- const GREEN = "\x1b[32m";
34
- const YELLOW = "\x1b[33m";
35
- const CYAN = "\x1b[36m";
36
- const RED = "\x1b[31m";
37
- const RESET = "\x1b[0m";
576
+ // ── 4. Git ─────────────────────────────────────────────────────────────────
577
+ {
578
+ const s = spinner("Checking Git…");
579
+ try {
580
+ const out = execSync("git --version", {
581
+ encoding: "utf-8",
582
+ stdio: ["pipe", "pipe", "pipe"],
583
+ });
584
+ const match = out.match(/git version (\d+\.\d+\.\d+)/);
585
+ const version = match ? `v${match[1]}` : "installed";
586
+ s.stop("success", `${green("[✓]")} Git ${dim(version)}`);
587
+ } catch {
588
+ s.stop(
589
+ "warn",
590
+ `${yellow("[!]")} Git not found — install: ${cyan("https://git-scm.com")}`,
591
+ );
592
+ // Git is a warning — do NOT throw
593
+ }
594
+ }
595
+
596
+ // ── 5. Claude Code ─────────────────────────────────────────────────────────
597
+ {
598
+ const s = spinner("Checking Claude Code…");
599
+ try {
600
+ const out = execSync("claude --version", {
601
+ encoding: "utf-8",
602
+ stdio: ["pipe", "pipe", "pipe"],
603
+ });
604
+ const match = out.trim().match(/(\d+\.\d+\.\d+)/);
605
+ const version = match ? `v${match[1]}` : "installed";
606
+ s.stop("success", `${green("[✓]")} Claude Code ${dim(version)}`);
607
+ } catch {
608
+ s.stop(
609
+ "warn",
610
+ `${yellow("[!]")} Claude Code not found — install: ${cyan("https://docs.anthropic.com/en/docs/claude-code")}`,
611
+ );
612
+ // Claude Code is a warning — do NOT throw
613
+ }
614
+ }
615
+ }
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // File discovery
619
+ // ---------------------------------------------------------------------------
38
620
 
39
621
  async function findMarkdownFiles(dir) {
40
622
  const results = [];
@@ -50,11 +632,82 @@ async function findMarkdownFiles(dir) {
50
632
  return results.sort();
51
633
  }
52
634
 
635
+ // ---------------------------------------------------------------------------
636
+ // Command category mapping
637
+ // ---------------------------------------------------------------------------
638
+
639
+ /**
640
+ * Maps command name stems (filename without .md extension, no path prefix)
641
+ * to display categories. Sub-commands (e.g. work-on/build/architect) are
642
+ * looked up by their stem — unmapped names fall into "Other".
643
+ */
644
+ const COMMAND_CATEGORIES = {
645
+ // Pipeline — core orchestration commands
646
+ "work-on": "Pipeline",
647
+ investigate: "Pipeline",
648
+ build: "Pipeline",
649
+ architect: "Pipeline",
650
+ context: "Pipeline",
651
+ implement: "Pipeline",
652
+ review: "Pipeline",
653
+ decompose: "Pipeline",
654
+ close: "Pipeline",
655
+ "review-pr": "Pipeline",
656
+ "review-pr-agents": "Pipeline",
657
+ "review-pr-staging": "Pipeline",
658
+ orchestrate: "Pipeline",
659
+ issue: "Pipeline",
660
+ milestone: "Pipeline",
661
+ "quality-gate": "Pipeline",
662
+ "work-on-monolithic": "Pipeline",
663
+
664
+ // Operations — ongoing automation and monitoring
665
+ autopilot: "Operations",
666
+ analytics: "Operations",
667
+ "geo-audit": "Operations",
668
+ "security-audit": "Operations",
669
+ "pipeline-health": "Operations",
670
+ audit: "Operations",
671
+ "audit-agents": "Operations",
672
+ "qa-sweep": "Operations",
673
+ "forge-stats": "Operations",
674
+ "sync-ecosystem": "Operations",
675
+
676
+ // Incident — response and recovery
677
+ "incident-response": "Incident",
678
+ rollback: "Incident",
679
+ "deploy-info": "Incident",
680
+ "failure-recon": "Incident",
681
+
682
+ // Ecosystem — project management utilities
683
+ validate: "Ecosystem",
684
+ cleanup: "Ecosystem",
685
+
686
+ // Setup — initial configuration
687
+ "forgedock-init": "Setup",
688
+ };
689
+
690
+ /** Canonical display order for categories. */
691
+ const CATEGORY_ORDER = [
692
+ "Pipeline",
693
+ "Operations",
694
+ "Incident",
695
+ "Ecosystem",
696
+ "Setup",
697
+ "Other",
698
+ ];
699
+
700
+ // ---------------------------------------------------------------------------
701
+ // Legacy commands (install, uninstall, update, init, help)
702
+ // ---------------------------------------------------------------------------
703
+
53
704
  async function install() {
705
+ checkPrerequisites();
706
+
54
707
  console.log("");
55
708
  console.log(`${BOLD}ForgeDock${RESET} — Installing pipeline commands`);
56
- console.log(` Source: ${CYAN}${COMMANDS_DIR}/${RESET}`);
57
- console.log(` Target: ${CYAN}${TARGET_DIR}/${RESET}`);
709
+ console.log(` Source: ${dim(COMMANDS_DIR + "/")}`);
710
+ console.log(` Target: ${dim(TARGET_DIR + "/")}`);
58
711
  console.log("");
59
712
 
60
713
  await mkdir(TARGET_DIR, { recursive: true });
@@ -63,6 +716,21 @@ async function install() {
63
716
  let installed = 0;
64
717
  let updated = 0;
65
718
  let skipped = 0;
719
+ /** Number of files installed via copyFile fallback (Windows: symlinks unavailable). */
720
+ let copied = 0;
721
+
722
+ /** @type {Array<{rel: string, action: 'installed'|'updated'|'skipped'|'conflict'|'copied'}>} */
723
+ const results = [];
724
+ /** @type {string[]} Relative paths of files that could not be symlinked (regular file conflict) */
725
+ const conflicts = [];
726
+
727
+ // -------------------------------------------------------------------------
728
+ // Phase 1: Symlink loop with live progress bar
729
+ // -------------------------------------------------------------------------
730
+
731
+ const bar = createProgressBar(files.length, {
732
+ label: " Installing commands...",
733
+ });
66
734
 
67
735
  for (const file of files) {
68
736
  const rel = relative(COMMANDS_DIR, file);
@@ -78,39 +746,175 @@ async function install() {
78
746
  const current = await readlink(target);
79
747
  if (current === file) {
80
748
  skipped++;
749
+ results.push({ rel, action: "skipped" });
750
+ bar.tick(1, dim(rel));
81
751
  } else {
82
- await symlink(file, target + ".tmp");
83
- const { rename } = await import("fs/promises");
84
- await rename(target + ".tmp", target);
85
- console.log(` ${YELLOW}Updated${RESET}: ${rel}`);
86
- updated++;
752
+ try {
753
+ await symlink(file, target + ".tmp");
754
+ const { rename } = await import("fs/promises");
755
+ await rename(target + ".tmp", target);
756
+ updated++;
757
+ results.push({ rel, action: "updated" });
758
+ bar.tick(1, rel);
759
+ } catch (symlinkErr) {
760
+ if (symlinkErr.code === "EPERM" || symlinkErr.code === "ENOTSUP") {
761
+ // Symlink not permitted (Windows standard user) — fall back to copy
762
+ await copyFile(file, target);
763
+ copied++;
764
+ results.push({ rel, action: "copied" });
765
+ bar.tick(1, rel);
766
+ } else {
767
+ throw symlinkErr;
768
+ }
769
+ }
87
770
  }
88
771
  } else {
89
- console.log(
90
- ` ${YELLOW}WARNING${RESET}: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)`,
91
- );
92
- skipped++;
772
+ // Regular file present — could be a previous copy-mode install or a user conflict.
773
+ // Distinguish: if the install-mode marker exists, treat as ForgeDock-managed copy.
774
+ const isCopyModeInstall = readInstallModeMarker() !== null;
775
+ if (isCopyModeInstall) {
776
+ // Re-copy to update the file
777
+ await copyFile(file, target);
778
+ copied++;
779
+ results.push({ rel, action: "copied" });
780
+ bar.tick(1, rel);
781
+ } else {
782
+ // User-owned regular file is blocking — record as conflict
783
+ skipped++;
784
+ conflicts.push(rel);
785
+ results.push({ rel, action: "conflict" });
786
+ bar.tick(1, rel);
787
+ }
788
+ }
789
+ } catch (err) {
790
+ if (err.code === "ENOENT") {
791
+ // Target doesn't exist — try symlink, fall back to copy on EPERM/ENOTSUP
792
+ try {
793
+ await symlink(file, target);
794
+ installed++;
795
+ results.push({ rel, action: "installed" });
796
+ bar.tick(1, rel);
797
+ } catch (symlinkErr) {
798
+ if (symlinkErr.code === "EPERM" || symlinkErr.code === "ENOTSUP") {
799
+ // Symlink not permitted (Windows standard user) — fall back to copy
800
+ await copyFile(file, target);
801
+ copied++;
802
+ results.push({ rel, action: "copied" });
803
+ bar.tick(1, rel);
804
+ } else {
805
+ throw symlinkErr;
806
+ }
807
+ }
808
+ } else {
809
+ throw err;
93
810
  }
94
- } catch {
95
- // Doesn't exist — create symlink
96
- await symlink(file, target);
97
- console.log(` ${GREEN}Installed${RESET}: ${rel}`);
98
- installed++;
99
811
  }
100
812
  }
101
813
 
102
- console.log("");
103
- console.log(
104
- `Done. ${GREEN}Installed: ${installed}${RESET}, Updated: ${updated}, Skipped: ${skipped}`,
105
- );
106
- console.log("");
814
+ // If any files were installed via copy, write the install-mode marker so that
815
+ // subsequent runs (detectInstallState, getInstalledCommandNames, uninstall) know
816
+ // that regular .md files in TARGET_DIR are ForgeDock-managed.
817
+ if (copied > 0) {
818
+ await writeInstallModeMarker();
819
+ }
820
+
821
+ // totalInstalled: fresh installs + copy-mode installs. Updated separately — mirrors
822
+ // pre-existing behaviour where only `installed > 0` (not updates) triggers this branch.
823
+ const totalInstalled = installed + copied;
824
+ const totalLabel =
825
+ totalInstalled > 0
826
+ ? `${green("✔")} Installed ${totalInstalled + updated}/${files.length} commands`
827
+ : updated > 0
828
+ ? `${green("✔")} Updated ${updated} command${updated === 1 ? "" : "s"}`
829
+ : `${dim("✔")} Commands up to date (${skipped} skipped)`;
830
+ bar.done(totalLabel);
831
+
832
+ // -------------------------------------------------------------------------
833
+ // Phase 2: Category grouping summary
834
+ // -------------------------------------------------------------------------
835
+
836
+ // Group installed/updated commands by category
837
+ /** @type {Map<string, string[]>} category → command stems */
838
+ const categoryMap = new Map(CATEGORY_ORDER.map((c) => [c, []]));
839
+
840
+ for (const { rel, action } of results) {
841
+ if (action === "skipped" || action === "conflict") continue;
842
+ const stem = rel.replace(/\.md$/, "").split("/").pop() ?? rel;
843
+ // 'copied' actions are treated the same as 'installed' for category display
844
+ const category = COMMAND_CATEGORIES[stem] ?? "Other";
845
+ const bucket = categoryMap.get(category) ?? categoryMap.get("Other") ?? [];
846
+ if (!categoryMap.has(category)) categoryMap.set(category, bucket);
847
+ bucket.push(stem);
848
+ }
849
+
850
+ // Build category lines — only show categories with at least one command
851
+ const categoryLines = [];
852
+ for (const cat of CATEGORY_ORDER) {
853
+ const cmds = categoryMap.get(cat) ?? [];
854
+ if (cmds.length === 0) continue;
855
+ const label = cat.padEnd(12);
856
+ const cmdList = cmds.join(", ");
857
+ const count = `(${cmds.length})`;
858
+ categoryLines.push(` ${bold(cyan(label))} ${cmdList} ${dim(count)}`);
859
+ }
860
+
861
+ if (categoryLines.length > 0) {
862
+ console.log("");
863
+ process.stdout.write(
864
+ box(["", ...categoryLines, ""], { title: "Commands installed" }),
865
+ );
866
+ }
867
+
868
+ // -------------------------------------------------------------------------
869
+ // Phase 3: Summary box — total counts
870
+ // -------------------------------------------------------------------------
871
+
872
+ const summaryLines = [
873
+ "",
874
+ ` ${green("Installed")} ${bold(String(installed))}`,
875
+ ` ${yellow("Updated")} ${bold(String(updated))}`,
876
+ ` ${dim("Skipped")} ${dim(String(skipped - conflicts.length))}`,
877
+ "",
878
+ ];
879
+ if (copied > 0) {
880
+ summaryLines.splice(
881
+ summaryLines.length - 1,
882
+ 0,
883
+ ` ${cyan("Copied")} ${bold(String(copied))} ${dim("(symlinks unavailable — files copied instead)")}`,
884
+ );
885
+ }
886
+ process.stdout.write(box(summaryLines, { title: "Summary" }));
887
+
888
+ // -------------------------------------------------------------------------
889
+ // Phase 4: Conflict highlighting (prominent red block)
890
+ // -------------------------------------------------------------------------
891
+
892
+ if (conflicts.length > 0) {
893
+ console.log("");
894
+ const conflictLines = [
895
+ "",
896
+ ` ${bold(red("⚠ Regular files blocking symlink creation:"))}`,
897
+ "",
898
+ ...conflicts.map((c) => ` ${red("✖")} ${c}`),
899
+ "",
900
+ ` ${dim("To let ForgeDock manage these, remove or rename each file:")}`,
901
+ ...conflicts.map((c) => ` ${dim(`rm ~/.claude/commands/${c}`)}`),
902
+ "",
903
+ ];
904
+ process.stdout.write(
905
+ box(conflictLines, {
906
+ title: `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`,
907
+ }),
908
+ );
909
+ }
910
+
911
+ // -------------------------------------------------------------------------
912
+ // Phase 5: FORGE_HOME setup — distinct status line
913
+ // -------------------------------------------------------------------------
107
914
 
108
- // Set FORGE_HOME in shell profiles
109
- let profileUpdated = false;
110
- for (const profile of [
111
- join(process.env.HOME, ".bashrc"),
112
- join(process.env.HOME, ".zshrc"),
113
- ]) {
915
+ console.log("");
916
+ let forgeHomeSet = false;
917
+ for (const profile of [join(HOME, ".bashrc"), join(HOME, ".zshrc")]) {
114
918
  if (existsSync(profile)) {
115
919
  const content = readFileSync(profile, "utf-8");
116
920
  if (!content.includes("FORGE_HOME")) {
@@ -118,123 +922,825 @@ async function install() {
118
922
  profile,
119
923
  `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
120
924
  );
121
- console.log(` Added FORGE_HOME to ${profile}`);
122
- profileUpdated = true;
925
+ const profileShort = profile.replace(HOME, "~");
926
+ console.log(
927
+ ` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`,
928
+ );
929
+ forgeHomeSet = true;
123
930
  }
124
931
  }
125
932
  }
126
933
 
934
+ if (!forgeHomeSet) {
935
+ console.log(
936
+ ` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`,
937
+ );
938
+ }
939
+
940
+ console.log("");
127
941
  console.log(
128
- `${GREEN}ForgeDock commands are now available as slash commands in any Claude Code session.${RESET}`,
942
+ `${green("ForgeDock commands are now available as slash commands in any Claude Code session.")}`,
129
943
  );
130
944
  console.log("");
131
945
 
132
946
  // forge.yaml advisory — guide users to run init if config is missing
133
947
  const forgeYamlPath = join(process.cwd(), "forge.yaml");
134
948
  if (!existsSync(forgeYamlPath)) {
135
- console.log(`${YELLOW}No forge.yaml found in current directory.${RESET}`);
949
+ console.log(`${yellow("No forge.yaml found in current directory.")}`);
136
950
  console.log(
137
- ` Run ${CYAN}npx forgedock init${RESET} in your project root to generate forge.yaml`,
951
+ ` Run ${cyan("npx forgedock init")} in your project root to generate forge.yaml`,
138
952
  );
139
953
  console.log("");
140
954
  }
955
+
956
+ return { installed, updated, skipped, copied };
141
957
  }
142
958
 
143
959
  async function uninstall() {
960
+ const { unlink } = await import("fs/promises");
961
+
144
962
  console.log("");
145
- console.log(`${BOLD}ForgeDock${RESET} — Removing pipeline commands`);
963
+ console.log(`${BOLD}ForgeDock${RESET} — Uninstall`);
146
964
  console.log("");
147
965
 
966
+ // -------------------------------------------------------------------------
967
+ // Phase 1: Dry-run scan — compute what would be removed
968
+ // -------------------------------------------------------------------------
969
+
148
970
  const files = await findMarkdownFiles(COMMANDS_DIR);
149
- let removed = 0;
971
+ /** @type {Array<{file: string, rel: string, target: string}>} */
972
+ const toRemove = [];
973
+ const copyMode = readInstallModeMarker() !== null;
150
974
 
151
975
  for (const file of files) {
152
976
  const rel = relative(COMMANDS_DIR, file);
153
977
  const target = join(TARGET_DIR, rel);
154
-
155
978
  try {
156
979
  const stats = await lstat(target);
157
980
  if (stats.isSymbolicLink()) {
158
981
  const current = await readlink(target);
159
982
  if (current === file) {
160
- const { unlink } = await import("fs/promises");
161
- await unlink(target);
162
- console.log(` ${RED}Removed${RESET}: ${rel}`);
163
- removed++;
983
+ toRemove.push({ file, rel, target });
164
984
  }
985
+ } else if (copyMode && rel.endsWith(".md")) {
986
+ // Copy-mode install — regular files managed by ForgeDock
987
+ toRemove.push({ file, rel, target });
165
988
  }
166
989
  } catch {
167
- // Doesn't exist — nothing to do
990
+ // Target doesn't exist — nothing to remove
168
991
  }
169
992
  }
170
993
 
171
- console.log("");
172
- console.log(`Done. Removed: ${removed} commands.`);
173
- console.log("");
174
- }
994
+ // Scan shell profiles for FORGE_HOME block
995
+ const FORGE_HOME_MARKER = "# ForgeDock — autonomous development pipeline";
996
+ const shellProfiles = [join(HOME, ".bashrc"), join(HOME, ".zshrc")];
997
+ /** @type {string[]} Profiles that contain the FORGE_HOME block */
998
+ const profilesWithForgeHome = shellProfiles.filter((p) => {
999
+ if (!existsSync(p)) return false;
1000
+ try {
1001
+ return readFileSync(p, "utf-8").includes(FORGE_HOME_MARKER);
1002
+ } catch {
1003
+ return false;
1004
+ }
1005
+ });
175
1006
 
176
- async function update() {
177
- console.log("");
178
- console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
179
- console.log("");
1007
+ // Check for forge.yaml in cwd
1008
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
1009
+ const hasForgeYaml = existsSync(forgeYamlPath);
180
1010
 
181
- // Check if installed via npm (no .git directory) or via git clone
182
- const gitDir = join(FORGE_HOME, ".git");
183
- if (existsSync(gitDir)) {
184
- try {
185
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
186
- cwd: FORGE_HOME,
1011
+ // -------------------------------------------------------------------------
1012
+ // Phase 2: Pre-removal summary
1013
+ // -------------------------------------------------------------------------
1014
+
1015
+ if (
1016
+ toRemove.length === 0 &&
1017
+ profilesWithForgeHome.length === 0 &&
1018
+ !hasForgeYaml
1019
+ ) {
1020
+ console.log(
1021
+ ` ${dim("Nothing to remove — ForgeDock does not appear to be installed.")}`,
1022
+ );
1023
+ console.log("");
1024
+ return;
1025
+ }
1026
+
1027
+ const summaryLines = [""];
1028
+ if (toRemove.length > 0) {
1029
+ const fileLabel = copyMode ? "files (copy-mode install)" : "symlinks";
1030
+ summaryLines.push(
1031
+ ` ${red("Commands")}: ${bold(String(toRemove.length))} ${fileLabel} in ${dim(TARGET_DIR)}`,
1032
+ );
1033
+ } else {
1034
+ summaryLines.push(` ${dim("Commands:")} none found`);
1035
+ }
1036
+ if (profilesWithForgeHome.length > 0) {
1037
+ summaryLines.push(
1038
+ ` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(HOME, "~")).join(", "))}`,
1039
+ );
1040
+ }
1041
+ if (hasForgeYaml) {
1042
+ summaryLines.push(` ${cyan("forge.yaml")}: present in current directory`);
1043
+ }
1044
+ summaryLines.push("");
1045
+
1046
+ process.stdout.write(box(summaryLines, { title: "What will be removed" }));
1047
+ console.log("");
1048
+
1049
+ // -------------------------------------------------------------------------
1050
+ // Phase 3: Main confirmation — default N (safe)
1051
+ // -------------------------------------------------------------------------
1052
+
1053
+ const commandLabel =
1054
+ toRemove.length === 1 ? "1 command" : `${toRemove.length} commands`;
1055
+ const profileLabel =
1056
+ profilesWithForgeHome.length > 0
1057
+ ? ` and FORGE_HOME from ${profilesWithForgeHome.length === 1 ? "shell profile" : "shell profiles"}`
1058
+ : "";
1059
+
1060
+ let confirmed = forceYes;
1061
+ if (!confirmed) {
1062
+ confirmed = await confirm(`Remove ${commandLabel}${profileLabel}?`, false);
1063
+ }
1064
+
1065
+ if (!confirmed) {
1066
+ console.log("");
1067
+ console.log(` ${dim("Aborted — nothing was removed.")}`);
1068
+ console.log("");
1069
+ return;
1070
+ }
1071
+
1072
+ // -------------------------------------------------------------------------
1073
+ // Phase 4: Remove symlinks with progress bar
1074
+ // -------------------------------------------------------------------------
1075
+
1076
+ if (toRemove.length > 0) {
1077
+ console.log("");
1078
+ const bar = createProgressBar(toRemove.length, {
1079
+ label: " Removing commands",
1080
+ });
1081
+ let removed = 0;
1082
+
1083
+ for (const { target, rel } of toRemove) {
1084
+ try {
1085
+ await unlink(target);
1086
+ removed++;
1087
+ bar.tick(1, rel);
1088
+ } catch (err) {
1089
+ bar.tick(1, `${red("failed:")} ${rel}`);
1090
+ }
1091
+ }
1092
+
1093
+ bar.done(`${green("✔")} Removed ${removed}/${toRemove.length} commands`);
1094
+
1095
+ // Clean up install-mode marker if copy-mode install was active
1096
+ if (copyMode) {
1097
+ await removeInstallModeMarker();
1098
+ }
1099
+ }
1100
+
1101
+ // -------------------------------------------------------------------------
1102
+ // Phase 5: FORGE_HOME cleanup (only if detected)
1103
+ // -------------------------------------------------------------------------
1104
+
1105
+ if (profilesWithForgeHome.length > 0) {
1106
+ console.log("");
1107
+ const cleanProfiles = forceYes
1108
+ ? true
1109
+ : await confirm("Remove FORGE_HOME export from shell profiles?", true);
1110
+
1111
+ if (cleanProfiles) {
1112
+ for (const profile of profilesWithForgeHome) {
1113
+ const tmpPath = profile + ".forgedock.tmp";
1114
+ try {
1115
+ const content = readFileSync(profile, "utf-8");
1116
+ // Remove the two-line block written by install():
1117
+ // \n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="..."\n
1118
+ // Also handle the case where it's at the very start of the file (no leading \n)
1119
+ const cleaned = content
1120
+ .replace(
1121
+ /\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME=[^\n]*\n/g,
1122
+ "\n",
1123
+ )
1124
+ .replace(
1125
+ /^# ForgeDock — autonomous development pipeline\nexport FORGE_HOME=[^\n]*\n/,
1126
+ "",
1127
+ );
1128
+ // Write to a temp file first, then atomically rename into place.
1129
+ // This prevents profile corruption if the process is killed mid-write.
1130
+ writeFileSync(tmpPath, cleaned, "utf-8");
1131
+ renameSync(tmpPath, profile);
1132
+ const profileShort = profile.replace(HOME, "~");
1133
+ console.log(
1134
+ ` ${green("✔")} Removed FORGE_HOME from ${profileShort}`,
1135
+ );
1136
+ } catch (err) {
1137
+ // Clean up temp file if it was created before the error
1138
+ try {
1139
+ unlinkSync(tmpPath);
1140
+ } catch {
1141
+ /* already gone or never created */
1142
+ }
1143
+ const profileShort = profile.replace(HOME, "~");
1144
+ console.log(
1145
+ ` ${red("✖")} Could not update ${profileShort}: ${err.message}`,
1146
+ );
1147
+ }
1148
+ }
1149
+ } else {
1150
+ console.log(` ${dim("Skipped — FORGE_HOME left in shell profiles.")}`);
1151
+ }
1152
+ }
1153
+
1154
+ // -------------------------------------------------------------------------
1155
+ // Phase 6: forge.yaml handling (default: keep)
1156
+ // -------------------------------------------------------------------------
1157
+
1158
+ if (hasForgeYaml) {
1159
+ console.log("");
1160
+ const deleteForgeYaml = forceYes
1161
+ ? false // --yes flag: keep forge.yaml by default (safe)
1162
+ : await confirm("Delete forge.yaml from this project?", false);
1163
+
1164
+ if (deleteForgeYaml) {
1165
+ try {
1166
+ await unlink(forgeYamlPath);
1167
+ console.log(` ${green("✔")} Deleted forge.yaml`);
1168
+ } catch (err) {
1169
+ console.log(
1170
+ ` ${red("✖")} Could not delete forge.yaml: ${err.message}`,
1171
+ );
1172
+ }
1173
+ } else {
1174
+ console.log(` ${dim("forge.yaml kept.")}`);
1175
+ }
1176
+ }
1177
+
1178
+ // -------------------------------------------------------------------------
1179
+ // Phase 7: Post-removal summary
1180
+ // -------------------------------------------------------------------------
1181
+
1182
+ console.log("");
1183
+ console.log(
1184
+ `${green("Uninstall complete.")} ForgeDock commands have been removed.`,
1185
+ );
1186
+ if (profilesWithForgeHome.length > 0) {
1187
+ console.log(
1188
+ ` ${dim("Restart your shell or run")} ${cyan("source ~/.bashrc")} ${dim("(or")} ${cyan("~/.zshrc")}${dim(")")} ${dim("to apply profile changes.")}`,
1189
+ );
1190
+ }
1191
+ console.log("");
1192
+ }
1193
+
1194
+ // ---------------------------------------------------------------------------
1195
+ // Update helpers
1196
+ // ---------------------------------------------------------------------------
1197
+
1198
+ /**
1199
+ * Query the npm registry for the latest published version of a package.
1200
+ * Returns null on any failure (offline, timeout, package not found).
1201
+ *
1202
+ * @param {string} pkg - npm package name (e.g. "forgedock")
1203
+ * @returns {string | null}
1204
+ */
1205
+ function queryNpmRegistry(pkg) {
1206
+ try {
1207
+ const result = execFileSync("npm", ["view", pkg, "version"], {
1208
+ encoding: "utf-8",
1209
+ stdio: ["pipe", "pipe", "pipe"],
1210
+ timeout: 5000,
1211
+ });
1212
+ return result.trim() || null;
1213
+ } catch {
1214
+ return null;
1215
+ }
1216
+ }
1217
+
1218
+ // ---------------------------------------------------------------------------
1219
+ // Proactive update check — cached remote version check
1220
+ // ---------------------------------------------------------------------------
1221
+
1222
+ /**
1223
+ * Path to the update-check cache file.
1224
+ * Stored in ~/.forgedock/ alongside credentials.json.
1225
+ * Populated lazily — FORGEDOCK_HOME is defined later in the file, but this
1226
+ * constant is only resolved at module evaluation time so the ordering is fine.
1227
+ */
1228
+ // NOTE: FORGEDOCK_HOME is declared later in the file (line ~2974). This const
1229
+ // is a forward reference that is only evaluated when the module finishes loading,
1230
+ // which is after FORGEDOCK_HOME is defined. Node ESM top-level await ensures
1231
+ // the module is fully evaluated before any exported binding is read.
1232
+ // We defer the join() call to avoid the forward-reference issue:
1233
+ function _getUpdateCheckCachePath() {
1234
+ return join(HOME, ".forgedock", "update-check.json");
1235
+ }
1236
+
1237
+ /** Default interval between automatic remote update checks (milliseconds). */
1238
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
1239
+
1240
+ /**
1241
+ * Check whether a newer version of ForgeDock is available, using a local cache
1242
+ * to avoid hitting the network on every invocation.
1243
+ *
1244
+ * For git installs: runs `git fetch origin main` (check-only, no merge) and
1245
+ * compares the local HEAD SHA against origin/main.
1246
+ * For npm installs: queries the npm registry via queryNpmRegistry().
1247
+ *
1248
+ * The cache file `~/.forgedock/update-check.json` stores the result with a
1249
+ * timestamp. If the cache is fresh (< UPDATE_CHECK_INTERVAL_MS old) and
1250
+ * `force` is false, the cached result is returned immediately without any
1251
+ * network call.
1252
+ *
1253
+ * Always returns null on any error — never throws.
1254
+ *
1255
+ * @param {boolean} [force=false] When true, bypass cache and always hit network.
1256
+ * @returns {Promise<{updateAvailable: boolean, latestVersion: string|null, remoteHead: string|null} | null>}
1257
+ */
1258
+ async function checkForRemoteUpdate(force = false) {
1259
+ const cachePath = _getUpdateCheckCachePath();
1260
+
1261
+ // --- Read cache ---
1262
+ if (!force) {
1263
+ try {
1264
+ const raw = readFileSync(cachePath, "utf-8");
1265
+ const cached = JSON.parse(raw);
1266
+ const age = Date.now() - (cached.checkedAt ?? 0);
1267
+ if (age < UPDATE_CHECK_INTERVAL_MS) {
1268
+ // Cache is fresh — but for git installs, reconcile against the live
1269
+ // local HEAD before trusting the stored flag. A manual `git pull` in
1270
+ // FORGE_HOME (bypassing `forgedock update`) advances local HEAD to the
1271
+ // cached remoteHead without refreshing the cache, which would otherwise
1272
+ // keep showing a stale "Update available" notice until the TTL expires.
1273
+ // This recheck is local-only (no fetch/network) and offline-safe.
1274
+ if (cached.remoteHead && existsSync(join(FORGE_HOME, ".git"))) {
1275
+ try {
1276
+ const localHead = execFileSync("git", ["rev-parse", "HEAD"], {
1277
+ cwd: FORGE_HOME,
1278
+ encoding: "utf-8",
1279
+ stdio: ["pipe", "pipe", "pipe"],
1280
+ timeout: 5000,
1281
+ }).trim();
1282
+ if (localHead === cached.remoteHead) {
1283
+ // Install has caught up out-of-band — no update available.
1284
+ return {
1285
+ updateAvailable: false,
1286
+ latestVersion: cached.latestVersion ?? null,
1287
+ remoteHead: cached.remoteHead,
1288
+ };
1289
+ }
1290
+ } catch {
1291
+ // git unavailable or not a repo — fall through to stored result.
1292
+ }
1293
+ }
1294
+ // Cache is fresh — return stored result
1295
+ return {
1296
+ updateAvailable: cached.updateAvailable ?? false,
1297
+ latestVersion: cached.latestVersion ?? null,
1298
+ remoteHead: cached.remoteHead ?? null,
1299
+ };
1300
+ }
1301
+ } catch {
1302
+ // Cache missing or malformed — proceed to network check
1303
+ }
1304
+ }
1305
+
1306
+ // --- Perform network check ---
1307
+ const gitDir = join(FORGE_HOME, ".git");
1308
+ let result;
1309
+
1310
+ if (existsSync(gitDir)) {
1311
+ // Git install: fetch and compare SHAs
1312
+ try {
1313
+ // Fetch — short timeout, offline-safe
1314
+ try {
1315
+ execFileSync("git", ["fetch", "origin", "main", "--quiet"], {
1316
+ cwd: FORGE_HOME,
1317
+ stdio: ["pipe", "pipe", "pipe"],
1318
+ timeout: 10000,
1319
+ });
1320
+ } catch {
1321
+ // Offline or fetch failed — skip check, don't update cache
1322
+ return null;
1323
+ }
1324
+
1325
+ const localHead = execFileSync("git", ["rev-parse", "HEAD"], {
1326
+ cwd: FORGE_HOME,
1327
+ encoding: "utf-8",
1328
+ stdio: ["pipe", "pipe", "pipe"],
1329
+ timeout: 5000,
1330
+ }).trim();
1331
+
1332
+ const remoteHead = execFileSync("git", ["rev-parse", "origin/main"], {
1333
+ cwd: FORGE_HOME,
1334
+ encoding: "utf-8",
1335
+ stdio: ["pipe", "pipe", "pipe"],
1336
+ timeout: 5000,
1337
+ }).trim();
1338
+
1339
+ result = {
1340
+ updateAvailable: localHead !== remoteHead,
1341
+ latestVersion: null,
1342
+ remoteHead,
1343
+ };
1344
+ } catch {
1345
+ return null;
1346
+ }
1347
+ } else {
1348
+ // npm install: query registry
1349
+ const latestVersion = queryNpmRegistry("forgedock");
1350
+ if (latestVersion === null) {
1351
+ // Offline or registry unreachable — skip check, don't update cache
1352
+ return null;
1353
+ }
1354
+ const currentVersion = getVersion();
1355
+ result = {
1356
+ updateAvailable: currentVersion !== latestVersion,
1357
+ latestVersion,
1358
+ remoteHead: null,
1359
+ };
1360
+ }
1361
+
1362
+ // --- Write cache ---
1363
+ try {
1364
+ const cacheDir = join(HOME, ".forgedock");
1365
+ await mkdir(cacheDir, { recursive: true });
1366
+ writeFileSync(
1367
+ cachePath,
1368
+ JSON.stringify({ checkedAt: Date.now(), ...result }, null, 2) + "\n",
1369
+ { encoding: "utf-8" },
1370
+ );
1371
+ } catch {
1372
+ // Best-effort — cache write failure is non-fatal
1373
+ }
1374
+
1375
+ return result;
1376
+ }
1377
+
1378
+ /**
1379
+ * Return a Set of relative paths for all ForgeDock-managed symlinks in TARGET_DIR.
1380
+ * Recursively walks subdirectories so commands installed under work-on/, work-on/build/,
1381
+ * etc. are included. Returns an empty Set if TARGET_DIR does not exist or cannot be read.
1382
+ *
1383
+ * @returns {Promise<Set<string>>}
1384
+ */
1385
+ async function getInstalledCommandNames() {
1386
+ const names = new Set();
1387
+ const copyMode = readInstallModeMarker() !== null;
1388
+
1389
+ async function walk(dir) {
1390
+ let entries;
1391
+ try {
1392
+ entries = await readdir(dir, { withFileTypes: true });
1393
+ } catch {
1394
+ return; // unreadable directory — skip
1395
+ }
1396
+ for (const entry of entries) {
1397
+ const fullPath = join(dir, entry.name);
1398
+ if (entry.isDirectory()) {
1399
+ await walk(fullPath);
1400
+ } else {
1401
+ try {
1402
+ const stats = await lstat(fullPath);
1403
+ if (stats.isSymbolicLink()) {
1404
+ const linkTarget = await readlink(fullPath);
1405
+ if (linkTarget.includes("commands") && linkTarget.endsWith(".md")) {
1406
+ names.add(relative(TARGET_DIR, fullPath));
1407
+ }
1408
+ } else if (copyMode && entry.name.endsWith(".md")) {
1409
+ // Copy-mode install: regular .md files in TARGET_DIR are ForgeDock-managed
1410
+ names.add(relative(TARGET_DIR, fullPath));
1411
+ }
1412
+ } catch {
1413
+ // Skip unreadable entries
1414
+ }
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ await walk(TARGET_DIR);
1420
+ return names;
1421
+ }
1422
+
1423
+ /**
1424
+ * Compute the added and removed command names between two Sets.
1425
+ * Returns formatted display lines, or an empty array if no diff.
1426
+ *
1427
+ * @param {Set<string>} before
1428
+ * @param {Set<string>} after
1429
+ * @returns {string[]}
1430
+ */
1431
+ function formatCommandDiff(before, after) {
1432
+ const added = [...after]
1433
+ .filter((n) => !before.has(n))
1434
+ .map((n) => n.replace(/\.md$/, ""));
1435
+ const removed = [...before]
1436
+ .filter((n) => !after.has(n))
1437
+ .map((n) => n.replace(/\.md$/, ""));
1438
+
1439
+ if (added.length === 0 && removed.length === 0) return [];
1440
+
1441
+ const lines = [""];
1442
+ if (added.length > 0) {
1443
+ lines.push(` ${green("Added")} ${added.join(", ")}`);
1444
+ }
1445
+ if (removed.length > 0) {
1446
+ lines.push(` ${red("Removed")} ${removed.join(", ")}`);
1447
+ }
1448
+ lines.push("");
1449
+ return lines;
1450
+ }
1451
+
1452
+ async function update() {
1453
+ const currentVersion = getVersion();
1454
+
1455
+ console.log("");
1456
+ console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
1457
+ console.log(` ${dim(`Current version: v${currentVersion}`)}`);
1458
+ console.log("");
1459
+
1460
+ // Determine install type: git clone (has .git) or npm global install
1461
+ const gitDir = join(FORGE_HOME, ".git");
1462
+ if (existsSync(gitDir)) {
1463
+ // -----------------------------------------------------------------------
1464
+ // Git install path
1465
+ // -----------------------------------------------------------------------
1466
+ // Snapshot installed commands before the update so we can show a diff after
1467
+ const commandsBefore = await getInstalledCommandNames();
1468
+ try {
1469
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1470
+ cwd: FORGE_HOME,
187
1471
  encoding: "utf-8",
1472
+ stdio: ["pipe", "pipe", "pipe"],
1473
+ timeout: 5000,
188
1474
  }).trim();
1475
+
189
1476
  if (branch !== "main") {
190
- console.log(` Not on main branch (${branch}) — skipping`);
1477
+ console.log(
1478
+ ` ${yellow("Not on main branch")} (${branch}) — skipping automatic update.`,
1479
+ );
1480
+ console.log(` Switch to ${cyan("main")} and re-run to update.`);
1481
+ console.log("");
191
1482
  return;
192
1483
  }
193
1484
 
194
1485
  const before = execSync("git rev-parse HEAD", {
195
1486
  cwd: FORGE_HOME,
196
1487
  encoding: "utf-8",
1488
+ stdio: ["pipe", "pipe", "pipe"],
1489
+ timeout: 5000,
197
1490
  }).trim();
198
- execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
199
- execSync("git merge --ff-only origin/main --quiet", {
1491
+
1492
+ // Fetch — graceful offline fallback
1493
+ try {
1494
+ execSync("git fetch origin main --quiet", {
1495
+ cwd: FORGE_HOME,
1496
+ stdio: ["pipe", "pipe", "pipe"],
1497
+ timeout: 10000,
1498
+ });
1499
+ } catch {
1500
+ console.log(
1501
+ ` ${yellow("Offline or unreachable.")} Could not fetch from origin.`,
1502
+ );
1503
+ console.log(
1504
+ ` ${dim("Skipping update check — re-run when connected.")}`,
1505
+ );
1506
+ console.log("");
1507
+ return;
1508
+ }
1509
+
1510
+ const remoteHead = execSync("git rev-parse origin/main", {
200
1511
  cwd: FORGE_HOME,
201
- });
1512
+ encoding: "utf-8",
1513
+ stdio: ["pipe", "pipe", "pipe"],
1514
+ timeout: 5000,
1515
+ }).trim();
1516
+
1517
+ if (before === remoteHead) {
1518
+ // Already up to date
1519
+ const lines = [
1520
+ "",
1521
+ ` ${green("✔")} ForgeDock v${currentVersion} — already up to date`,
1522
+ "",
1523
+ ];
1524
+ process.stdout.write(box(lines, { title: "Up to date" }));
1525
+ console.log("");
1526
+ return;
1527
+ }
1528
+
1529
+ // Show changelog between current HEAD and remote HEAD
1530
+ let changelogLines = [];
1531
+ try {
1532
+ const log = execSync(`git log --oneline ${before}..origin/main`, {
1533
+ cwd: FORGE_HOME,
1534
+ encoding: "utf-8",
1535
+ stdio: ["pipe", "pipe", "pipe"],
1536
+ timeout: 5000,
1537
+ }).trim();
1538
+ if (log) {
1539
+ changelogLines = log.split("\n").map((l) => ` ${dim(l)}`);
1540
+ }
1541
+ } catch {
1542
+ // Non-blocking — skip changelog if log fails
1543
+ }
1544
+
1545
+ if (changelogLines.length > 0) {
1546
+ const boxLines = ["", ...changelogLines, ""];
1547
+ process.stdout.write(box(boxLines, { title: "What's new" }));
1548
+ console.log("");
1549
+ }
1550
+
1551
+ // Apply update
1552
+ try {
1553
+ execSync("git merge --ff-only origin/main --quiet", {
1554
+ cwd: FORGE_HOME,
1555
+ stdio: ["pipe", "pipe", "pipe"],
1556
+ timeout: 15000,
1557
+ });
1558
+ } catch {
1559
+ // Check if the only changes are whitespace/line-ending noise (CRLF drift, etc.)
1560
+ let isWhitespaceOnly = false;
1561
+ try {
1562
+ const diffStat = execFileSync(
1563
+ "git",
1564
+ [
1565
+ "diff",
1566
+ "--ignore-all-space",
1567
+ "--ignore-blank-lines",
1568
+ "--name-only",
1569
+ ],
1570
+ {
1571
+ cwd: FORGE_HOME,
1572
+ encoding: "utf-8",
1573
+ stdio: ["pipe", "pipe", "pipe"],
1574
+ timeout: 5000,
1575
+ },
1576
+ ).trim();
1577
+ isWhitespaceOnly = diffStat === "";
1578
+ } catch {
1579
+ // Diff check failed — fall back to generic message
1580
+ }
1581
+
1582
+ console.log(
1583
+ ` ${yellow("Cannot fast-forward")} — local changes exist. Skipping merge.`,
1584
+ );
1585
+ if (isWhitespaceOnly) {
1586
+ console.log(
1587
+ ` ${dim("Only whitespace/line-ending differences detected.")}`,
1588
+ );
1589
+ console.log(
1590
+ ` ${dim("Run")} ${cyan("git checkout -- .")} ${dim("to discard them, then re-run")} ${cyan("npx forgedock update")}${dim(".")}`,
1591
+ );
1592
+ } else {
1593
+ console.log(` ${dim("Stash or discard local changes and re-run.")}`);
1594
+ }
1595
+ console.log("");
1596
+ return;
1597
+ }
1598
+
202
1599
  const after = execSync("git rev-parse HEAD", {
203
1600
  cwd: FORGE_HOME,
204
1601
  encoding: "utf-8",
1602
+ stdio: ["pipe", "pipe", "pipe"],
1603
+ timeout: 5000,
205
1604
  }).trim();
206
1605
 
207
- if (before === after) {
208
- console.log(` Already up to date.`);
209
- } else {
210
- console.log(` ${GREEN}Updated to latest.${RESET}`);
211
- await install();
1606
+ const newVersion = getVersion();
1607
+ console.log(` ${green("Updated")} v${currentVersion} → v${newVersion}`);
1608
+ console.log("");
1609
+
1610
+ // Invalidate the update-check cache so the next tuiOnboarding() run
1611
+ // reflects the freshly-applied update without hitting the network again.
1612
+ try {
1613
+ const cacheDir = join(HOME, ".forgedock");
1614
+ await mkdir(cacheDir, { recursive: true });
1615
+ writeFileSync(
1616
+ _getUpdateCheckCachePath(),
1617
+ JSON.stringify(
1618
+ {
1619
+ checkedAt: Date.now(),
1620
+ updateAvailable: false,
1621
+ latestVersion: newVersion,
1622
+ remoteHead: after,
1623
+ },
1624
+ null,
1625
+ 2,
1626
+ ) + "\n",
1627
+ { encoding: "utf-8" },
1628
+ );
1629
+ } catch {
1630
+ // Best-effort — cache write failure is non-fatal
1631
+ }
1632
+
1633
+ // Re-run symlink installer to pick up new/removed commands
1634
+ await install();
1635
+
1636
+ // Show command diff
1637
+ const commandsAfter = await getInstalledCommandNames();
1638
+ const diffLines = formatCommandDiff(commandsBefore, commandsAfter);
1639
+ if (diffLines.length > 0) {
1640
+ process.stdout.write(box(diffLines, { title: "Command changes" }));
1641
+ console.log("");
212
1642
  }
213
1643
  } catch (err) {
214
1644
  console.log(
215
- ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
1645
+ ` ${RED}Update failed.${RESET} ${err instanceof Error ? err.message : String(err)}`,
216
1646
  );
1647
+ console.log("");
217
1648
  }
218
1649
  } else {
219
- console.log(
220
- ` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
221
- );
1650
+ // -----------------------------------------------------------------------
1651
+ // npm install path
1652
+ // -----------------------------------------------------------------------
1653
+ console.log(` ${dim("Installed via npm.")}`);
1654
+ console.log("");
1655
+
1656
+ // Query registry for latest version — graceful offline fallback
1657
+ // Only use \r line-clearing trick in TTY environments; in non-TTY just print a static line
1658
+ if (process.stdout.isTTY) {
1659
+ process.stdout.write(` Checking npm registry...`);
1660
+ } else {
1661
+ console.log(` Checking npm registry...`);
1662
+ }
1663
+ const latestVersion = queryNpmRegistry("forgedock");
1664
+
1665
+ if (latestVersion === null) {
1666
+ if (process.stdout.isTTY) {
1667
+ process.stdout.write(
1668
+ `\r ${yellow("Offline or registry unreachable.")} Could not check for updates.\n`,
1669
+ );
1670
+ } else {
1671
+ console.log(
1672
+ ` Offline or registry unreachable. Could not check for updates.`,
1673
+ );
1674
+ }
1675
+ console.log(
1676
+ ` ${dim("Re-run when connected, or check: ")}${cyan("https://www.npmjs.com/package/forgedock")}`,
1677
+ );
1678
+ console.log("");
1679
+ return;
1680
+ }
1681
+
1682
+ // Clear the "Checking..." line (TTY only — non-TTY already printed a newline above)
1683
+ if (process.stdout.isTTY) {
1684
+ process.stdout.write(`\r${" ".repeat(50)}\r`);
1685
+ }
1686
+
1687
+ if (currentVersion === latestVersion) {
1688
+ const lines = [
1689
+ "",
1690
+ ` ${green("✔")} ForgeDock v${currentVersion} — already up to date`,
1691
+ "",
1692
+ ];
1693
+ process.stdout.write(box(lines, { title: "Up to date" }));
1694
+ console.log("");
1695
+ return;
1696
+ }
1697
+
1698
+ // Update available
1699
+ const lines = [
1700
+ "",
1701
+ ` ${yellow("Update available:")} v${currentVersion} → ${green(`v${latestVersion}`)}`,
1702
+ "",
1703
+ ` Run the following command to update:`,
1704
+ "",
1705
+ ` ${cyan(`npm update -g forgedock`)}`,
1706
+ "",
1707
+ ` Then re-run ${cyan("npx forgedock")} to refresh your commands.`,
1708
+ "",
1709
+ ];
1710
+ process.stdout.write(box(lines, { title: "npm update available" }));
1711
+ console.log("");
222
1712
  }
223
- console.log("");
224
1713
  }
225
1714
 
226
1715
  async function init() {
1716
+ // init() generates forge.yaml from prompts and git remote detection.
1717
+ // It does NOT require gh CLI or gh auth for core operation (project board
1718
+ // auto-discovery is optional and guarded separately). Only warn if Claude
1719
+ // Code is missing — it's the primary consumer of the generated config.
1720
+ try {
1721
+ execSync("claude --version", { stdio: ["pipe", "pipe", "pipe"] });
1722
+ } catch {
1723
+ console.log(` ${YELLOW}!${RESET} Claude Code CLI not found on PATH.`);
1724
+ console.log(
1725
+ ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
1726
+ );
1727
+ console.log("");
1728
+ }
1729
+
227
1730
  console.log("");
228
- console.log(`${BOLD}ForgeDock${RESET} — Generating forge.yaml`);
1731
+ console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
229
1732
  console.log("");
230
1733
 
231
1734
  const cwd = process.cwd();
232
- const worktreeBase = join(cwd, ".claude", "worktrees");
233
1735
  const outputPath = join(cwd, "forge.yaml");
234
1736
 
235
- // --- Detect git remote URL and parse owner/repo ---
236
- let owner = "your-github-org";
237
- let repo = "your-repo-name";
1737
+ // ---------------------------------------------------------------------------
1738
+ // Auto-detect defaults (silent — used as pre-fill values for prompts)
1739
+ // ---------------------------------------------------------------------------
1740
+
1741
+ // Detect git remote URL and parse owner/repo
1742
+ let detectedOwner = "your-github-org";
1743
+ let detectedRepo = "your-repo-name";
238
1744
  let remoteDetected = false;
239
1745
 
240
1746
  try {
@@ -252,60 +1758,42 @@ async function init() {
252
1758
  );
253
1759
 
254
1760
  if (sshMatch) {
255
- owner = sshMatch[1];
256
- repo = sshMatch[2];
1761
+ detectedOwner = sshMatch[1];
1762
+ detectedRepo = sshMatch[2];
257
1763
  remoteDetected = true;
258
1764
  } else if (httpsMatch) {
259
- owner = httpsMatch[1];
260
- repo = httpsMatch[2];
1765
+ detectedOwner = httpsMatch[1];
1766
+ detectedRepo = httpsMatch[2];
261
1767
  remoteDetected = true;
262
- } else {
263
- console.log(
264
- ` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
265
- );
266
1768
  }
267
1769
  } catch {
268
- console.log(
269
- ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
270
- );
1770
+ // No remote — use placeholders
271
1771
  }
272
1772
 
273
- if (remoteDetected) {
274
- console.log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
275
- }
276
-
277
- // --- Detect default branch ---
278
- let defaultBranch = "main";
1773
+ // Detect default branch
1774
+ let detectedDefault = "main";
279
1775
  try {
280
1776
  const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
281
1777
  cwd,
282
1778
  encoding: "utf-8",
283
1779
  stdio: ["pipe", "pipe", "pipe"],
284
1780
  }).trim();
285
- // refs/remotes/origin/main → main
286
- defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
287
- console.log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
1781
+ detectedDefault = headRef.replace(/^refs\/remotes\/origin\//, "");
288
1782
  } catch {
289
- // Fallback: try git rev-parse
290
1783
  try {
291
- defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
1784
+ const cur = execSync("git rev-parse --abbrev-ref HEAD", {
292
1785
  cwd,
293
1786
  encoding: "utf-8",
294
1787
  stdio: ["pipe", "pipe", "pipe"],
295
1788
  }).trim();
296
- if (defaultBranch === "HEAD") defaultBranch = "main";
297
- console.log(
298
- ` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
299
- );
1789
+ if (cur && cur !== "HEAD") detectedDefault = cur;
300
1790
  } catch {
301
- console.log(
302
- ` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
303
- );
1791
+ // Keep "main"
304
1792
  }
305
1793
  }
306
1794
 
307
- // --- Detect staging branch ---
308
- let stagingBranch = "staging";
1795
+ // Detect staging branch
1796
+ let detectedStaging = detectedDefault;
309
1797
  try {
310
1798
  const remoteBranches = execSync("git branch -r", {
311
1799
  cwd,
@@ -313,67 +1801,870 @@ async function init() {
313
1801
  stdio: ["pipe", "pipe", "pipe"],
314
1802
  });
315
1803
  if (remoteBranches.includes("origin/staging")) {
316
- stagingBranch = "staging";
317
- console.log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
318
- } else {
319
- stagingBranch = defaultBranch;
320
- console.log(
321
- ` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
322
- );
1804
+ detectedStaging = "staging";
323
1805
  }
324
1806
  } catch {
325
- console.log(
326
- ` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
327
- );
328
- stagingBranch = defaultBranch;
329
- }
330
-
331
- // --- Handle existing forge.yaml ---
332
- if (existsSync(outputPath)) {
333
- const baseBak = join(cwd, "forge.yaml.bak");
334
- const backupPath = existsSync(baseBak)
335
- ? join(
336
- cwd,
337
- `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
338
- )
339
- : baseBak;
340
- const backupName = backupPath.split("/").pop();
341
- renameSync(outputPath, backupPath);
342
- console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
1807
+ // Keep default
343
1808
  }
344
1809
 
345
- // --- Generate forge.yaml content ---
346
- const projectName = repo
1810
+ // Derive project name from repo slug
1811
+ const detectedName = detectedRepo
347
1812
  .split(/[-_]/)
348
1813
  .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
349
1814
  .join(" ");
350
1815
 
351
- const content = `# forge.yaml — ForgeDock Configuration
352
- #
353
- # Auto-generated by: npx forgedock init
354
- # Edit this file with your project details.
355
- #
356
- # Required sections: project, paths, branches
357
- # Optional sections: repos, project_board, services, review, verification
358
- #
359
- # See docs/CONFIG.md for full reference.
1816
+ // Non-TTY: skip interactive prompts and use detected values directly
1817
+ if (!process.stdin.isTTY) {
1818
+ if (!remoteDetected) {
1819
+ console.log(
1820
+ ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
1821
+ );
1822
+ }
1823
+ // Silent write — same as pre-interactive behavior
1824
+ _writeForgeYaml({
1825
+ outputPath,
1826
+ cwd,
1827
+ owner: detectedOwner,
1828
+ repo: detectedRepo,
1829
+ projectName: detectedName,
1830
+ description: "",
1831
+ root: cwd,
1832
+ worktreeBase: join(cwd, ".claude", "worktrees"),
1833
+ defaultBranch: detectedDefault,
1834
+ stagingBranch: detectedStaging,
1835
+ });
1836
+ console.log(` ${GREEN}Created${RESET}: forge.yaml`);
1837
+ console.log("");
1838
+ _printNextSteps({ remoteDetected });
1839
+ await validate(outputPath);
1840
+ return;
1841
+ }
360
1842
 
361
- # =============================================================================
362
- # PROJECT (REQUIRED)
363
- # =============================================================================
1843
+ // ---------------------------------------------------------------------------
1844
+ // Interactive flow — prompt for each required section
1845
+ // ---------------------------------------------------------------------------
364
1846
 
365
- project:
366
- name: "${projectName}"
367
- owner: "${owner}"
368
- repo: "${repo}"
369
- description: ""
1847
+ // Loop until user confirms the preview
1848
+ let confirmed = false;
370
1849
 
371
- # =============================================================================
372
- # PATHS (REQUIRED)
1850
+ while (!confirmed) {
1851
+ console.log(
1852
+ dim(
1853
+ " Auto-detected values are shown as defaults. Press Enter to accept.",
1854
+ ),
1855
+ );
1856
+ console.log("");
1857
+
1858
+ // --- Project section ---
1859
+ console.log(bold(" Project"));
1860
+
1861
+ const ownerInput = await input(
1862
+ " GitHub owner (org or user)",
1863
+ detectedOwner,
1864
+ );
1865
+ const repoInput = await input(" Repository name", detectedRepo);
1866
+ const nameInput = await input(
1867
+ " Project name",
1868
+ detectedName !== "Your-repo-name"
1869
+ ? detectedName
1870
+ : repoInput
1871
+ .split(/[-_]/)
1872
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1873
+ .join(" "),
1874
+ );
1875
+ const descInput = await input(" Brief description", "");
1876
+
1877
+ // --- Paths section ---
1878
+ console.log("");
1879
+ console.log(bold(" Paths"));
1880
+
1881
+ const rootInput = await input(" Repository root (absolute path)", cwd);
1882
+ const worktreeInput = await input(
1883
+ " Worktree base (for git worktrees)",
1884
+ join(rootInput || cwd, ".claude", "worktrees"),
1885
+ );
1886
+
1887
+ // --- Branches section ---
1888
+ console.log("");
1889
+ console.log(bold(" Branches"));
1890
+
1891
+ const defaultBranchInput = await input(" Default branch", detectedDefault);
1892
+ const stagingBranchInput = await input(
1893
+ " Staging branch (PR target for fast-lane changes)",
1894
+ detectedStaging,
1895
+ );
1896
+
1897
+ // --- Preview ---
1898
+ console.log("");
1899
+
1900
+ const previewLines = buildForgeYamlContent({
1901
+ owner: ownerInput,
1902
+ repo: repoInput,
1903
+ projectName: nameInput,
1904
+ description: descInput,
1905
+ root: rootInput || cwd,
1906
+ worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
1907
+ defaultBranch: defaultBranchInput,
1908
+ stagingBranch: stagingBranchInput,
1909
+ });
1910
+
1911
+ // Show preview in a box
1912
+ const previewDisplay = previewLines
1913
+ .split("\n")
1914
+ .slice(0, 30) // show first 30 lines of required sections only
1915
+ .join("\n");
1916
+
1917
+ process.stdout.write(
1918
+ box(previewDisplay, {
1919
+ title: "forge.yaml preview (required sections)",
1920
+ padding: 1,
1921
+ }),
1922
+ );
1923
+
1924
+ confirmed = await confirm(" Write this forge.yaml?", true);
1925
+
1926
+ if (!confirmed) {
1927
+ console.log("");
1928
+ console.log(dim(" Starting over — re-enter values below."));
1929
+ console.log("");
1930
+ } else {
1931
+ // -----------------------------------------------------------------------
1932
+ // Optional sections — multi-select, then guided prompts per section
1933
+ // -----------------------------------------------------------------------
1934
+ console.log("");
1935
+ console.log(bold(" Optional Sections"));
1936
+ console.log(
1937
+ dim(
1938
+ " Select sections to configure now. Unselected sections are written as",
1939
+ ),
1940
+ );
1941
+ console.log(
1942
+ dim(
1943
+ " commented-out placeholders — you can enable them later by editing forge.yaml.",
1944
+ ),
1945
+ );
1946
+ console.log("");
1947
+
1948
+ const OPTIONAL_SECTION_CHOICES = [
1949
+ {
1950
+ label:
1951
+ "Project Board — GitHub Projects v2 integration for workflow tracking",
1952
+ value: "projectBoard",
1953
+ },
1954
+ {
1955
+ label: "Multi-Repo — Satellite repos for cross-repo milestones",
1956
+ value: "multiRepo",
1957
+ },
1958
+ {
1959
+ label:
1960
+ "Review Context — Tech stack and conventions for PR review agents",
1961
+ value: "review",
1962
+ },
1963
+ {
1964
+ label:
1965
+ "Verification — Health check endpoints and response patterns",
1966
+ value: "verification",
1967
+ },
1968
+ ];
1969
+
1970
+ const selectedSections = await multiSelect(
1971
+ " Which optional sections would you like to configure?",
1972
+ OPTIONAL_SECTION_CHOICES,
1973
+ );
1974
+
1975
+ /** @type {Record<string, object>} */
1976
+ const optionalSections = {};
1977
+
1978
+ // --- Project Board prompts ---
1979
+ if (selectedSections.includes("projectBoard")) {
1980
+ const discovered = await discoverProjectBoard(
1981
+ ownerInput || detectedOwner,
1982
+ );
1983
+ if (discovered) {
1984
+ // Auto-discovery succeeded — use resolved IDs
1985
+ optionalSections.projectBoard = {
1986
+ projectNumber: discovered.projectNumber,
1987
+ projectId: discovered.projectId,
1988
+ fieldIds: discovered.fieldIds,
1989
+ optionIds: discovered.optionIds,
1990
+ };
1991
+ } else {
1992
+ // Fallback: manual entry (no regression from previous behaviour)
1993
+ console.log("");
1994
+ console.log(bold(" Project Board (manual)"));
1995
+ console.log(
1996
+ dim(
1997
+ " Find your project number: gh project list --owner " +
1998
+ (ownerInput || detectedOwner),
1999
+ ),
2000
+ );
2001
+ const projectNumber = await input(
2002
+ " GitHub Projects v2 project number",
2003
+ "1",
2004
+ );
2005
+ optionalSections.projectBoard = {
2006
+ projectNumber: parseInt(projectNumber, 10) || 1,
2007
+ };
2008
+ console.log(
2009
+ dim(
2010
+ " Field IDs (PVT_/PVTSSF_ strings) must be added manually after generation.",
2011
+ ),
2012
+ );
2013
+ console.log(
2014
+ dim(
2015
+ " Run: gh project field-list " +
2016
+ (projectNumber || "1") +
2017
+ " --owner " +
2018
+ (ownerInput || detectedOwner),
2019
+ ),
2020
+ );
2021
+ }
2022
+ }
2023
+
2024
+ // --- Multi-Repo prompts ---
2025
+ if (selectedSections.includes("multiRepo")) {
2026
+ console.log("");
2027
+ console.log(bold(" Multi-Repo"));
2028
+ console.log(
2029
+ dim(
2030
+ " Configure one satellite repo (add more by editing forge.yaml).",
2031
+ ),
2032
+ );
2033
+ const prefix = await input(
2034
+ " Satellite repo prefix (e.g. 'mcp', 'sdk')",
2035
+ "sat",
2036
+ );
2037
+ const satelliteRepo = await input(
2038
+ " Satellite repo name (just the name, owner will be reused)",
2039
+ "your-satellite-repo",
2040
+ );
2041
+ const satelliteBranch = await input(
2042
+ " Satellite default/staging branch",
2043
+ "main",
2044
+ );
2045
+ optionalSections.multiRepo = { prefix, satelliteRepo, satelliteBranch };
2046
+ }
2047
+
2048
+ // --- Review Context prompts ---
2049
+ if (selectedSections.includes("review")) {
2050
+ console.log("");
2051
+ console.log(bold(" Review Context"));
2052
+ const techStack = await input(
2053
+ " Tech stack (e.g. Next.js, FastAPI, PostgreSQL)",
2054
+ "Node.js, TypeScript",
2055
+ );
2056
+ const context = await input(
2057
+ " Architecture notes (one line; expand in forge.yaml later)",
2058
+ "",
2059
+ );
2060
+ optionalSections.review = { techStack, context };
2061
+ }
2062
+
2063
+ // --- Verification prompts ---
2064
+ if (selectedSections.includes("verification")) {
2065
+ console.log("");
2066
+ console.log(bold(" Verification"));
2067
+ const healthEndpoint = await input(
2068
+ " Health check endpoint URL",
2069
+ `https://api.${repoInput || detectedRepo}.io/health`,
2070
+ );
2071
+ optionalSections.verification = { healthEndpoint };
2072
+ }
2073
+
2074
+ // -----------------------------------------------------------------------
2075
+ // Handle existing forge.yaml with confirmation
2076
+ // -----------------------------------------------------------------------
2077
+ if (existsSync(outputPath)) {
2078
+ console.log("");
2079
+ console.log(` ${YELLOW}forge.yaml already exists.${RESET}`);
2080
+ const shouldOverwrite = await confirm(
2081
+ " Back up existing forge.yaml and overwrite?",
2082
+ true,
2083
+ );
2084
+ if (!shouldOverwrite) {
2085
+ console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
2086
+ console.log("");
2087
+ return;
2088
+ }
2089
+
2090
+ // Backup with timestamped name if .bak already exists (fix from #36)
2091
+ const baseBak = join(cwd, "forge.yaml.bak");
2092
+ const backupPath = existsSync(baseBak)
2093
+ ? join(
2094
+ cwd,
2095
+ `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
2096
+ )
2097
+ : baseBak;
2098
+ const backupName = basename(backupPath);
2099
+ renameSync(outputPath, backupPath);
2100
+ console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
2101
+ }
2102
+
2103
+ // Write the file
2104
+ _writeForgeYaml({
2105
+ outputPath,
2106
+ cwd,
2107
+ owner: ownerInput,
2108
+ repo: repoInput,
2109
+ projectName: nameInput,
2110
+ description: descInput,
2111
+ root: rootInput || cwd,
2112
+ worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
2113
+ defaultBranch: defaultBranchInput,
2114
+ stagingBranch: stagingBranchInput,
2115
+ optionalSections,
2116
+ });
2117
+
2118
+ console.log("");
2119
+ console.log(` ${GREEN}Created${RESET}: forge.yaml`);
2120
+ if (selectedSections.length > 0) {
2121
+ console.log(
2122
+ ` ${GREEN}Configured${RESET}: ${selectedSections
2123
+ .map(
2124
+ (s) =>
2125
+ ({
2126
+ projectBoard: "project_board",
2127
+ multiRepo: "repos",
2128
+ review: "review",
2129
+ verification: "verification",
2130
+ })[s],
2131
+ )
2132
+ .join(", ")}`,
2133
+ );
2134
+ }
2135
+ console.log("");
2136
+ _printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
2137
+ await validate(outputPath);
2138
+ }
2139
+ }
2140
+ }
2141
+
2142
+ // ---------------------------------------------------------------------------
2143
+ // Helpers for init()
2144
+ // ---------------------------------------------------------------------------
2145
+
2146
+ /**
2147
+ * Sanitize a string value for safe insertion into a YAML double-quoted scalar.
2148
+ * Escapes backslashes (so a trailing \ does not corrupt the closing quote),
2149
+ * then strips double-quotes and newlines to prevent YAML injection.
2150
+ * Backslash escaping MUST come first — before any other replacement.
2151
+ *
2152
+ * @param {string} value
2153
+ * @returns {string}
2154
+ */
2155
+ function _sanitizeYamlValue(value) {
2156
+ return String(value)
2157
+ .replace(/\\/g, "\\\\")
2158
+ .replace(/"/g, "")
2159
+ .replace(/[\r\n]/g, " ")
2160
+ .trim();
2161
+ }
2162
+
2163
+ /**
2164
+ * Sanitize a file-system path value for safe insertion into a YAML double-quoted scalar.
2165
+ * Escapes backslashes (Windows paths) in addition to stripping double-quotes and newlines.
2166
+ *
2167
+ * @param {string} value
2168
+ * @returns {string}
2169
+ */
2170
+ function _sanitizePathValue(value) {
2171
+ return String(value)
2172
+ .replace(/\\/g, "\\\\")
2173
+ .replace(/"/g, "")
2174
+ .replace(/[\r\n]/g, " ")
2175
+ .trim();
2176
+ }
2177
+
2178
+ /**
2179
+ * Attempt to auto-discover GitHub Projects v2 configuration for the given owner.
2180
+ *
2181
+ * Runs two gh CLI calls:
2182
+ * 1. gh project list --owner {owner} --format json → project select menu
2183
+ * 2. gh project field-list {number} --owner {owner} --format json → field/option mapping
2184
+ *
2185
+ * Shows a summary box and asks the user to confirm before returning data.
2186
+ *
2187
+ * @param {string} owner - GitHub org or username (user-typed via \`input()\` — untrusted)
2188
+ * @returns {Promise<{projectNumber: number, projectId: string, fieldIds: object, optionIds: object} | null>}
2189
+ * Resolved project board config, or null if skipped / discovery failed.
2190
+ *
2191
+ * @security \`owner\` is a raw user-typed value from an interactive prompt and is UNTRUSTED.
2192
+ * All gh CLI invocations in this function MUST use \`execFileSync\` with an argument array —
2193
+ * never interpolate \`owner\` into an \`execSync\` shell string. execFileSync does not invoke
2194
+ * a shell, so metacharacters in \`owner\` are treated literally and cannot escape argument
2195
+ * boundaries. (Ref: review-finding #149 — PR #148 originally used execSync template literals)
2196
+ */
2197
+ async function discoverProjectBoard(owner) {
2198
+ // Non-TTY: skip auto-discovery — can't prompt
2199
+ if (!process.stdout.isTTY) {
2200
+ return null;
2201
+ }
2202
+
2203
+ console.log("");
2204
+ console.log(bold(" Project Board"));
2205
+ console.log(dim(" Searching for GitHub Projects v2…"));
2206
+
2207
+ // -------------------------------------------------------------------------
2208
+ // Step 1: List projects
2209
+ // -------------------------------------------------------------------------
2210
+ let projects = [];
2211
+ try {
2212
+ const raw = execFileSync(
2213
+ "gh",
2214
+ ["project", "list", "--owner", owner, "--format", "json"],
2215
+ {
2216
+ encoding: "utf-8",
2217
+ stdio: ["pipe", "pipe", "pipe"],
2218
+ },
2219
+ );
2220
+ const parsed = JSON.parse(raw);
2221
+ projects = parsed.projects || [];
2222
+ } catch {
2223
+ // gh not available, auth failure, or no projects access — fall back silently
2224
+ console.log(
2225
+ dim(" Could not list projects — falling back to manual entry."),
2226
+ );
2227
+ return null;
2228
+ }
2229
+
2230
+ if (projects.length === 0) {
2231
+ console.log(dim(" No GitHub Projects v2 boards found for this owner."));
2232
+ console.log(
2233
+ dim(
2234
+ " You can add project_board configuration manually in forge.yaml later.",
2235
+ ),
2236
+ );
2237
+ return null;
2238
+ }
2239
+
2240
+ // Build select menu choices
2241
+ const projectChoices = projects.map((p) => ({
2242
+ label: `${p.title} ${dim("(#" + p.number + ", " + (p.items?.totalCount ?? "?") + " items)")}`,
2243
+ value: p.number,
2244
+ }));
2245
+ projectChoices.push({
2246
+ label: dim("Skip — configure project board manually later"),
2247
+ value: null,
2248
+ });
2249
+
2250
+ console.log("");
2251
+ const selectedNumber = await select(
2252
+ " Which GitHub Project board?",
2253
+ projectChoices,
2254
+ );
2255
+ if (selectedNumber === null) {
2256
+ return null;
2257
+ }
2258
+
2259
+ // Look up the selected project's id
2260
+ const selectedProject = projects.find((p) => p.number === selectedNumber);
2261
+ const projectId = selectedProject?.id ?? "";
2262
+ const projectTitle = _sanitizeYamlValue(
2263
+ selectedProject?.title ?? `Project #${selectedNumber}`,
2264
+ );
2265
+
2266
+ // -------------------------------------------------------------------------
2267
+ // Step 2: Fetch field list
2268
+ // -------------------------------------------------------------------------
2269
+ console.log("");
2270
+ console.log(dim(" Fetching project fields…"));
2271
+
2272
+ let fields = [];
2273
+ try {
2274
+ const raw = execFileSync(
2275
+ "gh",
2276
+ [
2277
+ "project",
2278
+ "field-list",
2279
+ String(selectedNumber),
2280
+ "--owner",
2281
+ owner,
2282
+ "--format",
2283
+ "json",
2284
+ ],
2285
+ {
2286
+ encoding: "utf-8",
2287
+ stdio: ["pipe", "pipe", "pipe"],
2288
+ },
2289
+ );
2290
+ const parsed = JSON.parse(raw);
2291
+ fields = (parsed.fields || []).filter(
2292
+ (f) => f.type === "ProjectV2SingleSelectField",
2293
+ );
2294
+ } catch {
2295
+ console.log(
2296
+ dim(" Could not fetch project fields — falling back to manual entry."),
2297
+ );
2298
+ return null;
2299
+ }
2300
+
2301
+ // -------------------------------------------------------------------------
2302
+ // Step 3: Match fields and options by name
2303
+ // -------------------------------------------------------------------------
2304
+
2305
+ /**
2306
+ * Match a field by checking if its name case-insensitively includes the target keyword.
2307
+ * Returns the field or null.
2308
+ */
2309
+ function matchField(keyword) {
2310
+ // Exact match first (case-insensitive)
2311
+ const exact = fields.find(
2312
+ (f) => f.name.toLowerCase() === keyword.toLowerCase(),
2313
+ );
2314
+ if (exact) return { field: exact, fuzzy: false };
2315
+ // Fuzzy: name contains keyword
2316
+ const fuzzy = fields.find((f) =>
2317
+ f.name.toLowerCase().includes(keyword.toLowerCase()),
2318
+ );
2319
+ if (fuzzy) return { field: fuzzy, fuzzy: true };
2320
+ return null;
2321
+ }
2322
+
2323
+ // Known field keys and their expected name keywords
2324
+ const FIELD_TARGETS = [
2325
+ { key: "status", keywords: ["status"] },
2326
+ { key: "lane", keywords: ["lane", "track"] },
2327
+ { key: "component", keywords: ["component"] },
2328
+ { key: "priority", keywords: ["priority"] },
2329
+ { key: "workflow", keywords: ["workflow"] },
2330
+ ];
2331
+
2332
+ // Known option name → forge.yaml key mappings per field
2333
+ const OPTION_MAPS = {
2334
+ status: { todo: "todo", "in progress": "in_progress", done: "done" },
2335
+ lane: { fast: "fast", feature: "feature", sync: "sync" },
2336
+ priority: { p0: "p0", p1: "p1", p2: "p2", p3: "p3" },
2337
+ workflow: {
2338
+ investigating: "investigating",
2339
+ "ready to build": "ready_to_build",
2340
+ building: "building",
2341
+ "in review": "in_review",
2342
+ merged: "merged",
2343
+ invalid: "invalid",
2344
+ decomposed: "decomposed",
2345
+ },
2346
+ component: {}, // component options vary by project — map all options by slugifying the name
2347
+ };
2348
+
2349
+ const resolvedFieldIds = {};
2350
+ const resolvedOptionIds = {};
2351
+ const fuzzyMatches = [];
2352
+
2353
+ for (const target of FIELD_TARGETS) {
2354
+ let match = null;
2355
+ for (const kw of target.keywords) {
2356
+ match = matchField(kw);
2357
+ if (match) break;
2358
+ }
2359
+ if (!match) continue;
2360
+
2361
+ const { field, fuzzy } = match;
2362
+ if (fuzzy) {
2363
+ fuzzyMatches.push({ key: target.key, fieldName: field.name });
2364
+ }
2365
+ resolvedFieldIds[target.key] = field.id;
2366
+
2367
+ // Map option IDs
2368
+ const optMap = OPTION_MAPS[target.key] ?? {};
2369
+ const mappedOptions = {};
2370
+
2371
+ if (target.key === "component") {
2372
+ // For component, map every option by slugifying its name (a–z, 0–9, underscore)
2373
+ for (const opt of field.options || []) {
2374
+ const slug = opt.name
2375
+ .toLowerCase()
2376
+ .replace(/[^a-z0-9]+/g, "_")
2377
+ .replace(/^_|_$/g, "");
2378
+ mappedOptions[slug] = opt.id;
2379
+ }
2380
+ } else {
2381
+ for (const opt of field.options || []) {
2382
+ const nameLower = opt.name.toLowerCase();
2383
+ const mappedKey = optMap[nameLower];
2384
+ if (mappedKey) {
2385
+ mappedOptions[mappedKey] = opt.id;
2386
+ }
2387
+ }
2388
+ }
2389
+
2390
+ if (Object.keys(mappedOptions).length > 0) {
2391
+ resolvedOptionIds[target.key] = mappedOptions;
2392
+ }
2393
+ }
2394
+
2395
+ // -------------------------------------------------------------------------
2396
+ // Step 4: Confirm fuzzy matches (if any)
2397
+ // -------------------------------------------------------------------------
2398
+ for (const { key, fieldName } of fuzzyMatches) {
2399
+ console.log("");
2400
+ console.log(
2401
+ yellow(
2402
+ ` Fuzzy match: field "${fieldName}" mapped to forge.yaml key "${key}"`,
2403
+ ),
2404
+ );
2405
+ const accepted = await confirm(
2406
+ ` Accept this mapping (${fieldName} → ${key})?`,
2407
+ true,
2408
+ );
2409
+ if (!accepted) {
2410
+ delete resolvedFieldIds[key];
2411
+ delete resolvedOptionIds[key];
2412
+ }
2413
+ }
2414
+
2415
+ // -------------------------------------------------------------------------
2416
+ // Step 5: Show summary box and confirm
2417
+ // -------------------------------------------------------------------------
2418
+ const summaryLines = [
2419
+ `Project: ${projectTitle} (#${selectedNumber})`,
2420
+ `Project ID: ${projectId}`,
2421
+ "",
2422
+ "Fields:",
2423
+ ];
2424
+ for (const [key, id] of Object.entries(resolvedFieldIds)) {
2425
+ const optCount = resolvedOptionIds[key]
2426
+ ? Object.keys(resolvedOptionIds[key]).length
2427
+ : 0;
2428
+ const optLabel =
2429
+ optCount > 0 ? ` (${optCount} options)` : " (no options mapped)";
2430
+ summaryLines.push(` ${key.padEnd(10)}: ${id}${optLabel}`);
2431
+ }
2432
+ const unmapped = FIELD_TARGETS.map((t) => t.key).filter(
2433
+ (k) => !resolvedFieldIds[k],
2434
+ );
2435
+ if (unmapped.length > 0) {
2436
+ summaryLines.push("");
2437
+ summaryLines.push(
2438
+ `Not found: ${unmapped.join(", ")} — placeholders will be used`,
2439
+ );
2440
+ }
2441
+
2442
+ console.log("");
2443
+ console.log(box(summaryLines, { title: "Project Board Config" }));
2444
+
2445
+ const confirmed = await confirm(" Use this configuration?", true);
2446
+ if (!confirmed) {
2447
+ console.log(
2448
+ dim(
2449
+ " Skipped auto-discovery. You can add project_board manually in forge.yaml.",
2450
+ ),
2451
+ );
2452
+ return null;
2453
+ }
2454
+
2455
+ return {
2456
+ projectNumber: selectedNumber,
2457
+ projectId,
2458
+ fieldIds: resolvedFieldIds,
2459
+ optionIds: resolvedOptionIds,
2460
+ };
2461
+ }
2462
+
2463
+ /**
2464
+ * Build the forge.yaml file content string from gathered values.
2465
+ * Required sections are always written as active YAML.
2466
+ * Optional sections are written as active YAML when config is provided in `optionalSections`,
2467
+ * or as commented-out placeholders when absent.
2468
+ *
2469
+ * @param {object} opts
2470
+ * @param {string} opts.owner
2471
+ * @param {string} opts.repo
2472
+ * @param {string} opts.projectName
2473
+ * @param {string} opts.description
2474
+ * @param {string} opts.root
2475
+ * @param {string} opts.worktreeBase
2476
+ * @param {string} opts.defaultBranch
2477
+ * @param {string} opts.stagingBranch
2478
+ * @param {object} [opts.optionalSections] - Optional section configs; absent key = commented-out
2479
+ * @param {object} [opts.optionalSections.projectBoard] - project_board section config
2480
+ * @param {object} [opts.optionalSections.multiRepo] - repos section config
2481
+ * @param {object} [opts.optionalSections.review] - review section config
2482
+ * @param {object} [opts.optionalSections.verification] - verification section config
2483
+ */
2484
+ function buildForgeYamlContent({
2485
+ owner,
2486
+ repo,
2487
+ projectName,
2488
+ description,
2489
+ root,
2490
+ worktreeBase,
2491
+ defaultBranch,
2492
+ stagingBranch,
2493
+ optionalSections = {},
2494
+ }) {
2495
+ // Sanitize all user-supplied string values before template interpolation to prevent
2496
+ // YAML injection via double-quotes or embedded newlines in double-quoted scalars.
2497
+ owner = _sanitizeYamlValue(owner);
2498
+ repo = _sanitizeYamlValue(repo);
2499
+ projectName = _sanitizeYamlValue(projectName);
2500
+ description = _sanitizeYamlValue(description);
2501
+ defaultBranch = _sanitizeYamlValue(defaultBranch);
2502
+ stagingBranch = _sanitizeYamlValue(stagingBranch);
2503
+ // Path values: also escape backslashes so Windows paths are valid YAML.
2504
+ // Preserve rawRoot for use in path.join() operations — join() expects raw OS paths,
2505
+ // not YAML-escaped strings. Always apply _sanitizePathValue() to the join() result.
2506
+ const rawRoot = root;
2507
+ root = _sanitizePathValue(root);
2508
+ worktreeBase = _sanitizePathValue(worktreeBase);
2509
+
2510
+ const { projectBoard, multiRepo, review, verification } = optionalSections;
2511
+
2512
+ // Sanitize optional-section user-supplied strings.
2513
+ const safeMultiRepo = multiRepo
2514
+ ? {
2515
+ prefix: _sanitizeYamlValue(multiRepo.prefix || "sat"),
2516
+ satelliteRepo: _sanitizeYamlValue(
2517
+ multiRepo.satelliteRepo || "your-satellite-repo",
2518
+ ),
2519
+ satelliteBranch: _sanitizeYamlValue(
2520
+ multiRepo.satelliteBranch || "main",
2521
+ ),
2522
+ }
2523
+ : null;
2524
+
2525
+ const safeReview = review
2526
+ ? {
2527
+ techStack: _sanitizeYamlValue(
2528
+ review.techStack || "Node.js, TypeScript, PostgreSQL",
2529
+ ),
2530
+ // context uses a block scalar (|) — only strip double-quotes; newlines are handled by split/join
2531
+ context: (
2532
+ review.context || "Add architecture notes and conventions here."
2533
+ ).replace(/"/g, ""),
2534
+ }
2535
+ : null;
2536
+
2537
+ const safeVerification = verification
2538
+ ? {
2539
+ healthEndpoint: _sanitizeYamlValue(
2540
+ verification.healthEndpoint || `https://api.${repo}.io/health`,
2541
+ ),
2542
+ }
2543
+ : null;
2544
+
2545
+ // --- repos section ---
2546
+ const reposSection = safeMultiRepo
2547
+ ? `repos:
2548
+ default:
2549
+ repo: "${owner}/${repo}"
2550
+ staging_branch: "${stagingBranch}"
2551
+ satellites:
2552
+ - prefix: "${safeMultiRepo.prefix}"
2553
+ repo: "${owner}/${safeMultiRepo.satelliteRepo}"
2554
+ staging_branch: "${safeMultiRepo.satelliteBranch}"
2555
+ local_path: "${_sanitizePathValue(join(rawRoot, "..", safeMultiRepo.satelliteRepo))}"`
2556
+ : `# repos:
2557
+ # default:
2558
+ # repo: "${owner}/${repo}"
2559
+ # staging_branch: "${stagingBranch}"
2560
+ # satellites:
2561
+ # - prefix: "mcp"
2562
+ # repo: "${owner}/your-satellite-repo"
2563
+ # staging_branch: "main"
2564
+ # local_path: "${_sanitizePathValue(join(rawRoot, "..", "your-satellite-repo"))}"`;
2565
+
2566
+ // --- project_board section ---
2567
+ // Build field_ids block — use resolved IDs where available, placeholders otherwise
2568
+ const FIELD_KEYS = ["status", "lane", "component", "priority", "workflow"];
2569
+ const resolvedFieldIds = projectBoard?.fieldIds ?? {};
2570
+ const resolvedOptionIds = projectBoard?.optionIds ?? {};
2571
+
2572
+ /**
2573
+ * Build a YAML block for option_ids given a nested map of { fieldKey: { optionKey: id } }.
2574
+ * Only writes fields that have at least one mapped option.
2575
+ */
2576
+ function _buildOptionIdsBlock(optionIds) {
2577
+ const entries = Object.entries(optionIds).filter(
2578
+ ([, opts]) => Object.keys(opts).length > 0,
2579
+ );
2580
+ if (entries.length === 0) return "";
2581
+ const lines = [" option_ids:"];
2582
+ for (const [fieldKey, opts] of entries) {
2583
+ lines.push(` ${fieldKey}:`);
2584
+ for (const [optKey, optId] of Object.entries(opts)) {
2585
+ lines.push(` ${optKey}: "${optId}"`);
2586
+ }
2587
+ }
2588
+ return "\n" + lines.join("\n");
2589
+ }
2590
+
2591
+ const fieldIdsBlock = FIELD_KEYS.map(
2592
+ (k) =>
2593
+ ` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
2594
+ ).join("\n");
2595
+
2596
+ const optionIdsBlock = projectBoard?.optionIds
2597
+ ? _buildOptionIdsBlock(resolvedOptionIds)
2598
+ : "";
2599
+
2600
+ const hasDiscoveredId = !!projectBoard?.projectId;
2601
+
2602
+ const projectBoardSection = projectBoard
2603
+ ? `project_board:
2604
+ owner: "${owner}"
2605
+ project_number: ${projectBoard.projectNumber || 1}
2606
+ project_id: "${hasDiscoveredId ? projectBoard.projectId : "PVT_kwHOxxxxxxxxxxxxxxxx"}"
2607
+ field_ids:
2608
+ ${fieldIdsBlock}${optionIdsBlock}${!hasDiscoveredId ? `\n# To find IDs: gh project field-list ${projectBoard.projectNumber || 1} --owner ${owner}` : ""}`
2609
+ : `# project_board:
2610
+ # owner: "${owner}"
2611
+ # project_number: 1
2612
+ # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
2613
+ # field_ids:
2614
+ # status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
2615
+ # lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
2616
+ # component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
2617
+ # priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
2618
+ # workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"`;
2619
+
2620
+ // --- review section ---
2621
+ const reviewSection = safeReview
2622
+ ? `review:
2623
+ tech_stack: "${safeReview.techStack}"
2624
+ context: |
2625
+ ${safeReview.context.split("\n").join("\n ")}`
2626
+ : `# review:
2627
+ # tech_stack: "Node.js, TypeScript, PostgreSQL"
2628
+ # context: |
2629
+ # Describe your repo structure and any unusual conventions here.`;
2630
+
2631
+ // --- verification section ---
2632
+ const verificationSection = safeVerification
2633
+ ? `verification:
2634
+ health_endpoint: "${safeVerification.healthEndpoint}"
2635
+ health_patterns:
2636
+ - '"status": "ok"'`
2637
+ : `# verification:
2638
+ # health_endpoint: "https://api.${repo}.io/health"
2639
+ # health_patterns:
2640
+ # - '"status": "ok"'`;
2641
+
2642
+ return `# forge.yaml — ForgeDock Configuration
2643
+ #
2644
+ # Generated by: npx forgedock init
2645
+ # Edit this file with your project details.
2646
+ #
2647
+ # Required sections: project, paths, branches
2648
+ # Optional sections: repos, project_board, services, review, verification
2649
+ #
2650
+ # See docs/CONFIG.md for full reference.
2651
+
2652
+ # =============================================================================
2653
+ # PROJECT (REQUIRED)
2654
+ # =============================================================================
2655
+
2656
+ project:
2657
+ name: "${projectName}"
2658
+ owner: "${owner}"
2659
+ repo: "${repo}"
2660
+ description: "${description}"
2661
+
2662
+ # =============================================================================
2663
+ # PATHS (REQUIRED)
373
2664
  # =============================================================================
374
2665
 
375
2666
  paths:
376
- root: "${cwd}"
2667
+ root: "${root}"
377
2668
  worktree_base: "${worktreeBase}"
378
2669
 
379
2670
  # =============================================================================
@@ -390,15 +2681,7 @@ branches:
390
2681
  # Multi-repo configuration. Remove the # to enable.
391
2682
  # =============================================================================
392
2683
 
393
- # repos:
394
- # default:
395
- # repo: "${owner}/${repo}"
396
- # staging_branch: "${stagingBranch}"
397
- # satellites:
398
- # - prefix: "mcp"
399
- # repo: "${owner}/your-satellite-repo"
400
- # staging_branch: "main"
401
- # local_path: "${join(cwd, "..", "your-satellite-repo")}"
2684
+ ${reposSection}
402
2685
 
403
2686
  # =============================================================================
404
2687
  # PROJECT BOARD (OPTIONAL)
@@ -406,103 +2689,1351 @@ branches:
406
2689
  # To find IDs: gh project list --owner ${owner}
407
2690
  # =============================================================================
408
2691
 
409
- # project_board:
410
- # owner: "${owner}"
411
- # project_number: 1
412
- # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
413
- # field_ids:
414
- # status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
415
- # lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
416
- # component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
417
- # priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
418
- # workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
2692
+ ${projectBoardSection}
419
2693
 
420
2694
  # =============================================================================
421
2695
  # REVIEW (OPTIONAL)
422
2696
  # Context injected into review agent prompts.
423
2697
  # =============================================================================
424
2698
 
425
- # review:
426
- # tech_stack: "Node.js, TypeScript, PostgreSQL"
427
- # context: |
428
- # Describe your repo structure and any unusual conventions here.
2699
+ ${reviewSection}
429
2700
 
430
2701
  # =============================================================================
431
2702
  # VERIFICATION (OPTIONAL)
432
2703
  # Health-check patterns for quality gate and validate commands.
433
2704
  # =============================================================================
434
2705
 
435
- # verification:
436
- # health_endpoint: "https://api.${repo}.io/health"
437
- # health_patterns:
438
- # - '"status": "ok"'
2706
+ ${verificationSection}
2707
+
2708
+ # =============================================================================
2709
+ # BOT (OPTIONAL)
2710
+ # GitHub App bot identity for pipeline operations.
2711
+ # Credentials are stored separately in ~/.forgedock/credentials.json — not here.
2712
+ # Run: npx forgedock bot setup to configure interactively.
2713
+ # Run: npx forgedock bot status to verify.
2714
+ # =============================================================================
2715
+
2716
+ # bot:
2717
+ # app_id: 12345
2718
+ # installation_id: 67890
2719
+ # private_key_path: "~/.forgedock/forgedock-bot.private-key.pem"
2720
+ # # When configured, pipeline commands use the app token instead of personal token.
2721
+ # # Bot-authored comments include <!-- forgedock-bot --> for audit filtering.
439
2722
  `;
2723
+ }
440
2724
 
441
- writeFileSync(outputPath, content, "utf-8");
2725
+ /** Write forge.yaml to disk. Accepts all params for buildForgeYamlContent plus outputPath. */
2726
+ function _writeForgeYaml(opts) {
2727
+ const content = buildForgeYamlContent(opts);
2728
+ writeFileSync(opts.outputPath, content, "utf-8");
2729
+ }
442
2730
 
443
- console.log(` ${GREEN}Created${RESET}: forge.yaml`);
444
- console.log("");
2731
+ /** Print next-steps guidance after forge.yaml is written. */
2732
+ function _printNextSteps({ remoteDetected }) {
445
2733
  console.log(`${BOLD}Next steps:${RESET}`);
446
- console.log(
447
- ` 1. Edit ${CYAN}forge.yaml${RESET} — fill in your project details`,
448
- );
449
- console.log(` Required: project.name, project.description`);
450
2734
  if (!remoteDetected) {
451
2735
  console.log(
452
- ` Required: project.owner, project.repo (could not auto-detect)`,
2736
+ ` ${YELLOW}!${RESET} Edit ${CYAN}forge.yaml${RESET} — set project.owner and project.repo`,
453
2737
  );
454
2738
  }
455
2739
  console.log(
456
- ` 2. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
2740
+ ` 1. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
457
2741
  );
458
2742
  console.log(
459
- ` 3. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
2743
+ ` 2. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
460
2744
  );
461
2745
  console.log("");
462
2746
  }
463
2747
 
464
- function help() {
465
- console.log("");
466
- console.log(
467
- `${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`,
468
- );
469
- console.log("");
470
- console.log("Usage:");
471
- console.log(
472
- ` ${CYAN}npx forgedock${RESET} Install commands (default)`,
473
- );
474
- console.log(` ${CYAN}npx forgedock install${RESET} Install commands`);
475
- console.log(
476
- ` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
477
- );
478
- console.log(` ${CYAN}npx forgedock uninstall${RESET} Remove commands`);
479
- console.log(
480
- ` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
481
- );
2748
+ // ---------------------------------------------------------------------------
2749
+ // Config Validation
2750
+ // ---------------------------------------------------------------------------
2751
+
2752
+ /**
2753
+ * Parse a simple top-level YAML key from a forge.yaml string.
2754
+ * Only handles the specific patterns used in forge.yaml required sections.
2755
+ *
2756
+ * @param {string} content - File content
2757
+ * @param {string} key - Dotted key, e.g. "project.owner"
2758
+ * @returns {string} - Trimmed value with surrounding quotes stripped, or empty string if not found
2759
+ *
2760
+ * @security Values returned by this function are UNTRUSTED user-controlled strings from forge.yaml.
2761
+ * - Callers MUST pass returned values as discrete arguments to execFileSync (args array) — never
2762
+ * interpolate into an execSync shell command string.
2763
+ * - execFileSync does not invoke a shell, so metacharacters in values are treated literally.
2764
+ * - When interpolation into a string argument is unavoidable (e.g. GraphQL query fields),
2765
+ * validate the value against a strict allowlist regex before use.
2766
+ */
2767
+ function _parseYamlKey(content, key) {
2768
+ const parts = key.split(".");
2769
+ const lines = content.split("\n");
2770
+
2771
+ if (parts.length === 2) {
2772
+ const [section, field] = parts;
2773
+ let inSection = false;
2774
+ for (const line of lines) {
2775
+ if (/^[a-z_]+:/.test(line)) {
2776
+ inSection = line.startsWith(`${section}:`);
2777
+ continue;
2778
+ }
2779
+ if (inSection && line.startsWith(` ${field}:`)) {
2780
+ const raw = line.replace(` ${field}:`, "").trim();
2781
+ return raw.replace(/^["']|["']$/g, "");
2782
+ }
2783
+ }
2784
+ }
2785
+ return "";
2786
+ }
2787
+
2788
+ /**
2789
+ * Check whether a YAML section is active (uncommented) in the content.
2790
+ * @param {string} content
2791
+ * @param {string} section - top-level section name, e.g. "project_board"
2792
+ * @returns {boolean}
2793
+ */
2794
+ function _sectionActive(content, section) {
2795
+ return content.split("\n").some((line) => line === `${section}:`);
2796
+ }
2797
+
2798
+ /**
2799
+ * Extract all satellite repo values from an active repos: section.
2800
+ * Returns an array of "owner/repo" strings from lines like:
2801
+ * repo: "owner/satellite-repo"
2802
+ * under the satellites: list (skipping the default: sub-key).
2803
+ *
2804
+ * @param {string} content
2805
+ * @returns {string[]}
2806
+ */
2807
+ function _parseSatelliteRepos(content) {
2808
+ const lines = content.split("\n");
2809
+ const repos = [];
2810
+ let inRepos = false;
2811
+ let inSatellites = false;
2812
+
2813
+ for (const line of lines) {
2814
+ if (/^[a-z_]+:/.test(line)) {
2815
+ inRepos = line.startsWith("repos:");
2816
+ inSatellites = false;
2817
+ continue;
2818
+ }
2819
+ if (!inRepos) continue;
2820
+
2821
+ if (line.trim() === "default:") {
2822
+ continue;
2823
+ }
2824
+ if (line.trim() === "satellites:") {
2825
+ inSatellites = true;
2826
+ continue;
2827
+ }
2828
+
2829
+ if (inSatellites && /^\s{6,}repo:/.test(line)) {
2830
+ const raw = line
2831
+ .replace(/^\s+repo:\s*/, "")
2832
+ .replace(/^["']|["']$/g, "")
2833
+ .trim();
2834
+ if (raw) repos.push(raw);
2835
+ }
2836
+ }
2837
+ return repos;
2838
+ }
2839
+
2840
+ /**
2841
+ * Validate the forge.yaml at the given path.
2842
+ *
2843
+ * Runs a series of checks and renders a summary box.
2844
+ * Never throws — all check failures are surfaced as warnings/errors in the output.
2845
+ *
2846
+ * @param {string} forgeYamlPath - Absolute path to forge.yaml
2847
+ * @returns {Promise<{ passed: boolean, checks: Array<{label: string, status: 'ok'|'warn'|'error', note: string}> }>}
2848
+ */
2849
+ async function validate(forgeYamlPath) {
2850
+ /** @type {Array<{label: string, status: 'ok'|'warn'|'error', note: string}>} */
2851
+ const checks = [];
2852
+
2853
+ // 1. Read forge.yaml
2854
+ if (!existsSync(forgeYamlPath)) {
2855
+ checks.push({
2856
+ label: "forge.yaml found",
2857
+ status: "error",
2858
+ note: `Not found: ${forgeYamlPath}`,
2859
+ });
2860
+ _renderValidationSummary(checks);
2861
+ return { passed: false, checks };
2862
+ }
2863
+
2864
+ let content;
2865
+ try {
2866
+ content = readFileSync(forgeYamlPath, "utf-8");
2867
+ } catch (err) {
2868
+ checks.push({
2869
+ label: "forge.yaml found",
2870
+ status: "error",
2871
+ note: `Cannot read: ${err.message}`,
2872
+ });
2873
+ _renderValidationSummary(checks);
2874
+ return { passed: false, checks };
2875
+ }
2876
+
2877
+ checks.push({ label: "forge.yaml found", status: "ok", note: forgeYamlPath });
2878
+
2879
+ // 2. Required fields
2880
+ const PLACEHOLDERS = new Set([
2881
+ "your-github-org",
2882
+ "your-repo-name",
2883
+ "",
2884
+ "your-org",
2885
+ "your-repo",
2886
+ ]);
2887
+
2888
+ const owner = _parseYamlKey(content, "project.owner");
2889
+ const repo = _parseYamlKey(content, "project.repo");
2890
+ const root = _parseYamlKey(content, "paths.root");
2891
+ const worktreeBase = _parseYamlKey(content, "paths.worktree_base");
2892
+ const defaultBranch = _parseYamlKey(content, "branches.default");
2893
+
2894
+ const missingFields = [];
2895
+ if (!owner || PLACEHOLDERS.has(owner)) missingFields.push("project.owner");
2896
+ if (!repo || PLACEHOLDERS.has(repo)) missingFields.push("project.repo");
2897
+ if (!root) missingFields.push("paths.root");
2898
+ if (!defaultBranch) missingFields.push("branches.default");
2899
+
2900
+ if (missingFields.length === 0) {
2901
+ checks.push({
2902
+ label: "Required fields",
2903
+ status: "ok",
2904
+ note: `${owner}/${repo}`,
2905
+ });
2906
+ } else {
2907
+ checks.push({
2908
+ label: "Required fields",
2909
+ status: "error",
2910
+ note: `Missing or placeholder: ${missingFields.join(", ")}`,
2911
+ });
2912
+ }
2913
+
2914
+ // 3. paths.root exists
2915
+ if (root) {
2916
+ if (existsSync(root)) {
2917
+ checks.push({ label: "paths.root exists", status: "ok", note: root });
2918
+ } else {
2919
+ checks.push({
2920
+ label: "paths.root exists",
2921
+ status: "warn",
2922
+ note: `Directory not found: ${root}`,
2923
+ });
2924
+ }
2925
+ }
2926
+
2927
+ // 4. Create worktree_base if missing
2928
+ if (worktreeBase) {
2929
+ if (existsSync(worktreeBase)) {
2930
+ checks.push({
2931
+ label: "worktree_base exists",
2932
+ status: "ok",
2933
+ note: worktreeBase,
2934
+ });
2935
+ } else {
2936
+ try {
2937
+ await mkdir(worktreeBase, { recursive: true });
2938
+ checks.push({
2939
+ label: "worktree_base exists",
2940
+ status: "ok",
2941
+ note: `Created: ${worktreeBase}`,
2942
+ });
2943
+ } catch (err) {
2944
+ checks.push({
2945
+ label: "worktree_base exists",
2946
+ status: "warn",
2947
+ note: `Cannot create: ${err.message}`,
2948
+ });
2949
+ }
2950
+ }
2951
+ }
2952
+
2953
+ // 5. GitHub repo access
2954
+ if (owner && repo && !PLACEHOLDERS.has(owner) && !PLACEHOLDERS.has(repo)) {
2955
+ try {
2956
+ execFileSync(
2957
+ "gh",
2958
+ ["repo", "view", `${owner}/${repo}`, "--json", "name"],
2959
+ {
2960
+ stdio: ["pipe", "pipe", "pipe"],
2961
+ timeout: 10000,
2962
+ },
2963
+ );
2964
+ checks.push({
2965
+ label: "GitHub repo accessible",
2966
+ status: "ok",
2967
+ note: `${owner}/${repo}`,
2968
+ });
2969
+ } catch {
2970
+ checks.push({
2971
+ label: "GitHub repo accessible",
2972
+ status: "warn",
2973
+ note: `Cannot access ${owner}/${repo} — check owner/repo or run: gh auth login`,
2974
+ });
2975
+ }
2976
+ } else {
2977
+ checks.push({
2978
+ label: "GitHub repo accessible",
2979
+ status: "warn",
2980
+ note: "Skipped — owner/repo not set",
2981
+ });
2982
+ }
2983
+
2984
+ // 6. Branch existence on remote
2985
+ const stagingBranch = _parseYamlKey(content, "branches.staging");
2986
+ const branchesToCheck = [
2987
+ ...new Set([defaultBranch, stagingBranch].filter(Boolean)),
2988
+ ];
2989
+
2990
+ if (root && existsSync(root) && branchesToCheck.length > 0) {
2991
+ for (const branch of branchesToCheck) {
2992
+ try {
2993
+ const result = execFileSync(
2994
+ "git",
2995
+ ["ls-remote", "--heads", "origin", branch],
2996
+ {
2997
+ cwd: root,
2998
+ stdio: ["pipe", "pipe", "pipe"],
2999
+ encoding: "utf-8",
3000
+ timeout: 10000,
3001
+ },
3002
+ );
3003
+ if (result.trim()) {
3004
+ checks.push({
3005
+ label: `Branch: ${branch}`,
3006
+ status: "ok",
3007
+ note: "Exists on remote",
3008
+ });
3009
+ } else {
3010
+ checks.push({
3011
+ label: `Branch: ${branch}`,
3012
+ status: "warn",
3013
+ note: "Not found on remote",
3014
+ });
3015
+ }
3016
+ } catch {
3017
+ checks.push({
3018
+ label: `Branch: ${branch}`,
3019
+ status: "warn",
3020
+ note: "Cannot verify — git error",
3021
+ });
3022
+ }
3023
+ }
3024
+ }
3025
+
3026
+ // 7. Project board validation
3027
+ if (_sectionActive(content, "project_board")) {
3028
+ const projectId = _parseYamlKey(content, "project_board.project_id");
3029
+ if (!projectId || projectId.includes("xxxx")) {
3030
+ checks.push({
3031
+ label: "Project board configured",
3032
+ status: "warn",
3033
+ note: "project_id is a placeholder — run /forgedock-init to configure",
3034
+ });
3035
+ } else if (!/^PVT_[A-Za-z0-9_=\-]+$/.test(projectId)) {
3036
+ checks.push({
3037
+ label: "Project board configured",
3038
+ status: "error",
3039
+ note: `project_id must match /^PVT_[A-Za-z0-9_=\\-]+$/ (got: ${projectId.slice(0, 12)}...)`,
3040
+ });
3041
+ } else {
3042
+ try {
3043
+ // Use -F (typed variable) to pass projectId as a GraphQL variable rather than
3044
+ // interpolating it into the query string, even though the regex above already
3045
+ // validates it to safe characters.
3046
+ const result = execFileSync(
3047
+ "gh",
3048
+ [
3049
+ "api",
3050
+ "graphql",
3051
+ "-F",
3052
+ `id=${projectId}`,
3053
+ "-f",
3054
+ "query=query($id: ID!) { node(id: $id) { id __typename } }",
3055
+ ],
3056
+ {
3057
+ stdio: ["pipe", "pipe", "pipe"],
3058
+ encoding: "utf-8",
3059
+ timeout: 10000,
3060
+ },
3061
+ );
3062
+ const parsed = JSON.parse(result);
3063
+ if (parsed?.data?.node?.id) {
3064
+ checks.push({
3065
+ label: "Project board configured",
3066
+ status: "ok",
3067
+ note: `${projectId.slice(0, 16)}... resolves`,
3068
+ });
3069
+ } else {
3070
+ checks.push({
3071
+ label: "Project board configured",
3072
+ status: "warn",
3073
+ note: "project_id may be invalid — verify with: gh project list",
3074
+ });
3075
+ }
3076
+ } catch {
3077
+ checks.push({
3078
+ label: "Project board configured",
3079
+ status: "warn",
3080
+ note: "Cannot verify project_id — gh error",
3081
+ });
3082
+ }
3083
+ }
3084
+ } else {
3085
+ checks.push({
3086
+ label: "Project board",
3087
+ status: "warn",
3088
+ note: "Not configured (optional)",
3089
+ });
3090
+ }
3091
+
3092
+ // 8. Satellite repo access
3093
+ if (_sectionActive(content, "repos")) {
3094
+ const satellites = _parseSatelliteRepos(content);
3095
+ if (satellites.length === 0) {
3096
+ checks.push({
3097
+ label: "Satellite repos",
3098
+ status: "warn",
3099
+ note: "repos: section active but no satellites found",
3100
+ });
3101
+ } else {
3102
+ for (const sat of satellites) {
3103
+ try {
3104
+ execFileSync("gh", ["repo", "view", sat, "--json", "name"], {
3105
+ stdio: ["pipe", "pipe", "pipe"],
3106
+ timeout: 10000,
3107
+ });
3108
+ checks.push({
3109
+ label: `Satellite: ${sat}`,
3110
+ status: "ok",
3111
+ note: "Accessible",
3112
+ });
3113
+ } catch {
3114
+ checks.push({
3115
+ label: `Satellite: ${sat}`,
3116
+ status: "warn",
3117
+ note: "Cannot access — check repo name or gh auth",
3118
+ });
3119
+ }
3120
+ }
3121
+ }
3122
+ } else {
3123
+ checks.push({
3124
+ label: "Satellite repos",
3125
+ status: "warn",
3126
+ note: "Not configured (optional)",
3127
+ });
3128
+ }
3129
+
3130
+ _renderValidationSummary(checks);
3131
+ const hasError = checks.some((c) => c.status === "error");
3132
+ return { passed: !hasError, checks };
3133
+ }
3134
+
3135
+ /**
3136
+ * Render the validation summary box to stdout.
3137
+ * @param {Array<{label: string, status: 'ok'|'warn'|'error', note: string}>} checks
3138
+ */
3139
+ function _renderValidationSummary(checks) {
3140
+ const lines = [""];
3141
+
3142
+ for (const check of checks) {
3143
+ let icon;
3144
+ let labelFn;
3145
+ if (check.status === "ok") {
3146
+ icon = green("✓");
3147
+ labelFn = (s) => s;
3148
+ } else if (check.status === "warn") {
3149
+ icon = yellow("!");
3150
+ labelFn = yellow;
3151
+ } else {
3152
+ icon = red("✗");
3153
+ labelFn = red;
3154
+ }
3155
+
3156
+ const noteStr = check.note ? ` ${dim("(" + check.note + ")")}` : "";
3157
+ lines.push(` ${icon} ${labelFn(check.label)}${noteStr}`);
3158
+ }
3159
+
3160
+ const hasErrors = checks.some((c) => c.status === "error");
3161
+ const boardOk = checks.some(
3162
+ (c) => c.label === "Project board configured" && c.status === "ok",
3163
+ );
3164
+
3165
+ lines.push("");
3166
+ lines.push(` ${bold("Next steps:")}`);
3167
+ if (hasErrors) {
3168
+ lines.push(
3169
+ ` ${dim("•")} Edit ${cyan("forge.yaml")} to fix the errors above`,
3170
+ );
3171
+ }
3172
+ if (!boardOk) {
3173
+ lines.push(
3174
+ ` ${dim("•")} Run ${cyan("/forgedock-init")} for AI-powered optional section setup`,
3175
+ );
3176
+ }
3177
+ lines.push(
3178
+ ` ${dim("•")} Run ${cyan("/work-on next")} to start your first task`,
3179
+ );
3180
+ lines.push("");
3181
+
3182
+ process.stdout.write(box(lines, { title: "Config Validation" }));
3183
+ }
3184
+
3185
+ // ---------------------------------------------------------------------------
3186
+ // Bot credentials — store in ~/.forgedock/credentials.json (NOT forge.yaml)
3187
+ // ---------------------------------------------------------------------------
3188
+
3189
+ const FORGEDOCK_HOME = join(HOME, ".forgedock");
3190
+ const CREDENTIALS_FILE = join(FORGEDOCK_HOME, "credentials.json");
3191
+
3192
+ /**
3193
+ * Save bot credentials to ~/.forgedock/credentials.json.
3194
+ * The private key itself is NOT stored — only the path.
3195
+ *
3196
+ * @param {{ appId: string, installationId: string, privateKeyPath: string }} creds
3197
+ */
3198
+ async function saveBotCredentials(creds) {
3199
+ await mkdir(FORGEDOCK_HOME, { recursive: true, mode: 0o700 });
3200
+ // Tighten permissions on pre-existing installs: fs.mkdir({ recursive: true }) ignores
3201
+ // mode when the directory already exists (Linux kernel behaviour). chmodSync enforces
3202
+ // 0o700 unconditionally, matching the intent of the mode option above.
3203
+ chmodSync(FORGEDOCK_HOME, 0o700);
3204
+ const existing = loadBotCredentials() ?? {};
3205
+ const updated = { ...existing, bot: creds };
3206
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
3207
+ encoding: "utf-8",
3208
+ mode: 0o600,
3209
+ });
3210
+ // Tighten permissions on files created before this fix (mode is only applied at creation time)
3211
+ chmodSync(CREDENTIALS_FILE, 0o600);
3212
+ }
3213
+
3214
+ /**
3215
+ * Load bot credentials from ~/.forgedock/credentials.json.
3216
+ * Returns null if the file is absent or malformed.
3217
+ *
3218
+ * @returns {{ appId: string, installationId: string, privateKeyPath: string } | null}
3219
+ */
3220
+ function loadBotCredentials() {
3221
+ try {
3222
+ const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
3223
+ const parsed = JSON.parse(raw);
3224
+ return parsed.bot ?? null;
3225
+ } catch {
3226
+ return null;
3227
+ }
3228
+ }
3229
+
3230
+ // ---------------------------------------------------------------------------
3231
+ // GitHub App JWT — generates a short-lived RS256 JWT for App-level API calls
3232
+ // ---------------------------------------------------------------------------
3233
+
3234
+ /**
3235
+ * Generate a GitHub App JWT (valid for 10 minutes).
3236
+ *
3237
+ * @param {string} appId - GitHub App ID (numeric string)
3238
+ * @param {string} privateKey - PEM-encoded PKCS#8 or PKCS#1 RSA private key content
3239
+ * @returns {string} Signed JWT
3240
+ */
3241
+ function generateAppJwt(appId, privateKey) {
3242
+ const now = Math.floor(Date.now() / 1000);
3243
+ const payload = {
3244
+ iat: now - 60, // issued 60s ago to account for clock skew
3245
+ exp: now + 600, // expires in 10 minutes (GitHub max)
3246
+ iss: appId,
3247
+ };
3248
+
3249
+ const header = Buffer.from(
3250
+ JSON.stringify({ alg: "RS256", typ: "JWT" }),
3251
+ ).toString("base64url");
3252
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
3253
+ const unsigned = `${header}.${body}`;
3254
+
3255
+ const sign = createSign("RSA-SHA256");
3256
+ sign.update(unsigned);
3257
+ const signature = sign.sign(privateKey, "base64url");
3258
+
3259
+ return `${unsigned}.${signature}`;
3260
+ }
3261
+
3262
+ // ---------------------------------------------------------------------------
3263
+ // Bot — connect existing GitHub App
3264
+ // ---------------------------------------------------------------------------
3265
+
3266
+ /**
3267
+ * Interactive flow: prompt for App ID, private key path, installation ID.
3268
+ * Validates credentials against the GitHub API before saving.
3269
+ */
3270
+ async function connectExistingBot() {
3271
+ console.log("");
3272
+ console.log(
3273
+ box(
3274
+ [
3275
+ `${bold("Connect an existing GitHub App")}`,
3276
+ "",
3277
+ "You'll need:",
3278
+ ` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
3279
+ ` • Private key file (.pem downloaded from app settings)`,
3280
+ ` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
3281
+ ].join("\n"),
3282
+ { title: "Connect GitHub App" },
3283
+ ),
3284
+ );
3285
+ console.log("");
3286
+
3287
+ const appId = await input("App ID (numeric):", "");
3288
+ if (!appId || !/^\d+$/.test(appId.trim())) {
3289
+ console.log(` ${RED}Invalid App ID — must be a numeric string.${RESET}`);
3290
+ return false;
3291
+ }
3292
+
3293
+ const privateKeyPath = await input("Private key path (.pem):", "");
3294
+ const trimmedKeyPath = privateKeyPath.trim();
3295
+ const home = os.homedir();
3296
+ const expanded = trimmedKeyPath.startsWith("~")
3297
+ ? join(home, trimmedKeyPath.slice(1))
3298
+ : trimmedKeyPath;
3299
+ const resolvedKeyPath = resolve(expanded);
3300
+ if (trimmedKeyPath.startsWith("~")) {
3301
+ const rel = relative(home, resolvedKeyPath);
3302
+ if (rel.startsWith("..") || isAbsolute(rel)) {
3303
+ console.log(
3304
+ ` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`,
3305
+ );
3306
+ return false;
3307
+ }
3308
+ }
3309
+ if (!existsSync(resolvedKeyPath)) {
3310
+ console.log(` ${RED}File not found: ${resolvedKeyPath}${RESET}`);
3311
+ return false;
3312
+ }
3313
+
3314
+ const installationId = await input("Installation ID (numeric):", "");
3315
+ if (!installationId || !/^\d+$/.test(installationId.trim())) {
3316
+ console.log(
3317
+ ` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`,
3318
+ );
3319
+ return false;
3320
+ }
3321
+
3322
+ // Validate credentials with GitHub API
3323
+ console.log("");
3324
+ console.log(` Validating credentials…`);
3325
+ let privateKey;
3326
+ try {
3327
+ privateKey = readFileSync(resolvedKeyPath, "utf-8");
3328
+ } catch (err) {
3329
+ console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
3330
+ return false;
3331
+ }
3332
+
3333
+ try {
3334
+ const jwt = generateAppJwt(appId.trim(), privateKey);
3335
+ const resp = await fetch("https://api.github.com/app", {
3336
+ headers: {
3337
+ Authorization: `Bearer ${jwt}`,
3338
+ Accept: "application/vnd.github+json",
3339
+ "X-GitHub-Api-Version": "2022-11-28",
3340
+ },
3341
+ });
3342
+ if (!resp.ok) {
3343
+ const body = await resp.json().catch(() => ({}));
3344
+ console.log(
3345
+ ` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`,
3346
+ );
3347
+ return false;
3348
+ }
3349
+ const appData = await resp.json();
3350
+ console.log(
3351
+ ` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`,
3352
+ );
3353
+ } catch (err) {
3354
+ console.log(` ${RED}Validation failed: ${err.message}${RESET}`);
3355
+ return false;
3356
+ }
3357
+
3358
+ await saveBotCredentials({
3359
+ appId: appId.trim(),
3360
+ installationId: installationId.trim(),
3361
+ privateKeyPath: resolvedKeyPath,
3362
+ });
3363
+
3364
+ console.log(` Credentials saved to ${cyan(CREDENTIALS_FILE)}`);
3365
+ return true;
3366
+ }
3367
+
3368
+ // ---------------------------------------------------------------------------
3369
+ // Bot — status command
3370
+ // ---------------------------------------------------------------------------
3371
+
3372
+ /**
3373
+ * Show bot health, identity, and rate limit usage.
3374
+ * Gracefully handles missing credentials.
3375
+ */
3376
+ async function botStatus() {
3377
+ console.log("");
3378
+ console.log(`${BOLD}ForgeDock Bot Status${RESET}`);
3379
+ console.log("");
3380
+
3381
+ const creds = loadBotCredentials();
3382
+ if (!creds) {
3383
+ console.log(
3384
+ ` ${YELLOW}No bot credentials found.${RESET}\n` +
3385
+ ` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
3386
+ ` add credentials to ${cyan(CREDENTIALS_FILE)}.`,
3387
+ );
3388
+ console.log("");
3389
+ return;
3390
+ }
3391
+
3392
+ console.log(` App ID: ${cyan(creds.appId)}`);
3393
+ console.log(` Installation ID: ${cyan(creds.installationId)}`);
3394
+ console.log(` Private key: ${cyan(creds.privateKeyPath)}`);
3395
+ console.log("");
3396
+
3397
+ // Check private key file
3398
+ if (!existsSync(creds.privateKeyPath)) {
3399
+ console.log(
3400
+ ` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`,
3401
+ );
3402
+ console.log(` Run ${cyan("npx forgedock bot setup")} to reconfigure.`);
3403
+ console.log("");
3404
+ return;
3405
+ }
3406
+
3407
+ console.log(" Verifying with GitHub API…");
3408
+ let privateKey;
3409
+ try {
3410
+ privateKey = readFileSync(creds.privateKeyPath, "utf-8");
3411
+ } catch (err) {
3412
+ console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
3413
+ console.log("");
3414
+ return;
3415
+ }
3416
+
3417
+ try {
3418
+ const jwt = generateAppJwt(creds.appId, privateKey);
3419
+
3420
+ // App identity
3421
+ const appResp = await fetch("https://api.github.com/app", {
3422
+ headers: {
3423
+ Authorization: `Bearer ${jwt}`,
3424
+ Accept: "application/vnd.github+json",
3425
+ "X-GitHub-Api-Version": "2022-11-28",
3426
+ },
3427
+ });
3428
+ if (!appResp.ok) {
3429
+ const body = await appResp.json().catch(() => ({}));
3430
+ console.log(
3431
+ ` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`,
3432
+ );
3433
+ console.log("");
3434
+ return;
3435
+ }
3436
+ const appData = await appResp.json();
3437
+ console.log(
3438
+ ` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`,
3439
+ );
3440
+
3441
+ // Rate limit for app
3442
+ const rlResp = await fetch("https://api.github.com/rate_limit", {
3443
+ headers: {
3444
+ Authorization: `Bearer ${jwt}`,
3445
+ Accept: "application/vnd.github+json",
3446
+ "X-GitHub-Api-Version": "2022-11-28",
3447
+ },
3448
+ });
3449
+ if (rlResp.ok) {
3450
+ const rl = await rlResp.json();
3451
+ const core = rl.resources?.core;
3452
+ if (core) {
3453
+ const used = core.limit - core.remaining;
3454
+ const resetTime = new Date(core.reset * 1000).toLocaleTimeString();
3455
+ console.log(
3456
+ ` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`,
3457
+ );
3458
+ }
3459
+ }
3460
+ } catch (err) {
3461
+ console.log(` ${RED}Error: ${err.message}${RESET}`);
3462
+ }
3463
+
3464
+ console.log("");
3465
+ }
3466
+
3467
+ // ---------------------------------------------------------------------------
3468
+ // Bot — setup TUI step (called from StepOrchestrator or directly)
3469
+ // ---------------------------------------------------------------------------
3470
+
3471
+ /**
3472
+ * Interactive bot setup step.
3473
+ * Shows options: Create new app / Connect existing / Skip.
3474
+ * Called as an optional step in tuiOnboarding() or via `npx forgedock bot setup`.
3475
+ */
3476
+ async function botSetup() {
3477
+ console.log("");
3478
+ console.log(
3479
+ box(
3480
+ [
3481
+ `${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
3482
+ "",
3483
+ "A GitHub App gives ForgeDock its own identity",
3484
+ "for pipeline operations. Benefits:",
3485
+ ` ${cyan("•")} Separate audit trail for bot actions`,
3486
+ ` ${cyan("•")} Higher API rate limits`,
3487
+ ` ${cyan("•")} Webhook-driven automation (future)`,
3488
+ ` ${cyan("•")} Multi-user team support`,
3489
+ ].join("\n"),
3490
+ { title: "GitHub Bot" },
3491
+ ),
3492
+ );
3493
+ console.log("");
3494
+
3495
+ const action = await select("How would you like to set up the bot?", [
3496
+ { label: "Create new GitHub App (opens browser)", value: "create" },
3497
+ { label: "Connect existing GitHub App", value: "connect" },
3498
+ { label: "Skip — use personal token (current default)", value: "skip" },
3499
+ ]);
3500
+
3501
+ if (action === "skip") {
3502
+ console.log(
3503
+ ` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`,
3504
+ );
3505
+ return;
3506
+ }
3507
+
3508
+ if (action === "create") {
3509
+ // Build the GitHub App manifest URL — opens the browser-based creation flow
3510
+ const manifestPath = join(dirname(__filename), "github-app-manifest.json");
3511
+ let manifest;
3512
+ try {
3513
+ manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
3514
+ } catch {
3515
+ console.log(
3516
+ ` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`,
3517
+ );
3518
+ return;
3519
+ }
3520
+
3521
+ const encodedManifest = encodeURIComponent(JSON.stringify(manifest));
3522
+ const createUrl = `https://github.com/settings/apps/new?manifest=${encodedManifest}`;
3523
+
3524
+ console.log("");
3525
+ console.log(
3526
+ ` ${bold("Opening GitHub App registration in your browser…")}`,
3527
+ );
3528
+ console.log(` URL: ${cyan(createUrl.substring(0, 80))}…`);
3529
+ console.log("");
3530
+
3531
+ // Try to open the browser
3532
+ try {
3533
+ const opener =
3534
+ process.platform === "win32"
3535
+ ? "start"
3536
+ : process.platform === "darwin"
3537
+ ? "open"
3538
+ : "xdg-open";
3539
+ // On Windows, `start "url"` treats the first quoted arg as the window title.
3540
+ // Pass an empty title placeholder so the URL is treated as the target: `start "" "url"`.
3541
+ const cmd =
3542
+ process.platform === "win32"
3543
+ ? `start "" "${createUrl}"`
3544
+ : `${opener} "${createUrl}"`;
3545
+ execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
3546
+ } catch {
3547
+ console.log(` ${YELLOW}Could not open browser automatically.${RESET}`);
3548
+ console.log(` Please open this URL manually:`);
3549
+ console.log(` ${cyan(createUrl)}`);
3550
+ }
3551
+
3552
+ console.log("");
3553
+ console.log(" After creating the app in your browser:");
3554
+ console.log(` 1. Download the private key (.pem) from the app settings`);
3555
+ console.log(` 2. Note your App ID and Installation ID`);
3556
+ console.log(
3557
+ ` 3. Run ${cyan("npx forgedock bot setup")} again and choose \"Connect existing\"`,
3558
+ );
3559
+ console.log("");
3560
+ return;
3561
+ }
3562
+
3563
+ // action === "connect"
3564
+ const connected = await connectExistingBot();
3565
+ if (!connected) {
3566
+ throw new Error("Bot connection failed — credentials were not saved.");
3567
+ }
3568
+ }
3569
+
3570
+ function help() {
3571
+ console.log("");
3572
+ console.log(
3573
+ `${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`,
3574
+ );
3575
+ console.log("");
3576
+ console.log("Usage:");
3577
+ console.log(
3578
+ ` ${CYAN}npx forgedock${RESET} Launch TUI onboarding (interactive)`,
3579
+ );
3580
+ console.log(` ${CYAN}npx forgedock install${RESET} Install commands`);
3581
+ console.log(
3582
+ ` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
3583
+ );
3584
+ console.log(
3585
+ ` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`,
3586
+ );
3587
+ console.log(
3588
+ ` ${CYAN}npx forgedock uninstall${RESET} Remove commands (interactive, with confirmation)`,
3589
+ );
3590
+ console.log(
3591
+ ` ${CYAN}npx forgedock uninstall --yes${RESET} Remove commands without prompts (non-interactive)`,
3592
+ );
3593
+ console.log(
3594
+ ` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
3595
+ );
3596
+ console.log(
3597
+ ` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`,
3598
+ );
3599
+ console.log(
3600
+ ` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`,
3601
+ );
3602
+ console.log(
3603
+ ` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`,
3604
+ );
482
3605
  console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
483
3606
  console.log("");
484
3607
  }
485
3608
 
486
- switch (command) {
487
- case "install":
3609
+ // ---------------------------------------------------------------------------
3610
+ // TUI Onboarding — interactive step-based flow (default when no command given)
3611
+ // ---------------------------------------------------------------------------
3612
+
3613
+ async function tuiOnboarding() {
3614
+ splash();
3615
+
3616
+ // Non-TTY fallback: skip TUI and detection, run install directly.
3617
+ // Print a prominent boxed notice so the user understands what happened and
3618
+ // what to do next. Best-effort update/checkout hints are folded into the box.
3619
+ if (!process.stdout.isTTY) {
3620
+ const noticeLines = [
3621
+ "",
3622
+ ` Non-interactive environment detected.`,
3623
+ ` ForgeDock's setup wizard requires an interactive terminal (TTY).`,
3624
+ "",
3625
+ ` ${dim("To run the interactive TUI:")} open a terminal and run ${cyan("npx forgedock")}`,
3626
+ "",
3627
+ ];
3628
+
3629
+ // Best-effort remote check — fold results into the box, never block install.
3630
+ try {
3631
+ const remoteCheck = await checkForRemoteUpdate();
3632
+ if (remoteCheck?.updateAvailable) {
3633
+ const isGitCheckout = existsSync(join(FORGE_HOME, ".git"));
3634
+ if (isGitCheckout) {
3635
+ // User is running from the ForgeDock source checkout and it's behind remote.
3636
+ noticeLines.push(
3637
+ ` ${yellow("Stale local checkout:")} the working-tree bin is behind origin/main.`,
3638
+ );
3639
+ noticeLines.push(
3640
+ ` ${dim("Run")} ${cyan(`git pull`)} ${dim(`in`)} ${dim(FORGE_HOME)}`,
3641
+ );
3642
+ noticeLines.push(
3643
+ ` ${dim("Or use the published package:")} ${cyan("npx forgedock@latest")}`,
3644
+ );
3645
+ } else {
3646
+ const versionHint = remoteCheck.latestVersion
3647
+ ? ` (v${getVersion()} → v${remoteCheck.latestVersion})`
3648
+ : "";
3649
+ noticeLines.push(
3650
+ ` ${yellow(`Update available${versionHint}`)} — run: ${cyan("npx forgedock update")}`,
3651
+ );
3652
+ }
3653
+ noticeLines.push("");
3654
+ }
3655
+ } catch {
3656
+ // Best-effort — never block install
3657
+ }
3658
+
3659
+ noticeLines.push(` Running ${cyan("install")} now...`);
3660
+ noticeLines.push("");
3661
+ process.stdout.write(
3662
+ box(noticeLines, { title: "Non-interactive environment" }),
3663
+ );
3664
+ console.log("");
488
3665
  await install();
489
- break;
490
- case "init":
491
- await init();
492
- break;
493
- case "uninstall":
494
- await uninstall();
495
- break;
496
- case "update":
497
- await update();
498
- break;
499
- case "help":
500
- case "--help":
501
- case "-h":
502
- help();
503
- break;
504
- default:
505
- console.log(`${RED}Unknown command: ${command}${RESET}`);
506
- help();
3666
+ return;
3667
+ }
3668
+
3669
+ // Detect install state before routing into a flow
3670
+ const detection = await detectInstallState();
3671
+
3672
+ // -------------------------------------------------------------------------
3673
+ // Flow: up-to-date
3674
+ // Commands installed, forge.yaml present, same version — show summary
3675
+ // -------------------------------------------------------------------------
3676
+ if (detection.state === "up-to-date") {
3677
+ const versionLabel = detection.installedVersion
3678
+ ? `v${detection.installedVersion}`
3679
+ : `v${detection.currentVersion}`;
3680
+ console.log(
3681
+ ` ${green("Everything up to date.")} ForgeDock ${versionLabel} is installed and configured.`,
3682
+ );
3683
+ console.log("");
3684
+
3685
+ // Proactive remote update check — cached, never blocks TUI startup
3686
+ let remoteUpdateInfo = null;
3687
+ try {
3688
+ remoteUpdateInfo = await checkForRemoteUpdate();
3689
+ } catch {
3690
+ // Best-effort — ignore all errors
3691
+ }
3692
+
3693
+ if (remoteUpdateInfo?.updateAvailable) {
3694
+ // Surface a prominent notice when behind the remote
3695
+ const noticeLines = [""];
3696
+ if (remoteUpdateInfo.latestVersion) {
3697
+ noticeLines.push(
3698
+ ` ${yellow("Update available:")} v${detection.currentVersion} → ${green(`v${remoteUpdateInfo.latestVersion}`)}`,
3699
+ );
3700
+ } else {
3701
+ // Git install: no version string, but commits available
3702
+ noticeLines.push(
3703
+ ` ${yellow("Update available")} — new commits on origin/main`,
3704
+ );
3705
+ }
3706
+ noticeLines.push(
3707
+ "",
3708
+ ` ${dim('Choose "Update now" below or run')} ${cyan("npx forgedock update")}`,
3709
+ "",
3710
+ );
3711
+ process.stdout.write(box(noticeLines, { title: "Update available" }));
3712
+ console.log("");
3713
+ }
3714
+
3715
+ // Build the action menu — insert "Update now" as the first choice when behind
3716
+ /** @type {Array<{label: string, value: string}>} */
3717
+ const menuChoices = [];
3718
+ if (remoteUpdateInfo?.updateAvailable) {
3719
+ menuChoices.push({ label: "Update now", value: "update" });
3720
+ }
3721
+ menuChoices.push(
3722
+ { label: "Nothing — exit", value: "exit" },
3723
+ {
3724
+ label: "Reconfigure project (regenerate forge.yaml)",
3725
+ value: "reconfigure",
3726
+ },
3727
+ { label: "Reinstall commands", value: "reinstall" },
3728
+ );
3729
+
3730
+ const action = await select("What would you like to do?", menuChoices);
3731
+
3732
+ if (action === "update") {
3733
+ console.log("");
3734
+ await update();
3735
+ } else if (action === "reconfigure") {
3736
+ console.log("");
3737
+ await init();
3738
+ } else if (action === "reinstall") {
3739
+ console.log("");
3740
+ await install();
3741
+ }
3742
+ console.log("");
3743
+ return;
3744
+ }
3745
+
3746
+ // -------------------------------------------------------------------------
3747
+ // Flow: update-available
3748
+ // Commands installed, forge.yaml present, newer version running — update
3749
+ // -------------------------------------------------------------------------
3750
+ if (detection.state === "update-available") {
3751
+ console.log(
3752
+ ` ${yellow("Update available.")} ` +
3753
+ `Installed: ${dim(`v${detection.installedVersion ?? "unknown"}`)} → ` +
3754
+ `New: ${green(`v${detection.currentVersion}`)}`,
3755
+ );
3756
+ console.log("");
3757
+
3758
+ // Snapshot commands before update so we can show a diff after
3759
+ const commandsBeforeUpdate = await getInstalledCommandNames();
3760
+
3761
+ const steps = [
3762
+ {
3763
+ name: "Preflight Checks",
3764
+ optional: false,
3765
+ run: async () => {
3766
+ await runPrerequisiteChecklist();
3767
+ },
3768
+ },
3769
+ {
3770
+ name: "Update Commands",
3771
+ optional: false,
3772
+ run: async () => {
3773
+ await install();
3774
+ },
3775
+ },
3776
+ ];
3777
+
3778
+ const orchestrator = new StepOrchestrator(steps);
3779
+ const success = await orchestrator.run();
3780
+
3781
+ if (success) {
3782
+ // Show command diff if any commands were added or removed
3783
+ const commandsAfterUpdate = await getInstalledCommandNames();
3784
+ const diffLines = formatCommandDiff(
3785
+ commandsBeforeUpdate,
3786
+ commandsAfterUpdate,
3787
+ );
3788
+ if (diffLines.length > 0) {
3789
+ process.stdout.write(box(diffLines, { title: "Command changes" }));
3790
+ console.log("");
3791
+ }
3792
+
3793
+ console.log(
3794
+ green("Update complete!") +
3795
+ ` ForgeDock v${detection.currentVersion} is now active.`,
3796
+ );
3797
+ console.log("");
3798
+ } else {
3799
+ console.log("");
3800
+ console.log(
3801
+ yellow("Update incomplete.") +
3802
+ ` Re-run ${cyan("npx forgedock")} to try again.`,
3803
+ );
3804
+ console.log("");
3805
+ process.exit(1);
3806
+ }
3807
+ return;
3808
+ }
3809
+
3810
+ // -------------------------------------------------------------------------
3811
+ // Flow: config-missing
3812
+ // Commands installed but forge.yaml not found — skip to config step
3813
+ // -------------------------------------------------------------------------
3814
+ if (detection.state === "config-missing") {
3815
+ const versionLabel = detection.installedVersion
3816
+ ? `v${detection.installedVersion}`
3817
+ : `v${detection.currentVersion}`;
3818
+ console.log(
3819
+ ` ${yellow("Commands installed")} (${versionLabel}) but no ${cyan("forge.yaml")} found in this directory.`,
3820
+ );
3821
+ console.log("");
3822
+
3823
+ const steps = [
3824
+ {
3825
+ name: "Project Configuration",
3826
+ optional: true,
3827
+ run: async () => {
3828
+ const shouldInit = await confirm(
3829
+ "Generate forge.yaml for this project?",
3830
+ true,
3831
+ );
3832
+ if (shouldInit) {
3833
+ await init();
3834
+ } else {
3835
+ console.log(
3836
+ ` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`,
3837
+ );
3838
+ }
3839
+ },
3840
+ },
3841
+ {
3842
+ name: "Validate Configuration",
3843
+ optional: true,
3844
+ run: async () => {
3845
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
3846
+ if (!existsSync(forgeYamlPath)) {
3847
+ console.log(
3848
+ ` ${dim("No forge.yaml found — skipping validation.")}`,
3849
+ );
3850
+ return;
3851
+ }
3852
+ await validate(forgeYamlPath);
3853
+ },
3854
+ },
3855
+ ];
3856
+
3857
+ const orchestrator = new StepOrchestrator(steps);
3858
+ const success = await orchestrator.run();
3859
+
3860
+ if (success) {
3861
+ console.log(
3862
+ green("Done!") +
3863
+ ` Run ${cyan("/help")} inside Claude Code to see available commands.`,
3864
+ );
3865
+ console.log("");
3866
+ } else {
3867
+ console.log("");
3868
+ console.log(
3869
+ yellow("Setup incomplete.") +
3870
+ ` Re-run ${cyan("npx forgedock")} to try again.`,
3871
+ );
3872
+ console.log("");
3873
+ process.exit(1);
3874
+ }
3875
+ return;
3876
+ }
3877
+
3878
+ // -------------------------------------------------------------------------
3879
+ // Flow: version-unknown
3880
+ // Commands installed (symlinks found) but package.json unreadable — reinstall
3881
+ // -------------------------------------------------------------------------
3882
+ if (detection.state === "version-unknown") {
3883
+ console.log(
3884
+ ` ${yellow("ForgeDock commands are installed")} but the installed version could not be determined.`,
3885
+ );
3886
+ console.log(
3887
+ ` ${dim("The package.json for the installed copy is missing or unreadable.")}`,
3888
+ );
3889
+ console.log("");
3890
+
3891
+ const action = await select("How would you like to proceed?", [
3892
+ { label: "Reinstall commands (recommended)", value: "reinstall" },
3893
+ { label: "Nothing — exit", value: "exit" },
3894
+ ]);
3895
+
3896
+ if (action === "reinstall") {
3897
+ console.log("");
3898
+ await install();
3899
+ }
3900
+ console.log("");
3901
+ return;
3902
+ }
3903
+
3904
+ // -------------------------------------------------------------------------
3905
+ // Flow: fresh-install (default)
3906
+ // No existing symlinks — run full onboarding
3907
+ // -------------------------------------------------------------------------
3908
+ const steps = [
3909
+ {
3910
+ name: "Preflight Checks",
3911
+ optional: false,
3912
+ run: async () => {
3913
+ await runPrerequisiteChecklist();
3914
+ },
3915
+ },
3916
+ {
3917
+ name: "Install Commands",
3918
+ optional: false,
3919
+ run: async () => {
3920
+ await install();
3921
+ },
3922
+ },
3923
+ {
3924
+ name: "Project Configuration",
3925
+ optional: true,
3926
+ run: async () => {
3927
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
3928
+ if (existsSync(forgeYamlPath)) {
3929
+ console.log(
3930
+ ` ${green("forge.yaml")} already exists — skipping generation.`,
3931
+ );
3932
+ return;
3933
+ }
3934
+
3935
+ const shouldInit = await confirm(
3936
+ "No forge.yaml found. Generate one now?",
3937
+ true,
3938
+ );
3939
+ if (shouldInit) {
3940
+ await init();
3941
+ } else {
3942
+ console.log(
3943
+ ` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`,
3944
+ );
3945
+ }
3946
+ },
3947
+ },
3948
+ {
3949
+ name: "Validate Configuration",
3950
+ optional: true,
3951
+ run: async () => {
3952
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
3953
+ if (!existsSync(forgeYamlPath)) {
3954
+ console.log(` ${dim("No forge.yaml found — skipping validation.")}`);
3955
+ return;
3956
+ }
3957
+ await validate(forgeYamlPath);
3958
+ },
3959
+ },
3960
+ {
3961
+ name: "GitHub Bot Setup",
3962
+ optional: true,
3963
+ run: async () => {
3964
+ await botSetup();
3965
+ },
3966
+ },
3967
+ ];
3968
+
3969
+ const orchestrator = new StepOrchestrator(steps);
3970
+ const success = await orchestrator.run();
3971
+
3972
+ if (success) {
3973
+ console.log(
3974
+ green("Setup complete!") +
3975
+ ` Run ${cyan("/help")} inside Claude Code to see available commands.`,
3976
+ );
3977
+ console.log("");
3978
+ } else {
3979
+ console.log("");
3980
+ console.log(
3981
+ yellow("Setup incomplete.") +
3982
+ ` Re-run ${cyan("npx forgedock")} to try again.`,
3983
+ );
3984
+ console.log("");
507
3985
  process.exit(1);
3986
+ }
3987
+ }
3988
+
3989
+ // ---------------------------------------------------------------------------
3990
+ // Command dispatch
3991
+ // ---------------------------------------------------------------------------
3992
+
3993
+ if (!command) {
3994
+ // No arguments — launch TUI onboarding
3995
+ await tuiOnboarding();
3996
+ } else {
3997
+ switch (command) {
3998
+ case "install":
3999
+ await install();
4000
+ break;
4001
+ case "init":
4002
+ await init();
4003
+ break;
4004
+ case "validate": {
4005
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
4006
+ const result = await validate(forgeYamlPath);
4007
+ if (!result.passed) process.exit(1);
4008
+ break;
4009
+ }
4010
+ case "uninstall":
4011
+ await uninstall();
4012
+ break;
4013
+ case "update":
4014
+ await update();
4015
+ break;
4016
+ case "bot": {
4017
+ const subcommand = args[1];
4018
+ if (!subcommand || subcommand === "status") {
4019
+ await botStatus();
4020
+ } else if (subcommand === "setup") {
4021
+ await botSetup();
4022
+ } else {
4023
+ console.log(`${RED}Unknown bot subcommand: ${subcommand}${RESET}`);
4024
+ console.log(`Usage: ${CYAN}npx forgedock bot [status|setup]${RESET}`);
4025
+ process.exit(1);
4026
+ }
4027
+ break;
4028
+ }
4029
+ case "help":
4030
+ case "--help":
4031
+ case "-h":
4032
+ help();
4033
+ break;
4034
+ default:
4035
+ console.log(`${RED}Unknown command: ${command}${RESET}`);
4036
+ help();
4037
+ process.exit(1);
4038
+ }
508
4039
  }