forgedock 1.0.22 → 1.1.2
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 +255 -211
- package/bin/batch-runner.mjs +339 -0
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +168 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +32 -0
- package/bin/engine-cli.mjs +222 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +896 -1540
- package/bin/hooks/interactive-engine.mjs +509 -0
- package/bin/hooks/pre-tool-use.mjs +498 -0
- package/bin/hooks/subagent-stop-enforce.mjs +197 -0
- package/bin/journey.mjs +841 -0
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +136 -20
- package/bin/settings-hook.mjs +346 -0
- package/bin/tui.mjs +6 -3
- package/commands/adopt.md +4 -1
- package/commands/analytics.md +4 -1
- package/commands/audit-agents.md +110 -27
- package/commands/audit.md +20 -1
- package/commands/autopilot.md +84 -12
- package/commands/changelog.md +2 -1
- package/commands/ci-audit.md +2 -1
- package/commands/cleanup.md +161 -1
- package/commands/compat-audit.md +2 -1
- package/commands/deploy-info.md +63 -4
- package/commands/design-bench.md +6 -1
- package/commands/design-render-critique-loop.md +2 -1
- package/commands/design.md +1 -0
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/forge-stats.md +122 -2
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +3 -0
- package/commands/incident-response.md +2 -1
- package/commands/issue.md +24 -3
- package/commands/milestone.md +77 -12
- package/commands/optimize.md +2 -1
- package/commands/orchestrate/config.md +67 -0
- package/commands/orchestrate/phase-1-resolve.md +159 -0
- package/commands/orchestrate/phase-2-triage.md +126 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
- package/commands/orchestrate/phase-3-dependency.md +591 -0
- package/commands/orchestrate/phase-4-execution.md +962 -0
- package/commands/orchestrate/phase-5-cleanup.md +47 -0
- package/commands/orchestrate/phase-6-report.md +198 -0
- package/commands/orchestrate/safety.md +62 -0
- package/commands/orchestrate.md +25 -2027
- package/commands/pipeline-health/config.md +34 -0
- package/commands/pipeline-health/phase-1-context.md +180 -0
- package/commands/pipeline-health/phase-2-metrics.md +1729 -0
- package/commands/pipeline-health/phase-3-analyze.md +204 -0
- package/commands/pipeline-health/phase-4-proposals.md +117 -0
- package/commands/pipeline-health/phase-5-report.md +284 -0
- package/commands/pipeline-health/phase-6-summary.md +52 -0
- package/commands/pipeline-health.md +25 -2401
- package/commands/pipeline-resume.md +24 -1
- package/commands/pipeline-status.md +65 -0
- package/commands/qa-sweep.md +4 -1
- package/commands/quality-gate.md +326 -42
- package/commands/replay.md +2 -1
- package/commands/review-pr-agents/api.md +135 -0
- package/commands/review-pr-agents/auth.md +137 -0
- package/commands/review-pr-agents/billing.md +101 -0
- package/commands/review-pr-agents/concurrency.md +145 -0
- package/commands/review-pr-agents/database.md +103 -0
- package/commands/review-pr-agents/frontend.md +89 -0
- package/commands/review-pr-agents/infra.md +385 -0
- package/commands/review-pr-agents/protocols.md +185 -0
- package/commands/review-pr-agents/scraper.md +185 -0
- package/commands/review-pr-agents/security.md +406 -0
- package/commands/review-pr-agents.md +45 -1722
- package/commands/review-pr-staging.md +196 -40
- package/commands/review-pr.md +612 -56
- package/commands/rollback.md +2 -1
- package/commands/scope.md +2 -1
- package/commands/security-audit.md +2 -1
- package/commands/signal-planner.md +623 -0
- package/commands/sync-ecosystem.md +1 -0
- package/commands/test-gate.md +17 -1
- package/commands/validate.md +2 -1
- package/commands/work-on/build/architect.md +28 -0
- package/commands/work-on/build/context.md +2 -0
- package/commands/work-on/build/implement.md +19 -5
- package/commands/work-on/build/validate.md +62 -6
- package/commands/work-on/build.md +187 -30
- package/commands/work-on/close.md +132 -5
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +48 -4
- package/commands/work-on-monolithic.md +8 -2
- package/commands/work-on.md +247 -7
- package/docs/BRAND.md +16 -5
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +44 -42
- package/docs/FORGE-PROTOCOL.md +15 -1064
- package/docs/anthropic-devrel-outreach.md +2 -2
- package/docs/articles/command-decomposition-token-savings.md +214 -0
- package/docs/articles/model-release-playbook.md +131 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
- package/docs/claude-breakpoints.json +75 -5
- package/docs/design/abc-benchmark.md +1 -1
- package/docs/design/design-spec-schema.md +1 -1
- package/docs/design/divergent-generation.md +1 -1
- package/docs/design/eval-gate-runbook.md +178 -0
- package/docs/design/show-hn-draft.md +26 -1
- package/docs/design/token-cost-baseline-1298.md +59 -0
- package/docs/eval/README.md +117 -0
- package/docs/eval/corpus/seed-v1.json +6 -0
- package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
- package/docs/site/annotations-explained.md +24 -0
- package/docs/site/command-learning-path.md +51 -14
- package/docs/site/command-reference.md +339 -113
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +58 -12
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +10 -2
- package/docs/spec/eval-run-result.md +147 -0
- package/docs/spec/forge-protocol-v1.md +441 -18
- package/docs/spec/label-state-machine.md +55 -0
- package/docs/spec/review-protocol.md +158 -0
- package/docs/spec-graph-schema.md +28 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
- package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
- package/examples/forgedock-demo/README.md +41 -22
- package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
- package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
- package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
- package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
- package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
- package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
- package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
- package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
- package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
- package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
- package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
- package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
- package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
- package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
- package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
- package/examples/forgedock-demo/scripts/smoke.js +18 -1
- package/examples/forgedock-demo/src/db.js +36 -11
- package/examples/forgedock-demo/src/routes/notes.js +106 -9
- package/examples/forgedock-demo/src/server.js +4 -1
- package/package.json +12 -4
- package/packages/protocol/LICENSE +21 -0
- package/packages/protocol/src/cli.js +135 -0
- package/packages/protocol/src/emit.js +146 -0
- package/packages/protocol/src/index.js +43 -0
- package/packages/protocol/src/parse.js +229 -0
- package/packages/protocol/src/types.js +171 -0
- package/packages/protocol/src/validate.js +122 -0
- package/scripts/bench-topology-cost.mjs +372 -0
- package/scripts/check-command-docs-drift.sh +198 -0
- package/scripts/check-protocol-restatements.sh +176 -0
- package/scripts/check-registry/README.md +72 -0
- package/scripts/check-registry/manifest.json +6 -0
- package/scripts/code-index.sh +454 -0
- package/scripts/conformance-check.mjs +168 -0
- package/scripts/doctor-pipeline-state.sh +484 -0
- package/scripts/eval-gate-baseline.json +17 -0
- package/scripts/eval-gate-scorecard.mjs +242 -0
- package/scripts/eval-scorecard-aggregator.mjs +252 -0
- package/scripts/flaky-quarantine.sh +241 -0
- package/scripts/forge-annotation.sh +567 -0
- package/scripts/issue-dedup.sh +236 -0
- package/scripts/validate-annotation-node.mjs +233 -0
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/devdocs/agent/using-forgedock.md +14 -0
package/bin/forgedock.mjs
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
3
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
4
|
|
|
5
|
-
import { fileURLToPath
|
|
6
|
-
import {
|
|
7
|
-
import { mkdir,
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { dirname, join, relative, resolve } from "path";
|
|
7
|
+
import { mkdir, lstat, readlink, readdir, unlink, readFile, writeFile } from "fs/promises";
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
10
|
-
appendFileSync,
|
|
11
10
|
readFileSync,
|
|
12
11
|
writeFileSync,
|
|
13
12
|
renameSync,
|
|
@@ -15,17 +14,44 @@ import {
|
|
|
15
14
|
} from "fs";
|
|
16
15
|
import { execSync, execFileSync } from "child_process";
|
|
17
16
|
import { homedir } from "os";
|
|
17
|
+
import {
|
|
18
|
+
makeCtx,
|
|
19
|
+
runJourney,
|
|
20
|
+
forge,
|
|
21
|
+
read,
|
|
22
|
+
review,
|
|
23
|
+
celebrate,
|
|
24
|
+
findMarkdownFiles,
|
|
25
|
+
parseInstallTier,
|
|
26
|
+
writeForgeYaml,
|
|
27
|
+
backupExisting,
|
|
28
|
+
manualLowConfidenceKeys,
|
|
29
|
+
} from "./journey.mjs";
|
|
30
|
+
import {
|
|
31
|
+
removeSessionStartHook,
|
|
32
|
+
removeSubagentStopHook,
|
|
33
|
+
removePreToolUseHook,
|
|
34
|
+
removeSubagentStopEnforceHook,
|
|
35
|
+
} from "./settings-hook.mjs";
|
|
36
|
+
import {
|
|
37
|
+
resolveState,
|
|
38
|
+
setOptOut,
|
|
39
|
+
clearNudgeSeen,
|
|
40
|
+
detectClaudeVersion,
|
|
41
|
+
loadBreakpoints,
|
|
42
|
+
hasFeature,
|
|
43
|
+
} from "./registry.mjs";
|
|
44
|
+
import { renderMark, ember } from "./cinema.mjs";
|
|
18
45
|
import {
|
|
19
46
|
renderLogo,
|
|
20
|
-
runSteps,
|
|
21
47
|
box,
|
|
22
48
|
table,
|
|
23
|
-
|
|
49
|
+
input,
|
|
24
50
|
dim,
|
|
25
51
|
green,
|
|
26
|
-
red,
|
|
27
52
|
yellow,
|
|
28
53
|
cyan,
|
|
54
|
+
createProgressBar,
|
|
29
55
|
RESET,
|
|
30
56
|
BOLD,
|
|
31
57
|
GREEN,
|
|
@@ -40,24 +66,69 @@ const __dirname = dirname(__filename);
|
|
|
40
66
|
const FORGE_HOME = dirname(__dirname);
|
|
41
67
|
const COMMANDS_DIR = join(FORGE_HOME, "commands");
|
|
42
68
|
|
|
43
|
-
// Resolve home
|
|
44
|
-
// os.homedir()
|
|
45
|
-
const HOME = homedir();
|
|
69
|
+
// Resolve home cross-platform: HOME on Unix, USERPROFILE on Windows, with
|
|
70
|
+
// os.homedir() as the always-available fallback (no hard exit — see #744).
|
|
71
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Install-mode path resolution
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// ForgeDock installs commands globally, to ~/.claude/. That is the only
|
|
77
|
+
// location the installer (bin/journey.mjs: forge()) ever writes to — it
|
|
78
|
+
// resolves its target from ctx.home (HOME/USERPROFILE/os.homedir()), never
|
|
79
|
+
// from process.cwd(). A prior refactor (#1288/#1500) added a project-scoped-
|
|
80
|
+
// by-default detection layer here without updating the installer to match,
|
|
81
|
+
// producing a split-brain where status/doctor/uninstall could report a
|
|
82
|
+
// "project-scoped" location that install() never actually wrote to (see
|
|
83
|
+
// #1589). This was backed out: detection now always agrees with what the
|
|
84
|
+
// installer does.
|
|
85
|
+
//
|
|
86
|
+
// --global is still accepted as a CLI flag for backward compatibility with
|
|
87
|
+
// existing invocations, but it is now a no-op — global is the only supported
|
|
88
|
+
// install location.
|
|
89
|
+
|
|
90
|
+
const GLOBAL_CLAUDE_DIR = join(HOME, ".claude");
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the install target directories for the current invocation.
|
|
94
|
+
* Always resolves to the global (~/.claude) location — see the block
|
|
95
|
+
* comment above for why project-scoped resolution was removed.
|
|
96
|
+
*
|
|
97
|
+
* @returns {{ targetDir: string, scriptsTargetDir: string, manifestPath: string, isGlobal: boolean }}
|
|
98
|
+
*/
|
|
99
|
+
function resolveInstallPaths() {
|
|
100
|
+
return {
|
|
101
|
+
targetDir: join(GLOBAL_CLAUDE_DIR, "commands"),
|
|
102
|
+
scriptsTargetDir: join(GLOBAL_CLAUDE_DIR, "scripts"),
|
|
103
|
+
manifestPath: join(GLOBAL_CLAUDE_DIR, "forgedock", "copied-commands.json"),
|
|
104
|
+
isGlobal: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve install paths for uninstall/update/doctor/status. Kept as a
|
|
110
|
+
* distinct entry point (rather than calling resolveInstallPaths() directly)
|
|
111
|
+
* since these commands conceptually "detect" an existing install; today
|
|
112
|
+
* that always resolves to the same global location as resolveInstallPaths().
|
|
113
|
+
*
|
|
114
|
+
* @returns {{ targetDir: string, scriptsTargetDir: string, manifestPath: string, isGlobal: boolean }}
|
|
115
|
+
*/
|
|
116
|
+
function detectInstallPaths() {
|
|
117
|
+
return resolveInstallPaths();
|
|
118
|
+
}
|
|
46
119
|
|
|
47
|
-
const TARGET_DIR = join(HOME, ".claude", "commands");
|
|
48
120
|
const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
|
|
49
|
-
const SCRIPTS_TARGET_DIR = join(HOME, ".claude", "scripts");
|
|
50
121
|
|
|
51
122
|
/**
|
|
52
|
-
* Allowlist of pipeline-agent scripts that
|
|
53
|
-
*
|
|
123
|
+
* Allowlist of pipeline-agent scripts that `uninstall` cleans up from
|
|
124
|
+
* ~/.claude/scripts/. The journey-based install no longer writes these
|
|
125
|
+
* itself — this set only identifies leftovers from legacy installs so
|
|
126
|
+
* `uninstall` can find and remove them.
|
|
54
127
|
*
|
|
55
128
|
* Internal tooling (gen-logo.mjs, verify-*.sh) lives in scripts/ but is NOT
|
|
56
|
-
*
|
|
129
|
+
* covered here — those scripts are invoked directly via $FORGE_HOME/scripts/ by
|
|
57
130
|
* review-pr.md and quality-gate.md and should not pollute the user's Claude
|
|
58
131
|
* scripts namespace.
|
|
59
|
-
*
|
|
60
|
-
* When adding a new pipeline-agent script, add its filename here.
|
|
61
132
|
*/
|
|
62
133
|
const PIPELINE_SCRIPTS = new Set([
|
|
63
134
|
"classify-lane.sh",
|
|
@@ -65,8 +136,19 @@ const PIPELINE_SCRIPTS = new Set([
|
|
|
65
136
|
"validate-pr-target.sh",
|
|
66
137
|
]);
|
|
67
138
|
|
|
68
|
-
|
|
69
|
-
|
|
139
|
+
// Journey flags are stripped from positionals and fed to makeCtx via ctx().
|
|
140
|
+
// --minimal is init-only. Subcommands with their own flag parsing (run,
|
|
141
|
+
// run-issue, demo) receive restArgs — everything after the command token.
|
|
142
|
+
const rawArgs = process.argv.slice(2);
|
|
143
|
+
const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras"]);
|
|
144
|
+
const flags = rawArgs.filter((a) => FLAGS.has(a));
|
|
145
|
+
// --global is stripped from positionals/restArgs for backward compatibility
|
|
146
|
+
// with existing invocations, but is otherwise a no-op — see the install-mode
|
|
147
|
+
// path resolution block above (global is now the only install location).
|
|
148
|
+
const positional = rawArgs.filter((a) => !FLAGS.has(a) && a !== "--global");
|
|
149
|
+
const command = positional[0] || "install";
|
|
150
|
+
const cmdIdx = rawArgs.findIndex((a) => !FLAGS.has(a) && a !== "--global");
|
|
151
|
+
const restArgs = cmdIdx === -1 ? [] : rawArgs.slice(cmdIdx + 1).filter((a) => a !== "--global");
|
|
70
152
|
|
|
71
153
|
// ---------------------------------------------------------------------------
|
|
72
154
|
// SessionStart hook — settings.json helpers
|
|
@@ -78,71 +160,6 @@ const command = args[0] || "install";
|
|
|
78
160
|
*/
|
|
79
161
|
const CLAUDE_SETTINGS_PATH = join(HOME, ".claude", "settings.json");
|
|
80
162
|
|
|
81
|
-
/**
|
|
82
|
-
* Timeout (in seconds) for the SessionStart hook entry written into
|
|
83
|
-
* ~/.claude/settings.json. Claude Code hook timeouts are in seconds —
|
|
84
|
-
* not milliseconds like the Node.js child_process timeouts elsewhere in
|
|
85
|
-
* this file.
|
|
86
|
-
*/
|
|
87
|
-
const SESSION_START_HOOK_TIMEOUT_SECONDS = 10;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Escape all shell metacharacters that receive special treatment inside
|
|
91
|
-
* POSIX double-quoted strings. Apply this to any filesystem path before
|
|
92
|
-
* embedding it in a double-quoted shell assignment or command argument.
|
|
93
|
-
*
|
|
94
|
-
* Characters escaped and why:
|
|
95
|
-
* " → \" (closes the double-quoted context — ref: forge#451)
|
|
96
|
-
* $ → \$ (variable expansion and $(...) command substitution — ref: forge#792)
|
|
97
|
-
* ` → \` (legacy backtick command substitution — ref: forge#792)
|
|
98
|
-
* ! → \! (bash history expansion — harmless in sh but risky in bash — ref: forge#792)
|
|
99
|
-
*
|
|
100
|
-
* Backslash (\) is intentionally NOT escaped here: path.join() backslashes
|
|
101
|
-
* must be converted to forward slashes by the caller before this function
|
|
102
|
-
* is applied (see sessionStartHookCommand below). On POSIX systems,
|
|
103
|
-
* paths never contain backslashes, so this is a no-op for shell profiles.
|
|
104
|
-
*
|
|
105
|
-
* <!-- Added: forge#808 -->
|
|
106
|
-
*
|
|
107
|
-
* @param {string} path - Filesystem path to escape for shell double-quote context.
|
|
108
|
-
* @returns {string} - Path safe for embedding inside a double-quoted shell string.
|
|
109
|
-
*/
|
|
110
|
-
function shellEscapeDoubleQuotedPath(path) {
|
|
111
|
-
return path
|
|
112
|
-
.replace(/\\/g, '\\\\') // backslash — must be first to avoid double-escaping
|
|
113
|
-
.replace(/"/g, '\\"') // double quote — breaks out of the argument
|
|
114
|
-
.replace(/\$/g, '\\$') // dollar sign — variable/command substitution
|
|
115
|
-
.replace(/`/g, '\\`') // backtick — legacy command substitution
|
|
116
|
-
.replace(/!/g, '\\!'); // exclamation mark — bash history expansion
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The command value written into the SessionStart hook entry.
|
|
121
|
-
* Uses forward slashes even on Windows — Node accepts them natively and
|
|
122
|
-
* they avoid any backslash-related issues in the hook command string.
|
|
123
|
-
* Backslashes emitted by path.join() on Windows are normalised to forward
|
|
124
|
-
* slashes on line 101 before any escaping is applied.
|
|
125
|
-
*
|
|
126
|
-
* When Claude Code's hook runner passes the command string through a POSIX
|
|
127
|
-
* shell (sh -c), the following characters receive special treatment inside
|
|
128
|
-
* double-quoted strings and must be escaped — see shellEscapeDoubleQuotedPath().
|
|
129
|
-
*
|
|
130
|
-
* `node --` is used to terminate Node.js option parsing so that a path
|
|
131
|
-
* component beginning with `-` cannot be misinterpreted as a CLI flag.
|
|
132
|
-
*
|
|
133
|
-
* @returns {string}
|
|
134
|
-
*/
|
|
135
|
-
function sessionStartHookCommand() {
|
|
136
|
-
const hookPath = join(
|
|
137
|
-
FORGE_HOME,
|
|
138
|
-
"bin",
|
|
139
|
-
"hooks",
|
|
140
|
-
"session-start.mjs",
|
|
141
|
-
).replace(/\\/g, "/");
|
|
142
|
-
const safePath = shellEscapeDoubleQuotedPath(hookPath);
|
|
143
|
-
return `node -- "${safePath}"`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
163
|
/**
|
|
147
164
|
* Check whether a hook command string refers to the ForgeDock SessionStart hook.
|
|
148
165
|
*
|
|
@@ -277,28 +294,6 @@ function readClaudeSettings() {
|
|
|
277
294
|
}
|
|
278
295
|
}
|
|
279
296
|
|
|
280
|
-
/**
|
|
281
|
-
* Atomically write an object to ~/.claude/settings.json.
|
|
282
|
-
* Uses a .tmp sibling + renameSync to avoid partial writes.
|
|
283
|
-
* Cleans up the tmp file on failure. (ref: forge#444)
|
|
284
|
-
*
|
|
285
|
-
* @param {object} settings
|
|
286
|
-
*/
|
|
287
|
-
function writeClaudeSettings(settings) {
|
|
288
|
-
const tmpPath = CLAUDE_SETTINGS_PATH + ".forgedock.tmp";
|
|
289
|
-
try {
|
|
290
|
-
writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
291
|
-
renameSync(tmpPath, CLAUDE_SETTINGS_PATH);
|
|
292
|
-
} catch (err) {
|
|
293
|
-
try {
|
|
294
|
-
unlinkSync(tmpPath);
|
|
295
|
-
} catch {
|
|
296
|
-
/* already gone or never created */
|
|
297
|
-
}
|
|
298
|
-
throw err;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
297
|
/**
|
|
303
298
|
* Atomically write text content to a file.
|
|
304
299
|
* Uses a .forgedock.tmp sibling + renameSync to avoid partial writes on
|
|
@@ -322,180 +317,6 @@ function atomicWriteFile(filePath, content) {
|
|
|
322
317
|
}
|
|
323
318
|
}
|
|
324
319
|
|
|
325
|
-
/**
|
|
326
|
-
* Idempotently write a `.symlink-source` sentinel file to TARGET_DIR
|
|
327
|
-
* (~/.claude/commands/) so other installers can detect that ForgeDock
|
|
328
|
-
* owns this namespace.
|
|
329
|
-
*
|
|
330
|
-
* The sentinel is a plain UTF-8 text file (not a symlink) containing
|
|
331
|
-
* the source path, a reinstall hint, and the install timestamp. It is
|
|
332
|
-
* updated on every install so the timestamp stays fresh. Write failures
|
|
333
|
-
* are non-fatal — the function returns 'failed' and the caller logs a
|
|
334
|
-
* warning; install continues regardless.
|
|
335
|
-
*
|
|
336
|
-
* Calling convention matches the other state-write helpers:
|
|
337
|
-
* 'written' — sentinel was newly created or refreshed
|
|
338
|
-
* 'failed' — an error occurred (non-fatal; install continues)
|
|
339
|
-
*
|
|
340
|
-
* <!-- Added: forge#1038 -->
|
|
341
|
-
*
|
|
342
|
-
* @returns {'written' | 'failed'}
|
|
343
|
-
*/
|
|
344
|
-
function writeSymlinkSentinel() {
|
|
345
|
-
const sentinelPath = join(TARGET_DIR, ".symlink-source");
|
|
346
|
-
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
347
|
-
const content =
|
|
348
|
-
`# ForgeDock command symlinks — DO NOT REPOINT\n` +
|
|
349
|
-
`# Source: ${COMMANDS_DIR}\n` +
|
|
350
|
-
// Machine-readable version line — read by install() on the next run to
|
|
351
|
-
// detect first-time vs. update installs and render a version diff (#1146).
|
|
352
|
-
`# Version: ${getVersion()}\n` +
|
|
353
|
-
`#\n` +
|
|
354
|
-
`# These symlinks are managed by ForgeDock (https://forgedock.com).\n` +
|
|
355
|
-
`# ForgeDock owns the global ~/.claude/commands/ namespace.\n` +
|
|
356
|
-
`# Project-specific commands should install to the project's\n` +
|
|
357
|
-
`# .claude/commands/ directory instead (Claude Code merges both;\n` +
|
|
358
|
-
`# project-local commands win on name collisions).\n` +
|
|
359
|
-
`#\n` +
|
|
360
|
-
`# Running another installer here will silently repoint these\n` +
|
|
361
|
-
`# symlinks, breaking all ForgeDock commands globally.\n` +
|
|
362
|
-
`#\n` +
|
|
363
|
-
`# To reinstall ForgeDock: npx forgedock install\n` +
|
|
364
|
-
`# Last installed: ${timestamp}\n`;
|
|
365
|
-
try {
|
|
366
|
-
atomicWriteFile(sentinelPath, content);
|
|
367
|
-
return "written";
|
|
368
|
-
} catch {
|
|
369
|
-
return "failed";
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Install the ForgeDock SessionStart hook into ~/.claude/settings.json
|
|
375
|
-
* idempotently. Does not modify any existing hooks unrelated to ForgeDock.
|
|
376
|
-
*
|
|
377
|
-
* Returns:
|
|
378
|
-
* 'installed' — hook was newly added
|
|
379
|
-
* 'already-present' — hook was already there (idempotent)
|
|
380
|
-
* 'failed' — an error occurred (non-fatal; install continues)
|
|
381
|
-
*
|
|
382
|
-
* @returns {Promise<'installed' | 'already-present' | 'failed'>}
|
|
383
|
-
*/
|
|
384
|
-
async function installSessionStartHook() {
|
|
385
|
-
try {
|
|
386
|
-
const settings = readClaudeSettings();
|
|
387
|
-
|
|
388
|
-
// Ensure the hooks section exists
|
|
389
|
-
if (!settings.hooks || typeof settings.hooks !== "object") {
|
|
390
|
-
settings.hooks = {};
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Ensure SessionStart array exists
|
|
394
|
-
if (!Array.isArray(settings.hooks.SessionStart)) {
|
|
395
|
-
settings.hooks.SessionStart = [];
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const cmd = sessionStartHookCommand();
|
|
399
|
-
|
|
400
|
-
// Check if already present — match by path suffix so uninstall/reinstall
|
|
401
|
-
// across different FORGE_HOME paths still works correctly. (ref: forge#537)
|
|
402
|
-
const alreadyPresent = settings.hooks.SessionStart.some((entry) => {
|
|
403
|
-
if (!entry || typeof entry !== "object") return false;
|
|
404
|
-
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
405
|
-
return hooks.some(
|
|
406
|
-
(h) =>
|
|
407
|
-
h &&
|
|
408
|
-
typeof h.command === "string" &&
|
|
409
|
-
isForgeSessionStartHook(h.command),
|
|
410
|
-
);
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
if (alreadyPresent) return "already-present";
|
|
414
|
-
|
|
415
|
-
// Append the new hook entry (matcher omitted — fires on every session start)
|
|
416
|
-
settings.hooks.SessionStart.push({
|
|
417
|
-
hooks: [
|
|
418
|
-
{
|
|
419
|
-
type: "command",
|
|
420
|
-
command: cmd,
|
|
421
|
-
timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
|
|
422
|
-
},
|
|
423
|
-
],
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
writeClaudeSettings(settings);
|
|
427
|
-
return "installed";
|
|
428
|
-
} catch {
|
|
429
|
-
return "failed";
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Remove the ForgeDock SessionStart hook from ~/.claude/settings.json
|
|
435
|
-
* idempotently. Only removes the entry written by installSessionStartHook().
|
|
436
|
-
* Unrelated hooks are preserved.
|
|
437
|
-
*
|
|
438
|
-
* Returns:
|
|
439
|
-
* 'removed' — hook was found and removed
|
|
440
|
-
* 'not-present' — hook was not in settings.json (already clean)
|
|
441
|
-
* 'failed' — an error occurred (non-fatal; uninstall continues)
|
|
442
|
-
*
|
|
443
|
-
* @returns {Promise<'removed' | 'not-present' | 'failed'>}
|
|
444
|
-
*/
|
|
445
|
-
async function removeSessionStartHook() {
|
|
446
|
-
try {
|
|
447
|
-
const settings = readClaudeSettings();
|
|
448
|
-
|
|
449
|
-
if (
|
|
450
|
-
!settings.hooks ||
|
|
451
|
-
!Array.isArray(settings.hooks.SessionStart) ||
|
|
452
|
-
settings.hooks.SessionStart.length === 0
|
|
453
|
-
) {
|
|
454
|
-
return "not-present";
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const originalLength = settings.hooks.SessionStart.length;
|
|
458
|
-
|
|
459
|
-
// Filter out the ForgeDock-managed entry, keep everything else
|
|
460
|
-
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
|
|
461
|
-
(entry) => {
|
|
462
|
-
if (!entry || typeof entry !== "object") return true;
|
|
463
|
-
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
464
|
-
const isForgeEntry = hooks.some(
|
|
465
|
-
(h) =>
|
|
466
|
-
h &&
|
|
467
|
-
typeof h.command === "string" &&
|
|
468
|
-
isForgeSessionStartHook(h.command),
|
|
469
|
-
);
|
|
470
|
-
return !isForgeEntry;
|
|
471
|
-
},
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
if (settings.hooks.SessionStart.length === originalLength) {
|
|
475
|
-
return "not-present";
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Clean up empty SessionStart array to leave settings.json tidy
|
|
479
|
-
if (settings.hooks.SessionStart.length === 0) {
|
|
480
|
-
delete settings.hooks.SessionStart;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Clean up empty hooks object
|
|
484
|
-
if (
|
|
485
|
-
settings.hooks &&
|
|
486
|
-
typeof settings.hooks === "object" &&
|
|
487
|
-
Object.keys(settings.hooks).length === 0
|
|
488
|
-
) {
|
|
489
|
-
delete settings.hooks;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
writeClaudeSettings(settings);
|
|
493
|
-
return "removed";
|
|
494
|
-
} catch {
|
|
495
|
-
return "failed";
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
320
|
// ---------------------------------------------------------------------------
|
|
500
321
|
// Version + splash — read package.json version and render branded logo
|
|
501
322
|
// ---------------------------------------------------------------------------
|
|
@@ -527,148 +348,6 @@ function splash() {
|
|
|
527
348
|
const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
|
|
528
349
|
const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
|
|
529
350
|
|
|
530
|
-
/**
|
|
531
|
-
* The behavioral rules block injected into the project's CLAUDE.md.
|
|
532
|
-
* Kept ≤30 lines (per issue #607 requirement).
|
|
533
|
-
* Content is fixed — this is NOT documentation, it is behavioral guidance.
|
|
534
|
-
*/
|
|
535
|
-
const FORGEDOCK_MANAGED_BLOCK = `${CLAUDE_BLOCK_BEGIN}
|
|
536
|
-
## ForgeDock Pipeline Rules
|
|
537
|
-
|
|
538
|
-
This project uses ForgeDock for structured development. Follow these rules:
|
|
539
|
-
|
|
540
|
-
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.
|
|
541
|
-
2. **Pipeline flow**: All implementation goes through \`/work-on <issue#>\`. This runs: investigate → architect → build → review → merge.
|
|
542
|
-
3. **Traceability**: Every code change must link to a GitHub issue. Use FORGE annotations to pass context between pipeline phases.
|
|
543
|
-
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.
|
|
544
|
-
5. **Available commands**: \`/work-on\`, \`/issue\`, \`/review-pr\`, \`/orchestrate\`, \`/quality-gate\`, \`/milestone\`, \`/autopilot\`
|
|
545
|
-
|
|
546
|
-
For full documentation, see: [ForgeDock docs](https://forgedock.com/docs)
|
|
547
|
-
${CLAUDE_BLOCK_END}`;
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Returns true if `dir` is inside a git work tree.
|
|
551
|
-
* Used to guard CLAUDE.md injection against non-project directories.
|
|
552
|
-
*
|
|
553
|
-
* @param {string} dir
|
|
554
|
-
* @returns {boolean}
|
|
555
|
-
*/
|
|
556
|
-
function isGitWorkTree(dir) {
|
|
557
|
-
try {
|
|
558
|
-
const out = execSync("git rev-parse --is-inside-work-tree", {
|
|
559
|
-
cwd: dir,
|
|
560
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
561
|
-
encoding: "utf-8",
|
|
562
|
-
});
|
|
563
|
-
return out.trim() === "true";
|
|
564
|
-
} catch {
|
|
565
|
-
return false;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Idempotently inject the ForgeDock managed block into a single file.
|
|
571
|
-
*
|
|
572
|
-
* Handles all corrupt marker states (ref: forge#269, forge#291):
|
|
573
|
-
* - File missing → create file containing block only
|
|
574
|
-
* - No markers → append block to existing content
|
|
575
|
-
* - BEGIN + END (correct) → replace content between markers
|
|
576
|
-
* - BEGIN only → strip orphaned BEGIN, re-append full block
|
|
577
|
-
* - END only → strip orphaned END, re-append full block
|
|
578
|
-
* - END before BEGIN → strip both + content between, re-append full block
|
|
579
|
-
* - Block already current → return 'unchanged'
|
|
580
|
-
*
|
|
581
|
-
* Returns:
|
|
582
|
-
* 'created' — file was created with block
|
|
583
|
-
* 'updated' — existing block was replaced with new content
|
|
584
|
-
* 'unchanged' — block was already present and current
|
|
585
|
-
* 'appended' — block was appended (no prior markers)
|
|
586
|
-
*
|
|
587
|
-
* @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
|
|
588
|
-
* @returns {'created' | 'updated' | 'unchanged' | 'appended'}
|
|
589
|
-
*/
|
|
590
|
-
function injectManagedBlock(filePath) {
|
|
591
|
-
const managed = FORGEDOCK_MANAGED_BLOCK;
|
|
592
|
-
|
|
593
|
-
// File does not exist — create it with the block
|
|
594
|
-
if (!existsSync(filePath)) {
|
|
595
|
-
atomicWriteFile(filePath, managed + "\n");
|
|
596
|
-
return "created";
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
let existing = readFileSync(filePath, "utf-8");
|
|
600
|
-
|
|
601
|
-
const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
|
|
602
|
-
const hasEnd = existing.includes(CLAUDE_BLOCK_END);
|
|
603
|
-
|
|
604
|
-
if (hasBegin && hasEnd) {
|
|
605
|
-
// Determine marker order
|
|
606
|
-
const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
|
|
607
|
-
const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
|
|
608
|
-
|
|
609
|
-
if (beginIdx < endIdx) {
|
|
610
|
-
// Normal order — replace content between markers (inclusive)
|
|
611
|
-
// Non-greedy match so we don't over-consume if someone manually
|
|
612
|
-
// duplicated the markers.
|
|
613
|
-
const blockRegex = new RegExp(
|
|
614
|
-
escapeRegExp(CLAUDE_BLOCK_BEGIN) +
|
|
615
|
-
"[\\s\\S]*?" +
|
|
616
|
-
escapeRegExp(CLAUDE_BLOCK_END),
|
|
617
|
-
);
|
|
618
|
-
if (!blockRegex.test(existing)) {
|
|
619
|
-
// Should not happen: both markers were confirmed above via includes() and
|
|
620
|
-
// indexOf() ordering. Guard against silent misclassification — if the regex
|
|
621
|
-
// somehow fails to match, replaced === existing would incorrectly return
|
|
622
|
-
// 'unchanged' instead of surfacing the broken state.
|
|
623
|
-
throw new Error(
|
|
624
|
-
`injectManagedBlock: regex failed to match markers in ${filePath}`,
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
|
-
const replaced = existing.replace(blockRegex, managed);
|
|
628
|
-
if (replaced === existing) {
|
|
629
|
-
// Content between markers was already identical to the managed block
|
|
630
|
-
return "unchanged";
|
|
631
|
-
}
|
|
632
|
-
atomicWriteFile(filePath, replaced);
|
|
633
|
-
return "updated";
|
|
634
|
-
} else {
|
|
635
|
-
// Reversed markers (END before BEGIN) — strip both markers + everything
|
|
636
|
-
// between them, then fall through to append. (ref: forge#291 BUG-2)
|
|
637
|
-
const strippedReversed = existing
|
|
638
|
-
.replace(
|
|
639
|
-
new RegExp(
|
|
640
|
-
escapeRegExp(CLAUDE_BLOCK_END) +
|
|
641
|
-
"[\\s\\S]*?" +
|
|
642
|
-
escapeRegExp(CLAUDE_BLOCK_BEGIN),
|
|
643
|
-
),
|
|
644
|
-
"",
|
|
645
|
-
)
|
|
646
|
-
// Strip any leftover isolated markers
|
|
647
|
-
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
|
|
648
|
-
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "");
|
|
649
|
-
existing = strippedReversed.trimEnd();
|
|
650
|
-
// Fall through to append
|
|
651
|
-
}
|
|
652
|
-
} else if (hasBegin) {
|
|
653
|
-
// Orphaned BEGIN — strip it, then fall through to append. (ref: forge#269)
|
|
654
|
-
existing = existing
|
|
655
|
-
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
|
|
656
|
-
.trimEnd();
|
|
657
|
-
// Fall through to append
|
|
658
|
-
} else if (hasEnd) {
|
|
659
|
-
// Orphaned END — strip it, then fall through to append. (ref: forge#291 BUG-1)
|
|
660
|
-
existing = existing
|
|
661
|
-
.replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
|
|
662
|
-
.trimEnd();
|
|
663
|
-
// Fall through to append
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// No valid block present — append
|
|
667
|
-
const separator = existing.length > 0 ? "\n\n" : "";
|
|
668
|
-
atomicWriteFile(filePath, existing + separator + managed + "\n");
|
|
669
|
-
return hasBegin || hasEnd ? "updated" : "appended";
|
|
670
|
-
}
|
|
671
|
-
|
|
672
351
|
/**
|
|
673
352
|
* Remove the ForgeDock managed block from a single file.
|
|
674
353
|
* Strips only the marker-bounded section; all surrounding content is preserved.
|
|
@@ -747,28 +426,16 @@ function escapeRegExp(str) {
|
|
|
747
426
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
748
427
|
}
|
|
749
428
|
|
|
750
|
-
async function findMarkdownFiles(dir) {
|
|
751
|
-
const results = [];
|
|
752
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
753
|
-
for (const entry of entries) {
|
|
754
|
-
const full = join(dir, entry.name);
|
|
755
|
-
if (entry.isDirectory()) {
|
|
756
|
-
results.push(...(await findMarkdownFiles(full)));
|
|
757
|
-
} else if (entry.name.endsWith(".md")) {
|
|
758
|
-
results.push(full);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
return results.sort();
|
|
762
|
-
}
|
|
763
|
-
|
|
764
429
|
// ---------------------------------------------------------------------------
|
|
765
430
|
// Shell profile FORGE_HOME removal
|
|
766
431
|
// ---------------------------------------------------------------------------
|
|
767
432
|
|
|
768
433
|
/**
|
|
769
|
-
* The sentinel comment line
|
|
770
|
-
* `export FORGE_HOME=` line.
|
|
771
|
-
*
|
|
434
|
+
* The sentinel comment line legacy installs wrote immediately before the
|
|
435
|
+
* `export FORGE_HOME=` line. The journey-based install no longer writes this
|
|
436
|
+
* block — this constant is used by `uninstall` cleanup for legacy installs
|
|
437
|
+
* that still have it in a shell profile. It is the reliable anchor for
|
|
438
|
+
* removal — never present in organic shell profiles.
|
|
772
439
|
*/
|
|
773
440
|
const FORGE_HOME_COMMENT = "# ForgeDock — autonomous development pipeline";
|
|
774
441
|
|
|
@@ -809,13 +476,13 @@ function removeForgeHomeFromProfile(profilePath) {
|
|
|
809
476
|
return "not-present";
|
|
810
477
|
}
|
|
811
478
|
|
|
812
|
-
// Step 1: Remove the 2-line ForgeDock block (comment + export)
|
|
813
|
-
//
|
|
814
|
-
// The \r? handles profiles with CRLF line endings.
|
|
479
|
+
// Step 1: Remove the 2-line ForgeDock block (comment + export) that
|
|
480
|
+
// installs from older builds appended, preceded by an optional leading
|
|
481
|
+
// blank line. The \r? handles profiles with CRLF line endings.
|
|
815
482
|
// The value pattern (?:[^"\\]|\\.)* matches a double-quoted shell string
|
|
816
483
|
// that may contain backslash-escape sequences (e.g. \" or \\) written by
|
|
817
|
-
//
|
|
818
|
-
// at the escaped-quote character inside \"
|
|
484
|
+
// the old installer's shell escaping. The simpler [^"]* would stop
|
|
485
|
+
// prematurely at the escaped-quote character inside \".
|
|
819
486
|
let cleaned = content.replace(
|
|
820
487
|
/\r?\n[ \t]*# ForgeDock — autonomous development pipeline\r?\nexport FORGE_HOME="(?:[^"\\]|\\.)*"\r?\n/g,
|
|
821
488
|
"\n",
|
|
@@ -851,93 +518,6 @@ function removeForgeHomeFromProfile(profilePath) {
|
|
|
851
518
|
return "removed";
|
|
852
519
|
}
|
|
853
520
|
|
|
854
|
-
/**
|
|
855
|
-
* Link (symlink) all commands from COMMANDS_DIR to TARGET_DIR.
|
|
856
|
-
* Called inside a runSteps() step — uses step.progress() and step.note().
|
|
857
|
-
*
|
|
858
|
-
* @param {object} step - StepAPI from runSteps
|
|
859
|
-
* @returns {Promise<{installed: number, updated: number, skipped: number}>}
|
|
860
|
-
*/
|
|
861
|
-
async function linkCommands(step) {
|
|
862
|
-
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
863
|
-
const total = files.length;
|
|
864
|
-
let installed = 0;
|
|
865
|
-
let updated = 0;
|
|
866
|
-
let skipped = 0;
|
|
867
|
-
|
|
868
|
-
for (let i = 0; i < files.length; i++) {
|
|
869
|
-
const file = files[i];
|
|
870
|
-
const rel = relative(COMMANDS_DIR, file);
|
|
871
|
-
const target = join(TARGET_DIR, rel);
|
|
872
|
-
const targetDir = dirname(target);
|
|
873
|
-
|
|
874
|
-
await mkdir(targetDir, { recursive: true });
|
|
875
|
-
step.progress(i + 1, total);
|
|
876
|
-
|
|
877
|
-
try {
|
|
878
|
-
const stats = await lstat(target);
|
|
879
|
-
|
|
880
|
-
if (stats.isSymbolicLink()) {
|
|
881
|
-
const current = await readlink(target);
|
|
882
|
-
if (current === file) {
|
|
883
|
-
skipped++;
|
|
884
|
-
} else {
|
|
885
|
-
// Symlink points to a different source — warn if it appears to belong
|
|
886
|
-
// to another Forge/ForgeDock installation (i.e. not a broken link that
|
|
887
|
-
// already pointed here under a different absolute path).
|
|
888
|
-
// Only emit the warning once per install to avoid log spam.
|
|
889
|
-
// <!-- Added: forge#1038 -->
|
|
890
|
-
if (!current.startsWith(COMMANDS_DIR)) {
|
|
891
|
-
step.note(
|
|
892
|
-
yellow(`collision: ${rel} was → ${current} — repointing to ForgeDock`),
|
|
893
|
-
);
|
|
894
|
-
}
|
|
895
|
-
try {
|
|
896
|
-
await symlink(file, target + ".tmp");
|
|
897
|
-
const { rename } = await import("fs/promises");
|
|
898
|
-
await rename(target + ".tmp", target);
|
|
899
|
-
updated++;
|
|
900
|
-
} catch (renameErr) {
|
|
901
|
-
try {
|
|
902
|
-
await unlink(target + ".tmp");
|
|
903
|
-
} catch {
|
|
904
|
-
/* .tmp already gone or was never created */
|
|
905
|
-
}
|
|
906
|
-
if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
|
|
907
|
-
// Windows without Developer Mode: symlink() throws EPERM/EACCES.
|
|
908
|
-
// Fall back to a direct copy (matches new-install fallback behaviour).
|
|
909
|
-
const { copyFile } = await import("fs/promises");
|
|
910
|
-
await copyFile(file, target);
|
|
911
|
-
updated++;
|
|
912
|
-
} else {
|
|
913
|
-
throw renameErr;
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
} else {
|
|
918
|
-
// Regular file — skip; user must remove manually
|
|
919
|
-
skipped++;
|
|
920
|
-
}
|
|
921
|
-
} catch (err) {
|
|
922
|
-
if (err.code !== "ENOENT") throw err;
|
|
923
|
-
// Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
|
|
924
|
-
try {
|
|
925
|
-
await symlink(file, target);
|
|
926
|
-
} catch (linkErr) {
|
|
927
|
-
if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
|
|
928
|
-
const { copyFile } = await import("fs/promises");
|
|
929
|
-
await copyFile(file, target);
|
|
930
|
-
} else {
|
|
931
|
-
throw linkErr;
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
installed++;
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
return { installed, updated, skipped };
|
|
939
|
-
}
|
|
940
|
-
|
|
941
521
|
/**
|
|
942
522
|
* Enumerate pipeline-agent scripts in SCRIPTS_DIR that should be installed
|
|
943
523
|
* to ~/.claude/scripts/. Only files present in PIPELINE_SCRIPTS are returned.
|
|
@@ -963,362 +543,102 @@ async function findScriptFiles(dir) {
|
|
|
963
543
|
return results.sort();
|
|
964
544
|
}
|
|
965
545
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
* Called inside a runSteps() step — uses step.progress() and step.note().
|
|
970
|
-
*
|
|
971
|
-
* @param {object} step - StepAPI from runSteps
|
|
972
|
-
* @returns {Promise<{installed: number, updated: number, skipped: number}>}
|
|
973
|
-
*/
|
|
974
|
-
async function linkScripts(step) {
|
|
975
|
-
const files = await findScriptFiles(SCRIPTS_DIR);
|
|
976
|
-
const total = files.length;
|
|
977
|
-
|
|
978
|
-
if (total === 0) {
|
|
979
|
-
step.skip("no scripts to link");
|
|
980
|
-
return { installed: 0, updated: 0, skipped: 0 };
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
await mkdir(SCRIPTS_TARGET_DIR, { recursive: true });
|
|
984
|
-
|
|
985
|
-
let installed = 0;
|
|
986
|
-
let updated = 0;
|
|
987
|
-
let skipped = 0;
|
|
988
|
-
|
|
989
|
-
for (let idx = 0; idx < files.length; idx++) {
|
|
990
|
-
const file = files[idx];
|
|
991
|
-
const rel = relative(SCRIPTS_DIR, file);
|
|
992
|
-
const target = join(SCRIPTS_TARGET_DIR, rel);
|
|
993
|
-
|
|
994
|
-
step.progress(idx + 1, total);
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// Journey context + status screen + init flow (onboarding surface)
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
995
549
|
|
|
996
|
-
|
|
997
|
-
|
|
550
|
+
function ctx() {
|
|
551
|
+
const c = makeCtx({ argv: flags });
|
|
552
|
+
c.forgeHome = FORGE_HOME;
|
|
553
|
+
c.home = HOME;
|
|
554
|
+
c.includeExtras = flags.includes("--extras");
|
|
555
|
+
// Detect installed Claude Code version for version-gated install paths (#1252).
|
|
556
|
+
// Fail-open: null means version is unknown; callers must handle gracefully.
|
|
557
|
+
c.claudeVersion = detectClaudeVersion();
|
|
558
|
+
c.breakpoints = loadBreakpoints(FORGE_HOME);
|
|
559
|
+
return c;
|
|
560
|
+
}
|
|
998
561
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
updated++;
|
|
1021
|
-
} else {
|
|
1022
|
-
throw renameErr;
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
} else {
|
|
1027
|
-
// Existing regular file — refresh if contents differ.
|
|
1028
|
-
if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
|
|
1029
|
-
skipped++;
|
|
1030
|
-
} else {
|
|
1031
|
-
const { copyFile } = await import("fs/promises");
|
|
1032
|
-
await copyFile(file, target);
|
|
1033
|
-
updated++;
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
} catch (err) {
|
|
1037
|
-
if (err.code !== "ENOENT") throw err;
|
|
1038
|
-
// Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
|
|
1039
|
-
try {
|
|
1040
|
-
await symlink(file, target);
|
|
1041
|
-
} catch (linkErr) {
|
|
1042
|
-
if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
|
|
1043
|
-
const { copyFile } = await import("fs/promises");
|
|
1044
|
-
await copyFile(file, target);
|
|
1045
|
-
} else {
|
|
1046
|
-
throw linkErr;
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
installed++;
|
|
1050
|
-
}
|
|
562
|
+
/** Compact status screen for configured/managed directories. */
|
|
563
|
+
function statusScreen(c) {
|
|
564
|
+
const state = resolveState(c.cwd);
|
|
565
|
+
const mark = renderMark("compact", c.mode);
|
|
566
|
+
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
567
|
+
c.stdout.write("\n" + mark[0] + "\n");
|
|
568
|
+
c.stdout.write(mark[1] + " " + ember("FORGEDOCK", c.mode) + " " + dim("status") + "\n");
|
|
569
|
+
c.stdout.write(mark[2] + "\n" + mark[3] + "\n\n");
|
|
570
|
+
const configured = existsSync(join(c.cwd, "forge.yaml"));
|
|
571
|
+
c.stdout.write(` directory ${state}\n`);
|
|
572
|
+
c.stdout.write(` forge.yaml ${configured ? "present" : "missing"}\n`);
|
|
573
|
+
const { targetDir: statusTargetDir } = detectInstallPaths();
|
|
574
|
+
c.stdout.write(` commands ${existsSync(statusTargetDir) ? "installed at " + statusTargetDir : "not installed"}\n`);
|
|
575
|
+
if (state === "managed-optedout") {
|
|
576
|
+
c.stdout.write(`\n ForgeDock is disabled (opted out) here. Re-enable: npx forgedock enable\n`);
|
|
577
|
+
} else if (state === "unmanaged") {
|
|
578
|
+
c.stdout.write(`\n ForgeDock is not active in this directory. Activate: npx forgedock enable\n`);
|
|
579
|
+
} else if (!configured) {
|
|
580
|
+
c.stdout.write(`\n Generate config: npx forgedock init\n`);
|
|
581
|
+
} else {
|
|
582
|
+
c.stdout.write(`\n Reconfigure: npx forgedock init · Refresh commands + hook: npx forgedock update\n`);
|
|
1051
583
|
}
|
|
1052
|
-
|
|
1053
|
-
return { installed, updated, skipped };
|
|
584
|
+
c.stdout.write("\n");
|
|
1054
585
|
}
|
|
1055
586
|
|
|
1056
|
-
async function
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
label: "Linking commands",
|
|
1112
|
-
async run(step) {
|
|
1113
|
-
const { installed, updated, skipped } = await linkCommands(step);
|
|
1114
|
-
const total = installed + updated + skipped;
|
|
1115
|
-
// Hoist for the post-install summary box (#1146).
|
|
1116
|
-
linkStats = { installed, updated, skipped };
|
|
1117
|
-
step.note(
|
|
1118
|
-
`${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped (${total} commands total)`,
|
|
1119
|
-
);
|
|
1120
|
-
},
|
|
1121
|
-
},
|
|
1122
|
-
{
|
|
1123
|
-
label: "Writing namespace sentinel",
|
|
1124
|
-
async run(step) {
|
|
1125
|
-
// Write .symlink-source to ~/.claude/commands/ so other installers
|
|
1126
|
-
// can detect that ForgeDock owns this namespace. Non-fatal on failure.
|
|
1127
|
-
// <!-- Added: forge#1038 -->
|
|
1128
|
-
const result = writeSymlinkSentinel();
|
|
1129
|
-
if (result === "failed") {
|
|
1130
|
-
step.note("could not write .symlink-source — run npx forgedock install again to retry");
|
|
1131
|
-
} else {
|
|
1132
|
-
step.note(cyan(join(TARGET_DIR, ".symlink-source")));
|
|
1133
|
-
}
|
|
1134
|
-
},
|
|
1135
|
-
},
|
|
1136
|
-
{
|
|
1137
|
-
label: "Linking scripts",
|
|
1138
|
-
async run(step) {
|
|
1139
|
-
const { installed, updated, skipped } = await linkScripts(step);
|
|
1140
|
-
if (installed + updated + skipped > 0) {
|
|
1141
|
-
step.note(
|
|
1142
|
-
`${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
|
|
1143
|
-
);
|
|
1144
|
-
}
|
|
1145
|
-
},
|
|
1146
|
-
},
|
|
1147
|
-
{
|
|
1148
|
-
label: "Configuring shell profile",
|
|
1149
|
-
async run(step) {
|
|
1150
|
-
// Set FORGE_HOME in shell profiles
|
|
1151
|
-
let profileUpdated = false;
|
|
1152
|
-
for (const profile of [
|
|
1153
|
-
join(HOME, ".bashrc"),
|
|
1154
|
-
join(HOME, ".zshrc"),
|
|
1155
|
-
]) {
|
|
1156
|
-
if (existsSync(profile)) {
|
|
1157
|
-
const content = readFileSync(profile, "utf-8");
|
|
1158
|
-
// Escape shell metacharacters in FORGE_HOME before embedding it in
|
|
1159
|
-
// a double-quoted shell assignment. A crafted install path
|
|
1160
|
-
// containing $(...), backticks, or " characters would otherwise
|
|
1161
|
-
// inject arbitrary shell commands executed on the user's next login.
|
|
1162
|
-
// shellEscapeDoubleQuotedPath() applies the same escaping used for
|
|
1163
|
-
// the SessionStart hook command (ref: forge#808, forge#792, forge#451).
|
|
1164
|
-
const safeForgeHome = shellEscapeDoubleQuotedPath(FORGE_HOME);
|
|
1165
|
-
const exactExport = `export FORGE_HOME="${safeForgeHome}"`;
|
|
1166
|
-
if (content.includes(exactExport)) {
|
|
1167
|
-
// Already set to the current path — no update needed.
|
|
1168
|
-
} else {
|
|
1169
|
-
// Either not present, or set to a stale path — refresh it.
|
|
1170
|
-
if (content.includes("FORGE_HOME")) {
|
|
1171
|
-
// Strip the old block before appending the updated one.
|
|
1172
|
-
const removeResult = removeForgeHomeFromProfile(profile);
|
|
1173
|
-
if (removeResult === "failed") {
|
|
1174
|
-
// Removal failed — old export still present. Skip append to
|
|
1175
|
-
// avoid writing a duplicate FORGE_HOME export. (ref: forge#846)
|
|
1176
|
-
continue;
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
appendFileSync(
|
|
1180
|
-
profile,
|
|
1181
|
-
`\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${safeForgeHome}"\n`,
|
|
1182
|
-
);
|
|
1183
|
-
profileUpdated = true;
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
if (!profileUpdated) {
|
|
1188
|
-
step.skip("FORGE_HOME already set");
|
|
1189
|
-
}
|
|
1190
|
-
},
|
|
1191
|
-
},
|
|
1192
|
-
{
|
|
1193
|
-
label: "Registering SessionStart hook",
|
|
1194
|
-
async run(step) {
|
|
1195
|
-
const hookResult = await installSessionStartHook();
|
|
1196
|
-
if (hookResult === "already-present") {
|
|
1197
|
-
step.skip("already present");
|
|
1198
|
-
} else if (hookResult === "failed") {
|
|
1199
|
-
// Non-fatal — warn but don't throw
|
|
1200
|
-
step.note("could not write — run npx forgedock install again to retry");
|
|
1201
|
-
}
|
|
1202
|
-
},
|
|
1203
|
-
},
|
|
1204
|
-
{
|
|
1205
|
-
label: "Generating forge.yaml",
|
|
1206
|
-
async run(step) {
|
|
1207
|
-
// Auto-generate forge.yaml if missing — no second command needed.
|
|
1208
|
-
// Only do this inside a real git project (ref: forge#585).
|
|
1209
|
-
const cwd = process.cwd();
|
|
1210
|
-
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
1211
|
-
if (existsSync(forgeYamlPath)) {
|
|
1212
|
-
step.skip("forge.yaml already exists");
|
|
1213
|
-
return;
|
|
1214
|
-
}
|
|
1215
|
-
if (!isGitWorkTree(cwd)) {
|
|
1216
|
-
step.skip("not a git project — run npx forgedock init inside your project");
|
|
1217
|
-
return;
|
|
1218
|
-
}
|
|
1219
|
-
await init(true);
|
|
1220
|
-
},
|
|
1221
|
-
},
|
|
1222
|
-
{
|
|
1223
|
-
label: "Updating CLAUDE.md",
|
|
1224
|
-
async run(step) {
|
|
1225
|
-
// Inject behavioral rules into CLAUDE.md (and AGENTS.md if present).
|
|
1226
|
-
// Only inside a git project — guards against non-project cwd (ref: forge#585).
|
|
1227
|
-
const installCwd = process.cwd();
|
|
1228
|
-
if (!isGitWorkTree(installCwd)) {
|
|
1229
|
-
step.skip("not a git project");
|
|
1230
|
-
return;
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
const claudeMdPath = join(installCwd, "CLAUDE.md");
|
|
1234
|
-
const agentsMdPath = join(installCwd, "AGENTS.md");
|
|
1235
|
-
|
|
1236
|
-
const claudeResult = injectManagedBlock(claudeMdPath);
|
|
1237
|
-
let note = "";
|
|
1238
|
-
if (claudeResult === "created") {
|
|
1239
|
-
note = "CLAUDE.md created";
|
|
1240
|
-
} else if (claudeResult === "updated") {
|
|
1241
|
-
note = "CLAUDE.md block refreshed";
|
|
1242
|
-
} else if (claudeResult === "appended") {
|
|
1243
|
-
note = "CLAUDE.md rules appended";
|
|
1244
|
-
} else {
|
|
1245
|
-
note = "CLAUDE.md already current";
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// Mirror to AGENTS.md only if it already exists (never create it)
|
|
1249
|
-
if (existsSync(agentsMdPath)) {
|
|
1250
|
-
const agentsResult = injectManagedBlock(agentsMdPath);
|
|
1251
|
-
if (agentsResult === "updated" || agentsResult === "appended") {
|
|
1252
|
-
note += " · AGENTS.md mirrored";
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
step.note(note);
|
|
1257
|
-
},
|
|
1258
|
-
},
|
|
1259
|
-
]);
|
|
1260
|
-
|
|
1261
|
-
if (result.ok) {
|
|
1262
|
-
const totalCommands =
|
|
1263
|
-
linkStats.installed + linkStats.updated + linkStats.skipped;
|
|
1264
|
-
const changedCommands = linkStats.installed + linkStats.updated;
|
|
1265
|
-
const versionLabel = currentVersion ? `v${currentVersion}` : "";
|
|
1266
|
-
|
|
1267
|
-
if (isFirstInstall) {
|
|
1268
|
-
// First-time install — show the full guided next-steps box so the user
|
|
1269
|
-
// has a prioritized path from "installed" to "using it". (#1146)
|
|
1270
|
-
const cmd = (text) => cyan(text.padEnd(22));
|
|
1271
|
-
process.stderr.write(
|
|
1272
|
-
box(
|
|
1273
|
-
[
|
|
1274
|
-
`${green("✔")} ${bold(`ForgeDock ${versionLabel}`.trim())} installed`,
|
|
1275
|
-
`${green("✔")} ${totalCommands} commands → ${cyan(TARGET_DIR)}`,
|
|
1276
|
-
"",
|
|
1277
|
-
bold("What's next?"),
|
|
1278
|
-
"",
|
|
1279
|
-
dim("First time?"),
|
|
1280
|
-
` ${cmd("npx forgedock demo")}${dim("Try the pipeline risk-free")}`,
|
|
1281
|
-
"",
|
|
1282
|
-
dim("Setting up your repo?"),
|
|
1283
|
-
` ${cmd("npx forgedock init")}${dim("Configure forge.yaml")}`,
|
|
1284
|
-
` ${cmd("npx forgedock doctor")}${dim("Verify your setup")}`,
|
|
1285
|
-
"",
|
|
1286
|
-
dim("Ready to go?"),
|
|
1287
|
-
` ${cyan("/work-on #N".padEnd(22))}${dim("Run it in Claude Code")}`,
|
|
1288
|
-
],
|
|
1289
|
-
{ title: "ForgeDock Installed" },
|
|
1290
|
-
) + "\n",
|
|
1291
|
-
);
|
|
1292
|
-
} else {
|
|
1293
|
-
// Update install — show a compact version diff and what changed, with a
|
|
1294
|
-
// changelog link instead of the full first-run guidance. (#1146)
|
|
1295
|
-
let headline;
|
|
1296
|
-
if (priorVersion && currentVersion && priorVersion !== currentVersion) {
|
|
1297
|
-
headline = `Updated from ${bold(`v${priorVersion}`)} → ${bold(`v${currentVersion}`)}.`;
|
|
1298
|
-
} else if (currentVersion && priorVersion === currentVersion) {
|
|
1299
|
-
headline = `Reinstalled ${bold(`v${currentVersion}`)}.`;
|
|
1300
|
-
} else if (currentVersion) {
|
|
1301
|
-
headline = `Updated to ${bold(`v${currentVersion}`)}.`;
|
|
1302
|
-
} else {
|
|
1303
|
-
headline = "Update complete.";
|
|
1304
|
-
}
|
|
1305
|
-
const changeNote =
|
|
1306
|
-
changedCommands > 0
|
|
1307
|
-
? ` ${changedCommands} new/changed command${changedCommands === 1 ? "" : "s"}.`
|
|
1308
|
-
: " Commands already up to date.";
|
|
1309
|
-
process.stderr.write(
|
|
1310
|
-
box(
|
|
1311
|
-
[
|
|
1312
|
-
`${green("✔")} ${headline}${changeNote}`,
|
|
1313
|
-
"",
|
|
1314
|
-
`Changelog: ${cyan("https://github.com/RapierCraftStudios/ForgeDock/releases")}`,
|
|
1315
|
-
`Verify: ${cyan("npx forgedock doctor")}`,
|
|
1316
|
-
],
|
|
1317
|
-
{ title: "ForgeDock Updated" },
|
|
1318
|
-
) + "\n",
|
|
1319
|
-
);
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
587
|
+
async function initFlow(c) {
|
|
588
|
+
const outputPath = join(c.cwd, "forge.yaml");
|
|
589
|
+
if (existsSync(outputPath) && process.stdin.isTTY !== true) {
|
|
590
|
+
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
591
|
+
c.stdout.write("\n forge.yaml already exists — non-interactive run, aborting to protect it.\n");
|
|
592
|
+
c.stdout.write(" " + dim("Run interactively (or delete forge.yaml) to regenerate.") + "\n");
|
|
593
|
+
return 1;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (flags.includes("--minimal")) {
|
|
597
|
+
// Staging semantics preserved (#1148): detection → minimal template with
|
|
598
|
+
// only the three required sections. No review screen — this is the
|
|
599
|
+
// power-user escape hatch for a ~20-line forge.yaml.
|
|
600
|
+
const { draft, description } = await read(c);
|
|
601
|
+
const backup = backupExisting(outputPath);
|
|
602
|
+
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
603
|
+
const content = buildMinimalForgeYaml({
|
|
604
|
+
projectName: draft.project.name.value,
|
|
605
|
+
owner: draft.project.owner.value,
|
|
606
|
+
repo: draft.project.repo.value,
|
|
607
|
+
description: description.value,
|
|
608
|
+
root: draft.paths.root.value,
|
|
609
|
+
worktreeBase: draft.paths.worktreeBase.value,
|
|
610
|
+
defaultBranch: draft.branches.default.value,
|
|
611
|
+
stagingBranch: draft.branches.staging.value,
|
|
612
|
+
});
|
|
613
|
+
atomicWriteFile(outputPath, content);
|
|
614
|
+
celebrate(c, { written: true, todoCount: 0, isMinimal: true });
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (flags.includes("--manual")) {
|
|
619
|
+
// Manual escape hatch: plain prompts, detection values as defaults.
|
|
620
|
+
const { draft, description } = await read(c);
|
|
621
|
+
const v = {
|
|
622
|
+
owner: await input("GitHub owner", draft.project.owner.value),
|
|
623
|
+
repo: await input("Repository", draft.project.repo.value),
|
|
624
|
+
name: await input("Project name", draft.project.name.value),
|
|
625
|
+
description: await input("Description", description.value),
|
|
626
|
+
root: await input("Repo root", draft.paths.root.value),
|
|
627
|
+
worktreeBase: await input("Worktree base", draft.paths.worktreeBase.value),
|
|
628
|
+
defaultBranch: await input("Default branch", draft.branches.default.value),
|
|
629
|
+
stagingBranch: await input("Staging branch", draft.branches.staging.value),
|
|
630
|
+
};
|
|
631
|
+
const backup = backupExisting(outputPath);
|
|
632
|
+
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
633
|
+
const lowKeys = manualLowConfidenceKeys(draft, description, v);
|
|
634
|
+
const { todoCount } = writeForgeYaml(v, lowKeys, outputPath);
|
|
635
|
+
celebrate(c, { written: true, todoCount });
|
|
636
|
+
return 0;
|
|
637
|
+
}
|
|
638
|
+
const { draft, description } = await read(c);
|
|
639
|
+
const reviewed = await review(c, draft, description);
|
|
640
|
+
celebrate(c, reviewed);
|
|
641
|
+
return reviewed.aborted ? 1 : 0;
|
|
1322
642
|
}
|
|
1323
643
|
|
|
1324
644
|
async function uninstall() {
|
|
@@ -1326,53 +646,111 @@ async function uninstall() {
|
|
|
1326
646
|
console.log(`${BOLD}ForgeDock${RESET} — Removing pipeline commands`);
|
|
1327
647
|
console.log("");
|
|
1328
648
|
|
|
649
|
+
const { targetDir, scriptsTargetDir, manifestPath } = detectInstallPaths();
|
|
650
|
+
console.log(` Mode: global (~/.claude)`);
|
|
651
|
+
console.log("");
|
|
652
|
+
|
|
1329
653
|
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
1330
654
|
let removed = 0;
|
|
1331
655
|
|
|
656
|
+
// Ownership manifest for copy-installed commands (Windows without Developer
|
|
657
|
+
// Mode, or a symlink→copy fallback from a previous run). Loaded up front:
|
|
658
|
+
// manifest-tracked copies are removed via the manifest loop below; the
|
|
659
|
+
// content-match branch in the main loop only covers copies written by older
|
|
660
|
+
// installs that predate the manifest.
|
|
661
|
+
let manifest = null;
|
|
662
|
+
try {
|
|
663
|
+
manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
664
|
+
} catch {
|
|
665
|
+
manifest = null;
|
|
666
|
+
}
|
|
667
|
+
const manifestFiles =
|
|
668
|
+
manifest && manifest.files && typeof manifest.files === "object"
|
|
669
|
+
? manifest.files
|
|
670
|
+
: {};
|
|
671
|
+
|
|
1332
672
|
for (const file of files) {
|
|
1333
673
|
const rel = relative(COMMANDS_DIR, file);
|
|
1334
|
-
const target = join(
|
|
674
|
+
const target = join(targetDir, rel);
|
|
1335
675
|
|
|
1336
676
|
try {
|
|
1337
677
|
const stats = await lstat(target);
|
|
1338
678
|
if (stats.isSymbolicLink()) {
|
|
1339
679
|
const current = await readlink(target);
|
|
1340
680
|
if (current === file) {
|
|
1341
|
-
const { unlink } = await import("fs/promises");
|
|
1342
681
|
await unlink(target);
|
|
1343
682
|
console.log(` ${RED}Removed${RESET}: ${rel}`);
|
|
1344
683
|
removed++;
|
|
1345
684
|
}
|
|
1346
685
|
} else if (
|
|
686
|
+
stats.isFile() &&
|
|
687
|
+
!manifestFiles[rel] &&
|
|
1347
688
|
readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
|
|
1348
689
|
) {
|
|
1349
|
-
// Regular file installed by
|
|
1350
|
-
|
|
690
|
+
// Regular file installed by an older copy-mode build (no manifest
|
|
691
|
+
// entry) — content matches the source, so it is ours to remove.
|
|
1351
692
|
await unlink(target);
|
|
1352
693
|
console.log(` ${RED}Removed${RESET}: ${rel}`);
|
|
1353
694
|
removed++;
|
|
1354
695
|
}
|
|
1355
696
|
} catch (err) {
|
|
1356
|
-
if (err.code !== "ENOENT")
|
|
697
|
+
if (err.code !== "ENOENT") {
|
|
698
|
+
console.error(
|
|
699
|
+
` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
|
|
700
|
+
);
|
|
701
|
+
throw err;
|
|
702
|
+
}
|
|
1357
703
|
// Doesn't exist — nothing to do
|
|
1358
704
|
}
|
|
1359
705
|
}
|
|
1360
706
|
|
|
707
|
+
// Remove copy-installed commands tracked in the ownership manifest. These
|
|
708
|
+
// are regular files, not symlinks, so the symlink branch above never touches
|
|
709
|
+
// them — the manifest is the only record of ForgeDock's ownership over them.
|
|
710
|
+
const manifestRels = Object.keys(manifestFiles);
|
|
711
|
+
for (const rel of manifestRels) {
|
|
712
|
+
const target = join(targetDir, rel);
|
|
713
|
+
try {
|
|
714
|
+
const stats = await lstat(target);
|
|
715
|
+
if (stats.isFile() && !stats.isSymbolicLink()) {
|
|
716
|
+
await unlink(target);
|
|
717
|
+
console.log(` ${RED}Removed${RESET}: ${rel} ${YELLOW}(copied)${RESET}`);
|
|
718
|
+
removed++;
|
|
719
|
+
}
|
|
720
|
+
} catch (err) {
|
|
721
|
+
if (err.code !== "ENOENT") {
|
|
722
|
+
console.error(
|
|
723
|
+
` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
|
|
724
|
+
);
|
|
725
|
+
throw err;
|
|
726
|
+
}
|
|
727
|
+
// Already gone — nothing to do
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (manifest && manifestRels.length > 0) {
|
|
731
|
+
manifest.files = {};
|
|
732
|
+
try {
|
|
733
|
+
await mkdir(dirname(manifestPath), { recursive: true });
|
|
734
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
735
|
+
} catch {
|
|
736
|
+
// Best-effort — a failed manifest write is non-fatal
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
1361
740
|
console.log("");
|
|
1362
741
|
console.log(`Done. Removed: ${removed} commands.`);
|
|
1363
742
|
console.log("");
|
|
1364
743
|
|
|
1365
|
-
// Remove scripts installed by ForgeDock from
|
|
744
|
+
// Remove scripts installed by ForgeDock from the detected scripts target dir
|
|
1366
745
|
const scriptFiles = await findScriptFiles(SCRIPTS_DIR);
|
|
1367
746
|
let scriptsRemoved = 0;
|
|
1368
747
|
|
|
1369
748
|
for (const file of scriptFiles) {
|
|
1370
749
|
const rel = relative(SCRIPTS_DIR, file);
|
|
1371
|
-
const target = join(
|
|
750
|
+
const target = join(scriptsTargetDir, rel);
|
|
1372
751
|
|
|
1373
752
|
try {
|
|
1374
753
|
const stats = await lstat(target);
|
|
1375
|
-
const { unlink } = await import("fs/promises");
|
|
1376
754
|
if (stats.isSymbolicLink()) {
|
|
1377
755
|
const current = await readlink(target);
|
|
1378
756
|
if (current === file) {
|
|
@@ -1400,13 +778,15 @@ async function uninstall() {
|
|
|
1400
778
|
console.log("");
|
|
1401
779
|
}
|
|
1402
780
|
|
|
1403
|
-
// Remove the SessionStart hook from ~/.claude/settings.json
|
|
1404
|
-
|
|
781
|
+
// Remove the SessionStart hook from ~/.claude/settings.json via the
|
|
782
|
+
// hardened settings-hook module (never writes through malformed JSON).
|
|
783
|
+
const settingsJsonPath = join(HOME, ".claude", "settings.json");
|
|
784
|
+
const { status: hookRemoveResult } = removeSessionStartHook(settingsJsonPath);
|
|
1405
785
|
if (hookRemoveResult === "removed") {
|
|
1406
786
|
console.log(
|
|
1407
787
|
` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}~/.claude/settings.json${RESET}`,
|
|
1408
788
|
);
|
|
1409
|
-
} else if (hookRemoveResult === "
|
|
789
|
+
} else if (hookRemoveResult === "absent") {
|
|
1410
790
|
console.log(
|
|
1411
791
|
` ✔ SessionStart hook already absent from ~/.claude/settings.json`,
|
|
1412
792
|
);
|
|
@@ -1415,9 +795,30 @@ async function uninstall() {
|
|
|
1415
795
|
` ${YELLOW}⚠${RESET} Could not update ~/.claude/settings.json — remove the hook manually.`,
|
|
1416
796
|
);
|
|
1417
797
|
}
|
|
798
|
+
|
|
799
|
+
// Remove enforcement hooks (#1250): PreToolUse, SubagentStop interactive
|
|
800
|
+
// engine adapter, and SubagentStop annotation verifier.
|
|
801
|
+
const { status: preToolUseRemoveResult } = removePreToolUseHook(settingsJsonPath);
|
|
802
|
+
if (preToolUseRemoveResult === "removed") {
|
|
803
|
+
console.log(` ${GREEN}✔${RESET} Removed PreToolUse enforcement hook from ${CYAN}~/.claude/settings.json${RESET}`);
|
|
804
|
+
} else if (preToolUseRemoveResult !== "absent") {
|
|
805
|
+
console.log(` ${YELLOW}⚠${RESET} Could not remove PreToolUse hook — check ${CYAN}~/.claude/settings.json${RESET} manually.`);
|
|
806
|
+
}
|
|
807
|
+
const { status: subagentStopRemoveResult } = removeSubagentStopHook(settingsJsonPath);
|
|
808
|
+
if (subagentStopRemoveResult === "removed") {
|
|
809
|
+
console.log(` ${GREEN}✔${RESET} Removed SubagentStop hook from ${CYAN}~/.claude/settings.json${RESET}`);
|
|
810
|
+
}
|
|
811
|
+
const { status: subagentStopEnforceRemoveResult } = removeSubagentStopEnforceHook(settingsJsonPath);
|
|
812
|
+
if (subagentStopEnforceRemoveResult === "removed") {
|
|
813
|
+
console.log(` ${GREEN}✔${RESET} Removed SubagentStop enforcement hook from ${CYAN}~/.claude/settings.json${RESET}`);
|
|
814
|
+
} else if (subagentStopEnforceRemoveResult !== "absent") {
|
|
815
|
+
console.log(` ${YELLOW}⚠${RESET} Could not remove SubagentStop enforcement hook — check ${CYAN}~/.claude/settings.json${RESET} manually.`);
|
|
816
|
+
}
|
|
1418
817
|
console.log("");
|
|
1419
818
|
|
|
1420
|
-
// Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present)
|
|
819
|
+
// Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present).
|
|
820
|
+
// The journey install no longer writes these, but installs from older builds
|
|
821
|
+
// did — uninstall stays responsible for cleaning them up.
|
|
1421
822
|
const uninstallCwd = process.cwd();
|
|
1422
823
|
const claudeMdPath = join(uninstallCwd, "CLAUDE.md");
|
|
1423
824
|
const agentsMdPath = join(uninstallCwd, "AGENTS.md");
|
|
@@ -1443,7 +844,8 @@ async function uninstall() {
|
|
|
1443
844
|
}
|
|
1444
845
|
console.log("");
|
|
1445
846
|
|
|
1446
|
-
// Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc)
|
|
847
|
+
// Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc) — written
|
|
848
|
+
// by installs from older builds; harmless no-op when absent.
|
|
1447
849
|
for (const profileName of [".bashrc", ".zshrc"]) {
|
|
1448
850
|
const profilePath = join(HOME, profileName);
|
|
1449
851
|
const profileResult = removeForgeHomeFromProfile(profilePath);
|
|
@@ -1481,655 +883,251 @@ async function uninstall() {
|
|
|
1481
883
|
|
|
1482
884
|
// Clean up registry nudgeSeen entry for cwd (written by the session-start hook)
|
|
1483
885
|
try {
|
|
1484
|
-
const { clearNudgeSeen } = await import(
|
|
1485
|
-
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1486
|
-
);
|
|
1487
886
|
await clearNudgeSeen(uninstallCwd);
|
|
1488
887
|
console.log(
|
|
1489
888
|
` ${GREEN}✔${RESET} Cleaned registry entries for ${CYAN}${uninstallCwd}${RESET}`,
|
|
1490
889
|
);
|
|
1491
890
|
} catch {
|
|
1492
891
|
// Non-fatal — registry is best-effort
|
|
1493
|
-
console.log(
|
|
1494
|
-
` ${YELLOW}⚠${RESET} Could not clean registry entries — safe to ignore.`,
|
|
1495
|
-
);
|
|
1496
|
-
}
|
|
1497
|
-
console.log("");
|
|
1498
|
-
|
|
1499
|
-
console.log(`${GREEN}ForgeDock uninstalled.${RESET} No residue remains from this directory.`);
|
|
1500
|
-
console.log(` Re-install at any time with: ${CYAN}npx forgedock${RESET}`);
|
|
1501
|
-
console.log("");
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
async function update() {
|
|
1505
|
-
console.log("");
|
|
1506
|
-
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
1507
|
-
console.log("");
|
|
1508
|
-
|
|
1509
|
-
// Check if installed via npm (no .git directory) or via git clone
|
|
1510
|
-
const gitDir = join(FORGE_HOME, ".git");
|
|
1511
|
-
if (existsSync(gitDir)) {
|
|
1512
|
-
try {
|
|
1513
|
-
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1514
|
-
cwd: FORGE_HOME,
|
|
1515
|
-
encoding: "utf-8",
|
|
1516
|
-
}).trim();
|
|
1517
|
-
if (branch !== "main") {
|
|
1518
|
-
console.log(` Not on main branch (${branch}) — skipping`);
|
|
1519
|
-
return;
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
const before = execSync("git rev-parse HEAD", {
|
|
1523
|
-
cwd: FORGE_HOME,
|
|
1524
|
-
encoding: "utf-8",
|
|
1525
|
-
}).trim();
|
|
1526
|
-
execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
|
|
1527
|
-
execSync("git merge --ff-only origin/main --quiet", {
|
|
1528
|
-
cwd: FORGE_HOME,
|
|
1529
|
-
});
|
|
1530
|
-
const after = execSync("git rev-parse HEAD", {
|
|
1531
|
-
cwd: FORGE_HOME,
|
|
1532
|
-
encoding: "utf-8",
|
|
1533
|
-
}).trim();
|
|
1534
|
-
|
|
1535
|
-
if (before === after) {
|
|
1536
|
-
console.log(` Already up to date.`);
|
|
1537
|
-
} else {
|
|
1538
|
-
console.log(` ${GREEN}Updated to latest.${RESET}`);
|
|
1539
|
-
await install();
|
|
1540
|
-
}
|
|
1541
|
-
} catch (err) {
|
|
1542
|
-
console.log(
|
|
1543
|
-
` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
|
|
1544
|
-
);
|
|
1545
|
-
}
|
|
1546
|
-
} else {
|
|
1547
|
-
console.log(
|
|
1548
|
-
` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
|
|
1549
|
-
);
|
|
1550
|
-
}
|
|
1551
|
-
console.log("");
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
async function init(fromInstall = false, minimal = false) {
|
|
1555
|
-
// When called from install() via runSteps(), suppress stdout to prevent
|
|
1556
|
-
// interleaving with the TUI spinner on stderr. runSteps() provides the
|
|
1557
|
-
// "Generating forge.yaml" step label as visual feedback. (#812)
|
|
1558
|
-
const log = fromInstall ? () => {} : console.log;
|
|
1559
|
-
log("");
|
|
1560
|
-
log(
|
|
1561
|
-
`${BOLD}ForgeDock${RESET} — Generating forge.yaml${minimal ? " (minimal)" : ""}`,
|
|
1562
|
-
);
|
|
1563
|
-
log("");
|
|
1564
|
-
|
|
1565
|
-
const cwd = process.cwd();
|
|
1566
|
-
const worktreeBase = join(cwd, ".claude", "worktrees");
|
|
1567
|
-
const outputPath = join(cwd, "forge.yaml");
|
|
1568
|
-
|
|
1569
|
-
// --- Detect git remote URL and parse owner/repo ---
|
|
1570
|
-
let owner = "your-github-org";
|
|
1571
|
-
let repo = "your-repo-name";
|
|
1572
|
-
let remoteDetected = false;
|
|
1573
|
-
|
|
1574
|
-
try {
|
|
1575
|
-
const remoteUrl = execSync("git remote get-url origin", {
|
|
1576
|
-
cwd,
|
|
1577
|
-
encoding: "utf-8",
|
|
1578
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1579
|
-
}).trim();
|
|
1580
|
-
|
|
1581
|
-
// SSH: git@github.com:owner/repo.git
|
|
1582
|
-
const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
1583
|
-
// HTTPS: https://github.com/owner/repo.git
|
|
1584
|
-
const httpsMatch = remoteUrl.match(
|
|
1585
|
-
/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
|
|
1586
|
-
);
|
|
1587
|
-
|
|
1588
|
-
if (sshMatch) {
|
|
1589
|
-
owner = sshMatch[1];
|
|
1590
|
-
repo = sshMatch[2];
|
|
1591
|
-
remoteDetected = true;
|
|
1592
|
-
} else if (httpsMatch) {
|
|
1593
|
-
owner = httpsMatch[1];
|
|
1594
|
-
repo = httpsMatch[2];
|
|
1595
|
-
remoteDetected = true;
|
|
1596
|
-
} else {
|
|
1597
|
-
log(
|
|
1598
|
-
` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
|
|
1599
|
-
);
|
|
1600
|
-
}
|
|
1601
|
-
} catch {
|
|
1602
|
-
log(
|
|
1603
|
-
` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
|
|
1604
|
-
);
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
if (remoteDetected) {
|
|
1608
|
-
log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
// --- Detect default branch ---
|
|
1612
|
-
let defaultBranch = "main";
|
|
1613
|
-
try {
|
|
1614
|
-
const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
1615
|
-
cwd,
|
|
1616
|
-
encoding: "utf-8",
|
|
1617
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1618
|
-
}).trim();
|
|
1619
|
-
// refs/remotes/origin/main → main
|
|
1620
|
-
defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
|
|
1621
|
-
log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
|
|
1622
|
-
} catch {
|
|
1623
|
-
// Fallback: try git rev-parse
|
|
1624
|
-
try {
|
|
1625
|
-
defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1626
|
-
cwd,
|
|
1627
|
-
encoding: "utf-8",
|
|
1628
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1629
|
-
}).trim();
|
|
1630
|
-
if (defaultBranch === "HEAD") defaultBranch = "main";
|
|
1631
|
-
log(
|
|
1632
|
-
` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
|
|
1633
|
-
);
|
|
1634
|
-
} catch {
|
|
1635
|
-
log(
|
|
1636
|
-
` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
|
|
1637
|
-
);
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
// --- Detect staging branch ---
|
|
1642
|
-
let stagingBranch = "staging";
|
|
1643
|
-
try {
|
|
1644
|
-
const remoteBranches = execSync("git branch -r", {
|
|
1645
|
-
cwd,
|
|
1646
|
-
encoding: "utf-8",
|
|
1647
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1648
|
-
});
|
|
1649
|
-
if (remoteBranches.includes("origin/staging")) {
|
|
1650
|
-
stagingBranch = "staging";
|
|
1651
|
-
log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
|
|
1652
|
-
} else {
|
|
1653
|
-
stagingBranch = defaultBranch;
|
|
1654
|
-
log(
|
|
1655
|
-
` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
|
|
1656
|
-
);
|
|
1657
|
-
}
|
|
1658
|
-
} catch {
|
|
1659
|
-
log(
|
|
1660
|
-
` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
|
|
892
|
+
console.log(
|
|
893
|
+
` ${YELLOW}⚠${RESET} Could not clean registry entries — safe to ignore.`,
|
|
1661
894
|
);
|
|
1662
|
-
stagingBranch = defaultBranch;
|
|
1663
895
|
}
|
|
896
|
+
console.log("");
|
|
1664
897
|
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
if (existsSync(readmePath)) {
|
|
1670
|
-
const readmeContent = readFileSync(readmePath, "utf-8").slice(0, 2048);
|
|
1671
|
-
// Skip the first heading line (# Title), grab the first non-empty paragraph
|
|
1672
|
-
const lines = readmeContent.split("\n");
|
|
1673
|
-
let inFirstParagraph = false;
|
|
1674
|
-
const paragraphLines = [];
|
|
1675
|
-
for (const line of lines) {
|
|
1676
|
-
// Skip heading lines at the top
|
|
1677
|
-
if (!inFirstParagraph && line.match(/^#/)) continue;
|
|
1678
|
-
// Skip blank lines before paragraph starts
|
|
1679
|
-
if (!inFirstParagraph && line.trim() === "") continue;
|
|
1680
|
-
// Skip lines that are just badges, HTML, code fences, horizontal rules, or tables
|
|
1681
|
-
if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
|
|
1682
|
-
if (!inFirstParagraph && line.match(/^---/)) continue;
|
|
1683
|
-
// Start collecting
|
|
1684
|
-
if (!inFirstParagraph) {
|
|
1685
|
-
inFirstParagraph = true;
|
|
1686
|
-
}
|
|
1687
|
-
// Stop at blank line (end of paragraph)
|
|
1688
|
-
if (line.trim() === "") break;
|
|
1689
|
-
paragraphLines.push(line.trim());
|
|
1690
|
-
}
|
|
1691
|
-
if (paragraphLines.length > 0) {
|
|
1692
|
-
// Flatten to single line, strip markdown links/bold/inline code
|
|
1693
|
-
description = paragraphLines
|
|
1694
|
-
.join(" ")
|
|
1695
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
1696
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
1697
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
1698
|
-
.slice(0, 200)
|
|
1699
|
-
.trim();
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
} catch {
|
|
1703
|
-
// Best-effort only — silently fall back to empty description
|
|
1704
|
-
}
|
|
898
|
+
console.log(`${GREEN}ForgeDock uninstalled.${RESET} No residue remains from this directory.`);
|
|
899
|
+
console.log(` Re-install at any time with: ${CYAN}npx forgedock${RESET}`);
|
|
900
|
+
console.log("");
|
|
901
|
+
}
|
|
1705
902
|
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
903
|
+
// Reinstall = relink commands + re-register the hook. Never the full
|
|
904
|
+
// journey: update must not reach read/review, which could overwrite a
|
|
905
|
+
// curated forge.yaml (and re-runs AI enrichment on every update). Also
|
|
906
|
+
// idempotent, so it's the repair path for a configured repo whose
|
|
907
|
+
// symlinks or hook registration got out of sync.
|
|
908
|
+
async function relinkAndHint() {
|
|
909
|
+
const c = ctx();
|
|
910
|
+
await forge(c);
|
|
911
|
+
if (!existsSync(join(c.cwd, "forge.yaml"))) {
|
|
912
|
+
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
913
|
+
c.stdout.write(" " + dim("Configure this repo: npx forgedock init") + "\n");
|
|
1710
914
|
}
|
|
915
|
+
}
|
|
1711
916
|
|
|
1712
|
-
|
|
1713
|
-
|
|
917
|
+
async function update() {
|
|
918
|
+
console.log("");
|
|
919
|
+
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
920
|
+
console.log("");
|
|
921
|
+
|
|
922
|
+
console.log(` Mode: global (~/.claude)`);
|
|
923
|
+
|
|
924
|
+
// Check if installed via npm (no .git directory) or via git clone
|
|
925
|
+
const gitDir = join(FORGE_HOME, ".git");
|
|
926
|
+
if (existsSync(gitDir)) {
|
|
1714
927
|
try {
|
|
1715
|
-
const
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
// Skip heading lines at the top
|
|
1723
|
-
if (!inFirstParagraph && line.match(/^#/)) continue;
|
|
1724
|
-
// Skip blank lines before paragraph starts
|
|
1725
|
-
if (!inFirstParagraph && line.trim() === "") continue;
|
|
1726
|
-
// Skip lines that are just badges, HTML comments, code fences, horizontal rules, or tables
|
|
1727
|
-
if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
|
|
1728
|
-
if (!inFirstParagraph && line.match(/^---/)) continue;
|
|
1729
|
-
// Start collecting
|
|
1730
|
-
if (!inFirstParagraph) {
|
|
1731
|
-
inFirstParagraph = true;
|
|
1732
|
-
}
|
|
1733
|
-
// Stop at blank line (end of paragraph)
|
|
1734
|
-
if (line.trim() === "") break;
|
|
1735
|
-
paragraphLines.push(line.trim());
|
|
1736
|
-
}
|
|
1737
|
-
if (paragraphLines.length > 0) {
|
|
1738
|
-
// Flatten to single line, strip markdown links/bold/inline code
|
|
1739
|
-
description = paragraphLines
|
|
1740
|
-
.join(" ")
|
|
1741
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
1742
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
1743
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
1744
|
-
.slice(0, 200)
|
|
1745
|
-
.trim();
|
|
1746
|
-
}
|
|
928
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
929
|
+
cwd: FORGE_HOME,
|
|
930
|
+
encoding: "utf-8",
|
|
931
|
+
}).trim();
|
|
932
|
+
if (branch !== "main") {
|
|
933
|
+
console.log(` Not on main branch (${branch}) — skipping`);
|
|
934
|
+
return;
|
|
1747
935
|
}
|
|
1748
|
-
} catch {
|
|
1749
|
-
// Best-effort only — silently fall back to empty description
|
|
1750
|
-
}
|
|
1751
936
|
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
);
|
|
1756
|
-
|
|
1757
|
-
|
|
937
|
+
const before = execSync("git rev-parse HEAD", {
|
|
938
|
+
cwd: FORGE_HOME,
|
|
939
|
+
encoding: "utf-8",
|
|
940
|
+
}).trim();
|
|
941
|
+
execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
|
|
942
|
+
execSync("git merge --ff-only origin/main --quiet", {
|
|
943
|
+
cwd: FORGE_HOME,
|
|
944
|
+
});
|
|
945
|
+
const after = execSync("git rev-parse HEAD", {
|
|
946
|
+
cwd: FORGE_HOME,
|
|
947
|
+
encoding: "utf-8",
|
|
948
|
+
}).trim();
|
|
1758
949
|
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
const backupName = basename(backupPath);
|
|
1769
|
-
renameSync(outputPath, backupPath);
|
|
1770
|
-
log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
// --- Generate forge.yaml content ---
|
|
1774
|
-
const projectName = repo
|
|
1775
|
-
.split(/[-_]/)
|
|
1776
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1777
|
-
.join(" ");
|
|
1778
|
-
|
|
1779
|
-
// Pre-escape values for embedding in YAML double-quoted strings.
|
|
1780
|
-
// Backslash must be escaped first to avoid double-escaping.
|
|
1781
|
-
const safeOwner = owner.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1782
|
-
const safeRepo = repo.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1783
|
-
const safeProjectName = projectName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1784
|
-
const safeDefaultBranch = defaultBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1785
|
-
const safeStagingBranch = stagingBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1786
|
-
|
|
1787
|
-
const fullContent = `# forge.yaml — ForgeDock Configuration
|
|
1788
|
-
#
|
|
1789
|
-
# Auto-generated by: npx forgedock init
|
|
1790
|
-
# Edit this file with your project details.
|
|
1791
|
-
#
|
|
1792
|
-
# Required sections: project, paths, branches
|
|
1793
|
-
# Optional sections: repos, project_board, services, review, verification
|
|
1794
|
-
#
|
|
1795
|
-
# See docs/CONFIG.md for full reference.
|
|
1796
|
-
|
|
1797
|
-
# =============================================================================
|
|
1798
|
-
# PROJECT (REQUIRED)
|
|
1799
|
-
# =============================================================================
|
|
1800
|
-
|
|
1801
|
-
project:
|
|
1802
|
-
name: "${safeProjectName}"
|
|
1803
|
-
owner: "${safeOwner}"
|
|
1804
|
-
repo: "${safeRepo}"
|
|
1805
|
-
description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1806
|
-
|
|
1807
|
-
# =============================================================================
|
|
1808
|
-
# PATHS (REQUIRED)
|
|
1809
|
-
# =============================================================================
|
|
1810
|
-
|
|
1811
|
-
paths:
|
|
1812
|
-
root: "${cwd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1813
|
-
worktree_base: "${worktreeBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1814
|
-
|
|
1815
|
-
# =============================================================================
|
|
1816
|
-
# BRANCHES (REQUIRED)
|
|
1817
|
-
# =============================================================================
|
|
1818
|
-
|
|
1819
|
-
branches:
|
|
1820
|
-
default: "${safeDefaultBranch}"
|
|
1821
|
-
staging: "${safeStagingBranch}"
|
|
1822
|
-
feature_pattern: "milestone/{slug}"
|
|
1823
|
-
|
|
1824
|
-
# =============================================================================
|
|
1825
|
-
# REPOS (OPTIONAL)
|
|
1826
|
-
# Multi-repo configuration. Remove the # to enable.
|
|
1827
|
-
# =============================================================================
|
|
1828
|
-
|
|
1829
|
-
# repos:
|
|
1830
|
-
# default:
|
|
1831
|
-
# repo: "${safeOwner}/${safeRepo}"
|
|
1832
|
-
# staging_branch: "${safeStagingBranch}"
|
|
1833
|
-
# satellites:
|
|
1834
|
-
# - prefix: "mcp"
|
|
1835
|
-
# repo: "${safeOwner}/your-satellite-repo"
|
|
1836
|
-
# staging_branch: "main"
|
|
1837
|
-
# local_path: "${join(cwd, "..", "your-satellite-repo").replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
1838
|
-
|
|
1839
|
-
# =============================================================================
|
|
1840
|
-
# PROJECT BOARD (OPTIONAL)
|
|
1841
|
-
# GitHub Projects v2 integration.
|
|
1842
|
-
# To find IDs: gh project list --owner ${safeOwner}
|
|
1843
|
-
# =============================================================================
|
|
1844
|
-
|
|
1845
|
-
# project_board:
|
|
1846
|
-
# owner: "${safeOwner}"
|
|
1847
|
-
# project_number: 1
|
|
1848
|
-
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
1849
|
-
# field_ids:
|
|
1850
|
-
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1851
|
-
# lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1852
|
-
# component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1853
|
-
# priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1854
|
-
# workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1855
|
-
|
|
1856
|
-
# =============================================================================
|
|
1857
|
-
# REVIEW (OPTIONAL)
|
|
1858
|
-
# Context injected into review agent prompts.
|
|
1859
|
-
# =============================================================================
|
|
1860
|
-
|
|
1861
|
-
# review:
|
|
1862
|
-
# tech_stack: "Node.js, TypeScript, PostgreSQL"
|
|
1863
|
-
# context: |
|
|
1864
|
-
# Describe your repo structure and any unusual conventions here.
|
|
1865
|
-
|
|
1866
|
-
# =============================================================================
|
|
1867
|
-
# VERIFICATION (OPTIONAL)
|
|
1868
|
-
# Health-check patterns for quality gate and validate commands.
|
|
1869
|
-
# =============================================================================
|
|
1870
|
-
|
|
1871
|
-
# verification:
|
|
1872
|
-
# health_endpoint: "https://api.${safeRepo}.io/health"
|
|
1873
|
-
# health_patterns:
|
|
1874
|
-
# - '"status": "ok"'
|
|
1875
|
-
#
|
|
1876
|
-
# # Integration test suites run by /test-gate against the provisioned cluster.
|
|
1877
|
-
# # Each entry specifies a logical cluster (matched to test_services), the shell
|
|
1878
|
-
# # command to execute, and the working directory (relative to project root).
|
|
1879
|
-
# # Used by: test-gate (Phase 3 Provision + Phase 4 Fan out)
|
|
1880
|
-
# #
|
|
1881
|
-
# # integration_tests:
|
|
1882
|
-
# # - cluster: "api"
|
|
1883
|
-
# # command: "pytest tests/integration/ -q --tb=short"
|
|
1884
|
-
# # working_dir: "."
|
|
1885
|
-
#
|
|
1886
|
-
# # Maps logical cluster names (from integration_tests) to running container
|
|
1887
|
-
# # names. /test-gate checks these containers are live before running tests.
|
|
1888
|
-
# # Omit entries for clusters that do not use Docker.
|
|
1889
|
-
# # Used by: test-gate (Phase 3 Provision)
|
|
1890
|
-
# #
|
|
1891
|
-
# # test_services:
|
|
1892
|
-
# # api: "${safeRepo}-api-blue"
|
|
1893
|
-
# # worker: "${safeRepo}-worker-blue"
|
|
1894
|
-
#
|
|
1895
|
-
# # Controls /test-gate posture at the staging-to-main deploy boundary.
|
|
1896
|
-
# # posture: blocking (default) or advisory.
|
|
1897
|
-
# # override_phrase: exact comment text an operator posts to bypass a BLOCK.
|
|
1898
|
-
# # Used by: test-gate (Phase 7 Verdict), review-pr-staging (Phase 6.5)
|
|
1899
|
-
# #
|
|
1900
|
-
# # test_gate:
|
|
1901
|
-
# # posture: "blocking"
|
|
1902
|
-
# # override_phrase: "OVERRIDE: shipping with test failures \u2014"
|
|
1903
|
-
`;
|
|
1904
|
-
|
|
1905
|
-
// When --minimal is requested, emit only the three required sections
|
|
1906
|
-
// (project, paths, branches) — no commented optional blocks. The minimal
|
|
1907
|
-
// output still passes `forgedock doctor` and drives `/work-on`, while staying
|
|
1908
|
-
// ~20 lines instead of ~200. (#1148)
|
|
1909
|
-
const content = minimal
|
|
1910
|
-
? buildMinimalForgeYaml({
|
|
1911
|
-
projectName,
|
|
1912
|
-
owner,
|
|
1913
|
-
repo,
|
|
1914
|
-
description,
|
|
1915
|
-
root: cwd,
|
|
1916
|
-
worktreeBase,
|
|
1917
|
-
defaultBranch,
|
|
1918
|
-
stagingBranch,
|
|
1919
|
-
})
|
|
1920
|
-
: fullContent;
|
|
1921
|
-
|
|
1922
|
-
atomicWriteFile(outputPath, content);
|
|
1923
|
-
|
|
1924
|
-
log(` ${GREEN}Created${RESET}: forge.yaml${minimal ? " (minimal)" : ""}`);
|
|
1925
|
-
log("");
|
|
1926
|
-
|
|
1927
|
-
if (fromInstall) {
|
|
1928
|
-
// Called automatically from install() — only print what still needs attention.
|
|
1929
|
-
// NOTE: log() is a no-op when fromInstall=true (stdout suppressed to avoid
|
|
1930
|
-
// interleaving with TUI spinner on stderr). If git remote was not detected,
|
|
1931
|
-
// the user will see the incomplete placeholder values in forge.yaml directly. (#812)
|
|
1932
|
-
if (!remoteDetected) {
|
|
1933
|
-
log(`${YELLOW}Action required:${RESET}`);
|
|
1934
|
-
log(
|
|
1935
|
-
` Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET} (git remote not detected)`,
|
|
950
|
+
if (before === after) {
|
|
951
|
+
console.log(` Already up to date.`);
|
|
952
|
+
} else {
|
|
953
|
+
console.log(` ${GREEN}Updated to latest.${RESET}`);
|
|
954
|
+
}
|
|
955
|
+
await relinkAndHint();
|
|
956
|
+
} catch (err) {
|
|
957
|
+
console.log(
|
|
958
|
+
` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
|
|
1936
959
|
);
|
|
1937
|
-
log("");
|
|
1938
960
|
}
|
|
1939
961
|
} else {
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
if (!remoteDetected) {
|
|
1943
|
-
log(
|
|
1944
|
-
` 1. Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET}`,
|
|
1945
|
-
);
|
|
1946
|
-
} else {
|
|
1947
|
-
log(
|
|
1948
|
-
` 1. Review ${CYAN}forge.yaml${RESET} — all required fields were auto-detected`,
|
|
1949
|
-
);
|
|
1950
|
-
}
|
|
1951
|
-
log(
|
|
1952
|
-
` 2. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
|
|
962
|
+
console.log(
|
|
963
|
+
` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
|
|
1953
964
|
);
|
|
1954
|
-
|
|
1955
|
-
log(
|
|
1956
|
-
` 3. Need more? Add optional sections from ${CYAN}forge.yaml.example${RESET} (see ${CYAN}docs/CONFIG.md${RESET})`,
|
|
1957
|
-
);
|
|
1958
|
-
} else {
|
|
1959
|
-
log(
|
|
1960
|
-
` 3. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
|
|
1961
|
-
);
|
|
1962
|
-
}
|
|
1963
|
-
log("");
|
|
965
|
+
await relinkAndHint();
|
|
1964
966
|
}
|
|
967
|
+
console.log("");
|
|
1965
968
|
}
|
|
1966
969
|
|
|
970
|
+
// ---------------------------------------------------------------------------
|
|
971
|
+
// Labels Bootstrap — idempotently create/update all ForgeDock-managed labels
|
|
972
|
+
// ---------------------------------------------------------------------------
|
|
973
|
+
|
|
1967
974
|
/**
|
|
1968
|
-
*
|
|
1969
|
-
*
|
|
975
|
+
* Resolve the target repo for the labels command.
|
|
976
|
+
*
|
|
977
|
+
* Priority order:
|
|
978
|
+
* 1. --repo <owner/repo> passed on the CLI
|
|
979
|
+
* 2. forge.yaml → project.owner + project.repo in the current working directory
|
|
980
|
+
* 3. Returns null if neither is available (caller prints an error)
|
|
1970
981
|
*
|
|
1971
|
-
* @param {string
|
|
982
|
+
* @param {string[]} subArgs - CLI args after "labels [setup]"
|
|
983
|
+
* @returns {string|null} "owner/repo" string or null
|
|
1972
984
|
*/
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
({ setOptOut } = await import(
|
|
1979
|
-
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
1980
|
-
));
|
|
1981
|
-
} catch (err) {
|
|
1982
|
-
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
1983
|
-
process.exit(1);
|
|
985
|
+
function resolveLabelsRepo(subArgs) {
|
|
986
|
+
// 1. Explicit --repo flag
|
|
987
|
+
const repoFlagIdx = subArgs.indexOf("--repo");
|
|
988
|
+
if (repoFlagIdx !== -1 && subArgs[repoFlagIdx + 1]) {
|
|
989
|
+
return subArgs[repoFlagIdx + 1];
|
|
1984
990
|
}
|
|
1985
991
|
|
|
1986
|
-
//
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
} catch (err) {
|
|
1990
|
-
console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
|
|
1991
|
-
process.exit(1);
|
|
1992
|
-
}
|
|
1993
|
-
|
|
1994
|
-
// Create .forgedock marker file so the directory is treated as managed
|
|
1995
|
-
// even without a forge.yaml (user can run `npx forgedock init` next)
|
|
1996
|
-
const markerPath = join(targetDir, ".forgedock");
|
|
1997
|
-
if (!existsSync(markerPath)) {
|
|
992
|
+
// 2. forge.yaml in cwd
|
|
993
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
994
|
+
if (existsSync(forgeYamlPath)) {
|
|
1998
995
|
try {
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
996
|
+
const raw = readFileSync(forgeYamlPath, "utf-8");
|
|
997
|
+
const ownerMatch = raw.match(/^\s*owner:\s*["']?([^\s"'#]+)["']?/m);
|
|
998
|
+
const repoMatch = raw.match(/^\s*repo:\s*["']?([^\s"'#]+)["']?/m);
|
|
999
|
+
if (ownerMatch && repoMatch) {
|
|
1000
|
+
return `${ownerMatch[1]}/${repoMatch[1]}`;
|
|
1001
|
+
}
|
|
1002
|
+
} catch {
|
|
1003
|
+
// fall through
|
|
2003
1004
|
}
|
|
2004
|
-
} else {
|
|
2005
|
-
console.log(` ✔ .forgedock marker already present in ${targetDir}`);
|
|
2006
1005
|
}
|
|
2007
1006
|
|
|
2008
|
-
|
|
2009
|
-
console.log(` Removed from opt-out registry. Run ${CYAN}npx forgedock init${RESET} to generate forge.yaml.`);
|
|
2010
|
-
console.log("");
|
|
1007
|
+
return null;
|
|
2011
1008
|
}
|
|
2012
1009
|
|
|
2013
1010
|
/**
|
|
2014
|
-
*
|
|
2015
|
-
*
|
|
1011
|
+
* Idempotently create or update all ForgeDock-managed labels on a GitHub repo.
|
|
1012
|
+
*
|
|
1013
|
+
* Reads the canonical manifest from bin/labels.json (co-located with this
|
|
1014
|
+
* script). For each label entry, calls:
|
|
1015
|
+
*
|
|
1016
|
+
* gh label create <name> --color <hex> --description <desc> --force --repo <repo>
|
|
2016
1017
|
*
|
|
2017
|
-
*
|
|
1018
|
+
* --force makes the call idempotent: it creates the label if absent, or updates
|
|
1019
|
+
* its color and description if it already exists. Safe to re-run at any time.
|
|
1020
|
+
*
|
|
1021
|
+
* @param {string} repo - "owner/repo" string
|
|
1022
|
+
* @returns {{ created: number, failed: string[] }}
|
|
2018
1023
|
*/
|
|
2019
|
-
async function
|
|
2020
|
-
const
|
|
1024
|
+
async function labelsSetup(repo) {
|
|
1025
|
+
const manifestPath = join(__dirname, "labels.json");
|
|
2021
1026
|
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
({ setOptOut } = await import(
|
|
2025
|
-
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
2026
|
-
));
|
|
2027
|
-
} catch (err) {
|
|
2028
|
-
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
1027
|
+
if (!existsSync(manifestPath)) {
|
|
1028
|
+
console.log(`${RED}Label manifest not found: ${manifestPath}${RESET}`);
|
|
2029
1029
|
process.exit(1);
|
|
2030
1030
|
}
|
|
2031
1031
|
|
|
1032
|
+
/** @type {Array<{name: string, color: string, description: string}>} */
|
|
1033
|
+
let labels;
|
|
2032
1034
|
try {
|
|
2033
|
-
|
|
1035
|
+
labels = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
2034
1036
|
} catch (err) {
|
|
2035
|
-
console.
|
|
1037
|
+
console.log(`${RED}Failed to parse labels.json: ${err.message}${RESET}`);
|
|
2036
1038
|
process.exit(1);
|
|
2037
1039
|
}
|
|
2038
1040
|
|
|
2039
|
-
console.log(` ${GREEN}✔${RESET} ForgeDock ${YELLOW}disabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
|
|
2040
|
-
console.log(` Added to opt-out registry. The session-start hook will be silent here.`);
|
|
2041
|
-
console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
2042
1041
|
console.log("");
|
|
2043
|
-
}
|
|
1042
|
+
console.log(`${BOLD}ForgeDock Label Bootstrap${RESET}`);
|
|
1043
|
+
console.log(` Repository: ${cyan(repo)}`);
|
|
1044
|
+
console.log(` Labels: ${labels.length} managed labels`);
|
|
1045
|
+
console.log("");
|
|
2044
1046
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
* with relevant detail about what markers or registry entries were found.
|
|
2049
|
-
*
|
|
2050
|
-
* @param {string} [dir] - Directory to inspect (default: process.cwd()).
|
|
2051
|
-
*/
|
|
2052
|
-
async function status(dir) {
|
|
2053
|
-
const targetDir = resolve(dir || process.cwd());
|
|
1047
|
+
const bar = createProgressBar(labels.length, {
|
|
1048
|
+
label: " Bootstrapping labels...",
|
|
1049
|
+
});
|
|
2054
1050
|
|
|
2055
|
-
let
|
|
2056
|
-
|
|
2057
|
-
({ resolveState } = await import(
|
|
2058
|
-
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
2059
|
-
));
|
|
2060
|
-
} catch (err) {
|
|
2061
|
-
console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
|
|
2062
|
-
process.exit(1);
|
|
2063
|
-
}
|
|
1051
|
+
let created = 0;
|
|
1052
|
+
const failed = [];
|
|
2064
1053
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
1054
|
+
for (const { name, color, description } of labels) {
|
|
1055
|
+
bar.tick(1, dim(name));
|
|
1056
|
+
try {
|
|
1057
|
+
execFileSync(
|
|
1058
|
+
"gh",
|
|
1059
|
+
[
|
|
1060
|
+
"label",
|
|
1061
|
+
"create",
|
|
1062
|
+
name,
|
|
1063
|
+
"--color",
|
|
1064
|
+
color,
|
|
1065
|
+
"--description",
|
|
1066
|
+
description,
|
|
1067
|
+
"--force",
|
|
1068
|
+
"--repo",
|
|
1069
|
+
repo,
|
|
1070
|
+
],
|
|
1071
|
+
{ stdio: ["pipe", "pipe", "pipe"] },
|
|
1072
|
+
);
|
|
1073
|
+
created++;
|
|
1074
|
+
} catch {
|
|
1075
|
+
failed.push(name);
|
|
1076
|
+
// Continue — don't abort the whole bootstrap for one failure
|
|
1077
|
+
}
|
|
2071
1078
|
}
|
|
2072
1079
|
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
console.log(` Directory: ${CYAN}${targetDir}${RESET}`);
|
|
2079
|
-
console.log("");
|
|
1080
|
+
bar.done(
|
|
1081
|
+
failed.length === 0
|
|
1082
|
+
? `${green("✔")} Bootstrapped ${created}/${labels.length} labels on ${cyan(repo)}`
|
|
1083
|
+
: `${yellow("⚠")} Bootstrapped ${created}/${labels.length} labels — ${failed.length} failed`,
|
|
1084
|
+
);
|
|
2080
1085
|
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
case "managed-optedout":
|
|
2092
|
-
console.log(` State: ${YELLOW}managed-optedout${RESET}`);
|
|
2093
|
-
console.log(` ForgeDock is installed but opted out for this directory.`);
|
|
2094
|
-
if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
|
|
2095
|
-
if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
|
|
2096
|
-
console.log(` The session-start hook is silent here (opt-out registry entry present).`);
|
|
2097
|
-
console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
2098
|
-
break;
|
|
2099
|
-
|
|
2100
|
-
case "unmanaged":
|
|
2101
|
-
default:
|
|
2102
|
-
console.log(` State: ${RED}unmanaged${RESET}`);
|
|
2103
|
-
console.log(` ForgeDock is not active in this directory.`);
|
|
2104
|
-
console.log(` No forge.yaml or .forgedock marker found.`);
|
|
2105
|
-
console.log(` To enable: ${CYAN}npx forgedock enable${RESET}`);
|
|
2106
|
-
console.log(` To set up: ${CYAN}npx forgedock init${RESET}`);
|
|
2107
|
-
break;
|
|
1086
|
+
if (failed.length > 0) {
|
|
1087
|
+
console.log("");
|
|
1088
|
+
console.log(` ${RED}Failed labels:${RESET}`);
|
|
1089
|
+
for (const name of failed) {
|
|
1090
|
+
console.log(` ${dim("•")} ${name}`);
|
|
1091
|
+
}
|
|
1092
|
+
console.log(
|
|
1093
|
+
`\n Run ${cyan("gh auth status")} to verify GitHub authentication.`,
|
|
1094
|
+
);
|
|
2108
1095
|
}
|
|
1096
|
+
|
|
2109
1097
|
console.log("");
|
|
1098
|
+
return { created, failed };
|
|
2110
1099
|
}
|
|
2111
1100
|
|
|
2112
1101
|
/**
|
|
2113
1102
|
* Run installation health checks and report pass/fail for each.
|
|
2114
1103
|
*
|
|
2115
1104
|
* Checks (in order):
|
|
2116
|
-
* 1.
|
|
2117
|
-
* 2.
|
|
2118
|
-
* 3.
|
|
2119
|
-
* 4.
|
|
2120
|
-
* 5.
|
|
2121
|
-
* 6.
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
1105
|
+
* 1. Command symlinks — TARGET_DIR entries point to correct COMMANDS_DIR files
|
|
1106
|
+
* 2. gh CLI installed and authenticated
|
|
1107
|
+
* 3. git configured (user.name + user.email)
|
|
1108
|
+
* 4. forge.yaml exists and has required keys
|
|
1109
|
+
* 5. SessionStart hook registered in ~/.claude/settings.json
|
|
1110
|
+
* 6. CLAUDE.md legacy managed block (cwd) — informational only; the
|
|
1111
|
+
* journey install never injects one, session context comes from the
|
|
1112
|
+
* SessionStart hook
|
|
1113
|
+
* 7. Required workflow labels exist on the GitHub repo (needs forge.yaml + gh auth)
|
|
1114
|
+
* 8. FORGE_HOME environment variable — informational only; not required
|
|
1115
|
+
* by the journey install
|
|
1116
|
+
* 9. Playwright MCP registered in ~/.claude/mcp_servers.json (advisory warn — required for /qa-sweep)
|
|
1117
|
+
* 10. yq installed (hard dependency for forge.yaml parsing)
|
|
1118
|
+
* 11. Claude Code installed and version compatible (advisory warn if not on PATH)
|
|
2125
1119
|
*
|
|
2126
|
-
*
|
|
1120
|
+
* Returns 0 if all checks pass (warnings allowed), 1 if any hard check fails.
|
|
2127
1121
|
*/
|
|
2128
1122
|
async function doctor() {
|
|
2129
1123
|
console.log("");
|
|
2130
1124
|
console.log(`${BOLD}ForgeDock Doctor${RESET} — Installation Health Check`);
|
|
2131
1125
|
console.log("");
|
|
2132
1126
|
|
|
1127
|
+
const { targetDir: TARGET_DIR } = detectInstallPaths();
|
|
1128
|
+
console.log(` Mode: global (~/.claude)`);
|
|
1129
|
+
console.log("");
|
|
1130
|
+
|
|
2133
1131
|
let failures = 0;
|
|
2134
1132
|
let warnings = 0;
|
|
2135
1133
|
|
|
@@ -2173,27 +1171,11 @@ async function doctor() {
|
|
|
2173
1171
|
const brokenLinks = [];
|
|
2174
1172
|
|
|
2175
1173
|
try {
|
|
2176
|
-
// Collect
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
2182
|
-
} catch {
|
|
2183
|
-
return results;
|
|
2184
|
-
}
|
|
2185
|
-
for (const entry of entries) {
|
|
2186
|
-
const full = join(dir, entry.name);
|
|
2187
|
-
if (entry.isDirectory()) {
|
|
2188
|
-
results.push(...(await collectMd(full)));
|
|
2189
|
-
} else if (entry.name.endsWith(".md")) {
|
|
2190
|
-
results.push(full);
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
return results;
|
|
2194
|
-
};
|
|
2195
|
-
|
|
2196
|
-
const sourceFiles = await collectMd(COMMANDS_DIR);
|
|
1174
|
+
// Collect installable .md files from COMMANDS_DIR using the same tier filter
|
|
1175
|
+
// applied by forge() / findMarkdownFiles() — doctor() must validate the filtered
|
|
1176
|
+
// install surface, not the raw directory walk, or it would report false failures
|
|
1177
|
+
// for 'internal' specs that were deliberately excluded from the install.
|
|
1178
|
+
const sourceFiles = await findMarkdownFiles(COMMANDS_DIR);
|
|
2197
1179
|
|
|
2198
1180
|
for (const src of sourceFiles) {
|
|
2199
1181
|
const rel = relative(COMMANDS_DIR, src);
|
|
@@ -2210,7 +1192,7 @@ async function doctor() {
|
|
|
2210
1192
|
}
|
|
2211
1193
|
} else {
|
|
2212
1194
|
// Regular file — copy-mode install (Windows without Developer Mode).
|
|
2213
|
-
//
|
|
1195
|
+
// forge() falls back to copyFile() on EPERM/EACCES, so a regular
|
|
2214
1196
|
// file is valid as long as its content matches the source. <!-- Added: forge#1174 -->
|
|
2215
1197
|
try {
|
|
2216
1198
|
const srcContent = readFileSync(src, "utf-8");
|
|
@@ -2388,13 +1370,53 @@ async function doctor() {
|
|
|
2388
1370
|
}
|
|
2389
1371
|
}
|
|
2390
1372
|
|
|
2391
|
-
// ── Check
|
|
1373
|
+
// ── Check 5b: 2026 feature-gated install capabilities ─────────────────────
|
|
1374
|
+
{
|
|
1375
|
+
const detectedVer = detectClaudeVersion();
|
|
1376
|
+
const breakpoints = loadBreakpoints(FORGE_HOME);
|
|
1377
|
+
if (detectedVer === null) {
|
|
1378
|
+
warn(
|
|
1379
|
+
"Version-gated features",
|
|
1380
|
+
"claude CLI not detected — cannot assess 2026 feature availability. Install or add claude to PATH.",
|
|
1381
|
+
);
|
|
1382
|
+
} else {
|
|
1383
|
+
const vStr = Array.isArray(detectedVer) ? detectedVer.join(".") : String(detectedVer);
|
|
1384
|
+
const featureChecks = [
|
|
1385
|
+
["effort levels", "effort_levels", "2.1.154"],
|
|
1386
|
+
["SubagentStop context injection", "stop_subagent_stop_additional_context", "2.1.163"],
|
|
1387
|
+
["Tool(param:value) permission rules", "tool_param_value_permission_rules", "2.1.178"],
|
|
1388
|
+
["skills/commands merge", "skills_commands_merge", "2.1.196"],
|
|
1389
|
+
["Sonnet 5 default + 1M context", "sonnet_5_default_1m_context", "2.1.197"],
|
|
1390
|
+
];
|
|
1391
|
+
const unavailable = featureChecks
|
|
1392
|
+
.filter(([, key]) => !hasFeature(breakpoints, key, vStr))
|
|
1393
|
+
.map(([label, , minVer]) => `${label} (requires v${minVer}+)`);
|
|
1394
|
+
if (unavailable.length === 0) {
|
|
1395
|
+
pass(
|
|
1396
|
+
"Version-gated features",
|
|
1397
|
+
`v${vStr} — all 2026 features available`,
|
|
1398
|
+
);
|
|
1399
|
+
} else {
|
|
1400
|
+
warn(
|
|
1401
|
+
"Version-gated features",
|
|
1402
|
+
`v${vStr} — unavailable: ${unavailable.join(", ")}. Update Claude Code to unlock.`,
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// ── Check 6: CLAUDE.md legacy managed block (cwd, informational) ──────────
|
|
1409
|
+
// The journey-based install never injects a CLAUDE.md block — session
|
|
1410
|
+
// context comes from the SessionStart hook (see Check 5). A block's absence
|
|
1411
|
+
// is therefore normal and PASSes. Its presence means a legacy block from an
|
|
1412
|
+
// older install is still around; that's informational, not a failure —
|
|
1413
|
+
// `npx forgedock uninstall` removes it.
|
|
2392
1414
|
{
|
|
2393
1415
|
const claudeMdPath = join(process.cwd(), "CLAUDE.md");
|
|
2394
1416
|
if (!existsSync(claudeMdPath)) {
|
|
2395
|
-
|
|
2396
|
-
"CLAUDE.md
|
|
2397
|
-
|
|
1417
|
+
pass(
|
|
1418
|
+
"CLAUDE.md legacy block",
|
|
1419
|
+
"no CLAUDE.md found — session context comes from the SessionStart hook",
|
|
2398
1420
|
);
|
|
2399
1421
|
} else {
|
|
2400
1422
|
try {
|
|
@@ -2403,15 +1425,18 @@ async function doctor() {
|
|
|
2403
1425
|
content.includes(CLAUDE_BLOCK_BEGIN) &&
|
|
2404
1426
|
content.includes(CLAUDE_BLOCK_END)
|
|
2405
1427
|
) {
|
|
2406
|
-
|
|
1428
|
+
warn(
|
|
1429
|
+
"CLAUDE.md legacy block",
|
|
1430
|
+
`legacy ForgeDock block found in ${claudeMdPath} — uninstall removes it; the SessionStart hook has replaced it`,
|
|
1431
|
+
);
|
|
2407
1432
|
} else {
|
|
2408
|
-
|
|
2409
|
-
"CLAUDE.md
|
|
2410
|
-
|
|
1433
|
+
pass(
|
|
1434
|
+
"CLAUDE.md legacy block",
|
|
1435
|
+
"no legacy ForgeDock block — session context comes from the SessionStart hook",
|
|
2411
1436
|
);
|
|
2412
1437
|
}
|
|
2413
1438
|
} catch (err) {
|
|
2414
|
-
fail("CLAUDE.md
|
|
1439
|
+
fail("CLAUDE.md legacy block", `Cannot read CLAUDE.md: ${err.message}`);
|
|
2415
1440
|
}
|
|
2416
1441
|
}
|
|
2417
1442
|
}
|
|
@@ -2478,7 +1503,7 @@ async function doctor() {
|
|
|
2478
1503
|
} else {
|
|
2479
1504
|
fail(
|
|
2480
1505
|
"GitHub workflow labels",
|
|
2481
|
-
`
|
|
1506
|
+
`Create the missing labels manually, e.g.: gh label create "<name>" --color <hex> --description "<desc>" -R ${forgeOwner}/${forgeRepo} (see bin/labels.json for definitions; missing: ${missingLabels.join(", ")})`,
|
|
2482
1507
|
);
|
|
2483
1508
|
}
|
|
2484
1509
|
}
|
|
@@ -2486,15 +1511,19 @@ async function doctor() {
|
|
|
2486
1511
|
}
|
|
2487
1512
|
}
|
|
2488
1513
|
|
|
2489
|
-
// ── Check 8: FORGE_HOME environment variable
|
|
1514
|
+
// ── Check 8: FORGE_HOME environment variable (informational) ──────────────
|
|
1515
|
+
// The journey-based install never exports FORGE_HOME — it isn't required
|
|
1516
|
+
// for the pipeline to run. It only affects orchestrate's legacy
|
|
1517
|
+
// classify-lane script fallback (tracked as a follow-up), so a missing
|
|
1518
|
+
// value is a warning, not a failure.
|
|
2490
1519
|
{
|
|
2491
1520
|
const envForgeHome = process.env.FORGE_HOME;
|
|
2492
1521
|
if (envForgeHome) {
|
|
2493
1522
|
pass("FORGE_HOME env var", `set to "${envForgeHome}"`);
|
|
2494
1523
|
} else {
|
|
2495
|
-
|
|
1524
|
+
warn(
|
|
2496
1525
|
"FORGE_HOME env var",
|
|
2497
|
-
|
|
1526
|
+
"not exported — only affects orchestrate's legacy classify-lane script fallback (tracked as a follow-up). Not required for normal use.",
|
|
2498
1527
|
);
|
|
2499
1528
|
}
|
|
2500
1529
|
}
|
|
@@ -2668,33 +1697,47 @@ async function doctor() {
|
|
|
2668
1697
|
}
|
|
2669
1698
|
console.log("");
|
|
2670
1699
|
|
|
2671
|
-
|
|
2672
|
-
process.exit(1);
|
|
2673
|
-
}
|
|
1700
|
+
return failures > 0 ? 1 : 0;
|
|
2674
1701
|
}
|
|
2675
1702
|
|
|
2676
1703
|
function help() {
|
|
2677
|
-
//
|
|
1704
|
+
// splash() already rendered the logo to stderr — the command table itself
|
|
1705
|
+
// goes to stdout (help output is the requested artifact).
|
|
2678
1706
|
const commands = [
|
|
2679
1707
|
["Command", "Description"],
|
|
2680
|
-
["npx forgedock", "
|
|
2681
|
-
["npx forgedock install", "Install commands"],
|
|
1708
|
+
["npx forgedock", "Guided setup: install commands + configure repo (default)"],
|
|
1709
|
+
["npx forgedock install", "Install commands to ~/.claude/"],
|
|
2682
1710
|
["npx forgedock init", "Generate forge.yaml config for your project"],
|
|
2683
1711
|
["npx forgedock init --minimal", "Generate a minimal forge.yaml (required sections only)"],
|
|
2684
|
-
["npx forgedock
|
|
2685
|
-
["npx forgedock
|
|
1712
|
+
["npx forgedock enable [dir]", "Activate ForgeDock in a directory"],
|
|
1713
|
+
["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
|
|
1714
|
+
["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
|
|
2686
1715
|
["npx forgedock run <cmd> [args]", "Run a command headlessly via the Anthropic API"],
|
|
1716
|
+
["npx forgedock run-issue <issue>", "Drive one issue through the durable engine"],
|
|
1717
|
+
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
2687
1718
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
2688
|
-
["npx forgedock
|
|
2689
|
-
["npx forgedock
|
|
2690
|
-
["npx forgedock
|
|
1719
|
+
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
1720
|
+
["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
|
|
1721
|
+
["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
|
|
2691
1722
|
["npx forgedock doctor", "Check installation health"],
|
|
1723
|
+
["npx forgedock update", "Pull latest & reinstall"],
|
|
1724
|
+
["npx forgedock uninstall", "Remove commands"],
|
|
2692
1725
|
["npx forgedock help", "Show this help"],
|
|
2693
1726
|
];
|
|
1727
|
+
const flagRows = [
|
|
1728
|
+
["Flag", "Description"],
|
|
1729
|
+
["--fast", "Skip animation/motion"],
|
|
1730
|
+
["--manual", "Plain text prompts instead of the review screen (init)"],
|
|
1731
|
+
["--verbose", "Show detection sources for every field (init)"],
|
|
1732
|
+
["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
|
|
1733
|
+
];
|
|
2694
1734
|
|
|
2695
|
-
process.
|
|
1735
|
+
process.stdout.write(
|
|
2696
1736
|
box(table(commands, { header: true }), { title: "Usage" }) + "\n",
|
|
2697
1737
|
);
|
|
1738
|
+
process.stdout.write(
|
|
1739
|
+
box(table(flagRows, { header: true }), { title: "Flags" }) + "\n",
|
|
1740
|
+
);
|
|
2698
1741
|
}
|
|
2699
1742
|
|
|
2700
1743
|
/**
|
|
@@ -2703,7 +1746,7 @@ function help() {
|
|
|
2703
1746
|
*
|
|
2704
1747
|
* Flags:
|
|
2705
1748
|
* --dry-run Preview the assembled prompt + tool plan; no API call.
|
|
2706
|
-
* --model <id> Override the model (default: claude-sonnet-
|
|
1749
|
+
* --model <id> Override the model (default: claude-sonnet-5 or $FORGEDOCK_MODEL).
|
|
2707
1750
|
* --max-iterations <n> Bound the tool-use loop (default: 50).
|
|
2708
1751
|
*
|
|
2709
1752
|
* The live loop requires ANTHROPIC_API_KEY and the optional @anthropic-ai/sdk
|
|
@@ -2716,7 +1759,7 @@ function help() {
|
|
|
2716
1759
|
* falling back to the platform default shell otherwise.
|
|
2717
1760
|
*/
|
|
2718
1761
|
async function run() {
|
|
2719
|
-
const runArgs =
|
|
1762
|
+
const runArgs = restArgs;
|
|
2720
1763
|
let dryRun = false;
|
|
2721
1764
|
let model;
|
|
2722
1765
|
let maxIterations;
|
|
@@ -2789,7 +1832,7 @@ async function demo() {
|
|
|
2789
1832
|
try {
|
|
2790
1833
|
const result = await runDemo({
|
|
2791
1834
|
forgeHome: FORGE_HOME,
|
|
2792
|
-
args:
|
|
1835
|
+
args: restArgs,
|
|
2793
1836
|
cwd: process.cwd(),
|
|
2794
1837
|
});
|
|
2795
1838
|
if (result && result.status === "error") {
|
|
@@ -2801,15 +1844,281 @@ async function demo() {
|
|
|
2801
1844
|
}
|
|
2802
1845
|
}
|
|
2803
1846
|
|
|
2804
|
-
|
|
1847
|
+
/**
|
|
1848
|
+
* `forgedock watch` — live per-agent orchestration view
|
|
1849
|
+
*
|
|
1850
|
+
* Polls GitHub every 5 seconds for in-flight issues (those with workflow:*
|
|
1851
|
+
* labels) and renders a per-agent table with issue#, title, phase, elapsed
|
|
1852
|
+
* time, and staleness status. All data is sourced from FORGE:HEARTBEAT
|
|
1853
|
+
* comments written by the pipeline at each phase boundary.
|
|
1854
|
+
*
|
|
1855
|
+
* Stalled agents (no HEARTBEAT update within pipeline.stall_timeout_minutes)
|
|
1856
|
+
* are highlighted in yellow.
|
|
1857
|
+
*
|
|
1858
|
+
* Run: npx forgedock watch [--repo owner/repo]
|
|
1859
|
+
* Exit: Ctrl+C
|
|
1860
|
+
*/
|
|
1861
|
+
async function watch() {
|
|
1862
|
+
const POLL_INTERVAL_MS = 5000;
|
|
1863
|
+
const USE_ANSI_WATCH = process.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
1864
|
+
|
|
1865
|
+
// ── Resolve repo ──────────────────────────────────────────────────────────
|
|
1866
|
+
const watchRepo = resolveLabelsRepo(restArgs);
|
|
1867
|
+
if (!watchRepo) {
|
|
1868
|
+
process.stderr.write(
|
|
1869
|
+
`${RED}No repository found.${RESET}\n` +
|
|
1870
|
+
` Run from a directory with ${cyan("forge.yaml")}, or pass ${cyan("--repo owner/repo")}.\n`,
|
|
1871
|
+
);
|
|
1872
|
+
process.exitCode = 1;
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// ── Validate gh CLI auth ───────────────────────────────────────────────────
|
|
1877
|
+
try {
|
|
1878
|
+
execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"] });
|
|
1879
|
+
} catch {
|
|
1880
|
+
process.stderr.write(
|
|
1881
|
+
`${RED}gh CLI is not authenticated.${RESET}\n` +
|
|
1882
|
+
` Fix: run ${cyan("gh auth login")} then retry.\n`,
|
|
1883
|
+
);
|
|
1884
|
+
process.exitCode = 1;
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// ── Resolve stall timeout ─────────────────────────────────────────────────
|
|
1889
|
+
let stallMinutes = 15;
|
|
1890
|
+
const forgeYamlPath = join(process.cwd(), "forge.yaml");
|
|
1891
|
+
if (existsSync(forgeYamlPath)) {
|
|
1892
|
+
try {
|
|
1893
|
+
const raw = readFileSync(forgeYamlPath, "utf-8");
|
|
1894
|
+
const m = raw.match(/^\s*stall_timeout_minutes:\s*(\d+)/m);
|
|
1895
|
+
if (m) stallMinutes = parseInt(m[1], 10);
|
|
1896
|
+
} catch { /* ignore */ }
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
1900
|
+
function extractPhase(heartbeatBody) {
|
|
1901
|
+
const m = heartbeatBody.match(/\*\*Phase\*\*:\s*(.+)/);
|
|
1902
|
+
return m ? m[1].trim() : "unknown";
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function extractTimestamp(heartbeatBody) {
|
|
1906
|
+
const m = heartbeatBody.match(/\*\*Timestamp\*\*:\s*(\S+)/);
|
|
1907
|
+
return m ? m[1].trim() : null;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function elapsedMin(isoTimestamp) {
|
|
1911
|
+
if (!isoTimestamp) return null;
|
|
1912
|
+
const ms = Date.now() - new Date(isoTimestamp).getTime();
|
|
1913
|
+
return Math.floor(ms / 60000);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function elapsedStr(minutes) {
|
|
1917
|
+
if (minutes === null) return "—";
|
|
1918
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1919
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// ── Cleanup on exit ────────────────────────────────────────────────────────
|
|
1923
|
+
let intervalHandle;
|
|
1924
|
+
function cleanup() {
|
|
1925
|
+
if (intervalHandle) clearTimeout(intervalHandle);
|
|
1926
|
+
if (USE_ANSI_WATCH) {
|
|
1927
|
+
process.stdout.write("\x1b[?25h"); // show cursor
|
|
1928
|
+
}
|
|
1929
|
+
process.exit(0);
|
|
1930
|
+
}
|
|
1931
|
+
process.on("SIGINT", cleanup);
|
|
1932
|
+
process.on("SIGTERM", cleanup);
|
|
1933
|
+
|
|
1934
|
+
if (USE_ANSI_WATCH) {
|
|
1935
|
+
process.stdout.write("\x1b[?25l"); // hide cursor
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// ── Render loop ────────────────────────────────────────────────────────────
|
|
1939
|
+
const WORKFLOW_LABELS = [
|
|
1940
|
+
"workflow:investigating",
|
|
1941
|
+
"workflow:ready-to-build",
|
|
1942
|
+
"workflow:building",
|
|
1943
|
+
"workflow:in-review",
|
|
1944
|
+
"needs-human",
|
|
1945
|
+
];
|
|
1946
|
+
|
|
1947
|
+
async function render() {
|
|
1948
|
+
const rows = [["#", "Title", "Phase", "Elapsed", "Status"]];
|
|
1949
|
+
let anyIssues = false;
|
|
1950
|
+
|
|
1951
|
+
for (const label of WORKFLOW_LABELS) {
|
|
1952
|
+
let issueJson;
|
|
1953
|
+
try {
|
|
1954
|
+
// Use execFileSync with argument array to avoid shell injection via watchRepo
|
|
1955
|
+
issueJson = execFileSync(
|
|
1956
|
+
"gh",
|
|
1957
|
+
["issue", "list", "-R", watchRepo, "--state", "open",
|
|
1958
|
+
"--label", label, "--limit", "30", "--json", "number,title"],
|
|
1959
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
1960
|
+
);
|
|
1961
|
+
} catch {
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
let issues;
|
|
1966
|
+
try { issues = JSON.parse(issueJson); } catch { continue; }
|
|
1967
|
+
|
|
1968
|
+
for (const issue of issues) {
|
|
1969
|
+
anyIssues = true;
|
|
1970
|
+
let phase = label.replace("workflow:", "");
|
|
1971
|
+
let elapsed = null;
|
|
1972
|
+
|
|
1973
|
+
// Fetch latest FORGE:HEARTBEAT comment for phase + timestamp
|
|
1974
|
+
try {
|
|
1975
|
+
const commentsJson = execFileSync(
|
|
1976
|
+
"gh",
|
|
1977
|
+
["api", `repos/${watchRepo}/issues/${issue.number}/comments`,
|
|
1978
|
+
"--jq", "[.[] | select(.body | contains(\"FORGE:HEARTBEAT\"))] | last | .body // \"\""],
|
|
1979
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
1980
|
+
).trim();
|
|
1981
|
+
if (commentsJson && commentsJson !== '""' && commentsJson !== "") {
|
|
1982
|
+
const body = commentsJson.replace(/^"|"$/g, "").replace(/\\n/g, "\n");
|
|
1983
|
+
phase = extractPhase(body) || phase;
|
|
1984
|
+
const ts = extractTimestamp(body);
|
|
1985
|
+
elapsed = elapsedMin(ts);
|
|
1986
|
+
}
|
|
1987
|
+
} catch { /* use label-derived phase */ }
|
|
1988
|
+
|
|
1989
|
+
const isStalled = elapsed !== null && elapsed >= stallMinutes;
|
|
1990
|
+
const isBlocked = label === "needs-human";
|
|
1991
|
+
const status = isBlocked ? "BLOCKED" : isStalled ? "STALLED" : "running";
|
|
1992
|
+
|
|
1993
|
+
const titleTrunc = issue.title.length > 38 ? issue.title.slice(0, 35) + "..." : issue.title;
|
|
1994
|
+
const phaseShort = phase.length > 30 ? phase.slice(0, 27) + "..." : phase;
|
|
1995
|
+
|
|
1996
|
+
const statusColored = USE_ANSI_WATCH
|
|
1997
|
+
? (isBlocked ? `\x1b[31m${status}\x1b[0m` : isStalled ? `\x1b[33m${status}\x1b[0m` : `\x1b[32m${status}\x1b[0m`)
|
|
1998
|
+
: status;
|
|
1999
|
+
|
|
2000
|
+
const titleColored = USE_ANSI_WATCH && (isStalled || isBlocked)
|
|
2001
|
+
? `\x1b[33m${titleTrunc}\x1b[0m`
|
|
2002
|
+
: titleTrunc;
|
|
2003
|
+
|
|
2004
|
+
rows.push([
|
|
2005
|
+
`#${issue.number}`,
|
|
2006
|
+
titleColored,
|
|
2007
|
+
phaseShort,
|
|
2008
|
+
elapsedStr(elapsed),
|
|
2009
|
+
statusColored,
|
|
2010
|
+
]);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
|
2015
|
+
|
|
2016
|
+
if (USE_ANSI_WATCH) {
|
|
2017
|
+
process.stdout.write("\x1b[2J\x1b[H"); // clear screen + home
|
|
2018
|
+
} else {
|
|
2019
|
+
process.stdout.write(`\n${"─".repeat(60)}\n`);
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
process.stdout.write(`${BOLD}ForgeDock Watch${RESET} — ${dim(watchRepo)} ${dim(now)}\n\n`);
|
|
2023
|
+
|
|
2024
|
+
if (!anyIssues || rows.length === 1) {
|
|
2025
|
+
process.stdout.write(` ${dim("No in-flight issues. All quiet.")}\n`);
|
|
2026
|
+
} else {
|
|
2027
|
+
process.stdout.write(table(rows, { header: true }));
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
if (USE_ANSI_WATCH) {
|
|
2031
|
+
process.stdout.write(`\n${dim("Refreshing every 5s — Ctrl+C to exit")}\n`);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// Self-scheduling render loop — each render awaits completion before
|
|
2036
|
+
// scheduling the next, preventing overlapping cycles when GitHub API
|
|
2037
|
+
// latency exceeds the poll interval (forge#1428). The scheduled timeout is
|
|
2038
|
+
// the ONLY handle keeping the event loop alive for this command — it must
|
|
2039
|
+
// stay ref'd, or Node exits after the first render despite printing
|
|
2040
|
+
// "Refreshing every Ns — Ctrl+C to exit" (forge#1593). The only intended
|
|
2041
|
+
// exit path is SIGINT/SIGTERM → cleanup(), which restores the cursor.
|
|
2042
|
+
async function scheduleRender() {
|
|
2043
|
+
await render();
|
|
2044
|
+
intervalHandle = setTimeout(scheduleRender, POLL_INTERVAL_MS);
|
|
2045
|
+
}
|
|
2046
|
+
await scheduleRender();
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
// The journey-routed commands render their own branded marks (hero/compact);
|
|
2050
|
+
// splash() would double-brand them. Engine-surface commands, help, and
|
|
2051
|
+
// unknown-command output keep the logo.
|
|
2052
|
+
const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
2053
|
+
const KNOWN_COMMANDS = new Set([
|
|
2054
|
+
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
2055
|
+
"run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "help", "--help", "-h",
|
|
2056
|
+
]);
|
|
2057
|
+
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash();
|
|
2805
2058
|
|
|
2059
|
+
let exitCode = 0;
|
|
2806
2060
|
switch (command) {
|
|
2807
|
-
case "install":
|
|
2808
|
-
|
|
2061
|
+
case "install": {
|
|
2062
|
+
const c = ctx();
|
|
2063
|
+
// Version-gated install path (#1252): detect Claude Code version and advise
|
|
2064
|
+
// on features available or missing. Fail-open — null version skips checks.
|
|
2065
|
+
if (c.claudeVersion) {
|
|
2066
|
+
// Gate: effort levels require v2.1.154+
|
|
2067
|
+
if (!hasFeature(c.breakpoints, "effort_levels", c.claudeVersion)) {
|
|
2068
|
+
process.stderr.write(
|
|
2069
|
+
` ${YELLOW}note${RESET} Claude Code v${c.claudeVersion} detected — effort-level frontmatter requires v2.1.154+. ` +
|
|
2070
|
+
`Some orchestration features will use fallback mode.\n`
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
// Gate: tool permission rules require v2.1.178+
|
|
2074
|
+
if (!hasFeature(c.breakpoints, "tool_param_value_permission_rules", c.claudeVersion)) {
|
|
2075
|
+
process.stderr.write(
|
|
2076
|
+
` ${YELLOW}note${RESET} Claude Code v${c.claudeVersion} detected — Tool(param:value) permission rules require v2.1.178+. ` +
|
|
2077
|
+
`Hook-based enforcement (#1250) will not be installed.\n`
|
|
2078
|
+
);
|
|
2079
|
+
} else {
|
|
2080
|
+
// v2.1.178+ supports permission rules — suggest opt-in rules for pipeline safety.
|
|
2081
|
+
process.stderr.write(
|
|
2082
|
+
` ${CYAN}tip${RESET} Claude Code v${c.claudeVersion} supports permission rules. ` +
|
|
2083
|
+
`Consider adding to ~/.claude/settings.json:\n` +
|
|
2084
|
+
` { "permissions": { "deny": ["Bash(git push origin main*)"] } }\n` +
|
|
2085
|
+
` This prevents accidental direct pushes to main.\n`
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
|
|
2090
|
+
statusScreen(c);
|
|
2091
|
+
} else {
|
|
2092
|
+
exitCode = await runJourney(c);
|
|
2093
|
+
}
|
|
2809
2094
|
break;
|
|
2095
|
+
}
|
|
2810
2096
|
case "init":
|
|
2811
|
-
await
|
|
2097
|
+
exitCode = await initFlow(ctx());
|
|
2098
|
+
break;
|
|
2099
|
+
case "enable": {
|
|
2100
|
+
const c = ctx();
|
|
2101
|
+
const dir = positional[1] ? resolve(positional[1]) : c.cwd;
|
|
2102
|
+
await setOptOut(dir, false);
|
|
2103
|
+
if (!existsSync(join(dir, "forge.yaml")) && !existsSync(join(dir, ".forgedock"))) {
|
|
2104
|
+
writeFileSync(join(dir, ".forgedock"), "", "utf-8");
|
|
2105
|
+
}
|
|
2106
|
+
c.stdout.write(`\n ForgeDock enabled in ${dir}.\n\n`);
|
|
2107
|
+
break;
|
|
2108
|
+
}
|
|
2109
|
+
case "disable": {
|
|
2110
|
+
const c = ctx();
|
|
2111
|
+
const dir = positional[1] ? resolve(positional[1]) : c.cwd;
|
|
2112
|
+
await setOptOut(dir, true);
|
|
2113
|
+
c.stdout.write(`\n ForgeDock disabled in ${dir}. Re-enable: npx forgedock enable\n\n`);
|
|
2114
|
+
break;
|
|
2115
|
+
}
|
|
2116
|
+
case "status": {
|
|
2117
|
+
const c = ctx();
|
|
2118
|
+
if (positional[1]) c.cwd = resolve(positional[1]);
|
|
2119
|
+
statusScreen(c);
|
|
2812
2120
|
break;
|
|
2121
|
+
}
|
|
2813
2122
|
case "uninstall":
|
|
2814
2123
|
await uninstall();
|
|
2815
2124
|
break;
|
|
@@ -2819,28 +2128,75 @@ switch (command) {
|
|
|
2819
2128
|
case "run":
|
|
2820
2129
|
await run();
|
|
2821
2130
|
break;
|
|
2131
|
+
case "run-issue": {
|
|
2132
|
+
const { runFromCli } = await import("./engine-cli.mjs");
|
|
2133
|
+
try {
|
|
2134
|
+
await runFromCli(restArgs);
|
|
2135
|
+
} catch (err) {
|
|
2136
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
2137
|
+
exitCode = 1;
|
|
2138
|
+
}
|
|
2139
|
+
break;
|
|
2140
|
+
}
|
|
2141
|
+
case "resume-stalled": {
|
|
2142
|
+
const { resumeStalledFromCli } = await import("./engine-cli.mjs");
|
|
2143
|
+
try {
|
|
2144
|
+
const result = await resumeStalledFromCli(restArgs);
|
|
2145
|
+
if (result && result.failed && result.failed.length > 0) exitCode = 1;
|
|
2146
|
+
} catch (err) {
|
|
2147
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
2148
|
+
exitCode = 1;
|
|
2149
|
+
}
|
|
2150
|
+
break;
|
|
2151
|
+
}
|
|
2822
2152
|
case "demo":
|
|
2823
2153
|
await demo();
|
|
2824
2154
|
break;
|
|
2825
|
-
case "
|
|
2826
|
-
await
|
|
2155
|
+
case "doctor":
|
|
2156
|
+
exitCode = await doctor();
|
|
2827
2157
|
break;
|
|
2828
|
-
case "
|
|
2829
|
-
await
|
|
2158
|
+
case "report": {
|
|
2159
|
+
const { runReport } = await import("./report.mjs");
|
|
2160
|
+
await runReport(restArgs);
|
|
2830
2161
|
break;
|
|
2831
|
-
|
|
2832
|
-
|
|
2162
|
+
}
|
|
2163
|
+
case "watch":
|
|
2164
|
+
await watch();
|
|
2833
2165
|
break;
|
|
2834
|
-
case "
|
|
2835
|
-
|
|
2166
|
+
case "labels": {
|
|
2167
|
+
const subcommand = positional[1];
|
|
2168
|
+
if (!subcommand || subcommand === "setup" || subcommand.startsWith("--")) {
|
|
2169
|
+
const repo = resolveLabelsRepo(restArgs);
|
|
2170
|
+
if (!repo) {
|
|
2171
|
+
console.log(
|
|
2172
|
+
`${RED}No repository specified.${RESET}\n` +
|
|
2173
|
+
` Pass ${cyan("--repo owner/repo")} or run from a directory with ${cyan("forge.yaml")}.`,
|
|
2174
|
+
);
|
|
2175
|
+
exitCode = 1;
|
|
2176
|
+
} else {
|
|
2177
|
+
await labelsSetup(repo);
|
|
2178
|
+
}
|
|
2179
|
+
} else {
|
|
2180
|
+
console.log(`${RED}Unknown labels subcommand: ${subcommand}${RESET}`);
|
|
2181
|
+
console.log(
|
|
2182
|
+
`Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo]${RESET}`,
|
|
2183
|
+
);
|
|
2184
|
+
exitCode = 1;
|
|
2185
|
+
}
|
|
2836
2186
|
break;
|
|
2187
|
+
}
|
|
2837
2188
|
case "help":
|
|
2838
2189
|
case "--help":
|
|
2839
2190
|
case "-h":
|
|
2840
2191
|
help();
|
|
2841
2192
|
break;
|
|
2842
2193
|
default:
|
|
2843
|
-
|
|
2194
|
+
console.log(`${RED}Unknown command: ${command}${RESET}`);
|
|
2844
2195
|
help();
|
|
2845
|
-
|
|
2196
|
+
exitCode = 1;
|
|
2846
2197
|
}
|
|
2198
|
+
// Natural exit: set the code and let the event loop drain so stdout is never
|
|
2199
|
+
// truncated (spinner timers are unref'd; SIGINT listeners are removed).
|
|
2200
|
+
// Subcommands (run/demo) may set process.exitCode themselves — never clobber
|
|
2201
|
+
// a failure they recorded with a success code from the router.
|
|
2202
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|