forgedock 1.0.21 → 1.1.1
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/README.md +227 -174
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +386 -0
- package/bin/engine/phases.mjs +148 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +27 -0
- package/bin/engine-cli.mjs +50 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +693 -1393
- package/bin/init-detect.mjs +95 -0
- package/bin/journey.mjs +783 -0
- package/bin/runner.mjs +847 -0
- package/bin/settings-hook.mjs +112 -0
- package/bin/tui.mjs +6 -3
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +85 -39
- package/commands/deploy-info.md +11 -5
- package/commands/design-bench.md +1 -0
- package/commands/design-render-critique-loop.md +1 -0
- package/commands/design.md +1 -0
- package/commands/forge-stats.md +2 -1
- package/commands/issue.md +1 -1
- package/commands/optimize.md +1 -1
- package/commands/orchestrate.md +346 -34
- package/commands/quality-gate.md +168 -80
- package/commands/review-pr-agents.md +69 -45
- package/commands/review-pr.md +54 -6
- package/commands/scope.md +11 -3
- package/commands/security-audit.md +24 -16
- package/commands/sync-ecosystem.md +2 -2
- package/commands/work-on/build/architect.md +47 -3
- package/commands/work-on/build/context.md +36 -5
- package/commands/work-on/build/implement.md +16 -4
- package/commands/work-on/build/validate.md +54 -8
- package/commands/work-on/build.md +27 -9
- package/commands/work-on/close.md +131 -3
- package/commands/work-on/investigate.md +24 -1
- package/commands/work-on/review.md +24 -0
- package/commands/work-on-monolithic.md +1 -0
- package/commands/work-on.md +271 -11
- package/docs/BRAND.md +6 -2
- package/docs/CONFIG.md +39 -34
- package/docs/site/.vitepress/config.mjs +85 -0
- package/docs/site/annotations-explained.md +55 -0
- package/docs/site/command-learning-path.md +104 -0
- package/docs/site/command-reference.md +3 -0
- package/docs/site/getting-started.md +42 -13
- package/docs/site/index.md +1 -1
- package/docs/site/troubleshooting.md +208 -0
- package/docs/spec/forge-protocol-v1.md +768 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/examples/forgedock-demo/README.md +113 -0
- package/examples/forgedock-demo/bootstrap.sh +125 -0
- package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
- package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
- package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
- package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
- package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
- package/examples/forgedock-demo/labels.json +21 -0
- package/examples/forgedock-demo/package.json +16 -0
- package/examples/forgedock-demo/scripts/smoke.js +61 -0
- package/examples/forgedock-demo/src/auth.js +28 -0
- package/examples/forgedock-demo/src/db.js +66 -0
- package/examples/forgedock-demo/src/routes/notes.js +64 -0
- package/examples/forgedock-demo/src/server.js +79 -0
- package/package.json +13 -3
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/forge.yaml.minimal +44 -0
package/bin/forgedock.mjs
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
3
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
4
|
|
|
5
|
-
import { fileURLToPath
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
6
|
import { dirname, join, relative, resolve } from "path";
|
|
7
|
-
import { mkdir,
|
|
7
|
+
import { mkdir, lstat, readlink, readdir, unlink, readFile, writeFile } from "fs/promises";
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
10
|
-
appendFileSync,
|
|
11
10
|
readFileSync,
|
|
12
11
|
writeFileSync,
|
|
13
12
|
renameSync,
|
|
@@ -15,17 +14,32 @@ import {
|
|
|
15
14
|
} from "fs";
|
|
16
15
|
import { execSync, execFileSync } from "child_process";
|
|
17
16
|
import { homedir } from "os";
|
|
17
|
+
import {
|
|
18
|
+
makeCtx,
|
|
19
|
+
runJourney,
|
|
20
|
+
forge,
|
|
21
|
+
read,
|
|
22
|
+
review,
|
|
23
|
+
celebrate,
|
|
24
|
+
findMarkdownFiles,
|
|
25
|
+
parseInstallTier,
|
|
26
|
+
writeForgeYaml,
|
|
27
|
+
backupExisting,
|
|
28
|
+
manualLowConfidenceKeys,
|
|
29
|
+
} from "./journey.mjs";
|
|
30
|
+
import { removeSessionStartHook } from "./settings-hook.mjs";
|
|
31
|
+
import { resolveState, setOptOut, clearNudgeSeen } from "./registry.mjs";
|
|
32
|
+
import { renderMark, ember } from "./cinema.mjs";
|
|
18
33
|
import {
|
|
19
34
|
renderLogo,
|
|
20
|
-
runSteps,
|
|
21
35
|
box,
|
|
22
36
|
table,
|
|
23
|
-
|
|
37
|
+
input,
|
|
24
38
|
dim,
|
|
25
39
|
green,
|
|
26
|
-
red,
|
|
27
40
|
yellow,
|
|
28
41
|
cyan,
|
|
42
|
+
createProgressBar,
|
|
29
43
|
RESET,
|
|
30
44
|
BOLD,
|
|
31
45
|
GREEN,
|
|
@@ -33,30 +47,32 @@ import {
|
|
|
33
47
|
YELLOW,
|
|
34
48
|
CYAN,
|
|
35
49
|
} from "./tui.mjs";
|
|
50
|
+
import { buildMinimalForgeYaml } from "./init-detect.mjs";
|
|
36
51
|
|
|
37
52
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
53
|
const __dirname = dirname(__filename);
|
|
39
54
|
const FORGE_HOME = dirname(__dirname);
|
|
40
55
|
const COMMANDS_DIR = join(FORGE_HOME, "commands");
|
|
41
56
|
|
|
42
|
-
// Resolve home
|
|
43
|
-
// os.homedir()
|
|
44
|
-
const HOME = homedir();
|
|
57
|
+
// Resolve home cross-platform: HOME on Unix, USERPROFILE on Windows, with
|
|
58
|
+
// os.homedir() as the always-available fallback (no hard exit — see #744).
|
|
59
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
45
60
|
|
|
46
61
|
const TARGET_DIR = join(HOME, ".claude", "commands");
|
|
62
|
+
const MANIFEST_PATH = join(HOME, ".claude", "forgedock", "copied-commands.json");
|
|
47
63
|
const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
|
|
48
64
|
const SCRIPTS_TARGET_DIR = join(HOME, ".claude", "scripts");
|
|
49
65
|
|
|
50
66
|
/**
|
|
51
|
-
* Allowlist of pipeline-agent scripts that
|
|
52
|
-
*
|
|
67
|
+
* Allowlist of pipeline-agent scripts that `uninstall` cleans up from
|
|
68
|
+
* ~/.claude/scripts/. The journey-based install no longer writes these
|
|
69
|
+
* itself — this set only identifies leftovers from legacy installs so
|
|
70
|
+
* `uninstall` can find and remove them.
|
|
53
71
|
*
|
|
54
72
|
* Internal tooling (gen-logo.mjs, verify-*.sh) lives in scripts/ but is NOT
|
|
55
|
-
*
|
|
73
|
+
* covered here — those scripts are invoked directly via $FORGE_HOME/scripts/ by
|
|
56
74
|
* review-pr.md and quality-gate.md and should not pollute the user's Claude
|
|
57
75
|
* scripts namespace.
|
|
58
|
-
*
|
|
59
|
-
* When adding a new pipeline-agent script, add its filename here.
|
|
60
76
|
*/
|
|
61
77
|
const PIPELINE_SCRIPTS = new Set([
|
|
62
78
|
"classify-lane.sh",
|
|
@@ -64,8 +80,16 @@ const PIPELINE_SCRIPTS = new Set([
|
|
|
64
80
|
"validate-pr-target.sh",
|
|
65
81
|
]);
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
// Journey flags are stripped from positionals and fed to makeCtx via ctx().
|
|
84
|
+
// --minimal is init-only. Subcommands with their own flag parsing (run,
|
|
85
|
+
// run-issue, demo) receive restArgs — everything after the command token.
|
|
86
|
+
const rawArgs = process.argv.slice(2);
|
|
87
|
+
const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal"]);
|
|
88
|
+
const flags = rawArgs.filter((a) => FLAGS.has(a));
|
|
89
|
+
const positional = rawArgs.filter((a) => !FLAGS.has(a));
|
|
90
|
+
const command = positional[0] || "install";
|
|
91
|
+
const cmdIdx = rawArgs.findIndex((a) => !FLAGS.has(a));
|
|
92
|
+
const restArgs = cmdIdx === -1 ? [] : rawArgs.slice(cmdIdx + 1);
|
|
69
93
|
|
|
70
94
|
// ---------------------------------------------------------------------------
|
|
71
95
|
// SessionStart hook — settings.json helpers
|
|
@@ -77,71 +101,6 @@ const command = args[0] || "install";
|
|
|
77
101
|
*/
|
|
78
102
|
const CLAUDE_SETTINGS_PATH = join(HOME, ".claude", "settings.json");
|
|
79
103
|
|
|
80
|
-
/**
|
|
81
|
-
* Timeout (in seconds) for the SessionStart hook entry written into
|
|
82
|
-
* ~/.claude/settings.json. Claude Code hook timeouts are in seconds —
|
|
83
|
-
* not milliseconds like the Node.js child_process timeouts elsewhere in
|
|
84
|
-
* this file.
|
|
85
|
-
*/
|
|
86
|
-
const SESSION_START_HOOK_TIMEOUT_SECONDS = 10;
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Escape all shell metacharacters that receive special treatment inside
|
|
90
|
-
* POSIX double-quoted strings. Apply this to any filesystem path before
|
|
91
|
-
* embedding it in a double-quoted shell assignment or command argument.
|
|
92
|
-
*
|
|
93
|
-
* Characters escaped and why:
|
|
94
|
-
* " → \" (closes the double-quoted context — ref: forge#451)
|
|
95
|
-
* $ → \$ (variable expansion and $(...) command substitution — ref: forge#792)
|
|
96
|
-
* ` → \` (legacy backtick command substitution — ref: forge#792)
|
|
97
|
-
* ! → \! (bash history expansion — harmless in sh but risky in bash — ref: forge#792)
|
|
98
|
-
*
|
|
99
|
-
* Backslash (\) is intentionally NOT escaped here: path.join() backslashes
|
|
100
|
-
* must be converted to forward slashes by the caller before this function
|
|
101
|
-
* is applied (see sessionStartHookCommand below). On POSIX systems,
|
|
102
|
-
* paths never contain backslashes, so this is a no-op for shell profiles.
|
|
103
|
-
*
|
|
104
|
-
* <!-- Added: forge#808 -->
|
|
105
|
-
*
|
|
106
|
-
* @param {string} path - Filesystem path to escape for shell double-quote context.
|
|
107
|
-
* @returns {string} - Path safe for embedding inside a double-quoted shell string.
|
|
108
|
-
*/
|
|
109
|
-
function shellEscapeDoubleQuotedPath(path) {
|
|
110
|
-
return path
|
|
111
|
-
.replace(/\\/g, '\\\\') // backslash — must be first to avoid double-escaping
|
|
112
|
-
.replace(/"/g, '\\"') // double quote — breaks out of the argument
|
|
113
|
-
.replace(/\$/g, '\\$') // dollar sign — variable/command substitution
|
|
114
|
-
.replace(/`/g, '\\`') // backtick — legacy command substitution
|
|
115
|
-
.replace(/!/g, '\\!'); // exclamation mark — bash history expansion
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The command value written into the SessionStart hook entry.
|
|
120
|
-
* Uses forward slashes even on Windows — Node accepts them natively and
|
|
121
|
-
* they avoid any backslash-related issues in the hook command string.
|
|
122
|
-
* Backslashes emitted by path.join() on Windows are normalised to forward
|
|
123
|
-
* slashes on line 101 before any escaping is applied.
|
|
124
|
-
*
|
|
125
|
-
* When Claude Code's hook runner passes the command string through a POSIX
|
|
126
|
-
* shell (sh -c), the following characters receive special treatment inside
|
|
127
|
-
* double-quoted strings and must be escaped — see shellEscapeDoubleQuotedPath().
|
|
128
|
-
*
|
|
129
|
-
* `node --` is used to terminate Node.js option parsing so that a path
|
|
130
|
-
* component beginning with `-` cannot be misinterpreted as a CLI flag.
|
|
131
|
-
*
|
|
132
|
-
* @returns {string}
|
|
133
|
-
*/
|
|
134
|
-
function sessionStartHookCommand() {
|
|
135
|
-
const hookPath = join(
|
|
136
|
-
FORGE_HOME,
|
|
137
|
-
"bin",
|
|
138
|
-
"hooks",
|
|
139
|
-
"session-start.mjs",
|
|
140
|
-
).replace(/\\/g, "/");
|
|
141
|
-
const safePath = shellEscapeDoubleQuotedPath(hookPath);
|
|
142
|
-
return `node -- "${safePath}"`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
104
|
/**
|
|
146
105
|
* Check whether a hook command string refers to the ForgeDock SessionStart hook.
|
|
147
106
|
*
|
|
@@ -276,28 +235,6 @@ function readClaudeSettings() {
|
|
|
276
235
|
}
|
|
277
236
|
}
|
|
278
237
|
|
|
279
|
-
/**
|
|
280
|
-
* Atomically write an object to ~/.claude/settings.json.
|
|
281
|
-
* Uses a .tmp sibling + renameSync to avoid partial writes.
|
|
282
|
-
* Cleans up the tmp file on failure. (ref: forge#444)
|
|
283
|
-
*
|
|
284
|
-
* @param {object} settings
|
|
285
|
-
*/
|
|
286
|
-
function writeClaudeSettings(settings) {
|
|
287
|
-
const tmpPath = CLAUDE_SETTINGS_PATH + ".forgedock.tmp";
|
|
288
|
-
try {
|
|
289
|
-
writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
290
|
-
renameSync(tmpPath, CLAUDE_SETTINGS_PATH);
|
|
291
|
-
} catch (err) {
|
|
292
|
-
try {
|
|
293
|
-
unlinkSync(tmpPath);
|
|
294
|
-
} catch {
|
|
295
|
-
/* already gone or never created */
|
|
296
|
-
}
|
|
297
|
-
throw err;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
238
|
/**
|
|
302
239
|
* Atomically write text content to a file.
|
|
303
240
|
* Uses a .forgedock.tmp sibling + renameSync to avoid partial writes on
|
|
@@ -321,177 +258,6 @@ function atomicWriteFile(filePath, content) {
|
|
|
321
258
|
}
|
|
322
259
|
}
|
|
323
260
|
|
|
324
|
-
/**
|
|
325
|
-
* Idempotently write a `.symlink-source` sentinel file to TARGET_DIR
|
|
326
|
-
* (~/.claude/commands/) so other installers can detect that ForgeDock
|
|
327
|
-
* owns this namespace.
|
|
328
|
-
*
|
|
329
|
-
* The sentinel is a plain UTF-8 text file (not a symlink) containing
|
|
330
|
-
* the source path, a reinstall hint, and the install timestamp. It is
|
|
331
|
-
* updated on every install so the timestamp stays fresh. Write failures
|
|
332
|
-
* are non-fatal — the function returns 'failed' and the caller logs a
|
|
333
|
-
* warning; install continues regardless.
|
|
334
|
-
*
|
|
335
|
-
* Calling convention matches the other state-write helpers:
|
|
336
|
-
* 'written' — sentinel was newly created or refreshed
|
|
337
|
-
* 'failed' — an error occurred (non-fatal; install continues)
|
|
338
|
-
*
|
|
339
|
-
* <!-- Added: forge#1038 -->
|
|
340
|
-
*
|
|
341
|
-
* @returns {'written' | 'failed'}
|
|
342
|
-
*/
|
|
343
|
-
function writeSymlinkSentinel() {
|
|
344
|
-
const sentinelPath = join(TARGET_DIR, ".symlink-source");
|
|
345
|
-
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
346
|
-
const content =
|
|
347
|
-
`# ForgeDock command symlinks — DO NOT REPOINT\n` +
|
|
348
|
-
`# Source: ${COMMANDS_DIR}\n` +
|
|
349
|
-
`#\n` +
|
|
350
|
-
`# These symlinks are managed by ForgeDock (https://forgedock.com).\n` +
|
|
351
|
-
`# ForgeDock owns the global ~/.claude/commands/ namespace.\n` +
|
|
352
|
-
`# Project-specific commands should install to the project's\n` +
|
|
353
|
-
`# .claude/commands/ directory instead (Claude Code merges both;\n` +
|
|
354
|
-
`# project-local commands win on name collisions).\n` +
|
|
355
|
-
`#\n` +
|
|
356
|
-
`# Running another installer here will silently repoint these\n` +
|
|
357
|
-
`# symlinks, breaking all ForgeDock commands globally.\n` +
|
|
358
|
-
`#\n` +
|
|
359
|
-
`# To reinstall ForgeDock: npx forgedock install\n` +
|
|
360
|
-
`# Last installed: ${timestamp}\n`;
|
|
361
|
-
try {
|
|
362
|
-
atomicWriteFile(sentinelPath, content);
|
|
363
|
-
return "written";
|
|
364
|
-
} catch {
|
|
365
|
-
return "failed";
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Install the ForgeDock SessionStart hook into ~/.claude/settings.json
|
|
371
|
-
* idempotently. Does not modify any existing hooks unrelated to ForgeDock.
|
|
372
|
-
*
|
|
373
|
-
* Returns:
|
|
374
|
-
* 'installed' — hook was newly added
|
|
375
|
-
* 'already-present' — hook was already there (idempotent)
|
|
376
|
-
* 'failed' — an error occurred (non-fatal; install continues)
|
|
377
|
-
*
|
|
378
|
-
* @returns {Promise<'installed' | 'already-present' | 'failed'>}
|
|
379
|
-
*/
|
|
380
|
-
async function installSessionStartHook() {
|
|
381
|
-
try {
|
|
382
|
-
const settings = readClaudeSettings();
|
|
383
|
-
|
|
384
|
-
// Ensure the hooks section exists
|
|
385
|
-
if (!settings.hooks || typeof settings.hooks !== "object") {
|
|
386
|
-
settings.hooks = {};
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Ensure SessionStart array exists
|
|
390
|
-
if (!Array.isArray(settings.hooks.SessionStart)) {
|
|
391
|
-
settings.hooks.SessionStart = [];
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const cmd = sessionStartHookCommand();
|
|
395
|
-
|
|
396
|
-
// Check if already present — match by path suffix so uninstall/reinstall
|
|
397
|
-
// across different FORGE_HOME paths still works correctly. (ref: forge#537)
|
|
398
|
-
const alreadyPresent = settings.hooks.SessionStart.some((entry) => {
|
|
399
|
-
if (!entry || typeof entry !== "object") return false;
|
|
400
|
-
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
401
|
-
return hooks.some(
|
|
402
|
-
(h) =>
|
|
403
|
-
h &&
|
|
404
|
-
typeof h.command === "string" &&
|
|
405
|
-
isForgeSessionStartHook(h.command),
|
|
406
|
-
);
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
if (alreadyPresent) return "already-present";
|
|
410
|
-
|
|
411
|
-
// Append the new hook entry (matcher omitted — fires on every session start)
|
|
412
|
-
settings.hooks.SessionStart.push({
|
|
413
|
-
hooks: [
|
|
414
|
-
{
|
|
415
|
-
type: "command",
|
|
416
|
-
command: cmd,
|
|
417
|
-
timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
|
|
418
|
-
},
|
|
419
|
-
],
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
writeClaudeSettings(settings);
|
|
423
|
-
return "installed";
|
|
424
|
-
} catch {
|
|
425
|
-
return "failed";
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Remove the ForgeDock SessionStart hook from ~/.claude/settings.json
|
|
431
|
-
* idempotently. Only removes the entry written by installSessionStartHook().
|
|
432
|
-
* Unrelated hooks are preserved.
|
|
433
|
-
*
|
|
434
|
-
* Returns:
|
|
435
|
-
* 'removed' — hook was found and removed
|
|
436
|
-
* 'not-present' — hook was not in settings.json (already clean)
|
|
437
|
-
* 'failed' — an error occurred (non-fatal; uninstall continues)
|
|
438
|
-
*
|
|
439
|
-
* @returns {Promise<'removed' | 'not-present' | 'failed'>}
|
|
440
|
-
*/
|
|
441
|
-
async function removeSessionStartHook() {
|
|
442
|
-
try {
|
|
443
|
-
const settings = readClaudeSettings();
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
!settings.hooks ||
|
|
447
|
-
!Array.isArray(settings.hooks.SessionStart) ||
|
|
448
|
-
settings.hooks.SessionStart.length === 0
|
|
449
|
-
) {
|
|
450
|
-
return "not-present";
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const originalLength = settings.hooks.SessionStart.length;
|
|
454
|
-
|
|
455
|
-
// Filter out the ForgeDock-managed entry, keep everything else
|
|
456
|
-
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
|
|
457
|
-
(entry) => {
|
|
458
|
-
if (!entry || typeof entry !== "object") return true;
|
|
459
|
-
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
460
|
-
const isForgeEntry = hooks.some(
|
|
461
|
-
(h) =>
|
|
462
|
-
h &&
|
|
463
|
-
typeof h.command === "string" &&
|
|
464
|
-
isForgeSessionStartHook(h.command),
|
|
465
|
-
);
|
|
466
|
-
return !isForgeEntry;
|
|
467
|
-
},
|
|
468
|
-
);
|
|
469
|
-
|
|
470
|
-
if (settings.hooks.SessionStart.length === originalLength) {
|
|
471
|
-
return "not-present";
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Clean up empty SessionStart array to leave settings.json tidy
|
|
475
|
-
if (settings.hooks.SessionStart.length === 0) {
|
|
476
|
-
delete settings.hooks.SessionStart;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Clean up empty hooks object
|
|
480
|
-
if (
|
|
481
|
-
settings.hooks &&
|
|
482
|
-
typeof settings.hooks === "object" &&
|
|
483
|
-
Object.keys(settings.hooks).length === 0
|
|
484
|
-
) {
|
|
485
|
-
delete settings.hooks;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
writeClaudeSettings(settings);
|
|
489
|
-
return "removed";
|
|
490
|
-
} catch {
|
|
491
|
-
return "failed";
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
261
|
// ---------------------------------------------------------------------------
|
|
496
262
|
// Version + splash — read package.json version and render branded logo
|
|
497
263
|
// ---------------------------------------------------------------------------
|
|
@@ -523,148 +289,6 @@ function splash() {
|
|
|
523
289
|
const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
|
|
524
290
|
const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
|
|
525
291
|
|
|
526
|
-
/**
|
|
527
|
-
* The behavioral rules block injected into the project's CLAUDE.md.
|
|
528
|
-
* Kept ≤30 lines (per issue #607 requirement).
|
|
529
|
-
* Content is fixed — this is NOT documentation, it is behavioral guidance.
|
|
530
|
-
*/
|
|
531
|
-
const FORGEDOCK_MANAGED_BLOCK = `${CLAUDE_BLOCK_BEGIN}
|
|
532
|
-
## ForgeDock Pipeline Rules
|
|
533
|
-
|
|
534
|
-
This project uses ForgeDock for structured development. Follow these rules:
|
|
535
|
-
|
|
536
|
-
1. **Issue-first**: When the user describes a bug, feature, or task conversationally — create a GitHub issue with \`/issue\` before writing any code. Never inline-fix without an issue number.
|
|
537
|
-
2. **Pipeline flow**: All implementation goes through \`/work-on <issue#>\`. This runs: investigate → architect → build → review → merge.
|
|
538
|
-
3. **Traceability**: Every code change must link to a GitHub issue. Use FORGE annotations to pass context between pipeline phases.
|
|
539
|
-
4. **Findings become issues**: When you discover bugs, inconsistencies, or improvements during other work — create a GitHub issue (\`gh issue create\`), don't fix inline.
|
|
540
|
-
5. **Available commands**: \`/work-on\`, \`/issue\`, \`/review-pr\`, \`/orchestrate\`, \`/quality-gate\`, \`/milestone\`, \`/autopilot\`
|
|
541
|
-
|
|
542
|
-
For full documentation, see: [ForgeDock docs](https://forgedock.com/docs)
|
|
543
|
-
${CLAUDE_BLOCK_END}`;
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Returns true if `dir` is inside a git work tree.
|
|
547
|
-
* Used to guard CLAUDE.md injection against non-project directories.
|
|
548
|
-
*
|
|
549
|
-
* @param {string} dir
|
|
550
|
-
* @returns {boolean}
|
|
551
|
-
*/
|
|
552
|
-
function isGitWorkTree(dir) {
|
|
553
|
-
try {
|
|
554
|
-
const out = execSync("git rev-parse --is-inside-work-tree", {
|
|
555
|
-
cwd: dir,
|
|
556
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
557
|
-
encoding: "utf-8",
|
|
558
|
-
});
|
|
559
|
-
return out.trim() === "true";
|
|
560
|
-
} catch {
|
|
561
|
-
return false;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Idempotently inject the ForgeDock managed block into a single file.
|
|
567
|
-
*
|
|
568
|
-
* Handles all corrupt marker states (ref: forge#269, forge#291):
|
|
569
|
-
* - File missing → create file containing block only
|
|
570
|
-
* - No markers → append block to existing content
|
|
571
|
-
* - BEGIN + END (correct) → replace content between markers
|
|
572
|
-
* - BEGIN only → strip orphaned BEGIN, re-append full block
|
|
573
|
-
* - END only → strip orphaned END, re-append full block
|
|
574
|
-
* - END before BEGIN → strip both + content between, re-append full block
|
|
575
|
-
* - Block already current → return 'unchanged'
|
|
576
|
-
*
|
|
577
|
-
* Returns:
|
|
578
|
-
* 'created' — file was created with block
|
|
579
|
-
* 'updated' — existing block was replaced with new content
|
|
580
|
-
* 'unchanged' — block was already present and current
|
|
581
|
-
* 'appended' — block was appended (no prior markers)
|
|
582
|
-
*
|
|
583
|
-
* @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
|
|
584
|
-
* @returns {'created' | 'updated' | 'unchanged' | 'appended'}
|
|
585
|
-
*/
|
|
586
|
-
function injectManagedBlock(filePath) {
|
|
587
|
-
const managed = FORGEDOCK_MANAGED_BLOCK;
|
|
588
|
-
|
|
589
|
-
// File does not exist — create it with the block
|
|
590
|
-
if (!existsSync(filePath)) {
|
|
591
|
-
atomicWriteFile(filePath, managed + "\n");
|
|
592
|
-
return "created";
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
let existing = readFileSync(filePath, "utf-8");
|
|
596
|
-
|
|
597
|
-
const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
|
|
598
|
-
const hasEnd = existing.includes(CLAUDE_BLOCK_END);
|
|
599
|
-
|
|
600
|
-
if (hasBegin && hasEnd) {
|
|
601
|
-
// Determine marker order
|
|
602
|
-
const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
|
|
603
|
-
const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
|
|
604
|
-
|
|
605
|
-
if (beginIdx < endIdx) {
|
|
606
|
-
// Normal order — replace content between markers (inclusive)
|
|
607
|
-
// Non-greedy match so we don't over-consume if someone manually
|
|
608
|
-
// duplicated the markers.
|
|
609
|
-
const blockRegex = new RegExp(
|
|
610
|
-
escapeRegExp(CLAUDE_BLOCK_BEGIN) +
|
|
611
|
-
"[\\s\\S]*?" +
|
|
612
|
-
escapeRegExp(CLAUDE_BLOCK_END),
|
|
613
|
-
);
|
|
614
|
-
if (!blockRegex.test(existing)) {
|
|
615
|
-
// Should not happen: both markers were confirmed above via includes() and
|
|
616
|
-
// indexOf() ordering. Guard against silent misclassification — if the regex
|
|
617
|
-
// somehow fails to match, replaced === existing would incorrectly return
|
|
618
|
-
// 'unchanged' instead of surfacing the broken state.
|
|
619
|
-
throw new Error(
|
|
620
|
-
`injectManagedBlock: regex failed to match markers in ${filePath}`,
|
|
621
|
-
);
|
|
622
|
-
}
|
|
623
|
-
const replaced = existing.replace(blockRegex, managed);
|
|
624
|
-
if (replaced === existing) {
|
|
625
|
-
// Content between markers was already identical to the managed block
|
|
626
|
-
return "unchanged";
|
|
627
|
-
}
|
|
628
|
-
atomicWriteFile(filePath, replaced);
|
|
629
|
-
return "updated";
|
|
630
|
-
} else {
|
|
631
|
-
// Reversed markers (END before BEGIN) — strip both markers + everything
|
|
632
|
-
// between them, then fall through to append. (ref: forge#291 BUG-2)
|
|
633
|
-
const strippedReversed = existing
|
|
634
|
-
.replace(
|
|
635
|
-
new RegExp(
|
|
636
|
-
escapeRegExp(CLAUDE_BLOCK_END) +
|
|
637
|
-
"[\\s\\S]*?" +
|
|
638
|
-
escapeRegExp(CLAUDE_BLOCK_BEGIN),
|
|
639
|
-
),
|
|
640
|
-
"",
|
|
641
|
-
)
|
|
642
|
-
// Strip any leftover isolated markers
|
|
643
|
-
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
|
|
644
|
-
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "");
|
|
645
|
-
existing = strippedReversed.trimEnd();
|
|
646
|
-
// Fall through to append
|
|
647
|
-
}
|
|
648
|
-
} else if (hasBegin) {
|
|
649
|
-
// Orphaned BEGIN — strip it, then fall through to append. (ref: forge#269)
|
|
650
|
-
existing = existing
|
|
651
|
-
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
|
|
652
|
-
.trimEnd();
|
|
653
|
-
// Fall through to append
|
|
654
|
-
} else if (hasEnd) {
|
|
655
|
-
// Orphaned END — strip it, then fall through to append. (ref: forge#291 BUG-1)
|
|
656
|
-
existing = existing
|
|
657
|
-
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
|
|
658
|
-
.trimEnd();
|
|
659
|
-
// Fall through to append
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// No valid block present — append
|
|
663
|
-
const separator = existing.length > 0 ? "\n\n" : "";
|
|
664
|
-
atomicWriteFile(filePath, existing + separator + managed + "\n");
|
|
665
|
-
return hasBegin || hasEnd ? "updated" : "appended";
|
|
666
|
-
}
|
|
667
|
-
|
|
668
292
|
/**
|
|
669
293
|
* Remove the ForgeDock managed block from a single file.
|
|
670
294
|
* Strips only the marker-bounded section; all surrounding content is preserved.
|
|
@@ -743,28 +367,16 @@ function escapeRegExp(str) {
|
|
|
743
367
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
744
368
|
}
|
|
745
369
|
|
|
746
|
-
async function findMarkdownFiles(dir) {
|
|
747
|
-
const results = [];
|
|
748
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
749
|
-
for (const entry of entries) {
|
|
750
|
-
const full = join(dir, entry.name);
|
|
751
|
-
if (entry.isDirectory()) {
|
|
752
|
-
results.push(...(await findMarkdownFiles(full)));
|
|
753
|
-
} else if (entry.name.endsWith(".md")) {
|
|
754
|
-
results.push(full);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
return results.sort();
|
|
758
|
-
}
|
|
759
|
-
|
|
760
370
|
// ---------------------------------------------------------------------------
|
|
761
371
|
// Shell profile FORGE_HOME removal
|
|
762
372
|
// ---------------------------------------------------------------------------
|
|
763
373
|
|
|
764
374
|
/**
|
|
765
|
-
* The sentinel comment line
|
|
766
|
-
* `export FORGE_HOME=` line.
|
|
767
|
-
*
|
|
375
|
+
* The sentinel comment line legacy installs wrote immediately before the
|
|
376
|
+
* `export FORGE_HOME=` line. The journey-based install no longer writes this
|
|
377
|
+
* block — this constant is used by `uninstall` cleanup for legacy installs
|
|
378
|
+
* that still have it in a shell profile. It is the reliable anchor for
|
|
379
|
+
* removal — never present in organic shell profiles.
|
|
768
380
|
*/
|
|
769
381
|
const FORGE_HOME_COMMENT = "# ForgeDock — autonomous development pipeline";
|
|
770
382
|
|
|
@@ -805,13 +417,13 @@ function removeForgeHomeFromProfile(profilePath) {
|
|
|
805
417
|
return "not-present";
|
|
806
418
|
}
|
|
807
419
|
|
|
808
|
-
// Step 1: Remove the 2-line ForgeDock block (comment + export)
|
|
809
|
-
//
|
|
810
|
-
// The \r? handles profiles with CRLF line endings.
|
|
420
|
+
// Step 1: Remove the 2-line ForgeDock block (comment + export) that
|
|
421
|
+
// installs from older builds appended, preceded by an optional leading
|
|
422
|
+
// blank line. The \r? handles profiles with CRLF line endings.
|
|
811
423
|
// The value pattern (?:[^"\\]|\\.)* matches a double-quoted shell string
|
|
812
424
|
// that may contain backslash-escape sequences (e.g. \" or \\) written by
|
|
813
|
-
//
|
|
814
|
-
// at the escaped-quote character inside \"
|
|
425
|
+
// the old installer's shell escaping. The simpler [^"]* would stop
|
|
426
|
+
// prematurely at the escaped-quote character inside \".
|
|
815
427
|
let cleaned = content.replace(
|
|
816
428
|
/\r?\n[ \t]*# ForgeDock — autonomous development pipeline\r?\nexport FORGE_HOME="(?:[^"\\]|\\.)*"\r?\n/g,
|
|
817
429
|
"\n",
|
|
@@ -847,93 +459,6 @@ function removeForgeHomeFromProfile(profilePath) {
|
|
|
847
459
|
return "removed";
|
|
848
460
|
}
|
|
849
461
|
|
|
850
|
-
/**
|
|
851
|
-
* Link (symlink) all commands from COMMANDS_DIR to TARGET_DIR.
|
|
852
|
-
* Called inside a runSteps() step — uses step.progress() and step.note().
|
|
853
|
-
*
|
|
854
|
-
* @param {object} step - StepAPI from runSteps
|
|
855
|
-
* @returns {Promise<{installed: number, updated: number, skipped: number}>}
|
|
856
|
-
*/
|
|
857
|
-
async function linkCommands(step) {
|
|
858
|
-
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
859
|
-
const total = files.length;
|
|
860
|
-
let installed = 0;
|
|
861
|
-
let updated = 0;
|
|
862
|
-
let skipped = 0;
|
|
863
|
-
|
|
864
|
-
for (let i = 0; i < files.length; i++) {
|
|
865
|
-
const file = files[i];
|
|
866
|
-
const rel = relative(COMMANDS_DIR, file);
|
|
867
|
-
const target = join(TARGET_DIR, rel);
|
|
868
|
-
const targetDir = dirname(target);
|
|
869
|
-
|
|
870
|
-
await mkdir(targetDir, { recursive: true });
|
|
871
|
-
step.progress(i + 1, total);
|
|
872
|
-
|
|
873
|
-
try {
|
|
874
|
-
const stats = await lstat(target);
|
|
875
|
-
|
|
876
|
-
if (stats.isSymbolicLink()) {
|
|
877
|
-
const current = await readlink(target);
|
|
878
|
-
if (current === file) {
|
|
879
|
-
skipped++;
|
|
880
|
-
} else {
|
|
881
|
-
// Symlink points to a different source — warn if it appears to belong
|
|
882
|
-
// to another Forge/ForgeDock installation (i.e. not a broken link that
|
|
883
|
-
// already pointed here under a different absolute path).
|
|
884
|
-
// Only emit the warning once per install to avoid log spam.
|
|
885
|
-
// <!-- Added: forge#1038 -->
|
|
886
|
-
if (!current.startsWith(COMMANDS_DIR)) {
|
|
887
|
-
step.note(
|
|
888
|
-
yellow(`collision: ${rel} was → ${current} — repointing to ForgeDock`),
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
try {
|
|
892
|
-
await symlink(file, target + ".tmp");
|
|
893
|
-
const { rename } = await import("fs/promises");
|
|
894
|
-
await rename(target + ".tmp", target);
|
|
895
|
-
updated++;
|
|
896
|
-
} catch (renameErr) {
|
|
897
|
-
try {
|
|
898
|
-
await unlink(target + ".tmp");
|
|
899
|
-
} catch {
|
|
900
|
-
/* .tmp already gone or was never created */
|
|
901
|
-
}
|
|
902
|
-
if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
|
|
903
|
-
// Windows without Developer Mode: symlink() throws EPERM/EACCES.
|
|
904
|
-
// Fall back to a direct copy (matches new-install fallback behaviour).
|
|
905
|
-
const { copyFile } = await import("fs/promises");
|
|
906
|
-
await copyFile(file, target);
|
|
907
|
-
updated++;
|
|
908
|
-
} else {
|
|
909
|
-
throw renameErr;
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
} else {
|
|
914
|
-
// Regular file — skip; user must remove manually
|
|
915
|
-
skipped++;
|
|
916
|
-
}
|
|
917
|
-
} catch (err) {
|
|
918
|
-
if (err.code !== "ENOENT") throw err;
|
|
919
|
-
// Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
|
|
920
|
-
try {
|
|
921
|
-
await symlink(file, target);
|
|
922
|
-
} catch (linkErr) {
|
|
923
|
-
if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
|
|
924
|
-
const { copyFile } = await import("fs/promises");
|
|
925
|
-
await copyFile(file, target);
|
|
926
|
-
} else {
|
|
927
|
-
throw linkErr;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
installed++;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
return { installed, updated, skipped };
|
|
935
|
-
}
|
|
936
|
-
|
|
937
462
|
/**
|
|
938
463
|
* Enumerate pipeline-agent scripts in SCRIPTS_DIR that should be installed
|
|
939
464
|
* to ~/.claude/scripts/. Only files present in PIPELINE_SCRIPTS are returned.
|
|
@@ -959,291 +484,96 @@ async function findScriptFiles(dir) {
|
|
|
959
484
|
return results.sort();
|
|
960
485
|
}
|
|
961
486
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
* Called inside a runSteps() step — uses step.progress() and step.note().
|
|
966
|
-
*
|
|
967
|
-
* @param {object} step - StepAPI from runSteps
|
|
968
|
-
* @returns {Promise<{installed: number, updated: number, skipped: number}>}
|
|
969
|
-
*/
|
|
970
|
-
async function linkScripts(step) {
|
|
971
|
-
const files = await findScriptFiles(SCRIPTS_DIR);
|
|
972
|
-
const total = files.length;
|
|
973
|
-
|
|
974
|
-
if (total === 0) {
|
|
975
|
-
step.skip("no scripts to link");
|
|
976
|
-
return { installed: 0, updated: 0, skipped: 0 };
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
await mkdir(SCRIPTS_TARGET_DIR, { recursive: true });
|
|
980
|
-
|
|
981
|
-
let installed = 0;
|
|
982
|
-
let updated = 0;
|
|
983
|
-
let skipped = 0;
|
|
984
|
-
|
|
985
|
-
for (let idx = 0; idx < files.length; idx++) {
|
|
986
|
-
const file = files[idx];
|
|
987
|
-
const rel = relative(SCRIPTS_DIR, file);
|
|
988
|
-
const target = join(SCRIPTS_TARGET_DIR, rel);
|
|
989
|
-
|
|
990
|
-
step.progress(idx + 1, total);
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Journey context + status screen + init flow (onboarding surface)
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
991
490
|
|
|
992
|
-
|
|
993
|
-
|
|
491
|
+
function ctx() {
|
|
492
|
+
const c = makeCtx({ argv: flags });
|
|
493
|
+
c.forgeHome = FORGE_HOME;
|
|
494
|
+
c.home = HOME;
|
|
495
|
+
return c;
|
|
496
|
+
}
|
|
994
497
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
await copyFile(file, target);
|
|
1016
|
-
updated++;
|
|
1017
|
-
} else {
|
|
1018
|
-
throw renameErr;
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
} else {
|
|
1023
|
-
// Existing regular file — refresh if contents differ.
|
|
1024
|
-
if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
|
|
1025
|
-
skipped++;
|
|
1026
|
-
} else {
|
|
1027
|
-
const { copyFile } = await import("fs/promises");
|
|
1028
|
-
await copyFile(file, target);
|
|
1029
|
-
updated++;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
} catch (err) {
|
|
1033
|
-
if (err.code !== "ENOENT") throw err;
|
|
1034
|
-
// Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
|
|
1035
|
-
try {
|
|
1036
|
-
await symlink(file, target);
|
|
1037
|
-
} catch (linkErr) {
|
|
1038
|
-
if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
|
|
1039
|
-
const { copyFile } = await import("fs/promises");
|
|
1040
|
-
await copyFile(file, target);
|
|
1041
|
-
} else {
|
|
1042
|
-
throw linkErr;
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
installed++;
|
|
1046
|
-
}
|
|
498
|
+
/** Compact status screen for configured/managed directories. */
|
|
499
|
+
function statusScreen(c) {
|
|
500
|
+
const state = resolveState(c.cwd);
|
|
501
|
+
const mark = renderMark("compact", c.mode);
|
|
502
|
+
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
503
|
+
c.stdout.write("\n" + mark[0] + "\n");
|
|
504
|
+
c.stdout.write(mark[1] + " " + ember("FORGEDOCK", c.mode) + " " + dim("status") + "\n");
|
|
505
|
+
c.stdout.write(mark[2] + "\n" + mark[3] + "\n\n");
|
|
506
|
+
const configured = existsSync(join(c.cwd, "forge.yaml"));
|
|
507
|
+
c.stdout.write(` directory ${state}\n`);
|
|
508
|
+
c.stdout.write(` forge.yaml ${configured ? "present" : "missing"}\n`);
|
|
509
|
+
c.stdout.write(` commands ${existsSync(TARGET_DIR) ? "installed at " + TARGET_DIR : "not installed"}\n`);
|
|
510
|
+
if (state === "managed-optedout") {
|
|
511
|
+
c.stdout.write(`\n ForgeDock is disabled (opted out) here. Re-enable: npx forgedock enable\n`);
|
|
512
|
+
} else if (state === "unmanaged") {
|
|
513
|
+
c.stdout.write(`\n ForgeDock is not active in this directory. Activate: npx forgedock enable\n`);
|
|
514
|
+
} else if (!configured) {
|
|
515
|
+
c.stdout.write(`\n Generate config: npx forgedock init\n`);
|
|
516
|
+
} else {
|
|
517
|
+
c.stdout.write(`\n Reconfigure: npx forgedock init · Refresh commands + hook: npx forgedock update\n`);
|
|
1047
518
|
}
|
|
1048
|
-
|
|
1049
|
-
return { installed, updated, skipped };
|
|
519
|
+
c.stdout.write("\n");
|
|
1050
520
|
}
|
|
1051
521
|
|
|
1052
|
-
async function
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
},
|
|
1108
|
-
{
|
|
1109
|
-
label: "Linking scripts",
|
|
1110
|
-
async run(step) {
|
|
1111
|
-
const { installed, updated, skipped } = await linkScripts(step);
|
|
1112
|
-
if (installed + updated + skipped > 0) {
|
|
1113
|
-
step.note(
|
|
1114
|
-
`${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
|
|
1115
|
-
);
|
|
1116
|
-
}
|
|
1117
|
-
},
|
|
1118
|
-
},
|
|
1119
|
-
{
|
|
1120
|
-
label: "Configuring shell profile",
|
|
1121
|
-
async run(step) {
|
|
1122
|
-
// Set FORGE_HOME in shell profiles
|
|
1123
|
-
let profileUpdated = false;
|
|
1124
|
-
for (const profile of [
|
|
1125
|
-
join(HOME, ".bashrc"),
|
|
1126
|
-
join(HOME, ".zshrc"),
|
|
1127
|
-
]) {
|
|
1128
|
-
if (existsSync(profile)) {
|
|
1129
|
-
const content = readFileSync(profile, "utf-8");
|
|
1130
|
-
// Escape shell metacharacters in FORGE_HOME before embedding it in
|
|
1131
|
-
// a double-quoted shell assignment. A crafted install path
|
|
1132
|
-
// containing $(...), backticks, or " characters would otherwise
|
|
1133
|
-
// inject arbitrary shell commands executed on the user's next login.
|
|
1134
|
-
// shellEscapeDoubleQuotedPath() applies the same escaping used for
|
|
1135
|
-
// the SessionStart hook command (ref: forge#808, forge#792, forge#451).
|
|
1136
|
-
const safeForgeHome = shellEscapeDoubleQuotedPath(FORGE_HOME);
|
|
1137
|
-
const exactExport = `export FORGE_HOME="${safeForgeHome}"`;
|
|
1138
|
-
if (content.includes(exactExport)) {
|
|
1139
|
-
// Already set to the current path — no update needed.
|
|
1140
|
-
} else {
|
|
1141
|
-
// Either not present, or set to a stale path — refresh it.
|
|
1142
|
-
if (content.includes("FORGE_HOME")) {
|
|
1143
|
-
// Strip the old block before appending the updated one.
|
|
1144
|
-
const removeResult = removeForgeHomeFromProfile(profile);
|
|
1145
|
-
if (removeResult === "failed") {
|
|
1146
|
-
// Removal failed — old export still present. Skip append to
|
|
1147
|
-
// avoid writing a duplicate FORGE_HOME export. (ref: forge#846)
|
|
1148
|
-
continue;
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
appendFileSync(
|
|
1152
|
-
profile,
|
|
1153
|
-
`\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${safeForgeHome}"\n`,
|
|
1154
|
-
);
|
|
1155
|
-
profileUpdated = true;
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
if (!profileUpdated) {
|
|
1160
|
-
step.skip("FORGE_HOME already set");
|
|
1161
|
-
}
|
|
1162
|
-
},
|
|
1163
|
-
},
|
|
1164
|
-
{
|
|
1165
|
-
label: "Registering SessionStart hook",
|
|
1166
|
-
async run(step) {
|
|
1167
|
-
const hookResult = await installSessionStartHook();
|
|
1168
|
-
if (hookResult === "already-present") {
|
|
1169
|
-
step.skip("already present");
|
|
1170
|
-
} else if (hookResult === "failed") {
|
|
1171
|
-
// Non-fatal — warn but don't throw
|
|
1172
|
-
step.note("could not write — run npx forgedock install again to retry");
|
|
1173
|
-
}
|
|
1174
|
-
},
|
|
1175
|
-
},
|
|
1176
|
-
{
|
|
1177
|
-
label: "Generating forge.yaml",
|
|
1178
|
-
async run(step) {
|
|
1179
|
-
// Auto-generate forge.yaml if missing — no second command needed.
|
|
1180
|
-
// Only do this inside a real git project (ref: forge#585).
|
|
1181
|
-
const cwd = process.cwd();
|
|
1182
|
-
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
1183
|
-
if (existsSync(forgeYamlPath)) {
|
|
1184
|
-
step.skip("forge.yaml already exists");
|
|
1185
|
-
return;
|
|
1186
|
-
}
|
|
1187
|
-
if (!isGitWorkTree(cwd)) {
|
|
1188
|
-
step.skip("not a git project — run npx forgedock init inside your project");
|
|
1189
|
-
return;
|
|
1190
|
-
}
|
|
1191
|
-
await init(true);
|
|
1192
|
-
},
|
|
1193
|
-
},
|
|
1194
|
-
{
|
|
1195
|
-
label: "Updating CLAUDE.md",
|
|
1196
|
-
async run(step) {
|
|
1197
|
-
// Inject behavioral rules into CLAUDE.md (and AGENTS.md if present).
|
|
1198
|
-
// Only inside a git project — guards against non-project cwd (ref: forge#585).
|
|
1199
|
-
const installCwd = process.cwd();
|
|
1200
|
-
if (!isGitWorkTree(installCwd)) {
|
|
1201
|
-
step.skip("not a git project");
|
|
1202
|
-
return;
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
const claudeMdPath = join(installCwd, "CLAUDE.md");
|
|
1206
|
-
const agentsMdPath = join(installCwd, "AGENTS.md");
|
|
1207
|
-
|
|
1208
|
-
const claudeResult = injectManagedBlock(claudeMdPath);
|
|
1209
|
-
let note = "";
|
|
1210
|
-
if (claudeResult === "created") {
|
|
1211
|
-
note = "CLAUDE.md created";
|
|
1212
|
-
} else if (claudeResult === "updated") {
|
|
1213
|
-
note = "CLAUDE.md block refreshed";
|
|
1214
|
-
} else if (claudeResult === "appended") {
|
|
1215
|
-
note = "CLAUDE.md rules appended";
|
|
1216
|
-
} else {
|
|
1217
|
-
note = "CLAUDE.md already current";
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
// Mirror to AGENTS.md only if it already exists (never create it)
|
|
1221
|
-
if (existsSync(agentsMdPath)) {
|
|
1222
|
-
const agentsResult = injectManagedBlock(agentsMdPath);
|
|
1223
|
-
if (agentsResult === "updated" || agentsResult === "appended") {
|
|
1224
|
-
note += " · AGENTS.md mirrored";
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
step.note(note);
|
|
1229
|
-
},
|
|
1230
|
-
},
|
|
1231
|
-
]);
|
|
1232
|
-
|
|
1233
|
-
if (result.ok) {
|
|
1234
|
-
process.stderr.write(
|
|
1235
|
-
box(
|
|
1236
|
-
[
|
|
1237
|
-
`${green("✔")} ${bold("ForgeDock is ready")}`,
|
|
1238
|
-
"",
|
|
1239
|
-
`Commands installed to ${cyan(TARGET_DIR)}`,
|
|
1240
|
-
`Scripts installed to ${cyan(SCRIPTS_TARGET_DIR)}`,
|
|
1241
|
-
`Next: ${cyan("cd <your-project>")} then ${cyan("npx forgedock init")}`,
|
|
1242
|
-
],
|
|
1243
|
-
{ title: "ForgeDock Installed" },
|
|
1244
|
-
) + "\n",
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
522
|
+
async function initFlow(c) {
|
|
523
|
+
const outputPath = join(c.cwd, "forge.yaml");
|
|
524
|
+
if (existsSync(outputPath) && process.stdin.isTTY !== true) {
|
|
525
|
+
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
526
|
+
c.stdout.write("\n forge.yaml already exists — non-interactive run, aborting to protect it.\n");
|
|
527
|
+
c.stdout.write(" " + dim("Run interactively (or delete forge.yaml) to regenerate.") + "\n");
|
|
528
|
+
return 1;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (flags.includes("--minimal")) {
|
|
532
|
+
// Staging semantics preserved (#1148): detection → minimal template with
|
|
533
|
+
// only the three required sections. No review screen — this is the
|
|
534
|
+
// power-user escape hatch for a ~20-line forge.yaml.
|
|
535
|
+
const { draft, description } = await read(c);
|
|
536
|
+
const backup = backupExisting(outputPath);
|
|
537
|
+
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
538
|
+
const content = buildMinimalForgeYaml({
|
|
539
|
+
projectName: draft.project.name.value,
|
|
540
|
+
owner: draft.project.owner.value,
|
|
541
|
+
repo: draft.project.repo.value,
|
|
542
|
+
description: description.value,
|
|
543
|
+
root: draft.paths.root.value,
|
|
544
|
+
worktreeBase: draft.paths.worktreeBase.value,
|
|
545
|
+
defaultBranch: draft.branches.default.value,
|
|
546
|
+
stagingBranch: draft.branches.staging.value,
|
|
547
|
+
});
|
|
548
|
+
atomicWriteFile(outputPath, content);
|
|
549
|
+
celebrate(c, { written: true, todoCount: 0 });
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (flags.includes("--manual")) {
|
|
554
|
+
// Manual escape hatch: plain prompts, detection values as defaults.
|
|
555
|
+
const { draft, description } = await read(c);
|
|
556
|
+
const v = {
|
|
557
|
+
owner: await input("GitHub owner", draft.project.owner.value),
|
|
558
|
+
repo: await input("Repository", draft.project.repo.value),
|
|
559
|
+
name: await input("Project name", draft.project.name.value),
|
|
560
|
+
description: await input("Description", description.value),
|
|
561
|
+
root: await input("Repo root", draft.paths.root.value),
|
|
562
|
+
worktreeBase: await input("Worktree base", draft.paths.worktreeBase.value),
|
|
563
|
+
defaultBranch: await input("Default branch", draft.branches.default.value),
|
|
564
|
+
stagingBranch: await input("Staging branch", draft.branches.staging.value),
|
|
565
|
+
};
|
|
566
|
+
const backup = backupExisting(outputPath);
|
|
567
|
+
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
568
|
+
const lowKeys = manualLowConfidenceKeys(draft, description, v);
|
|
569
|
+
const { todoCount } = writeForgeYaml(v, lowKeys, outputPath);
|
|
570
|
+
celebrate(c, { written: true, todoCount });
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
const { draft, description } = await read(c);
|
|
574
|
+
const reviewed = await review(c, draft, description);
|
|
575
|
+
celebrate(c, reviewed);
|
|
576
|
+
return reviewed.aborted ? 1 : 0;
|
|
1247
577
|
}
|
|
1248
578
|
|
|
1249
579
|
async function uninstall() {
|
|
@@ -1254,6 +584,22 @@ async function uninstall() {
|
|
|
1254
584
|
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
1255
585
|
let removed = 0;
|
|
1256
586
|
|
|
587
|
+
// Ownership manifest for copy-installed commands (Windows without Developer
|
|
588
|
+
// Mode, or a symlink→copy fallback from a previous run). Loaded up front:
|
|
589
|
+
// manifest-tracked copies are removed via the manifest loop below; the
|
|
590
|
+
// content-match branch in the main loop only covers copies written by older
|
|
591
|
+
// installs that predate the manifest.
|
|
592
|
+
let manifest = null;
|
|
593
|
+
try {
|
|
594
|
+
manifest = JSON.parse(await readFile(MANIFEST_PATH, "utf-8"));
|
|
595
|
+
} catch {
|
|
596
|
+
manifest = null;
|
|
597
|
+
}
|
|
598
|
+
const manifestFiles =
|
|
599
|
+
manifest && manifest.files && typeof manifest.files === "object"
|
|
600
|
+
? manifest.files
|
|
601
|
+
: {};
|
|
602
|
+
|
|
1257
603
|
for (const file of files) {
|
|
1258
604
|
const rel = relative(COMMANDS_DIR, file);
|
|
1259
605
|
const target = join(TARGET_DIR, rel);
|
|
@@ -1263,26 +609,65 @@ async function uninstall() {
|
|
|
1263
609
|
if (stats.isSymbolicLink()) {
|
|
1264
610
|
const current = await readlink(target);
|
|
1265
611
|
if (current === file) {
|
|
1266
|
-
const { unlink } = await import("fs/promises");
|
|
1267
612
|
await unlink(target);
|
|
1268
613
|
console.log(` ${RED}Removed${RESET}: ${rel}`);
|
|
1269
614
|
removed++;
|
|
1270
615
|
}
|
|
1271
616
|
} else if (
|
|
617
|
+
stats.isFile() &&
|
|
618
|
+
!manifestFiles[rel] &&
|
|
1272
619
|
readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
|
|
1273
620
|
) {
|
|
1274
|
-
// Regular file installed by
|
|
1275
|
-
|
|
621
|
+
// Regular file installed by an older copy-mode build (no manifest
|
|
622
|
+
// entry) — content matches the source, so it is ours to remove.
|
|
1276
623
|
await unlink(target);
|
|
1277
624
|
console.log(` ${RED}Removed${RESET}: ${rel}`);
|
|
1278
625
|
removed++;
|
|
1279
626
|
}
|
|
1280
627
|
} catch (err) {
|
|
1281
|
-
if (err.code !== "ENOENT")
|
|
628
|
+
if (err.code !== "ENOENT") {
|
|
629
|
+
console.error(
|
|
630
|
+
` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
|
|
631
|
+
);
|
|
632
|
+
throw err;
|
|
633
|
+
}
|
|
1282
634
|
// Doesn't exist — nothing to do
|
|
1283
635
|
}
|
|
1284
636
|
}
|
|
1285
637
|
|
|
638
|
+
// Remove copy-installed commands tracked in the ownership manifest. These
|
|
639
|
+
// are regular files, not symlinks, so the symlink branch above never touches
|
|
640
|
+
// them — the manifest is the only record of ForgeDock's ownership over them.
|
|
641
|
+
const manifestRels = Object.keys(manifestFiles);
|
|
642
|
+
for (const rel of manifestRels) {
|
|
643
|
+
const target = join(TARGET_DIR, rel);
|
|
644
|
+
try {
|
|
645
|
+
const stats = await lstat(target);
|
|
646
|
+
if (stats.isFile() && !stats.isSymbolicLink()) {
|
|
647
|
+
await unlink(target);
|
|
648
|
+
console.log(` ${RED}Removed${RESET}: ${rel} ${YELLOW}(copied)${RESET}`);
|
|
649
|
+
removed++;
|
|
650
|
+
}
|
|
651
|
+
} catch (err) {
|
|
652
|
+
if (err.code !== "ENOENT") {
|
|
653
|
+
console.error(
|
|
654
|
+
` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
|
|
655
|
+
);
|
|
656
|
+
throw err;
|
|
657
|
+
}
|
|
658
|
+
// Already gone — nothing to do
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (manifest && manifestRels.length > 0) {
|
|
662
|
+
manifest.files = {};
|
|
663
|
+
try {
|
|
664
|
+
await mkdir(dirname(MANIFEST_PATH), { recursive: true });
|
|
665
|
+
await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
666
|
+
} catch {
|
|
667
|
+
// Best-effort — a failed manifest write is non-fatal
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
1286
671
|
console.log("");
|
|
1287
672
|
console.log(`Done. Removed: ${removed} commands.`);
|
|
1288
673
|
console.log("");
|
|
@@ -1297,7 +682,6 @@ async function uninstall() {
|
|
|
1297
682
|
|
|
1298
683
|
try {
|
|
1299
684
|
const stats = await lstat(target);
|
|
1300
|
-
const { unlink } = await import("fs/promises");
|
|
1301
685
|
if (stats.isSymbolicLink()) {
|
|
1302
686
|
const current = await readlink(target);
|
|
1303
687
|
if (current === file) {
|
|
@@ -1325,13 +709,16 @@ async function uninstall() {
|
|
|
1325
709
|
console.log("");
|
|
1326
710
|
}
|
|
1327
711
|
|
|
1328
|
-
// Remove the SessionStart hook from ~/.claude/settings.json
|
|
1329
|
-
|
|
712
|
+
// Remove the SessionStart hook from ~/.claude/settings.json via the
|
|
713
|
+
// hardened settings-hook module (never writes through malformed JSON).
|
|
714
|
+
const { status: hookRemoveResult } = removeSessionStartHook(
|
|
715
|
+
join(HOME, ".claude", "settings.json"),
|
|
716
|
+
);
|
|
1330
717
|
if (hookRemoveResult === "removed") {
|
|
1331
718
|
console.log(
|
|
1332
719
|
` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}~/.claude/settings.json${RESET}`,
|
|
1333
720
|
);
|
|
1334
|
-
} else if (hookRemoveResult === "
|
|
721
|
+
} else if (hookRemoveResult === "absent") {
|
|
1335
722
|
console.log(
|
|
1336
723
|
` ✔ SessionStart hook already absent from ~/.claude/settings.json`,
|
|
1337
724
|
);
|
|
@@ -1342,7 +729,9 @@ async function uninstall() {
|
|
|
1342
729
|
}
|
|
1343
730
|
console.log("");
|
|
1344
731
|
|
|
1345
|
-
// Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present)
|
|
732
|
+
// Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present).
|
|
733
|
+
// The journey install no longer writes these, but installs from older builds
|
|
734
|
+
// did — uninstall stays responsible for cleaning them up.
|
|
1346
735
|
const uninstallCwd = process.cwd();
|
|
1347
736
|
const claudeMdPath = join(uninstallCwd, "CLAUDE.md");
|
|
1348
737
|
const agentsMdPath = join(uninstallCwd, "AGENTS.md");
|
|
@@ -1368,7 +757,8 @@ async function uninstall() {
|
|
|
1368
757
|
}
|
|
1369
758
|
console.log("");
|
|
1370
759
|
|
|
1371
|
-
// Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc)
|
|
760
|
+
// Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc) — written
|
|
761
|
+
// by installs from older builds; harmless no-op when absent.
|
|
1372
762
|
for (const profileName of [".bashrc", ".zshrc"]) {
|
|
1373
763
|
const profilePath = join(HOME, profileName);
|
|
1374
764
|
const profileResult = removeForgeHomeFromProfile(profilePath);
|
|
@@ -1406,9 +796,6 @@ async function uninstall() {
|
|
|
1406
796
|
|
|
1407
797
|
// Clean up registry nudgeSeen entry for cwd (written by the session-start hook)
|
|
1408
798
|
try {
|
|
1409
|
-
const { clearNudgeSeen } = await import(
|
|
1410
|
-
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1411
|
-
);
|
|
1412
799
|
await clearNudgeSeen(uninstallCwd);
|
|
1413
800
|
console.log(
|
|
1414
801
|
` ${GREEN}✔${RESET} Cleaned registry entries for ${CYAN}${uninstallCwd}${RESET}`,
|
|
@@ -1426,6 +813,20 @@ async function uninstall() {
|
|
|
1426
813
|
console.log("");
|
|
1427
814
|
}
|
|
1428
815
|
|
|
816
|
+
// Reinstall = relink commands + re-register the hook. Never the full
|
|
817
|
+
// journey: update must not reach read/review, which could overwrite a
|
|
818
|
+
// curated forge.yaml (and re-runs AI enrichment on every update). Also
|
|
819
|
+
// idempotent, so it's the repair path for a configured repo whose
|
|
820
|
+
// symlinks or hook registration got out of sync.
|
|
821
|
+
async function relinkAndHint() {
|
|
822
|
+
const c = ctx();
|
|
823
|
+
await forge(c);
|
|
824
|
+
if (!existsSync(join(c.cwd, "forge.yaml"))) {
|
|
825
|
+
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
826
|
+
c.stdout.write(" " + dim("Configure this repo: npx forgedock init") + "\n");
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
1429
830
|
async function update() {
|
|
1430
831
|
console.log("");
|
|
1431
832
|
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
@@ -1461,8 +862,8 @@ async function update() {
|
|
|
1461
862
|
console.log(` Already up to date.`);
|
|
1462
863
|
} else {
|
|
1463
864
|
console.log(` ${GREEN}Updated to latest.${RESET}`);
|
|
1464
|
-
await install();
|
|
1465
865
|
}
|
|
866
|
+
await relinkAndHint();
|
|
1466
867
|
} catch (err) {
|
|
1467
868
|
console.log(
|
|
1468
869
|
` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
|
|
@@ -1472,558 +873,162 @@ async function update() {
|
|
|
1472
873
|
console.log(
|
|
1473
874
|
` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
|
|
1474
875
|
);
|
|
876
|
+
await relinkAndHint();
|
|
1475
877
|
}
|
|
1476
878
|
console.log("");
|
|
1477
879
|
}
|
|
1478
880
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
// "Generating forge.yaml" step label as visual feedback. (#812)
|
|
1483
|
-
const log = fromInstall ? () => {} : console.log;
|
|
1484
|
-
log("");
|
|
1485
|
-
log(`${BOLD}ForgeDock${RESET} — Generating forge.yaml`);
|
|
1486
|
-
log("");
|
|
1487
|
-
|
|
1488
|
-
const cwd = process.cwd();
|
|
1489
|
-
const worktreeBase = join(cwd, ".claude", "worktrees");
|
|
1490
|
-
const outputPath = join(cwd, "forge.yaml");
|
|
1491
|
-
|
|
1492
|
-
// --- Detect git remote URL and parse owner/repo ---
|
|
1493
|
-
let owner = "your-github-org";
|
|
1494
|
-
let repo = "your-repo-name";
|
|
1495
|
-
let remoteDetected = false;
|
|
1496
|
-
|
|
1497
|
-
try {
|
|
1498
|
-
const remoteUrl = execSync("git remote get-url origin", {
|
|
1499
|
-
cwd,
|
|
1500
|
-
encoding: "utf-8",
|
|
1501
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1502
|
-
}).trim();
|
|
1503
|
-
|
|
1504
|
-
// SSH: git@github.com:owner/repo.git
|
|
1505
|
-
const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
1506
|
-
// HTTPS: https://github.com/owner/repo.git
|
|
1507
|
-
const httpsMatch = remoteUrl.match(
|
|
1508
|
-
/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
|
|
1509
|
-
);
|
|
1510
|
-
|
|
1511
|
-
if (sshMatch) {
|
|
1512
|
-
owner = sshMatch[1];
|
|
1513
|
-
repo = sshMatch[2];
|
|
1514
|
-
remoteDetected = true;
|
|
1515
|
-
} else if (httpsMatch) {
|
|
1516
|
-
owner = httpsMatch[1];
|
|
1517
|
-
repo = httpsMatch[2];
|
|
1518
|
-
remoteDetected = true;
|
|
1519
|
-
} else {
|
|
1520
|
-
log(
|
|
1521
|
-
` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
|
|
1522
|
-
);
|
|
1523
|
-
}
|
|
1524
|
-
} catch {
|
|
1525
|
-
log(
|
|
1526
|
-
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
|
|
1527
|
-
);
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
if (remoteDetected) {
|
|
1531
|
-
log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
// --- Detect default branch ---
|
|
1535
|
-
let defaultBranch = "main";
|
|
1536
|
-
try {
|
|
1537
|
-
const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
1538
|
-
cwd,
|
|
1539
|
-
encoding: "utf-8",
|
|
1540
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1541
|
-
}).trim();
|
|
1542
|
-
// refs/remotes/origin/main → main
|
|
1543
|
-
defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
|
|
1544
|
-
log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
|
|
1545
|
-
} catch {
|
|
1546
|
-
// Fallback: try git rev-parse
|
|
1547
|
-
try {
|
|
1548
|
-
defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1549
|
-
cwd,
|
|
1550
|
-
encoding: "utf-8",
|
|
1551
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1552
|
-
}).trim();
|
|
1553
|
-
if (defaultBranch === "HEAD") defaultBranch = "main";
|
|
1554
|
-
log(
|
|
1555
|
-
` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
|
|
1556
|
-
);
|
|
1557
|
-
} catch {
|
|
1558
|
-
log(
|
|
1559
|
-
` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
|
|
1560
|
-
);
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
// --- Detect staging branch ---
|
|
1565
|
-
let stagingBranch = "staging";
|
|
1566
|
-
try {
|
|
1567
|
-
const remoteBranches = execSync("git branch -r", {
|
|
1568
|
-
cwd,
|
|
1569
|
-
encoding: "utf-8",
|
|
1570
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1571
|
-
});
|
|
1572
|
-
if (remoteBranches.includes("origin/staging")) {
|
|
1573
|
-
stagingBranch = "staging";
|
|
1574
|
-
log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
|
|
1575
|
-
} else {
|
|
1576
|
-
stagingBranch = defaultBranch;
|
|
1577
|
-
log(
|
|
1578
|
-
` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
|
|
1579
|
-
);
|
|
1580
|
-
}
|
|
1581
|
-
} catch {
|
|
1582
|
-
log(
|
|
1583
|
-
` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
|
|
1584
|
-
);
|
|
1585
|
-
stagingBranch = defaultBranch;
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
// --- Auto-detect project description from README.md ---
|
|
1589
|
-
let description = "";
|
|
1590
|
-
try {
|
|
1591
|
-
const readmePath = join(cwd, "README.md");
|
|
1592
|
-
if (existsSync(readmePath)) {
|
|
1593
|
-
const readmeContent = readFileSync(readmePath, "utf-8").slice(0, 2048);
|
|
1594
|
-
// Skip the first heading line (# Title), grab the first non-empty paragraph
|
|
1595
|
-
const lines = readmeContent.split("\n");
|
|
1596
|
-
let inFirstParagraph = false;
|
|
1597
|
-
const paragraphLines = [];
|
|
1598
|
-
for (const line of lines) {
|
|
1599
|
-
// Skip heading lines at the top
|
|
1600
|
-
if (!inFirstParagraph && line.match(/^#/)) continue;
|
|
1601
|
-
// Skip blank lines before paragraph starts
|
|
1602
|
-
if (!inFirstParagraph && line.trim() === "") continue;
|
|
1603
|
-
// Skip lines that are just badges, HTML, code fences, horizontal rules, or tables
|
|
1604
|
-
if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
|
|
1605
|
-
if (!inFirstParagraph && line.match(/^---/)) continue;
|
|
1606
|
-
// Start collecting
|
|
1607
|
-
if (!inFirstParagraph) {
|
|
1608
|
-
inFirstParagraph = true;
|
|
1609
|
-
}
|
|
1610
|
-
// Stop at blank line (end of paragraph)
|
|
1611
|
-
if (line.trim() === "") break;
|
|
1612
|
-
paragraphLines.push(line.trim());
|
|
1613
|
-
}
|
|
1614
|
-
if (paragraphLines.length > 0) {
|
|
1615
|
-
// Flatten to single line, strip markdown links/bold/inline code
|
|
1616
|
-
description = paragraphLines
|
|
1617
|
-
.join(" ")
|
|
1618
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
1619
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
1620
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
1621
|
-
.slice(0, 200)
|
|
1622
|
-
.trim();
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
} catch {
|
|
1626
|
-
// Best-effort only — silently fall back to empty description
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
if (description) {
|
|
1630
|
-
log(
|
|
1631
|
-
` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from README.md)`,
|
|
1632
|
-
);
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
// --- Auto-detect project description from CLAUDE.md (fallback) ---
|
|
1636
|
-
if (!description) {
|
|
1637
|
-
try {
|
|
1638
|
-
const claudePath = join(cwd, "CLAUDE.md");
|
|
1639
|
-
if (existsSync(claudePath)) {
|
|
1640
|
-
const claudeContent = readFileSync(claudePath, "utf-8").slice(0, 2048);
|
|
1641
|
-
const lines = claudeContent.split("\n");
|
|
1642
|
-
let inFirstParagraph = false;
|
|
1643
|
-
const paragraphLines = [];
|
|
1644
|
-
for (const line of lines) {
|
|
1645
|
-
// Skip heading lines at the top
|
|
1646
|
-
if (!inFirstParagraph && line.match(/^#/)) continue;
|
|
1647
|
-
// Skip blank lines before paragraph starts
|
|
1648
|
-
if (!inFirstParagraph && line.trim() === "") continue;
|
|
1649
|
-
// Skip lines that are just badges, HTML comments, code fences, horizontal rules, or tables
|
|
1650
|
-
if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
|
|
1651
|
-
if (!inFirstParagraph && line.match(/^---/)) continue;
|
|
1652
|
-
// Start collecting
|
|
1653
|
-
if (!inFirstParagraph) {
|
|
1654
|
-
inFirstParagraph = true;
|
|
1655
|
-
}
|
|
1656
|
-
// Stop at blank line (end of paragraph)
|
|
1657
|
-
if (line.trim() === "") break;
|
|
1658
|
-
paragraphLines.push(line.trim());
|
|
1659
|
-
}
|
|
1660
|
-
if (paragraphLines.length > 0) {
|
|
1661
|
-
// Flatten to single line, strip markdown links/bold/inline code
|
|
1662
|
-
description = paragraphLines
|
|
1663
|
-
.join(" ")
|
|
1664
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
1665
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
1666
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
1667
|
-
.slice(0, 200)
|
|
1668
|
-
.trim();
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
} catch {
|
|
1672
|
-
// Best-effort only — silently fall back to empty description
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
if (description) {
|
|
1676
|
-
log(
|
|
1677
|
-
` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from CLAUDE.md)`,
|
|
1678
|
-
);
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
// --- Handle existing forge.yaml ---
|
|
1683
|
-
if (existsSync(outputPath)) {
|
|
1684
|
-
const baseBak = join(cwd, "forge.yaml.bak");
|
|
1685
|
-
const backupPath = existsSync(baseBak)
|
|
1686
|
-
? join(
|
|
1687
|
-
cwd,
|
|
1688
|
-
`forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
|
|
1689
|
-
)
|
|
1690
|
-
: baseBak;
|
|
1691
|
-
const backupName = backupPath.split("/").pop();
|
|
1692
|
-
renameSync(outputPath, backupPath);
|
|
1693
|
-
log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
// --- Generate forge.yaml content ---
|
|
1697
|
-
const projectName = repo
|
|
1698
|
-
.split(/[-_]/)
|
|
1699
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1700
|
-
.join(" ");
|
|
1701
|
-
|
|
1702
|
-
// Pre-escape values for embedding in YAML double-quoted strings.
|
|
1703
|
-
// Backslash must be escaped first to avoid double-escaping.
|
|
1704
|
-
const safeOwner = owner.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1705
|
-
const safeRepo = repo.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1706
|
-
const safeProjectName = projectName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1707
|
-
const safeDefaultBranch = defaultBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1708
|
-
const safeStagingBranch = stagingBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1709
|
-
|
|
1710
|
-
const content = `# forge.yaml — ForgeDock Configuration
|
|
1711
|
-
#
|
|
1712
|
-
# Auto-generated by: npx forgedock init
|
|
1713
|
-
# Edit this file with your project details.
|
|
1714
|
-
#
|
|
1715
|
-
# Required sections: project, paths, branches
|
|
1716
|
-
# Optional sections: repos, project_board, services, review, verification
|
|
1717
|
-
#
|
|
1718
|
-
# See docs/CONFIG.md for full reference.
|
|
1719
|
-
|
|
1720
|
-
# =============================================================================
|
|
1721
|
-
# PROJECT (REQUIRED)
|
|
1722
|
-
# =============================================================================
|
|
1723
|
-
|
|
1724
|
-
project:
|
|
1725
|
-
name: "${safeProjectName}"
|
|
1726
|
-
owner: "${safeOwner}"
|
|
1727
|
-
repo: "${safeRepo}"
|
|
1728
|
-
description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1729
|
-
|
|
1730
|
-
# =============================================================================
|
|
1731
|
-
# PATHS (REQUIRED)
|
|
1732
|
-
# =============================================================================
|
|
1733
|
-
|
|
1734
|
-
paths:
|
|
1735
|
-
root: "${cwd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1736
|
-
worktree_base: "${worktreeBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1737
|
-
|
|
1738
|
-
# =============================================================================
|
|
1739
|
-
# BRANCHES (REQUIRED)
|
|
1740
|
-
# =============================================================================
|
|
1741
|
-
|
|
1742
|
-
branches:
|
|
1743
|
-
default: "${safeDefaultBranch}"
|
|
1744
|
-
staging: "${safeStagingBranch}"
|
|
1745
|
-
feature_pattern: "milestone/{slug}"
|
|
1746
|
-
|
|
1747
|
-
# =============================================================================
|
|
1748
|
-
# REPOS (OPTIONAL)
|
|
1749
|
-
# Multi-repo configuration. Remove the # to enable.
|
|
1750
|
-
# =============================================================================
|
|
1751
|
-
|
|
1752
|
-
# repos:
|
|
1753
|
-
# default:
|
|
1754
|
-
# repo: "${safeOwner}/${safeRepo}"
|
|
1755
|
-
# staging_branch: "${safeStagingBranch}"
|
|
1756
|
-
# satellites:
|
|
1757
|
-
# - prefix: "mcp"
|
|
1758
|
-
# repo: "${safeOwner}/your-satellite-repo"
|
|
1759
|
-
# staging_branch: "main"
|
|
1760
|
-
# local_path: "${join(cwd, "..", "your-satellite-repo").replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1761
|
-
|
|
1762
|
-
# =============================================================================
|
|
1763
|
-
# PROJECT BOARD (OPTIONAL)
|
|
1764
|
-
# GitHub Projects v2 integration.
|
|
1765
|
-
# To find IDs: gh project list --owner ${safeOwner}
|
|
1766
|
-
# =============================================================================
|
|
1767
|
-
|
|
1768
|
-
# project_board:
|
|
1769
|
-
# owner: "${safeOwner}"
|
|
1770
|
-
# project_number: 1
|
|
1771
|
-
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
1772
|
-
# field_ids:
|
|
1773
|
-
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1774
|
-
# lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1775
|
-
# component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1776
|
-
# priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1777
|
-
# workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1778
|
-
|
|
1779
|
-
# =============================================================================
|
|
1780
|
-
# REVIEW (OPTIONAL)
|
|
1781
|
-
# Context injected into review agent prompts.
|
|
1782
|
-
# =============================================================================
|
|
1783
|
-
|
|
1784
|
-
# review:
|
|
1785
|
-
# tech_stack: "Node.js, TypeScript, PostgreSQL"
|
|
1786
|
-
# context: |
|
|
1787
|
-
# Describe your repo structure and any unusual conventions here.
|
|
1788
|
-
|
|
1789
|
-
# =============================================================================
|
|
1790
|
-
# VERIFICATION (OPTIONAL)
|
|
1791
|
-
# Health-check patterns for quality gate and validate commands.
|
|
1792
|
-
# =============================================================================
|
|
1793
|
-
|
|
1794
|
-
# verification:
|
|
1795
|
-
# health_endpoint: "https://api.${safeRepo}.io/health"
|
|
1796
|
-
# health_patterns:
|
|
1797
|
-
# - '"status": "ok"'
|
|
1798
|
-
#
|
|
1799
|
-
# # Integration test suites run by /test-gate against the provisioned cluster.
|
|
1800
|
-
# # Each entry specifies a logical cluster (matched to test_services), the shell
|
|
1801
|
-
# # command to execute, and the working directory (relative to project root).
|
|
1802
|
-
# # Used by: test-gate (Phase 3 Provision + Phase 4 Fan out)
|
|
1803
|
-
# #
|
|
1804
|
-
# # integration_tests:
|
|
1805
|
-
# # - cluster: "api"
|
|
1806
|
-
# # command: "pytest tests/integration/ -q --tb=short"
|
|
1807
|
-
# # working_dir: "."
|
|
1808
|
-
#
|
|
1809
|
-
# # Maps logical cluster names (from integration_tests) to running container
|
|
1810
|
-
# # names. /test-gate checks these containers are live before running tests.
|
|
1811
|
-
# # Omit entries for clusters that do not use Docker.
|
|
1812
|
-
# # Used by: test-gate (Phase 3 Provision)
|
|
1813
|
-
# #
|
|
1814
|
-
# # test_services:
|
|
1815
|
-
# # api: "${safeRepo}-api-blue"
|
|
1816
|
-
# # worker: "${safeRepo}-worker-blue"
|
|
1817
|
-
#
|
|
1818
|
-
# # Controls /test-gate posture at the staging-to-main deploy boundary.
|
|
1819
|
-
# # posture: blocking (default) or advisory.
|
|
1820
|
-
# # override_phrase: exact comment text an operator posts to bypass a BLOCK.
|
|
1821
|
-
# # Used by: test-gate (Phase 7 Verdict), review-pr-staging (Phase 6.5)
|
|
1822
|
-
# #
|
|
1823
|
-
# # test_gate:
|
|
1824
|
-
# # posture: "blocking"
|
|
1825
|
-
# # override_phrase: "OVERRIDE: shipping with test failures \u2014"
|
|
1826
|
-
`;
|
|
1827
|
-
|
|
1828
|
-
atomicWriteFile(outputPath, content);
|
|
1829
|
-
|
|
1830
|
-
log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1831
|
-
log("");
|
|
1832
|
-
|
|
1833
|
-
if (fromInstall) {
|
|
1834
|
-
// Called automatically from install() — only print what still needs attention.
|
|
1835
|
-
// NOTE: log() is a no-op when fromInstall=true (stdout suppressed to avoid
|
|
1836
|
-
// interleaving with TUI spinner on stderr). If git remote was not detected,
|
|
1837
|
-
// the user will see the incomplete placeholder values in forge.yaml directly. (#812)
|
|
1838
|
-
if (!remoteDetected) {
|
|
1839
|
-
log(`${YELLOW}Action required:${RESET}`);
|
|
1840
|
-
log(
|
|
1841
|
-
` Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET} (git remote not detected)`,
|
|
1842
|
-
);
|
|
1843
|
-
log("");
|
|
1844
|
-
}
|
|
1845
|
-
} else {
|
|
1846
|
-
// Called explicitly via `npx forgedock init` — print full next steps
|
|
1847
|
-
log(`${BOLD}Next steps:${RESET}`);
|
|
1848
|
-
if (!remoteDetected) {
|
|
1849
|
-
log(
|
|
1850
|
-
` 1. Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET}`,
|
|
1851
|
-
);
|
|
1852
|
-
} else {
|
|
1853
|
-
log(
|
|
1854
|
-
` 1. Review ${CYAN}forge.yaml${RESET} — all required fields were auto-detected`,
|
|
1855
|
-
);
|
|
1856
|
-
}
|
|
1857
|
-
log(
|
|
1858
|
-
` 2. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
|
|
1859
|
-
);
|
|
1860
|
-
log(
|
|
1861
|
-
` 3. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
|
|
1862
|
-
);
|
|
1863
|
-
log("");
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
// Labels Bootstrap — idempotently create/update all ForgeDock-managed labels
|
|
883
|
+
// ---------------------------------------------------------------------------
|
|
1866
884
|
|
|
1867
885
|
/**
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
886
|
+
* Resolve the target repo for the labels command.
|
|
887
|
+
*
|
|
888
|
+
* Priority order:
|
|
889
|
+
* 1. --repo <owner/repo> passed on the CLI
|
|
890
|
+
* 2. forge.yaml → project.owner + project.repo in the current working directory
|
|
891
|
+
* 3. Returns null if neither is available (caller prints an error)
|
|
1870
892
|
*
|
|
1871
|
-
* @param {string
|
|
893
|
+
* @param {string[]} subArgs - CLI args after "labels [setup]"
|
|
894
|
+
* @returns {string|null} "owner/repo" string or null
|
|
1872
895
|
*/
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
({ setOptOut } = await import(
|
|
1879
|
-
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1880
|
-
));
|
|
1881
|
-
} catch (err) {
|
|
1882
|
-
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
1883
|
-
process.exit(1);
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
// Remove from opt-out registry
|
|
1887
|
-
try {
|
|
1888
|
-
await setOptOut(targetDir, false);
|
|
1889
|
-
} catch (err) {
|
|
1890
|
-
console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
|
|
1891
|
-
process.exit(1);
|
|
896
|
+
function resolveLabelsRepo(subArgs) {
|
|
897
|
+
// 1. Explicit --repo flag
|
|
898
|
+
const repoFlagIdx = subArgs.indexOf("--repo");
|
|
899
|
+
if (repoFlagIdx !== -1 && subArgs[repoFlagIdx + 1]) {
|
|
900
|
+
return subArgs[repoFlagIdx + 1];
|
|
1892
901
|
}
|
|
1893
902
|
|
|
1894
|
-
//
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
if (!existsSync(markerPath)) {
|
|
903
|
+
// 2. forge.yaml in cwd
|
|
904
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
905
|
+
if (existsSync(forgeYamlPath)) {
|
|
1898
906
|
try {
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
907
|
+
const raw = readFileSync(forgeYamlPath, "utf-8");
|
|
908
|
+
const ownerMatch = raw.match(/^\s*owner:\s*["']?([^\s"'#]+)["']?/m);
|
|
909
|
+
const repoMatch = raw.match(/^\s*repo:\s*["']?([^\s"'#]+)["']?/m);
|
|
910
|
+
if (ownerMatch && repoMatch) {
|
|
911
|
+
return `${ownerMatch[1]}/${repoMatch[1]}`;
|
|
912
|
+
}
|
|
913
|
+
} catch {
|
|
914
|
+
// fall through
|
|
1903
915
|
}
|
|
1904
|
-
} else {
|
|
1905
|
-
console.log(` ✔ .forgedock marker already present in ${targetDir}`);
|
|
1906
916
|
}
|
|
1907
917
|
|
|
1908
|
-
|
|
1909
|
-
console.log(` Removed from opt-out registry. Run ${CYAN}npx forgedock init${RESET} to generate forge.yaml.`);
|
|
1910
|
-
console.log("");
|
|
918
|
+
return null;
|
|
1911
919
|
}
|
|
1912
920
|
|
|
1913
921
|
/**
|
|
1914
|
-
*
|
|
1915
|
-
* The session-start hook will be silent for this directory in future sessions.
|
|
922
|
+
* Idempotently create or update all ForgeDock-managed labels on a GitHub repo.
|
|
1916
923
|
*
|
|
1917
|
-
*
|
|
924
|
+
* Reads the canonical manifest from bin/labels.json (co-located with this
|
|
925
|
+
* script). For each label entry, calls:
|
|
926
|
+
*
|
|
927
|
+
* gh label create <name> --color <hex> --description <desc> --force --repo <repo>
|
|
928
|
+
*
|
|
929
|
+
* --force makes the call idempotent: it creates the label if absent, or updates
|
|
930
|
+
* its color and description if it already exists. Safe to re-run at any time.
|
|
931
|
+
*
|
|
932
|
+
* @param {string} repo - "owner/repo" string
|
|
933
|
+
* @returns {{ created: number, failed: string[] }}
|
|
1918
934
|
*/
|
|
1919
|
-
async function
|
|
1920
|
-
const
|
|
935
|
+
async function labelsSetup(repo) {
|
|
936
|
+
const manifestPath = join(__dirname, "labels.json");
|
|
1921
937
|
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
({ setOptOut } = await import(
|
|
1925
|
-
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1926
|
-
));
|
|
1927
|
-
} catch (err) {
|
|
1928
|
-
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
938
|
+
if (!existsSync(manifestPath)) {
|
|
939
|
+
console.log(`${RED}Label manifest not found: ${manifestPath}${RESET}`);
|
|
1929
940
|
process.exit(1);
|
|
1930
941
|
}
|
|
1931
942
|
|
|
943
|
+
/** @type {Array<{name: string, color: string, description: string}>} */
|
|
944
|
+
let labels;
|
|
1932
945
|
try {
|
|
1933
|
-
|
|
946
|
+
labels = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
1934
947
|
} catch (err) {
|
|
1935
|
-
console.
|
|
948
|
+
console.log(`${RED}Failed to parse labels.json: ${err.message}${RESET}`);
|
|
1936
949
|
process.exit(1);
|
|
1937
950
|
}
|
|
1938
951
|
|
|
1939
|
-
console.log(` ${GREEN}✔${RESET} ForgeDock ${YELLOW}disabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
|
|
1940
|
-
console.log(` Added to opt-out registry. The session-start hook will be silent here.`);
|
|
1941
|
-
console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
1942
952
|
console.log("");
|
|
1943
|
-
}
|
|
953
|
+
console.log(`${BOLD}ForgeDock Label Bootstrap${RESET}`);
|
|
954
|
+
console.log(` Repository: ${cyan(repo)}`);
|
|
955
|
+
console.log(` Labels: ${labels.length} managed labels`);
|
|
956
|
+
console.log("");
|
|
1944
957
|
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
* with relevant detail about what markers or registry entries were found.
|
|
1949
|
-
*
|
|
1950
|
-
* @param {string} [dir] - Directory to inspect (default: process.cwd()).
|
|
1951
|
-
*/
|
|
1952
|
-
async function status(dir) {
|
|
1953
|
-
const targetDir = resolve(dir || process.cwd());
|
|
958
|
+
const bar = createProgressBar(labels.length, {
|
|
959
|
+
label: " Bootstrapping labels...",
|
|
960
|
+
});
|
|
1954
961
|
|
|
1955
|
-
let
|
|
1956
|
-
|
|
1957
|
-
({ resolveState } = await import(
|
|
1958
|
-
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1959
|
-
));
|
|
1960
|
-
} catch (err) {
|
|
1961
|
-
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
1962
|
-
process.exit(1);
|
|
1963
|
-
}
|
|
962
|
+
let created = 0;
|
|
963
|
+
const failed = [];
|
|
1964
964
|
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
965
|
+
for (const { name, color, description } of labels) {
|
|
966
|
+
bar.tick(1, dim(name));
|
|
967
|
+
try {
|
|
968
|
+
execFileSync(
|
|
969
|
+
"gh",
|
|
970
|
+
[
|
|
971
|
+
"label",
|
|
972
|
+
"create",
|
|
973
|
+
name,
|
|
974
|
+
"--color",
|
|
975
|
+
color,
|
|
976
|
+
"--description",
|
|
977
|
+
description,
|
|
978
|
+
"--force",
|
|
979
|
+
"--repo",
|
|
980
|
+
repo,
|
|
981
|
+
],
|
|
982
|
+
{ stdio: ["pipe", "pipe", "pipe"] },
|
|
983
|
+
);
|
|
984
|
+
created++;
|
|
985
|
+
} catch {
|
|
986
|
+
failed.push(name);
|
|
987
|
+
// Continue — don't abort the whole bootstrap for one failure
|
|
988
|
+
}
|
|
1971
989
|
}
|
|
1972
990
|
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
console.log(` Directory: ${CYAN}${targetDir}${RESET}`);
|
|
1979
|
-
console.log("");
|
|
991
|
+
bar.done(
|
|
992
|
+
failed.length === 0
|
|
993
|
+
? `${green("✔")} Bootstrapped ${created}/${labels.length} labels on ${cyan(repo)}`
|
|
994
|
+
: `${yellow("⚠")} Bootstrapped ${created}/${labels.length} labels — ${failed.length} failed`,
|
|
995
|
+
);
|
|
1980
996
|
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
case "managed-optedout":
|
|
1992
|
-
console.log(` State: ${YELLOW}managed-optedout${RESET}`);
|
|
1993
|
-
console.log(` ForgeDock is installed but opted out for this directory.`);
|
|
1994
|
-
if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
|
|
1995
|
-
if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
|
|
1996
|
-
console.log(` The session-start hook is silent here (opt-out registry entry present).`);
|
|
1997
|
-
console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
1998
|
-
break;
|
|
1999
|
-
|
|
2000
|
-
case "unmanaged":
|
|
2001
|
-
default:
|
|
2002
|
-
console.log(` State: ${RED}unmanaged${RESET}`);
|
|
2003
|
-
console.log(` ForgeDock is not active in this directory.`);
|
|
2004
|
-
console.log(` No forge.yaml or .forgedock marker found.`);
|
|
2005
|
-
console.log(` To enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
2006
|
-
console.log(` To set up: ${CYAN}npx forgedock init${RESET}`);
|
|
2007
|
-
break;
|
|
997
|
+
if (failed.length > 0) {
|
|
998
|
+
console.log("");
|
|
999
|
+
console.log(` ${RED}Failed labels:${RESET}`);
|
|
1000
|
+
for (const name of failed) {
|
|
1001
|
+
console.log(` ${dim("•")} ${name}`);
|
|
1002
|
+
}
|
|
1003
|
+
console.log(
|
|
1004
|
+
`\n Run ${cyan("gh auth status")} to verify GitHub authentication.`,
|
|
1005
|
+
);
|
|
2008
1006
|
}
|
|
1007
|
+
|
|
2009
1008
|
console.log("");
|
|
1009
|
+
return { created, failed };
|
|
2010
1010
|
}
|
|
2011
1011
|
|
|
2012
1012
|
/**
|
|
2013
1013
|
* Run installation health checks and report pass/fail for each.
|
|
2014
1014
|
*
|
|
2015
1015
|
* Checks (in order):
|
|
2016
|
-
* 1.
|
|
2017
|
-
* 2.
|
|
2018
|
-
* 3.
|
|
2019
|
-
* 4.
|
|
2020
|
-
* 5.
|
|
2021
|
-
* 6.
|
|
2022
|
-
*
|
|
2023
|
-
*
|
|
2024
|
-
*
|
|
1016
|
+
* 1. Command symlinks — TARGET_DIR entries point to correct COMMANDS_DIR files
|
|
1017
|
+
* 2. gh CLI installed and authenticated
|
|
1018
|
+
* 3. git configured (user.name + user.email)
|
|
1019
|
+
* 4. forge.yaml exists and has required keys
|
|
1020
|
+
* 5. SessionStart hook registered in ~/.claude/settings.json
|
|
1021
|
+
* 6. CLAUDE.md legacy managed block (cwd) — informational only; the
|
|
1022
|
+
* journey install never injects one, session context comes from the
|
|
1023
|
+
* SessionStart hook
|
|
1024
|
+
* 7. Required workflow labels exist on the GitHub repo (needs forge.yaml + gh auth)
|
|
1025
|
+
* 8. FORGE_HOME environment variable — informational only; not required
|
|
1026
|
+
* by the journey install
|
|
1027
|
+
* 9. Playwright MCP registered in ~/.claude/mcp_servers.json (advisory warn — required for /qa-sweep)
|
|
1028
|
+
* 10. yq installed (hard dependency for forge.yaml parsing)
|
|
1029
|
+
* 11. Claude Code installed and version compatible (advisory warn if not on PATH)
|
|
2025
1030
|
*
|
|
2026
|
-
*
|
|
1031
|
+
* Returns 0 if all checks pass (warnings allowed), 1 if any hard check fails.
|
|
2027
1032
|
*/
|
|
2028
1033
|
async function doctor() {
|
|
2029
1034
|
console.log("");
|
|
@@ -2073,27 +1078,11 @@ async function doctor() {
|
|
|
2073
1078
|
const brokenLinks = [];
|
|
2074
1079
|
|
|
2075
1080
|
try {
|
|
2076
|
-
// Collect
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
2082
|
-
} catch {
|
|
2083
|
-
return results;
|
|
2084
|
-
}
|
|
2085
|
-
for (const entry of entries) {
|
|
2086
|
-
const full = join(dir, entry.name);
|
|
2087
|
-
if (entry.isDirectory()) {
|
|
2088
|
-
results.push(...(await collectMd(full)));
|
|
2089
|
-
} else if (entry.name.endsWith(".md")) {
|
|
2090
|
-
results.push(full);
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
return results;
|
|
2094
|
-
};
|
|
2095
|
-
|
|
2096
|
-
const sourceFiles = await collectMd(COMMANDS_DIR);
|
|
1081
|
+
// Collect installable .md files from COMMANDS_DIR using the same tier filter
|
|
1082
|
+
// applied by forge() / findMarkdownFiles() — doctor() must validate the filtered
|
|
1083
|
+
// install surface, not the raw directory walk, or it would report false failures
|
|
1084
|
+
// for 'internal' specs that were deliberately excluded from the install.
|
|
1085
|
+
const sourceFiles = await findMarkdownFiles(COMMANDS_DIR);
|
|
2097
1086
|
|
|
2098
1087
|
for (const src of sourceFiles) {
|
|
2099
1088
|
const rel = relative(COMMANDS_DIR, src);
|
|
@@ -2109,13 +1098,27 @@ async function doctor() {
|
|
|
2109
1098
|
symlinkOk = false;
|
|
2110
1099
|
}
|
|
2111
1100
|
} else {
|
|
2112
|
-
// Regular file —
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
1101
|
+
// Regular file — copy-mode install (Windows without Developer Mode).
|
|
1102
|
+
// forge() falls back to copyFile() on EPERM/EACCES, so a regular
|
|
1103
|
+
// file is valid as long as its content matches the source. <!-- Added: forge#1174 -->
|
|
1104
|
+
try {
|
|
1105
|
+
const srcContent = readFileSync(src, "utf-8");
|
|
1106
|
+
const tgtContent = readFileSync(tgt, "utf-8");
|
|
1107
|
+
if (srcContent !== tgtContent) {
|
|
1108
|
+
broken++;
|
|
1109
|
+
brokenLinks.push(`${rel} (stale copy)`);
|
|
1110
|
+
symlinkOk = false;
|
|
1111
|
+
}
|
|
1112
|
+
// else: content matches — valid copy-mode install, not broken
|
|
1113
|
+
} catch {
|
|
1114
|
+
// Could not read one of the files — treat as broken.
|
|
1115
|
+
broken++;
|
|
1116
|
+
brokenLinks.push(`${rel} (unreadable)`);
|
|
1117
|
+
symlinkOk = false;
|
|
1118
|
+
}
|
|
2116
1119
|
}
|
|
2117
1120
|
} catch {
|
|
2118
|
-
//
|
|
1121
|
+
// File missing
|
|
2119
1122
|
broken++;
|
|
2120
1123
|
brokenLinks.push(`${rel} (missing)`);
|
|
2121
1124
|
symlinkOk = false;
|
|
@@ -2123,15 +1126,15 @@ async function doctor() {
|
|
|
2123
1126
|
}
|
|
2124
1127
|
|
|
2125
1128
|
if (symlinkOk) {
|
|
2126
|
-
pass("Command
|
|
1129
|
+
pass("Command files", `${checked} files installed`);
|
|
2127
1130
|
} else {
|
|
2128
1131
|
fail(
|
|
2129
|
-
"Command
|
|
1132
|
+
"Command files",
|
|
2130
1133
|
`Run: npx forgedock install (${broken}/${checked} broken: ${brokenLinks.slice(0, 3).join(", ")}${brokenLinks.length > 3 ? "…" : ""})`,
|
|
2131
1134
|
);
|
|
2132
1135
|
}
|
|
2133
1136
|
} catch (err) {
|
|
2134
|
-
fail("Command
|
|
1137
|
+
fail("Command files", `Could not read commands directory: ${err.message}`);
|
|
2135
1138
|
}
|
|
2136
1139
|
}
|
|
2137
1140
|
|
|
@@ -2274,13 +1277,18 @@ async function doctor() {
|
|
|
2274
1277
|
}
|
|
2275
1278
|
}
|
|
2276
1279
|
|
|
2277
|
-
// ── Check 6: CLAUDE.md
|
|
1280
|
+
// ── Check 6: CLAUDE.md legacy managed block (cwd, informational) ──────────
|
|
1281
|
+
// The journey-based install never injects a CLAUDE.md block — session
|
|
1282
|
+
// context comes from the SessionStart hook (see Check 5). A block's absence
|
|
1283
|
+
// is therefore normal and PASSes. Its presence means a legacy block from an
|
|
1284
|
+
// older install is still around; that's informational, not a failure —
|
|
1285
|
+
// `npx forgedock uninstall` removes it.
|
|
2278
1286
|
{
|
|
2279
1287
|
const claudeMdPath = join(process.cwd(), "CLAUDE.md");
|
|
2280
1288
|
if (!existsSync(claudeMdPath)) {
|
|
2281
|
-
|
|
2282
|
-
"CLAUDE.md
|
|
2283
|
-
|
|
1289
|
+
pass(
|
|
1290
|
+
"CLAUDE.md legacy block",
|
|
1291
|
+
"no CLAUDE.md found — session context comes from the SessionStart hook",
|
|
2284
1292
|
);
|
|
2285
1293
|
} else {
|
|
2286
1294
|
try {
|
|
@@ -2289,15 +1297,18 @@ async function doctor() {
|
|
|
2289
1297
|
content.includes(CLAUDE_BLOCK_BEGIN) &&
|
|
2290
1298
|
content.includes(CLAUDE_BLOCK_END)
|
|
2291
1299
|
) {
|
|
2292
|
-
|
|
1300
|
+
warn(
|
|
1301
|
+
"CLAUDE.md legacy block",
|
|
1302
|
+
`legacy ForgeDock block found in ${claudeMdPath} — uninstall removes it; the SessionStart hook has replaced it`,
|
|
1303
|
+
);
|
|
2293
1304
|
} else {
|
|
2294
|
-
|
|
2295
|
-
"CLAUDE.md
|
|
2296
|
-
|
|
1305
|
+
pass(
|
|
1306
|
+
"CLAUDE.md legacy block",
|
|
1307
|
+
"no legacy ForgeDock block — session context comes from the SessionStart hook",
|
|
2297
1308
|
);
|
|
2298
1309
|
}
|
|
2299
1310
|
} catch (err) {
|
|
2300
|
-
fail("CLAUDE.md
|
|
1311
|
+
fail("CLAUDE.md legacy block", `Cannot read CLAUDE.md: ${err.message}`);
|
|
2301
1312
|
}
|
|
2302
1313
|
}
|
|
2303
1314
|
}
|
|
@@ -2364,7 +1375,7 @@ async function doctor() {
|
|
|
2364
1375
|
} else {
|
|
2365
1376
|
fail(
|
|
2366
1377
|
"GitHub workflow labels",
|
|
2367
|
-
`
|
|
1378
|
+
`Create the missing labels manually, e.g.: gh label create "<name>" --color <hex> --description "<desc>" -R ${forgeOwner}/${forgeRepo} (see bin/labels.json for definitions; missing: ${missingLabels.join(", ")})`,
|
|
2368
1379
|
);
|
|
2369
1380
|
}
|
|
2370
1381
|
}
|
|
@@ -2372,15 +1383,19 @@ async function doctor() {
|
|
|
2372
1383
|
}
|
|
2373
1384
|
}
|
|
2374
1385
|
|
|
2375
|
-
// ── Check 8: FORGE_HOME environment variable
|
|
1386
|
+
// ── Check 8: FORGE_HOME environment variable (informational) ──────────────
|
|
1387
|
+
// The journey-based install never exports FORGE_HOME — it isn't required
|
|
1388
|
+
// for the pipeline to run. It only affects orchestrate's legacy
|
|
1389
|
+
// classify-lane script fallback (tracked as a follow-up), so a missing
|
|
1390
|
+
// value is a warning, not a failure.
|
|
2376
1391
|
{
|
|
2377
1392
|
const envForgeHome = process.env.FORGE_HOME;
|
|
2378
1393
|
if (envForgeHome) {
|
|
2379
1394
|
pass("FORGE_HOME env var", `set to "${envForgeHome}"`);
|
|
2380
1395
|
} else {
|
|
2381
|
-
|
|
1396
|
+
warn(
|
|
2382
1397
|
"FORGE_HOME env var",
|
|
2383
|
-
|
|
1398
|
+
"not exported — only affects orchestrate's legacy classify-lane script fallback (tracked as a follow-up). Not required for normal use.",
|
|
2384
1399
|
);
|
|
2385
1400
|
}
|
|
2386
1401
|
}
|
|
@@ -2441,6 +1456,106 @@ async function doctor() {
|
|
|
2441
1456
|
}
|
|
2442
1457
|
}
|
|
2443
1458
|
|
|
1459
|
+
// ── Check 10: yq installed ────────────────────────────────────────────────
|
|
1460
|
+
// yq is a hard dependency: pipeline commands (work-on, review-pr, orchestrate)
|
|
1461
|
+
// read forge.yaml via yq. Without it, those commands fail. Fixed literal
|
|
1462
|
+
// command — no interpolation, so no injection surface (cf. #663/#789/#807).
|
|
1463
|
+
{
|
|
1464
|
+
let yqVersion = "";
|
|
1465
|
+
try {
|
|
1466
|
+
yqVersion = execSync("yq --version", {
|
|
1467
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1468
|
+
encoding: "utf-8",
|
|
1469
|
+
timeout: 5000,
|
|
1470
|
+
}).trim();
|
|
1471
|
+
} catch {
|
|
1472
|
+
// yq not installed or not on PATH
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (yqVersion) {
|
|
1476
|
+
pass("yq", yqVersion);
|
|
1477
|
+
} else {
|
|
1478
|
+
fail(
|
|
1479
|
+
"yq",
|
|
1480
|
+
"Install the YAML processor (used to read forge.yaml): https://github.com/mikefarah/yq#install",
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// ── Check 11: Claude Code installed + version compatible ───────────────────
|
|
1486
|
+
// The compatibility floor is the @anthropic-ai/claude-code peerDependency in
|
|
1487
|
+
// package.json (read dynamically so it never drifts from the declared floor).
|
|
1488
|
+
// Not-on-PATH is a warning, not a failure: Claude Code may run as the host
|
|
1489
|
+
// process without exposing the `claude` CLI on PATH, so a hard fail would be a
|
|
1490
|
+
// false negative. Only an installed-but-too-old version is a hard failure.
|
|
1491
|
+
{
|
|
1492
|
+
// Resolve the minimum compatible version from package.json peerDependencies.
|
|
1493
|
+
let minClaudeVersion = "2.0.0";
|
|
1494
|
+
try {
|
|
1495
|
+
const pkgRaw = readFileSync(join(FORGE_HOME, "package.json"), "utf-8");
|
|
1496
|
+
const pkg = JSON.parse(pkgRaw);
|
|
1497
|
+
const range = pkg?.peerDependencies?.["@anthropic-ai/claude-code"];
|
|
1498
|
+
if (typeof range === "string") {
|
|
1499
|
+
const m = range.match(/(\d+\.\d+\.\d+)/);
|
|
1500
|
+
if (m) minClaudeVersion = m[1];
|
|
1501
|
+
}
|
|
1502
|
+
} catch {
|
|
1503
|
+
// package.json unreadable — keep the hardcoded fallback floor.
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
let claudeRaw = "";
|
|
1507
|
+
let claudeInstalled = false;
|
|
1508
|
+
try {
|
|
1509
|
+
claudeRaw = execSync("claude --version", {
|
|
1510
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1511
|
+
encoding: "utf-8",
|
|
1512
|
+
timeout: 5000,
|
|
1513
|
+
}).trim();
|
|
1514
|
+
claudeInstalled = true;
|
|
1515
|
+
} catch {
|
|
1516
|
+
// claude CLI not on PATH
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (!claudeInstalled) {
|
|
1520
|
+
warn(
|
|
1521
|
+
"Claude Code",
|
|
1522
|
+
`\`claude\` CLI not found on PATH (need >= v${minClaudeVersion}). If you run Claude Code without the CLI on PATH this is fine; otherwise install it: https://docs.anthropic.com/en/docs/claude-code`,
|
|
1523
|
+
);
|
|
1524
|
+
} else {
|
|
1525
|
+
const verMatch = claudeRaw.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
1526
|
+
if (!verMatch) {
|
|
1527
|
+
warn(
|
|
1528
|
+
"Claude Code",
|
|
1529
|
+
`installed but version string could not be parsed ("${claudeRaw}"). Expected >= v${minClaudeVersion}.`,
|
|
1530
|
+
);
|
|
1531
|
+
} else {
|
|
1532
|
+
const found = [
|
|
1533
|
+
Number(verMatch[1]),
|
|
1534
|
+
Number(verMatch[2]),
|
|
1535
|
+
Number(verMatch[3]),
|
|
1536
|
+
];
|
|
1537
|
+
const floor = minClaudeVersion.split(".").map(Number);
|
|
1538
|
+
// Lexicographic semver comparison: found >= floor ?
|
|
1539
|
+
let compatible = true;
|
|
1540
|
+
for (let i = 0; i < 3; i++) {
|
|
1541
|
+
if (found[i] > floor[i]) break;
|
|
1542
|
+
if (found[i] < floor[i]) {
|
|
1543
|
+
compatible = false;
|
|
1544
|
+
break;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
if (compatible) {
|
|
1548
|
+
pass("Claude Code", `v${found.join(".")} (compatible, >= v${minClaudeVersion})`);
|
|
1549
|
+
} else {
|
|
1550
|
+
fail(
|
|
1551
|
+
"Claude Code",
|
|
1552
|
+
`v${found.join(".")} is below the supported floor v${minClaudeVersion}. Update Claude Code: https://docs.anthropic.com/en/docs/claude-code`,
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
2444
1559
|
// ── Summary ────────────────────────────────────────────────────────────────
|
|
2445
1560
|
console.log("");
|
|
2446
1561
|
if (failures === 0 && warnings === 0) {
|
|
@@ -2454,66 +1569,251 @@ async function doctor() {
|
|
|
2454
1569
|
}
|
|
2455
1570
|
console.log("");
|
|
2456
1571
|
|
|
2457
|
-
|
|
2458
|
-
process.exit(1);
|
|
2459
|
-
}
|
|
1572
|
+
return failures > 0 ? 1 : 0;
|
|
2460
1573
|
}
|
|
2461
1574
|
|
|
2462
1575
|
function help() {
|
|
2463
|
-
//
|
|
1576
|
+
// splash() already rendered the logo to stderr — the command table itself
|
|
1577
|
+
// goes to stdout (help output is the requested artifact).
|
|
2464
1578
|
const commands = [
|
|
2465
1579
|
["Command", "Description"],
|
|
2466
|
-
["npx forgedock", "
|
|
1580
|
+
["npx forgedock", "Guided setup: install commands + configure repo (default)"],
|
|
2467
1581
|
["npx forgedock install", "Install commands"],
|
|
2468
1582
|
["npx forgedock init", "Generate forge.yaml config for your project"],
|
|
2469
|
-
["npx forgedock
|
|
2470
|
-
["npx forgedock
|
|
2471
|
-
["npx forgedock
|
|
2472
|
-
["npx forgedock
|
|
2473
|
-
["npx forgedock
|
|
1583
|
+
["npx forgedock init --minimal", "Generate a minimal forge.yaml (required sections only)"],
|
|
1584
|
+
["npx forgedock enable [dir]", "Activate ForgeDock in a directory"],
|
|
1585
|
+
["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
|
|
1586
|
+
["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
|
|
1587
|
+
["npx forgedock run <cmd> [args]", "Run a command headlessly via the Anthropic API"],
|
|
1588
|
+
["npx forgedock run-issue <issue>", "Drive one issue through the durable engine"],
|
|
1589
|
+
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
1590
|
+
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
2474
1591
|
["npx forgedock doctor", "Check installation health"],
|
|
1592
|
+
["npx forgedock update", "Pull latest & reinstall"],
|
|
1593
|
+
["npx forgedock uninstall", "Remove commands"],
|
|
2475
1594
|
["npx forgedock help", "Show this help"],
|
|
2476
1595
|
];
|
|
1596
|
+
const flagRows = [
|
|
1597
|
+
["Flag", "Description"],
|
|
1598
|
+
["--fast", "Skip animation/motion"],
|
|
1599
|
+
["--manual", "Plain text prompts instead of the review screen (init)"],
|
|
1600
|
+
["--verbose", "Show detection sources for every field (init)"],
|
|
1601
|
+
["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
|
|
1602
|
+
];
|
|
2477
1603
|
|
|
2478
|
-
process.
|
|
1604
|
+
process.stdout.write(
|
|
2479
1605
|
box(table(commands, { header: true }), { title: "Usage" }) + "\n",
|
|
2480
1606
|
);
|
|
1607
|
+
process.stdout.write(
|
|
1608
|
+
box(table(flagRows, { header: true }), { title: "Flags" }) + "\n",
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* `forgedock run <command> [args...]` — execute a ForgeDock command spec
|
|
1614
|
+
* directly via the Anthropic API, outside of Claude Code (headless / CI).
|
|
1615
|
+
*
|
|
1616
|
+
* Flags:
|
|
1617
|
+
* --dry-run Preview the assembled prompt + tool plan; no API call.
|
|
1618
|
+
* --model <id> Override the model (default: claude-sonnet-4-5 or $FORGEDOCK_MODEL).
|
|
1619
|
+
* --max-iterations <n> Bound the tool-use loop (default: 50).
|
|
1620
|
+
*
|
|
1621
|
+
* The live loop requires ANTHROPIC_API_KEY and the optional @anthropic-ai/sdk
|
|
1622
|
+
* dependency. The runtime itself lives in bin/runner.mjs.
|
|
1623
|
+
*
|
|
1624
|
+
* Env:
|
|
1625
|
+
* FORGEDOCK_MODEL Default model id when --model is omitted.
|
|
1626
|
+
* FORGEDOCK_SHELL Override the shell used by run_bash. Defaults to bash when
|
|
1627
|
+
* found (Git Bash / WSL on Windows, /bin/bash on POSIX),
|
|
1628
|
+
* falling back to the platform default shell otherwise.
|
|
1629
|
+
*/
|
|
1630
|
+
async function run() {
|
|
1631
|
+
const runArgs = restArgs;
|
|
1632
|
+
let dryRun = false;
|
|
1633
|
+
let model;
|
|
1634
|
+
let maxIterations;
|
|
1635
|
+
const positional = [];
|
|
1636
|
+
for (let i = 0; i < runArgs.length; i++) {
|
|
1637
|
+
const a = runArgs[i];
|
|
1638
|
+
if (a === "--dry-run") {
|
|
1639
|
+
dryRun = true;
|
|
1640
|
+
} else if (a === "--model") {
|
|
1641
|
+
model = runArgs[++i];
|
|
1642
|
+
} else if (a.startsWith("--model=")) {
|
|
1643
|
+
model = a.slice("--model=".length);
|
|
1644
|
+
} else if (a === "--max-iterations") {
|
|
1645
|
+
maxIterations = parseInt(runArgs[++i], 10);
|
|
1646
|
+
} else if (a.startsWith("--max-iterations=")) {
|
|
1647
|
+
maxIterations = parseInt(a.slice("--max-iterations=".length), 10);
|
|
1648
|
+
} else {
|
|
1649
|
+
positional.push(a);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const commandName = positional[0];
|
|
1654
|
+
const commandArgs = positional.slice(1);
|
|
1655
|
+
|
|
1656
|
+
if (!commandName) {
|
|
1657
|
+
process.stderr.write(
|
|
1658
|
+
`${RED}Usage: forgedock run <command> [args...] [--dry-run] [--model <id>] [--max-iterations <n>]${RESET}\n`,
|
|
1659
|
+
);
|
|
1660
|
+
process.exit(1);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const { runCommand } = await import("./runner.mjs");
|
|
1664
|
+
try {
|
|
1665
|
+
const result = await runCommand({
|
|
1666
|
+
commandsDir: COMMANDS_DIR,
|
|
1667
|
+
commandName,
|
|
1668
|
+
args: commandArgs,
|
|
1669
|
+
cwd: process.cwd(),
|
|
1670
|
+
dryRun,
|
|
1671
|
+
...(model ? { model } : {}),
|
|
1672
|
+
...(Number.isInteger(maxIterations) && maxIterations > 0
|
|
1673
|
+
? { maxIterations }
|
|
1674
|
+
: {}),
|
|
1675
|
+
});
|
|
1676
|
+
// Treat a non-clean stop (iteration cap hit, or a max_tokens-truncated
|
|
1677
|
+
// turn) as a failed run so CI/headless callers notice.
|
|
1678
|
+
if (
|
|
1679
|
+
result &&
|
|
1680
|
+
(result.status === "max-iterations" || result.status === "incomplete")
|
|
1681
|
+
) {
|
|
1682
|
+
process.exitCode = 1;
|
|
1683
|
+
}
|
|
1684
|
+
} catch (err) {
|
|
1685
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
1686
|
+
process.exit(1);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* `forgedock demo` — one-command demo mode (issue #1145).
|
|
1692
|
+
*
|
|
1693
|
+
* Stands up a runnable ForgeDock demo at a predictable location (default
|
|
1694
|
+
* ~/forgedock-demo) with zero required decisions, then prints the exact next
|
|
1695
|
+
* steps. Clones the live demo repo when available, otherwise falls back to the
|
|
1696
|
+
* bundled scaffold at FORGE_HOME/examples/forgedock-demo. The runtime lives in
|
|
1697
|
+
* bin/demo.mjs.
|
|
1698
|
+
*/
|
|
1699
|
+
async function demo() {
|
|
1700
|
+
const { runDemo } = await import("./demo.mjs");
|
|
1701
|
+
try {
|
|
1702
|
+
const result = await runDemo({
|
|
1703
|
+
forgeHome: FORGE_HOME,
|
|
1704
|
+
args: restArgs,
|
|
1705
|
+
cwd: process.cwd(),
|
|
1706
|
+
});
|
|
1707
|
+
if (result && result.status === "error") {
|
|
1708
|
+
process.exitCode = 1;
|
|
1709
|
+
}
|
|
1710
|
+
} catch (err) {
|
|
1711
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
1712
|
+
process.exit(1);
|
|
1713
|
+
}
|
|
2481
1714
|
}
|
|
2482
1715
|
|
|
2483
|
-
|
|
1716
|
+
// The journey-routed commands render their own branded marks (hero/compact);
|
|
1717
|
+
// splash() would double-brand them. Engine-surface commands, help, and
|
|
1718
|
+
// unknown-command output keep the logo.
|
|
1719
|
+
const SPLASH_COMMANDS = new Set(["run", "run-issue", "demo", "doctor", "help", "--help", "-h"]);
|
|
1720
|
+
const KNOWN_COMMANDS = new Set([
|
|
1721
|
+
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
1722
|
+
"run", "run-issue", "demo", "doctor", "help", "--help", "-h",
|
|
1723
|
+
]);
|
|
1724
|
+
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash();
|
|
2484
1725
|
|
|
1726
|
+
let exitCode = 0;
|
|
2485
1727
|
switch (command) {
|
|
2486
|
-
case "install":
|
|
2487
|
-
|
|
1728
|
+
case "install": {
|
|
1729
|
+
const c = ctx();
|
|
1730
|
+
if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
|
|
1731
|
+
statusScreen(c);
|
|
1732
|
+
} else {
|
|
1733
|
+
exitCode = await runJourney(c);
|
|
1734
|
+
}
|
|
2488
1735
|
break;
|
|
1736
|
+
}
|
|
2489
1737
|
case "init":
|
|
2490
|
-
await
|
|
1738
|
+
exitCode = await initFlow(ctx());
|
|
1739
|
+
break;
|
|
1740
|
+
case "enable": {
|
|
1741
|
+
const c = ctx();
|
|
1742
|
+
const dir = positional[1] ? resolve(positional[1]) : c.cwd;
|
|
1743
|
+
await setOptOut(dir, false);
|
|
1744
|
+
if (!existsSync(join(dir, "forge.yaml")) && !existsSync(join(dir, ".forgedock"))) {
|
|
1745
|
+
writeFileSync(join(dir, ".forgedock"), "", "utf-8");
|
|
1746
|
+
}
|
|
1747
|
+
c.stdout.write(`\n ForgeDock enabled in ${dir}.\n\n`);
|
|
2491
1748
|
break;
|
|
1749
|
+
}
|
|
1750
|
+
case "disable": {
|
|
1751
|
+
const c = ctx();
|
|
1752
|
+
const dir = positional[1] ? resolve(positional[1]) : c.cwd;
|
|
1753
|
+
await setOptOut(dir, true);
|
|
1754
|
+
c.stdout.write(`\n ForgeDock disabled in ${dir}. Re-enable: npx forgedock enable\n\n`);
|
|
1755
|
+
break;
|
|
1756
|
+
}
|
|
1757
|
+
case "status": {
|
|
1758
|
+
const c = ctx();
|
|
1759
|
+
if (positional[1]) c.cwd = resolve(positional[1]);
|
|
1760
|
+
statusScreen(c);
|
|
1761
|
+
break;
|
|
1762
|
+
}
|
|
2492
1763
|
case "uninstall":
|
|
2493
1764
|
await uninstall();
|
|
2494
1765
|
break;
|
|
2495
1766
|
case "update":
|
|
2496
1767
|
await update();
|
|
2497
1768
|
break;
|
|
2498
|
-
case "
|
|
2499
|
-
await
|
|
1769
|
+
case "run":
|
|
1770
|
+
await run();
|
|
2500
1771
|
break;
|
|
2501
|
-
case "
|
|
2502
|
-
await
|
|
1772
|
+
case "run-issue": {
|
|
1773
|
+
const { runFromCli } = await import("./engine-cli.mjs");
|
|
1774
|
+
await runFromCli(restArgs);
|
|
2503
1775
|
break;
|
|
2504
|
-
|
|
2505
|
-
|
|
1776
|
+
}
|
|
1777
|
+
case "demo":
|
|
1778
|
+
await demo();
|
|
2506
1779
|
break;
|
|
2507
1780
|
case "doctor":
|
|
2508
|
-
await doctor();
|
|
1781
|
+
exitCode = await doctor();
|
|
1782
|
+
break;
|
|
1783
|
+
case "labels": {
|
|
1784
|
+
const subcommand = positional[1];
|
|
1785
|
+
if (!subcommand || subcommand === "setup" || subcommand.startsWith("--")) {
|
|
1786
|
+
const repo = resolveLabelsRepo(restArgs);
|
|
1787
|
+
if (!repo) {
|
|
1788
|
+
console.log(
|
|
1789
|
+
`${RED}No repository specified.${RESET}\n` +
|
|
1790
|
+
` Pass ${cyan("--repo owner/repo")} or run from a directory with ${cyan("forge.yaml")}.`,
|
|
1791
|
+
);
|
|
1792
|
+
exitCode = 1;
|
|
1793
|
+
} else {
|
|
1794
|
+
await labelsSetup(repo);
|
|
1795
|
+
}
|
|
1796
|
+
} else {
|
|
1797
|
+
console.log(`${RED}Unknown labels subcommand: ${subcommand}${RESET}`);
|
|
1798
|
+
console.log(
|
|
1799
|
+
`Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo]${RESET}`,
|
|
1800
|
+
);
|
|
1801
|
+
exitCode = 1;
|
|
1802
|
+
}
|
|
2509
1803
|
break;
|
|
1804
|
+
}
|
|
2510
1805
|
case "help":
|
|
2511
1806
|
case "--help":
|
|
2512
1807
|
case "-h":
|
|
2513
1808
|
help();
|
|
2514
1809
|
break;
|
|
2515
1810
|
default:
|
|
2516
|
-
|
|
1811
|
+
console.log(`${RED}Unknown command: ${command}${RESET}`);
|
|
2517
1812
|
help();
|
|
2518
|
-
|
|
1813
|
+
exitCode = 1;
|
|
2519
1814
|
}
|
|
1815
|
+
// Natural exit: set the code and let the event loop drain so stdout is never
|
|
1816
|
+
// truncated (spinner timers are unref'd; SIGINT listeners are removed).
|
|
1817
|
+
// Subcommands (run/demo) may set process.exitCode themselves — never clobber
|
|
1818
|
+
// a failure they recorded with a success code from the router.
|
|
1819
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|