forgedock 1.0.13 → 1.0.14

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { fileURLToPath } from "url";
4
4
  import { dirname, join, relative } from "path";
5
- import { mkdir, symlink, readlink, lstat, readdir, stat } from "fs/promises";
5
+ import { mkdir, symlink, readlink, lstat, readdir } from "fs/promises";
6
6
  import {
7
7
  existsSync,
8
8
  appendFileSync,
@@ -13,6 +13,24 @@ import {
13
13
  } from "fs";
14
14
  import { execSync } from "child_process";
15
15
  import readline from "readline";
16
+ import {
17
+ renderLogo,
18
+ runSteps,
19
+ box,
20
+ table,
21
+ bold,
22
+ dim,
23
+ green,
24
+ red,
25
+ yellow,
26
+ cyan,
27
+ RESET,
28
+ BOLD,
29
+ GREEN,
30
+ RED,
31
+ YELLOW,
32
+ CYAN,
33
+ } from "./tui.mjs";
16
34
 
17
35
  const __filename = fileURLToPath(import.meta.url);
18
36
  const __dirname = dirname(__filename);
@@ -20,8 +38,8 @@ const FORGE_HOME = dirname(__dirname);
20
38
  const COMMANDS_DIR = join(FORGE_HOME, "commands");
21
39
 
22
40
  if (!process.env.HOME) {
23
- console.error(
24
- "Error: HOME environment variable is not set. Cannot determine install location.",
41
+ process.stderr.write(
42
+ "Error: HOME environment variable is not set. Cannot determine install location.\n",
25
43
  );
26
44
  process.exit(1);
27
45
  }
@@ -31,24 +49,10 @@ const TARGET_DIR = join(process.env.HOME, ".claude", "commands");
31
49
  const args = process.argv.slice(2);
32
50
  const command = args[0] || "install";
33
51
 
34
- const BOLD = "\x1b[1m";
35
- const GREEN = "\x1b[32m";
36
- const YELLOW = "\x1b[33m";
37
- const CYAN = "\x1b[36m";
38
- const RED = "\x1b[31m";
39
- const RESET = "\x1b[0m";
40
- const DIM = "\x1b[2m";
41
-
42
52
  // ---------------------------------------------------------------------------
43
- // Splash — the ForgeDock wordmark shown at startup
53
+ // Splash — the ForgeDock F-monogram logo shown at startup
44
54
  // ---------------------------------------------------------------------------
45
55
 
46
- const LOGO_LINES = [
47
- " ╔═╗╔═╗╦═╗╔═╗╔═╗╔╦╗╔═╗╔═╗╦╔═",
48
- " ╠╣ ║ ║╠╦╝║ ╦║╣ ║║║ ║║ ╠╩╗",
49
- " ╚ ╚═╝╩╚═╚═╝╚═╝═╩╝╚═╝╚═╝╩ ╩",
50
- ];
51
-
52
56
  function getVersion() {
53
57
  try {
54
58
  const pkg = JSON.parse(
@@ -62,16 +66,7 @@ function getVersion() {
62
66
 
63
67
  function splash() {
64
68
  const version = getVersion();
65
- console.log("");
66
- for (const line of LOGO_LINES) {
67
- console.log(`${BOLD}${CYAN}${line}${RESET}`);
68
- }
69
- console.log("");
70
- console.log(` ${DIM}GitHub as a knowledge graph for AI agents${RESET}`);
71
- if (version) {
72
- console.log(` ${DIM}v${version}${RESET}`);
73
- }
74
- console.log("");
69
+ process.stderr.write(renderLogo({ version }) + "\n");
75
70
  }
76
71
 
77
72
  /**
@@ -80,15 +75,15 @@ function splash() {
80
75
  */
81
76
  async function confirmOverwrite(question) {
82
77
  if (!process.stdin.isTTY) {
83
- console.log(
84
- ` ${YELLOW}Non-interactive environment detected — aborting to protect existing config.${RESET}`,
78
+ process.stderr.write(
79
+ ` ${YELLOW}Non-interactive environment detected — aborting to protect existing config.${RESET}\n`,
85
80
  );
86
81
  return false;
87
82
  }
88
83
  return new Promise((resolve) => {
89
84
  const rl = readline.createInterface({
90
85
  input: process.stdin,
91
- output: process.stdout,
86
+ output: process.stderr,
92
87
  });
93
88
  rl.question(question, (answer) => {
94
89
  rl.close();
@@ -134,21 +129,24 @@ async function findMarkdownFiles(dir) {
134
129
  return results.sort();
135
130
  }
136
131
 
137
- async function install() {
138
- console.log("");
139
- console.log(`${BOLD}ForgeDock${RESET} — Installing pipeline commands`);
140
- console.log(` Source: ${CYAN}${COMMANDS_DIR}/${RESET}`);
141
- console.log(` Target: ${CYAN}${TARGET_DIR}/${RESET}`);
142
- console.log("");
143
-
144
- await mkdir(TARGET_DIR, { recursive: true });
132
+ // ---------------------------------------------------------------------------
133
+ // Shared file-linking logic — used by both install() and update()
134
+ // ---------------------------------------------------------------------------
145
135
 
136
+ /**
137
+ * Link or copy all commands from COMMANDS_DIR into TARGET_DIR.
138
+ * Calls step.progress(idx+1, total) on each file.
139
+ * Returns { installed, updated, skipped }.
140
+ */
141
+ async function linkCommands(step) {
146
142
  const files = await findMarkdownFiles(COMMANDS_DIR);
143
+ const total = files.length;
147
144
  let installed = 0;
148
145
  let updated = 0;
149
146
  let skipped = 0;
150
147
 
151
- for (const file of files) {
148
+ for (let idx = 0; idx < files.length; idx++) {
149
+ const file = files[idx];
152
150
  const rel = relative(COMMANDS_DIR, file);
153
151
  const target = join(TARGET_DIR, rel);
154
152
  const targetDir = dirname(target);
@@ -166,7 +164,6 @@ async function install() {
166
164
  await symlink(file, target + ".tmp");
167
165
  const { rename } = await import("fs/promises");
168
166
  await rename(target + ".tmp", target);
169
- console.log(` ${YELLOW}Updated${RESET}: ${rel}`);
170
167
  updated++;
171
168
  }
172
169
  } else {
@@ -178,15 +175,11 @@ async function install() {
178
175
  skipped++;
179
176
  } else {
180
177
  copyFileSync(file, target);
181
- console.log(` ${YELLOW}Updated${RESET}: ${rel}`);
182
178
  updated++;
183
179
  }
184
180
  }
185
181
  } catch (err) {
186
182
  if (err.code !== "ENOENT") {
187
- console.error(
188
- ` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
189
- );
190
183
  throw err;
191
184
  }
192
185
  // Doesn't exist — create it. Prefer a symlink; fall back to a copy on
@@ -194,182 +187,51 @@ async function install() {
194
187
  // Developer Mode / admin), which would otherwise throw EPERM. See #587.
195
188
  try {
196
189
  await symlink(file, target);
197
- console.log(` ${GREEN}Installed${RESET}: ${rel}`);
198
190
  } catch (linkErr) {
199
191
  if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
200
192
  copyFileSync(file, target);
201
- console.log(` ${GREEN}Installed${RESET} (copy): ${rel}`);
202
193
  } else {
203
194
  throw linkErr;
204
195
  }
205
196
  }
206
197
  installed++;
207
198
  }
208
- }
209
-
210
- console.log("");
211
- console.log(
212
- `Done. ${GREEN}Installed: ${installed}${RESET}, Updated: ${updated}, Skipped: ${skipped}`,
213
- );
214
- console.log("");
215
-
216
- // Set FORGE_HOME in shell profiles
217
- let profileUpdated = false;
218
- for (const profile of [
219
- join(process.env.HOME, ".bashrc"),
220
- join(process.env.HOME, ".zshrc"),
221
- ]) {
222
- if (existsSync(profile)) {
223
- const content = readFileSync(profile, "utf-8");
224
- if (!content.includes("FORGE_HOME")) {
225
- appendFileSync(
226
- profile,
227
- `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
228
- );
229
- console.log(` Added FORGE_HOME to ${profile}`);
230
- profileUpdated = true;
231
- }
232
- }
233
- }
234
-
235
- console.log(
236
- `${GREEN}ForgeDock commands are now available as slash commands in any Claude Code session.${RESET}`,
237
- );
238
- console.log("");
239
-
240
- // Auto-generate forge.yaml if missing — no second command needed.
241
- // Only do this inside a real git project; otherwise cwd may be a system or
242
- // read-only directory (e.g. C:\Windows when npx is launched from a WSL path),
243
- // and writing forge.yaml there would crash. See #585.
244
- const cwd = process.cwd();
245
- const forgeYamlPath = join(cwd, "forge.yaml");
246
- if (!existsSync(forgeYamlPath)) {
247
- if (isGitWorkTree(cwd)) {
248
- await init(true);
249
- } else {
250
- console.log(
251
- ` ${YELLOW}Skipping forge.yaml generation${RESET} — current directory is not a git project.`,
252
- );
253
- console.log(
254
- ` Run ${CYAN}npx forgedock init${RESET} inside your project directory to generate it.`,
255
- );
256
- console.log("");
257
- }
258
- }
259
- }
260
-
261
- async function uninstall() {
262
- console.log("");
263
- console.log(`${BOLD}ForgeDock${RESET} — Removing pipeline commands`);
264
- console.log("");
265
-
266
- const files = await findMarkdownFiles(COMMANDS_DIR);
267
- let removed = 0;
268
199
 
269
- for (const file of files) {
270
- const rel = relative(COMMANDS_DIR, file);
271
- const target = join(TARGET_DIR, rel);
272
-
273
- try {
274
- const stats = await lstat(target);
275
- const { unlink } = await import("fs/promises");
276
- if (stats.isSymbolicLink()) {
277
- const current = await readlink(target);
278
- if (current === file) {
279
- await unlink(target);
280
- console.log(` ${RED}Removed${RESET}: ${rel}`);
281
- removed++;
282
- }
283
- } else if (
284
- readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
285
- ) {
286
- // Regular file installed by ForgeDock in copy mode (content matches the
287
- // shipped command). Safe to remove. Content-differing files are left
288
- // in place in case the user edited them.
289
- await unlink(target);
290
- console.log(` ${RED}Removed${RESET}: ${rel}`);
291
- removed++;
292
- }
293
- } catch (err) {
294
- if (err.code !== "ENOENT") {
295
- console.error(
296
- ` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
297
- );
298
- throw err;
299
- }
300
- // Doesn't exist — nothing to do
301
- }
200
+ step.progress(idx + 1, total);
302
201
  }
303
202
 
304
- console.log("");
305
- console.log(`Done. Removed: ${removed} commands.`);
306
- console.log("");
203
+ return { installed, updated, skipped };
307
204
  }
308
205
 
309
- async function update() {
310
- console.log("");
311
- console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
312
- console.log("");
313
-
314
- // Check if installed via npm (no .git directory) or via git clone
315
- const gitDir = join(FORGE_HOME, ".git");
316
- if (existsSync(gitDir)) {
317
- try {
318
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
319
- cwd: FORGE_HOME,
320
- encoding: "utf-8",
321
- }).trim();
322
- if (branch !== "main") {
323
- console.log(` Not on main branch (${branch}) — skipping`);
324
- return;
325
- }
326
-
327
- const before = execSync("git rev-parse HEAD", {
328
- cwd: FORGE_HOME,
329
- encoding: "utf-8",
330
- }).trim();
331
- execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
332
- execSync("git merge --ff-only origin/main --quiet", {
333
- cwd: FORGE_HOME,
334
- });
335
- const after = execSync("git rev-parse HEAD", {
336
- cwd: FORGE_HOME,
337
- encoding: "utf-8",
338
- }).trim();
339
-
340
- if (before === after) {
341
- console.log(` Already up to date.`);
342
- } else {
343
- console.log(` ${GREEN}Updated to latest.${RESET}`);
344
- await install();
345
- }
346
- } catch (err) {
347
- console.log(
348
- ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
349
- );
350
- }
351
- } else {
352
- console.log(
353
- ` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
354
- );
355
- }
356
- console.log("");
357
- }
358
-
359
- async function init(fromInstall = false) {
360
- console.log("");
361
- console.log(`${BOLD}ForgeDock${RESET} — Generating forge.yaml`);
362
- console.log("");
206
+ // ---------------------------------------------------------------------------
207
+ // Core forge.yaml detection + write — used by both install() and init()
208
+ // ---------------------------------------------------------------------------
363
209
 
364
- const cwd = process.cwd();
210
+ /**
211
+ * Detect project info from the current working directory and write forge.yaml.
212
+ * Returns the detected config object.
213
+ * This is pure logic — no TUI output. Called from inside step callbacks.
214
+ *
215
+ * @param {string} cwd - Current working directory
216
+ * @param {object} [opts]
217
+ * @param {(text: string) => void} [opts.onNote] - Called with incremental progress notes
218
+ * @returns {Promise<{owner, repo, remoteDetected, defaultBranch, stagingBranch, description, outputPath, wrote: boolean, skipped: boolean, skipReason: string}>}
219
+ */
220
+ async function detectAndBuildForgeYaml(cwd, opts = {}) {
221
+ const { onNote = () => {} } = opts;
365
222
  const worktreeBase = join(cwd, ".claude", "worktrees");
366
223
  const outputPath = join(cwd, "forge.yaml");
367
224
 
368
- // --- Detect git remote URL and parse owner/repo ---
369
- let owner = "your-github-org";
370
- let repo = "your-repo-name";
371
- let remoteDetected = false;
225
+ const detected = {
226
+ owner: "your-github-org",
227
+ repo: "your-repo-name",
228
+ remoteDetected: false,
229
+ defaultBranch: "main",
230
+ stagingBranch: "staging",
231
+ description: "",
232
+ };
372
233
 
234
+ // --- Detect git remote URL and parse owner/repo ---
373
235
  try {
374
236
  const remoteUrl = execSync("git remote get-url origin", {
375
237
  cwd,
@@ -385,210 +247,102 @@ async function init(fromInstall = false) {
385
247
  );
386
248
 
387
249
  if (sshMatch) {
388
- owner = sshMatch[1];
389
- repo = sshMatch[2];
390
- remoteDetected = true;
250
+ detected.owner = sshMatch[1];
251
+ detected.repo = sshMatch[2];
252
+ detected.remoteDetected = true;
391
253
  } else if (httpsMatch) {
392
- owner = httpsMatch[1];
393
- repo = httpsMatch[2];
394
- remoteDetected = true;
395
- } else {
396
- console.log(
397
- ` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
398
- );
254
+ detected.owner = httpsMatch[1];
255
+ detected.repo = httpsMatch[2];
256
+ detected.remoteDetected = true;
399
257
  }
400
258
  } catch {
401
- console.log(
402
- ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
403
- );
259
+ // No git remote — use placeholders
404
260
  }
405
261
 
406
- if (remoteDetected) {
407
- console.log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
408
- }
262
+ onNote(
263
+ detected.remoteDetected
264
+ ? `${detected.owner}/${detected.repo}`
265
+ : "no git remote — using placeholders",
266
+ );
409
267
 
410
268
  // --- Detect default branch ---
411
- let defaultBranch = "main";
412
269
  try {
413
270
  const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
414
271
  cwd,
415
272
  encoding: "utf-8",
416
273
  stdio: ["pipe", "pipe", "pipe"],
417
274
  }).trim();
418
- // refs/remotes/origin/main → main
419
- defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
420
- console.log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
275
+ detected.defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
421
276
  } catch {
422
- // Fallback: try git rev-parse
423
277
  try {
424
- defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
278
+ detected.defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
425
279
  cwd,
426
280
  encoding: "utf-8",
427
281
  stdio: ["pipe", "pipe", "pipe"],
428
282
  }).trim();
429
- if (defaultBranch === "HEAD") defaultBranch = "main";
430
- console.log(
431
- ` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
432
- );
283
+ if (detected.defaultBranch === "HEAD") detected.defaultBranch = "main";
433
284
  } catch {
434
- console.log(
435
- ` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
436
- );
285
+ // Use default "main"
437
286
  }
438
287
  }
439
288
 
440
289
  // --- Detect staging branch ---
441
- let stagingBranch = "staging";
442
290
  try {
443
291
  const remoteBranches = execSync("git branch -r", {
444
292
  cwd,
445
293
  encoding: "utf-8",
446
294
  stdio: ["pipe", "pipe", "pipe"],
447
295
  });
448
- if (remoteBranches.includes("origin/staging")) {
449
- stagingBranch = "staging";
450
- console.log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
451
- } else {
452
- stagingBranch = defaultBranch;
453
- console.log(
454
- ` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
455
- );
456
- }
296
+ detected.stagingBranch = remoteBranches.includes("origin/staging")
297
+ ? "staging"
298
+ : detected.defaultBranch;
457
299
  } catch {
458
- console.log(
459
- ` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
460
- );
461
- stagingBranch = defaultBranch;
300
+ detected.stagingBranch = detected.defaultBranch;
462
301
  }
463
302
 
464
- // --- Auto-detect project description from README.md ---
465
- let description = "";
466
- try {
467
- const readmePath = join(cwd, "README.md");
468
- if (existsSync(readmePath)) {
469
- const readmeContent = readFileSync(readmePath, "utf-8").slice(0, 2048);
470
- // Skip the first heading line (# Title), grab the first non-empty paragraph
471
- const lines = readmeContent.split("\n");
472
- let inFirstParagraph = false;
473
- const paragraphLines = [];
474
- for (const line of lines) {
475
- // Skip heading lines at the top
476
- if (!inFirstParagraph && line.match(/^#/)) continue;
477
- // Skip blank lines before paragraph starts
478
- if (!inFirstParagraph && line.trim() === "") continue;
479
- // Skip lines that are just badges, HTML, code fences, horizontal rules, or tables
480
- if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
481
- if (!inFirstParagraph && line.match(/^---/)) continue;
482
- // Start collecting
483
- if (!inFirstParagraph) {
484
- inFirstParagraph = true;
485
- }
486
- // Stop at blank line (end of paragraph)
487
- if (line.trim() === "") break;
488
- paragraphLines.push(line.trim());
489
- }
490
- if (paragraphLines.length > 0) {
491
- // Flatten to single line, strip markdown links/bold/inline code
492
- description = paragraphLines
493
- .join(" ")
494
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
495
- .replace(/\*\*([^*]+)\*\*/g, "$1")
496
- .replace(/`([^`]+)`/g, "$1")
497
- .slice(0, 200)
498
- .trim();
303
+ // --- Auto-detect project description from README.md / CLAUDE.md ---
304
+ function extractFirstParagraph(text) {
305
+ const lines = text.split("\n");
306
+ let inFirstParagraph = false;
307
+ const paragraphLines = [];
308
+ for (const line of lines) {
309
+ if (!inFirstParagraph && line.match(/^#/)) continue;
310
+ if (!inFirstParagraph && line.trim() === "") continue;
311
+ if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
312
+ if (!inFirstParagraph && line.match(/^---/)) continue;
313
+ if (!inFirstParagraph) {
314
+ inFirstParagraph = true;
499
315
  }
316
+ if (line.trim() === "") break;
317
+ paragraphLines.push(line.trim());
500
318
  }
501
- } catch {
502
- // Best-effort only — silently fall back to empty description
319
+ if (paragraphLines.length === 0) return "";
320
+ return paragraphLines
321
+ .join(" ")
322
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
323
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
324
+ .replace(/`([^`]+)`/g, "$1")
325
+ .slice(0, 200)
326
+ .trim();
503
327
  }
504
328
 
505
- if (description) {
506
- console.log(
507
- ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from README.md)`,
508
- );
509
- }
510
-
511
- // --- Auto-detect project description from CLAUDE.md (fallback) ---
512
- if (!description) {
329
+ for (const fname of ["README.md", "CLAUDE.md"]) {
330
+ if (detected.description) break;
513
331
  try {
514
- const claudePath = join(cwd, "CLAUDE.md");
515
- if (existsSync(claudePath)) {
516
- const claudeContent = readFileSync(claudePath, "utf-8").slice(0, 2048);
517
- const lines = claudeContent.split("\n");
518
- let inFirstParagraph = false;
519
- const paragraphLines = [];
520
- for (const line of lines) {
521
- // Skip heading lines at the top
522
- if (!inFirstParagraph && line.match(/^#/)) continue;
523
- // Skip blank lines before paragraph starts
524
- if (!inFirstParagraph && line.trim() === "") continue;
525
- // Skip lines that are just badges, HTML comments, code fences, horizontal rules, or tables
526
- if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
527
- if (!inFirstParagraph && line.match(/^---/)) continue;
528
- // Start collecting
529
- if (!inFirstParagraph) {
530
- inFirstParagraph = true;
531
- }
532
- // Stop at blank line (end of paragraph)
533
- if (line.trim() === "") break;
534
- paragraphLines.push(line.trim());
535
- }
536
- if (paragraphLines.length > 0) {
537
- // Flatten to single line, strip markdown links/bold/inline code
538
- description = paragraphLines
539
- .join(" ")
540
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
541
- .replace(/\*\*([^*]+)\*\*/g, "$1")
542
- .replace(/`([^`]+)`/g, "$1")
543
- .slice(0, 200)
544
- .trim();
545
- }
332
+ const fpath = join(cwd, fname);
333
+ if (existsSync(fpath)) {
334
+ detected.description = extractFirstParagraph(
335
+ readFileSync(fpath, "utf-8").slice(0, 2048),
336
+ );
546
337
  }
547
338
  } catch {
548
- // Best-effort only — silently fall back to empty description
549
- }
550
-
551
- if (description) {
552
- console.log(
553
- ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from CLAUDE.md)`,
554
- );
339
+ // Best-effort only
555
340
  }
556
341
  }
557
342
 
558
- // --- Handle existing forge.yaml ---
559
- if (existsSync(outputPath)) {
560
- if (!fromInstall) {
561
- console.log(
562
- ` ${YELLOW}Warning:${RESET} forge.yaml already exists at ${outputPath}`,
563
- );
564
- console.log(
565
- ` Continuing will back up your existing config and replace it with a freshly generated one.`,
566
- );
567
- console.log(``);
568
- const confirmed = await confirmOverwrite(` Overwrite? [y/N] `);
569
- if (!confirmed) {
570
- console.log(``);
571
- console.log(
572
- ` ${YELLOW}Aborted.${RESET} Your forge.yaml was not modified.`,
573
- );
574
- console.log(``);
575
- return;
576
- }
577
- console.log(``);
578
- }
579
- const baseBak = join(cwd, "forge.yaml.bak");
580
- const backupPath = existsSync(baseBak)
581
- ? join(
582
- cwd,
583
- `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
584
- )
585
- : baseBak;
586
- const backupName = backupPath.split("/").pop();
587
- renameSync(outputPath, backupPath);
588
- console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
589
- }
343
+ // --- Build forge.yaml content ---
344
+ const { owner, repo, defaultBranch, stagingBranch, description } = detected;
590
345
 
591
- // --- Generate forge.yaml content ---
592
346
  const projectName = repo
593
347
  .split(/[-_]/)
594
348
  .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
@@ -687,70 +441,391 @@ branches:
687
441
  try {
688
442
  writeFileSync(outputPath, content, "utf-8");
689
443
  } catch (err) {
690
- console.error("");
691
- console.error(
692
- ` ${RED}Error${RESET}: Could not write forge.yaml to ${outputPath} — ${err.code ?? err.message}`,
444
+ throw new Error(
445
+ `Could not write forge.yaml to ${outputPath} — ${err.code ?? err.message}. ` +
446
+ `Run npx forgedock init from a writable project directory.`,
693
447
  );
694
- console.error(
695
- ` Run ${CYAN}npx forgedock init${RESET} from a writable project directory.`,
448
+ }
449
+
450
+ return { ...detected, outputPath, wrote: true };
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Subcommands
455
+ // ---------------------------------------------------------------------------
456
+
457
+ async function install() {
458
+ const result = await runSteps([
459
+ {
460
+ label: "Checking environment",
461
+ async run(step) {
462
+ await mkdir(TARGET_DIR, { recursive: true });
463
+ step.note(cyan(TARGET_DIR));
464
+ },
465
+ },
466
+ {
467
+ label: "Linking commands",
468
+ async run(step) {
469
+ const { installed, updated, skipped } = await linkCommands(step);
470
+ step.note(
471
+ `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
472
+ );
473
+ },
474
+ },
475
+ {
476
+ label: "Configuring session hook",
477
+ async run(step) {
478
+ let profileUpdated = false;
479
+ for (const profile of [
480
+ join(process.env.HOME, ".bashrc"),
481
+ join(process.env.HOME, ".zshrc"),
482
+ ]) {
483
+ if (existsSync(profile)) {
484
+ const content = readFileSync(profile, "utf-8");
485
+ if (!content.includes("FORGE_HOME")) {
486
+ appendFileSync(
487
+ profile,
488
+ `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
489
+ );
490
+ profileUpdated = true;
491
+ }
492
+ }
493
+ }
494
+ if (!profileUpdated) {
495
+ step.skip("FORGE_HOME already set");
496
+ }
497
+ },
498
+ },
499
+ {
500
+ label: "Generating forge.yaml",
501
+ async run(step) {
502
+ // Auto-generate forge.yaml if missing — no second command needed.
503
+ // Only do this inside a real git project; otherwise cwd may be a system or
504
+ // read-only directory (e.g. C:\Windows when npx is launched from a WSL path),
505
+ // and writing forge.yaml there would crash. See #585.
506
+ const cwd = process.cwd();
507
+ const forgeYamlPath = join(cwd, "forge.yaml");
508
+ if (existsSync(forgeYamlPath)) {
509
+ step.skip("forge.yaml already exists");
510
+ return;
511
+ }
512
+ if (!isGitWorkTree(cwd)) {
513
+ step.skip(
514
+ "not a git project — run npx forgedock init inside your project",
515
+ );
516
+ return;
517
+ }
518
+ const detected = await detectAndBuildForgeYaml(cwd, {
519
+ onNote: () => {},
520
+ });
521
+ if (!detected.remoteDetected) {
522
+ step.note("created — fill in project.owner and project.repo");
523
+ } else {
524
+ step.note(`created for ${detected.owner}/${detected.repo}`);
525
+ }
526
+ },
527
+ },
528
+ ]);
529
+
530
+ if (result.ok) {
531
+ process.stderr.write(
532
+ box(
533
+ [
534
+ `${green("✔")} ${bold("ForgeDock is ready")}`,
535
+ "",
536
+ `Commands installed to ${cyan(TARGET_DIR)}`,
537
+ `Next: ${cyan("cd <your-project>")} then ${cyan("npx forgedock init")}`,
538
+ ],
539
+ { title: "ForgeDock Installed" },
540
+ ) + "\n",
696
541
  );
697
- process.exit(1);
698
542
  }
543
+ }
544
+
545
+ async function uninstall() {
546
+ const result = await runSteps([
547
+ {
548
+ label: "Removing commands",
549
+ async run(step) {
550
+ const files = await findMarkdownFiles(COMMANDS_DIR);
551
+ const total = files.length;
552
+ let removed = 0;
553
+
554
+ for (let idx = 0; idx < files.length; idx++) {
555
+ const file = files[idx];
556
+ const rel = relative(COMMANDS_DIR, file);
557
+ const target = join(TARGET_DIR, rel);
558
+
559
+ try {
560
+ const stats = await lstat(target);
561
+ const { unlink } = await import("fs/promises");
562
+ if (stats.isSymbolicLink()) {
563
+ const current = await readlink(target);
564
+ if (current === file) {
565
+ await unlink(target);
566
+ removed++;
567
+ }
568
+ } else if (
569
+ readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
570
+ ) {
571
+ // Regular file installed by ForgeDock in copy mode (content matches the
572
+ // shipped command). Safe to remove. Content-differing files are left
573
+ // in place in case the user edited them.
574
+ await unlink(target);
575
+ removed++;
576
+ }
577
+ } catch (err) {
578
+ if (err.code !== "ENOENT") {
579
+ throw err;
580
+ }
581
+ // Doesn't exist — nothing to do
582
+ }
583
+
584
+ step.progress(idx + 1, total);
585
+ }
699
586
 
700
- console.log(` ${GREEN}Created${RESET}: forge.yaml`);
701
- console.log("");
587
+ step.note(`${red(String(removed))} removed`);
588
+ },
589
+ },
590
+ ]);
591
+
592
+ if (result.ok) {
593
+ process.stderr.write(
594
+ box(
595
+ [
596
+ `${green("✔")} ForgeDock commands removed`,
597
+ "",
598
+ `Re-install anytime: ${cyan("npx forgedock")}`,
599
+ ],
600
+ { title: "ForgeDock Uninstalled" },
601
+ ) + "\n",
602
+ );
603
+ }
604
+ }
702
605
 
606
+ async function update() {
607
+ // Check if installed via npm (no .git directory) or via git clone
608
+ const gitDir = join(FORGE_HOME, ".git");
609
+ if (!existsSync(gitDir)) {
610
+ await runSteps([
611
+ {
612
+ label: "Checking update path",
613
+ async run(step) {
614
+ step.skip(`installed via npm — run ${cyan("npm update -g forgedock")}`);
615
+ },
616
+ },
617
+ ]);
618
+ return;
619
+ }
620
+
621
+ const result = await runSteps([
622
+ {
623
+ label: "Checking current branch",
624
+ async run(step) {
625
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
626
+ cwd: FORGE_HOME,
627
+ encoding: "utf-8",
628
+ }).trim();
629
+ if (branch !== "main") {
630
+ step.skip(`not on main (${branch})`);
631
+ } else {
632
+ step.note("on main");
633
+ }
634
+ },
635
+ },
636
+ {
637
+ label: "Fetching latest",
638
+ async run(step) {
639
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
640
+ cwd: FORGE_HOME,
641
+ encoding: "utf-8",
642
+ }).trim();
643
+ if (branch !== "main") {
644
+ step.skip("skipped — not on main");
645
+ return;
646
+ }
647
+ const before = execSync("git rev-parse HEAD", {
648
+ cwd: FORGE_HOME,
649
+ encoding: "utf-8",
650
+ }).trim();
651
+ try {
652
+ execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
653
+ execSync("git merge --ff-only origin/main --quiet", {
654
+ cwd: FORGE_HOME,
655
+ });
656
+ } catch {
657
+ step.skip("cannot fast-forward — local changes exist");
658
+ return;
659
+ }
660
+ const after = execSync("git rev-parse HEAD", {
661
+ cwd: FORGE_HOME,
662
+ encoding: "utf-8",
663
+ }).trim();
664
+ if (before === after) {
665
+ step.skip("already up to date");
666
+ } else {
667
+ step.note(`${before.slice(0, 7)} → ${after.slice(0, 7)}`);
668
+ }
669
+ },
670
+ },
671
+ {
672
+ label: "Reinstalling commands",
673
+ async run(step) {
674
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
675
+ cwd: FORGE_HOME,
676
+ encoding: "utf-8",
677
+ }).trim();
678
+ if (branch !== "main") {
679
+ step.skip("skipped — not on main");
680
+ return;
681
+ }
682
+ const { installed, updated, skipped } = await linkCommands(step);
683
+ step.note(
684
+ `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
685
+ );
686
+ },
687
+ },
688
+ ]);
689
+
690
+ if (result.ok) {
691
+ process.stderr.write(
692
+ box([`${green("✔")} ForgeDock is up to date`], {
693
+ title: "ForgeDock Updated",
694
+ }) + "\n",
695
+ );
696
+ }
697
+ }
698
+
699
+ async function init(fromInstall = false) {
700
+ const cwd = process.cwd();
701
+ const outputPath = join(cwd, "forge.yaml");
702
+
703
+ // When called from install(), we already know forge.yaml doesn't exist
704
+ // and cwd is a git work tree — go straight to detection and write.
703
705
  if (fromInstall) {
704
- // Called automatically from install() — only print what still needs attention
705
- if (!remoteDetected) {
706
- console.log(`${YELLOW}Action required:${RESET}`);
707
- console.log(
708
- ` Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET} (git remote not detected)`,
706
+ const detected = await detectAndBuildForgeYaml(cwd, { onNote: () => {} });
707
+ if (!detected.remoteDetected) {
708
+ process.stderr.write(
709
+ box(
710
+ [
711
+ `${yellow("Action required:")}`,
712
+ `Edit ${cyan("forge.yaml")} — fill in ${cyan("project.owner")} and ${cyan("project.repo")}`,
713
+ "(git remote not detected)",
714
+ ],
715
+ { title: "forge.yaml Generated" },
716
+ ) + "\n",
709
717
  );
710
- console.log("");
711
718
  }
712
- } else {
713
- // Called explicitly via `npx forgedock init` — print full next steps
714
- console.log(`${BOLD}Next steps:${RESET}`);
715
- if (!remoteDetected) {
716
- console.log(
717
- ` 1. Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET}`,
718
- );
719
- } else {
720
- console.log(
721
- ` 1. Review ${CYAN}forge.yaml${RESET} — all required fields were auto-detected`,
719
+ return;
720
+ }
721
+
722
+ // Interactive path — run as a step-checklist with a full summary box.
723
+ let detected = null;
724
+ let aborted = false;
725
+
726
+ // Handle existing forge.yaml before entering runSteps (needs interactive prompt)
727
+ if (existsSync(outputPath)) {
728
+ process.stderr.write(
729
+ `\n ${YELLOW}Warning:${RESET} forge.yaml already exists at ${outputPath}\n`,
730
+ );
731
+ process.stderr.write(
732
+ ` Continuing will back up your existing config and replace it.\n\n`,
733
+ );
734
+ const confirmed = await confirmOverwrite(` Overwrite? [y/N] `);
735
+ if (!confirmed) {
736
+ process.stderr.write(
737
+ `\n ${YELLOW}Aborted.${RESET} Your forge.yaml was not modified.\n\n`,
722
738
  );
739
+ return;
723
740
  }
724
- console.log(
725
- ` 2. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
741
+ process.stderr.write("\n");
742
+
743
+ // Back up existing file before proceeding
744
+ const baseBak = join(cwd, "forge.yaml.bak");
745
+ const backupPath = existsSync(baseBak)
746
+ ? join(
747
+ cwd,
748
+ `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
749
+ )
750
+ : baseBak;
751
+ const backupName = backupPath.split("/").pop();
752
+ renameSync(outputPath, backupPath);
753
+ process.stderr.write(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}\n\n`);
754
+ }
755
+
756
+ const result = await runSteps([
757
+ {
758
+ label: "Detecting project",
759
+ async run(step) {
760
+ detected = await detectAndBuildForgeYaml(cwd, {
761
+ onNote: (msg) => step.note(msg),
762
+ });
763
+ },
764
+ },
765
+ ]);
766
+
767
+ if (!result.ok || !detected) return;
768
+
769
+ const {
770
+ owner,
771
+ repo,
772
+ defaultBranch,
773
+ stagingBranch,
774
+ remoteDetected,
775
+ description,
776
+ } = detected;
777
+
778
+ const summaryLines = [
779
+ `${green("✔")} forge.yaml generated`,
780
+ "",
781
+ ` repo: ${cyan(owner + "/" + repo)}`,
782
+ ` default: ${cyan(defaultBranch)}`,
783
+ ` staging: ${cyan(stagingBranch)}`,
784
+ ];
785
+ if (description) {
786
+ summaryLines.push(
787
+ ` desc: ${dim(
788
+ description.slice(0, 60) + (description.length > 60 ? "…" : ""),
789
+ )}`,
790
+ );
791
+ }
792
+ summaryLines.push("");
793
+ if (!remoteDetected) {
794
+ summaryLines.push(
795
+ `${yellow("→")} Edit ${cyan("forge.yaml")} — fill in ${cyan("project.owner")} and ${cyan("project.repo")}`,
726
796
  );
727
- console.log(
728
- ` 3. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
797
+ } else {
798
+ summaryLines.push(
799
+ `${green("→")} Review ${cyan("forge.yaml")} — all required fields were auto-detected`,
729
800
  );
730
- console.log("");
731
801
  }
732
- }
733
-
734
- function help() {
735
- console.log("");
736
- console.log(
737
- `${BOLD}ForgeDock${RESET} — GitHub as a knowledge graph for AI agents`,
802
+ summaryLines.push(
803
+ `${green("→")} Add ${cyan("forge.yaml")} to ${cyan(".gitignore")} if it contains sensitive paths`,
738
804
  );
739
- console.log("");
740
- console.log("Usage:");
741
- console.log(
742
- ` ${CYAN}npx forgedock${RESET} Install commands (default)`,
805
+ summaryLines.push(
806
+ `${green("→")} Run ${cyan("/forgedock-init")} inside Claude Code for guided AI-powered setup`,
743
807
  );
744
- console.log(` ${CYAN}npx forgedock install${RESET} Install commands`);
745
- console.log(
746
- ` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
808
+
809
+ process.stderr.write(
810
+ box(summaryLines, { title: "forge.yaml Generated" }) + "\n",
747
811
  );
748
- console.log(` ${CYAN}npx forgedock uninstall${RESET} Remove commands`);
749
- console.log(
750
- ` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
812
+ }
813
+
814
+ function help() {
815
+ // renderLogo already shown by splash() above — show the command table
816
+ const commands = [
817
+ ["Command", "Description"],
818
+ ["npx forgedock", "Install commands (default)"],
819
+ ["npx forgedock install", "Install commands"],
820
+ ["npx forgedock init", "Generate forge.yaml config for your project"],
821
+ ["npx forgedock uninstall", "Remove commands"],
822
+ ["npx forgedock update", "Pull latest & reinstall"],
823
+ ["npx forgedock help", "Show this help"],
824
+ ];
825
+
826
+ process.stderr.write(
827
+ box(table(commands, { header: true }), { title: "Usage" }) + "\n",
751
828
  );
752
- console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
753
- console.log("");
754
829
  }
755
830
 
756
831
  splash();
@@ -774,7 +849,7 @@ switch (command) {
774
849
  help();
775
850
  break;
776
851
  default:
777
- console.log(`${RED}Unknown command: ${command}${RESET}`);
852
+ process.stderr.write(`${RED}Unknown command: ${command}${RESET}\n`);
778
853
  help();
779
854
  process.exit(1);
780
855
  }