forgedock 1.0.3 → 1.0.5

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,15 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import os from "os";
3
4
  import { fileURLToPath } from "url";
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";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
6
+ import {
7
+ mkdir,
8
+ symlink,
9
+ copyFile,
10
+ readlink,
11
+ lstat,
12
+ readdir,
13
+ stat,
14
+ writeFile,
15
+ unlink as fsUnlink,
16
+ } from "fs/promises";
17
+ import {
18
+ existsSync,
19
+ appendFileSync,
20
+ chmodSync,
21
+ readFileSync,
22
+ writeFileSync,
23
+ renameSync,
24
+ unlinkSync,
25
+ } from "fs";
7
26
  import { execSync, execFileSync } from "child_process";
8
27
  import { createSign } from "crypto";
9
28
  import {
10
- BOLD, GREEN, YELLOW, CYAN, RED, RESET,
11
- bold, dim, green, yellow, cyan, red,
12
- box, stepHeader, select, multiSelect, confirm, input, createProgressBar, spinner,
29
+ BOLD,
30
+ GREEN,
31
+ YELLOW,
32
+ CYAN,
33
+ RED,
34
+ RESET,
35
+ bold,
36
+ dim,
37
+ green,
38
+ yellow,
39
+ cyan,
40
+ red,
41
+ box,
42
+ stepHeader,
43
+ select,
44
+ multiSelect,
45
+ confirm,
46
+ input,
47
+ createProgressBar,
48
+ spinner,
13
49
  } from "./tui.mjs";
14
50
 
15
51
  const __filename = fileURLToPath(import.meta.url);
@@ -17,14 +53,10 @@ const __dirname = dirname(__filename);
17
53
  const FORGE_HOME = dirname(__dirname);
18
54
  const COMMANDS_DIR = join(FORGE_HOME, "commands");
19
55
 
20
- if (!process.env.HOME) {
21
- console.error(
22
- "Error: HOME environment variable is not set. Cannot determine install location.",
23
- );
24
- process.exit(1);
25
- }
56
+ // Resolve home directory cross-platform: HOME (POSIX), USERPROFILE (Windows), os.homedir() fallback.
57
+ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
26
58
 
27
- const TARGET_DIR = join(process.env.HOME, ".claude", "commands");
59
+ const TARGET_DIR = join(HOME, ".claude", "commands");
28
60
 
29
61
  const args = process.argv.slice(2);
30
62
  const command = args[0];
@@ -36,7 +68,9 @@ const forceYes = args.includes("--yes") || args.includes("-y");
36
68
 
