forgedock 1.0.12 → 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,16 +2,35 @@
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,
9
9
  readFileSync,
10
10
  writeFileSync,
11
11
  renameSync,
12
+ copyFileSync,
12
13
  } from "fs";
13
14
  import { execSync } from "child_process";
14
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";
15
34
 
16
35
  const __filename = fileURLToPath(import.meta.url);
17
36
  const __dirname = dirname(__filename);
@@ -19,8 +38,8 @@ const FORGE_HOME = dirname(__dirname);
19
38
  const COMMANDS_DIR = join(FORGE_HOME, "commands");
20
39
 
21
40
  if (!process.env.HOME) {
22
- console.error(
23
- "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",
24
43
  );
25
44
  process.exit(1);
26
45
  }
@@ -30,24 +49,10 @@ const TARGET_DIR = join(process.env.HOME, ".claude", "commands");
30
49
  const args = process.argv.slice(2);
31
50
  const command = args[0] || "install";
32
51
 
33
- const BOLD = "\x1b[1m";
34
- const GREEN = "\x1b[32m";
35
- const YELLOW = "\x1b[33m";
36
- const CYAN = "\x1b[36m";
37
- const RED = "\x1b[31m";
38
- const RESET = "\x1b[0m";
39
- const DIM = "\x1b[2m";
40
-
41
52
  // ---------------------------------------------------------------------------
42
- // Splash — the ForgeDock wordmark shown at startup
53
+ // Splash — the ForgeDock F-monogram logo shown at startup
43
54
  // ---------------------------------------------------------------------------
44
55
 
