forgedock 1.0.16 → 1.0.17
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 +21 -3
- package/bin/forgedock.mjs +1992 -540
- package/bin/github-app-manifest.json.license +2 -0
- package/bin/hooks/session-start.mjs +4 -5
- package/bin/registry.mjs +20 -0
- package/bin/tui.mjs +172 -96
- package/commands/adopt.md +433 -0
- package/commands/analytics.md +2 -0
- package/commands/audit-agents.md +2 -0
- package/commands/audit.md +2 -0
- package/commands/changelog.md +354 -0
- package/commands/cleanup.md +2 -0
- package/commands/compat-audit.md +330 -0
- package/commands/deploy-info.md +6 -5
- package/commands/diagnose.md +483 -0
- package/commands/explain.md +349 -0
- package/commands/forge-stats.md +2 -0
- package/commands/forgedock-init.md +2 -0
- package/commands/geo-audit.md +2 -0
- package/commands/incident-response.md +2 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +2 -0
- package/commands/optimize.md +411 -0
- package/commands/orchestrate.md +118 -0
- package/commands/pipeline-health.md +60 -1
- package/commands/qa-sweep.md +2 -0
- package/commands/quality-gate.md +37 -0
- package/commands/replay.md +263 -0
- package/commands/resume.md +264 -0
- package/commands/review-pr-agents.md +5 -1
- package/commands/review-pr-staging.md +2 -0
- package/commands/review-pr.md +85 -3
- package/commands/rollback.md +2 -0
- package/commands/scope.md +280 -0
- package/commands/security-audit.md +2 -0
- package/commands/status.md +262 -0
- package/commands/sync-ecosystem.md +2 -0
- package/commands/validate.md +2 -0
- package/commands/work-on/build/architect.md +128 -18
- package/commands/work-on/build/context.md +130 -17
- package/commands/work-on/build/implement.md +2 -0
- package/commands/work-on/build/validate.md +2 -0
- package/commands/work-on/build.md +32 -7
- package/commands/work-on/close.md +85 -0
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +30 -3
- package/commands/work-on/review.md +13 -1
- package/commands/work-on-monolithic.md +2 -0
- package/commands/work-on.md +624 -36
- package/docs/BRAND.md +51 -0
- package/docs/CONFIG.md +94 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +174 -0
- package/docs/claude-breakpoints.json +47 -0
- package/docs/site/getting-started.md +21 -0
- package/package.json +3 -3
- package/scripts/classify-lane.sh +145 -0
- package/scripts/transition-label.sh +161 -0
- package/scripts/validate-pr-target.sh +30 -0
- package/templates/devdocs/README.md +72 -0
- package/templates/devdocs/agent/using-forgedock.md +3 -0
- package/templates/devdocs/agent/using-github.md +3 -0
- package/templates/devdocs/index.yaml +162 -0
- package/templates/devdocs/project/architecture.md +3 -0
- package/templates/devdocs/project/conventions.md +3 -0
- package/templates/devdocs/project/custom-instructions.md +3 -0
- package/templates/devdocs/project/glossary.md +3 -0
- package/templates/devdocs/project/stack.md +3 -0
- package/templates/devdocs/templates/commit-convention.md +3 -0
- package/templates/devdocs/templates/issue.md +3 -0
- package/templates/devdocs/templates/pull-request.md +3 -0
- package/templates/devdocs/templates/review-finding.md +3 -0
package/bin/forgedock.mjs
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
4
|
|
|
3
|
-
import { fileURLToPath } from "url";
|
|
4
|
-
import { dirname, join, relative } from "path";
|
|
5
|
-
import { mkdir, symlink, readlink, lstat, readdir } from "fs/promises";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
6
|
+
import { dirname, join, relative, resolve } from "path";
|
|
7
|
+
import { mkdir, symlink, readlink, lstat, readdir, stat, unlink } from "fs/promises";
|
|
6
8
|
import {
|
|
7
9
|
existsSync,
|
|
8
10
|
appendFileSync,
|
|
9
11
|
readFileSync,
|
|
10
12
|
writeFileSync,
|
|
11
13
|
renameSync,
|
|
12
|
-
|
|
14
|
+
unlinkSync,
|
|
13
15
|
} from "fs";
|
|
14
|
-
import { execSync } from "child_process";
|
|
15
|
-
import
|
|
16
|
+
import { execSync, execFileSync } from "child_process";
|
|
17
|
+
import { homedir } from "os";
|
|
16
18
|
import {
|
|
17
19
|
renderLogo,
|
|
18
20
|
runSteps,
|
|
@@ -37,20 +39,416 @@ const __dirname = dirname(__filename);
|
|
|
37
39
|
const FORGE_HOME = dirname(__dirname);
|
|
38
40
|
const COMMANDS_DIR = join(FORGE_HOME, "commands");
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
// Resolve home directory cross-platform: HOME on Unix, USERPROFILE on Windows.
|
|
43
|
+
// os.homedir() handles all supported platforms without a hard exit — see #744.
|
|
44
|
+
const HOME = homedir();
|
|
45
|
+
|
|
46
|
+
const TARGET_DIR = join(HOME, ".claude", "commands");
|
|
47
|
+
const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
|
|
48
|
+
const SCRIPTS_TARGET_DIR = join(HOME, ".claude", "scripts");
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Allowlist of pipeline-agent scripts that get installed to ~/.claude/scripts/.
|
|
52
|
+
* Only these files are symlinked (or copied on Windows) during install/update.
|
|
53
|
+
*
|
|
54
|
+
* Internal tooling (gen-logo.mjs, verify-*.sh) lives in scripts/ but is NOT
|
|
55
|
+
* installed — those scripts are invoked directly via $FORGE_HOME/scripts/ by
|
|
56
|
+
* review-pr.md and quality-gate.md and should not pollute the user's Claude
|
|
57
|
+
* scripts namespace.
|
|
58
|
+
*
|
|
59
|
+
* When adding a new pipeline-agent script, add its filename here.
|
|
60
|
+
*/
|
|
61
|
+
const PIPELINE_SCRIPTS = new Set([
|
|
62
|
+
"classify-lane.sh",
|
|
63
|
+
"transition-label.sh",
|
|
64
|
+
"validate-pr-target.sh",
|
|
65
|
+
]);
|
|
48
66
|
|
|
49
67
|
const args = process.argv.slice(2);
|
|
50
68
|
const command = args[0] || "install";
|
|
51
69
|
|
|
52
70
|
// ---------------------------------------------------------------------------
|
|
53
|
-
//
|
|
71
|
+
// SessionStart hook — settings.json helpers
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Path to the user-level Claude Code settings file.
|
|
76
|
+
* This is where the SessionStart hook entry is written/removed.
|
|
77
|
+
*/
|
|
78
|
+
const CLAUDE_SETTINGS_PATH = join(HOME, ".claude", "settings.json");
|
|
79
|
+
|
|
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
|
+
/**
|
|
146
|
+
* Check whether a hook command string refers to the ForgeDock SessionStart hook.
|
|
147
|
+
*
|
|
148
|
+
* Uses a platform-safe RegExp that matches both POSIX forward-slash paths
|
|
149
|
+
* (Linux/macOS) and Windows backslash paths, so uninstall can locate and
|
|
150
|
+
* remove the hook even when FORGE_HOME was different at install time.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} command - The command string from a hook entry.
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
function isForgeSessionStartHook(command) {
|
|
156
|
+
// Match: ...bin[/\]hooks[/\]session-start.mjs (quote-terminated or EOL)
|
|
157
|
+
return /[/\\]bin[/\\]hooks[/\\]session-start\.mjs["']?\s*$/.test(command);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Strip JSONC syntax (single-line comments, block comments, trailing commas)
|
|
162
|
+
* from a raw JSON string, returning a plain JSON string that JSON.parse() accepts.
|
|
163
|
+
*
|
|
164
|
+
* Handles settings.json files that Claude Code's own editor may annotate
|
|
165
|
+
* with comments and trailing commas.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} raw - Raw text of a JSONC file.
|
|
168
|
+
* @returns {string} - Valid JSON string.
|
|
169
|
+
*/
|
|
170
|
+
function stripJsonc(raw) {
|
|
171
|
+
let result = "";
|
|
172
|
+
let i = 0;
|
|
173
|
+
const len = raw.length;
|
|
174
|
+
|
|
175
|
+
while (i < len) {
|
|
176
|
+
const ch = raw[i];
|
|
177
|
+
|
|
178
|
+
// Inside a string literal — copy until unescaped closing quote
|
|
179
|
+
if (ch === '"') {
|
|
180
|
+
result += ch;
|
|
181
|
+
i++;
|
|
182
|
+
while (i < len) {
|
|
183
|
+
const sc = raw[i];
|
|
184
|
+
result += sc;
|
|
185
|
+
if (sc === "\\" && i + 1 < len) {
|
|
186
|
+
i++;
|
|
187
|
+
result += raw[i];
|
|
188
|
+
} else if (sc === '"') {
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
i++;
|
|
192
|
+
}
|
|
193
|
+
i++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Single-line comment — skip until newline
|
|
198
|
+
if (ch === "/" && i + 1 < len && raw[i + 1] === "/") {
|
|
199
|
+
while (i < len && raw[i] !== "\n") i++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Block comment — skip until */
|
|
204
|
+
if (ch === "/" && i + 1 < len && raw[i + 1] === "*") {
|
|
205
|
+
i += 2;
|
|
206
|
+
while (i + 1 < len && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
207
|
+
i += 2;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Trailing comma — skip if next structural char (past whitespace and
|
|
212
|
+
// comments) is } or ].
|
|
213
|
+
// This must run OUTSIDE the string-literal branch above so string content
|
|
214
|
+
// containing ",}" or ",]" is never touched.
|
|
215
|
+
// The lookahead skips whitespace AND inline comments so that patterns like
|
|
216
|
+
// `value, /* note */ }` or `value, // note\n}` are handled correctly.
|
|
217
|
+
if (ch === ",") {
|
|
218
|
+
let j = i + 1;
|
|
219
|
+
// Advance j past whitespace and comments
|
|
220
|
+
let advanced = true;
|
|
221
|
+
while (advanced && j < len) {
|
|
222
|
+
advanced = false;
|
|
223
|
+
// Skip whitespace
|
|
224
|
+
while (
|
|
225
|
+
j < len &&
|
|
226
|
+
(raw[j] === " " ||
|
|
227
|
+
raw[j] === "\t" ||
|
|
228
|
+
raw[j] === "\r" ||
|
|
229
|
+
raw[j] === "\n")
|
|
230
|
+
) {
|
|
231
|
+
j++;
|
|
232
|
+
advanced = true;
|
|
233
|
+
}
|
|
234
|
+
// Skip single-line comment
|
|
235
|
+
if (j + 1 < len && raw[j] === "/" && raw[j + 1] === "/") {
|
|
236
|
+
while (j < len && raw[j] !== "\n") j++;
|
|
237
|
+
advanced = true;
|
|
238
|
+
}
|
|
239
|
+
// Skip block comment
|
|
240
|
+
if (j + 1 < len && raw[j] === "/" && raw[j + 1] === "*") {
|
|
241
|
+
j += 2;
|
|
242
|
+
while (j + 1 < len && !(raw[j] === "*" && raw[j + 1] === "/")) j++;
|
|
243
|
+
j += 2;
|
|
244
|
+
advanced = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (j < len && (raw[j] === "}" || raw[j] === "]")) {
|
|
248
|
+
i++;
|
|
249
|
+
continue; // skip trailing comma
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
result += ch;
|
|
254
|
+
i++;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Read ~/.claude/settings.json, returning a parsed object.
|
|
262
|
+
* Returns an empty object if the file does not exist.
|
|
263
|
+
* Tolerates JSONC syntax (comments, trailing commas) that Claude Code's
|
|
264
|
+
* own settings editor may insert.
|
|
265
|
+
* Throws if the file exists but cannot be parsed even after JSONC stripping.
|
|
266
|
+
*
|
|
267
|
+
* @returns {object}
|
|
268
|
+
*/
|
|
269
|
+
function readClaudeSettings() {
|
|
270
|
+
try {
|
|
271
|
+
const raw = readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
|
|
272
|
+
return JSON.parse(stripJsonc(raw));
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err.code === "ENOENT") return {};
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
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
|
+
/**
|
|
302
|
+
* Atomically write text content to a file.
|
|
303
|
+
* Uses a .forgedock.tmp sibling + renameSync to avoid partial writes on
|
|
304
|
+
* crash or SIGINT. Cleans up the tmp file on failure. (ref: forge#813)
|
|
305
|
+
*
|
|
306
|
+
* @param {string} filePath - Absolute path to write
|
|
307
|
+
* @param {string} content - UTF-8 string content
|
|
308
|
+
*/
|
|
309
|
+
function atomicWriteFile(filePath, content) {
|
|
310
|
+
const tmpPath = filePath + ".forgedock.tmp";
|
|
311
|
+
try {
|
|
312
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
313
|
+
renameSync(tmpPath, filePath);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
try {
|
|
316
|
+
unlinkSync(tmpPath);
|
|
317
|
+
} catch {
|
|
318
|
+
/* already gone or never created */
|
|
319
|
+
}
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Install the ForgeDock SessionStart hook into ~/.claude/settings.json
|
|
326
|
+
* idempotently. Does not modify any existing hooks unrelated to ForgeDock.
|
|
327
|
+
*
|
|
328
|
+
* Returns:
|
|
329
|
+
* 'installed' — hook was newly added
|
|
330
|
+
* 'already-present' — hook was already there (idempotent)
|
|
331
|
+
* 'failed' — an error occurred (non-fatal; install continues)
|
|
332
|
+
*
|
|
333
|
+
* @returns {Promise<'installed' | 'already-present' | 'failed'>}
|
|
334
|
+
*/
|
|
335
|
+
async function installSessionStartHook() {
|
|
336
|
+
try {
|
|
337
|
+
const settings = readClaudeSettings();
|
|
338
|
+
|
|
339
|
+
// Ensure the hooks section exists
|
|
340
|
+
if (!settings.hooks || typeof settings.hooks !== "object") {
|
|
341
|
+
settings.hooks = {};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Ensure SessionStart array exists
|
|
345
|
+
if (!Array.isArray(settings.hooks.SessionStart)) {
|
|
346
|
+
settings.hooks.SessionStart = [];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const cmd = sessionStartHookCommand();
|
|
350
|
+
|
|
351
|
+
// Check if already present — match by path suffix so uninstall/reinstall
|
|
352
|
+
// across different FORGE_HOME paths still works correctly. (ref: forge#537)
|
|
353
|
+
const alreadyPresent = settings.hooks.SessionStart.some((entry) => {
|
|
354
|
+
if (!entry || typeof entry !== "object") return false;
|
|
355
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
356
|
+
return hooks.some(
|
|
357
|
+
(h) =>
|
|
358
|
+
h &&
|
|
359
|
+
typeof h.command === "string" &&
|
|
360
|
+
isForgeSessionStartHook(h.command),
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
if (alreadyPresent) return "already-present";
|
|
365
|
+
|
|
366
|
+
// Append the new hook entry (matcher omitted — fires on every session start)
|
|
367
|
+
settings.hooks.SessionStart.push({
|
|
368
|
+
hooks: [
|
|
369
|
+
{
|
|
370
|
+
type: "command",
|
|
371
|
+
command: cmd,
|
|
372
|
+
timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
writeClaudeSettings(settings);
|
|
378
|
+
return "installed";
|
|
379
|
+
} catch {
|
|
380
|
+
return "failed";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Remove the ForgeDock SessionStart hook from ~/.claude/settings.json
|
|
386
|
+
* idempotently. Only removes the entry written by installSessionStartHook().
|
|
387
|
+
* Unrelated hooks are preserved.
|
|
388
|
+
*
|
|
389
|
+
* Returns:
|
|
390
|
+
* 'removed' — hook was found and removed
|
|
391
|
+
* 'not-present' — hook was not in settings.json (already clean)
|
|
392
|
+
* 'failed' — an error occurred (non-fatal; uninstall continues)
|
|
393
|
+
*
|
|
394
|
+
* @returns {Promise<'removed' | 'not-present' | 'failed'>}
|
|
395
|
+
*/
|
|
396
|
+
async function removeSessionStartHook() {
|
|
397
|
+
try {
|
|
398
|
+
const settings = readClaudeSettings();
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
!settings.hooks ||
|
|
402
|
+
!Array.isArray(settings.hooks.SessionStart) ||
|
|
403
|
+
settings.hooks.SessionStart.length === 0
|
|
404
|
+
) {
|
|
405
|
+
return "not-present";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const originalLength = settings.hooks.SessionStart.length;
|
|
409
|
+
|
|
410
|
+
// Filter out the ForgeDock-managed entry, keep everything else
|
|
411
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
|
|
412
|
+
(entry) => {
|
|
413
|
+
if (!entry || typeof entry !== "object") return true;
|
|
414
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
415
|
+
const isForgeEntry = hooks.some(
|
|
416
|
+
(h) =>
|
|
417
|
+
h &&
|
|
418
|
+
typeof h.command === "string" &&
|
|
419
|
+
isForgeSessionStartHook(h.command),
|
|
420
|
+
);
|
|
421
|
+
return !isForgeEntry;
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
if (settings.hooks.SessionStart.length === originalLength) {
|
|
426
|
+
return "not-present";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Clean up empty SessionStart array to leave settings.json tidy
|
|
430
|
+
if (settings.hooks.SessionStart.length === 0) {
|
|
431
|
+
delete settings.hooks.SessionStart;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Clean up empty hooks object
|
|
435
|
+
if (
|
|
436
|
+
settings.hooks &&
|
|
437
|
+
typeof settings.hooks === "object" &&
|
|
438
|
+
Object.keys(settings.hooks).length === 0
|
|
439
|
+
) {
|
|
440
|
+
delete settings.hooks;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
writeClaudeSettings(settings);
|
|
444
|
+
return "removed";
|
|
445
|
+
} catch {
|
|
446
|
+
return "failed";
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Version + splash — read package.json version and render branded logo
|
|
54
452
|
// ---------------------------------------------------------------------------
|
|
55
453
|
|
|
56
454
|
function getVersion() {
|
|
@@ -69,38 +467,42 @@ function splash() {
|
|
|
69
467
|
process.stderr.write(renderLogo({ version }) + "\n");
|
|
70
468
|
}
|
|
71
469
|
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// CLAUDE.md / AGENTS.md managed-block injection
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
72
474
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
475
|
+
* Marker strings that delimit the ForgeDock-managed block in CLAUDE.md / AGENTS.md.
|
|
476
|
+
* Must be exact — used for both detection and regex anchoring.
|
|
75
477
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
process.stderr.write(
|
|
79
|
-
` ${YELLOW}Non-interactive environment detected — aborting to protect existing config.${RESET}\n`,
|
|
80
|
-
);
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
return new Promise((resolve) => {
|
|
84
|
-
const rl = readline.createInterface({
|
|
85
|
-
input: process.stdin,
|
|
86
|
-
output: process.stderr,
|
|
87
|
-
});
|
|
88
|
-
rl.question(question, (answer) => {
|
|
89
|
-
rl.close();
|
|
90
|
-
resolve(
|
|
91
|
-
answer.trim().toLowerCase() === "y" ||
|
|
92
|
-
answer.trim().toLowerCase() === "yes",
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
}
|
|
478
|
+
const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
|
|
479
|
+
const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
|
|
97
480
|
|
|
98
481
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
482
|
+
* The behavioral rules block injected into the project's CLAUDE.md.
|
|
483
|
+
* Kept ≤30 lines (per issue #607 requirement).
|
|
484
|
+
* Content is fixed — this is NOT documentation, it is behavioral guidance.
|
|
485
|
+
*/
|
|
486
|
+
const FORGEDOCK_MANAGED_BLOCK = `${CLAUDE_BLOCK_BEGIN}
|
|
487
|
+
## ForgeDock Pipeline Rules
|
|
488
|
+
|
|
489
|
+
This project uses ForgeDock for structured development. Follow these rules:
|
|
490
|
+
|
|
491
|
+
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.
|
|
492
|
+
2. **Pipeline flow**: All implementation goes through \`/work-on <issue#>\`. This runs: investigate → architect → build → review → merge.
|
|
493
|
+
3. **Traceability**: Every code change must link to a GitHub issue. Use FORGE annotations to pass context between pipeline phases.
|
|
494
|
+
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.
|
|
495
|
+
5. **Available commands**: \`/work-on\`, \`/issue\`, \`/review-pr\`, \`/orchestrate\`, \`/quality-gate\`, \`/milestone\`, \`/autopilot\`
|
|
496
|
+
|
|
497
|
+
For full documentation, see: [ForgeDock docs](https://forgedock.com/docs)
|
|
498
|
+
${CLAUDE_BLOCK_END}`;
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Returns true if `dir` is inside a git work tree.
|
|
502
|
+
* Used to guard CLAUDE.md injection against non-project directories.
|
|
503
|
+
*
|
|
504
|
+
* @param {string} dir
|
|
505
|
+
* @returns {boolean}
|
|
104
506
|
*/
|
|
105
507
|
function isGitWorkTree(dir) {
|
|
106
508
|
try {
|
|
@@ -115,6 +517,178 @@ function isGitWorkTree(dir) {
|
|
|
115
517
|
}
|
|
116
518
|
}
|
|
117
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Idempotently inject the ForgeDock managed block into a single file.
|
|
522
|
+
*
|
|
523
|
+
* Handles all corrupt marker states (ref: forge#269, forge#291):
|
|
524
|
+
* - File missing → create file containing block only
|
|
525
|
+
* - No markers → append block to existing content
|
|
526
|
+
* - BEGIN + END (correct) → replace content between markers
|
|
527
|
+
* - BEGIN only → strip orphaned BEGIN, re-append full block
|
|
528
|
+
* - END only → strip orphaned END, re-append full block
|
|
529
|
+
* - END before BEGIN → strip both + content between, re-append full block
|
|
530
|
+
* - Block already current → return 'unchanged'
|
|
531
|
+
*
|
|
532
|
+
* Returns:
|
|
533
|
+
* 'created' — file was created with block
|
|
534
|
+
* 'updated' — existing block was replaced with new content
|
|
535
|
+
* 'unchanged' — block was already present and current
|
|
536
|
+
* 'appended' — block was appended (no prior markers)
|
|
537
|
+
*
|
|
538
|
+
* @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
|
|
539
|
+
* @returns {'created' | 'updated' | 'unchanged' | 'appended'}
|
|
540
|
+
*/
|
|
541
|
+
function injectManagedBlock(filePath) {
|
|
542
|
+
const managed = FORGEDOCK_MANAGED_BLOCK;
|
|
543
|
+
|
|
544
|
+
// File does not exist — create it with the block
|
|
545
|
+
if (!existsSync(filePath)) {
|
|
546
|
+
atomicWriteFile(filePath, managed + "\n");
|
|
547
|
+
return "created";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
let existing = readFileSync(filePath, "utf-8");
|
|
551
|
+
|
|
552
|
+
const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
|
|
553
|
+
const hasEnd = existing.includes(CLAUDE_BLOCK_END);
|
|
554
|
+
|
|
555
|
+
if (hasBegin && hasEnd) {
|
|
556
|
+
// Determine marker order
|
|
557
|
+
const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
|
|
558
|
+
const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
|
|
559
|
+
|
|
560
|
+
if (beginIdx < endIdx) {
|
|
561
|
+
// Normal order — replace content between markers (inclusive)
|
|
562
|
+
// Non-greedy match so we don't over-consume if someone manually
|
|
563
|
+
// duplicated the markers.
|
|
564
|
+
const blockRegex = new RegExp(
|
|
565
|
+
escapeRegExp(CLAUDE_BLOCK_BEGIN) +
|
|
566
|
+
"[\\s\\S]*?" +
|
|
567
|
+
escapeRegExp(CLAUDE_BLOCK_END),
|
|
568
|
+
);
|
|
569
|
+
const replaced = existing.replace(blockRegex, managed);
|
|
570
|
+
if (replaced === existing) {
|
|
571
|
+
// Regex matched but content was already identical
|
|
572
|
+
return "unchanged";
|
|
573
|
+
}
|
|
574
|
+
atomicWriteFile(filePath, replaced);
|
|
575
|
+
return "updated";
|
|
576
|
+
} else {
|
|
577
|
+
// Reversed markers (END before BEGIN) — strip both markers + everything
|
|
578
|
+
// between them, then fall through to append. (ref: forge#291 BUG-2)
|
|
579
|
+
const strippedReversed = existing
|
|
580
|
+
.replace(
|
|
581
|
+
new RegExp(
|
|
582
|
+
escapeRegExp(CLAUDE_BLOCK_END) +
|
|
583
|
+
"[\\s\\S]*?" +
|
|
584
|
+
escapeRegExp(CLAUDE_BLOCK_BEGIN),
|
|
585
|
+
),
|
|
586
|
+
"",
|
|
587
|
+
)
|
|
588
|
+
// Strip any leftover isolated markers
|
|
589
|
+
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
|
|
590
|
+
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "");
|
|
591
|
+
existing = strippedReversed.trimEnd();
|
|
592
|
+
// Fall through to append
|
|
593
|
+
}
|
|
594
|
+
} else if (hasBegin) {
|
|
595
|
+
// Orphaned BEGIN — strip it, then fall through to append. (ref: forge#269)
|
|
596
|
+
existing = existing
|
|
597
|
+
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
|
|
598
|
+
.trimEnd();
|
|
599
|
+
// Fall through to append
|
|
600
|
+
} else if (hasEnd) {
|
|
601
|
+
// Orphaned END — strip it, then fall through to append. (ref: forge#291 BUG-1)
|
|
602
|
+
existing = existing
|
|
603
|
+
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
|
|
604
|
+
.trimEnd();
|
|
605
|
+
// Fall through to append
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// No valid block present — append
|
|
609
|
+
const separator = existing.length > 0 ? "\n\n" : "";
|
|
610
|
+
atomicWriteFile(filePath, existing + separator + managed + "\n");
|
|
611
|
+
return hasBegin || hasEnd ? "updated" : "appended";
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Remove the ForgeDock managed block from a single file.
|
|
616
|
+
* Strips only the marker-bounded section; all surrounding content is preserved.
|
|
617
|
+
* No-ops if the file doesn't exist or contains no markers.
|
|
618
|
+
*
|
|
619
|
+
* Returns:
|
|
620
|
+
* 'removed' — block was found and removed
|
|
621
|
+
* 'not-present' — no block in file (or file doesn't exist)
|
|
622
|
+
*
|
|
623
|
+
* @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
|
|
624
|
+
* @returns {'removed' | 'not-present'}
|
|
625
|
+
*/
|
|
626
|
+
function removeManagedBlock(filePath) {
|
|
627
|
+
if (!existsSync(filePath)) return "not-present";
|
|
628
|
+
|
|
629
|
+
const existing = readFileSync(filePath, "utf-8");
|
|
630
|
+
const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
|
|
631
|
+
const hasEnd = existing.includes(CLAUDE_BLOCK_END);
|
|
632
|
+
|
|
633
|
+
if (!hasBegin && !hasEnd) return "not-present";
|
|
634
|
+
|
|
635
|
+
let cleaned = existing;
|
|
636
|
+
|
|
637
|
+
if (hasBegin && hasEnd) {
|
|
638
|
+
const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
|
|
639
|
+
const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
|
|
640
|
+
|
|
641
|
+
if (beginIdx < endIdx) {
|
|
642
|
+
// Normal block — remove it
|
|
643
|
+
cleaned = existing
|
|
644
|
+
.replace(
|
|
645
|
+
new RegExp(
|
|
646
|
+
escapeRegExp(CLAUDE_BLOCK_BEGIN) +
|
|
647
|
+
"[\\s\\S]*?" +
|
|
648
|
+
escapeRegExp(CLAUDE_BLOCK_END),
|
|
649
|
+
),
|
|
650
|
+
"",
|
|
651
|
+
)
|
|
652
|
+
.trimEnd();
|
|
653
|
+
} else {
|
|
654
|
+
// Reversed — strip both and content between
|
|
655
|
+
cleaned = existing
|
|
656
|
+
.replace(
|
|
657
|
+
new RegExp(
|
|
658
|
+
escapeRegExp(CLAUDE_BLOCK_END) +
|
|
659
|
+
"[\\s\\S]*?" +
|
|
660
|
+
escapeRegExp(CLAUDE_BLOCK_BEGIN),
|
|
661
|
+
),
|
|
662
|
+
"",
|
|
663
|
+
)
|
|
664
|
+
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
|
|
665
|
+
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
|
|
666
|
+
.trimEnd();
|
|
667
|
+
}
|
|
668
|
+
} else {
|
|
669
|
+
// Orphaned markers — strip them
|
|
670
|
+
cleaned = cleaned
|
|
671
|
+
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
|
|
672
|
+
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
|
|
673
|
+
.trimEnd();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (cleaned === existing.trimEnd()) return "not-present";
|
|
677
|
+
|
|
678
|
+
atomicWriteFile(filePath, cleaned.length > 0 ? cleaned + "\n" : "");
|
|
679
|
+
return "removed";
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Escape a string for use inside a RegExp constructor.
|
|
684
|
+
*
|
|
685
|
+
* @param {string} str
|
|
686
|
+
* @returns {string}
|
|
687
|
+
*/
|
|
688
|
+
function escapeRegExp(str) {
|
|
689
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
690
|
+
}
|
|
691
|
+
|
|
118
692
|
async function findMarkdownFiles(dir) {
|
|
119
693
|
const results = [];
|
|
120
694
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -130,13 +704,97 @@ async function findMarkdownFiles(dir) {
|
|
|
130
704
|
}
|
|
131
705
|
|
|
132
706
|
// ---------------------------------------------------------------------------
|
|
133
|
-
//
|
|
707
|
+
// Shell profile FORGE_HOME removal
|
|
134
708
|
// ---------------------------------------------------------------------------
|
|
135
709
|
|
|
136
710
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
711
|
+
* The sentinel comment line written by install() immediately before the
|
|
712
|
+
* `export FORGE_HOME=` line. This is the reliable anchor for removal —
|
|
713
|
+
* it is never present in organic shell profiles.
|
|
714
|
+
*/
|
|
715
|
+
const FORGE_HOME_COMMENT = "# ForgeDock — autonomous development pipeline";
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Remove the ForgeDock FORGE_HOME block from a single shell profile file.
|
|
719
|
+
*
|
|
720
|
+
* install() appends exactly this 2-line block (preceded by a blank line):
|
|
721
|
+
*
|
|
722
|
+
* # ForgeDock — autonomous development pipeline
|
|
723
|
+
* export FORGE_HOME="<path>"
|
|
724
|
+
*
|
|
725
|
+
* This function strips that block (and any orphaned `export FORGE_HOME=...`
|
|
726
|
+
* lines immediately following the comment, or any standalone
|
|
727
|
+
* `export FORGE_HOME=...` lines that have no paired comment). All other
|
|
728
|
+
* content is preserved.
|
|
729
|
+
*
|
|
730
|
+
* Uses an atomic write (tmp file + rename) to avoid partial writes.
|
|
731
|
+
*
|
|
732
|
+
* Returns:
|
|
733
|
+
* 'removed' — block was found and removed
|
|
734
|
+
* 'not-present' — block not found in file (or file does not exist)
|
|
735
|
+
* 'failed' — write error (non-fatal; callers warn and continue)
|
|
736
|
+
*
|
|
737
|
+
* @param {string} profilePath - Absolute path to .bashrc or .zshrc
|
|
738
|
+
* @returns {'removed' | 'not-present' | 'failed'}
|
|
739
|
+
*/
|
|
740
|
+
function removeForgeHomeFromProfile(profilePath) {
|
|
741
|
+
if (!existsSync(profilePath)) return "not-present";
|
|
742
|
+
|
|
743
|
+
let content;
|
|
744
|
+
try {
|
|
745
|
+
content = readFileSync(profilePath, "utf-8");
|
|
746
|
+
} catch {
|
|
747
|
+
return "failed";
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (!content.includes(FORGE_HOME_COMMENT) && !content.includes("export FORGE_HOME=")) {
|
|
751
|
+
return "not-present";
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Step 1: Remove the 2-line ForgeDock block (comment + export), preceded
|
|
755
|
+
// by an optional leading blank line that install() inserts.
|
|
756
|
+
// The \r? handles profiles with CRLF line endings.
|
|
757
|
+
let cleaned = content.replace(
|
|
758
|
+
/\r?\n[ \t]*# ForgeDock — autonomous development pipeline\r?\nexport FORGE_HOME="[^"]*"\r?\n/g,
|
|
759
|
+
"\n",
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
// Step 2: Remove any orphaned `export FORGE_HOME=...` lines that may
|
|
763
|
+
// remain if the comment line was already manually deleted.
|
|
764
|
+
cleaned = cleaned.replace(/^export FORGE_HOME=.*\r?\n/gm, "");
|
|
765
|
+
|
|
766
|
+
// Step 3: Trim trailing blank lines introduced by the removal, but
|
|
767
|
+
// preserve a single trailing newline if the original file had one.
|
|
768
|
+
const hadTrailingNewline = content.endsWith("\n");
|
|
769
|
+
cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
770
|
+
if (cleaned.length > 0 && hadTrailingNewline) {
|
|
771
|
+
cleaned += "\n";
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (cleaned === content) return "not-present";
|
|
775
|
+
|
|
776
|
+
const tmpPath = profilePath + ".forgedock.tmp";
|
|
777
|
+
try {
|
|
778
|
+
writeFileSync(tmpPath, cleaned, "utf-8");
|
|
779
|
+
renameSync(tmpPath, profilePath);
|
|
780
|
+
} catch {
|
|
781
|
+
try {
|
|
782
|
+
unlinkSync(tmpPath);
|
|
783
|
+
} catch {
|
|
784
|
+
/* already gone or never created */
|
|
785
|
+
}
|
|
786
|
+
return "failed";
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return "removed";
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Link (symlink) all commands from COMMANDS_DIR to TARGET_DIR.
|
|
794
|
+
* Called inside a runSteps() step — uses step.progress() and step.note().
|
|
795
|
+
*
|
|
796
|
+
* @param {object} step - StepAPI from runSteps
|
|
797
|
+
* @returns {Promise<{installed: number, updated: number, skipped: number}>}
|
|
140
798
|
*/
|
|
141
799
|
async function linkCommands(step) {
|
|
142
800
|
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
@@ -145,13 +803,14 @@ async function linkCommands(step) {
|
|
|
145
803
|
let updated = 0;
|
|
146
804
|
let skipped = 0;
|
|
147
805
|
|
|
148
|
-
for (let
|
|
149
|
-
const file = files[
|
|
806
|
+
for (let i = 0; i < files.length; i++) {
|
|
807
|
+
const file = files[i];
|
|
150
808
|
const rel = relative(COMMANDS_DIR, file);
|
|
151
809
|
const target = join(TARGET_DIR, rel);
|
|
152
810
|
const targetDir = dirname(target);
|
|
153
811
|
|
|
154
812
|
await mkdir(targetDir, { recursive: true });
|
|
813
|
+
step.progress(i + 1, total);
|
|
155
814
|
|
|
156
815
|
try {
|
|
157
816
|
const stats = await lstat(target);
|
|
@@ -161,299 +820,167 @@ async function linkCommands(step) {
|
|
|
161
820
|
if (current === file) {
|
|
162
821
|
skipped++;
|
|
163
822
|
} else {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
823
|
+
try {
|
|
824
|
+
await symlink(file, target + ".tmp");
|
|
825
|
+
const { rename } = await import("fs/promises");
|
|
826
|
+
await rename(target + ".tmp", target);
|
|
827
|
+
updated++;
|
|
828
|
+
} catch (renameErr) {
|
|
829
|
+
try {
|
|
830
|
+
await unlink(target + ".tmp");
|
|
831
|
+
} catch {
|
|
832
|
+
/* .tmp already gone or was never created */
|
|
833
|
+
}
|
|
834
|
+
if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
|
|
835
|
+
// Windows without Developer Mode: symlink() throws EPERM/EACCES.
|
|
836
|
+
// Fall back to a direct copy (matches new-install fallback behaviour).
|
|
837
|
+
const { copyFile } = await import("fs/promises");
|
|
838
|
+
await copyFile(file, target);
|
|
839
|
+
updated++;
|
|
840
|
+
} else {
|
|
841
|
+
throw renameErr;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
168
844
|
}
|
|
169
845
|
} else {
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
// ForgeDock-managed (we only iterate shipped command names), so refresh
|
|
173
|
-
// it: skip if already current, otherwise overwrite with a fresh copy.
|
|
174
|
-
if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
|
|
175
|
-
skipped++;
|
|
176
|
-
} else {
|
|
177
|
-
copyFileSync(file, target);
|
|
178
|
-
updated++;
|
|
179
|
-
}
|
|
846
|
+
// Regular file — skip; user must remove manually
|
|
847
|
+
skipped++;
|
|
180
848
|
}
|
|
181
849
|
} catch (err) {
|
|
182
|
-
if (err.code !== "ENOENT")
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
// Doesn't exist — create it. Prefer a symlink; fall back to a copy on
|
|
186
|
-
// systems where symlink creation is not permitted (e.g. Windows without
|
|
187
|
-
// Developer Mode / admin), which would otherwise throw EPERM. See #587.
|
|
850
|
+
if (err.code !== "ENOENT") throw err;
|
|
851
|
+
// Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
|
|
188
852
|
try {
|
|
189
853
|
await symlink(file, target);
|
|
190
854
|
} catch (linkErr) {
|
|
191
855
|
if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
|
|
192
|
-
|
|
856
|
+
const { copyFile } = await import("fs/promises");
|
|
857
|
+
await copyFile(file, target);
|
|
193
858
|
} else {
|
|
194
859
|
throw linkErr;
|
|
195
860
|
}
|
|
196
861
|
}
|
|
197
862
|
installed++;
|
|
198
863
|
}
|
|
199
|
-
|
|
200
|
-
step.progress(idx + 1, total);
|
|
201
864
|
}
|
|
202
865
|
|
|
203
866
|
return { installed, updated, skipped };
|
|
204
867
|
}
|
|
205
868
|
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
// Core forge.yaml detection + write — used by both install() and init()
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
|
|
210
869
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* @param {string}
|
|
216
|
-
* @
|
|
217
|
-
* @param {(text: string) => void} [opts.onNote] - Called with incremental progress notes
|
|
218
|
-
* @returns {Promise<{owner, repo, remoteDetected, defaultBranch, stagingBranch, description, outputPath, wrote: boolean, skipped: boolean, skipReason: string}>}
|
|
870
|
+
* Enumerate pipeline-agent scripts in SCRIPTS_DIR that should be installed
|
|
871
|
+
* to ~/.claude/scripts/. Only files present in PIPELINE_SCRIPTS are returned.
|
|
872
|
+
* Subdirectories are not traversed — scripts/ is a flat directory.
|
|
873
|
+
*
|
|
874
|
+
* @param {string} dir - Absolute path to the scripts source directory.
|
|
875
|
+
* @returns {Promise<string[]>} Sorted list of absolute file paths.
|
|
219
876
|
*/
|
|
220
|
-
async function
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
const outputPath = join(cwd, "forge.yaml");
|
|
224
|
-
|
|
225
|
-
const detected = {
|
|
226
|
-
owner: "your-github-org",
|
|
227
|
-
repo: "your-repo-name",
|
|
228
|
-
remoteDetected: false,
|
|
229
|
-
defaultBranch: "main",
|
|
230
|
-
stagingBranch: "staging",
|
|
231
|
-
description: "",
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
// --- Detect git remote URL and parse owner/repo ---
|
|
877
|
+
async function findScriptFiles(dir) {
|
|
878
|
+
const results = [];
|
|
879
|
+
let entries;
|
|
235
880
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}).trim();
|
|
241
|
-
|
|
242
|
-
// SSH: git@github.com:owner/repo.git
|
|
243
|
-
const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
244
|
-
// HTTPS: https://github.com/owner/repo.git
|
|
245
|
-
const httpsMatch = remoteUrl.match(
|
|
246
|
-
/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
|
|
247
|
-
);
|
|
248
|
-
|
|
249
|
-
if (sshMatch) {
|
|
250
|
-
detected.owner = sshMatch[1];
|
|
251
|
-
detected.repo = sshMatch[2];
|
|
252
|
-
detected.remoteDetected = true;
|
|
253
|
-
} else if (httpsMatch) {
|
|
254
|
-
detected.owner = httpsMatch[1];
|
|
255
|
-
detected.repo = httpsMatch[2];
|
|
256
|
-
detected.remoteDetected = true;
|
|
257
|
-
}
|
|
258
|
-
} catch {
|
|
259
|
-
// No git remote — use placeholders
|
|
881
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
882
|
+
} catch (err) {
|
|
883
|
+
if (err.code === "ENOENT") return results; // scripts/ dir absent — skip silently
|
|
884
|
+
throw err;
|
|
260
885
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
? `${detected.owner}/${detected.repo}`
|
|
265
|
-
: "no git remote — using placeholders",
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
// --- Detect default branch ---
|
|
269
|
-
try {
|
|
270
|
-
const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
271
|
-
cwd,
|
|
272
|
-
encoding: "utf-8",
|
|
273
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
274
|
-
}).trim();
|
|
275
|
-
detected.defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
|
|
276
|
-
} catch {
|
|
277
|
-
try {
|
|
278
|
-
detected.defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
279
|
-
cwd,
|
|
280
|
-
encoding: "utf-8",
|
|
281
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
282
|
-
}).trim();
|
|
283
|
-
if (detected.defaultBranch === "HEAD") detected.defaultBranch = "main";
|
|
284
|
-
} catch {
|
|
285
|
-
// Use default "main"
|
|
886
|
+
for (const entry of entries) {
|
|
887
|
+
if (entry.isFile() && PIPELINE_SCRIPTS.has(entry.name)) {
|
|
888
|
+
results.push(join(dir, entry.name));
|
|
286
889
|
}
|
|
287
890
|
}
|
|
891
|
+
return results.sort();
|
|
892
|
+
}
|
|
288
893
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
detected.stagingBranch = detected.defaultBranch;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// --- Auto-detect project description from README.md / CLAUDE.md ---
|
|
304
|
-
function extractFirstParagraph(text) {
|
|
305
|
-
const lines = text.split("\n");
|
|
306
|
-
let inFirstParagraph = false;
|
|
307
|
-
const paragraphLines = [];
|
|
308
|
-
for (const line of lines) {
|
|
309
|
-
if (!inFirstParagraph && line.match(/^#/)) continue;
|
|
310
|
-
if (!inFirstParagraph && line.trim() === "") continue;
|
|
311
|
-
if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
|
|
312
|
-
if (!inFirstParagraph && line.match(/^---/)) continue;
|
|
313
|
-
if (!inFirstParagraph) {
|
|
314
|
-
inFirstParagraph = true;
|
|
315
|
-
}
|
|
316
|
-
if (line.trim() === "") break;
|
|
317
|
-
paragraphLines.push(line.trim());
|
|
318
|
-
}
|
|
319
|
-
if (paragraphLines.length === 0) return "";
|
|
320
|
-
return paragraphLines
|
|
321
|
-
.join(" ")
|
|
322
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
323
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
324
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
325
|
-
.slice(0, 200)
|
|
326
|
-
.trim();
|
|
327
|
-
}
|
|
894
|
+
/**
|
|
895
|
+
* Link or copy all scripts from SCRIPTS_DIR into SCRIPTS_TARGET_DIR (~/.claude/scripts/).
|
|
896
|
+
* Mirrors linkCommands() behaviour: symlink where permitted, copy on EPERM/EACCES (Windows).
|
|
897
|
+
* Called inside a runSteps() step — uses step.progress() and step.note().
|
|
898
|
+
*
|
|
899
|
+
* @param {object} step - StepAPI from runSteps
|
|
900
|
+
* @returns {Promise<{installed: number, updated: number, skipped: number}>}
|
|
901
|
+
*/
|
|
902
|
+
async function linkScripts(step) {
|
|
903
|
+
const files = await findScriptFiles(SCRIPTS_DIR);
|
|
904
|
+
const total = files.length;
|
|
328
905
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const fpath = join(cwd, fname);
|
|
333
|
-
if (existsSync(fpath)) {
|
|
334
|
-
detected.description = extractFirstParagraph(
|
|
335
|
-
readFileSync(fpath, "utf-8").slice(0, 2048),
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
} catch {
|
|
339
|
-
// Best-effort only
|
|
340
|
-
}
|
|
906
|
+
if (total === 0) {
|
|
907
|
+
step.skip("no scripts to link");
|
|
908
|
+
return { installed: 0, updated: 0, skipped: 0 };
|
|
341
909
|
}
|
|
342
910
|
|
|
343
|
-
|
|
344
|
-
const { owner, repo, defaultBranch, stagingBranch, description } = detected;
|
|
345
|
-
|
|
346
|
-
const projectName = repo
|
|
347
|
-
.split(/[-_]/)
|
|
348
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
349
|
-
.join(" ");
|
|
350
|
-
|
|
351
|
-
const content = `# forge.yaml — ForgeDock Configuration
|
|
352
|
-
#
|
|
353
|
-
# Auto-generated by: npx forgedock init
|
|
354
|
-
# Edit this file with your project details.
|
|
355
|
-
#
|
|
356
|
-
# Required sections: project, paths, branches
|
|
357
|
-
# Optional sections: repos, project_board, services, review, verification
|
|
358
|
-
#
|
|
359
|
-
# See docs/CONFIG.md for full reference.
|
|
360
|
-
|
|
361
|
-
# =============================================================================
|
|
362
|
-
# PROJECT (REQUIRED)
|
|
363
|
-
# =============================================================================
|
|
364
|
-
|
|
365
|
-
project:
|
|
366
|
-
name: "${projectName}"
|
|
367
|
-
owner: "${owner}"
|
|
368
|
-
repo: "${repo}"
|
|
369
|
-
description: "${description.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"
|
|
370
|
-
|
|
371
|
-
# =============================================================================
|
|
372
|
-
# PATHS (REQUIRED)
|
|
373
|
-
# =============================================================================
|
|
374
|
-
|
|
375
|
-
paths:
|
|
376
|
-
root: "${cwd.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"
|
|
377
|
-
worktree_base: "${worktreeBase.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"
|
|
378
|
-
|
|
379
|
-
# =============================================================================
|
|
380
|
-
# BRANCHES (REQUIRED)
|
|
381
|
-
# =============================================================================
|
|
382
|
-
|
|
383
|
-
branches:
|
|
384
|
-
default: "${defaultBranch}"
|
|
385
|
-
staging: "${stagingBranch}"
|
|
386
|
-
feature_pattern: "milestone/{slug}"
|
|
387
|
-
|
|
388
|
-
# =============================================================================
|
|
389
|
-
# REPOS (OPTIONAL)
|
|
390
|
-
# Multi-repo configuration. Remove the # to enable.
|
|
391
|
-
# =============================================================================
|
|
392
|
-
|
|
393
|
-
# repos:
|
|
394
|
-
# default:
|
|
395
|
-
# repo: "${owner}/${repo}"
|
|
396
|
-
# staging_branch: "${stagingBranch}"
|
|
397
|
-
# satellites:
|
|
398
|
-
# - prefix: "mcp"
|
|
399
|
-
# repo: "${owner}/your-satellite-repo"
|
|
400
|
-
# staging_branch: "main"
|
|
401
|
-
# local_path: "${join(cwd, "..", "your-satellite-repo")}"
|
|
402
|
-
|
|
403
|
-
# =============================================================================
|
|
404
|
-
# PROJECT BOARD (OPTIONAL)
|
|
405
|
-
# GitHub Projects v2 integration.
|
|
406
|
-
# To find IDs: gh project list --owner ${owner}
|
|
407
|
-
# =============================================================================
|
|
408
|
-
|
|
409
|
-
# project_board:
|
|
410
|
-
# owner: "${owner}"
|
|
411
|
-
# project_number: 1
|
|
412
|
-
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
413
|
-
# field_ids:
|
|
414
|
-
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
415
|
-
# lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
416
|
-
# component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
417
|
-
# priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
418
|
-
# workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
911
|
+
await mkdir(SCRIPTS_TARGET_DIR, { recursive: true });
|
|
419
912
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
# =============================================================================
|
|
913
|
+
let installed = 0;
|
|
914
|
+
let updated = 0;
|
|
915
|
+
let skipped = 0;
|
|
424
916
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
917
|
+
for (let idx = 0; idx < files.length; idx++) {
|
|
918
|
+
const file = files[idx];
|
|
919
|
+
const rel = relative(SCRIPTS_DIR, file);
|
|
920
|
+
const target = join(SCRIPTS_TARGET_DIR, rel);
|
|
429
921
|
|
|
430
|
-
|
|
431
|
-
# VERIFICATION (OPTIONAL)
|
|
432
|
-
# Health-check patterns for quality gate and validate commands.
|
|
433
|
-
# =============================================================================
|
|
922
|
+
step.progress(idx + 1, total);
|
|
434
923
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
# health_patterns:
|
|
438
|
-
# - '"status": "ok"'
|
|
439
|
-
`;
|
|
924
|
+
try {
|
|
925
|
+
const stats = await lstat(target);
|
|
440
926
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
927
|
+
if (stats.isSymbolicLink()) {
|
|
928
|
+
const current = await readlink(target);
|
|
929
|
+
if (current === file) {
|
|
930
|
+
skipped++;
|
|
931
|
+
} else {
|
|
932
|
+
try {
|
|
933
|
+
await symlink(file, target + ".tmp");
|
|
934
|
+
const { rename } = await import("fs/promises");
|
|
935
|
+
await rename(target + ".tmp", target);
|
|
936
|
+
updated++;
|
|
937
|
+
} catch (renameErr) {
|
|
938
|
+
try {
|
|
939
|
+
await unlink(target + ".tmp");
|
|
940
|
+
} catch {
|
|
941
|
+
/* .tmp already gone or was never created */
|
|
942
|
+
}
|
|
943
|
+
if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
|
|
944
|
+
// Windows without Developer Mode: symlink() throws EPERM/EACCES.
|
|
945
|
+
// Fall back to a direct copy (matches new-install fallback behaviour).
|
|
946
|
+
const { copyFile } = await import("fs/promises");
|
|
947
|
+
await copyFile(file, target);
|
|
948
|
+
updated++;
|
|
949
|
+
} else {
|
|
950
|
+
throw renameErr;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
} else {
|
|
955
|
+
// Existing regular file — refresh if contents differ.
|
|
956
|
+
if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
|
|
957
|
+
skipped++;
|
|
958
|
+
} else {
|
|
959
|
+
const { copyFile } = await import("fs/promises");
|
|
960
|
+
await copyFile(file, target);
|
|
961
|
+
updated++;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
} catch (err) {
|
|
965
|
+
if (err.code !== "ENOENT") throw err;
|
|
966
|
+
// Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
|
|
967
|
+
try {
|
|
968
|
+
await symlink(file, target);
|
|
969
|
+
} catch (linkErr) {
|
|
970
|
+
if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
|
|
971
|
+
const { copyFile } = await import("fs/promises");
|
|
972
|
+
await copyFile(file, target);
|
|
973
|
+
} else {
|
|
974
|
+
throw linkErr;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
installed++;
|
|
978
|
+
}
|
|
448
979
|
}
|
|
449
980
|
|
|
450
|
-
return {
|
|
981
|
+
return { installed, updated, skipped };
|
|
451
982
|
}
|
|
452
983
|
|
|
453
|
-
// ---------------------------------------------------------------------------
|
|
454
|
-
// Subcommands
|
|
455
|
-
// ---------------------------------------------------------------------------
|
|
456
|
-
|
|
457
984
|
async function install() {
|
|
458
985
|
const result = await runSteps([
|
|
459
986
|
{
|
|
@@ -467,25 +994,53 @@ async function install() {
|
|
|
467
994
|
label: "Linking commands",
|
|
468
995
|
async run(step) {
|
|
469
996
|
const { installed, updated, skipped } = await linkCommands(step);
|
|
997
|
+
const total = installed + updated + skipped;
|
|
470
998
|
step.note(
|
|
471
|
-
`${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
|
|
999
|
+
`${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped (${total} commands total)`,
|
|
472
1000
|
);
|
|
473
1001
|
},
|
|
474
1002
|
},
|
|
475
1003
|
{
|
|
476
|
-
label: "
|
|
1004
|
+
label: "Linking scripts",
|
|
1005
|
+
async run(step) {
|
|
1006
|
+
const { installed, updated, skipped } = await linkScripts(step);
|
|
1007
|
+
if (installed + updated + skipped > 0) {
|
|
1008
|
+
step.note(
|
|
1009
|
+
`${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
label: "Configuring shell profile",
|
|
477
1016
|
async run(step) {
|
|
1017
|
+
// Set FORGE_HOME in shell profiles
|
|
478
1018
|
let profileUpdated = false;
|
|
479
1019
|
for (const profile of [
|
|
480
|
-
join(
|
|
481
|
-
join(
|
|
1020
|
+
join(HOME, ".bashrc"),
|
|
1021
|
+
join(HOME, ".zshrc"),
|
|
482
1022
|
]) {
|
|
483
1023
|
if (existsSync(profile)) {
|
|
484
1024
|
const content = readFileSync(profile, "utf-8");
|
|
485
|
-
|
|
1025
|
+
// Escape shell metacharacters in FORGE_HOME before embedding it in
|
|
1026
|
+
// a double-quoted shell assignment. A crafted install path
|
|
1027
|
+
// containing $(...), backticks, or " characters would otherwise
|
|
1028
|
+
// inject arbitrary shell commands executed on the user's next login.
|
|
1029
|
+
// shellEscapeDoubleQuotedPath() applies the same escaping used for
|
|
1030
|
+
// the SessionStart hook command (ref: forge#808, forge#792, forge#451).
|
|
1031
|
+
const safeForgeHome = shellEscapeDoubleQuotedPath(FORGE_HOME);
|
|
1032
|
+
const exactExport = `export FORGE_HOME="${safeForgeHome}"`;
|
|
1033
|
+
if (content.includes(exactExport)) {
|
|
1034
|
+
// Already set to the current path — no update needed.
|
|
1035
|
+
} else {
|
|
1036
|
+
// Either not present, or set to a stale path — refresh it.
|
|
1037
|
+
if (content.includes("FORGE_HOME")) {
|
|
1038
|
+
// Strip the old block before appending the updated one.
|
|
1039
|
+
removeForgeHomeFromProfile(profile);
|
|
1040
|
+
}
|
|
486
1041
|
appendFileSync(
|
|
487
1042
|
profile,
|
|
488
|
-
`\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${
|
|
1043
|
+
`\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${safeForgeHome}"\n`,
|
|
489
1044
|
);
|
|
490
1045
|
profileUpdated = true;
|
|
491
1046
|
}
|
|
@@ -496,13 +1051,23 @@ async function install() {
|
|
|
496
1051
|
}
|
|
497
1052
|
},
|
|
498
1053
|
},
|
|
1054
|
+
{
|
|
1055
|
+
label: "Registering SessionStart hook",
|
|
1056
|
+
async run(step) {
|
|
1057
|
+
const hookResult = await installSessionStartHook();
|
|
1058
|
+
if (hookResult === "already-present") {
|
|
1059
|
+
step.skip("already present");
|
|
1060
|
+
} else if (hookResult === "failed") {
|
|
1061
|
+
// Non-fatal — warn but don't throw
|
|
1062
|
+
step.note("could not write — run npx forgedock install again to retry");
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
499
1066
|
{
|
|
500
1067
|
label: "Generating forge.yaml",
|
|
501
1068
|
async run(step) {
|
|
502
1069
|
// Auto-generate forge.yaml if missing — no second command needed.
|
|
503
|
-
// Only do this inside a real git project
|
|
504
|
-
// read-only directory (e.g. C:\Windows when npx is launched from a WSL path),
|
|
505
|
-
// and writing forge.yaml there would crash. See #585.
|
|
1070
|
+
// Only do this inside a real git project (ref: forge#585).
|
|
506
1071
|
const cwd = process.cwd();
|
|
507
1072
|
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
508
1073
|
if (existsSync(forgeYamlPath)) {
|
|
@@ -510,19 +1075,47 @@ async function install() {
|
|
|
510
1075
|
return;
|
|
511
1076
|
}
|
|
512
1077
|
if (!isGitWorkTree(cwd)) {
|
|
513
|
-
step.skip(
|
|
514
|
-
|
|
515
|
-
|
|
1078
|
+
step.skip("not a git project — run npx forgedock init inside your project");
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
await init(true);
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
label: "Updating CLAUDE.md",
|
|
1086
|
+
async run(step) {
|
|
1087
|
+
// Inject behavioral rules into CLAUDE.md (and AGENTS.md if present).
|
|
1088
|
+
// Only inside a git project — guards against non-project cwd (ref: forge#585).
|
|
1089
|
+
const installCwd = process.cwd();
|
|
1090
|
+
if (!isGitWorkTree(installCwd)) {
|
|
1091
|
+
step.skip("not a git project");
|
|
516
1092
|
return;
|
|
517
1093
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
1094
|
+
|
|
1095
|
+
const claudeMdPath = join(installCwd, "CLAUDE.md");
|
|
1096
|
+
const agentsMdPath = join(installCwd, "AGENTS.md");
|
|
1097
|
+
|
|
1098
|
+
const claudeResult = injectManagedBlock(claudeMdPath);
|
|
1099
|
+
let note = "";
|
|
1100
|
+
if (claudeResult === "created") {
|
|
1101
|
+
note = "CLAUDE.md created";
|
|
1102
|
+
} else if (claudeResult === "updated") {
|
|
1103
|
+
note = "CLAUDE.md block refreshed";
|
|
1104
|
+
} else if (claudeResult === "appended") {
|
|
1105
|
+
note = "CLAUDE.md rules appended";
|
|
523
1106
|
} else {
|
|
524
|
-
|
|
1107
|
+
note = "CLAUDE.md already current";
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Mirror to AGENTS.md only if it already exists (never create it)
|
|
1111
|
+
if (existsSync(agentsMdPath)) {
|
|
1112
|
+
const agentsResult = injectManagedBlock(agentsMdPath);
|
|
1113
|
+
if (agentsResult === "updated" || agentsResult === "appended") {
|
|
1114
|
+
note += " · AGENTS.md mirrored";
|
|
1115
|
+
}
|
|
525
1116
|
}
|
|
1117
|
+
|
|
1118
|
+
step.note(note);
|
|
526
1119
|
},
|
|
527
1120
|
},
|
|
528
1121
|
]);
|
|
@@ -534,6 +1127,7 @@ async function install() {
|
|
|
534
1127
|
`${green("✔")} ${bold("ForgeDock is ready")}`,
|
|
535
1128
|
"",
|
|
536
1129
|
`Commands installed to ${cyan(TARGET_DIR)}`,
|
|
1130
|
+
`Scripts installed to ${cyan(SCRIPTS_TARGET_DIR)}`,
|
|
537
1131
|
`Next: ${cyan("cd <your-project>")} then ${cyan("npx forgedock init")}`,
|
|
538
1132
|
],
|
|
539
1133
|
{ title: "ForgeDock Installed" },
|
|
@@ -543,204 +1137,431 @@ async function install() {
|
|
|
543
1137
|
}
|
|
544
1138
|
|
|
545
1139
|
async function uninstall() {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
async run(step) {
|
|
550
|
-
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
551
|
-
const total = files.length;
|
|
552
|
-
let removed = 0;
|
|
1140
|
+
console.log("");
|
|
1141
|
+
console.log(`${BOLD}ForgeDock${RESET} — Removing pipeline commands`);
|
|
1142
|
+
console.log("");
|
|
553
1143
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const rel = relative(COMMANDS_DIR, file);
|
|
557
|
-
const target = join(TARGET_DIR, rel);
|
|
1144
|
+
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
1145
|
+
let removed = 0;
|
|
558
1146
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (stats.isSymbolicLink()) {
|
|
563
|
-
const current = await readlink(target);
|
|
564
|
-
if (current === file) {
|
|
565
|
-
await unlink(target);
|
|
566
|
-
removed++;
|
|
567
|
-
}
|
|
568
|
-
} else if (
|
|
569
|
-
readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
|
|
570
|
-
) {
|
|
571
|
-
// Regular file installed by ForgeDock in copy mode (content matches the
|
|
572
|
-
// shipped command). Safe to remove. Content-differing files are left
|
|
573
|
-
// in place in case the user edited them.
|
|
574
|
-
await unlink(target);
|
|
575
|
-
removed++;
|
|
576
|
-
}
|
|
577
|
-
} catch (err) {
|
|
578
|
-
if (err.code !== "ENOENT") {
|
|
579
|
-
throw err;
|
|
580
|
-
}
|
|
581
|
-
// Doesn't exist — nothing to do
|
|
582
|
-
}
|
|
1147
|
+
for (const file of files) {
|
|
1148
|
+
const rel = relative(COMMANDS_DIR, file);
|
|
1149
|
+
const target = join(TARGET_DIR, rel);
|
|
583
1150
|
|
|
584
|
-
|
|
1151
|
+
try {
|
|
1152
|
+
const stats = await lstat(target);
|
|
1153
|
+
if (stats.isSymbolicLink()) {
|
|
1154
|
+
const current = await readlink(target);
|
|
1155
|
+
if (current === file) {
|
|
1156
|
+
const { unlink } = await import("fs/promises");
|
|
1157
|
+
await unlink(target);
|
|
1158
|
+
console.log(` ${RED}Removed${RESET}: ${rel}`);
|
|
1159
|
+
removed++;
|
|
585
1160
|
}
|
|
1161
|
+
}
|
|
1162
|
+
} catch {
|
|
1163
|
+
// Doesn't exist — nothing to do
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
586
1166
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
]);
|
|
1167
|
+
console.log("");
|
|
1168
|
+
console.log(`Done. Removed: ${removed} commands.`);
|
|
1169
|
+
console.log("");
|
|
591
1170
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
1171
|
+
// Remove scripts installed by ForgeDock from ~/.claude/scripts/
|
|
1172
|
+
const scriptFiles = await findScriptFiles(SCRIPTS_DIR);
|
|
1173
|
+
let scriptsRemoved = 0;
|
|
1174
|
+
|
|
1175
|
+
for (const file of scriptFiles) {
|
|
1176
|
+
const rel = relative(SCRIPTS_DIR, file);
|
|
1177
|
+
const target = join(SCRIPTS_TARGET_DIR, rel);
|
|
1178
|
+
|
|
1179
|
+
try {
|
|
1180
|
+
const stats = await lstat(target);
|
|
1181
|
+
const { unlink } = await import("fs/promises");
|
|
1182
|
+
if (stats.isSymbolicLink()) {
|
|
1183
|
+
const current = await readlink(target);
|
|
1184
|
+
if (current === file) {
|
|
1185
|
+
await unlink(target);
|
|
1186
|
+
console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
|
|
1187
|
+
scriptsRemoved++;
|
|
1188
|
+
}
|
|
1189
|
+
} else if (
|
|
1190
|
+
readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
|
|
1191
|
+
) {
|
|
1192
|
+
// Regular file installed by ForgeDock in copy mode — content matches.
|
|
1193
|
+
await unlink(target);
|
|
1194
|
+
console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
|
|
1195
|
+
scriptsRemoved++;
|
|
1196
|
+
}
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
if (err.code !== "ENOENT") throw err;
|
|
1199
|
+
// Doesn't exist — nothing to do
|
|
1200
|
+
}
|
|
603
1201
|
}
|
|
604
|
-
}
|
|
605
1202
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
await runSteps([
|
|
611
|
-
{
|
|
612
|
-
label: "Checking update path",
|
|
613
|
-
async run(step) {
|
|
614
|
-
step.skip(`installed via npm — run ${cyan("npm update -g forgedock")}`);
|
|
615
|
-
},
|
|
616
|
-
},
|
|
617
|
-
]);
|
|
618
|
-
return;
|
|
1203
|
+
if (scriptFiles.length > 0) {
|
|
1204
|
+
console.log("");
|
|
1205
|
+
console.log(`Done. Removed: ${scriptsRemoved} scripts.`);
|
|
1206
|
+
console.log("");
|
|
619
1207
|
}
|
|
620
1208
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
label: "Fetching latest",
|
|
638
|
-
async run(step) {
|
|
639
|
-
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
640
|
-
cwd: FORGE_HOME,
|
|
641
|
-
encoding: "utf-8",
|
|
642
|
-
}).trim();
|
|
643
|
-
if (branch !== "main") {
|
|
644
|
-
step.skip("skipped — not on main");
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
const before = execSync("git rev-parse HEAD", {
|
|
648
|
-
cwd: FORGE_HOME,
|
|
649
|
-
encoding: "utf-8",
|
|
650
|
-
}).trim();
|
|
651
|
-
try {
|
|
652
|
-
execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
|
|
653
|
-
execSync("git merge --ff-only origin/main --quiet", {
|
|
654
|
-
cwd: FORGE_HOME,
|
|
655
|
-
});
|
|
656
|
-
} catch {
|
|
657
|
-
step.skip("cannot fast-forward — local changes exist");
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
const after = execSync("git rev-parse HEAD", {
|
|
661
|
-
cwd: FORGE_HOME,
|
|
662
|
-
encoding: "utf-8",
|
|
663
|
-
}).trim();
|
|
664
|
-
if (before === after) {
|
|
665
|
-
step.skip("already up to date");
|
|
666
|
-
} else {
|
|
667
|
-
step.note(`${before.slice(0, 7)} → ${after.slice(0, 7)}`);
|
|
668
|
-
}
|
|
669
|
-
},
|
|
670
|
-
},
|
|
671
|
-
{
|
|
672
|
-
label: "Reinstalling commands",
|
|
673
|
-
async run(step) {
|
|
674
|
-
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
675
|
-
cwd: FORGE_HOME,
|
|
676
|
-
encoding: "utf-8",
|
|
677
|
-
}).trim();
|
|
678
|
-
if (branch !== "main") {
|
|
679
|
-
step.skip("skipped — not on main");
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
const { installed, updated, skipped } = await linkCommands(step);
|
|
683
|
-
step.note(
|
|
684
|
-
`${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
|
|
685
|
-
);
|
|
686
|
-
},
|
|
687
|
-
},
|
|
688
|
-
]);
|
|
1209
|
+
// Remove the SessionStart hook from ~/.claude/settings.json
|
|
1210
|
+
const hookRemoveResult = await removeSessionStartHook();
|
|
1211
|
+
if (hookRemoveResult === "removed") {
|
|
1212
|
+
console.log(
|
|
1213
|
+
` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}~/.claude/settings.json${RESET}`,
|
|
1214
|
+
);
|
|
1215
|
+
} else if (hookRemoveResult === "not-present") {
|
|
1216
|
+
console.log(
|
|
1217
|
+
` ✔ SessionStart hook already absent from ~/.claude/settings.json`,
|
|
1218
|
+
);
|
|
1219
|
+
} else {
|
|
1220
|
+
console.log(
|
|
1221
|
+
` ${YELLOW}⚠${RESET} Could not update ~/.claude/settings.json — remove the hook manually.`,
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
console.log("");
|
|
689
1225
|
|
|
690
|
-
if
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1226
|
+
// Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present)
|
|
1227
|
+
const uninstallCwd = process.cwd();
|
|
1228
|
+
const claudeMdPath = join(uninstallCwd, "CLAUDE.md");
|
|
1229
|
+
const agentsMdPath = join(uninstallCwd, "AGENTS.md");
|
|
1230
|
+
|
|
1231
|
+
const claudeRemoveResult = removeManagedBlock(claudeMdPath);
|
|
1232
|
+
if (claudeRemoveResult === "removed") {
|
|
1233
|
+
console.log(
|
|
1234
|
+
` ${GREEN}✔${RESET} Removed ForgeDock pipeline rules from ${CYAN}CLAUDE.md${RESET}`,
|
|
695
1235
|
);
|
|
1236
|
+
} else {
|
|
1237
|
+
console.log(` ✔ No ForgeDock block in CLAUDE.md — nothing to remove`);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (existsSync(agentsMdPath)) {
|
|
1241
|
+
const agentsRemoveResult = removeManagedBlock(agentsMdPath);
|
|
1242
|
+
if (agentsRemoveResult === "removed") {
|
|
1243
|
+
console.log(
|
|
1244
|
+
` ${GREEN}✔${RESET} Removed ForgeDock pipeline rules from ${CYAN}AGENTS.md${RESET}`,
|
|
1245
|
+
);
|
|
1246
|
+
} else {
|
|
1247
|
+
console.log(` ✔ No ForgeDock block in AGENTS.md — nothing to remove`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
console.log("");
|
|
1251
|
+
|
|
1252
|
+
// Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc)
|
|
1253
|
+
for (const profileName of [".bashrc", ".zshrc"]) {
|
|
1254
|
+
const profilePath = join(HOME, profileName);
|
|
1255
|
+
const profileResult = removeForgeHomeFromProfile(profilePath);
|
|
1256
|
+
if (profileResult === "removed") {
|
|
1257
|
+
console.log(
|
|
1258
|
+
` ${GREEN}✔${RESET} Removed FORGE_HOME export from ${CYAN}~/${profileName}${RESET}`,
|
|
1259
|
+
);
|
|
1260
|
+
} else if (profileResult === "not-present") {
|
|
1261
|
+
console.log(` ✔ No FORGE_HOME entry in ~/${profileName} — nothing to remove`);
|
|
1262
|
+
} else {
|
|
1263
|
+
console.log(
|
|
1264
|
+
` ${YELLOW}⚠${RESET} Could not update ~/${profileName} — remove the FORGE_HOME export manually.`,
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
console.log("");
|
|
1269
|
+
|
|
1270
|
+
// Remove .forgedock marker file from cwd (created by `npx forgedock enable`)
|
|
1271
|
+
const markerPath = join(uninstallCwd, ".forgedock");
|
|
1272
|
+
if (existsSync(markerPath)) {
|
|
1273
|
+
try {
|
|
1274
|
+
unlinkSync(markerPath);
|
|
1275
|
+
console.log(
|
|
1276
|
+
` ${GREEN}✔${RESET} Removed ${CYAN}.forgedock${RESET} marker from ${CYAN}${uninstallCwd}${RESET}`,
|
|
1277
|
+
);
|
|
1278
|
+
} catch {
|
|
1279
|
+
console.log(
|
|
1280
|
+
` ${YELLOW}⚠${RESET} Could not remove .forgedock marker — remove it manually: ${markerPath}`,
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
} else {
|
|
1284
|
+
console.log(` ✔ No .forgedock marker in current directory — nothing to remove`);
|
|
696
1285
|
}
|
|
1286
|
+
console.log("");
|
|
1287
|
+
|
|
1288
|
+
// Clean up registry nudgeSeen entry for cwd (written by the session-start hook)
|
|
1289
|
+
try {
|
|
1290
|
+
const { clearNudgeSeen } = await import(
|
|
1291
|
+
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1292
|
+
);
|
|
1293
|
+
await clearNudgeSeen(uninstallCwd);
|
|
1294
|
+
console.log(
|
|
1295
|
+
` ${GREEN}✔${RESET} Cleaned registry entries for ${CYAN}${uninstallCwd}${RESET}`,
|
|
1296
|
+
);
|
|
1297
|
+
} catch {
|
|
1298
|
+
// Non-fatal — registry is best-effort
|
|
1299
|
+
console.log(
|
|
1300
|
+
` ${YELLOW}⚠${RESET} Could not clean registry entries — safe to ignore.`,
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
console.log("");
|
|
1304
|
+
|
|
1305
|
+
console.log(`${GREEN}ForgeDock uninstalled.${RESET} No residue remains from this directory.`);
|
|
1306
|
+
console.log(` Re-install at any time with: ${CYAN}npx forgedock${RESET}`);
|
|
1307
|
+
console.log("");
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async function update() {
|
|
1311
|
+
console.log("");
|
|
1312
|
+
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
1313
|
+
console.log("");
|
|
1314
|
+
|
|
1315
|
+
// Check if installed via npm (no .git directory) or via git clone
|
|
1316
|
+
const gitDir = join(FORGE_HOME, ".git");
|
|
1317
|
+
if (existsSync(gitDir)) {
|
|
1318
|
+
try {
|
|
1319
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1320
|
+
cwd: FORGE_HOME,
|
|
1321
|
+
encoding: "utf-8",
|
|
1322
|
+
}).trim();
|
|
1323
|
+
if (branch !== "main") {
|
|
1324
|
+
console.log(` Not on main branch (${branch}) — skipping`);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const before = execSync("git rev-parse HEAD", {
|
|
1329
|
+
cwd: FORGE_HOME,
|
|
1330
|
+
encoding: "utf-8",
|
|
1331
|
+
}).trim();
|
|
1332
|
+
execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
|
|
1333
|
+
execSync("git merge --ff-only origin/main --quiet", {
|
|
1334
|
+
cwd: FORGE_HOME,
|
|
1335
|
+
});
|
|
1336
|
+
const after = execSync("git rev-parse HEAD", {
|
|
1337
|
+
cwd: FORGE_HOME,
|
|
1338
|
+
encoding: "utf-8",
|
|
1339
|
+
}).trim();
|
|
1340
|
+
|
|
1341
|
+
if (before === after) {
|
|
1342
|
+
console.log(` Already up to date.`);
|
|
1343
|
+
} else {
|
|
1344
|
+
console.log(` ${GREEN}Updated to latest.${RESET}`);
|
|
1345
|
+
await install();
|
|
1346
|
+
}
|
|
1347
|
+
} catch (err) {
|
|
1348
|
+
console.log(
|
|
1349
|
+
` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
} else {
|
|
1353
|
+
console.log(
|
|
1354
|
+
` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
console.log("");
|
|
697
1358
|
}
|
|
698
1359
|
|
|
699
1360
|
async function init(fromInstall = false) {
|
|
1361
|
+
// When called from install() via runSteps(), suppress stdout to prevent
|
|
1362
|
+
// interleaving with the TUI spinner on stderr. runSteps() provides the
|
|
1363
|
+
// "Generating forge.yaml" step label as visual feedback. (#812)
|
|
1364
|
+
const log = fromInstall ? () => {} : console.log;
|
|
1365
|
+
log("");
|
|
1366
|
+
log(`${BOLD}ForgeDock${RESET} — Generating forge.yaml`);
|
|
1367
|
+
log("");
|
|
1368
|
+
|
|
700
1369
|
const cwd = process.cwd();
|
|
1370
|
+
const worktreeBase = join(cwd, ".claude", "worktrees");
|
|
701
1371
|
const outputPath = join(cwd, "forge.yaml");
|
|
702
1372
|
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1373
|
+
// --- Detect git remote URL and parse owner/repo ---
|
|
1374
|
+
let owner = "your-github-org";
|
|
1375
|
+
let repo = "your-repo-name";
|
|
1376
|
+
let remoteDetected = false;
|
|
1377
|
+
|
|
1378
|
+
try {
|
|
1379
|
+
const remoteUrl = execSync("git remote get-url origin", {
|
|
1380
|
+
cwd,
|
|
1381
|
+
encoding: "utf-8",
|
|
1382
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1383
|
+
}).trim();
|
|
1384
|
+
|
|
1385
|
+
// SSH: git@github.com:owner/repo.git
|
|
1386
|
+
const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
1387
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
1388
|
+
const httpsMatch = remoteUrl.match(
|
|
1389
|
+
/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
|
|
1390
|
+
);
|
|
1391
|
+
|
|
1392
|
+
if (sshMatch) {
|
|
1393
|
+
owner = sshMatch[1];
|
|
1394
|
+
repo = sshMatch[2];
|
|
1395
|
+
remoteDetected = true;
|
|
1396
|
+
} else if (httpsMatch) {
|
|
1397
|
+
owner = httpsMatch[1];
|
|
1398
|
+
repo = httpsMatch[2];
|
|
1399
|
+
remoteDetected = true;
|
|
1400
|
+
} else {
|
|
1401
|
+
log(
|
|
1402
|
+
` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
|
|
717
1403
|
);
|
|
718
1404
|
}
|
|
719
|
-
|
|
1405
|
+
} catch {
|
|
1406
|
+
log(
|
|
1407
|
+
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
|
|
1408
|
+
);
|
|
720
1409
|
}
|
|
721
1410
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1411
|
+
if (remoteDetected) {
|
|
1412
|
+
log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
|
|
1413
|
+
}
|
|
725
1414
|
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1415
|
+
// --- Detect default branch ---
|
|
1416
|
+
let defaultBranch = "main";
|
|
1417
|
+
try {
|
|
1418
|
+
const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
1419
|
+
cwd,
|
|
1420
|
+
encoding: "utf-8",
|
|
1421
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1422
|
+
}).trim();
|
|
1423
|
+
// refs/remotes/origin/main → main
|
|
1424
|
+
defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
|
|
1425
|
+
log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
|
|
1426
|
+
} catch {
|
|
1427
|
+
// Fallback: try git rev-parse
|
|
1428
|
+
try {
|
|
1429
|
+
defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1430
|
+
cwd,
|
|
1431
|
+
encoding: "utf-8",
|
|
1432
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1433
|
+
}).trim();
|
|
1434
|
+
if (defaultBranch === "HEAD") defaultBranch = "main";
|
|
1435
|
+
log(
|
|
1436
|
+
` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
|
|
1437
|
+
);
|
|
1438
|
+
} catch {
|
|
1439
|
+
log(
|
|
1440
|
+
` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// --- Detect staging branch ---
|
|
1446
|
+
let stagingBranch = "staging";
|
|
1447
|
+
try {
|
|
1448
|
+
const remoteBranches = execSync("git branch -r", {
|
|
1449
|
+
cwd,
|
|
1450
|
+
encoding: "utf-8",
|
|
1451
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1452
|
+
});
|
|
1453
|
+
if (remoteBranches.includes("origin/staging")) {
|
|
1454
|
+
stagingBranch = "staging";
|
|
1455
|
+
log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
|
|
1456
|
+
} else {
|
|
1457
|
+
stagingBranch = defaultBranch;
|
|
1458
|
+
log(
|
|
1459
|
+
` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
} catch {
|
|
1463
|
+
log(
|
|
1464
|
+
` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
|
|
730
1465
|
);
|
|
731
|
-
|
|
732
|
-
|
|
1466
|
+
stagingBranch = defaultBranch;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// --- Auto-detect project description from README.md ---
|
|
1470
|
+
let description = "";
|
|
1471
|
+
try {
|
|
1472
|
+
const readmePath = join(cwd, "README.md");
|
|
1473
|
+
if (existsSync(readmePath)) {
|
|
1474
|
+
const readmeContent = readFileSync(readmePath, "utf-8").slice(0, 2048);
|
|
1475
|
+
// Skip the first heading line (# Title), grab the first non-empty paragraph
|
|
1476
|
+
const lines = readmeContent.split("\n");
|
|
1477
|
+
let inFirstParagraph = false;
|
|
1478
|
+
const paragraphLines = [];
|
|
1479
|
+
for (const line of lines) {
|
|
1480
|
+
// Skip heading lines at the top
|
|
1481
|
+
if (!inFirstParagraph && line.match(/^#/)) continue;
|
|
1482
|
+
// Skip blank lines before paragraph starts
|
|
1483
|
+
if (!inFirstParagraph && line.trim() === "") continue;
|
|
1484
|
+
// Skip lines that are just badges, HTML, code fences, horizontal rules, or tables
|
|
1485
|
+
if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
|
|
1486
|
+
if (!inFirstParagraph && line.match(/^---/)) continue;
|
|
1487
|
+
// Start collecting
|
|
1488
|
+
if (!inFirstParagraph) {
|
|
1489
|
+
inFirstParagraph = true;
|
|
1490
|
+
}
|
|
1491
|
+
// Stop at blank line (end of paragraph)
|
|
1492
|
+
if (line.trim() === "") break;
|
|
1493
|
+
paragraphLines.push(line.trim());
|
|
1494
|
+
}
|
|
1495
|
+
if (paragraphLines.length > 0) {
|
|
1496
|
+
// Flatten to single line, strip markdown links/bold/inline code
|
|
1497
|
+
description = paragraphLines
|
|
1498
|
+
.join(" ")
|
|
1499
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
1500
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
1501
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
1502
|
+
.slice(0, 200)
|
|
1503
|
+
.trim();
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
} catch {
|
|
1507
|
+
// Best-effort only — silently fall back to empty description
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (description) {
|
|
1511
|
+
log(
|
|
1512
|
+
` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from README.md)`,
|
|
733
1513
|
);
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// --- Auto-detect project description from CLAUDE.md (fallback) ---
|
|
1517
|
+
if (!description) {
|
|
1518
|
+
try {
|
|
1519
|
+
const claudePath = join(cwd, "CLAUDE.md");
|
|
1520
|
+
if (existsSync(claudePath)) {
|
|
1521
|
+
const claudeContent = readFileSync(claudePath, "utf-8").slice(0, 2048);
|
|
1522
|
+
const lines = claudeContent.split("\n");
|
|
1523
|
+
let inFirstParagraph = false;
|
|
1524
|
+
const paragraphLines = [];
|
|
1525
|
+
for (const line of lines) {
|
|
1526
|
+
// Skip heading lines at the top
|
|
1527
|
+
if (!inFirstParagraph && line.match(/^#/)) continue;
|
|
1528
|
+
// Skip blank lines before paragraph starts
|
|
1529
|
+
if (!inFirstParagraph && line.trim() === "") continue;
|
|
1530
|
+
// Skip lines that are just badges, HTML comments, code fences, horizontal rules, or tables
|
|
1531
|
+
if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
|
|
1532
|
+
if (!inFirstParagraph && line.match(/^---/)) continue;
|
|
1533
|
+
// Start collecting
|
|
1534
|
+
if (!inFirstParagraph) {
|
|
1535
|
+
inFirstParagraph = true;
|
|
1536
|
+
}
|
|
1537
|
+
// Stop at blank line (end of paragraph)
|
|
1538
|
+
if (line.trim() === "") break;
|
|
1539
|
+
paragraphLines.push(line.trim());
|
|
1540
|
+
}
|
|
1541
|
+
if (paragraphLines.length > 0) {
|
|
1542
|
+
// Flatten to single line, strip markdown links/bold/inline code
|
|
1543
|
+
description = paragraphLines
|
|
1544
|
+
.join(" ")
|
|
1545
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
1546
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
1547
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
1548
|
+
.slice(0, 200)
|
|
1549
|
+
.trim();
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
} catch {
|
|
1553
|
+
// Best-effort only — silently fall back to empty description
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
if (description) {
|
|
1557
|
+
log(
|
|
1558
|
+
` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from CLAUDE.md)`,
|
|
738
1559
|
);
|
|
739
|
-
return;
|
|
740
1560
|
}
|
|
741
|
-
|
|
1561
|
+
}
|
|
742
1562
|
|
|
743
|
-
|
|
1563
|
+
// --- Handle existing forge.yaml ---
|
|
1564
|
+
if (existsSync(outputPath)) {
|
|
744
1565
|
const baseBak = join(cwd, "forge.yaml.bak");
|
|
745
1566
|
const backupPath = existsSync(baseBak)
|
|
746
1567
|
? join(
|
|
@@ -750,65 +1571,680 @@ async function init(fromInstall = false) {
|
|
|
750
1571
|
: baseBak;
|
|
751
1572
|
const backupName = backupPath.split("/").pop();
|
|
752
1573
|
renameSync(outputPath, backupPath);
|
|
753
|
-
|
|
1574
|
+
log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
|
|
754
1575
|
}
|
|
755
1576
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
onNote: (msg) => step.note(msg),
|
|
762
|
-
});
|
|
763
|
-
},
|
|
764
|
-
},
|
|
765
|
-
]);
|
|
1577
|
+
// --- Generate forge.yaml content ---
|
|
1578
|
+
const projectName = repo
|
|
1579
|
+
.split(/[-_]/)
|
|
1580
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1581
|
+
.join(" ");
|
|
766
1582
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1583
|
+
const content = `# forge.yaml — ForgeDock Configuration
|
|
1584
|
+
#
|
|
1585
|
+
# Auto-generated by: npx forgedock init
|
|
1586
|
+
# Edit this file with your project details.
|
|
1587
|
+
#
|
|
1588
|
+
# Required sections: project, paths, branches
|
|
1589
|
+
# Optional sections: repos, project_board, services, review, verification
|
|
1590
|
+
#
|
|
1591
|
+
# See docs/CONFIG.md for full reference.
|
|
1592
|
+
|
|
1593
|
+
# =============================================================================
|
|
1594
|
+
# PROJECT (REQUIRED)
|
|
1595
|
+
# =============================================================================
|
|
1596
|
+
|
|
1597
|
+
project:
|
|
1598
|
+
name: "${projectName}"
|
|
1599
|
+
owner: "${owner}"
|
|
1600
|
+
repo: "${repo}"
|
|
1601
|
+
description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1602
|
+
|
|
1603
|
+
# =============================================================================
|
|
1604
|
+
# PATHS (REQUIRED)
|
|
1605
|
+
# =============================================================================
|
|
1606
|
+
|
|
1607
|
+
paths:
|
|
1608
|
+
root: "${cwd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1609
|
+
worktree_base: "${worktreeBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1610
|
+
|
|
1611
|
+
# =============================================================================
|
|
1612
|
+
# BRANCHES (REQUIRED)
|
|
1613
|
+
# =============================================================================
|
|
1614
|
+
|
|
1615
|
+
branches:
|
|
1616
|
+
default: "${defaultBranch}"
|
|
1617
|
+
staging: "${stagingBranch}"
|
|
1618
|
+
feature_pattern: "milestone/{slug}"
|
|
1619
|
+
|
|
1620
|
+
# =============================================================================
|
|
1621
|
+
# REPOS (OPTIONAL)
|
|
1622
|
+
# Multi-repo configuration. Remove the # to enable.
|
|
1623
|
+
# =============================================================================
|
|
1624
|
+
|
|
1625
|
+
# repos:
|
|
1626
|
+
# default:
|
|
1627
|
+
# repo: "${owner}/${repo}"
|
|
1628
|
+
# staging_branch: "${stagingBranch}"
|
|
1629
|
+
# satellites:
|
|
1630
|
+
# - prefix: "mcp"
|
|
1631
|
+
# repo: "${owner}/your-satellite-repo"
|
|
1632
|
+
# staging_branch: "main"
|
|
1633
|
+
# local_path: "${join(cwd, "..", "your-satellite-repo").replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1634
|
+
|
|
1635
|
+
# =============================================================================
|
|
1636
|
+
# PROJECT BOARD (OPTIONAL)
|
|
1637
|
+
# GitHub Projects v2 integration.
|
|
1638
|
+
# To find IDs: gh project list --owner ${owner}
|
|
1639
|
+
# =============================================================================
|
|
1640
|
+
|
|
1641
|
+
# project_board:
|
|
1642
|
+
# owner: "${owner}"
|
|
1643
|
+
# project_number: 1
|
|
1644
|
+
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
1645
|
+
# field_ids:
|
|
1646
|
+
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1647
|
+
# lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1648
|
+
# component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1649
|
+
# priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1650
|
+
# workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1651
|
+
|
|
1652
|
+
# =============================================================================
|
|
1653
|
+
# REVIEW (OPTIONAL)
|
|
1654
|
+
# Context injected into review agent prompts.
|
|
1655
|
+
# =============================================================================
|
|
1656
|
+
|
|
1657
|
+
# review:
|
|
1658
|
+
# tech_stack: "Node.js, TypeScript, PostgreSQL"
|
|
1659
|
+
# context: |
|
|
1660
|
+
# Describe your repo structure and any unusual conventions here.
|
|
1661
|
+
|
|
1662
|
+
# =============================================================================
|
|
1663
|
+
# VERIFICATION (OPTIONAL)
|
|
1664
|
+
# Health-check patterns for quality gate and validate commands.
|
|
1665
|
+
# =============================================================================
|
|
1666
|
+
|
|
1667
|
+
# verification:
|
|
1668
|
+
# health_endpoint: "https://api.${repo}.io/health"
|
|
1669
|
+
# health_patterns:
|
|
1670
|
+
# - '"status": "ok"'
|
|
1671
|
+
`;
|
|
1672
|
+
|
|
1673
|
+
atomicWriteFile(outputPath, content);
|
|
1674
|
+
|
|
1675
|
+
log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1676
|
+
log("");
|
|
1677
|
+
|
|
1678
|
+
if (fromInstall) {
|
|
1679
|
+
// Called automatically from install() — only print what still needs attention.
|
|
1680
|
+
// NOTE: log() is a no-op when fromInstall=true (stdout suppressed to avoid
|
|
1681
|
+
// interleaving with TUI spinner on stderr). If git remote was not detected,
|
|
1682
|
+
// the user will see the incomplete placeholder values in forge.yaml directly. (#812)
|
|
1683
|
+
if (!remoteDetected) {
|
|
1684
|
+
log(`${YELLOW}Action required:${RESET}`);
|
|
1685
|
+
log(
|
|
1686
|
+
` Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET} (git remote not detected)`,
|
|
1687
|
+
);
|
|
1688
|
+
log("");
|
|
1689
|
+
}
|
|
1690
|
+
} else {
|
|
1691
|
+
// Called explicitly via `npx forgedock init` — print full next steps
|
|
1692
|
+
log(`${BOLD}Next steps:${RESET}`);
|
|
1693
|
+
if (!remoteDetected) {
|
|
1694
|
+
log(
|
|
1695
|
+
` 1. Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET}`,
|
|
1696
|
+
);
|
|
1697
|
+
} else {
|
|
1698
|
+
log(
|
|
1699
|
+
` 1. Review ${CYAN}forge.yaml${RESET} — all required fields were auto-detected`,
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
log(
|
|
1703
|
+
` 2. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
|
|
790
1704
|
);
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
if (!remoteDetected) {
|
|
794
|
-
summaryLines.push(
|
|
795
|
-
`${yellow("→")} Edit ${cyan("forge.yaml")} — fill in ${cyan("project.owner")} and ${cyan("project.repo")}`,
|
|
1705
|
+
log(
|
|
1706
|
+
` 3. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
|
|
796
1707
|
);
|
|
1708
|
+
log("");
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Mark a directory as ForgeDock-managed by removing it from the opt-out
|
|
1714
|
+
* registry and creating a .forgedock marker file.
|
|
1715
|
+
*
|
|
1716
|
+
* @param {string} [dir] - Directory to enable (default: process.cwd()).
|
|
1717
|
+
*/
|
|
1718
|
+
async function enable(dir) {
|
|
1719
|
+
const targetDir = resolve(dir || process.cwd());
|
|
1720
|
+
|
|
1721
|
+
let setOptOut;
|
|
1722
|
+
try {
|
|
1723
|
+
({ setOptOut } = await import(
|
|
1724
|
+
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1725
|
+
));
|
|
1726
|
+
} catch (err) {
|
|
1727
|
+
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
1728
|
+
process.exit(1);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Remove from opt-out registry
|
|
1732
|
+
try {
|
|
1733
|
+
await setOptOut(targetDir, false);
|
|
1734
|
+
} catch (err) {
|
|
1735
|
+
console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
|
|
1736
|
+
process.exit(1);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Create .forgedock marker file so the directory is treated as managed
|
|
1740
|
+
// even without a forge.yaml (user can run `npx forgedock init` next)
|
|
1741
|
+
const markerPath = join(targetDir, ".forgedock");
|
|
1742
|
+
if (!existsSync(markerPath)) {
|
|
1743
|
+
try {
|
|
1744
|
+
writeFileSync(markerPath, "", "utf-8");
|
|
1745
|
+
console.log(` ${GREEN}Created${RESET}: .forgedock marker in ${targetDir}`);
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
console.log(` ${YELLOW}Warning${RESET}: could not create .forgedock marker: ${err.message}`);
|
|
1748
|
+
}
|
|
797
1749
|
} else {
|
|
798
|
-
|
|
799
|
-
|
|
1750
|
+
console.log(` ✔ .forgedock marker already present in ${targetDir}`);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
console.log(` ${GREEN}✔${RESET} ForgeDock ${GREEN}enabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
|
|
1754
|
+
console.log(` Removed from opt-out registry. Run ${CYAN}npx forgedock init${RESET} to generate forge.yaml.`);
|
|
1755
|
+
console.log("");
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Opt a directory out of ForgeDock by adding it to the opt-out registry.
|
|
1760
|
+
* The session-start hook will be silent for this directory in future sessions.
|
|
1761
|
+
*
|
|
1762
|
+
* @param {string} [dir] - Directory to disable (default: process.cwd()).
|
|
1763
|
+
*/
|
|
1764
|
+
async function disable(dir) {
|
|
1765
|
+
const targetDir = resolve(dir || process.cwd());
|
|
1766
|
+
|
|
1767
|
+
let setOptOut;
|
|
1768
|
+
try {
|
|
1769
|
+
({ setOptOut } = await import(
|
|
1770
|
+
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1771
|
+
));
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
1774
|
+
process.exit(1);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
try {
|
|
1778
|
+
await setOptOut(targetDir, true);
|
|
1779
|
+
} catch (err) {
|
|
1780
|
+
console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
|
|
1781
|
+
process.exit(1);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
console.log(` ${GREEN}✔${RESET} ForgeDock ${YELLOW}disabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
|
|
1785
|
+
console.log(` Added to opt-out registry. The session-start hook will be silent here.`);
|
|
1786
|
+
console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
1787
|
+
console.log("");
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/**
|
|
1791
|
+
* Show the resolved ForgeDock state for a directory.
|
|
1792
|
+
* Prints human-readable state (managed-active, managed-optedout, unmanaged)
|
|
1793
|
+
* with relevant detail about what markers or registry entries were found.
|
|
1794
|
+
*
|
|
1795
|
+
* @param {string} [dir] - Directory to inspect (default: process.cwd()).
|
|
1796
|
+
*/
|
|
1797
|
+
async function status(dir) {
|
|
1798
|
+
const targetDir = resolve(dir || process.cwd());
|
|
1799
|
+
|
|
1800
|
+
let resolveState;
|
|
1801
|
+
try {
|
|
1802
|
+
({ resolveState } = await import(
|
|
1803
|
+
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1804
|
+
));
|
|
1805
|
+
} catch (err) {
|
|
1806
|
+
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
1807
|
+
process.exit(1);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
let state;
|
|
1811
|
+
try {
|
|
1812
|
+
state = resolveState(targetDir);
|
|
1813
|
+
} catch (err) {
|
|
1814
|
+
console.error(`${RED}Error resolving state: ${err.message}${RESET}`);
|
|
1815
|
+
process.exit(1);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
const hasForgeYaml = existsSync(join(targetDir, "forge.yaml"));
|
|
1819
|
+
const hasMarker = existsSync(join(targetDir, ".forgedock"));
|
|
1820
|
+
|
|
1821
|
+
console.log("");
|
|
1822
|
+
console.log(`${BOLD}ForgeDock Status${RESET}`);
|
|
1823
|
+
console.log(` Directory: ${CYAN}${targetDir}${RESET}`);
|
|
1824
|
+
console.log("");
|
|
1825
|
+
|
|
1826
|
+
switch (state) {
|
|
1827
|
+
case "managed-active":
|
|
1828
|
+
console.log(` State: ${GREEN}managed-active${RESET}`);
|
|
1829
|
+
console.log(` ForgeDock is active in this directory.`);
|
|
1830
|
+
if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
|
|
1831
|
+
if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
|
|
1832
|
+
console.log(` The session-start hook will inject context on session start.`);
|
|
1833
|
+
console.log(` To disable: ${CYAN}npx forgedock disable${RESET}`);
|
|
1834
|
+
break;
|
|
1835
|
+
|
|
1836
|
+
case "managed-optedout":
|
|
1837
|
+
console.log(` State: ${YELLOW}managed-optedout${RESET}`);
|
|
1838
|
+
console.log(` ForgeDock is installed but opted out for this directory.`);
|
|
1839
|
+
if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
|
|
1840
|
+
if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
|
|
1841
|
+
console.log(` The session-start hook is silent here (opt-out registry entry present).`);
|
|
1842
|
+
console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
1843
|
+
break;
|
|
1844
|
+
|
|
1845
|
+
case "unmanaged":
|
|
1846
|
+
default:
|
|
1847
|
+
console.log(` State: ${RED}unmanaged${RESET}`);
|
|
1848
|
+
console.log(` ForgeDock is not active in this directory.`);
|
|
1849
|
+
console.log(` No forge.yaml or .forgedock marker found.`);
|
|
1850
|
+
console.log(` To enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
1851
|
+
console.log(` To set up: ${CYAN}npx forgedock init${RESET}`);
|
|
1852
|
+
break;
|
|
1853
|
+
}
|
|
1854
|
+
console.log("");
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Run installation health checks and report pass/fail for each.
|
|
1859
|
+
*
|
|
1860
|
+
* Checks (in order):
|
|
1861
|
+
* 1. Command symlinks — TARGET_DIR entries point to correct COMMANDS_DIR files
|
|
1862
|
+
* 2. gh CLI installed and authenticated
|
|
1863
|
+
* 3. git configured (user.name + user.email)
|
|
1864
|
+
* 4. forge.yaml exists and has required keys
|
|
1865
|
+
* 5. SessionStart hook registered in ~/.claude/settings.json
|
|
1866
|
+
* 6. CLAUDE.md has the ForgeDock behavioral block (cwd)
|
|
1867
|
+
* 7. Required workflow labels exist on the GitHub repo (needs forge.yaml + gh auth)
|
|
1868
|
+
* 8. FORGE_HOME environment variable is set
|
|
1869
|
+
*
|
|
1870
|
+
* Exits with code 0 if all checks pass, code 1 if any fail.
|
|
1871
|
+
*/
|
|
1872
|
+
async function doctor() {
|
|
1873
|
+
console.log("");
|
|
1874
|
+
console.log(`${BOLD}ForgeDock Doctor${RESET} — Installation Health Check`);
|
|
1875
|
+
console.log("");
|
|
1876
|
+
|
|
1877
|
+
let failures = 0;
|
|
1878
|
+
let warnings = 0;
|
|
1879
|
+
|
|
1880
|
+
/**
|
|
1881
|
+
* Print a pass line.
|
|
1882
|
+
* @param {string} label
|
|
1883
|
+
* @param {string} [detail]
|
|
1884
|
+
*/
|
|
1885
|
+
function pass(label, detail) {
|
|
1886
|
+
const suffix = detail ? ` ${detail}` : "";
|
|
1887
|
+
console.log(` ${GREEN}✔${RESET} ${label}${suffix}`);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Print a fail line with a remediation hint.
|
|
1892
|
+
* @param {string} label
|
|
1893
|
+
* @param {string} hint
|
|
1894
|
+
*/
|
|
1895
|
+
function fail(label, hint) {
|
|
1896
|
+
failures++;
|
|
1897
|
+
console.log(` ${RED}✗${RESET} ${BOLD}${label}${RESET}`);
|
|
1898
|
+
console.log(` Fix: ${hint}`);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Print a warning line (informational, does not count as failure).
|
|
1903
|
+
* @param {string} label
|
|
1904
|
+
* @param {string} hint
|
|
1905
|
+
*/
|
|
1906
|
+
function warn(label, hint) {
|
|
1907
|
+
warnings++;
|
|
1908
|
+
console.log(` ${YELLOW}⚠${RESET} ${label}`);
|
|
1909
|
+
console.log(` Note: ${hint}`);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// ── Check 1: Command symlinks ──────────────────────────────────────────────
|
|
1913
|
+
{
|
|
1914
|
+
let symlinkOk = true;
|
|
1915
|
+
let checked = 0;
|
|
1916
|
+
let broken = 0;
|
|
1917
|
+
const brokenLinks = [];
|
|
1918
|
+
|
|
1919
|
+
try {
|
|
1920
|
+
// Collect all .md files from COMMANDS_DIR recursively
|
|
1921
|
+
const collectMd = async (dir) => {
|
|
1922
|
+
const results = [];
|
|
1923
|
+
let entries;
|
|
1924
|
+
try {
|
|
1925
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1926
|
+
} catch {
|
|
1927
|
+
return results;
|
|
1928
|
+
}
|
|
1929
|
+
for (const entry of entries) {
|
|
1930
|
+
const full = join(dir, entry.name);
|
|
1931
|
+
if (entry.isDirectory()) {
|
|
1932
|
+
results.push(...(await collectMd(full)));
|
|
1933
|
+
} else if (entry.name.endsWith(".md")) {
|
|
1934
|
+
results.push(full);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return results;
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
const sourceFiles = await collectMd(COMMANDS_DIR);
|
|
1941
|
+
|
|
1942
|
+
for (const src of sourceFiles) {
|
|
1943
|
+
const rel = relative(COMMANDS_DIR, src);
|
|
1944
|
+
const tgt = join(TARGET_DIR, rel);
|
|
1945
|
+
checked++;
|
|
1946
|
+
try {
|
|
1947
|
+
const lstats = await lstat(tgt);
|
|
1948
|
+
if (lstats.isSymbolicLink()) {
|
|
1949
|
+
const dest = await readlink(tgt);
|
|
1950
|
+
if (dest !== src) {
|
|
1951
|
+
broken++;
|
|
1952
|
+
brokenLinks.push(rel);
|
|
1953
|
+
symlinkOk = false;
|
|
1954
|
+
}
|
|
1955
|
+
} else {
|
|
1956
|
+
// Regular file — not a symlink (install left a copy)
|
|
1957
|
+
broken++;
|
|
1958
|
+
brokenLinks.push(`${rel} (not a symlink)`);
|
|
1959
|
+
symlinkOk = false;
|
|
1960
|
+
}
|
|
1961
|
+
} catch {
|
|
1962
|
+
// Symlink missing
|
|
1963
|
+
broken++;
|
|
1964
|
+
brokenLinks.push(`${rel} (missing)`);
|
|
1965
|
+
symlinkOk = false;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
if (symlinkOk) {
|
|
1970
|
+
pass("Command symlinks", `${checked} symlinks valid`);
|
|
1971
|
+
} else {
|
|
1972
|
+
fail(
|
|
1973
|
+
"Command symlinks",
|
|
1974
|
+
`Run: npx forgedock install (${broken}/${checked} broken: ${brokenLinks.slice(0, 3).join(", ")}${brokenLinks.length > 3 ? "…" : ""})`,
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
fail("Command symlinks", `Could not read commands directory: ${err.message}`);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// ── Check 2: gh CLI installed + authenticated ──────────────────────────────
|
|
1983
|
+
let ghAvailable = false;
|
|
1984
|
+
{
|
|
1985
|
+
let ghInstalled = false;
|
|
1986
|
+
let ghAuthed = false;
|
|
1987
|
+
try {
|
|
1988
|
+
execSync("gh --version", { stdio: ["ignore", "pipe", "ignore"] });
|
|
1989
|
+
ghInstalled = true;
|
|
1990
|
+
} catch {
|
|
1991
|
+
// gh not installed
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if (!ghInstalled) {
|
|
1995
|
+
fail("gh CLI", "Install the GitHub CLI: https://cli.github.com/");
|
|
1996
|
+
} else {
|
|
1997
|
+
try {
|
|
1998
|
+
execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"] });
|
|
1999
|
+
ghAuthed = true;
|
|
2000
|
+
} catch {
|
|
2001
|
+
// Not authenticated
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
if (ghAuthed) {
|
|
2005
|
+
pass("gh CLI", "installed and authenticated");
|
|
2006
|
+
ghAvailable = true;
|
|
2007
|
+
} else {
|
|
2008
|
+
fail("gh CLI", "Run: gh auth login");
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// ── Check 3: git configured ────────────────────────────────────────────────
|
|
2014
|
+
{
|
|
2015
|
+
let gitName = "";
|
|
2016
|
+
let gitEmail = "";
|
|
2017
|
+
try {
|
|
2018
|
+
gitName = execSync("git config --global user.name", {
|
|
2019
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2020
|
+
encoding: "utf-8",
|
|
2021
|
+
}).trim();
|
|
2022
|
+
} catch {
|
|
2023
|
+
/* not set */
|
|
2024
|
+
}
|
|
2025
|
+
try {
|
|
2026
|
+
gitEmail = execSync("git config --global user.email", {
|
|
2027
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2028
|
+
encoding: "utf-8",
|
|
2029
|
+
}).trim();
|
|
2030
|
+
} catch {
|
|
2031
|
+
/* not set */
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
if (gitName && gitEmail) {
|
|
2035
|
+
pass("git config", `user.name="${gitName}", user.email="${gitEmail}"`);
|
|
2036
|
+
} else {
|
|
2037
|
+
const missing = [
|
|
2038
|
+
!gitName && "user.name",
|
|
2039
|
+
!gitEmail && "user.email",
|
|
2040
|
+
].filter(Boolean).join(", ");
|
|
2041
|
+
fail(
|
|
2042
|
+
"git config",
|
|
2043
|
+
`Run: git config --global user.name "Your Name" and git config --global user.email "you@example.com" (missing: ${missing})`,
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// ── Check 4: forge.yaml exists + has required keys ─────────────────────────
|
|
2049
|
+
let forgeOwner = null;
|
|
2050
|
+
let forgeRepo = null;
|
|
2051
|
+
{
|
|
2052
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
2053
|
+
if (!existsSync(forgeYamlPath)) {
|
|
2054
|
+
warn(
|
|
2055
|
+
"forge.yaml",
|
|
2056
|
+
"No forge.yaml in current directory. Run: npx forgedock init",
|
|
2057
|
+
);
|
|
2058
|
+
} else {
|
|
2059
|
+
let content = "";
|
|
2060
|
+
try {
|
|
2061
|
+
content = readFileSync(forgeYamlPath, "utf-8");
|
|
2062
|
+
} catch (err) {
|
|
2063
|
+
fail("forge.yaml", `Cannot read forge.yaml: ${err.message}`);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
if (content) {
|
|
2067
|
+
const hasProject = /^project:/m.test(content);
|
|
2068
|
+
const hasPaths = /^paths:/m.test(content);
|
|
2069
|
+
const hasBranches = /^branches:/m.test(content);
|
|
2070
|
+
const missing = [
|
|
2071
|
+
!hasProject && "project",
|
|
2072
|
+
!hasPaths && "paths",
|
|
2073
|
+
!hasBranches && "branches",
|
|
2074
|
+
].filter(Boolean);
|
|
2075
|
+
|
|
2076
|
+
if (missing.length === 0) {
|
|
2077
|
+
pass("forge.yaml", "exists with required keys");
|
|
2078
|
+
// Extract owner and repo for the label check (simple regex, no YAML parser needed)
|
|
2079
|
+
const ownerMatch = content.match(/^\s+owner:\s+"?([^"\n]+)"?\s*$/m);
|
|
2080
|
+
const repoMatch = content.match(/^\s+repo:\s+"?([^"\n]+)"?\s*$/m);
|
|
2081
|
+
if (ownerMatch) forgeOwner = ownerMatch[1].trim();
|
|
2082
|
+
if (repoMatch) forgeRepo = repoMatch[1].trim();
|
|
2083
|
+
} else {
|
|
2084
|
+
fail("forge.yaml", `Missing required keys: ${missing.join(", ")}. Edit forge.yaml or run: npx forgedock init`);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// ── Check 5: SessionStart hook registered ─────────────────────────────────
|
|
2091
|
+
{
|
|
2092
|
+
try {
|
|
2093
|
+
const settings = readClaudeSettings();
|
|
2094
|
+
const sessionStartEntries = Array.isArray(settings?.hooks?.SessionStart)
|
|
2095
|
+
? settings.hooks.SessionStart
|
|
2096
|
+
: [];
|
|
2097
|
+
const hookPresent = sessionStartEntries.some((entry) => {
|
|
2098
|
+
if (!entry || typeof entry !== "object") return false;
|
|
2099
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
2100
|
+
return hooks.some(
|
|
2101
|
+
(h) =>
|
|
2102
|
+
h &&
|
|
2103
|
+
typeof h.command === "string" &&
|
|
2104
|
+
isForgeSessionStartHook(h.command),
|
|
2105
|
+
);
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
if (hookPresent) {
|
|
2109
|
+
pass("SessionStart hook", "registered in ~/.claude/settings.json");
|
|
2110
|
+
} else {
|
|
2111
|
+
fail(
|
|
2112
|
+
"SessionStart hook",
|
|
2113
|
+
"Run: npx forgedock install (writes hook entry to ~/.claude/settings.json)",
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
} catch (err) {
|
|
2117
|
+
fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${err.message}. Run: npx forgedock install`);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// ── Check 6: CLAUDE.md has ForgeDock block (cwd) ──────────────────────────
|
|
2122
|
+
{
|
|
2123
|
+
const claudeMdPath = join(process.cwd(), "CLAUDE.md");
|
|
2124
|
+
if (!existsSync(claudeMdPath)) {
|
|
2125
|
+
warn(
|
|
2126
|
+
"CLAUDE.md behavioral block",
|
|
2127
|
+
`No CLAUDE.md found in ${process.cwd()}. Run: npx forgedock install (from your project directory)`,
|
|
2128
|
+
);
|
|
2129
|
+
} else {
|
|
2130
|
+
try {
|
|
2131
|
+
const content = readFileSync(claudeMdPath, "utf-8");
|
|
2132
|
+
if (
|
|
2133
|
+
content.includes(CLAUDE_BLOCK_BEGIN) &&
|
|
2134
|
+
content.includes(CLAUDE_BLOCK_END)
|
|
2135
|
+
) {
|
|
2136
|
+
pass("CLAUDE.md behavioral block", `found in ${claudeMdPath}`);
|
|
2137
|
+
} else {
|
|
2138
|
+
fail(
|
|
2139
|
+
"CLAUDE.md behavioral block",
|
|
2140
|
+
`Run: npx forgedock install (from ${process.cwd()}) to inject the ForgeDock pipeline rules block`,
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
fail("CLAUDE.md behavioral block", `Cannot read CLAUDE.md: ${err.message}`);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// ── Check 7: Required workflow labels on GitHub repo ───────────────────────
|
|
2150
|
+
{
|
|
2151
|
+
if (!forgeOwner || !forgeRepo) {
|
|
2152
|
+
warn(
|
|
2153
|
+
"GitHub workflow labels",
|
|
2154
|
+
"Skipped — could not resolve owner/repo from forge.yaml",
|
|
2155
|
+
);
|
|
2156
|
+
} else if (!ghAvailable) {
|
|
2157
|
+
warn(
|
|
2158
|
+
"GitHub workflow labels",
|
|
2159
|
+
"Skipped — gh CLI not authenticated (see check 2 above)",
|
|
2160
|
+
);
|
|
2161
|
+
} else {
|
|
2162
|
+
// Read expected labels from labels.json (co-located in bin/)
|
|
2163
|
+
const labelsJsonPath = join(__dirname, "labels.json");
|
|
2164
|
+
let expectedLabels = [];
|
|
2165
|
+
let labelsJsonOk = false;
|
|
2166
|
+
try {
|
|
2167
|
+
const labelsRaw = readFileSync(labelsJsonPath, "utf-8");
|
|
2168
|
+
const allLabels = JSON.parse(labelsRaw);
|
|
2169
|
+
expectedLabels = allLabels
|
|
2170
|
+
.filter((l) => l.name.startsWith("workflow:"))
|
|
2171
|
+
.map((l) => l.name);
|
|
2172
|
+
labelsJsonOk = true;
|
|
2173
|
+
} catch {
|
|
2174
|
+
warn(
|
|
2175
|
+
"GitHub workflow labels",
|
|
2176
|
+
"Could not read bin/labels.json — skipping label check",
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
if (labelsJsonOk && expectedLabels.length > 0) {
|
|
2181
|
+
let existingLabels = [];
|
|
2182
|
+
let ghLabelOk = false;
|
|
2183
|
+
try {
|
|
2184
|
+
const out = execFileSync(
|
|
2185
|
+
"gh",
|
|
2186
|
+
["label", "list", "-R", `${forgeOwner}/${forgeRepo}`, "--json", "name", "--limit", "200"],
|
|
2187
|
+
{ stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" },
|
|
2188
|
+
);
|
|
2189
|
+
const parsed = JSON.parse(out);
|
|
2190
|
+
existingLabels = parsed.map((l) => l.name);
|
|
2191
|
+
ghLabelOk = true;
|
|
2192
|
+
} catch {
|
|
2193
|
+
warn(
|
|
2194
|
+
"GitHub workflow labels",
|
|
2195
|
+
`Could not fetch labels from ${forgeOwner}/${forgeRepo}. Ensure gh is authenticated and repo is accessible.`,
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
if (ghLabelOk) {
|
|
2200
|
+
const missingLabels = expectedLabels.filter(
|
|
2201
|
+
(l) => !existingLabels.includes(l),
|
|
2202
|
+
);
|
|
2203
|
+
if (missingLabels.length === 0) {
|
|
2204
|
+
pass(
|
|
2205
|
+
"GitHub workflow labels",
|
|
2206
|
+
`all ${expectedLabels.length} workflow labels present on ${forgeOwner}/${forgeRepo}`,
|
|
2207
|
+
);
|
|
2208
|
+
} else {
|
|
2209
|
+
fail(
|
|
2210
|
+
"GitHub workflow labels",
|
|
2211
|
+
`Run: npx forgedock labels setup (missing: ${missingLabels.join(", ")})`,
|
|
2212
|
+
);
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// ── Check 8: FORGE_HOME environment variable ───────────────────────────────
|
|
2220
|
+
{
|
|
2221
|
+
const envForgeHome = process.env.FORGE_HOME;
|
|
2222
|
+
if (envForgeHome) {
|
|
2223
|
+
pass("FORGE_HOME env var", `set to "${envForgeHome}"`);
|
|
2224
|
+
} else {
|
|
2225
|
+
fail(
|
|
2226
|
+
"FORGE_HOME env var",
|
|
2227
|
+
`Add to your shell profile: export FORGE_HOME="${FORGE_HOME}" then restart your shell (or run: source ~/.bashrc)`,
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// ── Summary ────────────────────────────────────────────────────────────────
|
|
2233
|
+
console.log("");
|
|
2234
|
+
if (failures === 0 && warnings === 0) {
|
|
2235
|
+
console.log(`${GREEN}${BOLD}All checks passed.${RESET} ForgeDock installation is healthy.`);
|
|
2236
|
+
} else if (failures === 0) {
|
|
2237
|
+
console.log(`${YELLOW}${BOLD}${warnings} warning(s).${RESET} Checks passed with notes above.`);
|
|
2238
|
+
} else {
|
|
2239
|
+
console.log(
|
|
2240
|
+
`${RED}${BOLD}${failures} check(s) failed${warnings > 0 ? `, ${warnings} warning(s)` : ""}.${RESET} See fix hints above.`,
|
|
800
2241
|
);
|
|
801
2242
|
}
|
|
802
|
-
|
|
803
|
-
`${green("→")} Add ${cyan("forge.yaml")} to ${cyan(".gitignore")} if it contains sensitive paths`,
|
|
804
|
-
);
|
|
805
|
-
summaryLines.push(
|
|
806
|
-
`${green("→")} Run ${cyan("/forgedock-init")} inside Claude Code for guided AI-powered setup`,
|
|
807
|
-
);
|
|
2243
|
+
console.log("");
|
|
808
2244
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
2245
|
+
if (failures > 0) {
|
|
2246
|
+
process.exit(1);
|
|
2247
|
+
}
|
|
812
2248
|
}
|
|
813
2249
|
|
|
814
2250
|
function help() {
|
|
@@ -820,6 +2256,10 @@ function help() {
|
|
|
820
2256
|
["npx forgedock init", "Generate forge.yaml config for your project"],
|
|
821
2257
|
["npx forgedock uninstall", "Remove commands"],
|
|
822
2258
|
["npx forgedock update", "Pull latest & reinstall"],
|
|
2259
|
+
["npx forgedock enable [dir]", "Mark directory as ForgeDock-managed"],
|
|
2260
|
+
["npx forgedock disable [dir]", "Opt directory out of ForgeDock"],
|
|
2261
|
+
["npx forgedock status [dir]", "Show resolved state for a directory"],
|
|
2262
|
+
["npx forgedock doctor", "Check installation health"],
|
|
823
2263
|
["npx forgedock help", "Show this help"],
|
|
824
2264
|
];
|
|
825
2265
|
|
|
@@ -843,6 +2283,18 @@ switch (command) {
|
|
|
843
2283
|
case "update":
|
|
844
2284
|
await update();
|
|
845
2285
|
break;
|
|
2286
|
+
case "enable":
|
|
2287
|
+
await enable(args[1]);
|
|
2288
|
+
break;
|
|
2289
|
+
case "disable":
|
|
2290
|
+
await disable(args[1]);
|
|
2291
|
+
break;
|
|
2292
|
+
case "status":
|
|
2293
|
+
await status(args[1]);
|
|
2294
|
+
break;
|
|
2295
|
+
case "doctor":
|
|
2296
|
+
await doctor();
|
|
2297
|
+
break;
|
|
846
2298
|
case "help":
|
|
847
2299
|
case "--help":
|
|
848
2300
|
case "-h":
|