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