37
69
  function getVersion() {
38
70
  try {
39
- const pkg = JSON.parse(readFileSync(join(FORGE_HOME, "package.json"), "utf-8"));
71
+ const pkg = JSON.parse(
72
+ readFileSync(join(FORGE_HOME, "package.json"), "utf-8"),
73
+ );
40
74
  return pkg.version || "0.0.0";
41
75
  } catch {
42
76
  return "0.0.0";
@@ -257,7 +291,11 @@ class StepOrchestrator {
257
291
  if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
258
292
  // Restore terminal to cooked mode in case a select/multiSelect prompt was active
259
293
  if (process.stdin.isTTY) {
260
- try { process.stdin.setRawMode(false); } catch { /* ignore */ }
294
+ try {
295
+ process.stdin.setRawMode(false);
296
+ } catch {
297
+ /* ignore */
298
+ }
261
299
  }
262
300
  process.exit(130);
263
301
  };
@@ -378,7 +416,7 @@ function checkPrerequisites() {
378
416
  } catch {
379
417
  issues.push(
380
418
  `${RED}✗${RESET} GitHub CLI (gh) is not installed.\n` +
381
- ` Install: ${CYAN}https://cli.github.com${RESET}`
419
+ ` Install: ${CYAN}https://cli.github.com${RESET}`,
382
420
  );
383
421
  }
384
422
 
@@ -389,7 +427,7 @@ function checkPrerequisites() {
389
427
  } catch {
390
428
  issues.push(
391
429
  `${RED}✗${RESET} GitHub CLI is not authenticated.\n` +
392
- ` Run: ${CYAN}gh auth login${RESET}`
430
+ ` Run: ${CYAN}gh auth login${RESET}`,
393
431
  );
394
432
  }
395
433
  }
@@ -400,7 +438,7 @@ function checkPrerequisites() {
400
438
  } catch {
401
439
  warnings.push(
402
440
  `${YELLOW}!${RESET} Claude Code CLI not found on PATH.\n` +
403
- ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`
441
+ ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
404
442
  );
405
443
  }
406
444
 
@@ -409,7 +447,7 @@ function checkPrerequisites() {
409
447
  if (nodeVersion < 18) {
410
448
  issues.push(
411
449
  `${RED}✗${RESET} Node.js >= 18 required (found ${process.versions.node}).\n` +
412
- ` Update: ${CYAN}https://nodejs.org${RESET}`
450
+ ` Update: ${CYAN}https://nodejs.org${RESET}`,
413
451
  );
414
452
  }
415
453
 
@@ -424,7 +462,7 @@ function checkPrerequisites() {
424
462
  if (issues.length > 0) {
425
463
  console.log(
426
464
  `${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`
465
+ `with authentication to function.\nResolve the issues above, then re-run the command.\n`,
428
466
  );
429
467
  process.exit(1);
430
468
  }
@@ -451,13 +489,21 @@ async function runPrerequisiteChecklist() {
451
489
  {
452
490
  const s = spinner("Checking GitHub CLI…");
453
491
  try {
454
- const out = execSync("gh --version", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
492
+ const out = execSync("gh --version", {
493
+ encoding: "utf-8",
494
+ stdio: ["pipe", "pipe", "pipe"],
495
+ });
455
496
  const match = out.match(/gh version (\d+\.\d+\.\d+)/);
456
497
  const version = match ? `v${match[1]}` : "installed";
457
498
  s.stop("success", `${green("[✓]")} GitHub CLI ${dim(version)}`);
458
499
  } 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");
500
+ s.stop(
501
+ "fail",
502
+ `${red("[✗]")} GitHub CLI not found — install: ${cyan("https://cli.github.com")}`,
503
+ );
504
+ throw new Error(
505
+ "GitHub CLI (gh) is required. Install it from https://cli.github.com",
506
+ );
461
507
  }
462
508
  }
463
509
 
@@ -468,7 +514,10 @@ async function runPrerequisiteChecklist() {
468
514
  // gh auth status writes to stderr; capture it via the error path too
469
515
  let combined = "";
470
516
  try {
471
- combined = execSync("gh auth status", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
517
+ combined = execSync("gh auth status", {
518
+ encoding: "utf-8",
519
+ stdio: ["pipe", "pipe", "pipe"],
520
+ });
472
521
  } catch (err) {
473
522
  combined = (err.stderr || "") + (err.stdout || "");
474
523
  if (!/Logged in/.test(combined)) {
@@ -491,9 +540,15 @@ async function runPrerequisiteChecklist() {
491
540
  } catch {
492
541
  // Org lookup is best-effort — not required
493
542
  }
494
- s.stop("success", `${green("[✓]")} GitHub Auth ${dim(`logged in as ${username}${orgLabel}`)}`);
543
+ s.stop(
544
+ "success",
545
+ `${green("[✓]")} GitHub Auth ${dim(`logged in as ${username}${orgLabel}`)}`,
546
+ );
495
547
  } catch {
496
- s.stop("fail", `${red("[✗]")} GitHub Auth not authenticated — run: ${cyan("gh auth login")}`);
548
+ s.stop(
549
+ "fail",
550
+ `${red("[✗]")} GitHub Auth not authenticated — run: ${cyan("gh auth login")}`,
551
+ );
497
552
  throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
498
553
  }
499
554
  }
@@ -504,22 +559,36 @@ async function runPrerequisiteChecklist() {
504
559
  const nodeVersion = process.versions.node;
505
560
  const nodeMajor = parseInt(nodeVersion.split(".")[0], 10);
506
561
  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`);
562
+ s.stop(
563
+ "fail",
564
+ `${red("[✗]")} Node.js v${nodeVersion} (>= 18 required) — update: ${cyan("https://nodejs.org")}`,
565
+ );
566
+ throw new Error(
567
+ `Node.js >= 18 required (found v${nodeVersion}). Update at https://nodejs.org`,
568
+ );
509
569
  }
510
- s.stop("success", `${green("[✓]")} Node.js ${dim(`v${nodeVersion} (>= 18 required)`)}`);
570
+ s.stop(
571
+ "success",
572
+ `${green("[✓]")} Node.js ${dim(`v${nodeVersion} (>= 18 required)`)}`,
573
+ );
511
574
  }
512
575
 
513
576
  // ── 4. Git ─────────────────────────────────────────────────────────────────
514
577
  {
515
578
  const s = spinner("Checking Git…");
516
579
  try {
517
- const out = execSync("git --version", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
580
+ const out = execSync("git --version", {
581
+ encoding: "utf-8",
582
+ stdio: ["pipe", "pipe", "pipe"],
583
+ });
518
584
  const match = out.match(/git version (\d+\.\d+\.\d+)/);
519
585
  const version = match ? `v${match[1]}` : "installed";
520
586
  s.stop("success", `${green("[✓]")} Git ${dim(version)}`);
521
587
  } catch {
522
- s.stop("warn", `${yellow("[!]")} Git not found — install: ${cyan("https://git-scm.com")}`);
588
+ s.stop(
589
+ "warn",
590
+ `${yellow("[!]")} Git not found — install: ${cyan("https://git-scm.com")}`,
591
+ );
523
592
  // Git is a warning — do NOT throw
524
593
  }
525
594
  }
@@ -528,12 +597,18 @@ async function runPrerequisiteChecklist() {
528
597
  {
529
598
  const s = spinner("Checking Claude Code…");
530
599
  try {
531
- const out = execSync("claude --version", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
600
+ const out = execSync("claude --version", {
601
+ encoding: "utf-8",
602
+ stdio: ["pipe", "pipe", "pipe"],
603
+ });
532
604
  const match = out.trim().match(/(\d+\.\d+\.\d+)/);
533
605
  const version = match ? `v${match[1]}` : "installed";
534
606
  s.stop("success", `${green("[✓]")} Claude Code ${dim(version)}`);
535
607
  } catch {
536
- s.stop("warn", `${yellow("[!]")} Claude Code not found — install: ${cyan("https://docs.anthropic.com/en/docs/claude-code")}`);
608
+ s.stop(
609
+ "warn",
610
+ `${yellow("[!]")} Claude Code not found — install: ${cyan("https://docs.anthropic.com/en/docs/claude-code")}`,
611
+ );
537
612
  // Claude Code is a warning — do NOT throw
538
613
  }
539
614
  }
@@ -568,52 +643,59 @@ async function findMarkdownFiles(dir) {
568
643
  */
569
644
  const COMMAND_CATEGORIES = {
570
645
  // 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",
646
+ "work-on": "Pipeline",
647
+ investigate: "Pipeline",
648
+ build: "Pipeline",
649
+ architect: "Pipeline",
650
+ context: "Pipeline",
651
+ implement: "Pipeline",
652
+ review: "Pipeline",
653
+ decompose: "Pipeline",
654
+ close: "Pipeline",
655
+ "review-pr": "Pipeline",
656
+ "review-pr-agents": "Pipeline",
582
657
  "review-pr-staging": "Pipeline",
583
- "orchestrate": "Pipeline",
584
- "issue": "Pipeline",
585
- "milestone": "Pipeline",
586
- "quality-gate": "Pipeline",
587
- "work-on-monolithic":"Pipeline",
658
+ orchestrate: "Pipeline",
659
+ issue: "Pipeline",
660
+ milestone: "Pipeline",
661
+ "quality-gate": "Pipeline",
662
+ "work-on-monolithic": "Pipeline",
588
663
 
589
664
  // 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",
665
+ autopilot: "Operations",
666
+ analytics: "Operations",
667
+ "geo-audit": "Operations",
668
+ "security-audit": "Operations",
669
+ "pipeline-health": "Operations",
670
+ audit: "Operations",
671
+ "audit-agents": "Operations",
672
+ "qa-sweep": "Operations",
673
+ "forge-stats": "Operations",
674
+ "sync-ecosystem": "Operations",
600
675
 
601
676
  // Incident — response and recovery
602
677
  "incident-response": "Incident",
603
- "rollback": "Incident",
604
- "deploy-info": "Incident",
605
- "failure-recon": "Incident",
678
+ rollback: "Incident",
679
+ "deploy-info": "Incident",
680
+ "failure-recon": "Incident",
606
681
 
607
682
  // Ecosystem — project management utilities
608
- "validate": "Ecosystem",
609
- "cleanup": "Ecosystem",
683
+ validate: "Ecosystem",
684
+ cleanup: "Ecosystem",
610
685
 
611
686
  // Setup — initial configuration
612
- "forgedock-init": "Setup",
687
+ "forgedock-init": "Setup",
613
688
  };
614
689
 
615
690
  /** Canonical display order for categories. */
616
- const CATEGORY_ORDER = ["Pipeline", "Operations", "Incident", "Ecosystem", "Setup", "Other"];
691
+ const CATEGORY_ORDER = [
692
+ "Pipeline",
693
+ "Operations",
694
+ "Incident",
695
+ "Ecosystem",
696
+ "Setup",
697
+ "Other",
698
+ ];
617
699
 
618
700
  // ---------------------------------------------------------------------------
619
701
  // Legacy commands (install, uninstall, update, init, help)
@@ -646,7 +728,9 @@ async function install() {
646
728
  // Phase 1: Symlink loop with live progress bar
647
729
  // -------------------------------------------------------------------------
648
730
 
649
- const bar = createProgressBar(files.length, { label: " Installing commands..." });
731
+ const bar = createProgressBar(files.length, {
732
+ label: " Installing commands...",
733
+ });
650
734
 
651
735
  for (const file of files) {
652
736
  const rel = relative(COMMANDS_DIR, file);
@@ -776,7 +860,9 @@ async function install() {
776
860
 
777
861
  if (categoryLines.length > 0) {
778
862
  console.log("");
779
- process.stdout.write(box(["", ...categoryLines, ""], { title: "Commands installed" }));
863
+ process.stdout.write(
864
+ box(["", ...categoryLines, ""], { title: "Commands installed" }),
865
+ );
780
866
  }
781
867
 
782
868
  // -------------------------------------------------------------------------
@@ -791,8 +877,10 @@ async function install() {
791
877
  "",
792
878
  ];
793
879
  if (copied > 0) {
794
- summaryLines.splice(summaryLines.length - 1, 0,
795
- ` ${cyan("Copied")} ${bold(String(copied))} ${dim("(symlinks unavailable — files copied instead)")}`
880
+ summaryLines.splice(
881
+ summaryLines.length - 1,
882
+ 0,
883
+ ` ${cyan("Copied")} ${bold(String(copied))} ${dim("(symlinks unavailable — files copied instead)")}`,
796
884
  );
797
885
  }
798
886
  process.stdout.write(box(summaryLines, { title: "Summary" }));
@@ -813,7 +901,11 @@ async function install() {
813
901
  ...conflicts.map((c) => ` ${dim(`rm ~/.claude/commands/${c}`)}`),
814
902
  "",
815
903
  ];
816
- process.stdout.write(box(conflictLines, { title: `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}` }));
904
+ process.stdout.write(
905
+ box(conflictLines, {
906
+ title: `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`,
907
+ }),
908
+ );
817
909
  }
818
910
 
819
911
  // -------------------------------------------------------------------------
@@ -822,31 +914,32 @@ async function install() {
822
914
 
823
915
  console.log("");
824
916
  let forgeHomeSet = false;
825
- for (const profile of [
826
- join(process.env.HOME ?? "", ".bashrc"),
827
- join(process.env.HOME ?? "", ".zshrc"),
828
- ]) {
917
+ for (const profile of [join(HOME, ".bashrc"), join(HOME, ".zshrc")]) {
829
918
  if (existsSync(profile)) {
830
919
  const content = readFileSync(profile, "utf-8");
831
920
  if (!content.includes("FORGE_HOME")) {
832
921
  appendFileSync(
833
922
  profile,
834
- `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`
923
+ `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
924
+ );
925
+ const profileShort = profile.replace(HOME, "~");
926
+ console.log(
927
+ ` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`,
835
928
  );
836
- const profileShort = profile.replace(process.env.HOME ?? "", "~");
837
- console.log(` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`);
838
929
  forgeHomeSet = true;
839
930
  }
840
931
  }
841
932
  }
842
933
 
843
934
  if (!forgeHomeSet) {
844
- console.log(` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`);
935
+ console.log(
936
+ ` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`,
937
+ );
845
938
  }
846
939
 
847
940
  console.log("");
848
941
  console.log(
849
- `${green("ForgeDock commands are now available as slash commands in any Claude Code session.")}`
942
+ `${green("ForgeDock commands are now available as slash commands in any Claude Code session.")}`,
850
943
  );
851
944
  console.log("");
852
945
 
@@ -855,7 +948,7 @@ async function install() {
855
948
  if (!existsSync(forgeYamlPath)) {
856
949
  console.log(`${yellow("No forge.yaml found in current directory.")}`);
857
950
  console.log(
858
- ` Run ${cyan("npx forgedock init")} in your project root to generate forge.yaml`
951
+ ` Run ${cyan("npx forgedock init")} in your project root to generate forge.yaml`,
859
952
  );
860
953
  console.log("");
861
954
  }
@@ -900,10 +993,7 @@ async function uninstall() {
900
993
 
901
994
  // Scan shell profiles for FORGE_HOME block
902
995
  const FORGE_HOME_MARKER = "# ForgeDock — autonomous development pipeline";
903
- const shellProfiles = [
904
- join(process.env.HOME ?? "", ".bashrc"),
905
- join(process.env.HOME ?? "", ".zshrc"),
906
- ];
996
+ const shellProfiles = [join(HOME, ".bashrc"), join(HOME, ".zshrc")];
907
997
  /** @type {string[]} Profiles that contain the FORGE_HOME block */
908
998
  const profilesWithForgeHome = shellProfiles.filter((p) => {
909
999
  if (!existsSync(p)) return false;
@@ -922,8 +1012,14 @@ async function uninstall() {
922
1012
  // Phase 2: Pre-removal summary
923
1013
  // -------------------------------------------------------------------------
924
1014
 
925
- if (toRemove.length === 0 && profilesWithForgeHome.length === 0 && !hasForgeYaml) {
926
- console.log(` ${dim("Nothing to remove — ForgeDock does not appear to be installed.")}`);
1015
+ if (
1016
+ toRemove.length === 0 &&
1017
+ profilesWithForgeHome.length === 0 &&
1018
+ !hasForgeYaml
1019
+ ) {
1020
+ console.log(
1021
+ ` ${dim("Nothing to remove — ForgeDock does not appear to be installed.")}`,
1022
+ );
927
1023
  console.log("");
928
1024
  return;
929
1025
  }
@@ -931,13 +1027,15 @@ async function uninstall() {
931
1027
  const summaryLines = [""];
932
1028
  if (toRemove.length > 0) {
933
1029
  const fileLabel = copyMode ? "files (copy-mode install)" : "symlinks";
934
- summaryLines.push(` ${red("Commands")}: ${bold(String(toRemove.length))} ${fileLabel} in ${dim(TARGET_DIR)}`);
1030
+ summaryLines.push(
1031
+ ` ${red("Commands")}: ${bold(String(toRemove.length))} ${fileLabel} in ${dim(TARGET_DIR)}`,
1032
+ );
935
1033
  } else {
936
1034
  summaryLines.push(` ${dim("Commands:")} none found`);
937
1035
  }
938
1036
  if (profilesWithForgeHome.length > 0) {
939
1037
  summaryLines.push(
940
- ` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(process.env.HOME ?? "", "~")).join(", "))}`
1038
+ ` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(HOME, "~")).join(", "))}`,
941
1039
  );
942
1040
  }
943
1041
  if (hasForgeYaml) {
@@ -961,10 +1059,7 @@ async function uninstall() {
961
1059
 
962
1060
  let confirmed = forceYes;
963
1061
  if (!confirmed) {
964
- confirmed = await confirm(
965
- `Remove ${commandLabel}${profileLabel}?`,
966
- false
967
- );
1062
+ confirmed = await confirm(`Remove ${commandLabel}${profileLabel}?`, false);
968
1063
  }
969
1064
 
970
1065
  if (!confirmed) {
@@ -980,7 +1075,9 @@ async function uninstall() {
980
1075
 
981
1076
  if (toRemove.length > 0) {
982
1077
  console.log("");
983
- const bar = createProgressBar(toRemove.length, { label: " Removing commands" });
1078
+ const bar = createProgressBar(toRemove.length, {
1079
+ label: " Removing commands",
1080
+ });
984
1081
  let removed = 0;
985
1082
 
986
1083
  for (const { target, rel } of toRemove) {
@@ -1022,23 +1119,31 @@ async function uninstall() {
1022
1119
  const cleaned = content
1023
1120
  .replace(
1024
1121
  /\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME=[^\n]*\n/g,
1025
- "\n"
1122
+ "\n",
1026
1123
  )
1027
1124
  .replace(
1028
1125
  /^# ForgeDock — autonomous development pipeline\nexport FORGE_HOME=[^\n]*\n/,
1029
- ""
1126
+ "",
1030
1127
  );
1031
1128
  // Write to a temp file first, then atomically rename into place.
1032
1129
  // This prevents profile corruption if the process is killed mid-write.
1033
1130
  writeFileSync(tmpPath, cleaned, "utf-8");
1034
1131
  renameSync(tmpPath, profile);
1035
- const profileShort = profile.replace(process.env.HOME ?? "", "~");
1036
- console.log(` ${green("✔")} Removed FORGE_HOME from ${profileShort}`);
1132
+ const profileShort = profile.replace(HOME, "~");
1133
+ console.log(
1134
+ ` ${green("✔")} Removed FORGE_HOME from ${profileShort}`,
1135
+ );
1037
1136
  } catch (err) {
1038
1137
  // 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}`);
1138
+ try {
1139
+ unlinkSync(tmpPath);
1140
+ } catch {
1141
+ /* already gone or never created */
1142
+ }
1143
+ const profileShort = profile.replace(HOME, "~");
1144
+ console.log(
1145
+ ` ${red("✖")} Could not update ${profileShort}: ${err.message}`,
1146
+ );
1042
1147
  }
1043
1148
  }
1044
1149
  } else {
@@ -1053,7 +1158,7 @@ async function uninstall() {
1053
1158
  if (hasForgeYaml) {
1054
1159
  console.log("");
1055
1160
  const deleteForgeYaml = forceYes
1056
- ? false // --yes flag: keep forge.yaml by default (safe)
1161
+ ? false // --yes flag: keep forge.yaml by default (safe)
1057
1162
  : await confirm("Delete forge.yaml from this project?", false);
1058
1163
 
1059
1164
  if (deleteForgeYaml) {
@@ -1061,7 +1166,9 @@ async function uninstall() {
1061
1166
  await unlink(forgeYamlPath);
1062
1167
  console.log(` ${green("✔")} Deleted forge.yaml`);
1063
1168
  } catch (err) {
1064
- console.log(` ${red("✖")} Could not delete forge.yaml: ${err.message}`);
1169
+ console.log(
1170
+ ` ${red("✖")} Could not delete forge.yaml: ${err.message}`,
1171
+ );
1065
1172
  }
1066
1173
  } else {
1067
1174
  console.log(` ${dim("forge.yaml kept.")}`);
@@ -1073,9 +1180,13 @@ async function uninstall() {
1073
1180
  // -------------------------------------------------------------------------
1074
1181
 
1075
1182
  console.log("");
1076
- console.log(`${green("Uninstall complete.")} ForgeDock commands have been removed.`);
1183
+ console.log(
1184
+ `${green("Uninstall complete.")} ForgeDock commands have been removed.`,
1185
+ );
1077
1186
  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.")}`);
1187
+ console.log(
1188
+ ` ${dim("Restart your shell or run")} ${cyan("source ~/.bashrc")} ${dim("(or")} ${cyan("~/.zshrc")}${dim(")")} ${dim("to apply profile changes.")}`,
1189
+ );
1079
1190
  }
1080
1191
  console.log("");
1081
1192
  }
@@ -1104,6 +1215,193 @@ function queryNpmRegistry(pkg) {
1104
1215
  }
1105
1216
  }
1106
1217
 
1218
+ // ---------------------------------------------------------------------------
1219
+ // Proactive update check — cached remote version check
1220
+ // ---------------------------------------------------------------------------
1221
+
1222
+ /**
1223
+ * Path to the update-check cache file.
1224
+ * Stored in ~/.forgedock/ alongside credentials.json.
1225
+ * Populated lazily — FORGEDOCK_HOME is defined later in the file, but this
1226
+ * constant is only resolved at module evaluation time so the ordering is fine.
1227
+ */
1228
+ // NOTE: FORGEDOCK_HOME is declared later in the file (line ~2974). This const
1229
+ // is a forward reference that is only evaluated when the module finishes loading,
1230
+ // which is after FORGEDOCK_HOME is defined. Node ESM top-level await ensures
1231
+ // the module is fully evaluated before any exported binding is read.
1232
+ // We defer the join() call to avoid the forward-reference issue:
1233
+ function _getUpdateCheckCachePath() {
1234
+ return join(HOME, ".forgedock", "update-check.json");
1235
+ }
1236
+
1237
+ /** Default interval between automatic remote update checks (milliseconds). */
1238
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
1239
+
1240
+ /**
1241
+ * Check whether a newer version of ForgeDock is available, using a local cache
1242
+ * to avoid hitting the network on every invocation.
1243
+ *
1244
+ * For git installs: runs `git fetch origin main` (check-only, no merge) and
1245
+ * compares the local HEAD SHA against origin/main.
1246
+ * For npm installs: queries the npm registry via queryNpmRegistry().
1247
+ *
1248
+ * The cache file `~/.forgedock/update-check.json` stores the result with a
1249
+ * timestamp. If the cache is fresh (< UPDATE_CHECK_INTERVAL_MS old) and
1250
+ * `force` is false, the cached result is returned immediately without any
1251
+ * network call.
1252
+ *
1253
+ * Always returns null on any error — never throws.
1254
+ *
1255
+ * @param {boolean} [force=false] When true, bypass cache and always hit network.
1256
+ * @returns {Promise<{updateAvailable: boolean, latestVersion: string|null, remoteHead: string|null} | null>}
1257
+ */
1258
+ async function checkForRemoteUpdate(force = false) {
1259
+ const cachePath = _getUpdateCheckCachePath();
1260
+
1261
+ // --- Read cache ---
1262
+ if (!force) {
1263
+ try {
1264
+ const raw = readFileSync(cachePath, "utf-8");
1265
+ const cached = JSON.parse(raw);
1266
+ const age = Date.now() - (cached.checkedAt ?? 0);
1267
+ if (age < UPDATE_CHECK_INTERVAL_MS) {
1268
+ // Cache is fresh — but for git installs, reconcile against the live
1269
+ // local HEAD before trusting the stored flag. A manual `git pull` in
1270
+ // FORGE_HOME (bypassing `forgedock update`) advances local HEAD to the
1271
+ // cached remoteHead without refreshing the cache, which would otherwise
1272
+ // keep showing a stale "Update available" notice until the TTL expires.
1273
+ // This recheck is local-only (no fetch/network) and offline-safe.
1274
+ if (cached.remoteHead && existsSync(join(FORGE_HOME, ".git"))) {
1275
+ try {
1276
+ const localHead = execFileSync("git", ["rev-parse", "HEAD"], {
1277
+ cwd: FORGE_HOME,
1278
+ encoding: "utf-8",
1279
+ stdio: ["pipe", "pipe", "pipe"],
1280
+ timeout: 5000,
1281
+ }).trim();
1282
+ if (localHead === cached.remoteHead) {
1283
+ // Install has caught up out-of-band — no update available.
1284
+ return {
1285
+ updateAvailable: false,
1286
+ latestVersion: cached.latestVersion ?? null,
1287
+ remoteHead: cached.remoteHead,
1288
+ };
1289
+ }
1290
+ } catch {
1291
+ // git unavailable or not a repo — fall through to stored result.
1292
+ }
1293
+ }
1294
+ // Cache is fresh — return stored result
1295
+ return {
1296
+ updateAvailable: cached.updateAvailable ?? false,
1297
+ latestVersion: cached.latestVersion ?? null,
1298
+ remoteHead: cached.remoteHead ?? null,
1299
+ };
1300
+ }
1301
+ } catch {
1302
+ // Cache missing or malformed — proceed to network check
1303
+ }
1304
+ }
1305
+
1306
+ // --- Perform network check ---
1307
+ const gitDir = join(FORGE_HOME, ".git");
1308
+ let result;
1309
+
1310
+ if (existsSync(gitDir)) {
1311
+ // Git install: fetch and compare SHAs
1312
+ try {
1313
+ // Fetch — short timeout, offline-safe
1314
+ try {
1315
+ execFileSync("git", ["fetch", "origin", "main", "--quiet"], {
1316
+ cwd: FORGE_HOME,
1317
+ stdio: ["pipe", "pipe", "pipe"],
1318
+ timeout: 10000,
1319
+ });
1320
+ } catch {
1321
+ // Offline or fetch failed — skip check, don't update cache
1322
+ return null;
1323
+ }
1324
+
1325
+ const localHead = execFileSync("git", ["rev-parse", "HEAD"], {
1326
+ cwd: FORGE_HOME,
1327
+ encoding: "utf-8",
1328
+ stdio: ["pipe", "pipe", "pipe"],
1329
+ timeout: 5000,
1330
+ }).trim();
1331
+
1332
+ const remoteHead = execFileSync("git", ["rev-parse", "origin/main"], {
1333
+ cwd: FORGE_HOME,
1334
+ encoding: "utf-8",
1335
+ stdio: ["pipe", "pipe", "pipe"],
1336
+ timeout: 5000,
1337
+ }).trim();
1338
+
1339
+ // An update is only "available" when local HEAD is strictly BEHIND
1340
+ // origin/main. Plain SHA inequality is direction-agnostic: a checkout
1341
+ // that is ahead of or diverged from origin/main (e.g. a contributor with
1342
+ // local commits) would otherwise show a spurious "Update available"
1343
+ // notice. `git merge-base --is-ancestor HEAD origin/main` exits 0 when
1344
+ // HEAD is an ancestor of origin/main (behind or equal); combined with the
1345
+ // inequality below this means strictly behind. Any non-zero exit ("not an
1346
+ // ancestor") or error throws under execFileSync — treat all such cases as
1347
+ // "not behind" so we never show a false-positive notice.
1348
+ let headIsAncestorOfRemote = false;
1349
+ try {
1350
+ execFileSync(
1351
+ "git",
1352
+ ["merge-base", "--is-ancestor", "HEAD", "origin/main"],
1353
+ {
1354
+ cwd: FORGE_HOME,
1355
+ stdio: ["pipe", "pipe", "pipe"],
1356
+ timeout: 5000,
1357
+ },
1358
+ );
1359
+ headIsAncestorOfRemote = true;
1360
+ } catch {
1361
+ // HEAD is ahead of, diverged from, or unrelated to origin/main — or git
1362
+ // failed. In every case, do not report an update as available.
1363
+ headIsAncestorOfRemote = false;
1364
+ }
1365
+
1366
+ result = {
1367
+ updateAvailable: localHead !== remoteHead && headIsAncestorOfRemote,
1368
+ latestVersion: null,
1369
+ remoteHead,
1370
+ };
1371
+ } catch {
1372
+ return null;
1373
+ }
1374
+ } else {
1375
+ // npm install: query registry
1376
+ const latestVersion = queryNpmRegistry("forgedock");
1377
+ if (latestVersion === null) {
1378
+ // Offline or registry unreachable — skip check, don't update cache
1379
+ return null;
1380
+ }
1381
+ const currentVersion = getVersion();
1382
+ result = {
1383
+ updateAvailable: currentVersion !== latestVersion,
1384
+ latestVersion,
1385
+ remoteHead: null,
1386
+ };
1387
+ }
1388
+
1389
+ // --- Write cache ---
1390
+ try {
1391
+ const cacheDir = join(HOME, ".forgedock");
1392
+ await mkdir(cacheDir, { recursive: true });
1393
+ writeFileSync(
1394
+ cachePath,
1395
+ JSON.stringify({ checkedAt: Date.now(), ...result }, null, 2) + "\n",
1396
+ { encoding: "utf-8" },
1397
+ );
1398
+ } catch {
1399
+ // Best-effort — cache write failure is non-fatal
1400
+ }
1401
+
1402
+ return result;
1403
+ }
1404
+
1107
1405
  /**
1108
1406
  * Return a Set of relative paths for all ForgeDock-managed symlinks in TARGET_DIR.
1109
1407
  * Recursively walks subdirectories so commands installed under work-on/, work-on/build/,
@@ -1158,8 +1456,12 @@ async function getInstalledCommandNames() {
1158
1456
  * @returns {string[]}
1159
1457
  */
1160
1458
  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$/, ""));
1459
+ const added = [...after]
1460
+ .filter((n) => !before.has(n))
1461
+ .map((n) => n.replace(/\.md$/, ""));
1462
+ const removed = [...before]
1463
+ .filter((n) => !after.has(n))
1464
+ .map((n) => n.replace(/\.md$/, ""));
1163
1465
 
1164
1466
  if (added.length === 0 && removed.length === 0) return [];
1165
1467
 
@@ -1199,7 +1501,9 @@ async function update() {
1199
1501
  }).trim();
1200
1502
 
1201
1503
  if (branch !== "main") {
1202
- console.log(` ${yellow("Not on main branch")} (${branch}) — skipping automatic update.`);
1504
+ console.log(
1505
+ ` ${yellow("Not on main branch")} (${branch}) — skipping automatic update.`,
1506
+ );
1203
1507
  console.log(` Switch to ${cyan("main")} and re-run to update.`);
1204
1508
  console.log("");
1205
1509
  return;
@@ -1220,8 +1524,12 @@ async function update() {
1220
1524
  timeout: 10000,
1221
1525
  });
1222
1526
  } catch {
1223
- console.log(` ${yellow("Offline or unreachable.")} Could not fetch from origin.`);
1224
- console.log(` ${dim("Skipping update check — re-run when connected.")}`);
1527
+ console.log(
1528
+ ` ${yellow("Offline or unreachable.")} Could not fetch from origin.`,
1529
+ );
1530
+ console.log(
1531
+ ` ${dim("Skipping update check — re-run when connected.")}`,
1532
+ );
1225
1533
  console.log("");
1226
1534
  return;
1227
1535
  }
@@ -1275,10 +1583,42 @@ async function update() {
1275
1583
  timeout: 15000,
1276
1584
  });
1277
1585
  } catch {
1586
+ // Check if the only changes are whitespace/line-ending noise (CRLF drift, etc.)
1587
+ let isWhitespaceOnly = false;
1588
+ try {
1589
+ const diffStat = execFileSync(
1590
+ "git",
1591
+ [
1592
+ "diff",
1593
+ "--ignore-all-space",
1594
+ "--ignore-blank-lines",
1595
+ "--name-only",
1596
+ ],
1597
+ {
1598
+ cwd: FORGE_HOME,
1599
+ encoding: "utf-8",
1600
+ stdio: ["pipe", "pipe", "pipe"],
1601
+ timeout: 5000,
1602
+ },
1603
+ ).trim();
1604
+ isWhitespaceOnly = diffStat === "";
1605
+ } catch {
1606
+ // Diff check failed — fall back to generic message
1607
+ }
1608
+
1278
1609
  console.log(
1279
- ` ${yellow("Cannot fast-forward")} — local changes exist. Skipping merge.`
1610
+ ` ${yellow("Cannot fast-forward")} — local changes exist. Skipping merge.`,
1280
1611
  );
1281
- console.log(` ${dim("Stash or discard local changes and re-run.")}`);
1612
+ if (isWhitespaceOnly) {
1613
+ console.log(
1614
+ ` ${dim("Only whitespace/line-ending differences detected.")}`,
1615
+ );
1616
+ console.log(
1617
+ ` ${dim("Run")} ${cyan("git checkout -- .")} ${dim("to discard them, then re-run")} ${cyan("npx forgedock update")}${dim(".")}`,
1618
+ );
1619
+ } else {
1620
+ console.log(` ${dim("Stash or discard local changes and re-run.")}`);
1621
+ }
1282
1622
  console.log("");
1283
1623
  return;
1284
1624
  }
@@ -1291,11 +1631,32 @@ async function update() {
1291
1631
  }).trim();
1292
1632
 
1293
1633
  const newVersion = getVersion();
1294
- console.log(
1295
- ` ${green("Updated")} v${currentVersion} → v${newVersion}`
1296
- );
1634
+ console.log(` ${green("Updated")} v${currentVersion} → v${newVersion}`);
1297
1635
  console.log("");
1298
1636
 
1637
+ // Invalidate the update-check cache so the next tuiOnboarding() run
1638
+ // reflects the freshly-applied update without hitting the network again.
1639
+ try {
1640
+ const cacheDir = join(HOME, ".forgedock");
1641
+ await mkdir(cacheDir, { recursive: true });
1642
+ writeFileSync(
1643
+ _getUpdateCheckCachePath(),
1644
+ JSON.stringify(
1645
+ {
1646
+ checkedAt: Date.now(),
1647
+ updateAvailable: false,
1648
+ latestVersion: newVersion,
1649
+ remoteHead: after,
1650
+ },
1651
+ null,
1652
+ 2,
1653
+ ) + "\n",
1654
+ { encoding: "utf-8" },
1655
+ );
1656
+ } catch {
1657
+ // Best-effort — cache write failure is non-fatal
1658
+ }
1659
+
1299
1660
  // Re-run symlink installer to pick up new/removed commands
1300
1661
  await install();
1301
1662
 
@@ -1308,7 +1669,7 @@ async function update() {
1308
1669
  }
1309
1670
  } catch (err) {
1310
1671
  console.log(
1311
- ` ${RED}Update failed.${RESET} ${err instanceof Error ? err.message : String(err)}`
1672
+ ` ${RED}Update failed.${RESET} ${err instanceof Error ? err.message : String(err)}`,
1312
1673
  );
1313
1674
  console.log("");
1314
1675
  }
