forgedock 1.0.2 → 1.0.3

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