forgedock 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/forge-utils.mjs +164 -0
- package/bin/forgedock.mjs +1914 -394
- package/bin/hooks/session-start.mjs +317 -0
- package/bin/init-detect.mjs +325 -0
- package/bin/init-enrich-api.mjs +211 -0
- package/bin/registry.mjs +358 -0
- package/bin/tui.mjs +470 -5
- package/commands/forgedock-init.md +305 -384
- package/commands/work-on.md +18 -0
- package/docs/CONFIG.md +185 -0
- package/docs/superpowers/specs/2026-06-09-intelligent-onboarding-design.md +172 -0
- package/package.json +5 -1
- package/scripts/verify-route-registration.sh +23 -17
package/bin/forgedock.mjs
CHANGED
|
@@ -54,7 +54,14 @@ import {
|
|
|
54
54
|
input,
|
|
55
55
|
createProgressBar,
|
|
56
56
|
spinner,
|
|
57
|
+
annotatedReviewScreen,
|
|
57
58
|
} from "./tui.mjs";
|
|
59
|
+
import { detectConfig } from "./init-detect.mjs";
|
|
60
|
+
import {
|
|
61
|
+
enrich as enrichViaAPI,
|
|
62
|
+
parseEnrichedDraft,
|
|
63
|
+
} from "./init-enrich-api.mjs";
|
|
64
|
+
import { resolveState, setOptOut } from "./registry.mjs";
|
|
58
65
|
|
|
59
66
|
const __filename = fileURLToPath(import.meta.url);
|
|
60
67
|
const __dirname = dirname(__filename);
|
|
@@ -69,6 +76,11 @@ const TARGET_DIR = join(HOME, ".claude", "commands");
|
|
|
69
76
|
const args = process.argv.slice(2);
|
|
70
77
|
const command = args[0];
|
|
71
78
|
const forceYes = args.includes("--yes") || args.includes("-y");
|
|
79
|
+
// --manual: bypass autopilot enrichment and present the annotated review screen
|
|
80
|
+
// with detection baseline values only (no AI-enriched suggestions).
|
|
81
|
+
// --verbose: surface field detection sources and confidences during the init flow.
|
|
82
|
+
const manualMode = args.includes("--manual");
|
|
83
|
+
const verboseMode = args.includes("--verbose");
|
|
72
84
|
|
|
73
85
|
// ---------------------------------------------------------------------------
|
|
74
86
|
// Version — read dynamically from package.json
|
|
@@ -133,6 +145,295 @@ async function removeInstallModeMarker() {
|
|
|
133
145
|
}
|
|
134
146
|
}
|
|
135
147
|
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// SessionStart hook — settings.json helpers
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Path to the user-level Claude Code settings file.
|
|
154
|
+
* This is where the SessionStart hook entry is written/removed.
|
|
155
|
+
*/
|
|
156
|
+
const CLAUDE_SETTINGS_PATH = join(HOME, ".claude", "settings.json");
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Timeout (in seconds) for the SessionStart hook entry written into
|
|
160
|
+
* ~/.claude/settings.json. Claude Code hook timeouts are in seconds —
|
|
161
|
+
* not milliseconds like the Node.js child_process timeouts elsewhere in
|
|
162
|
+
* this file.
|
|
163
|
+
*/
|
|
164
|
+
const SESSION_START_HOOK_TIMEOUT_SECONDS = 10;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The command value written into the SessionStart hook entry.
|
|
168
|
+
* Identifies the hook by a path suffix so it can be found even if
|
|
169
|
+
* FORGE_HOME changes between install and uninstall runs.
|
|
170
|
+
*
|
|
171
|
+
* Uses forward slashes even on Windows — Node accepts them natively and
|
|
172
|
+
* they survive hook-runner invocations that do not go through a shell
|
|
173
|
+
* (e.g. execFile). Backslashes emitted by path.join() on Windows would
|
|
174
|
+
* be fragile when the hook runner has no shell to interpret the quoted
|
|
175
|
+
* command string.
|
|
176
|
+
*/
|
|
177
|
+
function sessionStartHookCommand() {
|
|
178
|
+
const hookPath = join(
|
|
179
|
+
FORGE_HOME,
|
|
180
|
+
"bin",
|
|
181
|
+
"hooks",
|
|
182
|
+
"session-start.mjs",
|
|
183
|
+
).replace(/\\/g, "/");
|
|
184
|
+
// Escape embedded double quotes so a crafted install path cannot break out
|
|
185
|
+
// of the quoted argument and inject shell tokens.
|
|
186
|
+
const safePath = hookPath.replace(/"/g, '\\"');
|
|
187
|
+
return `node "${safePath}"`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check whether a hook command string refers to the ForgeDock SessionStart hook.
|
|
192
|
+
*
|
|
193
|
+
* Uses a platform-safe RegExp that matches both POSIX forward-slash paths
|
|
194
|
+
* (Linux/macOS) and Windows backslash paths, so uninstall can locate and
|
|
195
|
+
* remove the hook even when FORGE_HOME was different at install time.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} command - The command string from a hook entry.
|
|
198
|
+
* @returns {boolean}
|
|
199
|
+
*/
|
|
200
|
+
function isForgeSessionStartHook(command) {
|
|
201
|
+
// Match: ...bin[/\]hooks[/\]session-start.mjs (quote-terminated or EOL)
|
|
202
|
+
return /[/\\]bin[/\\]hooks[/\\]session-start\.mjs["']?\s*$/.test(command);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Strip JSONC syntax (single-line comments, block comments, trailing commas)
|
|
207
|
+
* from a raw JSON string, returning a plain JSON string that JSON.parse() accepts.
|
|
208
|
+
*
|
|
209
|
+
* The implementation is a single-pass character-walk state machine that correctly
|
|
210
|
+
* skips content inside string literals, so comment-like sequences embedded in
|
|
211
|
+
* JSON string values are preserved.
|
|
212
|
+
*
|
|
213
|
+
* Handles:
|
|
214
|
+
* - Single-line comments: // ... \n
|
|
215
|
+
* - Block comments: /* ... *\/
|
|
216
|
+
* - Trailing commas: ,\s*[}\]]
|
|
217
|
+
*
|
|
218
|
+
* @param {string} raw - Raw text of a JSONC file.
|
|
219
|
+
* @returns {string} - Valid JSON string.
|
|
220
|
+
*/
|
|
221
|
+
function stripJsonc(raw) {
|
|
222
|
+
let result = "";
|
|
223
|
+
let i = 0;
|
|
224
|
+
const len = raw.length;
|
|
225
|
+
|
|
226
|
+
while (i < len) {
|
|
227
|
+
const ch = raw[i];
|
|
228
|
+
|
|
229
|
+
// Inside a string literal — copy until unescaped closing quote
|
|
230
|
+
if (ch === '"') {
|
|
231
|
+
result += ch;
|
|
232
|
+
i++;
|
|
233
|
+
while (i < len) {
|
|
234
|
+
const sc = raw[i];
|
|
235
|
+
result += sc;
|
|
236
|
+
if (sc === "\\" && i + 1 < len) {
|
|
237
|
+
// Escaped character — copy the next char verbatim and advance past it
|
|
238
|
+
i++;
|
|
239
|
+
result += raw[i];
|
|
240
|
+
} else if (sc === '"') {
|
|
241
|
+
break; // End of string literal
|
|
242
|
+
}
|
|
243
|
+
i++;
|
|
244
|
+
}
|
|
245
|
+
i++;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Single-line comment — skip until newline
|
|
250
|
+
if (ch === "/" && i + 1 < len && raw[i + 1] === "/") {
|
|
251
|
+
while (i < len && raw[i] !== "\n") i++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Block comment — skip until */
|
|
256
|
+
if (ch === "/" && i + 1 < len && raw[i + 1] === "*") {
|
|
257
|
+
i += 2;
|
|
258
|
+
while (i + 1 < len && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
259
|
+
i += 2; // Skip closing */
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
result += ch;
|
|
264
|
+
i++;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Remove trailing commas before } or ]
|
|
268
|
+
return result.replace(/,(\s*[}\]])/g, "$1");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read ~/.claude/settings.json, returning a parsed object.
|
|
273
|
+
* Returns an empty object if the file does not exist.
|
|
274
|
+
* Tolerates JSONC syntax (comments, trailing commas) that Claude Code's
|
|
275
|
+
* own settings editor may insert into user-edited settings.json files.
|
|
276
|
+
* Throws if the file exists but cannot be parsed even after JSONC stripping.
|
|
277
|
+
*
|
|
278
|
+
* @returns {object}
|
|
279
|
+
*/
|
|
280
|
+
function readClaudeSettings() {
|
|
281
|
+
try {
|
|
282
|
+
const raw = readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
|
|
283
|
+
return JSON.parse(stripJsonc(raw));
|
|
284
|
+
} catch (err) {
|
|
285
|
+
if (err.code === "ENOENT") return {};
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Atomically write an object to ~/.claude/settings.json.
|
|
292
|
+
* Uses a .tmp sibling + renameSync to avoid partial writes.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} settings
|
|
295
|
+
*/
|
|
296
|
+
function writeClaudeSettings(settings) {
|
|
297
|
+
const tmpPath = CLAUDE_SETTINGS_PATH + ".forgedock.tmp";
|
|
298
|
+
try {
|
|
299
|
+
writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
300
|
+
renameSync(tmpPath, CLAUDE_SETTINGS_PATH);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
// Clean up temp file if it was created before the error
|
|
303
|
+
try {
|
|
304
|
+
unlinkSync(tmpPath);
|
|
305
|
+
} catch {
|
|
306
|
+
/* already gone or never created */
|
|
307
|
+
}
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Install the ForgeDock SessionStart hook into ~/.claude/settings.json
|
|
314
|
+
* idempotently. Does not modify any existing hooks unrelated to ForgeDock.
|
|
315
|
+
*
|
|
316
|
+
* Returns:
|
|
317
|
+
* 'installed' — hook was newly added
|
|
318
|
+
* 'already-present' — hook was already there (idempotent)
|
|
319
|
+
* 'failed' — an error occurred (non-fatal; install continues)
|
|
320
|
+
*
|
|
321
|
+
* @returns {Promise<'installed' | 'already-present' | 'failed'>}
|
|
322
|
+
*/
|
|
323
|
+
async function installSessionStartHook() {
|
|
324
|
+
try {
|
|
325
|
+
const settings = readClaudeSettings();
|
|
326
|
+
|
|
327
|
+
// Ensure the hooks section exists
|
|
328
|
+
if (!settings.hooks || typeof settings.hooks !== "object") {
|
|
329
|
+
settings.hooks = {};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Ensure SessionStart array exists
|
|
333
|
+
if (!Array.isArray(settings.hooks.SessionStart)) {
|
|
334
|
+
settings.hooks.SessionStart = [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const command = sessionStartHookCommand();
|
|
338
|
+
|
|
339
|
+
// Check if already present (idempotent — match by path suffix)
|
|
340
|
+
const alreadyPresent = settings.hooks.SessionStart.some((entry) => {
|
|
341
|
+
if (!entry || typeof entry !== "object") return false;
|
|
342
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
343
|
+
return hooks.some(
|
|
344
|
+
(h) =>
|
|
345
|
+
h &&
|
|
346
|
+
typeof h.command === "string" &&
|
|
347
|
+
isForgeSessionStartHook(h.command),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (alreadyPresent) return "already-present";
|
|
352
|
+
|
|
353
|
+
// Append the new hook entry
|
|
354
|
+
settings.hooks.SessionStart.push({
|
|
355
|
+
hooks: [
|
|
356
|
+
{
|
|
357
|
+
type: "command",
|
|
358
|
+
command,
|
|
359
|
+
timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
writeClaudeSettings(settings);
|
|
365
|
+
return "installed";
|
|
366
|
+
} catch {
|
|
367
|
+
return "failed";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Remove the ForgeDock SessionStart hook from ~/.claude/settings.json
|
|
373
|
+
* idempotently. Only removes the entry written by installSessionStartHook().
|
|
374
|
+
* Unrelated hooks are preserved.
|
|
375
|
+
*
|
|
376
|
+
* Returns:
|
|
377
|
+
* 'removed' — hook was found and removed
|
|
378
|
+
* 'not-present' — hook was not in settings.json (already clean)
|
|
379
|
+
* 'failed' — an error occurred (non-fatal; uninstall continues)
|
|
380
|
+
*
|
|
381
|
+
* @returns {Promise<'removed' | 'not-present' | 'failed'>}
|
|
382
|
+
*/
|
|
383
|
+
async function removeSessionStartHook() {
|
|
384
|
+
try {
|
|
385
|
+
const settings = readClaudeSettings();
|
|
386
|
+
|
|
387
|
+
if (
|
|
388
|
+
!settings.hooks ||
|
|
389
|
+
!Array.isArray(settings.hooks.SessionStart) ||
|
|
390
|
+
settings.hooks.SessionStart.length === 0
|
|
391
|
+
) {
|
|
392
|
+
return "not-present";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const originalLength = settings.hooks.SessionStart.length;
|
|
396
|
+
|
|
397
|
+
// Filter out the ForgeDock-managed entry, keep everything else
|
|
398
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
|
|
399
|
+
(entry) => {
|
|
400
|
+
if (!entry || typeof entry !== "object") return true;
|
|
401
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
402
|
+
const isForgeEntry = hooks.some(
|
|
403
|
+
(h) =>
|
|
404
|
+
h &&
|
|
405
|
+
typeof h.command === "string" &&
|
|
406
|
+
isForgeSessionStartHook(h.command),
|
|
407
|
+
);
|
|
408
|
+
return !isForgeEntry;
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
if (settings.hooks.SessionStart.length === originalLength) {
|
|
413
|
+
return "not-present";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Clean up empty SessionStart array to leave settings.json tidy
|
|
417
|
+
if (settings.hooks.SessionStart.length === 0) {
|
|
418
|
+
delete settings.hooks.SessionStart;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Clean up empty hooks object
|
|
422
|
+
if (
|
|
423
|
+
settings.hooks &&
|
|
424
|
+
typeof settings.hooks === "object" &&
|
|
425
|
+
Object.keys(settings.hooks).length === 0
|
|
426
|
+
) {
|
|
427
|
+
delete settings.hooks;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
writeClaudeSettings(settings);
|
|
431
|
+
return "removed";
|
|
432
|
+
} catch {
|
|
433
|
+
return "failed";
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
136
437
|
// ---------------------------------------------------------------------------
|
|
137
438
|
// Install state detection
|
|
138
439
|
// ---------------------------------------------------------------------------
|
|
@@ -538,10 +839,14 @@ async function runPrerequisiteChecklist() {
|
|
|
538
839
|
// Try to get primary org from gh api
|
|
539
840
|
let orgLabel = "";
|
|
540
841
|
try {
|
|
541
|
-
const orgsJson =
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
842
|
+
const orgsJson = execFileSync(
|
|
843
|
+
"gh",
|
|
844
|
+
["api", "/user/orgs", "--jq", ".[0].login"],
|
|
845
|
+
{
|
|
846
|
+
encoding: "utf-8",
|
|
847
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
848
|
+
},
|
|
849
|
+
).trim();
|
|
545
850
|
if (orgsJson && orgsJson !== "null" && orgsJson !== "") {
|
|
546
851
|
orgLabel = ` (${orgsJson})`;
|
|
547
852
|
}
|
|
@@ -906,7 +1211,11 @@ async function install() {
|
|
|
906
1211
|
...conflicts.map((c) => ` ${red("✖")} ${c}`),
|
|
907
1212
|
"",
|
|
908
1213
|
` ${dim("To let ForgeDock manage these, remove or rename each file:")}`,
|
|
909
|
-
...conflicts.map((c) =>
|
|
1214
|
+
...conflicts.map((c) =>
|
|
1215
|
+
process.platform === "win32"
|
|
1216
|
+
? ` ${dim(`Remove-Item ~\\.claude\\commands\\${c.replace(/\//g, "\\")}`)}`
|
|
1217
|
+
: ` ${dim(`rm ~/.claude/commands/${c}`)}`,
|
|
1218
|
+
),
|
|
910
1219
|
"",
|
|
911
1220
|
];
|
|
912
1221
|
process.stdout.write(
|
|
@@ -922,27 +1231,57 @@ async function install() {
|
|
|
922
1231
|
|
|
923
1232
|
console.log("");
|
|
924
1233
|
let forgeHomeSet = false;
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
)
|
|
937
|
-
|
|
1234
|
+
if (process.platform === "win32") {
|
|
1235
|
+
// On Windows, ~/.bashrc and ~/.zshrc do not exist.
|
|
1236
|
+
// Persist FORGE_HOME for the current user via setx (built-in, no elevation needed).
|
|
1237
|
+
// If setx fails for any reason, print a PowerShell one-liner the user can run manually.
|
|
1238
|
+
try {
|
|
1239
|
+
// setx exits 0 even when it truncates; capture stdout and check for the truncation
|
|
1240
|
+
// warning before declaring success. <!-- Added: forge#415 -->
|
|
1241
|
+
const setxOut = execFileSync("setx", ["FORGE_HOME", FORGE_HOME], {
|
|
1242
|
+
stdio: "pipe",
|
|
1243
|
+
});
|
|
1244
|
+
if (
|
|
1245
|
+
setxOut.toString().includes("WARNING: Data being saved is truncated")
|
|
1246
|
+
) {
|
|
1247
|
+
throw new Error("setx truncated the FORGE_HOME value");
|
|
1248
|
+
}
|
|
1249
|
+
console.log(
|
|
1250
|
+
` ${green("✔")} ${bold("FORGE_HOME")} set via ${cyan("setx")} ${dim("(restart your terminal to apply)")}`,
|
|
1251
|
+
);
|
|
1252
|
+
} catch {
|
|
1253
|
+
// setx unavailable, failed, or truncated — print manual guidance
|
|
1254
|
+
console.log(
|
|
1255
|
+
` ${yellow("⚠")} ${bold("FORGE_HOME")} not set automatically — run this in PowerShell:`,
|
|
1256
|
+
);
|
|
1257
|
+
console.log(
|
|
1258
|
+
` ${cyan(`[System.Environment]::SetEnvironmentVariable('FORGE_HOME', '${FORGE_HOME.replace(/'/g, "''")}', 'User')`)}`,
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
forgeHomeSet = true;
|
|
1262
|
+
} else {
|
|
1263
|
+
for (const profile of [join(HOME, ".bashrc"), join(HOME, ".zshrc")]) {
|
|
1264
|
+
if (existsSync(profile)) {
|
|
1265
|
+
const content = readFileSync(profile, "utf-8");
|
|
1266
|
+
if (!content.includes("FORGE_HOME")) {
|
|
1267
|
+
appendFileSync(
|
|
1268
|
+
profile,
|
|
1269
|
+
`\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
|
|
1270
|
+
);
|
|
1271
|
+
const profileShort = profile.replace(HOME, "~");
|
|
1272
|
+
console.log(
|
|
1273
|
+
` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`,
|
|
1274
|
+
);
|
|
1275
|
+
forgeHomeSet = true;
|
|
1276
|
+
}
|
|
938
1277
|
}
|
|
939
1278
|
}
|
|
940
|
-
}
|
|
941
1279
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1280
|
+
if (!forgeHomeSet) {
|
|
1281
|
+
console.log(
|
|
1282
|
+
` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
946
1285
|
}
|
|
947
1286
|
|
|
948
1287
|
console.log("");
|
|
@@ -951,6 +1290,31 @@ async function install() {
|
|
|
951
1290
|
);
|
|
952
1291
|
console.log("");
|
|
953
1292
|
|
|
1293
|
+
// -------------------------------------------------------------------------
|
|
1294
|
+
// Phase 6: SessionStart hook — install into ~/.claude/settings.json
|
|
1295
|
+
// -------------------------------------------------------------------------
|
|
1296
|
+
|
|
1297
|
+
const hookResult = await installSessionStartHook();
|
|
1298
|
+
if (hookResult === "installed") {
|
|
1299
|
+
console.log(
|
|
1300
|
+
` ${green("✔")} ${bold("SessionStart hook")} installed in ${cyan("~/.claude/settings.json")}`,
|
|
1301
|
+
);
|
|
1302
|
+
} else if (hookResult === "already-present") {
|
|
1303
|
+
console.log(
|
|
1304
|
+
` ${dim("✔")} ${dim("SessionStart hook already present in ~/.claude/settings.json")}`,
|
|
1305
|
+
);
|
|
1306
|
+
} else {
|
|
1307
|
+
// "failed" — warning only, does not block install
|
|
1308
|
+
console.log(
|
|
1309
|
+
` ${yellow("⚠")} ${yellow("Could not write SessionStart hook to ~/.claude/settings.json")}`,
|
|
1310
|
+
);
|
|
1311
|
+
console.log(
|
|
1312
|
+
` ${dim(" Run")} ${cyan("npx forgedock install")} ${dim("again to retry.")}`,
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
console.log("");
|
|
1317
|
+
|
|
954
1318
|
// forge.yaml advisory — guide users to run init if config is missing
|
|
955
1319
|
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
956
1320
|
if (!existsSync(forgeYamlPath)) {
|
|
@@ -1012,10 +1376,39 @@ async function uninstall() {
|
|
|
1012
1376
|
}
|
|
1013
1377
|
});
|
|
1014
1378
|
|
|
1379
|
+
// On Windows, always attempt FORGE_HOME cleanup — do not gate on process.env.FORGE_HOME.
|
|
1380
|
+
// setx writes to HKCU\Environment (the registry) but does NOT update process.env in the
|
|
1381
|
+
// current running process. If the user runs install then uninstall in the same terminal
|
|
1382
|
+
// session, process.env.FORGE_HOME is undefined even though the registry key was written.
|
|
1383
|
+
// The REG delete call in Phase 5 is already wrapped in try/catch and handles the
|
|
1384
|
+
// key-not-found case gracefully, so unconditional detection is safe.
|
|
1385
|
+
const hasWindowsForgeHome = process.platform === "win32";
|
|
1386
|
+
|
|
1015
1387
|
// Check for forge.yaml in cwd
|
|
1016
1388
|
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
1017
1389
|
const hasForgeYaml = existsSync(forgeYamlPath);
|
|
1018
1390
|
|
|
1391
|
+
// Check whether the SessionStart hook is present in settings.json
|
|
1392
|
+
let hasSessionStartHook = false;
|
|
1393
|
+
try {
|
|
1394
|
+
const settings = readClaudeSettings();
|
|
1395
|
+
const sessionStartEntries = settings?.hooks?.SessionStart;
|
|
1396
|
+
if (Array.isArray(sessionStartEntries)) {
|
|
1397
|
+
hasSessionStartHook = sessionStartEntries.some((entry) => {
|
|
1398
|
+
if (!entry || typeof entry !== "object") return false;
|
|
1399
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
1400
|
+
return hooks.some(
|
|
1401
|
+
(h) =>
|
|
1402
|
+
h &&
|
|
1403
|
+
typeof h.command === "string" &&
|
|
1404
|
+
isForgeSessionStartHook(h.command),
|
|
1405
|
+
);
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
} catch {
|
|
1409
|
+
// settings.json unreadable — treat as not present
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1019
1412
|
// -------------------------------------------------------------------------
|
|
1020
1413
|
// Phase 2: Pre-removal summary
|
|
1021
1414
|
// -------------------------------------------------------------------------
|
|
@@ -1023,7 +1416,9 @@ async function uninstall() {
|
|
|
1023
1416
|
if (
|
|
1024
1417
|
toRemove.length === 0 &&
|
|
1025
1418
|
profilesWithForgeHome.length === 0 &&
|
|
1026
|
-
!
|
|
1419
|
+
!hasWindowsForgeHome &&
|
|
1420
|
+
!hasForgeYaml &&
|
|
1421
|
+
!hasSessionStartHook
|
|
1027
1422
|
) {
|
|
1028
1423
|
console.log(
|
|
1029
1424
|
` ${dim("Nothing to remove — ForgeDock does not appear to be installed.")}`,
|
|
@@ -1046,6 +1441,16 @@ async function uninstall() {
|
|
|
1046
1441
|
` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(HOME, "~")).join(", "))}`,
|
|
1047
1442
|
);
|
|
1048
1443
|
}
|
|
1444
|
+
if (hasWindowsForgeHome) {
|
|
1445
|
+
summaryLines.push(
|
|
1446
|
+
` ${yellow("Env var")}: FORGE_HOME set in Windows user environment`,
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
if (hasSessionStartHook) {
|
|
1450
|
+
summaryLines.push(
|
|
1451
|
+
` ${yellow("Hook")}: SessionStart hook in ${dim("~/.claude/settings.json")}`,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1049
1454
|
if (hasForgeYaml) {
|
|
1050
1455
|
summaryLines.push(` ${cyan("forge.yaml")}: present in current directory`);
|
|
1051
1456
|
}
|
|
@@ -1063,7 +1468,9 @@ async function uninstall() {
|
|
|
1063
1468
|
const profileLabel =
|
|
1064
1469
|
profilesWithForgeHome.length > 0
|
|
1065
1470
|
? ` and FORGE_HOME from ${profilesWithForgeHome.length === 1 ? "shell profile" : "shell profiles"}`
|
|
1066
|
-
:
|
|
1471
|
+
: hasWindowsForgeHome
|
|
1472
|
+
? " and FORGE_HOME from Windows user environment"
|
|
1473
|
+
: "";
|
|
1067
1474
|
|
|
1068
1475
|
let confirmed = forceYes;
|
|
1069
1476
|
if (!confirmed) {
|
|
@@ -1110,6 +1517,42 @@ async function uninstall() {
|
|
|
1110
1517
|
// Phase 5: FORGE_HOME cleanup (only if detected)
|
|
1111
1518
|
// -------------------------------------------------------------------------
|
|
1112
1519
|
|
|
1520
|
+
if (hasWindowsForgeHome) {
|
|
1521
|
+
// Windows: delete FORGE_HOME from HKCU\Environment via REG delete.
|
|
1522
|
+
// setx cannot delete registry keys — `setx VAR ""` writes an empty REG_SZ
|
|
1523
|
+
// entry rather than removing the key. REG delete is the correct approach
|
|
1524
|
+
// for HKCU and requires no elevation.
|
|
1525
|
+
console.log("");
|
|
1526
|
+
const cleanWindows = forceYes
|
|
1527
|
+
? true
|
|
1528
|
+
: await confirm("Remove FORGE_HOME from Windows user environment?", true);
|
|
1529
|
+
|
|
1530
|
+
if (cleanWindows) {
|
|
1531
|
+
try {
|
|
1532
|
+
execFileSync(
|
|
1533
|
+
"REG",
|
|
1534
|
+
["delete", "HKCU\\Environment", "/v", "FORGE_HOME", "/f"],
|
|
1535
|
+
{ stdio: "pipe" },
|
|
1536
|
+
);
|
|
1537
|
+
console.log(
|
|
1538
|
+
` ${green("✔")} Removed FORGE_HOME from Windows user environment`,
|
|
1539
|
+
);
|
|
1540
|
+
} catch {
|
|
1541
|
+
// REG delete unavailable or key not found — print PowerShell fallback
|
|
1542
|
+
console.log(
|
|
1543
|
+
` ${yellow("⚠")} Could not remove FORGE_HOME automatically — run this in PowerShell:`,
|
|
1544
|
+
);
|
|
1545
|
+
console.log(
|
|
1546
|
+
` ${cyan("[System.Environment]::SetEnvironmentVariable('FORGE_HOME', $null, 'User')")}`,
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
} else {
|
|
1550
|
+
console.log(
|
|
1551
|
+
` ${dim("Skipped — FORGE_HOME left in Windows user environment.")}`,
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1113
1556
|
if (profilesWithForgeHome.length > 0) {
|
|
1114
1557
|
console.log("");
|
|
1115
1558
|
const cleanProfiles = forceYes
|
|
@@ -1160,7 +1603,42 @@ async function uninstall() {
|
|
|
1160
1603
|
}
|
|
1161
1604
|
|
|
1162
1605
|
// -------------------------------------------------------------------------
|
|
1163
|
-
// Phase 6:
|
|
1606
|
+
// Phase 6: SessionStart hook removal
|
|
1607
|
+
// -------------------------------------------------------------------------
|
|
1608
|
+
|
|
1609
|
+
if (hasSessionStartHook) {
|
|
1610
|
+
console.log("");
|
|
1611
|
+
const removeHook = forceYes
|
|
1612
|
+
? true
|
|
1613
|
+
: await confirm(
|
|
1614
|
+
"Remove SessionStart hook from ~/.claude/settings.json?",
|
|
1615
|
+
true,
|
|
1616
|
+
);
|
|
1617
|
+
|
|
1618
|
+
if (removeHook) {
|
|
1619
|
+
const hookRemoveResult = await removeSessionStartHook();
|
|
1620
|
+
if (hookRemoveResult === "removed") {
|
|
1621
|
+
console.log(
|
|
1622
|
+
` ${green("✔")} Removed SessionStart hook from ${dim("~/.claude/settings.json")}`,
|
|
1623
|
+
);
|
|
1624
|
+
} else if (hookRemoveResult === "not-present") {
|
|
1625
|
+
console.log(
|
|
1626
|
+
` ${dim("✔")} SessionStart hook already absent from ~/.claude/settings.json`,
|
|
1627
|
+
);
|
|
1628
|
+
} else {
|
|
1629
|
+
console.log(
|
|
1630
|
+
` ${red("✖")} Could not update ~/.claude/settings.json — remove the hook manually.`,
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
} else {
|
|
1634
|
+
console.log(
|
|
1635
|
+
` ${dim("Skipped — SessionStart hook left in settings.json.")}`,
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// -------------------------------------------------------------------------
|
|
1641
|
+
// Phase 7: forge.yaml handling (default: keep)
|
|
1164
1642
|
// -------------------------------------------------------------------------
|
|
1165
1643
|
|
|
1166
1644
|
if (hasForgeYaml) {
|
|
@@ -1184,18 +1662,23 @@ async function uninstall() {
|
|
|
1184
1662
|
}
|
|
1185
1663
|
|
|
1186
1664
|
// -------------------------------------------------------------------------
|
|
1187
|
-
// Phase
|
|
1665
|
+
// Phase 8: Post-removal summary
|
|
1188
1666
|
// -------------------------------------------------------------------------
|
|
1189
1667
|
|
|
1190
1668
|
console.log("");
|
|
1191
1669
|
console.log(
|
|
1192
1670
|
`${green("Uninstall complete.")} ForgeDock commands have been removed.`,
|
|
1193
1671
|
);
|
|
1194
|
-
if (profilesWithForgeHome.length > 0) {
|
|
1672
|
+
if (profilesWithForgeHome.length > 0 && process.platform !== "win32") {
|
|
1195
1673
|
console.log(
|
|
1196
1674
|
` ${dim("Restart your shell or run")} ${cyan("source ~/.bashrc")} ${dim("(or")} ${cyan("~/.zshrc")}${dim(")")} ${dim("to apply profile changes.")}`,
|
|
1197
1675
|
);
|
|
1198
1676
|
}
|
|
1677
|
+
if (hasWindowsForgeHome) {
|
|
1678
|
+
console.log(
|
|
1679
|
+
` ${dim("Restart your terminal to apply environment changes.")}`,
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1199
1682
|
console.log("");
|
|
1200
1683
|
}
|
|
1201
1684
|
|
|
@@ -1216,6 +1699,9 @@ function queryNpmRegistry(pkg) {
|
|
|
1216
1699
|
encoding: "utf-8",
|
|
1217
1700
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1218
1701
|
timeout: 5000,
|
|
1702
|
+
// On Windows, npm is a .cmd shim — shell: true enables .cmd resolution.
|
|
1703
|
+
// On POSIX, npm resolves directly — no shell needed. (Ref: review-finding #413)
|
|
1704
|
+
shell: process.platform === "win32",
|
|
1219
1705
|
});
|
|
1220
1706
|
return result.trim() || null;
|
|
1221
1707
|
} catch {
|
|
@@ -1557,12 +2043,16 @@ async function update() {
|
|
|
1557
2043
|
// Show changelog between current HEAD and remote HEAD
|
|
1558
2044
|
let changelogLines = [];
|
|
1559
2045
|
try {
|
|
1560
|
-
const log =
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
2046
|
+
const log = execFileSync(
|
|
2047
|
+
"git",
|
|
2048
|
+
["log", "--oneline", `${before}..origin/main`],
|
|
2049
|
+
{
|
|
2050
|
+
cwd: FORGE_HOME,
|
|
2051
|
+
encoding: "utf-8",
|
|
2052
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2053
|
+
timeout: 5000,
|
|
2054
|
+
},
|
|
2055
|
+
).trim();
|
|
1566
2056
|
if (log) {
|
|
1567
2057
|
changelogLines = log.split("\n").map((l) => ` ${dim(l)}`);
|
|
1568
2058
|
}
|
|
@@ -1740,278 +2230,325 @@ async function update() {
|
|
|
1740
2230
|
}
|
|
1741
2231
|
}
|
|
1742
2232
|
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
2233
|
+
// ---------------------------------------------------------------------------
|
|
2234
|
+
// Backend-selection ladder helpers — detect, enrich via skill, enrich via API
|
|
2235
|
+
// ---------------------------------------------------------------------------
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* Determine which enrichment backend is available.
|
|
2239
|
+
*
|
|
2240
|
+
* Ladder:
|
|
2241
|
+
* 1. skill — running inside a Claude Code session (CLAUDE_CODE_SESSION_ID set)
|
|
2242
|
+
* 2. api — ANTHROPIC_API_KEY env var is present and non-empty
|
|
2243
|
+
* 3. none — fall through to deterministic baseline
|
|
2244
|
+
*
|
|
2245
|
+
* @returns {'skill'|'api'|'none'}
|
|
2246
|
+
*/
|
|
2247
|
+
function _detectBackend() {
|
|
2248
|
+
// Primary signal: Claude Code sets CLAUDE_CODE_SESSION_ID in its environment.
|
|
2249
|
+
// This is the only reliable indicator of an active CC session — non-TTY stdin
|
|
2250
|
+
// alone is not sufficient (CI/CD and piped scripts also run non-interactively).
|
|
2251
|
+
if (process.env.CLAUDE_CODE_SESSION_ID) {
|
|
2252
|
+
return "skill";
|
|
1756
2253
|
}
|
|
1757
2254
|
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2255
|
+
// API backend: BYO key present.
|
|
2256
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
2257
|
+
return "api";
|
|
2258
|
+
}
|
|
1761
2259
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
// ---------------------------------------------------------------------------
|
|
1766
|
-
// Auto-detect defaults (silent — used as pre-fill values for prompts)
|
|
1767
|
-
// ---------------------------------------------------------------------------
|
|
1768
|
-
|
|
1769
|
-
// Detect git remote URL and parse owner/repo
|
|
1770
|
-
let detectedOwner = "your-github-org";
|
|
1771
|
-
let detectedRepo = "your-repo-name";
|
|
1772
|
-
let remoteDetected = false;
|
|
2260
|
+
return "none";
|
|
2261
|
+
}
|
|
1773
2262
|
|
|
2263
|
+
/**
|
|
2264
|
+
* Enrich a ConfigDraft by invoking the /forgedock-init skill backend via the
|
|
2265
|
+
* Claude Code CLI (`claude -p`), with the full prompt piped via stdin.
|
|
2266
|
+
*
|
|
2267
|
+
* Uses execFileSync with the `input` option to pipe the prompt
|
|
2268
|
+
* (`/forgedock-init <draftJSON>`) to the claude process's stdin rather than
|
|
2269
|
+
* embedding it in a CLI argument. This avoids OS ARG_MAX limits for large
|
|
2270
|
+
* JSON payloads and keeps the argument list minimal.
|
|
2271
|
+
*
|
|
2272
|
+
* @param {object} draft - ConfigDraft from detectConfig()
|
|
2273
|
+
* @param {string} cwd - Working directory for the claude invocation
|
|
2274
|
+
* @returns {Promise<object>} Enriched ConfigDraft, or the original draft on failure
|
|
2275
|
+
*/
|
|
2276
|
+
async function _enrichViaSkill(draft, cwd) {
|
|
1774
2277
|
try {
|
|
1775
|
-
const
|
|
2278
|
+
const draftJson = JSON.stringify(draft);
|
|
2279
|
+
const output = execFileSync("claude", ["-p"], {
|
|
1776
2280
|
cwd,
|
|
1777
2281
|
encoding: "utf-8",
|
|
2282
|
+
timeout: 120000, // 2 minutes — enrichment may take a while for large repos
|
|
2283
|
+
input: `/forgedock-init ${draftJson}`,
|
|
1778
2284
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
|
|
1786
|
-
);
|
|
2285
|
+
// On Windows, npm-installed CLIs like `claude` are .cmd shims. execFileSync
|
|
2286
|
+
// does not resolve .cmd extensions without a shell — shell: true enables the
|
|
2287
|
+
// cmd.exe lookup that finds claude.cmd on PATH. Safe here: executable name is
|
|
2288
|
+
// hardcoded, no user input is involved. (Ref: review-finding #382)
|
|
2289
|
+
shell: process.platform === "win32",
|
|
2290
|
+
});
|
|
1787
2291
|
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
remoteDetected = true;
|
|
2292
|
+
return parseEnrichedDraft(output, draft);
|
|
2293
|
+
} catch (err) {
|
|
2294
|
+
// Log the failure at debug level but never surface to user — fall back silently.
|
|
2295
|
+
if (process.env.FORGEDOCK_DEBUG) {
|
|
2296
|
+
console.error(
|
|
2297
|
+
` ${dim("[debug]")} skill enrichment failed: ${err.message}`,
|
|
2298
|
+
);
|
|
1796
2299
|
}
|
|
1797
|
-
|
|
1798
|
-
// No remote — use placeholders
|
|
2300
|
+
return draft;
|
|
1799
2301
|
}
|
|
2302
|
+
}
|
|
1800
2303
|
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
try {
|
|
1812
|
-
const cur = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1813
|
-
cwd,
|
|
1814
|
-
encoding: "utf-8",
|
|
1815
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1816
|
-
}).trim();
|
|
1817
|
-
if (cur && cur !== "HEAD") detectedDefault = cur;
|
|
1818
|
-
} catch {
|
|
1819
|
-
// Keep "main"
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Build an optionalSections object from an enriched ConfigDraft, promoting
|
|
2306
|
+
* sections that have medium-or-higher confidence to active (non-commented) YAML.
|
|
2307
|
+
* Sections with only low-confidence fields are omitted so they remain commented out.
|
|
2308
|
+
*
|
|
2309
|
+
* @param {object} enrichedDraft - ConfigDraft returned by an enrichment backend
|
|
2310
|
+
* @returns {object} optionalSections compatible with _writeForgeYaml / buildForgeYamlContent
|
|
2311
|
+
*/
|
|
2312
|
+
function _optionalSectionsFromDraft(enrichedDraft) {
|
|
2313
|
+
const sections = {};
|
|
1822
2314
|
|
|
1823
|
-
//
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2315
|
+
// project_board — include if project_number has at least medium confidence
|
|
2316
|
+
const pb = enrichedDraft.project_board;
|
|
2317
|
+
if (
|
|
2318
|
+
pb &&
|
|
2319
|
+
pb.project_number &&
|
|
2320
|
+
pb.project_number.confidence !== "low" &&
|
|
2321
|
+
pb.project_number.value
|
|
2322
|
+
) {
|
|
2323
|
+
sections.projectBoard = {
|
|
2324
|
+
projectNumber: parseInt(pb.project_number.value, 10) || 1,
|
|
2325
|
+
projectId: pb.project_id?.value || "",
|
|
2326
|
+
fieldIds: {
|
|
2327
|
+
status: pb.field_ids?.status?.value || "",
|
|
2328
|
+
lane: pb.field_ids?.lane?.value || "",
|
|
2329
|
+
component: pb.field_ids?.component?.value || "",
|
|
2330
|
+
priority: pb.field_ids?.priority?.value || "",
|
|
2331
|
+
workflow: pb.field_ids?.workflow?.value || "",
|
|
2332
|
+
},
|
|
2333
|
+
optionIds: pb.option_ids || {},
|
|
2334
|
+
};
|
|
1836
2335
|
}
|
|
1837
2336
|
|
|
1838
|
-
//
|
|
1839
|
-
const
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
.
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2337
|
+
// repos.satellites — include if at least one satellite found
|
|
2338
|
+
const repos = enrichedDraft.repos;
|
|
2339
|
+
if (
|
|
2340
|
+
repos &&
|
|
2341
|
+
repos.satellites &&
|
|
2342
|
+
Array.isArray(repos.satellites) &&
|
|
2343
|
+
repos.satellites.length > 0
|
|
2344
|
+
) {
|
|
2345
|
+
const first = repos.satellites[0];
|
|
2346
|
+
if (first && first.prefix && first.prefix.value) {
|
|
2347
|
+
sections.multiRepo = {
|
|
2348
|
+
prefix: first.prefix.value || "sat",
|
|
2349
|
+
satelliteRepo:
|
|
2350
|
+
(first.repo?.value || "").split("/").pop() || "satellite",
|
|
2351
|
+
satelliteBranch: first.staging_branch?.value || "main",
|
|
2352
|
+
};
|
|
1850
2353
|
}
|
|
1851
|
-
// Silent write — same as pre-interactive behavior
|
|
1852
|
-
_writeForgeYaml({
|
|
1853
|
-
outputPath,
|
|
1854
|
-
cwd,
|
|
1855
|
-
owner: detectedOwner,
|
|
1856
|
-
repo: detectedRepo,
|
|
1857
|
-
projectName: detectedName,
|
|
1858
|
-
description: "",
|
|
1859
|
-
root: cwd,
|
|
1860
|
-
worktreeBase: join(cwd, ".claude", "worktrees"),
|
|
1861
|
-
defaultBranch: detectedDefault,
|
|
1862
|
-
stagingBranch: detectedStaging,
|
|
1863
|
-
});
|
|
1864
|
-
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
1865
|
-
console.log("");
|
|
1866
|
-
await injectClaudeMd(cwd);
|
|
1867
|
-
console.log("");
|
|
1868
|
-
_printNextSteps({ remoteDetected });
|
|
1869
|
-
await validate(outputPath);
|
|
1870
|
-
return;
|
|
1871
2354
|
}
|
|
1872
2355
|
|
|
1873
|
-
//
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
console.log("");
|
|
2356
|
+
// review — include if tech_stack has at least medium confidence
|
|
2357
|
+
const review = enrichedDraft.review;
|
|
2358
|
+
if (
|
|
2359
|
+
review &&
|
|
2360
|
+
review.tech_stack &&
|
|
2361
|
+
review.tech_stack.confidence !== "low" &&
|
|
2362
|
+
review.tech_stack.value
|
|
2363
|
+
) {
|
|
2364
|
+
sections.review = {
|
|
2365
|
+
techStack: review.tech_stack.value,
|
|
2366
|
+
context: review.context?.value || "",
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
1887
2369
|
|
|
1888
|
-
|
|
1889
|
-
|
|
2370
|
+
// verification — include if health_endpoint has at least medium confidence
|
|
2371
|
+
const verification = enrichedDraft.verification;
|
|
2372
|
+
if (
|
|
2373
|
+
verification &&
|
|
2374
|
+
verification.health_endpoint &&
|
|
2375
|
+
verification.health_endpoint.confidence !== "low" &&
|
|
2376
|
+
verification.health_endpoint.value
|
|
2377
|
+
) {
|
|
2378
|
+
sections.verification = {
|
|
2379
|
+
healthEndpoint: verification.health_endpoint.value,
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
1890
2382
|
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
detectedOwner,
|
|
1894
|
-
);
|
|
1895
|
-
const repoInput = await input(" Repository name", detectedRepo);
|
|
1896
|
-
const nameInput = await input(
|
|
1897
|
-
" Project name",
|
|
1898
|
-
detectedName !== "Your-repo-name"
|
|
1899
|
-
? detectedName
|
|
1900
|
-
: repoInput
|
|
1901
|
-
.split(/[-_]/)
|
|
1902
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1903
|
-
.join(" "),
|
|
1904
|
-
);
|
|
1905
|
-
const descInput = await input(" Brief description", "");
|
|
2383
|
+
return sections;
|
|
2384
|
+
}
|
|
1906
2385
|
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2386
|
+
/**
|
|
2387
|
+
* Returns true if an enrichment backend produced enriched data for the draft.
|
|
2388
|
+
* Used in three places: skill-backend spinner display, api-backend spinner display,
|
|
2389
|
+
* and the enrichmentSucceeded gate that controls optional-section auto-population.
|
|
2390
|
+
*
|
|
2391
|
+
* @param {object} draft - The (possibly) enriched draft
|
|
2392
|
+
* @param {object} baseDraft - The original un-enriched draft
|
|
2393
|
+
* @returns {boolean}
|
|
2394
|
+
*/
|
|
2395
|
+
function _isEnriched(draft, baseDraft) {
|
|
2396
|
+
return draft !== baseDraft || Boolean(draft.meta?.enriched);
|
|
2397
|
+
}
|
|
1910
2398
|
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2399
|
+
/**
|
|
2400
|
+
* Module-level constant — single source of truth for the optional section
|
|
2401
|
+
* choice list used in both the enrichment and manual branches of init().
|
|
2402
|
+
* Add new sections here; all call sites update automatically.
|
|
2403
|
+
*/
|
|
2404
|
+
const OPTIONAL_SECTION_CHOICES = [
|
|
2405
|
+
{
|
|
2406
|
+
label:
|
|
2407
|
+
"Project Board — GitHub Projects v2 integration for workflow tracking",
|
|
2408
|
+
value: "projectBoard",
|
|
2409
|
+
},
|
|
2410
|
+
{
|
|
2411
|
+
label: "Multi-Repo — Satellite repos for cross-repo milestones",
|
|
2412
|
+
value: "multiRepo",
|
|
2413
|
+
},
|
|
2414
|
+
{
|
|
2415
|
+
label: "Review Context — Tech stack and conventions for PR review agents",
|
|
2416
|
+
value: "review",
|
|
2417
|
+
},
|
|
2418
|
+
{
|
|
2419
|
+
label: "Verification — Health check endpoints and response patterns",
|
|
2420
|
+
value: "verification",
|
|
2421
|
+
},
|
|
2422
|
+
];
|
|
1916
2423
|
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2424
|
+
/**
|
|
2425
|
+
* Maps camelCase optional-section keys to their forge.yaml section names.
|
|
2426
|
+
* Used for display messages after enrichment and after writing forge.yaml.
|
|
2427
|
+
*/
|
|
2428
|
+
const SECTION_KEY_TO_YAML_NAME = {
|
|
2429
|
+
projectBoard: "project_board",
|
|
2430
|
+
multiRepo: "repos",
|
|
2431
|
+
review: "review",
|
|
2432
|
+
verification: "verification",
|
|
2433
|
+
};
|
|
1920
2434
|
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2435
|
+
/**
|
|
2436
|
+
* Non-TTY (headless) init path: enrich (already done), silently write forge.yaml,
|
|
2437
|
+
* inject CLAUDE.md, print next steps, and validate.
|
|
2438
|
+
*
|
|
2439
|
+
* Extracted from init() to reduce its size.
|
|
2440
|
+
*
|
|
2441
|
+
* @param {object} draft - Enriched (or baseline) ConfigDraft
|
|
2442
|
+
* @param {string} outputPath - Absolute path to forge.yaml
|
|
2443
|
+
* @param {string} cwd - Current working directory
|
|
2444
|
+
* @param {boolean} remoteDetected - Whether a git remote was detected
|
|
2445
|
+
*/
|
|
2446
|
+
async function _initNonTTY(draft, outputPath, cwd, remoteDetected) {
|
|
2447
|
+
if (!remoteDetected) {
|
|
2448
|
+
console.log(
|
|
2449
|
+
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
|
|
1925
2450
|
);
|
|
2451
|
+
}
|
|
2452
|
+
// Derive optional sections from the enriched draft for the silent write.
|
|
2453
|
+
const enrichedSections = _optionalSectionsFromDraft(draft);
|
|
2454
|
+
_writeForgeYaml({
|
|
2455
|
+
outputPath,
|
|
2456
|
+
cwd,
|
|
2457
|
+
owner: draft.project.owner.value,
|
|
2458
|
+
repo: draft.project.repo.value,
|
|
2459
|
+
projectName: draft.project.name.value,
|
|
2460
|
+
description: "",
|
|
2461
|
+
root: cwd,
|
|
2462
|
+
worktreeBase: join(cwd, ".claude", "worktrees"),
|
|
2463
|
+
defaultBranch: draft.branches.default.value,
|
|
2464
|
+
stagingBranch: draft.branches.staging.value,
|
|
2465
|
+
optionalSections: enrichedSections,
|
|
2466
|
+
});
|
|
2467
|
+
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
2468
|
+
console.log("");
|
|
2469
|
+
await injectClaudeMd(cwd);
|
|
2470
|
+
console.log("");
|
|
2471
|
+
_printNextSteps({ remoteDetected });
|
|
2472
|
+
await validate(outputPath);
|
|
2473
|
+
}
|
|
1926
2474
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
2475
|
+
/**
|
|
2476
|
+
* Interactive optional-section collection wizard.
|
|
2477
|
+
*
|
|
2478
|
+
* When enrichmentSucceeded, pre-populates sections from the enriched draft and
|
|
2479
|
+
* offers a multi-select for any remaining sections not auto-discovered.
|
|
2480
|
+
* When enrichment is unavailable (manualMode or no backend), presents the full
|
|
2481
|
+
* multi-select wizard for all sections.
|
|
2482
|
+
*
|
|
2483
|
+
* Returns the populated optionalSections record for use by _writeForgeYaml.
|
|
2484
|
+
*
|
|
2485
|
+
* @param {object} opts
|
|
2486
|
+
* @param {string} opts.ownerInput - Sanitized owner value from review screen
|
|
2487
|
+
* @param {string} opts.detectedOwner - Owner value from detection baseline
|
|
2488
|
+
* @param {string} opts.repoInput - Sanitized repo value from review screen
|
|
2489
|
+
* @param {string} opts.detectedRepo - Repo value from detection baseline
|
|
2490
|
+
* @param {boolean} opts.enrichmentSucceeded - Whether AI enrichment produced a result
|
|
2491
|
+
* @param {object} opts.draft - The (possibly enriched) ConfigDraft
|
|
2492
|
+
* @returns {Promise<Record<string, object>>} Populated optional sections map
|
|
2493
|
+
*/
|
|
2494
|
+
async function _collectOptionalSections({
|
|
2495
|
+
ownerInput,
|
|
2496
|
+
detectedOwner,
|
|
2497
|
+
repoInput,
|
|
2498
|
+
detectedRepo,
|
|
2499
|
+
enrichmentSucceeded,
|
|
2500
|
+
draft,
|
|
2501
|
+
}) {
|
|
2502
|
+
/** @type {Record<string, object>} */
|
|
2503
|
+
const optionalSections = {};
|
|
2504
|
+
|
|
2505
|
+
if (enrichmentSucceeded) {
|
|
2506
|
+
// Lift discovered sections directly from the enriched draft.
|
|
2507
|
+
// Only sections with medium-or-higher confidence are included; low-confidence
|
|
2508
|
+
// sections remain commented-out in the output (no behaviour change for users
|
|
2509
|
+
// without an enrichment backend).
|
|
2510
|
+
Object.assign(optionalSections, _optionalSectionsFromDraft(draft));
|
|
2511
|
+
|
|
2512
|
+
// Surface which sections were auto-populated by enrichment.
|
|
2513
|
+
const autoSections = Object.keys(optionalSections);
|
|
2514
|
+
if (autoSections.length > 0) {
|
|
2515
|
+
console.log("");
|
|
2516
|
+
console.log(
|
|
2517
|
+
` ${GREEN}Auto-configured${RESET} by enrichment: ${autoSections
|
|
2518
|
+
.map((s) => SECTION_KEY_TO_YAML_NAME[s])
|
|
2519
|
+
.join(", ")}`,
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
1940
2522
|
|
|
1941
|
-
//
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
.slice(0, 30) // show first 30 lines of required sections only
|
|
1945
|
-
.join("\n");
|
|
2523
|
+
// Still offer the multi-select so the user can add sections that enrichment
|
|
2524
|
+
// couldn't discover (e.g. project_board when gh is unauthenticated).
|
|
2525
|
+
const alreadyConfigured = new Set(autoSections);
|
|
1946
2526
|
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
title: "forge.yaml preview (required sections)",
|
|
1950
|
-
padding: 1,
|
|
1951
|
-
}),
|
|
2527
|
+
const remainingSectionChoices = OPTIONAL_SECTION_CHOICES.filter(
|
|
2528
|
+
(c) => !alreadyConfigured.has(c.value),
|
|
1952
2529
|
);
|
|
1953
2530
|
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
if (!confirmed) {
|
|
1957
|
-
console.log("");
|
|
1958
|
-
console.log(dim(" Starting over — re-enter values below."));
|
|
1959
|
-
console.log("");
|
|
1960
|
-
} else {
|
|
1961
|
-
// -----------------------------------------------------------------------
|
|
1962
|
-
// Optional sections — multi-select, then guided prompts per section
|
|
1963
|
-
// -----------------------------------------------------------------------
|
|
2531
|
+
if (remainingSectionChoices.length > 0) {
|
|
1964
2532
|
console.log("");
|
|
1965
|
-
console.log(bold(" Optional Sections"));
|
|
2533
|
+
console.log(bold(" Additional Optional Sections"));
|
|
1966
2534
|
console.log(
|
|
1967
2535
|
dim(
|
|
1968
|
-
"
|
|
1969
|
-
),
|
|
1970
|
-
);
|
|
1971
|
-
console.log(
|
|
1972
|
-
dim(
|
|
1973
|
-
" commented-out placeholders — you can enable them later by editing forge.yaml.",
|
|
2536
|
+
" The following sections were not auto-discovered. Select any to configure now.",
|
|
1974
2537
|
),
|
|
1975
2538
|
);
|
|
1976
2539
|
console.log("");
|
|
1977
2540
|
|
|
1978
|
-
const OPTIONAL_SECTION_CHOICES = [
|
|
1979
|
-
{
|
|
1980
|
-
label:
|
|
1981
|
-
"Project Board — GitHub Projects v2 integration for workflow tracking",
|
|
1982
|
-
value: "projectBoard",
|
|
1983
|
-
},
|
|
1984
|
-
{
|
|
1985
|
-
label: "Multi-Repo — Satellite repos for cross-repo milestones",
|
|
1986
|
-
value: "multiRepo",
|
|
1987
|
-
},
|
|
1988
|
-
{
|
|
1989
|
-
label:
|
|
1990
|
-
"Review Context — Tech stack and conventions for PR review agents",
|
|
1991
|
-
value: "review",
|
|
1992
|
-
},
|
|
1993
|
-
{
|
|
1994
|
-
label:
|
|
1995
|
-
"Verification — Health check endpoints and response patterns",
|
|
1996
|
-
value: "verification",
|
|
1997
|
-
},
|
|
1998
|
-
];
|
|
1999
|
-
|
|
2000
2541
|
const selectedSections = await multiSelect(
|
|
2001
|
-
" Which
|
|
2002
|
-
|
|
2542
|
+
" Which additional sections would you like to configure?",
|
|
2543
|
+
remainingSectionChoices,
|
|
2003
2544
|
);
|
|
2004
2545
|
|
|
2005
|
-
|
|
2006
|
-
const optionalSections = {};
|
|
2007
|
-
|
|
2008
|
-
// --- Project Board prompts ---
|
|
2546
|
+
// --- Project Board prompts (manual, only when enrichment didn't find it) ---
|
|
2009
2547
|
if (selectedSections.includes("projectBoard")) {
|
|
2010
2548
|
const discovered = await discoverProjectBoard(
|
|
2011
2549
|
ownerInput || detectedOwner,
|
|
2012
2550
|
);
|
|
2013
2551
|
if (discovered) {
|
|
2014
|
-
// Auto-discovery succeeded — use resolved IDs
|
|
2015
2552
|
optionalSections.projectBoard = {
|
|
2016
2553
|
projectNumber: discovered.projectNumber,
|
|
2017
2554
|
projectId: discovered.projectId,
|
|
@@ -2019,7 +2556,6 @@ async function init() {
|
|
|
2019
2556
|
optionIds: discovered.optionIds,
|
|
2020
2557
|
};
|
|
2021
2558
|
} else {
|
|
2022
|
-
// Fallback: manual entry (no regression from previous behaviour)
|
|
2023
2559
|
console.log("");
|
|
2024
2560
|
console.log(bold(" Project Board (manual)"));
|
|
2025
2561
|
console.log(
|
|
@@ -2100,75 +2636,346 @@ async function init() {
|
|
|
2100
2636
|
);
|
|
2101
2637
|
optionalSections.verification = { healthEndpoint };
|
|
2102
2638
|
}
|
|
2639
|
+
}
|
|
2640
|
+
} else {
|
|
2641
|
+
// No enrichment — use the full interactive multi-select wizard (existing behaviour).
|
|
2642
|
+
console.log("");
|
|
2643
|
+
console.log(bold(" Optional Sections"));
|
|
2644
|
+
console.log(
|
|
2645
|
+
dim(
|
|
2646
|
+
" Select sections to configure now. Unselected sections are written as",
|
|
2647
|
+
),
|
|
2648
|
+
);
|
|
2649
|
+
console.log(
|
|
2650
|
+
dim(
|
|
2651
|
+
" commented-out placeholders — you can enable them later by editing forge.yaml.",
|
|
2652
|
+
),
|
|
2653
|
+
);
|
|
2654
|
+
console.log("");
|
|
2103
2655
|
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2656
|
+
const selectedSections = await multiSelect(
|
|
2657
|
+
" Which optional sections would you like to configure?",
|
|
2658
|
+
OPTIONAL_SECTION_CHOICES,
|
|
2659
|
+
);
|
|
2660
|
+
|
|
2661
|
+
// --- Project Board prompts ---
|
|
2662
|
+
if (selectedSections.includes("projectBoard")) {
|
|
2663
|
+
const discovered = await discoverProjectBoard(
|
|
2664
|
+
ownerInput || detectedOwner,
|
|
2665
|
+
);
|
|
2666
|
+
if (discovered) {
|
|
2667
|
+
optionalSections.projectBoard = {
|
|
2668
|
+
projectNumber: discovered.projectNumber,
|
|
2669
|
+
projectId: discovered.projectId,
|
|
2670
|
+
fieldIds: discovered.fieldIds,
|
|
2671
|
+
optionIds: discovered.optionIds,
|
|
2672
|
+
};
|
|
2673
|
+
} else {
|
|
2108
2674
|
console.log("");
|
|
2109
|
-
console.log(
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2675
|
+
console.log(bold(" Project Board (manual)"));
|
|
2676
|
+
console.log(
|
|
2677
|
+
dim(
|
|
2678
|
+
" Find your project number: gh project list --owner " +
|
|
2679
|
+
(ownerInput || detectedOwner),
|
|
2680
|
+
),
|
|
2681
|
+
);
|
|
2682
|
+
const projectNumber = await input(
|
|
2683
|
+
" GitHub Projects v2 project number",
|
|
2684
|
+
"1",
|
|
2685
|
+
);
|
|
2686
|
+
optionalSections.projectBoard = {
|
|
2687
|
+
projectNumber: parseInt(projectNumber, 10) || 1,
|
|
2688
|
+
};
|
|
2689
|
+
console.log(
|
|
2690
|
+
dim(
|
|
2691
|
+
" Field IDs (PVT_/PVTSSF_ strings) must be added manually after generation.",
|
|
2692
|
+
),
|
|
2693
|
+
);
|
|
2694
|
+
console.log(
|
|
2695
|
+
dim(
|
|
2696
|
+
" Run: gh project field-list " +
|
|
2697
|
+
(projectNumber || "1") +
|
|
2698
|
+
" --owner " +
|
|
2699
|
+
(ownerInput || detectedOwner),
|
|
2700
|
+
),
|
|
2113
2701
|
);
|
|
2114
|
-
if (!shouldOverwrite) {
|
|
2115
|
-
console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
|
|
2116
|
-
console.log("");
|
|
2117
|
-
return;
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
// Backup with timestamped name if .bak already exists (fix from #36)
|
|
2121
|
-
const baseBak = join(cwd, "forge.yaml.bak");
|
|
2122
|
-
const backupPath = existsSync(baseBak)
|
|
2123
|
-
? join(
|
|
2124
|
-
cwd,
|
|
2125
|
-
`forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
|
|
2126
|
-
)
|
|
2127
|
-
: baseBak;
|
|
2128
|
-
const backupName = basename(backupPath);
|
|
2129
|
-
renameSync(outputPath, backupPath);
|
|
2130
|
-
console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
|
|
2131
2702
|
}
|
|
2703
|
+
}
|
|
2132
2704
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
repo
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2705
|
+
// --- Multi-Repo prompts ---
|
|
2706
|
+
if (selectedSections.includes("multiRepo")) {
|
|
2707
|
+
console.log("");
|
|
2708
|
+
console.log(bold(" Multi-Repo"));
|
|
2709
|
+
console.log(
|
|
2710
|
+
dim(" Configure one satellite repo (add more by editing forge.yaml)."),
|
|
2711
|
+
);
|
|
2712
|
+
const prefix = await input(
|
|
2713
|
+
" Satellite repo prefix (e.g. 'mcp', 'sdk')",
|
|
2714
|
+
"sat",
|
|
2715
|
+
);
|
|
2716
|
+
const satelliteRepo = await input(
|
|
2717
|
+
" Satellite repo name (just the name, owner will be reused)",
|
|
2718
|
+
"your-satellite-repo",
|
|
2719
|
+
);
|
|
2720
|
+
const satelliteBranch = await input(
|
|
2721
|
+
" Satellite default/staging branch",
|
|
2722
|
+
"main",
|
|
2723
|
+
);
|
|
2724
|
+
optionalSections.multiRepo = { prefix, satelliteRepo, satelliteBranch };
|
|
2725
|
+
}
|
|
2147
2726
|
|
|
2727
|
+
// --- Review Context prompts ---
|
|
2728
|
+
if (selectedSections.includes("review")) {
|
|
2148
2729
|
console.log("");
|
|
2149
|
-
console.log(
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
);
|
|
2164
|
-
}
|
|
2730
|
+
console.log(bold(" Review Context"));
|
|
2731
|
+
const techStack = await input(
|
|
2732
|
+
" Tech stack (e.g. Next.js, FastAPI, PostgreSQL)",
|
|
2733
|
+
"Node.js, TypeScript",
|
|
2734
|
+
);
|
|
2735
|
+
const context = await input(
|
|
2736
|
+
" Architecture notes (one line; expand in forge.yaml later)",
|
|
2737
|
+
"",
|
|
2738
|
+
);
|
|
2739
|
+
optionalSections.review = { techStack, context };
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// --- Verification prompts ---
|
|
2743
|
+
if (selectedSections.includes("verification")) {
|
|
2165
2744
|
console.log("");
|
|
2166
|
-
|
|
2745
|
+
console.log(bold(" Verification"));
|
|
2746
|
+
const healthEndpoint = await input(
|
|
2747
|
+
" Health check endpoint URL",
|
|
2748
|
+
`https://api.${repoInput || detectedRepo}.io/health`,
|
|
2749
|
+
);
|
|
2750
|
+
optionalSections.verification = { healthEndpoint };
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
return optionalSections;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
async function init() {
|
|
2758
|
+
// init() generates forge.yaml from prompts and git remote detection.
|
|
2759
|
+
// It does NOT require gh CLI or gh auth for core operation (project board
|
|
2760
|
+
// auto-discovery is optional and guarded separately). Only warn if Claude
|
|
2761
|
+
// Code is missing — it's the primary consumer of the generated config.
|
|
2762
|
+
try {
|
|
2763
|
+
execFileSync("claude", ["--version"], {
|
|
2764
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2765
|
+
timeout: 5000,
|
|
2766
|
+
// On Windows, claude is a .cmd shim — execFileSync needs shell: true to find it.
|
|
2767
|
+
// (Ref: review-finding #382)
|
|
2768
|
+
shell: process.platform === "win32",
|
|
2769
|
+
});
|
|
2770
|
+
} catch {
|
|
2771
|
+
console.log(` ${YELLOW}!${RESET} Claude Code CLI not found on PATH.`);
|
|
2772
|
+
console.log(
|
|
2773
|
+
` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
|
|
2774
|
+
);
|
|
2775
|
+
console.log("");
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
console.log("");
|
|
2779
|
+
console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
|
|
2780
|
+
console.log("");
|
|
2781
|
+
|
|
2782
|
+
const cwd = process.cwd();
|
|
2783
|
+
const outputPath = join(cwd, "forge.yaml");
|
|
2784
|
+
|
|
2785
|
+
// ---------------------------------------------------------------------------
|
|
2786
|
+
// Step 1: Auto-detect defaults via init-detect module.
|
|
2787
|
+
// Returns a ConfigDraft with per-field { value, confidence, source, why }.
|
|
2788
|
+
// ---------------------------------------------------------------------------
|
|
2789
|
+
|
|
2790
|
+
const baseDraft = await detectConfig(cwd);
|
|
2791
|
+
const remoteDetected = baseDraft.meta.remoteDetected;
|
|
2792
|
+
|
|
2793
|
+
// ---------------------------------------------------------------------------
|
|
2794
|
+
// Step 2: Backend-selection ladder — enrich the draft when possible.
|
|
2795
|
+
// Ladder: skill (Claude Code session) → api (ANTHROPIC_API_KEY) → none (baseline)
|
|
2796
|
+
// --manual bypasses enrichment entirely: presents the annotated review screen
|
|
2797
|
+
// with detection baseline values only — no AI-enriched suggestions applied.
|
|
2798
|
+
// ---------------------------------------------------------------------------
|
|
2799
|
+
|
|
2800
|
+
const backend = _detectBackend();
|
|
2801
|
+
let draft = baseDraft;
|
|
2802
|
+
|
|
2803
|
+
if (manualMode) {
|
|
2804
|
+
// Escape hatch: skip all AI enrichment; review screen shows detection baseline.
|
|
2805
|
+
console.log(
|
|
2806
|
+
` ${CYAN}--manual${RESET} mode: skipping autopilot enrichment — annotated review screen`,
|
|
2807
|
+
);
|
|
2808
|
+
console.log("");
|
|
2809
|
+
} else if (backend === "skill") {
|
|
2810
|
+
const s = spinner("Enriching config via skill backend…");
|
|
2811
|
+
draft = await _enrichViaSkill(baseDraft, cwd);
|
|
2812
|
+
const enriched = _isEnriched(draft, baseDraft);
|
|
2813
|
+
s.stop(
|
|
2814
|
+
enriched ? "success" : "warn",
|
|
2815
|
+
enriched
|
|
2816
|
+
? `${green("[✓]")} Config enriched via Claude Code`
|
|
2817
|
+
: `${yellow("[!]")} Skill enrichment unavailable — using detected baseline`,
|
|
2818
|
+
);
|
|
2819
|
+
} else if (backend === "api") {
|
|
2820
|
+
const s = spinner("Enriching config via Anthropic API…");
|
|
2821
|
+
draft = await enrichViaAPI(baseDraft);
|
|
2822
|
+
const enriched = _isEnriched(draft, baseDraft);
|
|
2823
|
+
s.stop(
|
|
2824
|
+
enriched ? "success" : "warn",
|
|
2825
|
+
enriched
|
|
2826
|
+
? `${green("[✓]")} Config enriched via Anthropic API`
|
|
2827
|
+
: `${yellow("[!]")} API enrichment unavailable — using detected baseline`,
|
|
2828
|
+
);
|
|
2829
|
+
}
|
|
2830
|
+
// backend === 'none' (or manualMode): proceed silently with the deterministic baseline
|
|
2831
|
+
|
|
2832
|
+
const detectedOwner = draft.project.owner.value;
|
|
2833
|
+
const detectedRepo = draft.project.repo.value;
|
|
2834
|
+
const detectedName = draft.project.name.value;
|
|
2835
|
+
const detectedDefault = draft.branches.default.value;
|
|
2836
|
+
const detectedStaging = draft.branches.staging.value;
|
|
2837
|
+
|
|
2838
|
+
// Non-TTY: enrich (done above) then silently write without interactive prompts.
|
|
2839
|
+
if (!process.stdin.isTTY) {
|
|
2840
|
+
await _initNonTTY(draft, outputPath, cwd, remoteDetected);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
// ---------------------------------------------------------------------------
|
|
2845
|
+
// Step 3: Annotated review screen — single screen replacing the per-field wizard
|
|
2846
|
+
// ---------------------------------------------------------------------------
|
|
2847
|
+
|
|
2848
|
+
// Read existing content for diff-style display if forge.yaml already exists.
|
|
2849
|
+
let existingContent = "";
|
|
2850
|
+
const hasExistingConfig = existsSync(outputPath);
|
|
2851
|
+
if (hasExistingConfig) {
|
|
2852
|
+
try {
|
|
2853
|
+
existingContent = readFileSync(outputPath, "utf-8");
|
|
2854
|
+
} catch {
|
|
2855
|
+
// Best-effort — missing or unreadable existing file is handled below
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
// Show the annotated review screen. The enriched draft populates confidence
|
|
2860
|
+
// badges from both init-detect and init-enrich. Returns accepted/edited values
|
|
2861
|
+
// plus the list of field keys that had low confidence (for TODO comment injection).
|
|
2862
|
+
// --verbose: pass showSources:true so the screen surfaces each field's detection
|
|
2863
|
+
// source and confidence rationale (the .source and .why metadata from ConfigDraft).
|
|
2864
|
+
const reviewed = await annotatedReviewScreen(draft, {
|
|
2865
|
+
hasExistingConfig,
|
|
2866
|
+
existingContent,
|
|
2867
|
+
showSources: verboseMode,
|
|
2868
|
+
});
|
|
2869
|
+
|
|
2870
|
+
const ownerInput = _sanitizeYamlValue(reviewed.owner || detectedOwner);
|
|
2871
|
+
const repoInput = _sanitizeYamlValue(reviewed.repo || detectedRepo);
|
|
2872
|
+
const nameInput = _sanitizeYamlValue(reviewed.name || detectedName);
|
|
2873
|
+
const descInput = _sanitizeYamlValue(reviewed.description || "");
|
|
2874
|
+
const rootInput = reviewed.root || cwd;
|
|
2875
|
+
const worktreeInput =
|
|
2876
|
+
reviewed.worktreeBase || join(cwd, ".claude", "worktrees");
|
|
2877
|
+
const defaultBranchInput = _sanitizeYamlValue(
|
|
2878
|
+
reviewed.defaultBranch || detectedDefault,
|
|
2879
|
+
);
|
|
2880
|
+
const stagingBranchInput = _sanitizeYamlValue(
|
|
2881
|
+
reviewed.stagingBranch || detectedStaging,
|
|
2882
|
+
);
|
|
2883
|
+
|
|
2884
|
+
// -----------------------------------------------------------------------
|
|
2885
|
+
// Optional sections — pre-populate from enriched draft if available,
|
|
2886
|
+
// otherwise fall back to interactive multi-select + guided prompts.
|
|
2887
|
+
// -----------------------------------------------------------------------
|
|
2888
|
+
|
|
2889
|
+
// manualMode skips enrichment — enrichmentSucceeded is false so optional sections
|
|
2890
|
+
// use the interactive multi-select path rather than the enrichment-lifted path.
|
|
2891
|
+
const enrichmentSucceeded =
|
|
2892
|
+
!manualMode && backend !== "none" && _isEnriched(draft, baseDraft);
|
|
2893
|
+
|
|
2894
|
+
const optionalSections = await _collectOptionalSections({
|
|
2895
|
+
ownerInput,
|
|
2896
|
+
detectedOwner,
|
|
2897
|
+
repoInput,
|
|
2898
|
+
detectedRepo,
|
|
2899
|
+
enrichmentSucceeded,
|
|
2900
|
+
draft,
|
|
2901
|
+
});
|
|
2902
|
+
|
|
2903
|
+
// -----------------------------------------------------------------------
|
|
2904
|
+
// Handle existing forge.yaml with backup
|
|
2905
|
+
// -----------------------------------------------------------------------
|
|
2906
|
+
if (hasExistingConfig) {
|
|
2907
|
+
console.log("");
|
|
2908
|
+
const shouldOverwrite = await confirm(
|
|
2909
|
+
" Back up existing forge.yaml and overwrite?",
|
|
2910
|
+
true,
|
|
2911
|
+
);
|
|
2912
|
+
if (!shouldOverwrite) {
|
|
2913
|
+
console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
|
|
2167
2914
|
console.log("");
|
|
2168
|
-
|
|
2169
|
-
await validate(outputPath);
|
|
2915
|
+
return;
|
|
2170
2916
|
}
|
|
2917
|
+
|
|
2918
|
+
// Backup with timestamped name if .bak already exists (fix from #36)
|
|
2919
|
+
const baseBak = join(cwd, "forge.yaml.bak");
|
|
2920
|
+
const backupPath = existsSync(baseBak)
|
|
2921
|
+
? join(
|
|
2922
|
+
cwd,
|
|
2923
|
+
`forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
|
|
2924
|
+
)
|
|
2925
|
+
: baseBak;
|
|
2926
|
+
const backupName = basename(backupPath);
|
|
2927
|
+
renameSync(outputPath, backupPath);
|
|
2928
|
+
console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
|
|
2171
2929
|
}
|
|
2930
|
+
|
|
2931
|
+
// Write the file — passing lowConfidenceKeys so _writeForgeYaml can inject
|
|
2932
|
+
// # TODO(forgedock:<field>) comments for fields that were low-confidence.
|
|
2933
|
+
_writeForgeYaml({
|
|
2934
|
+
outputPath,
|
|
2935
|
+
cwd,
|
|
2936
|
+
owner: ownerInput,
|
|
2937
|
+
repo: repoInput,
|
|
2938
|
+
projectName: nameInput,
|
|
2939
|
+
description: descInput,
|
|
2940
|
+
root: rootInput || cwd,
|
|
2941
|
+
worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
|
|
2942
|
+
defaultBranch: defaultBranchInput,
|
|
2943
|
+
stagingBranch: stagingBranchInput,
|
|
2944
|
+
optionalSections,
|
|
2945
|
+
lowConfidenceKeys: reviewed.lowConfidenceKeys ?? [],
|
|
2946
|
+
});
|
|
2947
|
+
|
|
2948
|
+
console.log("");
|
|
2949
|
+
console.log(` ${GREEN}Created${RESET}: forge.yaml`);
|
|
2950
|
+
const configuredSectionKeys = Object.keys(optionalSections);
|
|
2951
|
+
if (configuredSectionKeys.length > 0) {
|
|
2952
|
+
console.log(
|
|
2953
|
+
` ${GREEN}Configured${RESET}: ${configuredSectionKeys
|
|
2954
|
+
.map((s) => SECTION_KEY_TO_YAML_NAME[s])
|
|
2955
|
+
.filter(Boolean)
|
|
2956
|
+
.join(", ")}`,
|
|
2957
|
+
);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
// Show TODO flag summary for low-confidence fields written with comments
|
|
2961
|
+
if (reviewed.lowConfidenceKeys && reviewed.lowConfidenceKeys.length > 0) {
|
|
2962
|
+
console.log("");
|
|
2963
|
+
console.log(
|
|
2964
|
+
` ${YELLOW}⚠${RESET} ${reviewed.lowConfidenceKeys.length} field(s) written with ${CYAN}# TODO(forgedock:<field>)${RESET} comments:`,
|
|
2965
|
+
);
|
|
2966
|
+
for (const key of reviewed.lowConfidenceKeys) {
|
|
2967
|
+
console.log(` ${dim("·")} ${key}`);
|
|
2968
|
+
}
|
|
2969
|
+
console.log(
|
|
2970
|
+
` ${dim("Search for")} ${CYAN}TODO(forgedock:${RESET} ${dim("in forge.yaml to find them.")}`,
|
|
2971
|
+
);
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
console.log("");
|
|
2975
|
+
await injectClaudeMd(cwd);
|
|
2976
|
+
console.log("");
|
|
2977
|
+
_printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
|
|
2978
|
+
await validate(outputPath);
|
|
2172
2979
|
}
|
|
2173
2980
|
|
|
2174
2981
|
// ---------------------------------------------------------------------------
|
|
@@ -2380,7 +3187,9 @@ async function injectClaudeMd(cwd) {
|
|
|
2380
3187
|
/**
|
|
2381
3188
|
* Sanitize a string value for safe insertion into a YAML double-quoted scalar.
|
|
2382
3189
|
* Escapes backslashes (so a trailing \ does not corrupt the closing quote),
|
|
2383
|
-
* then strips
|
|
3190
|
+
* then strips C0/C1 Unicode control characters (\x00-\x08, \x0B, \x0C, \x0E-\x1F, \x7F-\x9F)
|
|
3191
|
+
* that are invalid per YAML 1.2 in double-quoted scalars, then strips double-quotes
|
|
3192
|
+
* and newlines to prevent YAML injection.
|
|
2384
3193
|
* Backslash escaping MUST come first — before any other replacement.
|
|
2385
3194
|
*
|
|
2386
3195
|
* @param {string} value
|
|
@@ -2389,6 +3198,7 @@ async function injectClaudeMd(cwd) {
|
|
|
2389
3198
|
function _sanitizeYamlValue(value) {
|
|
2390
3199
|
return String(value)
|
|
2391
3200
|
.replace(/\\/g, "\\\\")
|
|
3201
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, "")
|
|
2392
3202
|
.replace(/"/g, "")
|
|
2393
3203
|
.replace(/[\r\n]/g, " ")
|
|
2394
3204
|
.trim();
|
|
@@ -2409,6 +3219,30 @@ function _sanitizePathValue(value) {
|
|
|
2409
3219
|
.trim();
|
|
2410
3220
|
}
|
|
2411
3221
|
|
|
3222
|
+
/**
|
|
3223
|
+
* Sanitize a string for safe use as an unquoted YAML mapping key.
|
|
3224
|
+
* Strips or normalizes characters that are structurally significant in YAML outside of
|
|
3225
|
+
* quoted scalars, or that produce non-idiomatic key names:
|
|
3226
|
+
* - Whitespace (space, tab, newline, carriage return, and all Unicode whitespace) →
|
|
3227
|
+
* underscore, so LLM-derived names like "In Progress" become "In_Progress".
|
|
3228
|
+
* - Colon (key separator) and '#' (comment marker) → underscore.
|
|
3229
|
+
* Sequences of replaced characters are collapsed to a single underscore so the key
|
|
3230
|
+
* remains readable. Falls back to "_" if the result is empty.
|
|
3231
|
+
* <!-- Added: forge#396 -->
|
|
3232
|
+
*
|
|
3233
|
+
* @param {string} key
|
|
3234
|
+
* @returns {string}
|
|
3235
|
+
*/
|
|
3236
|
+
function _sanitizeYamlKey(key) {
|
|
3237
|
+
return (
|
|
3238
|
+
String(key)
|
|
3239
|
+
.replace(/[\s:#]+/g, "_") // whitespace (incl. space) + structural YAML chars → underscore
|
|
3240
|
+
.replace(/_+/g, "_") // collapse multiple underscores
|
|
3241
|
+
.replace(/^_|_$/g, "") // strip leading/trailing underscores
|
|
3242
|
+
.trim() || "_" // fallback if result is empty
|
|
3243
|
+
);
|
|
3244
|
+
}
|
|
3245
|
+
|
|
2412
3246
|
/**
|
|
2413
3247
|
* Attempt to auto-discover GitHub Projects v2 configuration for the given owner.
|
|
2414
3248
|
*
|
|
@@ -2761,10 +3595,15 @@ function buildForgeYamlContent({
|
|
|
2761
3595
|
techStack: _sanitizeYamlValue(
|
|
2762
3596
|
review.techStack || "Node.js, TypeScript, PostgreSQL",
|
|
2763
3597
|
),
|
|
2764
|
-
// context uses a block scalar (|) —
|
|
3598
|
+
// context uses a block scalar (|) — normalize CR/CRLF to LF, strip C0/C1 control chars
|
|
3599
|
+
// (excluding \t=\x09 and LF=\x0A which are valid in block scalars), then strip double-quotes.
|
|
3600
|
+
// Must normalize \r first so split("\n") doesn't embed bare \r. split/join handles indentation.
|
|
2765
3601
|
context: (
|
|
2766
3602
|
review.context || "Add architecture notes and conventions here."
|
|
2767
|
-
)
|
|
3603
|
+
)
|
|
3604
|
+
.replace(/\r\n|\r/g, "\n")
|
|
3605
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, "")
|
|
3606
|
+
.replace(/"/g, ""),
|
|
2768
3607
|
}
|
|
2769
3608
|
: null;
|
|
2770
3609
|
|
|
@@ -2803,6 +3642,26 @@ function buildForgeYamlContent({
|
|
|
2803
3642
|
const resolvedFieldIds = projectBoard?.fieldIds ?? {};
|
|
2804
3643
|
const resolvedOptionIds = projectBoard?.optionIds ?? {};
|
|
2805
3644
|
|
|
3645
|
+
// Sanitize all LLM-derived project_board string values before YAML interpolation.
|
|
3646
|
+
// projectId and fieldIds come from the LLM enrichment path (_optionalSectionsFromDraft)
|
|
3647
|
+
// and must be treated as untrusted — a crafted response containing a double-quote or
|
|
3648
|
+
// newline would break out of the YAML double-quoted scalar.
|
|
3649
|
+
// projectNumber is numeric (unquoted in YAML) and does not need sanitization.
|
|
3650
|
+
// <!-- Added: forge#384 -->
|
|
3651
|
+
const safeProjectBoard = projectBoard
|
|
3652
|
+
? {
|
|
3653
|
+
projectId: _sanitizeYamlValue(projectBoard.projectId ?? ""),
|
|
3654
|
+
projectNumber: projectBoard.projectNumber,
|
|
3655
|
+
fieldIds: Object.fromEntries(
|
|
3656
|
+
FIELD_KEYS.map((k) => [
|
|
3657
|
+
k,
|
|
3658
|
+
_sanitizeYamlValue(resolvedFieldIds[k] ?? ""),
|
|
3659
|
+
]),
|
|
3660
|
+
),
|
|
3661
|
+
optionIds: projectBoard.optionIds,
|
|
3662
|
+
}
|
|
3663
|
+
: null;
|
|
3664
|
+
|
|
2806
3665
|
/**
|
|
2807
3666
|
* Build a YAML block for option_ids given a nested map of { fieldKey: { optionKey: id } }.
|
|
2808
3667
|
* Only writes fields that have at least one mapped option.
|
|
@@ -2814,9 +3673,11 @@ function buildForgeYamlContent({
|
|
|
2814
3673
|
if (entries.length === 0) return "";
|
|
2815
3674
|
const lines = [" option_ids:"];
|
|
2816
3675
|
for (const [fieldKey, opts] of entries) {
|
|
2817
|
-
lines.push(` ${fieldKey}:`);
|
|
3676
|
+
lines.push(` ${_sanitizeYamlKey(fieldKey)}:`);
|
|
2818
3677
|
for (const [optKey, optId] of Object.entries(opts)) {
|
|
2819
|
-
lines.push(
|
|
3678
|
+
lines.push(
|
|
3679
|
+
` ${_sanitizeYamlKey(optKey)}: "${_sanitizeYamlValue(String(optId))}"`,
|
|
3680
|
+
);
|
|
2820
3681
|
}
|
|
2821
3682
|
}
|
|
2822
3683
|
return "\n" + lines.join("\n");
|
|
@@ -2824,20 +3685,20 @@ function buildForgeYamlContent({
|
|
|
2824
3685
|
|
|
2825
3686
|
const fieldIdsBlock = FIELD_KEYS.map(
|
|
2826
3687
|
(k) =>
|
|
2827
|
-
` ${k}: "${
|
|
3688
|
+
` ${k}: "${safeProjectBoard?.fieldIds[k] || "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
|
|
2828
3689
|
).join("\n");
|
|
2829
3690
|
|
|
2830
3691
|
const optionIdsBlock = projectBoard?.optionIds
|
|
2831
3692
|
? _buildOptionIdsBlock(resolvedOptionIds)
|
|
2832
3693
|
: "";
|
|
2833
3694
|
|
|
2834
|
-
const hasDiscoveredId = !!
|
|
3695
|
+
const hasDiscoveredId = !!safeProjectBoard?.projectId;
|
|
2835
3696
|
|
|
2836
3697
|
const projectBoardSection = projectBoard
|
|
2837
3698
|
? `project_board:
|
|
2838
3699
|
owner: "${owner}"
|
|
2839
3700
|
project_number: ${projectBoard.projectNumber || 1}
|
|
2840
|
-
project_id: "${hasDiscoveredId ?
|
|
3701
|
+
project_id: "${hasDiscoveredId ? safeProjectBoard.projectId : "PVT_kwHOxxxxxxxxxxxxxxxx"}"
|
|
2841
3702
|
field_ids:
|
|
2842
3703
|
${fieldIdsBlock}${optionIdsBlock}${!hasDiscoveredId ? `\n# To find IDs: gh project field-list ${projectBoard.projectNumber || 1} --owner ${owner}` : ""}`
|
|
2843
3704
|
: `# project_board:
|
|
@@ -2941,10 +3802,120 @@ ${verificationSection}
|
|
|
2941
3802
|
`;
|
|
2942
3803
|
}
|
|
2943
3804
|
|
|
2944
|
-
/**
|
|
3805
|
+
/**
|
|
3806
|
+
* Mapping from annotatedReviewScreen field keys to the YAML key patterns they
|
|
3807
|
+
* correspond to in the generated forge.yaml. Used to inject TODO comments.
|
|
3808
|
+
*
|
|
3809
|
+
* Each entry is either:
|
|
3810
|
+
* - A plain string: the leading-whitespace + key prefix that identifies the line
|
|
3811
|
+
* (matches in any YAML section).
|
|
3812
|
+
* - An object `{ pattern: string, section: string }`: only matches when the
|
|
3813
|
+
* scanner is currently inside the named top-level YAML section. Use this form
|
|
3814
|
+
* when the same key name appears in multiple sections but should only be
|
|
3815
|
+
* annotated in one (e.g. `owner:` appears in both `project:` and `project_board:`).
|
|
3816
|
+
*/
|
|
3817
|
+
const TODO_FIELD_YAML_KEYS = {
|
|
3818
|
+
owner: { pattern: " owner:", section: "project" },
|
|
3819
|
+
repo: " repo:",
|
|
3820
|
+
name: " name:",
|
|
3821
|
+
description: " description:",
|
|
3822
|
+
root: " root:",
|
|
3823
|
+
worktreeBase: " worktree_base:",
|
|
3824
|
+
defaultBranch: " default:",
|
|
3825
|
+
stagingBranch: " staging:",
|
|
3826
|
+
};
|
|
3827
|
+
|
|
3828
|
+
/**
|
|
3829
|
+
* Inject `# TODO(forgedock:<fieldKey>)` comments above each low-confidence
|
|
3830
|
+
* field line in the YAML content string.
|
|
3831
|
+
*
|
|
3832
|
+
* The comment is inserted as a full line above the matching key line.
|
|
3833
|
+
* Example output:
|
|
3834
|
+
* # TODO(forgedock:owner) — low-confidence: verify and update
|
|
3835
|
+
* owner: "your-github-org"
|
|
3836
|
+
*
|
|
3837
|
+
* Section tracking: the scanner maintains `currentSection` by detecting
|
|
3838
|
+
* unindented top-level keys (lines matching `/^[a-z_]+:/`). Entries in
|
|
3839
|
+
* TODO_FIELD_YAML_KEYS that specify a `section` constraint are only annotated
|
|
3840
|
+
* when `currentSection` matches — preventing false annotations on keys that
|
|
3841
|
+
* share the same name across multiple YAML sections (e.g. `owner:` appears in
|
|
3842
|
+
* both `project:` and `project_board:`).
|
|
3843
|
+
*
|
|
3844
|
+
* @param {string} content - Generated forge.yaml content
|
|
3845
|
+
* @param {string[]} lowConfidenceKeys - Array of field key strings to flag
|
|
3846
|
+
* @returns {string} - Content with TODO comments injected
|
|
3847
|
+
*/
|
|
3848
|
+
function _injectTodoComments(content, lowConfidenceKeys) {
|
|
3849
|
+
if (!lowConfidenceKeys || lowConfidenceKeys.length === 0) return content;
|
|
3850
|
+
|
|
3851
|
+
const lines = content.split("\n");
|
|
3852
|
+
const result = [];
|
|
3853
|
+
// Track the current top-level YAML section so section-scoped entries in
|
|
3854
|
+
// TODO_FIELD_YAML_KEYS are only matched within their intended section.
|
|
3855
|
+
let currentSection = null;
|
|
3856
|
+
|
|
3857
|
+
for (const line of lines) {
|
|
3858
|
+
// Detect top-level section headers: unindented lines of the form `word:` or `word_word:`.
|
|
3859
|
+
// Ignore comment lines (starting with #) and blank lines.
|
|
3860
|
+
const sectionMatch = line.match(/^([a-z][a-z0-9_]*):/);
|
|
3861
|
+
if (sectionMatch) {
|
|
3862
|
+
currentSection = sectionMatch[1];
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
// Check if this line matches any low-confidence field key pattern.
|
|
3866
|
+
for (const key of lowConfidenceKeys) {
|
|
3867
|
+
const entry = TODO_FIELD_YAML_KEYS[key];
|
|
3868
|
+
if (!entry) continue;
|
|
3869
|
+
|
|
3870
|
+
// Support both plain-string entries and { pattern, section } object entries.
|
|
3871
|
+
const pattern = typeof entry === "string" ? entry : entry.pattern;
|
|
3872
|
+
const requiredSection =
|
|
3873
|
+
typeof entry === "string" ? null : (entry.section ?? null);
|
|
3874
|
+
|
|
3875
|
+
if (
|
|
3876
|
+
line.startsWith(pattern) &&
|
|
3877
|
+
(requiredSection === null || currentSection === requiredSection)
|
|
3878
|
+
) {
|
|
3879
|
+
// Insert the TODO comment above the key line (same indentation).
|
|
3880
|
+
const indent = line.match(/^(\s*)/)[1];
|
|
3881
|
+
result.push(
|
|
3882
|
+
`${indent}# TODO(forgedock:${key}) — low-confidence: verify and update`,
|
|
3883
|
+
);
|
|
3884
|
+
break;
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
result.push(line);
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
return result.join("\n");
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
/** Write forge.yaml to disk. Accepts all params for buildForgeYamlContent plus outputPath and lowConfidenceKeys.
|
|
3894
|
+
* Uses a tmp-file + renameSync pattern to ensure the write is atomic — consistent with how
|
|
3895
|
+
* writeClaudeSettings() and shell-profile writes are done elsewhere in this file.
|
|
3896
|
+
*/
|
|
2945
3897
|
function _writeForgeYaml(opts) {
|
|
2946
|
-
|
|
2947
|
-
|
|
3898
|
+
let content = buildForgeYamlContent(opts);
|
|
3899
|
+
// Inject TODO comments for low-confidence fields detected during review.
|
|
3900
|
+
if (opts.lowConfidenceKeys && opts.lowConfidenceKeys.length > 0) {
|
|
3901
|
+
content = _injectTodoComments(content, opts.lowConfidenceKeys);
|
|
3902
|
+
}
|
|
3903
|
+
// Atomic write: write to a .tmp sibling then rename into place so a kill
|
|
3904
|
+
// mid-write never leaves forge.yaml partially written (config is regenerable,
|
|
3905
|
+
// but a partial file causes confusing validation errors).
|
|
3906
|
+
const tmpPath = opts.outputPath + ".forgedock.tmp";
|
|
3907
|
+
try {
|
|
3908
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
3909
|
+
renameSync(tmpPath, opts.outputPath);
|
|
3910
|
+
} catch (err) {
|
|
3911
|
+
// Clean up temp file if it was created before the error
|
|
3912
|
+
try {
|
|
3913
|
+
unlinkSync(tmpPath);
|
|
3914
|
+
} catch {
|
|
3915
|
+
/* already gone or never created */
|
|
3916
|
+
}
|
|
3917
|
+
throw err;
|
|
3918
|
+
}
|
|
2948
3919
|
}
|
|
2949
3920
|
|
|
2950
3921
|
/** Print next-steps guidance after forge.yaml is written. */
|
|
@@ -3077,6 +4048,7 @@ async function validate(forgeYamlPath) {
|
|
|
3077
4048
|
note: `Not found: ${forgeYamlPath}`,
|
|
3078
4049
|
});
|
|
3079
4050
|
_renderValidationSummary(checks);
|
|
4051
|
+
await _offerRemediations(checks, forgeYamlPath);
|
|
3080
4052
|
return { passed: false, checks };
|
|
3081
4053
|
}
|
|
3082
4054
|
|
|
@@ -3090,6 +4062,7 @@ async function validate(forgeYamlPath) {
|
|
|
3090
4062
|
note: `Cannot read: ${err.message}`,
|
|
3091
4063
|
});
|
|
3092
4064
|
_renderValidationSummary(checks);
|
|
4065
|
+
await _offerRemediations(checks, forgeYamlPath);
|
|
3093
4066
|
return { passed: false, checks };
|
|
3094
4067
|
}
|
|
3095
4068
|
|
|
@@ -3278,77 +4251,445 @@ async function validate(forgeYamlPath) {
|
|
|
3278
4251
|
timeout: 10000,
|
|
3279
4252
|
},
|
|
3280
4253
|
);
|
|
3281
|
-
const parsed = JSON.parse(result);
|
|
3282
|
-
if (parsed?.data?.node?.id) {
|
|
3283
|
-
checks.push({
|
|
3284
|
-
label: "Project board configured",
|
|
3285
|
-
status: "ok",
|
|
3286
|
-
note: `${projectId.slice(0, 16)}... resolves`,
|
|
3287
|
-
});
|
|
3288
|
-
} else {
|
|
3289
|
-
checks.push({
|
|
3290
|
-
label: "Project board configured",
|
|
3291
|
-
status: "warn",
|
|
3292
|
-
note: "project_id may be invalid — verify with: gh project list",
|
|
3293
|
-
});
|
|
4254
|
+
const parsed = JSON.parse(result);
|
|
4255
|
+
if (parsed?.data?.node?.id) {
|
|
4256
|
+
checks.push({
|
|
4257
|
+
label: "Project board configured",
|
|
4258
|
+
status: "ok",
|
|
4259
|
+
note: `${projectId.slice(0, 16)}... resolves`,
|
|
4260
|
+
});
|
|
4261
|
+
} else {
|
|
4262
|
+
checks.push({
|
|
4263
|
+
label: "Project board configured",
|
|
4264
|
+
status: "warn",
|
|
4265
|
+
note: "project_id may be invalid — verify with: gh project list",
|
|
4266
|
+
});
|
|
4267
|
+
}
|
|
4268
|
+
} catch {
|
|
4269
|
+
checks.push({
|
|
4270
|
+
label: "Project board configured",
|
|
4271
|
+
status: "warn",
|
|
4272
|
+
note: "Cannot verify project_id — gh error",
|
|
4273
|
+
});
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
} else {
|
|
4277
|
+
checks.push({
|
|
4278
|
+
label: "Project board",
|
|
4279
|
+
status: "warn",
|
|
4280
|
+
note: "Not configured (optional)",
|
|
4281
|
+
});
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
// 8. Satellite repo access
|
|
4285
|
+
if (_sectionActive(content, "repos")) {
|
|
4286
|
+
const satellites = _parseSatelliteRepos(content);
|
|
4287
|
+
if (satellites.length === 0) {
|
|
4288
|
+
checks.push({
|
|
4289
|
+
label: "Satellite repos",
|
|
4290
|
+
status: "warn",
|
|
4291
|
+
note: "repos: section active but no satellites found",
|
|
4292
|
+
});
|
|
4293
|
+
} else {
|
|
4294
|
+
for (const sat of satellites) {
|
|
4295
|
+
try {
|
|
4296
|
+
execFileSync("gh", ["repo", "view", sat, "--json", "name"], {
|
|
4297
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
4298
|
+
timeout: 10000,
|
|
4299
|
+
});
|
|
4300
|
+
checks.push({
|
|
4301
|
+
label: `Satellite: ${sat}`,
|
|
4302
|
+
status: "ok",
|
|
4303
|
+
note: "Accessible",
|
|
4304
|
+
});
|
|
4305
|
+
} catch {
|
|
4306
|
+
checks.push({
|
|
4307
|
+
label: `Satellite: ${sat}`,
|
|
4308
|
+
status: "warn",
|
|
4309
|
+
note: "Cannot access — check repo name or gh auth",
|
|
4310
|
+
});
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
} else {
|
|
4315
|
+
checks.push({
|
|
4316
|
+
label: "Satellite repos",
|
|
4317
|
+
status: "warn",
|
|
4318
|
+
note: "Not configured (optional)",
|
|
4319
|
+
});
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
_renderValidationSummary(checks);
|
|
4323
|
+
await _offerRemediations(checks, forgeYamlPath);
|
|
4324
|
+
const hasError = checks.some((c) => c.status === "error");
|
|
4325
|
+
return { passed: !hasError, checks };
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
/**
|
|
4329
|
+
* Offer plain-language explanations and optional auto-fixes for each failing check.
|
|
4330
|
+
*
|
|
4331
|
+
* Runs after _renderValidationSummary() so the summary is always visible first.
|
|
4332
|
+
* For each check that is not 'ok':
|
|
4333
|
+
* - Prints a plain-language sentence explaining why it matters and what to do.
|
|
4334
|
+
* - Prints a copy-paste command where applicable.
|
|
4335
|
+
* - For safe, deterministic fixes (directory creation, gh auth), offers an
|
|
4336
|
+
* auto-apply step gated on a TTY confirmation prompt.
|
|
4337
|
+
*
|
|
4338
|
+
* Non-destructive policy:
|
|
4339
|
+
* - Never auto-edits forge.yaml values.
|
|
4340
|
+
* - Never auto-applies project board ID changes.
|
|
4341
|
+
* - Auto-mkdir is offered only when the user explicitly listed the path in forge.yaml.
|
|
4342
|
+
* - Spawning `gh auth login` is offered only in TTY environments.
|
|
4343
|
+
* - Non-TTY: prints copy-paste text only, skips all interactive prompts.
|
|
4344
|
+
*
|
|
4345
|
+
* @param {Array<{label: string, status: 'ok'|'warn'|'error', note: string}>} checks
|
|
4346
|
+
* @param {string} forgeYamlPath - Absolute path to forge.yaml (used in explanations)
|
|
4347
|
+
* @returns {Promise<void>}
|
|
4348
|
+
*/
|
|
4349
|
+
async function _offerRemediations(checks, forgeYamlPath) {
|
|
4350
|
+
// Collect only failing checks — skip 'ok' and purely-optional skipped checks
|
|
4351
|
+
// that have no actionable remediation (e.g. "Not configured (optional)").
|
|
4352
|
+
const failing = checks.filter(
|
|
4353
|
+
(c) =>
|
|
4354
|
+
c.status !== "ok" &&
|
|
4355
|
+
!c.note.endsWith("(optional)") &&
|
|
4356
|
+
c.note !== "Not configured (optional)",
|
|
4357
|
+
);
|
|
4358
|
+
|
|
4359
|
+
if (failing.length === 0) return;
|
|
4360
|
+
|
|
4361
|
+
/** Whether we're in an interactive terminal — gates all prompts. */
|
|
4362
|
+
const isTTY = Boolean(process.stdin.isTTY);
|
|
4363
|
+
|
|
4364
|
+
// Print section header
|
|
4365
|
+
console.log("");
|
|
4366
|
+
console.log(` ${bold("Remediation guide:")}`);
|
|
4367
|
+
|
|
4368
|
+
for (const check of failing) {
|
|
4369
|
+
const label = check.label;
|
|
4370
|
+
const note = check.note ?? "";
|
|
4371
|
+
const isError = check.status === "error";
|
|
4372
|
+
|
|
4373
|
+
// ── 1. forge.yaml not found ─────────────────────────────────────────────
|
|
4374
|
+
if (label === "forge.yaml found") {
|
|
4375
|
+
console.log("");
|
|
4376
|
+
console.log(` ${isError ? red("✗") : yellow("!")} ${bold(label)}`);
|
|
4377
|
+
console.log(
|
|
4378
|
+
` ForgeDock reads ${cyan("forge.yaml")} from your project root to know which`,
|
|
4379
|
+
);
|
|
4380
|
+
console.log(
|
|
4381
|
+
` GitHub repo, branches, and paths to use. Without it, no pipeline commands`,
|
|
4382
|
+
);
|
|
4383
|
+
console.log(` will function correctly.`);
|
|
4384
|
+
console.log(
|
|
4385
|
+
` ${bold("Fix:")} Generate forge.yaml with AI assistance:`,
|
|
4386
|
+
);
|
|
4387
|
+
console.log(` ${cyan("npx forgedock init")}`);
|
|
4388
|
+
continue;
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
// ── 2. Required fields missing ──────────────────────────────────────────
|
|
4392
|
+
if (label === "Required fields") {
|
|
4393
|
+
// Extract the field names from the note
|
|
4394
|
+
const missingMatch = note.match(/Missing or placeholder:\s*(.+)/);
|
|
4395
|
+
const fields = missingMatch ? missingMatch[1] : note;
|
|
4396
|
+
console.log("");
|
|
4397
|
+
console.log(` ${isError ? red("✗") : yellow("!")} ${bold(label)}`);
|
|
4398
|
+
console.log(
|
|
4399
|
+
` The following fields are required but missing or still set to`,
|
|
4400
|
+
);
|
|
4401
|
+
console.log(` placeholder values: ${cyan(fields)}`);
|
|
4402
|
+
console.log(
|
|
4403
|
+
` ForgeDock uses these to route all GitHub API calls and git operations.`,
|
|
4404
|
+
);
|
|
4405
|
+
console.log(
|
|
4406
|
+
` ${bold("Fix:")} Open ${cyan("forge.yaml")} and fill in the real values:`,
|
|
4407
|
+
);
|
|
4408
|
+
for (const field of fields.split(",").map((f) => f.trim())) {
|
|
4409
|
+
let hint = "";
|
|
4410
|
+
if (field === "project.owner") hint = ` # your GitHub org or username`;
|
|
4411
|
+
else if (field === "project.repo")
|
|
4412
|
+
hint = ` # the repository name (without the owner prefix)`;
|
|
4413
|
+
else if (field === "paths.root")
|
|
4414
|
+
hint = ` # absolute path to this project on your machine`;
|
|
4415
|
+
else if (field === "branches.default")
|
|
4416
|
+
hint = ` # usually "main" or "master"`;
|
|
4417
|
+
console.log(` ${cyan(field)}: "<value>"${dim(hint)}`);
|
|
4418
|
+
}
|
|
4419
|
+
continue;
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4422
|
+
// ── 3. paths.root does not exist ────────────────────────────────────────
|
|
4423
|
+
if (label === "paths.root exists") {
|
|
4424
|
+
const pathMatch = note.match(/Directory not found:\s*(.+)/);
|
|
4425
|
+
const missingPath = pathMatch ? pathMatch[1].trim() : note;
|
|
4426
|
+
console.log("");
|
|
4427
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4428
|
+
console.log(
|
|
4429
|
+
` ForgeDock uses ${cyan("paths.root")} as the base directory for all git`,
|
|
4430
|
+
);
|
|
4431
|
+
console.log(
|
|
4432
|
+
` worktrees and file operations. The configured path does not exist:`,
|
|
4433
|
+
);
|
|
4434
|
+
console.log(` ${dim(missingPath)}`);
|
|
4435
|
+
console.log(
|
|
4436
|
+
` ${bold("Option A:")} If this is your project directory, create it:`,
|
|
4437
|
+
);
|
|
4438
|
+
console.log(` ${cyan(`mkdir -p "${missingPath}"`)}`);
|
|
4439
|
+
console.log(
|
|
4440
|
+
` ${bold("Option B:")} If the project already exists elsewhere, update forge.yaml:`,
|
|
4441
|
+
);
|
|
4442
|
+
console.log(
|
|
4443
|
+
` ${cyan("paths.root")}: "<correct absolute path to your project>"`,
|
|
4444
|
+
);
|
|
4445
|
+
|
|
4446
|
+
// Offer auto-mkdir if TTY — safe because the user explicitly listed this path
|
|
4447
|
+
if (isTTY) {
|
|
4448
|
+
console.log("");
|
|
4449
|
+
const shouldCreate = await confirm(
|
|
4450
|
+
` Create ${dim(missingPath)} now?`,
|
|
4451
|
+
false,
|
|
4452
|
+
);
|
|
4453
|
+
if (shouldCreate) {
|
|
4454
|
+
try {
|
|
4455
|
+
await mkdir(missingPath, { recursive: true });
|
|
4456
|
+
console.log(` ${green("✔")} Created: ${dim(missingPath)}`);
|
|
4457
|
+
} catch (mkdirErr) {
|
|
4458
|
+
console.log(
|
|
4459
|
+
` ${red("✖")} Could not create directory: ${mkdirErr.message}`,
|
|
4460
|
+
);
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
continue;
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
// ── 4. worktree_base cannot be created ──────────────────────────────────
|
|
4468
|
+
if (label === "worktree_base exists" && note.startsWith("Cannot create:")) {
|
|
4469
|
+
const errMsg = note.replace("Cannot create:", "").trim();
|
|
4470
|
+
console.log("");
|
|
4471
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4472
|
+
console.log(
|
|
4473
|
+
` ForgeDock could not create the worktree directory automatically.`,
|
|
4474
|
+
);
|
|
4475
|
+
console.log(` Error: ${dim(errMsg)}`);
|
|
4476
|
+
console.log(
|
|
4477
|
+
` Worktrees are temporary checkout directories used during builds.`,
|
|
4478
|
+
);
|
|
4479
|
+
console.log(
|
|
4480
|
+
` ${bold("Fix:")} Check that the parent directory is writable, or update`,
|
|
4481
|
+
);
|
|
4482
|
+
console.log(
|
|
4483
|
+
` ${cyan("paths.worktree_base")} in forge.yaml to a writable location.`,
|
|
4484
|
+
);
|
|
4485
|
+
continue;
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4488
|
+
// ── 5. GitHub repo inaccessible ─────────────────────────────────────────
|
|
4489
|
+
if (label === "GitHub repo accessible") {
|
|
4490
|
+
// Try to detect whether gh is unauthenticated vs wrong repo name
|
|
4491
|
+
let isAuthIssue = false;
|
|
4492
|
+
try {
|
|
4493
|
+
execFileSync("gh", ["auth", "status"], {
|
|
4494
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
4495
|
+
timeout: 5000,
|
|
4496
|
+
});
|
|
4497
|
+
} catch {
|
|
4498
|
+
isAuthIssue = true;
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
// Extract the repo slug from the note if possible
|
|
4502
|
+
const repoMatch = note.match(/Cannot access ([^\s]+)/);
|
|
4503
|
+
const repoSlug = repoMatch ? repoMatch[1] : "owner/repo";
|
|
4504
|
+
|
|
4505
|
+
console.log("");
|
|
4506
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4507
|
+
|
|
4508
|
+
if (isAuthIssue) {
|
|
4509
|
+
console.log(
|
|
4510
|
+
` The GitHub CLI is not authenticated. ForgeDock needs read access to`,
|
|
4511
|
+
);
|
|
4512
|
+
console.log(
|
|
4513
|
+
` ${cyan(repoSlug)} to list issues, manage PRs, and run pipeline commands.`,
|
|
4514
|
+
);
|
|
4515
|
+
console.log(` ${bold("Fix:")} Authenticate with the GitHub CLI:`);
|
|
4516
|
+
console.log(` ${cyan("gh auth login")}`);
|
|
4517
|
+
|
|
4518
|
+
if (isTTY) {
|
|
4519
|
+
console.log("");
|
|
4520
|
+
const shouldAuth = await confirm(
|
|
4521
|
+
` Run ${cyan("gh auth login")} now?`,
|
|
4522
|
+
false,
|
|
4523
|
+
);
|
|
4524
|
+
if (shouldAuth) {
|
|
4525
|
+
try {
|
|
4526
|
+
execFileSync("gh", ["auth", "login"], {
|
|
4527
|
+
stdio: "inherit",
|
|
4528
|
+
shell: process.platform === "win32",
|
|
4529
|
+
});
|
|
4530
|
+
} catch {
|
|
4531
|
+
// User may have cancelled — not an error from our side
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
3294
4534
|
}
|
|
3295
|
-
}
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
4535
|
+
} else {
|
|
4536
|
+
console.log(
|
|
4537
|
+
` Cannot access ${cyan(repoSlug)}. The GitHub CLI is authenticated but`,
|
|
4538
|
+
);
|
|
4539
|
+
console.log(
|
|
4540
|
+
` the repo may not exist, you may not have permission, or the owner/repo`,
|
|
4541
|
+
);
|
|
4542
|
+
console.log(` values in forge.yaml are incorrect.`);
|
|
4543
|
+
console.log(
|
|
4544
|
+
` ${bold("Fix:")} Verify the repo exists and is accessible:`,
|
|
4545
|
+
);
|
|
4546
|
+
console.log(` ${cyan(`gh repo view ${repoSlug}`)}`);
|
|
4547
|
+
console.log(
|
|
4548
|
+
` Then update ${cyan("project.owner")} and ${cyan("project.repo")} in forge.yaml if needed.`,
|
|
4549
|
+
);
|
|
3301
4550
|
}
|
|
4551
|
+
continue;
|
|
3302
4552
|
}
|
|
3303
|
-
} else {
|
|
3304
|
-
checks.push({
|
|
3305
|
-
label: "Project board",
|
|
3306
|
-
status: "warn",
|
|
3307
|
-
note: "Not configured (optional)",
|
|
3308
|
-
});
|
|
3309
|
-
}
|
|
3310
4553
|
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
}
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
4554
|
+
// ── 6. Branch not found on remote ───────────────────────────────────────
|
|
4555
|
+
if (label.startsWith("Branch: ")) {
|
|
4556
|
+
const branchName = label.replace("Branch: ", "").trim();
|
|
4557
|
+
console.log("");
|
|
4558
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4559
|
+
console.log(
|
|
4560
|
+
` Branch ${cyan(branchName)} was not found on the remote. ForgeDock uses`,
|
|
4561
|
+
);
|
|
4562
|
+
console.log(` this branch for PRs and as a base for new work.`);
|
|
4563
|
+
console.log(` ${bold("Option A:")} Create and push the branch:`);
|
|
4564
|
+
console.log(
|
|
4565
|
+
` ${cyan(`git checkout -b ${branchName} && git push -u origin ${branchName}`)}`,
|
|
4566
|
+
);
|
|
4567
|
+
console.log(
|
|
4568
|
+
` ${bold("Option B:")} Update the branch name in forge.yaml to one that exists:`,
|
|
4569
|
+
);
|
|
4570
|
+
console.log(
|
|
4571
|
+
` ${cyan("branches.default")} or ${cyan("branches.staging")} → the correct branch name`,
|
|
4572
|
+
);
|
|
4573
|
+
continue;
|
|
4574
|
+
}
|
|
4575
|
+
|
|
4576
|
+
// ── 7. Project board not configured or invalid ──────────────────────────
|
|
4577
|
+
if (label === "Project board configured" || label === "Project board") {
|
|
4578
|
+
if (note.includes("placeholder")) {
|
|
4579
|
+
console.log("");
|
|
4580
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4581
|
+
console.log(
|
|
4582
|
+
` The ${cyan("project_board.project_id")} in forge.yaml is still a placeholder.`,
|
|
4583
|
+
);
|
|
4584
|
+
console.log(
|
|
4585
|
+
` This ID is required for the pipeline to move issues across the board automatically.`,
|
|
4586
|
+
);
|
|
4587
|
+
console.log(
|
|
4588
|
+
` ${bold("Fix:")} Get your project ID from the GitHub CLI:`,
|
|
4589
|
+
);
|
|
4590
|
+
console.log(
|
|
4591
|
+
` ${cyan("gh project list --owner <your-org-or-username>")}`,
|
|
4592
|
+
);
|
|
4593
|
+
console.log(
|
|
4594
|
+
` Copy the ${cyan("PVT_...")} ID from the output and update ${cyan("project_board.project_id")} in forge.yaml.`,
|
|
4595
|
+
);
|
|
4596
|
+
} else if (isError) {
|
|
4597
|
+
// Malformed project_id
|
|
4598
|
+
console.log("");
|
|
4599
|
+
console.log(` ${red("✗")} ${bold(label)}`);
|
|
4600
|
+
console.log(
|
|
4601
|
+
` The ${cyan("project_board.project_id")} value does not match the expected`,
|
|
4602
|
+
);
|
|
4603
|
+
console.log(
|
|
4604
|
+
` format (${cyan("PVT_...")} — a GitHub Projects GraphQL node ID).`,
|
|
4605
|
+
);
|
|
4606
|
+
console.log(` ${bold("Fix:")} Look up your real project ID:`);
|
|
4607
|
+
console.log(
|
|
4608
|
+
` ${cyan("gh project list --owner <your-org-or-username>")}`,
|
|
4609
|
+
);
|
|
4610
|
+
console.log(
|
|
4611
|
+
` Replace the value in forge.yaml with the ${cyan("PVT_...")} ID shown.`,
|
|
4612
|
+
);
|
|
4613
|
+
} else if (note.includes("may be invalid")) {
|
|
4614
|
+
console.log("");
|
|
4615
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4616
|
+
console.log(
|
|
4617
|
+
` The ${cyan("project_board.project_id")} could not be verified via the GitHub API.`,
|
|
4618
|
+
);
|
|
4619
|
+
console.log(
|
|
4620
|
+
` It may be invalid or the GitHub CLI may lack org-level permissions.`,
|
|
4621
|
+
);
|
|
4622
|
+
console.log(` ${bold("Fix:")} Verify your project ID:`);
|
|
4623
|
+
console.log(
|
|
4624
|
+
` ${cyan("gh project list --owner <your-org-or-username>")}`,
|
|
4625
|
+
);
|
|
4626
|
+
} else if (note.includes("gh error")) {
|
|
4627
|
+
console.log("");
|
|
4628
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4629
|
+
console.log(
|
|
4630
|
+
` Could not verify the project board ID — the GitHub API call failed.`,
|
|
4631
|
+
);
|
|
4632
|
+
console.log(
|
|
4633
|
+
` This is usually a transient network issue or a permissions gap.`,
|
|
4634
|
+
);
|
|
4635
|
+
console.log(` ${bold("Fix:")} Check your auth and try again:`);
|
|
4636
|
+
console.log(` ${cyan("gh auth status")}`);
|
|
4637
|
+
console.log(
|
|
4638
|
+
` ${cyan("gh project list --owner <your-org-or-username>")}`,
|
|
4639
|
+
);
|
|
3339
4640
|
}
|
|
4641
|
+
continue;
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4644
|
+
// ── 8. Satellite repo inaccessible ──────────────────────────────────────
|
|
4645
|
+
if (label.startsWith("Satellite: ")) {
|
|
4646
|
+
const satRepo = label.replace("Satellite: ", "").trim();
|
|
4647
|
+
console.log("");
|
|
4648
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4649
|
+
console.log(` Cannot access satellite repo ${cyan(satRepo)}.`);
|
|
4650
|
+
console.log(
|
|
4651
|
+
` Satellite repos are used when your project spans multiple repositories.`,
|
|
4652
|
+
);
|
|
4653
|
+
console.log(` ${bold("Fix:")} Verify the repo name and your access:`);
|
|
4654
|
+
console.log(` ${cyan(`gh repo view ${satRepo}`)}`);
|
|
4655
|
+
console.log(
|
|
4656
|
+
` If the repo name is wrong, update ${cyan("repos.satellites")} in forge.yaml.`,
|
|
4657
|
+
);
|
|
4658
|
+
console.log(
|
|
4659
|
+
` If it is a private repo you cannot access, run: ${cyan("gh auth login")}`,
|
|
4660
|
+
);
|
|
4661
|
+
continue;
|
|
4662
|
+
}
|
|
4663
|
+
|
|
4664
|
+
// ── 9. Satellite repos section misconfigured ─────────────────────────────
|
|
4665
|
+
if (label === "Satellite repos" && note.includes("no satellites found")) {
|
|
4666
|
+
console.log("");
|
|
4667
|
+
console.log(` ${yellow("!")} ${bold(label)}`);
|
|
4668
|
+
console.log(
|
|
4669
|
+
` The ${cyan("repos:")} section in forge.yaml is active (uncommented) but`,
|
|
4670
|
+
);
|
|
4671
|
+
console.log(
|
|
4672
|
+
` contains no satellite entries. Either add satellites or comment out the section.`,
|
|
4673
|
+
);
|
|
4674
|
+
console.log(
|
|
4675
|
+
` ${bold("Fix:")} Add a satellite entry or comment out the repos: section.`,
|
|
4676
|
+
);
|
|
4677
|
+
continue;
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4680
|
+
// ── Fallback: generic remediation for unexpected checks ──────────────────
|
|
4681
|
+
if (note && !note.endsWith("(optional)")) {
|
|
4682
|
+
console.log("");
|
|
4683
|
+
console.log(
|
|
4684
|
+
` ${isError ? red("✗") : yellow("!")} ${bold(label)}: ${dim(note)}`,
|
|
4685
|
+
);
|
|
4686
|
+
console.log(
|
|
4687
|
+
` Review the check above and update forge.yaml or run ${cyan("npx forgedock init")} to regenerate.`,
|
|
4688
|
+
);
|
|
3340
4689
|
}
|
|
3341
|
-
} else {
|
|
3342
|
-
checks.push({
|
|
3343
|
-
label: "Satellite repos",
|
|
3344
|
-
status: "warn",
|
|
3345
|
-
note: "Not configured (optional)",
|
|
3346
|
-
});
|
|
3347
4690
|
}
|
|
3348
4691
|
|
|
3349
|
-
|
|
3350
|
-
const hasError = checks.some((c) => c.status === "error");
|
|
3351
|
-
return { passed: !hasError, checks };
|
|
4692
|
+
console.log("");
|
|
3352
4693
|
}
|
|
3353
4694
|
|
|
3354
4695
|
/**
|
|
@@ -3738,6 +5079,161 @@ async function docsInit() {
|
|
|
3738
5079
|
console.log("");
|
|
3739
5080
|
}
|
|
3740
5081
|
|
|
5082
|
+
// ---------------------------------------------------------------------------
|
|
5083
|
+
// enable / disable / status — per-directory state management
|
|
5084
|
+
// ---------------------------------------------------------------------------
|
|
5085
|
+
|
|
5086
|
+
/**
|
|
5087
|
+
* Enable ForgeDock for a directory.
|
|
5088
|
+
*
|
|
5089
|
+
* - Clears any existing opt-out entry in the registry (so the directory becomes
|
|
5090
|
+
* active again if it was previously disabled).
|
|
5091
|
+
* - Writes a lightweight `.forgedock` marker file if neither `forge.yaml` nor
|
|
5092
|
+
* `.forgedock` already exists — this marks the directory as ForgeDock-managed
|
|
5093
|
+
* before a full config is generated.
|
|
5094
|
+
*
|
|
5095
|
+
* @param {string} dir - Absolute path to the directory to enable.
|
|
5096
|
+
* @returns {Promise<void>}
|
|
5097
|
+
*/
|
|
5098
|
+
async function enableCommand(dir) {
|
|
5099
|
+
const absDir = resolve(dir);
|
|
5100
|
+
|
|
5101
|
+
// Clear any opt-out entry so the directory becomes active
|
|
5102
|
+
await setOptOut(absDir, false);
|
|
5103
|
+
|
|
5104
|
+
// Write .forgedock marker only if the directory is not already managed
|
|
5105
|
+
const hasForgeYaml = existsSync(join(absDir, "forge.yaml"));
|
|
5106
|
+
const hasMarker = existsSync(join(absDir, ".forgedock"));
|
|
5107
|
+
|
|
5108
|
+
if (!hasForgeYaml && !hasMarker) {
|
|
5109
|
+
try {
|
|
5110
|
+
await writeFile(join(absDir, ".forgedock"), "", "utf-8");
|
|
5111
|
+
console.log(`${GREEN}✓${RESET} ForgeDock enabled for ${cyan(absDir)}`);
|
|
5112
|
+
console.log(
|
|
5113
|
+
` ${dim(".forgedock marker written — run")} ${cyan("npx forgedock init")} ${dim("to generate forge.yaml")}`,
|
|
5114
|
+
);
|
|
5115
|
+
} catch (err) {
|
|
5116
|
+
console.log(
|
|
5117
|
+
`${YELLOW}!${RESET} Could not write .forgedock marker: ${err.message}`,
|
|
5118
|
+
);
|
|
5119
|
+
console.log(
|
|
5120
|
+
` ${dim("Opt-out cleared — directory will be treated as active once a marker or forge.yaml is added.")}`,
|
|
5121
|
+
);
|
|
5122
|
+
}
|
|
5123
|
+
} else {
|
|
5124
|
+
console.log(`${GREEN}✓${RESET} ForgeDock enabled for ${cyan(absDir)}`);
|
|
5125
|
+
if (hasForgeYaml) {
|
|
5126
|
+
console.log(
|
|
5127
|
+
` ${dim("forge.yaml already present — directory is managed.")}`,
|
|
5128
|
+
);
|
|
5129
|
+
} else {
|
|
5130
|
+
console.log(
|
|
5131
|
+
` ${dim(".forgedock marker already present — directory is managed.")}`,
|
|
5132
|
+
);
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
/**
|
|
5138
|
+
* Disable ForgeDock for a directory.
|
|
5139
|
+
*
|
|
5140
|
+
* Records an explicit opt-out in the central registry so the SessionStart hook
|
|
5141
|
+
* stays completely silent in this directory. The `.forgedock` marker (if present)
|
|
5142
|
+
* is left untouched — re-enabling simply clears the opt-out entry.
|
|
5143
|
+
*
|
|
5144
|
+
* @param {string} dir - Absolute path to the directory to disable.
|
|
5145
|
+
* @returns {Promise<void>}
|
|
5146
|
+
*/
|
|
5147
|
+
async function disableCommand(dir) {
|
|
5148
|
+
const absDir = resolve(dir);
|
|
5149
|
+
|
|
5150
|
+
await setOptOut(absDir, true);
|
|
5151
|
+
|
|
5152
|
+
console.log(`${YELLOW}–${RESET} ForgeDock disabled for ${cyan(absDir)}`);
|
|
5153
|
+
console.log(
|
|
5154
|
+
` ${dim("The SessionStart hook will be silent in this directory.")}`,
|
|
5155
|
+
);
|
|
5156
|
+
console.log(
|
|
5157
|
+
` ${dim("Re-enable with:")} ${cyan(`npx forgedock enable "${absDir}"`)}`,
|
|
5158
|
+
);
|
|
5159
|
+
}
|
|
5160
|
+
|
|
5161
|
+
/**
|
|
5162
|
+
* Print the resolved ForgeDock state for a directory.
|
|
5163
|
+
*
|
|
5164
|
+
* Maps the three states returned by resolveState() to a human-readable
|
|
5165
|
+
* explanation of what ForgeDock will do in this directory.
|
|
5166
|
+
*
|
|
5167
|
+
* @param {string} dir - Absolute path to the directory to inspect.
|
|
5168
|
+
* @returns {Promise<void>}
|
|
5169
|
+
*/
|
|
5170
|
+
async function statusCommand(dir) {
|
|
5171
|
+
const absDir = resolve(dir);
|
|
5172
|
+
const state = resolveState(absDir);
|
|
5173
|
+
|
|
5174
|
+
const hasForgeYaml = existsSync(join(absDir, "forge.yaml"));
|
|
5175
|
+
const hasMarker = existsSync(join(absDir, ".forgedock"));
|
|
5176
|
+
|
|
5177
|
+
console.log("");
|
|
5178
|
+
console.log(`${BOLD}ForgeDock status${RESET} — ${cyan(absDir)}`);
|
|
5179
|
+
console.log("");
|
|
5180
|
+
|
|
5181
|
+
switch (state) {
|
|
5182
|
+
case "managed-active":
|
|
5183
|
+
console.log(` ${GREEN}● Active${RESET}`);
|
|
5184
|
+
console.log(
|
|
5185
|
+
` ${dim("ForgeDock is managed and active in this directory.")}`,
|
|
5186
|
+
);
|
|
5187
|
+
if (hasForgeYaml) {
|
|
5188
|
+
console.log(` ${dim("Managed via:")} forge.yaml`);
|
|
5189
|
+
} else if (hasMarker) {
|
|
5190
|
+
console.log(` ${dim("Managed via:")} .forgedock marker`);
|
|
5191
|
+
console.log(
|
|
5192
|
+
` ${dim("Run")} ${cyan("npx forgedock init")} ${dim("to generate a full forge.yaml.")}`,
|
|
5193
|
+
);
|
|
5194
|
+
}
|
|
5195
|
+
console.log(
|
|
5196
|
+
` ${dim("To disable:")} ${cyan(`npx forgedock disable "${absDir}"`)}`,
|
|
5197
|
+
);
|
|
5198
|
+
break;
|
|
5199
|
+
|
|
5200
|
+
case "managed-optedout":
|
|
5201
|
+
console.log(` ${YELLOW}○ Disabled${RESET}`);
|
|
5202
|
+
console.log(
|
|
5203
|
+
` ${dim("This directory is managed (has a forge.yaml or .forgedock marker)")}`,
|
|
5204
|
+
);
|
|
5205
|
+
console.log(
|
|
5206
|
+
` ${dim("but has been explicitly opted out — the SessionStart hook is silent here.")}`,
|
|
5207
|
+
);
|
|
5208
|
+
if (hasForgeYaml) {
|
|
5209
|
+
console.log(` ${dim("Managed via:")} forge.yaml`);
|
|
5210
|
+
} else if (hasMarker) {
|
|
5211
|
+
console.log(` ${dim("Managed via:")} .forgedock marker`);
|
|
5212
|
+
}
|
|
5213
|
+
console.log(
|
|
5214
|
+
` ${dim("To re-enable:")} ${cyan(`npx forgedock enable "${absDir}"`)}`,
|
|
5215
|
+
);
|
|
5216
|
+
break;
|
|
5217
|
+
|
|
5218
|
+
case "unmanaged":
|
|
5219
|
+
console.log(` ${dim("◌ Unmanaged")}`);
|
|
5220
|
+
console.log(
|
|
5221
|
+
` ${dim("No forge.yaml or .forgedock marker found in this directory.")}`,
|
|
5222
|
+
);
|
|
5223
|
+
console.log(` ${dim("ForgeDock is not active here.")}`);
|
|
5224
|
+
console.log(
|
|
5225
|
+
` ${dim("To enable:")} ${cyan(`npx forgedock enable "${absDir}"`)}`,
|
|
5226
|
+
);
|
|
5227
|
+
break;
|
|
5228
|
+
|
|
5229
|
+
default:
|
|
5230
|
+
console.log(` ${dim("Unknown state:")} ${state}`);
|
|
5231
|
+
break;
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
console.log("");
|
|
5235
|
+
}
|
|
5236
|
+
|
|
3741
5237
|
function help() {
|
|
3742
5238
|
console.log("");
|
|
3743
5239
|
console.log(
|
|
@@ -3752,6 +5248,12 @@ function help() {
|
|
|
3752
5248
|
console.log(
|
|
3753
5249
|
` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
|
|
3754
5250
|
);
|
|
5251
|
+
console.log(
|
|
5252
|
+
` ${CYAN}npx forgedock init --manual${RESET} Skip autopilot enrichment; review screen shows detection baseline only`,
|
|
5253
|
+
);
|
|
5254
|
+
console.log(
|
|
5255
|
+
` ${CYAN}npx forgedock init --verbose${RESET} Show detection sources and confidence during init`,
|
|
5256
|
+
);
|
|
3755
5257
|
console.log(
|
|
3756
5258
|
` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`,
|
|
3757
5259
|
);
|
|
@@ -3779,6 +5281,15 @@ function help() {
|
|
|
3779
5281
|
console.log(
|
|
3780
5282
|
` ${CYAN}npx forgedock docs init${RESET} Scaffold devdocs knowledge tree into current project`,
|
|
3781
5283
|
);
|
|
5284
|
+
console.log(
|
|
5285
|
+
` ${CYAN}npx forgedock enable [dir]${RESET} Enable ForgeDock for a directory (default: cwd)`,
|
|
5286
|
+
);
|
|
5287
|
+
console.log(
|
|
5288
|
+
` ${CYAN}npx forgedock disable [dir]${RESET} Disable ForgeDock for a directory (default: cwd)`,
|
|
5289
|
+
);
|
|
5290
|
+
console.log(
|
|
5291
|
+
` ${CYAN}npx forgedock status [dir]${RESET} Show ForgeDock state for a directory (default: cwd)`,
|
|
5292
|
+
);
|
|
3782
5293
|
console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
|
|
3783
5294
|
console.log("");
|
|
3784
5295
|
}
|
|
@@ -4223,6 +5734,15 @@ if (!command) {
|
|
|
4223
5734
|
}
|
|
4224
5735
|
break;
|
|
4225
5736
|
}
|
|
5737
|
+
case "enable":
|
|
5738
|
+
await enableCommand(args[1] || process.cwd());
|
|
5739
|
+
break;
|
|
5740
|
+
case "disable":
|
|
5741
|
+
await disableCommand(args[1] || process.cwd());
|
|
5742
|
+
break;
|
|
5743
|
+
case "status":
|
|
5744
|
+
await statusCommand(args[1] || process.cwd());
|
|
5745
|
+
break;
|
|
4226
5746
|
case "help":
|
|
4227
5747
|
case "--help":
|
|
4228
5748
|
case "-h":
|