@@ -1330,11 +1691,17 @@ async function update() {
1330
1691
 
1331
1692
  if (latestVersion === null) {
1332
1693
  if (process.stdout.isTTY) {
1333
- process.stdout.write(`\r ${yellow("Offline or registry unreachable.")} Could not check for updates.\n`);
1694
+ process.stdout.write(
1695
+ `\r ${yellow("Offline or registry unreachable.")} Could not check for updates.\n`,
1696
+ );
1334
1697
  } else {
1335
- console.log(` Offline or registry unreachable. Could not check for updates.`);
1698
+ console.log(
1699
+ ` Offline or registry unreachable. Could not check for updates.`,
1700
+ );
1336
1701
  }
1337
- console.log(` ${dim("Re-run when connected, or check: ")}${cyan("https://www.npmjs.com/package/forgedock")}`);
1702
+ console.log(
1703
+ ` ${dim("Re-run when connected, or check: ")}${cyan("https://www.npmjs.com/package/forgedock")}`,
1704
+ );
1338
1705
  console.log("");
1339
1706
  return;
1340
1707
  }
@@ -1373,7 +1740,19 @@ async function update() {
1373
1740
  }
1374
1741
 
1375
1742
  async function init() {
1376
- checkPrerequisites();
1743
+ // init() generates forge.yaml from prompts and git remote detection.
1744
+ // It does NOT require gh CLI or gh auth for core operation (project board
1745
+ // auto-discovery is optional and guarded separately). Only warn if Claude
1746
+ // Code is missing — it's the primary consumer of the generated config.
1747
+ try {
1748
+ execSync("claude --version", { stdio: ["pipe", "pipe", "pipe"] });
1749
+ } catch {
1750
+ console.log(` ${YELLOW}!${RESET} Claude Code CLI not found on PATH.`);
1751
+ console.log(
1752
+ ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
1753
+ );
1754
+ console.log("");
1755
+ }
1377
1756
 
1378
1757
  console.log("");
1379
1758
  console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
@@ -1401,7 +1780,9 @@ async function init() {
1401
1780
  // SSH: git@github.com:owner/repo.git
1402
1781
  const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
1403
1782
  // HTTPS: https://github.com/owner/repo.git
1404
- const httpsMatch = remoteUrl.match(/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/);
1783
+ const httpsMatch = remoteUrl.match(
1784
+ /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
1785
+ );
1405
1786
 
1406
1787
  if (sshMatch) {
1407
1788
  detectedOwner = sshMatch[1];
@@ -1463,16 +1844,21 @@ async function init() {
1463
1844
  if (!process.stdin.isTTY) {
1464
1845
  if (!remoteDetected) {
1465
1846
  console.log(
1466
- ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`
1847
+ ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
1467
1848
  );
1468
1849
  }
1469
1850
  // Silent write — same as pre-interactive behavior
1470
1851
  _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,
1852
+ outputPath,
1853
+ cwd,
1854
+ owner: detectedOwner,
1855
+ repo: detectedRepo,
1856
+ projectName: detectedName,
1857
+ description: "",
1858
+ root: cwd,
1859
+ worktreeBase: join(cwd, ".claude", "worktrees"),
1860
+ defaultBranch: detectedDefault,
1861
+ stagingBranch: detectedStaging,
1476
1862
  });
1477
1863
  console.log(` ${GREEN}Created${RESET}: forge.yaml`);
1478
1864
  console.log("");
@@ -1489,17 +1875,29 @@ async function init() {
1489
1875
  let confirmed = false;
1490
1876
 
1491
1877
  while (!confirmed) {
1492
- console.log(dim(" Auto-detected values are shown as defaults. Press Enter to accept."));
1878
+ console.log(
1879
+ dim(
1880
+ " Auto-detected values are shown as defaults. Press Enter to accept.",
1881
+ ),
1882
+ );
1493
1883
  console.log("");
1494
1884
 
1495
1885
  // --- Project section ---
1496
1886
  console.log(bold(" Project"));
1497
1887
 
1498
- const ownerInput = await input(" GitHub owner (org or user)", detectedOwner);
1888
+ const ownerInput = await input(
1889
+ " GitHub owner (org or user)",
1890
+ detectedOwner,
1891
+ );
1499
1892
  const repoInput = await input(" Repository name", detectedRepo);
1500
1893
  const nameInput = await input(
1501
1894
  " Project name",
1502
- detectedName !== "Your-repo-name" ? detectedName : repoInput.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")
1895
+ detectedName !== "Your-repo-name"
1896
+ ? detectedName
1897
+ : repoInput
1898
+ .split(/[-_]/)
1899
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1900
+ .join(" "),
1503
1901
  );
1504
1902
  const descInput = await input(" Brief description", "");
1505
1903
 
@@ -1510,7 +1908,7 @@ async function init() {
1510
1908
  const rootInput = await input(" Repository root (absolute path)", cwd);
1511
1909
  const worktreeInput = await input(
1512
1910
  " Worktree base (for git worktrees)",
1513
- join(rootInput || cwd, ".claude", "worktrees")
1911
+ join(rootInput || cwd, ".claude", "worktrees"),
1514
1912
  );
1515
1913
 
1516
1914
  // --- Branches section ---
@@ -1518,7 +1916,10 @@ async function init() {
1518
1916
  console.log(bold(" Branches"));
1519
1917
 
1520
1918
  const defaultBranchInput = await input(" Default branch", detectedDefault);
1521
- const stagingBranchInput = await input(" Staging branch (PR target for fast-lane changes)", detectedStaging);
1919
+ const stagingBranchInput = await input(
1920
+ " Staging branch (PR target for fast-lane changes)",
1921
+ detectedStaging,
1922
+ );
1522
1923
 
1523
1924
  // --- Preview ---
1524
1925
  console.log("");
@@ -1541,7 +1942,10 @@ async function init() {
1541
1942
  .join("\n");
1542
1943
 
1543
1944
  process.stdout.write(
1544
- box(previewDisplay, { title: "forge.yaml preview (required sections)", padding: 1 })
1945
+ box(previewDisplay, {
1946
+ title: "forge.yaml preview (required sections)",
1947
+ padding: 1,
1948
+ }),
1545
1949
  );
1546
1950
 
1547
1951
  confirmed = await confirm(" Write this forge.yaml?", true);
@@ -1556,13 +1960,22 @@ async function init() {
1556
1960
  // -----------------------------------------------------------------------
1557
1961
  console.log("");
1558
1962
  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."));
1963
+ console.log(
1964
+ dim(
1965
+ " Select sections to configure now. Unselected sections are written as",
1966
+ ),
1967
+ );
1968
+ console.log(
1969
+ dim(
1970
+ " commented-out placeholders — you can enable them later by editing forge.yaml.",
1971
+ ),
1972
+ );
1561
1973
  console.log("");
1562
1974
 
1563
1975
  const OPTIONAL_SECTION_CHOICES = [
1564
1976
  {
1565
- label: "Project Board — GitHub Projects v2 integration for workflow tracking",
1977
+ label:
1978
+ "Project Board — GitHub Projects v2 integration for workflow tracking",
1566
1979
  value: "projectBoard",
1567
1980
  },
1568
1981
  {
@@ -1570,18 +1983,20 @@ async function init() {
1570
1983
  value: "multiRepo",
1571
1984
  },
1572
1985
  {
1573
- label: "Review Context — Tech stack and conventions for PR review agents",
1986
+ label:
1987
+ "Review Context — Tech stack and conventions for PR review agents",
1574
1988
  value: "review",
1575
1989
  },
1576
1990
  {
1577
- label: "Verification — Health check endpoints and response patterns",
1991
+ label:
1992
+ "Verification — Health check endpoints and response patterns",
1578
1993
  value: "verification",
1579
1994
  },
1580
1995
  ];
1581
1996
 
1582
1997
  const selectedSections = await multiSelect(
1583
1998
  " Which optional sections would you like to configure?",
1584
- OPTIONAL_SECTION_CHOICES
1999
+ OPTIONAL_SECTION_CHOICES,
1585
2000
  );
1586
2001
 
1587
2002
  /** @type {Record<string, object>} */
@@ -1589,29 +2004,47 @@ async function init() {
1589
2004
 
1590
2005
  // --- Project Board prompts ---
1591
2006
  if (selectedSections.includes("projectBoard")) {
1592
- const discovered = await discoverProjectBoard(ownerInput || detectedOwner);
2007
+ const discovered = await discoverProjectBoard(
2008
+ ownerInput || detectedOwner,
2009
+ );
1593
2010
  if (discovered) {
1594
2011
  // Auto-discovery succeeded — use resolved IDs
1595
2012
  optionalSections.projectBoard = {
1596
2013
  projectNumber: discovered.projectNumber,
1597
- projectId: discovered.projectId,
1598
- fieldIds: discovered.fieldIds,
1599
- optionIds: discovered.optionIds,
2014
+ projectId: discovered.projectId,
2015
+ fieldIds: discovered.fieldIds,
2016
+ optionIds: discovered.optionIds,
1600
2017
  };
1601
2018
  } else {
1602
2019
  // Fallback: manual entry (no regression from previous behaviour)
1603
2020
  console.log("");
1604
2021
  console.log(bold(" Project Board (manual)"));
1605
- console.log(dim(" Find your project number: gh project list --owner " + (ownerInput || detectedOwner)));
2022
+ console.log(
2023
+ dim(
2024
+ " Find your project number: gh project list --owner " +
2025
+ (ownerInput || detectedOwner),
2026
+ ),
2027
+ );
1606
2028
  const projectNumber = await input(
1607
2029
  " GitHub Projects v2 project number",
1608
- "1"
2030
+ "1",
1609
2031
  );
1610
2032
  optionalSections.projectBoard = {
1611
2033
  projectNumber: parseInt(projectNumber, 10) || 1,
1612
2034
  };
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)));
2035
+ console.log(
2036
+ dim(
2037
+ " Field IDs (PVT_/PVTSSF_ strings) must be added manually after generation.",
2038
+ ),
2039
+ );
2040
+ console.log(
2041
+ dim(
2042
+ " Run: gh project field-list " +
2043
+ (projectNumber || "1") +
2044
+ " --owner " +
2045
+ (ownerInput || detectedOwner),
2046
+ ),
2047
+ );
1615
2048
  }
1616
2049
  }
1617
2050
 
@@ -1619,18 +2052,22 @@ async function init() {
1619
2052
  if (selectedSections.includes("multiRepo")) {
1620
2053
  console.log("");
1621
2054
  console.log(bold(" Multi-Repo"));
1622
- console.log(dim(" Configure one satellite repo (add more by editing forge.yaml)."));
2055
+ console.log(
2056
+ dim(
2057
+ " Configure one satellite repo (add more by editing forge.yaml).",
2058
+ ),
2059
+ );
1623
2060
  const prefix = await input(
1624
2061
  " Satellite repo prefix (e.g. 'mcp', 'sdk')",
1625
- "sat"
2062
+ "sat",
1626
2063
  );
1627
2064
  const satelliteRepo = await input(
1628
2065
  " Satellite repo name (just the name, owner will be reused)",
1629
- "your-satellite-repo"
2066
+ "your-satellite-repo",
1630
2067
  );
1631
2068
  const satelliteBranch = await input(
1632
2069
  " Satellite default/staging branch",
1633
- "main"
2070
+ "main",
1634
2071
  );
1635
2072
  optionalSections.multiRepo = { prefix, satelliteRepo, satelliteBranch };
1636
2073
  }
@@ -1641,11 +2078,11 @@ async function init() {
1641
2078
  console.log(bold(" Review Context"));
1642
2079
  const techStack = await input(
1643
2080
  " Tech stack (e.g. Next.js, FastAPI, PostgreSQL)",
1644
- "Node.js, TypeScript"
2081
+ "Node.js, TypeScript",
1645
2082
  );
1646
2083
  const context = await input(
1647
2084
  " Architecture notes (one line; expand in forge.yaml later)",
1648
- ""
2085
+ "",
1649
2086
  );
1650
2087
  optionalSections.review = { techStack, context };
1651
2088
  }
@@ -1656,7 +2093,7 @@ async function init() {
1656
2093
  console.log(bold(" Verification"));
1657
2094
  const healthEndpoint = await input(
1658
2095
  " Health check endpoint URL",
1659
- `https://api.${repoInput || detectedRepo}.io/health`
2096
+ `https://api.${repoInput || detectedRepo}.io/health`,
1660
2097
  );
1661
2098
  optionalSections.verification = { healthEndpoint };
1662
2099
  }
@@ -1669,7 +2106,7 @@ async function init() {
1669
2106
  console.log(` ${YELLOW}forge.yaml already exists.${RESET}`);
1670
2107
  const shouldOverwrite = await confirm(
1671
2108
  " Back up existing forge.yaml and overwrite?",
1672
- true
2109
+ true,
1673
2110
  );
1674
2111
  if (!shouldOverwrite) {
1675
2112
  console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
@@ -1680,9 +2117,12 @@ async function init() {
1680
2117
  // Backup with timestamped name if .bak already exists (fix from #36)
1681
2118
  const baseBak = join(cwd, "forge.yaml.bak");
1682
2119
  const backupPath = existsSync(baseBak)
1683
- ? join(cwd, `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`)
2120
+ ? join(
2121
+ cwd,
2122
+ `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
2123
+ )
1684
2124
  : baseBak;
1685
- const backupName = backupPath.split("/").pop();
2125
+ const backupName = basename(backupPath);
1686
2126
  renameSync(outputPath, backupPath);
1687
2127
  console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
1688
2128
  }
@@ -1705,12 +2145,19 @@ async function init() {
1705
2145
  console.log("");
1706
2146
  console.log(` ${GREEN}Created${RESET}: forge.yaml`);
1707
2147
  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(", ")}`);
2148
+ console.log(
2149
+ ` ${GREEN}Configured${RESET}: ${selectedSections
2150
+ .map(
2151
+ (s) =>
2152
+ ({
2153
+ projectBoard: "project_board",
2154
+ multiRepo: "repos",
2155
+ review: "review",
2156
+ verification: "verification",
2157
+ })[s],
2158
+ )
2159
+ .join(", ")}`,
2160
+ );
1714
2161
  }
1715
2162
  console.log("");
1716
2163
  _printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
@@ -1725,13 +2172,19 @@ async function init() {
1725
2172
 
1726
2173
  /**
1727
2174
  * Sanitize a string value for safe insertion into a YAML double-quoted scalar.
1728
- * Strips double-quotes and newlines to prevent YAML injection.
2175
+ * Escapes backslashes (so a trailing \ does not corrupt the closing quote),
2176
+ * then strips double-quotes and newlines to prevent YAML injection.
2177
+ * Backslash escaping MUST come first — before any other replacement.
1729
2178
  *
1730
2179
  * @param {string} value
1731
2180
  * @returns {string}
1732
2181
  */
1733
2182
  function _sanitizeYamlValue(value) {
1734
- return String(value).replace(/"/g, "").replace(/[\r\n]/g, " ").trim();
2183
+ return String(value)
2184
+ .replace(/\\/g, "\\\\")
2185
+ .replace(/"/g, "")
2186
+ .replace(/[\r\n]/g, " ")
2187
+ .trim();
1735
2188
  }
1736
2189
 
1737
2190
  /**
@@ -1742,7 +2195,11 @@ function _sanitizeYamlValue(value) {
1742
2195
  * @returns {string}
1743
2196
  */
1744
2197
  function _sanitizePathValue(value) {
1745
- return String(value).replace(/\\/g, "\\\\").replace(/"/g, "").replace(/[\r\n]/g, " ").trim();
2198
+ return String(value)
2199
+ .replace(/\\/g, "\\\\")
2200
+ .replace(/"/g, "")
2201
+ .replace(/[\r\n]/g, " ")
2202
+ .trim();
1746
2203
  }
1747
2204
 
1748
2205
  /**
@@ -1779,21 +2236,31 @@ async function discoverProjectBoard(owner) {
1779
2236
  // -------------------------------------------------------------------------
1780
2237
  let projects = [];
1781
2238
  try {
1782
- const raw = execFileSync("gh", ["project", "list", "--owner", owner, "--format", "json"], {
1783
- encoding: "utf-8",
1784
- stdio: ["pipe", "pipe", "pipe"],
1785
- });
2239
+ const raw = execFileSync(
2240
+ "gh",
2241
+ ["project", "list", "--owner", owner, "--format", "json"],
2242
+ {
2243
+ encoding: "utf-8",
2244
+ stdio: ["pipe", "pipe", "pipe"],
2245
+ },
2246
+ );
1786
2247
  const parsed = JSON.parse(raw);
1787
2248
  projects = parsed.projects || [];
1788
2249
  } catch {
1789
2250
  // 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."));
2251
+ console.log(
2252
+ dim(" Could not list projects — falling back to manual entry."),
2253
+ );
1791
2254
  return null;
1792
2255
  }
1793
2256
 
1794
2257
  if (projects.length === 0) {
1795
2258
  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."));
2259
+ console.log(
2260
+ dim(
2261
+ " You can add project_board configuration manually in forge.yaml later.",
2262
+ ),
2263
+ );
1797
2264
  return null;
1798
2265
  }
1799
2266
 
@@ -1802,10 +2269,16 @@ async function discoverProjectBoard(owner) {
1802
2269
  label: `${p.title} ${dim("(#" + p.number + ", " + (p.items?.totalCount ?? "?") + " items)")}`,
1803
2270
  value: p.number,
1804
2271
  }));
1805
- projectChoices.push({ label: dim("Skip — configure project board manually later"), value: null });
2272
+ projectChoices.push({
2273
+ label: dim("Skip — configure project board manually later"),
2274
+ value: null,
2275
+ });
1806
2276
 
1807
2277
  console.log("");
1808
- const selectedNumber = await select(" Which GitHub Project board?", projectChoices);
2278
+ const selectedNumber = await select(
2279
+ " Which GitHub Project board?",
2280
+ projectChoices,
2281
+ );
1809
2282
  if (selectedNumber === null) {
1810
2283
  return null;
1811
2284
  }
@@ -1813,7 +2286,9 @@ async function discoverProjectBoard(owner) {
1813
2286
  // Look up the selected project's id
1814
2287
  const selectedProject = projects.find((p) => p.number === selectedNumber);
1815
2288
  const projectId = selectedProject?.id ?? "";
1816
- const projectTitle = _sanitizeYamlValue(selectedProject?.title ?? `Project #${selectedNumber}`);
2289
+ const projectTitle = _sanitizeYamlValue(
2290
+ selectedProject?.title ?? `Project #${selectedNumber}`,
2291
+ );
1817
2292
 
1818
2293
  // -------------------------------------------------------------------------
1819
2294
  // Step 2: Fetch field list
@@ -1823,14 +2298,30 @@ async function discoverProjectBoard(owner) {
1823
2298
 
1824
2299
  let fields = [];
1825
2300
  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
- });
2301
+ const raw = execFileSync(
2302
+ "gh",
2303
+ [
2304
+ "project",
2305
+ "field-list",
2306
+ String(selectedNumber),
2307
+ "--owner",
2308
+ owner,
2309
+ "--format",
2310
+ "json",
2311
+ ],
2312
+ {
2313
+ encoding: "utf-8",
2314
+ stdio: ["pipe", "pipe", "pipe"],
2315
+ },
2316
+ );
1830
2317
  const parsed = JSON.parse(raw);
1831
- fields = (parsed.fields || []).filter((f) => f.type === "ProjectV2SingleSelectField");
2318
+ fields = (parsed.fields || []).filter(
2319
+ (f) => f.type === "ProjectV2SingleSelectField",
2320
+ );
1832
2321
  } catch {
1833
- console.log(dim(" Could not fetch project fields — falling back to manual entry."));
2322
+ console.log(
2323
+ dim(" Could not fetch project fields — falling back to manual entry."),
2324
+ );
1834
2325
  return null;
1835
2326
  }
1836
2327
 
@@ -1844,36 +2335,40 @@ async function discoverProjectBoard(owner) {
1844
2335
  */
1845
2336
  function matchField(keyword) {
1846
2337
  // Exact match first (case-insensitive)
1847
- const exact = fields.find((f) => f.name.toLowerCase() === keyword.toLowerCase());
2338
+ const exact = fields.find(
2339
+ (f) => f.name.toLowerCase() === keyword.toLowerCase(),
2340
+ );
1848
2341
  if (exact) return { field: exact, fuzzy: false };
1849
2342
  // Fuzzy: name contains keyword
1850
- const fuzzy = fields.find((f) => f.name.toLowerCase().includes(keyword.toLowerCase()));
2343
+ const fuzzy = fields.find((f) =>
2344
+ f.name.toLowerCase().includes(keyword.toLowerCase()),
2345
+ );
1851
2346
  if (fuzzy) return { field: fuzzy, fuzzy: true };
1852
2347
  return null;
1853
2348
  }
1854
2349
 
1855
2350
  // Known field keys and their expected name keywords
1856
2351
  const FIELD_TARGETS = [
1857
- { key: "status", keywords: ["status"] },
1858
- { key: "lane", keywords: ["lane", "track"] },
2352
+ { key: "status", keywords: ["status"] },
2353
+ { key: "lane", keywords: ["lane", "track"] },
1859
2354
  { key: "component", keywords: ["component"] },
1860
- { key: "priority", keywords: ["priority"] },
1861
- { key: "workflow", keywords: ["workflow"] },
2355
+ { key: "priority", keywords: ["priority"] },
2356
+ { key: "workflow", keywords: ["workflow"] },
1862
2357
  ];
1863
2358
 
1864
2359
  // Known option name → forge.yaml key mappings per field
1865
2360
  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" },
2361
+ status: { todo: "todo", "in progress": "in_progress", done: "done" },
2362
+ lane: { fast: "fast", feature: "feature", sync: "sync" },
2363
+ priority: { p0: "p0", p1: "p1", p2: "p2", p3: "p3" },
1869
2364
  workflow: {
1870
- "investigating": "investigating",
2365
+ investigating: "investigating",
1871
2366
  "ready to build": "ready_to_build",
1872
- "building": "building",
2367
+ building: "building",
1873
2368
  "in review": "in_review",
1874
- "merged": "merged",
1875
- "invalid": "invalid",
1876
- "decomposed": "decomposed",
2369
+ merged: "merged",
2370
+ invalid: "invalid",
2371
+ decomposed: "decomposed",
1877
2372
  },
1878
2373
  component: {}, // component options vary by project — map all options by slugifying the name
1879
2374
  };
@@ -1903,7 +2398,10 @@ async function discoverProjectBoard(owner) {
1903
2398
  if (target.key === "component") {
1904
2399
  // For component, map every option by slugifying its name (a–z, 0–9, underscore)
1905
2400
  for (const opt of field.options || []) {
1906
- const slug = opt.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
2401
+ const slug = opt.name
2402
+ .toLowerCase()
2403
+ .replace(/[^a-z0-9]+/g, "_")
2404
+ .replace(/^_|_$/g, "");
1907
2405
  mappedOptions[slug] = opt.id;
1908
2406
  }
1909
2407
  } else {
@@ -1926,8 +2424,15 @@ async function discoverProjectBoard(owner) {
1926
2424
  // -------------------------------------------------------------------------
1927
2425
  for (const { key, fieldName } of fuzzyMatches) {
1928
2426
  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);
2427
+ console.log(
2428
+ yellow(
2429
+ ` Fuzzy match: field "${fieldName}" mapped to forge.yaml key "${key}"`,
2430
+ ),
2431
+ );
2432
+ const accepted = await confirm(
2433
+ ` Accept this mapping (${fieldName} → ${key})?`,
2434
+ true,
2435
+ );
1931
2436
  if (!accepted) {
1932
2437
  delete resolvedFieldIds[key];
1933
2438
  delete resolvedOptionIds[key];
@@ -1944,14 +2449,21 @@ async function discoverProjectBoard(owner) {
1944
2449
  "Fields:",
1945
2450
  ];
1946
2451
  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)";
2452
+ const optCount = resolvedOptionIds[key]
2453
+ ? Object.keys(resolvedOptionIds[key]).length
2454
+ : 0;
2455
+ const optLabel =
2456
+ optCount > 0 ? ` (${optCount} options)` : " (no options mapped)";
1949
2457
  summaryLines.push(` ${key.padEnd(10)}: ${id}${optLabel}`);
1950
2458
  }
1951
- const unmapped = FIELD_TARGETS.map((t) => t.key).filter((k) => !resolvedFieldIds[k]);
2459
+ const unmapped = FIELD_TARGETS.map((t) => t.key).filter(
2460
+ (k) => !resolvedFieldIds[k],
2461
+ );
1952
2462
  if (unmapped.length > 0) {
1953
2463
  summaryLines.push("");
1954
- summaryLines.push(`Not found: ${unmapped.join(", ")} — placeholders will be used`);
2464
+ summaryLines.push(
2465
+ `Not found: ${unmapped.join(", ")} — placeholders will be used`,
2466
+ );
1955
2467
  }
1956
2468
 
1957
2469
  console.log("");
@@ -1959,7 +2471,11 @@ async function discoverProjectBoard(owner) {
1959
2471
 
1960
2472
  const confirmed = await confirm(" Use this configuration?", true);
1961
2473
  if (!confirmed) {
1962
- console.log(dim(" Skipped auto-discovery. You can add project_board manually in forge.yaml."));
2474
+ console.log(
2475
+ dim(
2476
+ " Skipped auto-discovery. You can add project_board manually in forge.yaml.",
2477
+ ),
2478
+ );
1963
2479
  return null;
1964
2480
  }
1965
2481
 
@@ -1992,40 +2508,66 @@ async function discoverProjectBoard(owner) {
1992
2508
  * @param {object} [opts.optionalSections.review] - review section config
1993
2509
  * @param {object} [opts.optionalSections.verification] - verification section config
1994
2510
  */
1995
- function buildForgeYamlContent({ owner, repo, projectName, description, root, worktreeBase, defaultBranch, stagingBranch, optionalSections = {} }) {
2511
+ function buildForgeYamlContent({
2512
+ owner,
2513
+ repo,
2514
+ projectName,
2515
+ description,
2516
+ root,
2517
+ worktreeBase,
2518
+ defaultBranch,
2519
+ stagingBranch,
2520
+ optionalSections = {},
2521
+ }) {
1996
2522
  // Sanitize all user-supplied string values before template interpolation to prevent
1997
2523
  // 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);
2524
+ owner = _sanitizeYamlValue(owner);
2525
+ repo = _sanitizeYamlValue(repo);
2526
+ projectName = _sanitizeYamlValue(projectName);
2527
+ description = _sanitizeYamlValue(description);
2002
2528
  defaultBranch = _sanitizeYamlValue(defaultBranch);
2003
2529
  stagingBranch = _sanitizeYamlValue(stagingBranch);
2004
2530
  // Path values: also escape backslashes so Windows paths are valid YAML.
2005
2531
  // Preserve rawRoot for use in path.join() operations — join() expects raw OS paths,
2006
2532
  // not YAML-escaped strings. Always apply _sanitizePathValue() to the join() result.
2007
2533
  const rawRoot = root;
2008
- root = _sanitizePathValue(root);
2009
- worktreeBase = _sanitizePathValue(worktreeBase);
2534
+ root = _sanitizePathValue(root);
2535
+ worktreeBase = _sanitizePathValue(worktreeBase);
2010
2536
 
2011
2537
  const { projectBoard, multiRepo, review, verification } = optionalSections;
2012
2538
 
2013
2539
  // 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;
2540
+ const safeMultiRepo = multiRepo
2541
+ ? {
2542
+ prefix: _sanitizeYamlValue(multiRepo.prefix || "sat"),
2543
+ satelliteRepo: _sanitizeYamlValue(
2544
+ multiRepo.satelliteRepo || "your-satellite-repo",
2545
+ ),
2546
+ satelliteBranch: _sanitizeYamlValue(
2547
+ multiRepo.satelliteBranch || "main",
2548
+ ),
2549
+ }
2550
+ : null;
2551
+
2552
+ const safeReview = review
2553
+ ? {
2554
+ techStack: _sanitizeYamlValue(
2555
+ review.techStack || "Node.js, TypeScript, PostgreSQL",
2556
+ ),
2557
+ // context uses a block scalar (|) — only strip double-quotes; newlines are handled by split/join
2558
+ context: (
2559
+ review.context || "Add architecture notes and conventions here."
2560
+ ).replace(/"/g, ""),
2561
+ }
2562
+ : null;
2563
+
2564
+ const safeVerification = verification
2565
+ ? {
2566
+ healthEndpoint: _sanitizeYamlValue(
2567
+ verification.healthEndpoint || `https://api.${repo}.io/health`,
2568
+ ),
2569
+ }
2570
+ : null;
2029
2571
 
2030
2572
  // --- repos section ---
2031
2573
  const reposSection = safeMultiRepo
@@ -2059,7 +2601,9 @@ function buildForgeYamlContent({ owner, repo, projectName, description, root, wo
2059
2601
  * Only writes fields that have at least one mapped option.
2060
2602
  */
2061
2603
  function _buildOptionIdsBlock(optionIds) {
2062
- const entries = Object.entries(optionIds).filter(([, opts]) => Object.keys(opts).length > 0);
2604
+ const entries = Object.entries(optionIds).filter(
2605
+ ([, opts]) => Object.keys(opts).length > 0,
2606
+ );
2063
2607
  if (entries.length === 0) return "";
2064
2608
  const lines = [" option_ids:"];
2065
2609
  for (const [fieldKey, opts] of entries) {
@@ -2071,13 +2615,16 @@ function buildForgeYamlContent({ owner, repo, projectName, description, root, wo
2071
2615
  return "\n" + lines.join("\n");
2072
2616
  }
2073
2617
 
2074
- const fieldIdsBlock = FIELD_KEYS
2075
- .map((k) => ` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`)
2076
- .join("\n");
2618
+ const fieldIdsBlock = FIELD_KEYS.map(
2619
+ (k) =>
2620
+ ` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
2621
+ ).join("\n");
2077
2622
 
2078
- const optionIdsBlock = projectBoard?.optionIds ? _buildOptionIdsBlock(resolvedOptionIds) : "";
2623
+ const optionIdsBlock = projectBoard?.optionIds
2624
+ ? _buildOptionIdsBlock(resolvedOptionIds)
2625
+ : "";
2079
2626
 
2080
- const hasDiscoveredId = !!(projectBoard?.projectId);
2627
+ const hasDiscoveredId = !!projectBoard?.projectId;
2081
2628
 
2082
2629
  const projectBoardSection = projectBoard
2083
2630
  ? `project_board:
@@ -2212,10 +2759,16 @@ function _writeForgeYaml(opts) {
2212
2759
  function _printNextSteps({ remoteDetected }) {
2213
2760
  console.log(`${BOLD}Next steps:${RESET}`);
2214
2761
  if (!remoteDetected) {
2215
- console.log(` ${YELLOW}!${RESET} Edit ${CYAN}forge.yaml${RESET} — set project.owner and project.repo`);
2762
+ console.log(
2763
+ ` ${YELLOW}!${RESET} Edit ${CYAN}forge.yaml${RESET} — set project.owner and project.repo`,
2764
+ );
2216
2765
  }
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`);
2766
+ console.log(
2767
+ ` 1. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
2768
+ );
2769
+ console.log(
2770
+ ` 2. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
2771
+ );
2219
2772
  console.log("");
2220
2773
  }
2221
2774
 
@@ -2292,11 +2845,19 @@ function _parseSatelliteRepos(content) {
2292
2845
  }
2293
2846
  if (!inRepos) continue;
2294
2847
 
2295
- if (line.trim() === "default:") { continue; }
2296
- if (line.trim() === "satellites:") { inSatellites = true; continue; }
2848
+ if (line.trim() === "default:") {
2849
+ continue;
2850
+ }
2851
+ if (line.trim() === "satellites:") {
2852
+ inSatellites = true;
2853
+ continue;
2854
+ }
2297
2855
 
2298
2856
  if (inSatellites && /^\s{6,}repo:/.test(line)) {
2299
- const raw = line.replace(/^\s+repo:\s*/, "").replace(/^["']|["']$/g, "").trim();
2857
+ const raw = line
2858
+ .replace(/^\s+repo:\s*/, "")
2859
+ .replace(/^["']|["']$/g, "")
2860
+ .trim();
2300
2861
  if (raw) repos.push(raw);
2301
2862
  }
2302
2863
  }
@@ -2318,7 +2879,11 @@ async function validate(forgeYamlPath) {
2318
2879
 
2319
2880
  // 1. Read forge.yaml
2320
2881
  if (!existsSync(forgeYamlPath)) {
2321
- checks.push({ label: "forge.yaml found", status: "error", note: `Not found: ${forgeYamlPath}` });
2882
+ checks.push({
2883
+ label: "forge.yaml found",
2884
+ status: "error",
2885
+ note: `Not found: ${forgeYamlPath}`,
2886
+ });
2322
2887
  _renderValidationSummary(checks);
2323
2888
  return { passed: false, checks };
2324
2889
  }
@@ -2327,7 +2892,11 @@ async function validate(forgeYamlPath) {
2327
2892
  try {
2328
2893
  content = readFileSync(forgeYamlPath, "utf-8");
2329
2894
  } catch (err) {
2330
- checks.push({ label: "forge.yaml found", status: "error", note: `Cannot read: ${err.message}` });
2895
+ checks.push({
2896
+ label: "forge.yaml found",
2897
+ status: "error",
2898
+ note: `Cannot read: ${err.message}`,
2899
+ });
2331
2900
  _renderValidationSummary(checks);
2332
2901
  return { passed: false, checks };
2333
2902
  }
@@ -2335,7 +2904,13 @@ async function validate(forgeYamlPath) {
2335
2904
  checks.push({ label: "forge.yaml found", status: "ok", note: forgeYamlPath });
2336
2905
 
2337
2906
  // 2. Required fields
2338
- const PLACEHOLDERS = new Set(["your-github-org", "your-repo-name", "", "your-org", "your-repo"]);
2907
+ const PLACEHOLDERS = new Set([
2908
+ "your-github-org",
2909
+ "your-repo-name",
2910
+ "",
2911
+ "your-org",
2912
+ "your-repo",
2913
+ ]);
2339
2914
 
2340
2915
  const owner = _parseYamlKey(content, "project.owner");
2341
2916
  const repo = _parseYamlKey(content, "project.repo");
@@ -2350,7 +2925,11 @@ async function validate(forgeYamlPath) {
2350
2925
  if (!defaultBranch) missingFields.push("branches.default");
2351
2926
 
2352
2927
  if (missingFields.length === 0) {
2353
- checks.push({ label: "Required fields", status: "ok", note: `${owner}/${repo}` });
2928
+ checks.push({
2929
+ label: "Required fields",
2930
+ status: "ok",
2931
+ note: `${owner}/${repo}`,
2932
+ });
2354
2933
  } else {
2355
2934
  checks.push({
2356
2935
  label: "Required fields",
@@ -2364,20 +2943,36 @@ async function validate(forgeYamlPath) {
2364
2943
  if (existsSync(root)) {
2365
2944
  checks.push({ label: "paths.root exists", status: "ok", note: root });
2366
2945
  } else {
2367
- checks.push({ label: "paths.root exists", status: "warn", note: `Directory not found: ${root}` });
2946
+ checks.push({
2947
+ label: "paths.root exists",
2948
+ status: "warn",
2949
+ note: `Directory not found: ${root}`,
2950
+ });
2368
2951
  }
2369
2952
  }
2370
2953
 
2371
2954
  // 4. Create worktree_base if missing
2372
2955
  if (worktreeBase) {
2373
2956
  if (existsSync(worktreeBase)) {
2374
- checks.push({ label: "worktree_base exists", status: "ok", note: worktreeBase });
2957
+ checks.push({
2958
+ label: "worktree_base exists",
2959
+ status: "ok",
2960
+ note: worktreeBase,
2961
+ });
2375
2962
  } else {
2376
2963
  try {
2377
2964
  await mkdir(worktreeBase, { recursive: true });
2378
- checks.push({ label: "worktree_base exists", status: "ok", note: `Created: ${worktreeBase}` });
2965
+ checks.push({
2966
+ label: "worktree_base exists",
2967
+ status: "ok",
2968
+ note: `Created: ${worktreeBase}`,
2969
+ });
2379
2970
  } catch (err) {
2380
- checks.push({ label: "worktree_base exists", status: "warn", note: `Cannot create: ${err.message}` });
2971
+ checks.push({
2972
+ label: "worktree_base exists",
2973
+ status: "warn",
2974
+ note: `Cannot create: ${err.message}`,
2975
+ });
2381
2976
  }
2382
2977
  }
2383
2978
  }
@@ -2385,11 +2980,19 @@ async function validate(forgeYamlPath) {
2385
2980
  // 5. GitHub repo access
2386
2981
  if (owner && repo && !PLACEHOLDERS.has(owner) && !PLACEHOLDERS.has(repo)) {
2387
2982
  try {
2388
- execFileSync("gh", ["repo", "view", `${owner}/${repo}`, "--json", "name"], {
2389
- stdio: ["pipe", "pipe", "pipe"],
2390
- timeout: 10000,
2983
+ execFileSync(
2984
+ "gh",
2985
+ ["repo", "view", `${owner}/${repo}`, "--json", "name"],
2986
+ {
2987
+ stdio: ["pipe", "pipe", "pipe"],
2988
+ timeout: 10000,
2989
+ },
2990
+ );
2991
+ checks.push({
2992
+ label: "GitHub repo accessible",
2993
+ status: "ok",
2994
+ note: `${owner}/${repo}`,
2391
2995
  });
2392
- checks.push({ label: "GitHub repo accessible", status: "ok", note: `${owner}/${repo}` });
2393
2996
  } catch {
2394
2997
  checks.push({
2395
2998
  label: "GitHub repo accessible",
@@ -2398,29 +3001,51 @@ async function validate(forgeYamlPath) {
2398
3001
  });
2399
3002
  }
2400
3003
  } else {
2401
- checks.push({ label: "GitHub repo accessible", status: "warn", note: "Skipped — owner/repo not set" });
3004
+ checks.push({
3005
+ label: "GitHub repo accessible",
3006
+ status: "warn",
3007
+ note: "Skipped — owner/repo not set",
3008
+ });
2402
3009
  }
2403
3010
 
2404
3011
  // 6. Branch existence on remote
2405
3012
  const stagingBranch = _parseYamlKey(content, "branches.staging");
2406
- const branchesToCheck = [...new Set([defaultBranch, stagingBranch].filter(Boolean))];
3013
+ const branchesToCheck = [
3014
+ ...new Set([defaultBranch, stagingBranch].filter(Boolean)),
3015
+ ];
2407
3016
 
2408
3017
  if (root && existsSync(root) && branchesToCheck.length > 0) {
2409
3018
  for (const branch of branchesToCheck) {
2410
3019
  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
- });
3020
+ const result = execFileSync(
3021
+ "git",
3022
+ ["ls-remote", "--heads", "origin", branch],
3023
+ {
3024
+ cwd: root,
3025
+ stdio: ["pipe", "pipe", "pipe"],
3026
+ encoding: "utf-8",
3027
+ timeout: 10000,
3028
+ },
3029
+ );
2417
3030
  if (result.trim()) {
2418
- checks.push({ label: `Branch: ${branch}`, status: "ok", note: "Exists on remote" });
3031
+ checks.push({
3032
+ label: `Branch: ${branch}`,
3033
+ status: "ok",
3034
+ note: "Exists on remote",
3035
+ });
2419
3036
  } else {
2420
- checks.push({ label: `Branch: ${branch}`, status: "warn", note: "Not found on remote" });
3037
+ checks.push({
3038
+ label: `Branch: ${branch}`,
3039
+ status: "warn",
3040
+ note: "Not found on remote",
3041
+ });
2421
3042
  }
2422
3043
  } catch {
2423
- checks.push({ label: `Branch: ${branch}`, status: "warn", note: "Cannot verify — git error" });
3044
+ checks.push({
3045
+ label: `Branch: ${branch}`,
3046
+ status: "warn",
3047
+ note: "Cannot verify — git error",
3048
+ });
2424
3049
  }
2425
3050
  }
2426
3051
  }
@@ -2448,31 +3073,58 @@ async function validate(forgeYamlPath) {
2448
3073
  const result = execFileSync(
2449
3074
  "gh",
2450
3075
  [
2451
- "api", "graphql",
2452
- "-F", `id=${projectId}`,
2453
- "-f", "query=query($id: ID!) { node(id: $id) { id __typename } }",
3076
+ "api",
3077
+ "graphql",
3078
+ "-F",
3079
+ `id=${projectId}`,
3080
+ "-f",
3081
+ "query=query($id: ID!) { node(id: $id) { id __typename } }",
2454
3082
  ],
2455
- { stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8", timeout: 10000 }
3083
+ {
3084
+ stdio: ["pipe", "pipe", "pipe"],
3085
+ encoding: "utf-8",
3086
+ timeout: 10000,
3087
+ },
2456
3088
  );
2457
3089
  const parsed = JSON.parse(result);
2458
3090
  if (parsed?.data?.node?.id) {
2459
- checks.push({ label: "Project board configured", status: "ok", note: `${projectId.slice(0, 16)}... resolves` });
3091
+ checks.push({
3092
+ label: "Project board configured",
3093
+ status: "ok",
3094
+ note: `${projectId.slice(0, 16)}... resolves`,
3095
+ });
2460
3096
  } else {
2461
- checks.push({ label: "Project board configured", status: "warn", note: "project_id may be invalid — verify with: gh project list" });
3097
+ checks.push({
3098
+ label: "Project board configured",
3099
+ status: "warn",
3100
+ note: "project_id may be invalid — verify with: gh project list",
3101
+ });
2462
3102
  }
2463
3103
  } catch {
2464
- checks.push({ label: "Project board configured", status: "warn", note: "Cannot verify project_id — gh error" });
3104
+ checks.push({
3105
+ label: "Project board configured",
3106
+ status: "warn",
3107
+ note: "Cannot verify project_id — gh error",
3108
+ });
2465
3109
  }
2466
3110
  }
2467
3111
  } else {
2468
- checks.push({ label: "Project board", status: "warn", note: "Not configured (optional)" });
3112
+ checks.push({
3113
+ label: "Project board",
3114
+ status: "warn",
3115
+ note: "Not configured (optional)",
3116
+ });
2469
3117
  }
2470
3118
 
2471
3119
  // 8. Satellite repo access
2472
3120
  if (_sectionActive(content, "repos")) {
2473
3121
  const satellites = _parseSatelliteRepos(content);
2474
3122
  if (satellites.length === 0) {
2475
- checks.push({ label: "Satellite repos", status: "warn", note: "repos: section active but no satellites found" });
3123
+ checks.push({
3124
+ label: "Satellite repos",
3125
+ status: "warn",
3126
+ note: "repos: section active but no satellites found",
3127
+ });
2476
3128
  } else {
2477
3129
  for (const sat of satellites) {
2478
3130
  try {
@@ -2480,14 +3132,26 @@ async function validate(forgeYamlPath) {
2480
3132
  stdio: ["pipe", "pipe", "pipe"],
2481
3133
  timeout: 10000,
2482
3134
  });
2483
- checks.push({ label: `Satellite: ${sat}`, status: "ok", note: "Accessible" });
3135
+ checks.push({
3136
+ label: `Satellite: ${sat}`,
3137
+ status: "ok",
3138
+ note: "Accessible",
3139
+ });
2484
3140
  } catch {
2485
- checks.push({ label: `Satellite: ${sat}`, status: "warn", note: "Cannot access — check repo name or gh auth" });
3141
+ checks.push({
3142
+ label: `Satellite: ${sat}`,
3143
+ status: "warn",
3144
+ note: "Cannot access — check repo name or gh auth",
3145
+ });
2486
3146
  }
2487
3147
  }
2488
3148
  }
2489
3149
  } else {
2490
- checks.push({ label: "Satellite repos", status: "warn", note: "Not configured (optional)" });
3150
+ checks.push({
3151
+ label: "Satellite repos",
3152
+ status: "warn",
3153
+ note: "Not configured (optional)",
3154
+ });
2491
3155
  }
2492
3156
 
2493
3157
  _renderValidationSummary(checks);
@@ -2521,17 +3185,25 @@ function _renderValidationSummary(checks) {
2521
3185
  }
2522
3186
 
2523
3187
  const hasErrors = checks.some((c) => c.status === "error");
2524
- const boardOk = checks.some((c) => c.label === "Project board configured" && c.status === "ok");
3188
+ const boardOk = checks.some(
3189
+ (c) => c.label === "Project board configured" && c.status === "ok",
3190
+ );
2525
3191
 
2526
3192
  lines.push("");
2527
3193
  lines.push(` ${bold("Next steps:")}`);
2528
3194
  if (hasErrors) {
2529
- lines.push(` ${dim("•")} Edit ${cyan("forge.yaml")} to fix the errors above`);
3195
+ lines.push(
3196
+ ` ${dim("•")} Edit ${cyan("forge.yaml")} to fix the errors above`,
3197
+ );
2530
3198
  }
2531
3199
  if (!boardOk) {
2532
- lines.push(` ${dim("•")} Run ${cyan("/forgedock-init")} for AI-powered optional section setup`);
3200
+ lines.push(
3201
+ ` ${dim("•")} Run ${cyan("/forgedock-init")} for AI-powered optional section setup`,
3202
+ );
2533
3203
  }
2534
- lines.push(` ${dim("•")} Run ${cyan("/work-on next")} to start your first task`);
3204
+ lines.push(
3205
+ ` ${dim("•")} Run ${cyan("/work-on next")} to start your first task`,
3206
+ );
2535
3207
  lines.push("");
2536
3208
 
2537
3209
  process.stdout.write(box(lines, { title: "Config Validation" }));
@@ -2541,7 +3213,7 @@ function _renderValidationSummary(checks) {
2541
3213
  // Bot credentials — store in ~/.forgedock/credentials.json (NOT forge.yaml)
2542
3214
  // ---------------------------------------------------------------------------
2543
3215
 
2544
- const FORGEDOCK_HOME = join(process.env.HOME, ".forgedock");
3216
+ const FORGEDOCK_HOME = join(HOME, ".forgedock");
2545
3217
  const CREDENTIALS_FILE = join(FORGEDOCK_HOME, "credentials.json");
2546
3218
 
2547
3219
  /**
@@ -2558,7 +3230,10 @@ async function saveBotCredentials(creds) {
2558
3230
  chmodSync(FORGEDOCK_HOME, 0o700);
2559
3231
  const existing = loadBotCredentials() ?? {};
2560
3232
  const updated = { ...existing, bot: creds };
2561
- writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
3233
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
3234
+ encoding: "utf-8",
3235
+ mode: 0o600,
3236
+ });
2562
3237
  // Tighten permissions on files created before this fix (mode is only applied at creation time)
2563
3238
  chmodSync(CREDENTIALS_FILE, 0o600);
2564
3239
  }
@@ -2593,12 +3268,14 @@ function loadBotCredentials() {
2593
3268
  function generateAppJwt(appId, privateKey) {
2594
3269
  const now = Math.floor(Date.now() / 1000);
2595
3270
  const payload = {
2596
- iat: now - 60, // issued 60s ago to account for clock skew
2597
- exp: now + 600, // expires in 10 minutes (GitHub max)
3271
+ iat: now - 60, // issued 60s ago to account for clock skew
3272
+ exp: now + 600, // expires in 10 minutes (GitHub max)
2598
3273
  iss: appId,
2599
3274
  };
2600
3275
 
2601
- const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
3276
+ const header = Buffer.from(
3277
+ JSON.stringify({ alg: "RS256", typ: "JWT" }),
3278
+ ).toString("base64url");
2602
3279
  const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
2603
3280
  const unsigned = `${header}.${body}`;
2604
3281
 
@@ -2619,17 +3296,19 @@ function generateAppJwt(appId, privateKey) {
2619
3296
  */
2620
3297
  async function connectExistingBot() {
2621
3298
  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
- ));
3299
+ console.log(
3300
+ box(
3301
+ [
3302
+ `${bold("Connect an existing GitHub App")}`,
3303
+ "",
3304
+ "You'll need:",
3305
+ ` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
3306
+ ` • Private key file (.pem downloaded from app settings)`,
3307
+ ` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
3308
+ ].join("\n"),
3309
+ { title: "Connect GitHub App" },
3310
+ ),
3311
+ );
2633
3312
  console.log("");
2634
3313
 
2635
3314
  const appId = await input("App ID (numeric):", "");
@@ -2639,12 +3318,20 @@ async function connectExistingBot() {
2639
3318
  }
2640
3319
 
2641
3320
  const privateKeyPath = await input("Private key path (.pem):", "");
2642
- const HOME = process.env.HOME ?? "";
2643
- const expanded = privateKeyPath.trim().replace(/^~/, HOME);
3321
+ const trimmedKeyPath = privateKeyPath.trim();
3322
+ const home = os.homedir();
3323
+ const expanded = trimmedKeyPath.startsWith("~")
3324
+ ? join(home, trimmedKeyPath.slice(1))
3325
+ : trimmedKeyPath;
2644
3326
  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;
3327
+ if (trimmedKeyPath.startsWith("~")) {
3328
+ const rel = relative(home, resolvedKeyPath);
3329
+ if (rel.startsWith("..") || isAbsolute(rel)) {
3330
+ console.log(
3331
+ ` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`,
3332
+ );
3333
+ return false;
3334
+ }
2648
3335
  }
2649
3336
  if (!existsSync(resolvedKeyPath)) {
2650
3337
  console.log(` ${RED}File not found: ${resolvedKeyPath}${RESET}`);
@@ -2653,7 +3340,9 @@ async function connectExistingBot() {
2653
3340
 
2654
3341
  const installationId = await input("Installation ID (numeric):", "");
2655
3342
  if (!installationId || !/^\d+$/.test(installationId.trim())) {
2656
- console.log(` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`);
3343
+ console.log(
3344
+ ` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`,
3345
+ );
2657
3346
  return false;
2658
3347
  }
2659
3348
 
@@ -2679,11 +3368,15 @@ async function connectExistingBot() {
2679
3368
  });
2680
3369
  if (!resp.ok) {
2681
3370
  const body = await resp.json().catch(() => ({}));
2682
- console.log(` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`);
3371
+ console.log(
3372
+ ` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`,
3373
+ );
2683
3374
  return false;
2684
3375
  }
2685
3376
  const appData = await resp.json();
2686
- console.log(` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`);
3377
+ console.log(
3378
+ ` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`,
3379
+ );
2687
3380
  } catch (err) {
2688
3381
  console.log(` ${RED}Validation failed: ${err.message}${RESET}`);
2689
3382
  return false;
@@ -2716,8 +3409,8 @@ async function botStatus() {
2716
3409
  if (!creds) {
2717
3410
  console.log(
2718
3411
  ` ${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)}.`
3412
+ ` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
3413
+ ` add credentials to ${cyan(CREDENTIALS_FILE)}.`,
2721
3414
  );
2722
3415
  console.log("");
2723
3416
  return;
@@ -2730,7 +3423,9 @@ async function botStatus() {
2730
3423
 
2731
3424
  // Check private key file
2732
3425
  if (!existsSync(creds.privateKeyPath)) {
2733
- console.log(` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`);
3426
+ console.log(
3427
+ ` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`,
3428
+ );
2734
3429
  console.log(` Run ${cyan("npx forgedock bot setup")} to reconfigure.`);
2735
3430
  console.log("");
2736
3431
  return;
@@ -2759,12 +3454,16 @@ async function botStatus() {
2759
3454
  });
2760
3455
  if (!appResp.ok) {
2761
3456
  const body = await appResp.json().catch(() => ({}));
2762
- console.log(` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`);
3457
+ console.log(
3458
+ ` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`,
3459
+ );
2763
3460
  console.log("");
2764
3461
  return;
2765
3462
  }
2766
3463
  const appData = await appResp.json();
2767
- console.log(` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`);
3464
+ console.log(
3465
+ ` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`,
3466
+ );
2768
3467
 
2769
3468
  // Rate limit for app
2770
3469
  const rlResp = await fetch("https://api.github.com/rate_limit", {
@@ -2780,7 +3479,9 @@ async function botStatus() {
2780
3479
  if (core) {
2781
3480
  const used = core.limit - core.remaining;
2782
3481
  const resetTime = new Date(core.reset * 1000).toLocaleTimeString();
2783
- console.log(` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`);
3482
+ console.log(
3483
+ ` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`,
3484
+ );
2784
3485
  }
2785
3486
  }
2786
3487
  } catch (err) {
@@ -2801,19 +3502,21 @@ async function botStatus() {
2801
3502
  */
2802
3503
  async function botSetup() {
2803
3504
  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
- ));
3505
+ console.log(
3506
+ box(
3507
+ [
3508
+ `${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
3509
+ "",
3510
+ "A GitHub App gives ForgeDock its own identity",
3511
+ "for pipeline operations. Benefits:",
3512
+ ` ${cyan("•")} Separate audit trail for bot actions`,
3513
+ ` ${cyan("•")} Higher API rate limits`,
3514
+ ` ${cyan("•")} Webhook-driven automation (future)`,
3515
+ ` ${cyan("•")} Multi-user team support`,
3516
+ ].join("\n"),
3517
+ { title: "GitHub Bot" },
3518
+ ),
3519
+ );
2817
3520
  console.log("");
2818
3521
 
2819
3522
  const action = await select("How would you like to set up the bot?", [
@@ -2824,7 +3527,7 @@ async function botSetup() {
2824
3527
 
2825
3528
  if (action === "skip") {
2826
3529
  console.log(
2827
- ` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`
3530
+ ` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`,
2828
3531
  );
2829
3532
  return;
2830
3533
  }
@@ -2836,7 +3539,9 @@ async function botSetup() {
2836
3539
  try {
2837
3540
  manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
2838
3541
  } catch {
2839
- console.log(` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`);
3542
+ console.log(
3543
+ ` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`,
3544
+ );
2840
3545
  return;
2841
3546
  }
2842
3547
 
@@ -2844,19 +3549,26 @@ async function botSetup() {
2844
3549
  const createUrl = `https://github.com/settings/apps/new?manifest=${encodedManifest}`;
2845
3550
 
2846
3551
  console.log("");
2847
- console.log(` ${bold("Opening GitHub App registration in your browser…")}`);
3552
+ console.log(
3553
+ ` ${bold("Opening GitHub App registration in your browser…")}`,
3554
+ );
2848
3555
  console.log(` URL: ${cyan(createUrl.substring(0, 80))}…`);
2849
3556
  console.log("");
2850
3557
 
2851
3558
  // Try to open the browser
2852
3559
  try {
2853
- const opener = process.platform === "win32" ? "start" :
2854
- process.platform === "darwin" ? "open" : "xdg-open";
3560
+ const opener =
3561
+ process.platform === "win32"
3562
+ ? "start"
3563
+ : process.platform === "darwin"
3564
+ ? "open"
3565
+ : "xdg-open";
2855
3566
  // On Windows, `start "url"` treats the first quoted arg as the window title.
2856
3567
  // 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}"`;
3568
+ const cmd =
3569
+ process.platform === "win32"
3570
+ ? `start "" "${createUrl}"`
3571
+ : `${opener} "${createUrl}"`;
2860
3572
  execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
2861
3573
  } catch {
2862
3574
  console.log(` ${YELLOW}Could not open browser automatically.${RESET}`);
@@ -2868,7 +3580,9 @@ async function botSetup() {
2868
3580
  console.log(" After creating the app in your browser:");
2869
3581
  console.log(` 1. Download the private key (.pem) from the app settings`);
2870
3582
  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\"`);
3583
+ console.log(
3584
+ ` 3. Run ${cyan("npx forgedock bot setup")} again and choose \"Connect existing\"`,
3585
+ );
2872
3586
  console.log("");
2873
3587
  return;
2874
3588
  }
@@ -2882,19 +3596,39 @@ async function botSetup() {
2882
3596
 
2883
3597
  function help() {
2884
3598
  console.log("");
2885
- console.log(`${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`);
3599
+ console.log(
3600
+ `${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`,
3601
+ );
2886
3602
  console.log("");
2887
3603
  console.log("Usage:");
2888
- console.log(` ${CYAN}npx forgedock${RESET} Launch TUI onboarding (interactive)`);
3604
+ console.log(
3605
+ ` ${CYAN}npx forgedock${RESET} Launch TUI onboarding (interactive)`,
3606
+ );
2889
3607
  console.log(` ${CYAN}npx forgedock install${RESET} Install commands`);
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)`);
3608
+ console.log(
3609
+ ` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
3610
+ );
3611
+ console.log(
3612
+ ` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`,
3613
+ );
3614
+ console.log(
3615
+ ` ${CYAN}npx forgedock uninstall${RESET} Remove commands (interactive, with confirmation)`,
3616
+ );
3617
+ console.log(
3618
+ ` ${CYAN}npx forgedock uninstall --yes${RESET} Remove commands without prompts (non-interactive)`,
3619
+ );
3620
+ console.log(
3621
+ ` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
3622
+ );
3623
+ console.log(
3624
+ ` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`,
3625
+ );
3626
+ console.log(
3627
+ ` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`,
3628
+ );
3629
+ console.log(
3630
+ ` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`,
3631
+ );
2898
3632
  console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
2899
3633
  console.log("");
2900
3634
  }
@@ -2906,9 +3640,54 @@ function help() {
2906
3640
  async function tuiOnboarding() {
2907
3641
  splash();
2908
3642
 
2909
- // Non-TTY fallback: skip TUI and detection, run install directly
3643
+ // Non-TTY fallback: skip TUI and detection, run install directly.
3644
+ // Print a prominent boxed notice so the user understands what happened and
3645
+ // what to do next. Best-effort update/checkout hints are folded into the box.
2910
3646
  if (!process.stdout.isTTY) {
2911
- console.log(dim(" Non-interactive environment detected — running install."));
3647
+ const noticeLines = [
3648
+ "",
3649
+ ` Non-interactive environment detected.`,
3650
+ ` ForgeDock's setup wizard requires an interactive terminal (TTY).`,
3651
+ "",
3652
+ ` ${dim("To run the interactive TUI:")} open a terminal and run ${cyan("npx forgedock")}`,
3653
+ "",
3654
+ ];
3655
+
3656
+ // Best-effort remote check — fold results into the box, never block install.
3657
+ try {
3658
+ const remoteCheck = await checkForRemoteUpdate();
3659
+ if (remoteCheck?.updateAvailable) {
3660
+ const isGitCheckout = existsSync(join(FORGE_HOME, ".git"));
3661
+ if (isGitCheckout) {
3662
+ // User is running from the ForgeDock source checkout and it's behind remote.
3663
+ noticeLines.push(
3664
+ ` ${yellow("Stale local checkout:")} the working-tree bin is behind origin/main.`,
3665
+ );
3666
+ noticeLines.push(
3667
+ ` ${dim("Run")} ${cyan(`git pull`)} ${dim(`in`)} ${dim(FORGE_HOME)}`,
3668
+ );
3669
+ noticeLines.push(
3670
+ ` ${dim("Or use the published package:")} ${cyan("npx forgedock@latest")}`,
3671
+ );
3672
+ } else {
3673
+ const versionHint = remoteCheck.latestVersion
3674
+ ? ` (v${getVersion()} → v${remoteCheck.latestVersion})`
3675
+ : "";
3676
+ noticeLines.push(
3677
+ ` ${yellow(`Update available${versionHint}`)} — run: ${cyan("npx forgedock update")}`,
3678
+ );
3679
+ }
3680
+ noticeLines.push("");
3681
+ }
3682
+ } catch {
3683
+ // Best-effort — never block install
3684
+ }
3685
+
3686
+ noticeLines.push(` Running ${cyan("install")} now...`);
3687
+ noticeLines.push("");
3688
+ process.stdout.write(
3689
+ box(noticeLines, { title: "Non-interactive environment" }),
3690
+ );
2912
3691
  console.log("");
2913
3692
  await install();
2914
3693
  return;
@@ -2926,17 +3705,61 @@ async function tuiOnboarding() {
2926
3705
  ? `v${detection.installedVersion}`
2927
3706
  : `v${detection.currentVersion}`;
2928
3707
  console.log(
2929
- ` ${green("Everything up to date.")} ForgeDock ${versionLabel} is installed and configured.`
3708
+ ` ${green("Everything up to date.")} ForgeDock ${versionLabel} is installed and configured.`,
2930
3709
  );
2931
3710
  console.log("");
2932
3711
 
2933
- const action = await select("What would you like to do?", [
3712
+ // Proactive remote update check — cached, never blocks TUI startup
3713
+ let remoteUpdateInfo = null;
3714
+ try {
3715
+ remoteUpdateInfo = await checkForRemoteUpdate();
3716
+ } catch {
3717
+ // Best-effort — ignore all errors
3718
+ }
3719
+
3720
+ if (remoteUpdateInfo?.updateAvailable) {
3721
+ // Surface a prominent notice when behind the remote
3722
+ const noticeLines = [""];
3723
+ if (remoteUpdateInfo.latestVersion) {
3724
+ noticeLines.push(
3725
+ ` ${yellow("Update available:")} v${detection.currentVersion} → ${green(`v${remoteUpdateInfo.latestVersion}`)}`,
3726
+ );
3727
+ } else {
3728
+ // Git install: no version string, but commits available
3729
+ noticeLines.push(
3730
+ ` ${yellow("Update available")} — new commits on origin/main`,
3731
+ );
3732
+ }
3733
+ noticeLines.push(
3734
+ "",
3735
+ ` ${dim('Choose "Update now" below or run')} ${cyan("npx forgedock update")}`,
3736
+ "",
3737
+ );
3738
+ process.stdout.write(box(noticeLines, { title: "Update available" }));
3739
+ console.log("");
3740
+ }
3741
+
3742
+ // Build the action menu — insert "Update now" as the first choice when behind
3743
+ /** @type {Array<{label: string, value: string}>} */
3744
+ const menuChoices = [];
3745
+ if (remoteUpdateInfo?.updateAvailable) {
3746
+ menuChoices.push({ label: "Update now", value: "update" });
3747
+ }
3748
+ menuChoices.push(
2934
3749
  { label: "Nothing — exit", value: "exit" },
2935
- { label: "Reconfigure project (regenerate forge.yaml)", value: "reconfigure" },
3750
+ {
3751
+ label: "Reconfigure project (regenerate forge.yaml)",
3752
+ value: "reconfigure",
3753
+ },
2936
3754
  { label: "Reinstall commands", value: "reinstall" },
2937
- ]);
3755
+ );
3756
+
3757
+ const action = await select("What would you like to do?", menuChoices);
2938
3758
 
2939
- if (action === "reconfigure") {
3759
+ if (action === "update") {
3760
+ console.log("");
3761
+ await update();
3762
+ } else if (action === "reconfigure") {
2940
3763
  console.log("");
2941
3764
  await init();
2942
3765
  } else if (action === "reinstall") {
@@ -2954,8 +3777,8 @@ async function tuiOnboarding() {
2954
3777
  if (detection.state === "update-available") {
2955
3778
  console.log(
2956
3779
  ` ${yellow("Update available.")} ` +
2957
- `Installed: ${dim(`v${detection.installedVersion ?? "unknown"}`)} → ` +
2958
- `New: ${green(`v${detection.currentVersion}`)}`
3780
+ `Installed: ${dim(`v${detection.installedVersion ?? "unknown"}`)} → ` +
3781
+ `New: ${green(`v${detection.currentVersion}`)}`,
2959
3782
  );
2960
3783
  console.log("");
2961
3784
 
@@ -2985,7 +3808,10 @@ async function tuiOnboarding() {
2985
3808
  if (success) {
2986
3809
  // Show command diff if any commands were added or removed
2987
3810
  const commandsAfterUpdate = await getInstalledCommandNames();
2988
- const diffLines = formatCommandDiff(commandsBeforeUpdate, commandsAfterUpdate);
3811
+ const diffLines = formatCommandDiff(
3812
+ commandsBeforeUpdate,
3813
+ commandsAfterUpdate,
3814
+ );
2989
3815
  if (diffLines.length > 0) {
2990
3816
  process.stdout.write(box(diffLines, { title: "Command changes" }));
2991
3817
  console.log("");
@@ -2993,14 +3819,14 @@ async function tuiOnboarding() {
2993
3819
 
2994
3820
  console.log(
2995
3821
  green("Update complete!") +
2996
- ` ForgeDock v${detection.currentVersion} is now active.`
3822
+ ` ForgeDock v${detection.currentVersion} is now active.`,
2997
3823
  );
2998
3824
  console.log("");
2999
3825
  } else {
3000
3826
  console.log("");
3001
3827
  console.log(
3002
3828
  yellow("Update incomplete.") +
3003
- ` Re-run ${cyan("npx forgedock")} to try again.`
3829
+ ` Re-run ${cyan("npx forgedock")} to try again.`,
3004
3830
  );
3005
3831
  console.log("");
3006
3832
  process.exit(1);
@@ -3017,7 +3843,7 @@ async function tuiOnboarding() {
3017
3843
  ? `v${detection.installedVersion}`
3018
3844
  : `v${detection.currentVersion}`;
3019
3845
  console.log(
3020
- ` ${yellow("Commands installed")} (${versionLabel}) but no ${cyan("forge.yaml")} found in this directory.`
3846
+ ` ${yellow("Commands installed")} (${versionLabel}) but no ${cyan("forge.yaml")} found in this directory.`,
3021
3847
  );
3022
3848
  console.log("");
3023
3849
 
@@ -3028,13 +3854,13 @@ async function tuiOnboarding() {
3028
3854
  run: async () => {
3029
3855
  const shouldInit = await confirm(
3030
3856
  "Generate forge.yaml for this project?",
3031
- true
3857
+ true,
3032
3858
  );
3033
3859
  if (shouldInit) {
3034
3860
  await init();
3035
3861
  } else {
3036
3862
  console.log(
3037
- ` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`
3863
+ ` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`,
3038
3864
  );
3039
3865
  }
3040
3866
  },
@@ -3045,7 +3871,9 @@ async function tuiOnboarding() {
3045
3871
  run: async () => {
3046
3872
  const forgeYamlPath = join(process.cwd(), "forge.yaml");
3047
3873
  if (!existsSync(forgeYamlPath)) {
3048
- console.log(` ${dim("No forge.yaml found — skipping validation.")}`);
3874
+ console.log(
3875
+ ` ${dim("No forge.yaml found — skipping validation.")}`,
3876
+ );
3049
3877
  return;
3050
3878
  }
3051
3879
  await validate(forgeYamlPath);
@@ -3059,14 +3887,14 @@ async function tuiOnboarding() {
3059
3887
  if (success) {
3060
3888
  console.log(
3061
3889
  green("Done!") +
3062
- ` Run ${cyan("/help")} inside Claude Code to see available commands.`
3890
+ ` Run ${cyan("/help")} inside Claude Code to see available commands.`,
3063
3891
  );
3064
3892
  console.log("");
3065
3893
  } else {
3066
3894
  console.log("");
3067
3895
  console.log(
3068
3896
  yellow("Setup incomplete.") +
3069
- ` Re-run ${cyan("npx forgedock")} to try again.`
3897
+ ` Re-run ${cyan("npx forgedock")} to try again.`,
3070
3898
  );
3071
3899
  console.log("");
3072
3900
  process.exit(1);
@@ -3080,10 +3908,10 @@ async function tuiOnboarding() {
3080
3908
  // -------------------------------------------------------------------------
3081
3909
  if (detection.state === "version-unknown") {
3082
3910
  console.log(
3083
- ` ${yellow("ForgeDock commands are installed")} but the installed version could not be determined.`
3911
+ ` ${yellow("ForgeDock commands are installed")} but the installed version could not be determined.`,
3084
3912
  );
3085
3913
  console.log(
3086
- ` ${dim("The package.json for the installed copy is missing or unreadable.")}`
3914
+ ` ${dim("The package.json for the installed copy is missing or unreadable.")}`,
3087
3915
  );
3088
3916
  console.log("");
3089
3917
 
@@ -3125,19 +3953,21 @@ async function tuiOnboarding() {
3125
3953
  run: async () => {
3126
3954
  const forgeYamlPath = join(process.cwd(), "forge.yaml");
3127
3955
  if (existsSync(forgeYamlPath)) {
3128
- console.log(` ${green("forge.yaml")} already exists — skipping generation.`);
3956
+ console.log(
3957
+ ` ${green("forge.yaml")} already exists — skipping generation.`,
3958
+ );
3129
3959
  return;
3130
3960
  }
3131
3961
 
3132
3962
  const shouldInit = await confirm(
3133
3963
  "No forge.yaml found. Generate one now?",
3134
- true
3964
+ true,
3135
3965
  );
3136
3966
  if (shouldInit) {
3137
3967
  await init();
3138
3968
  } else {
3139
3969
  console.log(
3140
- ` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`
3970
+ ` ${dim("Skipped.")} Run ${cyan("npx forgedock init")} later to generate forge.yaml.`,
3141
3971
  );
3142
3972
  }
3143
3973
  },
@@ -3169,14 +3999,14 @@ async function tuiOnboarding() {
3169
3999
  if (success) {
3170
4000
  console.log(
3171
4001
  green("Setup complete!") +
3172
- ` Run ${cyan("/help")} inside Claude Code to see available commands.`
4002
+ ` Run ${cyan("/help")} inside Claude Code to see available commands.`,
3173
4003
  );
3174
4004
  console.log("");
3175
4005
  } else {
3176
4006
  console.log("");
3177
4007
  console.log(
3178
4008
  yellow("Setup incomplete.") +
3179
- ` Re-run ${cyan("npx forgedock")} to try again.`
4009
+ ` Re-run ${cyan("npx forgedock")} to try again.`,
3180
4010
  );
3181
4011
  console.log("");
3182
4012
  process.exit(1);