45
- const LOGO_LINES = [
46
- " ╔═╗╔═╗╦═╗╔═╗╔═╗╔╦╗╔═╗╔═╗╦╔═",
47
- " ╠╣ ║ ║╠╦╝║ ╦║╣ ║║║ ║║ ╠╩╗",
48
- " ╚ ╚═╝╩╚═╚═╝╚═╝═╩╝╚═╝╚═╝╩ ╩",
49
- ];
50
-
51
56
  function getVersion() {
52
57
  try {
53
58
  const pkg = JSON.parse(
@@ -61,16 +66,7 @@ function getVersion() {
61
66
 
62
67
  function splash() {
63
68
  const version = getVersion();
64
- console.log("");
65
- for (const line of LOGO_LINES) {
66
- console.log(`${BOLD}${CYAN}${line}${RESET}`);
67
- }
68
- console.log("");
69
- console.log(` ${DIM}GitHub as a knowledge graph for AI agents${RESET}`);
70
- if (version) {
71
- console.log(` ${DIM}v${version}${RESET}`);
72
- }
73
- console.log("");
69
+ process.stderr.write(renderLogo({ version }) + "\n");
74
70
  }
75
71
 
76
72
  /**
@@ -79,15 +75,15 @@ function splash() {
79
75
  */
80
76
  async function confirmOverwrite(question) {
81
77
  if (!process.stdin.isTTY) {
82
- console.log(
83
- ` ${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`,
84
80
  );
85
81
  return false;
86
82
  }
87
83
  return new Promise((resolve) => {
88
84
  const rl = readline.createInterface({
89
85
  input: process.stdin,
90
- output: process.stdout,
86
+ output: process.stderr,
91
87
  });
92
88
  rl.question(question, (answer) => {
93
89
  rl.close();
@@ -133,21 +129,24 @@ async function findMarkdownFiles(dir) {
133
129
  return results.sort();
134
130
  }
135
131
 
136
- async function install() {
137
- console.log("");
138
- console.log(`${BOLD}ForgeDock${RESET} — Installing pipeline commands`);
139
- console.log(` Source: ${CYAN}${COMMANDS_DIR}/${RESET}`);
140
- console.log(` Target: ${CYAN}${TARGET_DIR}/${RESET}`);
141
- console.log("");
142
-
143
- await mkdir(TARGET_DIR, { recursive: true });
132
+ // ---------------------------------------------------------------------------
133
+ // Shared file-linking logic — used by both install() and update()
134
+ // ---------------------------------------------------------------------------
144
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) {
145
142
  const files = await findMarkdownFiles(COMMANDS_DIR);
143
+ const total = files.length;
146
144
  let installed = 0;
147
145
  let updated = 0;
148
146
  let skipped = 0;
149
147
 
150
- for (const file of files) {
148
+ for (let idx = 0; idx < files.length; idx++) {
149
+ const file = files[idx];
151
150
  const rel = relative(COMMANDS_DIR, file);
152
151
  const target = join(TARGET_DIR, rel);
153
152
  const targetDir = dirname(target);
@@ -165,183 +164,74 @@ async function install() {
165
164
  await symlink(file, target + ".tmp");
166
165
  const { rename } = await import("fs/promises");
167
166
  await rename(target + ".tmp", target);
168
- console.log(` ${YELLOW}Updated${RESET}: ${rel}`);
169
167
  updated++;
170
168
  }
171
169
  } else {
172
- console.log(
173
- ` ${YELLOW}WARNING${RESET}: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)`,
174
- );
175
- skipped++;
170
+ // Existing regular file — a ForgeDock-shipped command copied by a prior
171
+ // install (common on Windows, where symlinks aren't permitted). It is
172
+ // ForgeDock-managed (we only iterate shipped command names), so refresh
173
+ // it: skip if already current, otherwise overwrite with a fresh copy.
174
+ if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
175
+ skipped++;
176
+ } else {
177
+ copyFileSync(file, target);
178
+ updated++;
179
+ }
176
180
  }
177
181
  } catch (err) {
178
182
  if (err.code !== "ENOENT") {
179
- console.error(
180
- ` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
181
- );
182
183
  throw err;
183
184
  }
184
- // Doesn't exist — create symlink
185
- await symlink(file, target);
186
- console.log(` ${GREEN}Installed${RESET}: ${rel}`);
187
- installed++;
188
- }
189
- }
190
-
191
- console.log("");
192
- console.log(
193
- `Done. ${GREEN}Installed: ${installed}${RESET}, Updated: ${updated}, Skipped: ${skipped}`,
194
- );
195
- console.log("");
196
-
197
- // Set FORGE_HOME in shell profiles
198
- let profileUpdated = false;
199
- for (const profile of [
200
- join(process.env.HOME, ".bashrc"),
201
- join(process.env.HOME, ".zshrc"),
202
- ]) {
203
- if (existsSync(profile)) {
204
- const content = readFileSync(profile, "utf-8");
205
- if (!content.includes("FORGE_HOME")) {
206
- appendFileSync(
207
- profile,
208
- `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
209
- );
210
- console.log(` Added FORGE_HOME to ${profile}`);
211
- profileUpdated = true;
212
- }
213
- }
214
- }
215
-
216
- console.log(
217
- `${GREEN}ForgeDock commands are now available as slash commands in any Claude Code session.${RESET}`,
218
- );
219
- console.log("");
220
-
221
- // Auto-generate forge.yaml if missing — no second command needed.
222
- // Only do this inside a real git project; otherwise cwd may be a system or
223
- // read-only directory (e.g. C:\Windows when npx is launched from a WSL path),
224
- // and writing forge.yaml there would crash. See #585.
225
- const cwd = process.cwd();
226
- const forgeYamlPath = join(cwd, "forge.yaml");
227
- if (!existsSync(forgeYamlPath)) {
228
- if (isGitWorkTree(cwd)) {
229
- await init(true);
230
- } else {
231
- console.log(
232
- ` ${YELLOW}Skipping forge.yaml generation${RESET} — current directory is not a git project.`,
233
- );
234
- console.log(
235
- ` Run ${CYAN}npx forgedock init${RESET} inside your project directory to generate it.`,
236
- );
237
- console.log("");
238
- }
239
- }
240
- }
241
-
242
- async function uninstall() {
243
- console.log("");
244
- console.log(`${BOLD}ForgeDock${RESET} — Removing pipeline commands`);
245
- console.log("");
246
-
247
- const files = await findMarkdownFiles(COMMANDS_DIR);
248
- let removed = 0;
249
-
250
- for (const file of files) {
251
- const rel = relative(COMMANDS_DIR, file);
252
- const target = join(TARGET_DIR, rel);
253
-
254
- try {
255
- const stats = await lstat(target);
256
- if (stats.isSymbolicLink()) {
257
- const current = await readlink(target);
258
- if (current === file) {
259
- const { unlink } = await import("fs/promises");
260
- await unlink(target);
261
- console.log(` ${RED}Removed${RESET}: ${rel}`);
262
- removed++;
185
+ // Doesn't exist — create it. Prefer a symlink; fall back to a copy on
186
+ // systems where symlink creation is not permitted (e.g. Windows without
187
+ // Developer Mode / admin), which would otherwise throw EPERM. See #587.
188
+ try {
189
+ await symlink(file, target);
190
+ } catch (linkErr) {
191
+ if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
192
+ copyFileSync(file, target);
193
+ } else {
194
+ throw linkErr;
263
195
  }
264
196
  }
265
- } catch (err) {
266
- if (err.code !== "ENOENT") {
267
- console.error(
268
- ` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
269
- );
270
- throw err;
271
- }
272
- // Doesn't exist — nothing to do
197
+ installed++;
273
198
  }
274
- }
275
-
276
- console.log("");
277
- console.log(`Done. Removed: ${removed} commands.`);
278
- console.log("");
279
- }
280
-
281
- async function update() {
282
- console.log("");
283
- console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
284
- console.log("");
285
-
286
- // Check if installed via npm (no .git directory) or via git clone
287
- const gitDir = join(FORGE_HOME, ".git");
288
- if (existsSync(gitDir)) {
289
- try {
290
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
291
- cwd: FORGE_HOME,
292
- encoding: "utf-8",
293
- }).trim();
294
- if (branch !== "main") {
295
- console.log(` Not on main branch (${branch}) — skipping`);
296
- return;
297
- }
298
-
299
- const before = execSync("git rev-parse HEAD", {
300
- cwd: FORGE_HOME,
301
- encoding: "utf-8",
302
- }).trim();
303
- execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
304
- execSync("git merge --ff-only origin/main --quiet", {
305
- cwd: FORGE_HOME,
306
- });
307
- const after = execSync("git rev-parse HEAD", {
308
- cwd: FORGE_HOME,
309
- encoding: "utf-8",
310
- }).trim();
311
199
 
312
- if (before === after) {
313
- console.log(` Already up to date.`);
314
- } else {
315
- console.log(` ${GREEN}Updated to latest.${RESET}`);
316
- await install();
317
- }
318
- } catch (err) {
319
- console.log(
320
- ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
321
- );
322
- }
323
- } else {
324
- console.log(
325
- ` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
326
- );
200
+ step.progress(idx + 1, total);
327
201
  }
328
- console.log("");
202
+
203
+ return { installed, updated, skipped };
329
204
  }
330
205
 
331
- async function init(fromInstall = false) {
332
- console.log("");
333
- console.log(`${BOLD}ForgeDock${RESET} — Generating forge.yaml`);
334
- console.log("");
206
+ // ---------------------------------------------------------------------------
207
+ // Core forge.yaml detection + write — used by both install() and init()
208
+ // ---------------------------------------------------------------------------
335
209
 
336
- 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;
337
222
  const worktreeBase = join(cwd, ".claude", "worktrees");
338
223
  const outputPath = join(cwd, "forge.yaml");
339
224
 
340
- // --- Detect git remote URL and parse owner/repo ---
341
- let owner = "your-github-org";
342
- let repo = "your-repo-name";
343
- 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
+ };
344
233
 
234
+ // --- Detect git remote URL and parse owner/repo ---
345
235
  try {
346
236
  const remoteUrl = execSync("git remote get-url origin", {
347
237
  cwd,
@@ -357,210 +247,102 @@ async function init(fromInstall = false) {
357
247
  );
358
248
 
359
249
  if (sshMatch) {
360
- owner = sshMatch[1];
361
- repo = sshMatch[2];
362
- remoteDetected = true;
250
+ detected.owner = sshMatch[1];
251
+ detected.repo = sshMatch[2];
252
+ detected.remoteDetected = true;
363
253
  } else if (httpsMatch) {
364
- owner = httpsMatch[1];
365
- repo = httpsMatch[2];
366
- remoteDetected = true;
367
- } else {
368
- console.log(
369
- ` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
370
- );
254
+ detected.owner = httpsMatch[1];
255
+ detected.repo = httpsMatch[2];
256
+ detected.remoteDetected = true;
371
257
  }
372
258
  } catch {
373
- console.log(
374
- ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
375
- );
259
+ // No git remote — use placeholders
376
260
  }
377
261
 
378
- if (remoteDetected) {
379
- console.log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
380
- }
262
+ onNote(
263
+ detected.remoteDetected
264
+ ? `${detected.owner}/${detected.repo}`
265
+ : "no git remote — using placeholders",
266
+ );
381
267
 
382
268
  // --- Detect default branch ---
383
- let defaultBranch = "main";
384
269
  try {
385
270
  const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
386
271
  cwd,
387
272
  encoding: "utf-8",
388
273
  stdio: ["pipe", "pipe", "pipe"],
389
274
  }).trim();
390
- // refs/remotes/origin/main → main
391
- defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
392
- console.log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
275
+ detected.defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
393
276
  } catch {
394
- // Fallback: try git rev-parse
395
277
  try {
396
- defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
278
+ detected.defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
397
279
  cwd,
398
280
  encoding: "utf-8",
399
281
  stdio: ["pipe", "pipe", "pipe"],
400
282
  }).trim();
401
- if (defaultBranch === "HEAD") defaultBranch = "main";
402
- console.log(
403
- ` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
404
- );
283
+ if (detected.defaultBranch === "HEAD") detected.defaultBranch = "main";
405
284
  } catch {
406
- console.log(
407
- ` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
408
- );
285
+ // Use default "main"
409
286
  }
410
287
  }
411
288
 
412
289
  // --- Detect staging branch ---
413
- let stagingBranch = "staging";
414
290
  try {
415
291
  const remoteBranches = execSync("git branch -r", {
416
292
  cwd,
417
293
  encoding: "utf-8",
418
294
  stdio: ["pipe", "pipe", "pipe"],
419
295
  });
420
- if (remoteBranches.includes("origin/staging")) {
421
- stagingBranch = "staging";
422
- console.log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
423
- } else {
424
- stagingBranch = defaultBranch;
425
- console.log(
426
- ` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
427
- );
428
- }
296
+ detected.stagingBranch = remoteBranches.includes("origin/staging")
297
+ ? "staging"
298
+ : detected.defaultBranch;
429
299
  } catch {
430
- console.log(
431
- ` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
432
- );
433
- stagingBranch = defaultBranch;
300
+ detected.stagingBranch = detected.defaultBranch;
434
301
  }
435
302
 
436
- // --- Auto-detect project description from README.md ---
437
- let description = "";
438
- try {
439
- const readmePath = join(cwd, "README.md");
440
- if (existsSync(readmePath)) {
441
- const readmeContent = readFileSync(readmePath, "utf-8").slice(0, 2048);
442
- // Skip the first heading line (# Title), grab the first non-empty paragraph
443
- const lines = readmeContent.split("\n");
444
- let inFirstParagraph = false;
445
- const paragraphLines = [];
446
- for (const line of lines) {
447
- // Skip heading lines at the top
448
- if (!inFirstParagraph && line.match(/^#/)) continue;
449
- // Skip blank lines before paragraph starts
450
- if (!inFirstParagraph && line.trim() === "") continue;
451
- // Skip lines that are just badges, HTML, code fences, horizontal rules, or tables
452
- if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
453
- if (!inFirstParagraph && line.match(/^---/)) continue;
454
- // Start collecting
455
- if (!inFirstParagraph) {
456
- inFirstParagraph = true;
457
- }
458
- // Stop at blank line (end of paragraph)
459
- if (line.trim() === "") break;
460
- paragraphLines.push(line.trim());
461
- }
462
- if (paragraphLines.length > 0) {
463
- // Flatten to single line, strip markdown links/bold/inline code
464
- description = paragraphLines
465
- .join(" ")
466
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
467
- .replace(/\*\*([^*]+)\*\*/g, "$1")
468
- .replace(/`([^`]+)`/g, "$1")
469
- .slice(0, 200)
470
- .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;
471
315
  }
316
+ if (line.trim() === "") break;
317
+ paragraphLines.push(line.trim());
472
318
  }
473
- } catch {
474
- // Best-effort only — silently fall back to empty description
475
- }
476
-
477
- if (description) {
478
- console.log(
479
- ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from README.md)`,
480
- );
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();
481
327
  }
482
328
 
483
- // --- Auto-detect project description from CLAUDE.md (fallback) ---
484
- if (!description) {
329
+ for (const fname of ["README.md", "CLAUDE.md"]) {
330
+ if (detected.description) break;
485
331
  try {
486
- const claudePath = join(cwd, "CLAUDE.md");
487
- if (existsSync(claudePath)) {
488
- const claudeContent = readFileSync(claudePath, "utf-8").slice(0, 2048);
489
- const lines = claudeContent.split("\n");
490
- let inFirstParagraph = false;
491
- const paragraphLines = [];
492
- for (const line of lines) {
493
- // Skip heading lines at the top
494
- if (!inFirstParagraph && line.match(/^#/)) continue;
495
- // Skip blank lines before paragraph starts
496
- if (!inFirstParagraph && line.trim() === "") continue;
497
- // Skip lines that are just badges, HTML comments, code fences, horizontal rules, or tables
498
- if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
499
- if (!inFirstParagraph && line.match(/^---/)) continue;
500
- // Start collecting
501
- if (!inFirstParagraph) {
502
- inFirstParagraph = true;
503
- }
504
- // Stop at blank line (end of paragraph)
505
- if (line.trim() === "") break;
506
- paragraphLines.push(line.trim());
507
- }
508
- if (paragraphLines.length > 0) {
509
- // Flatten to single line, strip markdown links/bold/inline code
510
- description = paragraphLines
511
- .join(" ")
512
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
513
- .replace(/\*\*([^*]+)\*\*/g, "$1")
514
- .replace(/`([^`]+)`/g, "$1")
515
- .slice(0, 200)
516
- .trim();
517
- }
332
+ const fpath = join(cwd, fname);
333
+ if (existsSync(fpath)) {
334
+ detected.description = extractFirstParagraph(
335
+ readFileSync(fpath, "utf-8").slice(0, 2048),
336
+ );
518
337
  }
519
338
  } catch {
520
- // Best-effort only — silently fall back to empty description
521
- }
522
-
523
- if (description) {
524
- console.log(
525
- ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from CLAUDE.md)`,
526
- );
339
+ // Best-effort only
527
340
  }
528
341
  }
529
342
 
530
- // --- Handle existing forge.yaml ---
531
- if (existsSync(outputPath)) {
532
- if (!fromInstall) {
533
- console.log(
534
- ` ${YELLOW}Warning:${RESET} forge.yaml already exists at ${outputPath}`,
535
- );
536
- console.log(
537
- ` Continuing will back up your existing config and replace it with a freshly generated one.`,
538
- );
539
- console.log(``);
540
- const confirmed = await confirmOverwrite(` Overwrite? [y/N] `);
541
- if (!confirmed) {
542
- console.log(``);
543
- console.log(
544
- ` ${YELLOW}Aborted.${RESET} Your forge.yaml was not modified.`,
545
- );
546
- console.log(``);
547
- return;
548
- }
549
- console.log(``);
550
- }
551
- const baseBak = join(cwd, "forge.yaml.bak");
552
- const backupPath = existsSync(baseBak)
553
- ? join(
554
- cwd,
555
- `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
556
- )
557
- : baseBak;
558
- const backupName = backupPath.split("/").pop();
559
- renameSync(outputPath, backupPath);
560
- console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
561
- }
343
+ // --- Build forge.yaml content ---
344
+ const { owner, repo, defaultBranch, stagingBranch, description } = detected;
562
345
 
563
- // --- Generate forge.yaml content ---
564
346
  const projectName = repo
565
347
  .split(/[-_]/)
566
348
  .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
@@ -659,70 +441,391 @@ branches:
659
441
  try {
660
442
  writeFileSync(outputPath, content, "utf-8");
661
443
  } catch (err) {
662
- console.error("");
663
- console.error(
664
- ` ${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.`,
665
447
  );
666
- console.error(
667
- ` 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",
668
541
  );
669
- process.exit(1);
670
542
  }
543
+ }
671
544
 
672
- console.log(` ${GREEN}Created${RESET}: forge.yaml`);
673
- console.log("");
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
+ }
674
583
 
584
+ step.progress(idx + 1, total);
585
+ }
586
+
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
+ }
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.
675
705
  if (fromInstall) {
676
- // Called automatically from install() — only print what still needs attention
677
- if (!remoteDetected) {
678
- console.log(`${YELLOW}Action required:${RESET}`);
679
- console.log(
680
- ` 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",
681
717
  );
682
- console.log("");
683
718
  }
684
- } else {
685
- // Called explicitly via `npx forgedock init` — print full next steps
686
- console.log(`${BOLD}Next steps:${RESET}`);
687
- if (!remoteDetected) {
688
- console.log(
689
- ` 1. Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET}`,
690
- );
691
- } else {
692
- console.log(
693
- ` 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`,
694
738
  );
739
+ return;
695
740
  }
696
- console.log(
697
- ` 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")}`,
698
796
  );
699
- console.log(
700
- ` 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`,
701
800
  );
702
- console.log("");
703
801
  }
704
- }
705
-
706
- function help() {
707
- console.log("");
708
- console.log(
709
- `${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`,
710
804
  );
711
- console.log("");
712
- console.log("Usage:");
713
- console.log(
714
- ` ${CYAN}npx forgedock${RESET} Install commands (default)`,
805
+ summaryLines.push(
806
+ `${green("→")} Run ${cyan("/forgedock-init")} inside Claude Code for guided AI-powered setup`,
715
807
  );
716
- console.log(` ${CYAN}npx forgedock install${RESET} Install commands`);
717
- console.log(
718
- ` ${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",
719
811
  );
720
- console.log(` ${CYAN}npx forgedock uninstall${RESET} Remove commands`);
721
- console.log(
722
- ` ${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",
723
828
  );
724
- console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
725
- console.log("");
726
829
  }
727
830
 
728
831
  splash();
@@ -746,7 +849,7 @@ switch (command) {
746
849
  help();
747
850
  break;
748
851
  default:
749
- console.log(`${RED}Unknown command: ${command}${RESET}`);
852
+ process.stderr.write(`${RED}Unknown command: ${command}${RESET}\n`);
750
853
  help();
751
854
  process.exit(1);
752
855
  }