forgedock 1.1.11 → 1.3.0
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 +9 -3
- package/bin/cli-spawn-shared.mjs +25 -0
- package/bin/engine/phases.mjs +178 -28
- package/bin/engine/state.mjs +39 -7
- package/bin/engine-cli.mjs +85 -3
- package/bin/engine.mjs +71 -8
- package/bin/forgedock.mjs +322 -20
- package/bin/hooks/pre-tool-use.mjs +345 -10
- package/bin/init-enrich-api.mjs +12 -4
- package/bin/init-enrich-cli.mjs +328 -0
- package/bin/init-enrich.mjs +137 -0
- package/bin/journey.mjs +116 -15
- package/bin/runner.mjs +653 -10
- package/commands/adopt.md +1 -1
- package/commands/analytics.md +10 -7
- package/commands/audit-agents.md +29 -6
- package/commands/audit.md +9 -20
- package/commands/autopilot.md +52 -78
- package/commands/changelog.md +1 -1
- package/commands/ci-audit.md +19 -10
- package/commands/cleanup.md +1 -1
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-info.md +1 -1
- package/commands/deploy-pr.md +1 -1
- package/commands/design-bench.md +1 -1
- package/commands/design-render-critique-loop.md +1 -1
- package/commands/diagnose.md +1 -1
- package/commands/forge-stats.md +6 -5
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +9 -6
- package/commands/incident-response.md +15 -5
- package/commands/issue.md +302 -15
- package/commands/milestone.md +45 -18
- package/commands/optimize.md +1 -1
- package/commands/orchestrate/phase-1-resolve.md +20 -5
- package/commands/orchestrate/phase-4-execution.md +8 -1
- package/commands/orchestrate/phase-5-cleanup.md +43 -2
- package/commands/orchestrate/phase-6-report.md +1 -0
- package/commands/orchestrate.md +1 -1
- package/commands/pipeline-health/phase-4-proposals.md +40 -10
- package/commands/pipeline-health/phase-5-report.md +140 -13
- package/commands/pipeline-health.md +1 -1
- package/commands/pipeline-resume.md +1 -1
- package/commands/pipeline-status.md +1 -1
- package/commands/qa-sweep.md +27 -4
- package/commands/recover-orphans.md +1 -1
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/protocols.md +4 -0
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr-agents.md +7 -4
- package/commands/review-pr-staging.md +44 -15
- package/commands/review-pr.md +122 -28
- package/commands/rollback.md +15 -5
- package/commands/security-audit.md +14 -11
- package/commands/signal-planner.md +32 -12
- package/commands/spec-doctor.md +1 -1
- package/commands/sync-ecosystem.md +54 -11
- package/commands/test-gate.md +49 -11
- package/commands/upgrade-deps.md +518 -0
- package/commands/validate.md +16 -5
- package/commands/work-on/build/architect.md +1 -1
- package/commands/work-on/build/context.md +1 -1
- package/commands/work-on/build/implement.md +2 -2
- package/commands/work-on/build/validate.md +64 -2
- package/commands/work-on/build.md +10 -2
- package/commands/work-on/close.md +1 -1
- package/commands/work-on/decompose.md +35 -25
- package/commands/work-on/investigate.md +2 -2
- package/commands/work-on/remediate.md +1 -1
- package/commands/work-on/review.md +5 -3
- package/commands/work-on-monolithic.md +1 -1
- package/commands/work-on.md +28 -7
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CI.md +18 -0
- package/docs/CONFIG.md +41 -2
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/annotations-explained.md +2 -2
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +38 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/review-protocol.md +6 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +2 -2
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +14 -3
- package/packages/protocol/src/index.js +2 -1
- package/packages/protocol/src/parse.js +17 -8
- package/packages/protocol/src/types.js +1 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-command-side-effects.sh +261 -0
- package/scripts/check-spec-markers.sh +273 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +322 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- package/templates/github-actions/forgedock-autopilot.yml +113 -0
package/bin/runner.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* listCommands(commandsDir) → string[] (available command names)
|
|
17
17
|
* loadCommandSpec(commandsDir, name) → {path,name,content}
|
|
18
18
|
* buildSystemPrompt(spec, opts) → string
|
|
19
|
+
* buildCliSystemPrompt(spec) → string (cli-backend system prompt)
|
|
19
20
|
* buildUserMessage(name, args) → string
|
|
20
21
|
* TOOL_DEFINITIONS → object[] (Anthropic tool schemas)
|
|
21
22
|
* truncateToolResult(content) → string (cap + truncation marker)
|
|
@@ -42,11 +43,15 @@ import {
|
|
|
42
43
|
writeFileSync,
|
|
43
44
|
readdirSync,
|
|
44
45
|
mkdirSync,
|
|
46
|
+
mkdtempSync,
|
|
47
|
+
rmSync,
|
|
45
48
|
realpathSync,
|
|
46
49
|
} from "fs";
|
|
47
50
|
import { join, dirname, basename, relative, isAbsolute } from "path";
|
|
51
|
+
import os from "os";
|
|
48
52
|
import { execSync, spawnSync } from "child_process";
|
|
49
53
|
import { parseForgeYaml, resolveModelAlias } from "./forge-utils.mjs";
|
|
54
|
+
import { DEFAULT_SPAWN_MAX_BUFFER_BYTES } from "./cli-spawn-shared.mjs";
|
|
50
55
|
|
|
51
56
|
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
52
57
|
const DEFAULT_MAX_ITERATIONS = 50;
|
|
@@ -55,6 +60,94 @@ const DEFAULT_MAX_TOKENS = 16384;
|
|
|
55
60
|
// generous enough for CI steps (git clones, test suites) while bounding
|
|
56
61
|
// the worst-case hang to 5 minutes. Override via FORGEDOCK_BASH_TIMEOUT (ms).
|
|
57
62
|
const DEFAULT_BASH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
63
|
+
// Default wall-clock limit for a single `claude --print` CLI-backend
|
|
64
|
+
// invocation (issue #2003). This bounds an entire command run (the CLI's own
|
|
65
|
+
// internal tool-use loop), not one bash step, so it is deliberately larger
|
|
66
|
+
// than DEFAULT_BASH_TIMEOUT_MS. Override via FORGEDOCK_CLI_TIMEOUT_MS (ms).
|
|
67
|
+
const DEFAULT_CLI_TIMEOUT_MS = 15 * 60 * 1000;
|
|
68
|
+
// Short bound for the `claude --version` presence probe used by backend
|
|
69
|
+
// auto-detection — this must never make --dry-run (or a live run) hang, so
|
|
70
|
+
// it is far shorter than DEFAULT_CLI_TIMEOUT_MS. Mirrors the timeout already
|
|
71
|
+
// used by the `claude --version` doctor check in bin/forgedock.mjs.
|
|
72
|
+
const CLI_PROBE_TIMEOUT_MS = 5000;
|
|
73
|
+
/**
|
|
74
|
+
* Wraps a Set in a Proxy that blocks add()/delete()/clear(), so the
|
|
75
|
+
* returned collection is genuinely read-only — not just Object.frozen.
|
|
76
|
+
*
|
|
77
|
+
* Note: `Object.freeze(new Set(...))` does NOT prevent mutation. Set's
|
|
78
|
+
* mutator methods (add/delete/clear) operate on an internal [[SetData]]
|
|
79
|
+
* slot, not on the object's own enumerable properties, so Object.freeze()
|
|
80
|
+
* — which only locks down property descriptors — has no effect on them;
|
|
81
|
+
* `.add()` on a frozen Set still silently succeeds. A Proxy trapping the
|
|
82
|
+
* mutator method names is the only reliable way to make a Set read-only.
|
|
83
|
+
*
|
|
84
|
+
* Two known, intentionally-accepted deviations from a plain Set (neither
|
|
85
|
+
* is exercised by any current caller — grep confirms only `.has()` and
|
|
86
|
+
* spread/iteration are used):
|
|
87
|
+
* - `structuredClone()` throws on a Proxy (no native [[SetData]] slot to
|
|
88
|
+
* brand-check), where it would succeed on a plain Set.
|
|
89
|
+
* - Without the `constructor` passthrough below, `.constructor` would
|
|
90
|
+
* resolve to a bound wrapper rather than `Set` itself. Special-cased
|
|
91
|
+
* here so `VALID_BACKENDS.constructor === Set` still holds.
|
|
92
|
+
*/
|
|
93
|
+
function readOnlySet(values) {
|
|
94
|
+
const target = new Set(values);
|
|
95
|
+
return new Proxy(target, {
|
|
96
|
+
get(t, prop, receiver) {
|
|
97
|
+
if (prop === "add" || prop === "delete" || prop === "clear") {
|
|
98
|
+
return () => {
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
"VALID_BACKENDS is read-only and must not be mutated.",
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// Return the real Set constructor, not a bound wrapper — keeps
|
|
105
|
+
// `VALID_BACKENDS.constructor === Set` true, matching a plain Set.
|
|
106
|
+
if (prop === "constructor") return Set;
|
|
107
|
+
const value = Reflect.get(t, prop, t);
|
|
108
|
+
return typeof value === "function" ? value.bind(t) : value;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Valid values for the `backend` option / --backend flag / FORGEDOCK_BACKEND
|
|
115
|
+
* env. Exported (issue #2013) so bin/forgedock.mjs's CLI-layer `--backend`
|
|
116
|
+
* flag validation can reuse this exact Set instead of maintaining its own
|
|
117
|
+
* independently-hardcoded copy — keeps both layers' "Must be one of: ..."
|
|
118
|
+
* error messages structurally unable to drift apart.
|
|
119
|
+
*
|
|
120
|
+
* Read-only (issue #2075): every importer (bin/engine.mjs, bin/forgedock.mjs)
|
|
121
|
+
* holds a reference to this exact instance, not a copy. Without the
|
|
122
|
+
* read-only wrapper above, any consumer calling .add()/.delete()/.clear()
|
|
123
|
+
* on it would silently change the accepted backend values — and the "Must
|
|
124
|
+
* be one of: ..." error-message wording — for every other consumer
|
|
125
|
+
* simultaneously. Treat it as immutable; mutation attempts throw.
|
|
126
|
+
*/
|
|
127
|
+
export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
|
|
128
|
+
// Per-process memoization for isClaudeCliAvailable(), keyed by `cwd` (issue
|
|
129
|
+
// #2011). `runCommand()` calls resolveBackend() — and therefore, for the
|
|
130
|
+
// default "auto" backend, isClaudeCliAvailable() — unconditionally on every
|
|
131
|
+
// invocation, including every `--dry-run`. Without caching, a process that
|
|
132
|
+
// calls runCommand() repeatedly (e.g. bin/batch-runner.mjs driving many
|
|
133
|
+
// commands, or an orchestration loop) re-spawns `claude --version` on every
|
|
134
|
+
// single call.
|
|
135
|
+
//
|
|
136
|
+
// Bounded on two axes (issues #2057, #2058 — review findings on PR #2056):
|
|
137
|
+
// 1. TTL (`CLI_AVAILABILITY_CACHE_TTL_MS`): an entry older than the TTL is
|
|
138
|
+
// treated as absent and re-probed, so a `claude` install/uninstall that
|
|
139
|
+
// happens mid-run of a long-lived process (e.g. an orchestration loop)
|
|
140
|
+
// is picked up within one TTL window instead of staying stale for the
|
|
141
|
+
// rest of the process's lifetime.
|
|
142
|
+
// 2. Max size (`CLI_AVAILABILITY_CACHE_MAX_SIZE`): entries beyond the cap
|
|
143
|
+
// evict the oldest (insertion-order, via `Map`'s iteration order and a
|
|
144
|
+
// delete+re-set on refresh) so a process that probes an unbounded
|
|
145
|
+
// number of distinct `cwd` values cannot grow this cache without limit.
|
|
146
|
+
// Each entry stores `{ available, cachedAt }` instead of a bare boolean so
|
|
147
|
+
// `isClaudeCliAvailable()` can evaluate the TTL.
|
|
148
|
+
const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
|
|
149
|
+
const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
|
|
150
|
+
const cliAvailabilityCache = new Map();
|
|
58
151
|
// Cap tool-result payloads so a large file read or verbose command does not
|
|
59
152
|
// blow the context window in a single turn.
|
|
60
153
|
const MAX_TOOL_RESULT_CHARS = 100_000;
|
|
@@ -106,6 +199,398 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
106
199
|
}
|
|
107
200
|
}
|
|
108
201
|
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Backend selection (issue #2003) — CLI vs API execution backend
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Probe whether the Claude Code CLI (`claude`) is present and responds on
|
|
208
|
+
* this machine, so `forgedock run` can drive it directly instead of
|
|
209
|
+
* requiring a separate, billable ANTHROPIC_API_KEY.
|
|
210
|
+
*
|
|
211
|
+
* Deliberately uses `execSync("claude --version", ...)` — a single command
|
|
212
|
+
* *string* executed through a shell — rather than
|
|
213
|
+
* `execFileSync("claude", [...])`. On Windows, `claude` is installed as a
|
|
214
|
+
* `claude.cmd` shim; `execFileSync` does not resolve `.cmd` shims and throws
|
|
215
|
+
* ENOENT even though the CLI is genuinely installed and on PATH (this exact
|
|
216
|
+
* regression previously made an unrelated skill-based enrichment backend
|
|
217
|
+
* silently dead on Windows — see issue #382). `execSync` always runs its
|
|
218
|
+
* command through a shell, which resolves PATHEXT-registered `.cmd`/`.bat`
|
|
219
|
+
* shims correctly on Windows and behaves identically on POSIX. This mirrors
|
|
220
|
+
* the pre-existing `claude --version` doctor check in bin/forgedock.mjs.
|
|
221
|
+
*
|
|
222
|
+
* Bounded by CLI_PROBE_TIMEOUT_MS so a hung or misbehaving `claude` binary
|
|
223
|
+
* can never make backend resolution (and therefore --dry-run, or the start
|
|
224
|
+
* of a live run) hang indefinitely. Any failure — not installed, not on
|
|
225
|
+
* PATH, times out, non-zero exit — is treated as "not available" and never
|
|
226
|
+
* throws; the caller (resolveBackend) falls back to the API backend.
|
|
227
|
+
*
|
|
228
|
+
* Memoized per `cwd` for up to `CLI_AVAILABILITY_CACHE_TTL_MS` (see
|
|
229
|
+
* `cliAvailabilityCache` above, issues #2011, #2057, #2058) — the first call
|
|
230
|
+
* for a given `cwd` pays the probe cost; every subsequent call for that same
|
|
231
|
+
* `cwd` within the TTL window returns the cached result instantly, with no
|
|
232
|
+
* new child process spawned. Once the TTL elapses the entry is treated as
|
|
233
|
+
* absent and re-probed, and the cache never grows past
|
|
234
|
+
* `CLI_AVAILABILITY_CACHE_MAX_SIZE` distinct `cwd` entries (oldest evicted
|
|
235
|
+
* first).
|
|
236
|
+
*
|
|
237
|
+
* @param {string} [cwd] - Working directory for the probe (default cwd).
|
|
238
|
+
* @param {object} [opts]
|
|
239
|
+
* @param {typeof execSync} [opts.execImpl] - Test seam only — production
|
|
240
|
+
* callers must never override this; built-in ESM modules like
|
|
241
|
+
* `node:child_process` export non-configurable bindings that `node:test`'s
|
|
242
|
+
* `mock.method` cannot redefine, so tests inject a stub here directly
|
|
243
|
+
* instead. Mirrors the existing `bin` test-seam parameter on
|
|
244
|
+
* `runCliBackend()`.
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync } = {}) {
|
|
248
|
+
const cached = cliAvailabilityCache.get(cwd);
|
|
249
|
+
if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
|
|
250
|
+
return cached.available;
|
|
251
|
+
}
|
|
252
|
+
let available;
|
|
253
|
+
try {
|
|
254
|
+
execImpl("claude --version", {
|
|
255
|
+
cwd,
|
|
256
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
257
|
+
encoding: "utf-8",
|
|
258
|
+
timeout: CLI_PROBE_TIMEOUT_MS,
|
|
259
|
+
});
|
|
260
|
+
available = true;
|
|
261
|
+
} catch {
|
|
262
|
+
available = false;
|
|
263
|
+
}
|
|
264
|
+
// Re-inserting an existing key moves it to the end of Map's iteration
|
|
265
|
+
// order (delete+set), so eviction below always drops the true oldest
|
|
266
|
+
// entry — whether "oldest" means "never refreshed" or "not refreshed in
|
|
267
|
+
// the longest time."
|
|
268
|
+
cliAvailabilityCache.delete(cwd);
|
|
269
|
+
cliAvailabilityCache.set(cwd, { available, cachedAt: Date.now() });
|
|
270
|
+
if (cliAvailabilityCache.size > CLI_AVAILABILITY_CACHE_MAX_SIZE) {
|
|
271
|
+
const oldestKey = cliAvailabilityCache.keys().next().value;
|
|
272
|
+
cliAvailabilityCache.delete(oldestKey);
|
|
273
|
+
}
|
|
274
|
+
return available;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Shared backend-resolution primitive (issue #2026) used by both
|
|
279
|
+
* `resolveBackend()` (below, `forgedock run`'s engine ladder — issue #2003)
|
|
280
|
+
* and `resolveEnrichBackend()` (bin/init-enrich.mjs's init-enrichment
|
|
281
|
+
* ladder — issue #2004). Both ladders share the exact same two steps — an
|
|
282
|
+
* explicit override wins outright, otherwise probe the local Claude Code
|
|
283
|
+
* CLI and prefer it when present — but diverge on (a) which override
|
|
284
|
+
* values are valid and whether an invalid one throws or is silently
|
|
285
|
+
* ignored, and (b) what to return when the CLI probe fails (`resolveBackend`
|
|
286
|
+
* always falls back to `"api"`; `resolveEnrichBackend` falls back to `"api"`
|
|
287
|
+
* only if `ANTHROPIC_API_KEY` is set, else `"none"`). Rather than force
|
|
288
|
+
* those into one shape, this shared helper implements exactly the common
|
|
289
|
+
* two steps and defers to a `cliFallback` callback for the diverging tail,
|
|
290
|
+
* and defers override *validation* (throw vs. fall-through) entirely to
|
|
291
|
+
* the caller — this function only performs a membership check, it never
|
|
292
|
+
* throws. This removes the duplicated CLI-probe-and-branch logic while
|
|
293
|
+
* preserving each caller's distinct, already-shipped behavior byte-for-byte.
|
|
294
|
+
*
|
|
295
|
+
* @param {object} opts
|
|
296
|
+
* @param {string} [opts.override] - Pre-validated override value. Returned
|
|
297
|
+
* as-is when it is a member of `validOverrides`; otherwise ignored (the
|
|
298
|
+
* caller is responsible for deciding whether an unrecognized value should
|
|
299
|
+
* throw or fall through — this helper never throws).
|
|
300
|
+
* @param {Set<string>} opts.validOverrides - The set of override values this
|
|
301
|
+
* caller accepts outright.
|
|
302
|
+
* @param {string} [opts.cwd] - Working directory for the CLI probe.
|
|
303
|
+
* @param {Function} [opts.isCliAvailableFn] - Injectable CLI-presence probe.
|
|
304
|
+
* Defaults to `isClaudeCliAvailable` (which is itself memoized per `cwd` —
|
|
305
|
+
* issue #2011). Callers MUST pass this through rather than reimplementing
|
|
306
|
+
* probing, to preserve that cache.
|
|
307
|
+
* @param {Function} opts.cliFallback - Called (no args) when no valid
|
|
308
|
+
* override was given AND the CLI probe failed. Must return the caller's
|
|
309
|
+
* fallback backend value (e.g. `"api"`, or `"api"|"none"` depending on an
|
|
310
|
+
* API key check).
|
|
311
|
+
* @returns {string} The resolved backend value.
|
|
312
|
+
*/
|
|
313
|
+
export function resolveBackendLadder({
|
|
314
|
+
override,
|
|
315
|
+
validOverrides,
|
|
316
|
+
cwd = process.cwd(),
|
|
317
|
+
isCliAvailableFn = isClaudeCliAvailable,
|
|
318
|
+
cliFallback,
|
|
319
|
+
}) {
|
|
320
|
+
if (override !== undefined && validOverrides.has(override)) return override;
|
|
321
|
+
if (isCliAvailableFn(cwd)) return "cli";
|
|
322
|
+
return cliFallback();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Resolve which execution backend `runCommand()` should use.
|
|
327
|
+
*
|
|
328
|
+
* Ladder (issue #2003):
|
|
329
|
+
* 1. An explicit `"cli"` or `"api"` request always wins outright — no
|
|
330
|
+
* probing, no fallback. This is what `--backend cli|api` and
|
|
331
|
+
* `FORGEDOCK_BACKEND=cli|api` produce.
|
|
332
|
+
* 2. `"auto"` (the default) probes `isClaudeCliAvailable()`: prefers the
|
|
333
|
+
* CLI backend when a working `claude` install is detected (reuses
|
|
334
|
+
* whatever credentials the CLI already has — Pro/Max OAuth or a
|
|
335
|
+
* CLI-managed key — with no ANTHROPIC_API_KEY required), otherwise
|
|
336
|
+
* falls back to the API backend unchanged (existing behavior for every
|
|
337
|
+
* caller that never opts in to this feature).
|
|
338
|
+
*
|
|
339
|
+
* Delegates its shared "override-or-probe-CLI" shape to
|
|
340
|
+
* `resolveBackendLadder()` (issue #2026) — this function retains its own
|
|
341
|
+
* `VALID_BACKENDS` throw-on-invalid check (NOT shared; see that helper's
|
|
342
|
+
* doc comment) and its own no-API-key-check fallback (always `"api"`,
|
|
343
|
+
* unconditionally — the API key itself is validated lazily downstream, only
|
|
344
|
+
* if/when the api backend is actually selected).
|
|
345
|
+
*
|
|
346
|
+
* @param {object} [opts]
|
|
347
|
+
* @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
|
|
348
|
+
* @param {string} [opts.cwd] - Working directory for the CLI probe.
|
|
349
|
+
* @returns {"cli"|"api"}
|
|
350
|
+
*/
|
|
351
|
+
export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {}) {
|
|
352
|
+
if (!VALID_BACKENDS.has(requested)) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
return resolveBackendLadder({
|
|
358
|
+
override: requested === "auto" ? undefined : requested,
|
|
359
|
+
validOverrides: new Set(["cli", "api"]),
|
|
360
|
+
cwd,
|
|
361
|
+
cliFallback: () => "api",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Run a command via the local Claude Code CLI instead of the Anthropic SDK
|
|
367
|
+
* (issue #2003's "cli" backend). Shells out to headless print mode
|
|
368
|
+
* (`claude --print "<message>" --dangerously-skip-permissions`), reusing
|
|
369
|
+
* whatever the CLI is already authenticated with — no ANTHROPIC_API_KEY is
|
|
370
|
+
* read or required anywhere on this path.
|
|
371
|
+
*
|
|
372
|
+
* SECURITY — argv array, NEVER `shell: true`: `userMessage` is built from
|
|
373
|
+
* `buildUserMessage(commandName, args)`, and `args` originates from
|
|
374
|
+
* user-supplied CLI arguments to `forgedock run <command> <args...>` — it is
|
|
375
|
+
* untrusted input from this function's point of view. `spawnSync("claude",
|
|
376
|
+
* [...argv], { shell: false (default) })` passes each argument as a discrete,
|
|
377
|
+
* unparsed argv element: the target process receives `userMessage` as one
|
|
378
|
+
* literal string, with no shell ever tokenizing or expanding it, so shell
|
|
379
|
+
* metacharacters (`$(...)`, backticks, `;`, `&&`, `|`, `!`) inside it cannot
|
|
380
|
+
* trigger command injection. An earlier version of this function built a
|
|
381
|
+
* shell command *string* (`claude --print "<quoted message>" ...`) run via
|
|
382
|
+
* `spawnSync(command, { shell: true })` with hand-rolled quoting
|
|
383
|
+
* (backslash/double-quote escaping only) — that does NOT neutralize
|
|
384
|
+
* `$(...)`/backtick/`!` expansion inside POSIX double quotes and was an
|
|
385
|
+
* exploitable injection (verified: a crafted `userMessage` could execute
|
|
386
|
+
* arbitrary shell commands with this process's privileges). Do not
|
|
387
|
+
* reintroduce `shell: true` or string-command interpolation here.
|
|
388
|
+
*
|
|
389
|
+
* Windows `.cmd` shim resolution works WITHOUT `shell: true`: Node's
|
|
390
|
+
* spawn/spawnSync resolve a bare `"claude"` command via PATH+PATHEXT and
|
|
391
|
+
* safely re-invoke through cmd.exe internally when the resolved target is a
|
|
392
|
+
* `.cmd`/`.bat` file, using a properly escaped mechanism, and argv elements
|
|
393
|
+
* are never parsed as shell syntax (verified empirically on Windows: a
|
|
394
|
+
* malicious-looking argv element is delivered to the child process
|
|
395
|
+
* byte-for-byte, not executed). This is the correct fix for the
|
|
396
|
+
* `execFileSync("claude", [...])` ENOENT-on-Windows regression from issue
|
|
397
|
+
* #382 — that regression was about USING execFileSync with an unresolved
|
|
398
|
+
* bare command name in an older/incompatible way, not about needing
|
|
399
|
+
* `shell: true`; spawnSync's own PATH/PATHEXT resolution (unlike
|
|
400
|
+
* execFileSync's, in the failure mode #382 hit) handles the `.cmd` shim
|
|
401
|
+
* transparently here.
|
|
402
|
+
*
|
|
403
|
+
* `--dangerously-skip-permissions` mirrors the established headless-CI
|
|
404
|
+
* invocation pattern already documented in docs/CI.md and
|
|
405
|
+
* templates/workflows/forgedock-review.yml: without it, the CLI would block
|
|
406
|
+
* on an interactive permission prompt that a headless caller can never
|
|
407
|
+
* answer, hanging until FORGEDOCK_CLI_TIMEOUT_MS/DEFAULT_CLI_TIMEOUT_MS
|
|
408
|
+
* kills it.
|
|
409
|
+
*
|
|
410
|
+
* SYSTEM PROMPT / COMMAND SPEC DELIVERY (issue #2019): `systemPrompt` (built
|
|
411
|
+
* by `buildCliSystemPrompt(spec)` — see below) is written to a private
|
|
412
|
+
* temp file and forwarded via `--append-system-prompt-file <path>`, NOT
|
|
413
|
+
* passed inline as a `--system-prompt`/`--append-system-prompt` argv string.
|
|
414
|
+
* Two reasons this matters:
|
|
415
|
+
* 1. Size: a command spec like `commands/work-on.md` is 100+ KB, and the
|
|
416
|
+
* full `commands/*.md` corpus is over 1MB. Windows' CreateProcess has a
|
|
417
|
+
* ~32K character command-line limit; spawnSync here uses `shell: false`
|
|
418
|
+
* so it hits that OS-level argv limit directly. A file path is always
|
|
419
|
+
* short regardless of spec size.
|
|
420
|
+
* 2. Semantics: `--append-system-prompt-file` *appends* to the CLI's own
|
|
421
|
+
* default system prompt (tool descriptions, environment info, etc.),
|
|
422
|
+
* matching how a real `/work-on` slash-command invocation inside Claude
|
|
423
|
+
* Code behaves — the ForgeDock spec augments the CLI's native
|
|
424
|
+
* capabilities rather than replacing them. `--system-prompt-file` would
|
|
425
|
+
* instead *replace* the CLI's default prompt entirely, which would
|
|
426
|
+
* strip the CLI of its own operating instructions.
|
|
427
|
+
* The temp file lives in a per-call `mkdtempSync` directory and is always
|
|
428
|
+
* removed in a `finally` block, covering the timeout/spawn-error/non-zero-exit
|
|
429
|
+
* paths as well as the success path.
|
|
430
|
+
*
|
|
431
|
+
* Model selection is intentionally NOT forwarded to the CLI in this first
|
|
432
|
+
* increment — the CLI backend uses whatever model the `claude` CLI itself is
|
|
433
|
+
* configured for. `opts.model`/FORGEDOCK_MODEL only affects the API backend.
|
|
434
|
+
* Likewise, structured token usage is not available the same way the
|
|
435
|
+
* Anthropic SDK exposes it; `usage` is reported as `null` (an already
|
|
436
|
+
* fully-supported value throughout renderSummaryCard/renderDryRun).
|
|
437
|
+
*
|
|
438
|
+
* @param {object} opts
|
|
439
|
+
* @param {{path: string, name: string, content: string}} opts.spec
|
|
440
|
+
* @param {string} opts.userMessage
|
|
441
|
+
* @param {string} [opts.systemPrompt] - CLI-appropriate system prompt (see
|
|
442
|
+
* `buildCliSystemPrompt`), forwarded via `--append-system-prompt-file`.
|
|
443
|
+
* Omitted/empty is tolerated (no flag is added) so existing callers that
|
|
444
|
+
* have not been updated yet do not break, but production callers should
|
|
445
|
+
* always supply it — see issue #2019.
|
|
446
|
+
* @param {string[]} [opts.args]
|
|
447
|
+
* @param {string} opts.cwd
|
|
448
|
+
* @param {{log: Function, error?: Function}} [opts.logger]
|
|
449
|
+
* @param {string} [opts.bin] - Executable to invoke (default "claude"). Test
|
|
450
|
+
* seam only — production callers must never override this; it exists so
|
|
451
|
+
* tests can point at a controlled fake binary instead of either invoking
|
|
452
|
+
* the real `claude` CLI (slow, non-deterministic, real side effects) or
|
|
453
|
+
* fighting platform-specific PATH-resolution semantics to shim "claude"
|
|
454
|
+
* itself (Windows resolves a bare executable name via the *calling*
|
|
455
|
+
* process's search path at the OS level, which is not reliably
|
|
456
|
+
* overridable per-call from within the same process).
|
|
457
|
+
* @param {Function} [opts.spawnFn] - Injectable replacement for
|
|
458
|
+
* `child_process.spawnSync` (default `spawnSync`). Test seam only —
|
|
459
|
+
* production callers must never override this. Mirrors the identical
|
|
460
|
+
* `spawnFn` seam on `bin/init-enrich-cli.mjs`'s `enrich()` (issue #2033):
|
|
461
|
+
* both functions spawn the local `claude` CLI in headless print mode, so
|
|
462
|
+
* sharing the same seam shape lets tests exercise both with equivalent
|
|
463
|
+
* fixtures and makes future divergence between the two easier to catch.
|
|
464
|
+
* This is additive to the existing `bin` override seam above — tests may
|
|
465
|
+
* still use a real fake-binary-on-disk + real `spawnSync` (the original
|
|
466
|
+
* seam), or fully mock the call via `spawnFn` (no fake binary needed).
|
|
467
|
+
* @returns {{status: string, command: string, iterations: number, stopReason: string, usage: null, model: string, backend: "cli"}}
|
|
468
|
+
*/
|
|
469
|
+
export function runCliBackend({
|
|
470
|
+
spec,
|
|
471
|
+
userMessage,
|
|
472
|
+
systemPrompt = "",
|
|
473
|
+
args = [],
|
|
474
|
+
cwd,
|
|
475
|
+
logger = console,
|
|
476
|
+
bin = "claude",
|
|
477
|
+
spawnFn = spawnSync,
|
|
478
|
+
}) {
|
|
479
|
+
const rawTimeout = parseInt(process.env.FORGEDOCK_CLI_TIMEOUT_MS, 10);
|
|
480
|
+
const timeoutMs =
|
|
481
|
+
Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : DEFAULT_CLI_TIMEOUT_MS;
|
|
482
|
+
|
|
483
|
+
// Write the system prompt (command spec + framing) to a private temp file
|
|
484
|
+
// rather than passing it inline as an argv string — see the SYSTEM PROMPT
|
|
485
|
+
// block comment above for why. The directory is created fresh per call and
|
|
486
|
+
// always removed in `finally`, regardless of how spawnSync exits below.
|
|
487
|
+
//
|
|
488
|
+
// `tmpDir` is declared here (outside `try`) so `finally` below can see it,
|
|
489
|
+
// but the `mkdtempSync`/`writeFileSync` calls themselves now run *inside*
|
|
490
|
+
// the `try` block (issue #2061 — review finding on PR #2060) rather than
|
|
491
|
+
// before it: if either call throws (disk full, EACCES on a locked-down
|
|
492
|
+
// temp dir, etc.), that throw must still be covered by the same `finally`
|
|
493
|
+
// cleanup path, not bypass it. `tmpDir` is assigned immediately after
|
|
494
|
+
// `mkdtempSync` succeeds — before `writeFileSync` runs — so a failure in
|
|
495
|
+
// `writeFileSync` still leaves `tmpDir` set and the already-created
|
|
496
|
+
// directory gets cleaned up.
|
|
497
|
+
let tmpDir = null;
|
|
498
|
+
let cliArgs = ["--print", userMessage, "--dangerously-skip-permissions"];
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
if (systemPrompt) {
|
|
502
|
+
const createdDir = mkdtempSync(join(os.tmpdir(), "forgedock-cli-system-prompt-"));
|
|
503
|
+
tmpDir = createdDir;
|
|
504
|
+
const systemPromptPath = join(createdDir, "system-prompt.txt");
|
|
505
|
+
writeFileSync(systemPromptPath, systemPrompt, "utf-8");
|
|
506
|
+
cliArgs = [
|
|
507
|
+
"--print",
|
|
508
|
+
userMessage,
|
|
509
|
+
"--append-system-prompt-file",
|
|
510
|
+
systemPromptPath,
|
|
511
|
+
"--dangerously-skip-permissions",
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Scrub the Anthropic API key from the child environment. This backend
|
|
516
|
+
// runs with `--dangerously-skip-permissions` (no confirmation gate on any
|
|
517
|
+
// tool call, including bash), and the runner is designed to feed
|
|
518
|
+
// untrusted third-party content (issue/PR bodies via gh) into the model
|
|
519
|
+
// loop — so a prompt-injection payload could otherwise issue a bash
|
|
520
|
+
// command that exfiltrates the key straight out of its own environment.
|
|
521
|
+
// Mirrors the existing scrub in the `run_bash` tool handler below.
|
|
522
|
+
// GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
|
|
523
|
+
// intact — only the Anthropic key is removed.
|
|
524
|
+
const childEnv = { ...process.env };
|
|
525
|
+
delete childEnv.ANTHROPIC_API_KEY;
|
|
526
|
+
|
|
527
|
+
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
528
|
+
// unparsed elements — see the SECURITY note above. This also correctly
|
|
529
|
+
// resolves the Windows `.cmd` shim without needing shell:true.
|
|
530
|
+
const result = spawnFn(bin, cliArgs, {
|
|
531
|
+
cwd,
|
|
532
|
+
encoding: "utf-8",
|
|
533
|
+
maxBuffer: DEFAULT_SPAWN_MAX_BUFFER_BYTES,
|
|
534
|
+
timeout: timeoutMs,
|
|
535
|
+
env: childEnv,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const stdout = result.stdout ? String(result.stdout) : "";
|
|
539
|
+
const stderr = result.stderr ? String(result.stderr) : "";
|
|
540
|
+
|
|
541
|
+
// Same timeout-detection shape as run_bash below: ETIMEDOUT is the
|
|
542
|
+
// authoritative Node-initiated kill signal; the elapsed-time fallback is
|
|
543
|
+
// intentionally omitted here since spawnSync's own `timeout` + ETIMEDOUT is
|
|
544
|
+
// reliable for this single, non-looped invocation.
|
|
545
|
+
const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
|
|
546
|
+
if (timedOut) {
|
|
547
|
+
const timeoutSecs = Math.round(timeoutMs / 1000);
|
|
548
|
+
throw new Error(
|
|
549
|
+
`claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
|
|
550
|
+
`Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
if (result.error) {
|
|
554
|
+
throw new Error(`Failed to invoke claude CLI: ${result.error.message}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const output = (stdout + stderr).trim();
|
|
558
|
+
if (output) logger.log(output);
|
|
559
|
+
|
|
560
|
+
if (result.status !== 0) {
|
|
561
|
+
const err = new Error(
|
|
562
|
+
`claude CLI exited with status ${result.status ?? "?"}. See output above for details.`,
|
|
563
|
+
);
|
|
564
|
+
err.code = "CLI_BACKEND_FAILED";
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
logger.log(
|
|
569
|
+
renderSummaryCard({
|
|
570
|
+
command: spec.name,
|
|
571
|
+
args,
|
|
572
|
+
iterations: 1,
|
|
573
|
+
stopReason: "cli_exit_0",
|
|
574
|
+
usage: null,
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
status: "complete",
|
|
580
|
+
command: spec.name,
|
|
581
|
+
iterations: 1,
|
|
582
|
+
stopReason: "cli_exit_0",
|
|
583
|
+
usage: null,
|
|
584
|
+
model: "cli",
|
|
585
|
+
backend: "cli",
|
|
586
|
+
};
|
|
587
|
+
} finally {
|
|
588
|
+
if (tmpDir) {
|
|
589
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
109
594
|
// ---------------------------------------------------------------------------
|
|
110
595
|
// Command spec resolution
|
|
111
596
|
// ---------------------------------------------------------------------------
|
|
@@ -231,6 +716,36 @@ export function buildSystemPrompt(spec, opts = {}) {
|
|
|
231
716
|
.join("\n");
|
|
232
717
|
}
|
|
233
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Assemble the system prompt to forward to the CLI backend (issue #2019).
|
|
721
|
+
*
|
|
722
|
+
* This is deliberately NOT `buildSystemPrompt()` reused verbatim:
|
|
723
|
+
* `buildSystemPrompt()` was written for the API backend's custom, minimal
|
|
724
|
+
* 3-tool loop (`read_file`/`write_file`/`run_bash`) and explicitly tells the
|
|
725
|
+
* model "You have three tools to do real work" — that claim is false for the
|
|
726
|
+
* CLI backend, which runs inside the full `claude` CLI with its own native
|
|
727
|
+
* tool set (Read/Write/Edit/Bash/etc.) and its own default system prompt.
|
|
728
|
+
* Passing the API-flavored prompt to the CLI via `--append-system-prompt-file`
|
|
729
|
+
* would misinform the model about which tools it actually has.
|
|
730
|
+
*
|
|
731
|
+
* Forwarded via `--append-system-prompt-file` (see `runCliBackend`), which
|
|
732
|
+
* *appends* to the CLI's own default system prompt rather than replacing it
|
|
733
|
+
* — the CLI keeps its normal tool descriptions/environment info, with the
|
|
734
|
+
* ForgeDock command specification layered on top, mirroring how invoking
|
|
735
|
+
* `/${spec.name}` as a real Claude Code slash command would behave.
|
|
736
|
+
*
|
|
737
|
+
* @param {{name: string, content: string}} spec
|
|
738
|
+
* @returns {string}
|
|
739
|
+
*/
|
|
740
|
+
export function buildCliSystemPrompt(spec) {
|
|
741
|
+
return [
|
|
742
|
+
`You are executing the ForgeDock "/${spec.name}" command. Follow the command specification below exactly, using your normal available tools to do the work (file edits, git, gh, running scripts/build/test commands, etc.). Post FORGE annotations to GitHub via the gh CLI exactly as the spec instructs. Do not ask the user questions — this is a headless run. When the command is fully complete, stop and emit a concise final summary of what was accomplished.`,
|
|
743
|
+
``,
|
|
744
|
+
`=== COMMAND SPECIFICATION (commands/${spec.name}.md) ===`,
|
|
745
|
+
spec.content,
|
|
746
|
+
].join("\n");
|
|
747
|
+
}
|
|
748
|
+
|
|
234
749
|
/**
|
|
235
750
|
* Build the initial user message equivalent to the Claude Code slash invocation.
|
|
236
751
|
*
|
|
@@ -603,7 +1118,7 @@ export function getToolHandlers(cwd) {
|
|
|
603
1118
|
const maxBuffer =
|
|
604
1119
|
Number.isFinite(rawMaxBuffer) && rawMaxBuffer > 0
|
|
605
1120
|
? rawMaxBuffer
|
|
606
|
-
:
|
|
1121
|
+
: DEFAULT_SPAWN_MAX_BUFFER_BYTES;
|
|
607
1122
|
const result = spawnSync(command, {
|
|
608
1123
|
cwd,
|
|
609
1124
|
encoding: "utf-8",
|
|
@@ -698,25 +1213,53 @@ export function getToolHandlers(cwd) {
|
|
|
698
1213
|
|
|
699
1214
|
/**
|
|
700
1215
|
* Render the dry-run preview: what would be sent to the API, no network.
|
|
701
|
-
*
|
|
1216
|
+
*
|
|
1217
|
+
* `systemPrompt` in `ctx` must already be the backend-appropriate prompt —
|
|
1218
|
+
* callers resolve `backend === "cli" ? cliSystemPrompt : systemPrompt` before
|
|
1219
|
+
* calling this (see `runCommand`). This function branches the `tools:` line
|
|
1220
|
+
* (and the on-disk-vs-inline framing of the system-prompt line) on `backend`
|
|
1221
|
+
* so the preview accurately reflects what each backend actually sends: the
|
|
1222
|
+
* `api` backend sends `TOOL_DEFINITIONS` (its custom read_file/write_file/
|
|
1223
|
+
* run_bash loop) inline as `system`; the `cli` backend uses the `claude`
|
|
1224
|
+
* CLI's own native tool set and receives the prompt via
|
|
1225
|
+
* `--append-system-prompt-file`, not inline (issue #2019).
|
|
1226
|
+
*
|
|
1227
|
+
* @param {{spec: object, systemPrompt: string, userMessage: string, model: string, maxIterations: number, backend?: "cli"|"api"}} ctx
|
|
702
1228
|
* @returns {string}
|
|
703
1229
|
*/
|
|
704
1230
|
export function renderDryRun(ctx) {
|
|
705
|
-
const { spec, systemPrompt, userMessage, model, maxIterations } = ctx;
|
|
1231
|
+
const { spec, systemPrompt, userMessage, model, maxIterations, backend } = ctx;
|
|
1232
|
+
const backendLine =
|
|
1233
|
+
backend === "cli"
|
|
1234
|
+
? `│ backend: cli (claude CLI detected — no ANTHROPIC_API_KEY needed)`
|
|
1235
|
+
: backend === "api"
|
|
1236
|
+
? `│ backend: api (ANTHROPIC_API_KEY required)`
|
|
1237
|
+
: null;
|
|
1238
|
+
const toolsLine =
|
|
1239
|
+
backend === "cli"
|
|
1240
|
+
? `│ tools: claude CLI's native tools (Read/Write/Edit/Bash/etc. — not TOOL_DEFINITIONS below)`
|
|
1241
|
+
: `│ tools: ${TOOL_DEFINITIONS.map((t) => t.name).join(", ")}`;
|
|
1242
|
+
const systemPromptLine =
|
|
1243
|
+
backend === "cli"
|
|
1244
|
+
? `│ system prompt: ${systemPrompt.length} chars (appended to CLI's default via --append-system-prompt-file)`
|
|
1245
|
+
: `│ system prompt: ${systemPrompt.length} chars`;
|
|
706
1246
|
return [
|
|
707
1247
|
`┌─ ForgeDock run (dry-run) ───────────────────────────────`,
|
|
708
1248
|
`│ command: /${spec.name}`,
|
|
709
1249
|
`│ spec: ${spec.path}`,
|
|
1250
|
+
backendLine,
|
|
710
1251
|
`│ model: ${model}`,
|
|
711
1252
|
`│ max iterations: ${maxIterations}`,
|
|
712
|
-
|
|
713
|
-
|
|
1253
|
+
toolsLine,
|
|
1254
|
+
systemPromptLine,
|
|
714
1255
|
`│ user message: ${userMessage}`,
|
|
715
1256
|
`└─────────────────────────────────────────────────────────`,
|
|
716
1257
|
``,
|
|
717
1258
|
`(dry-run) No API call made. Set ANTHROPIC_API_KEY and install`,
|
|
718
1259
|
`@anthropic-ai/sdk, then drop --dry-run to execute the pipeline.`,
|
|
719
|
-
]
|
|
1260
|
+
]
|
|
1261
|
+
.filter((line) => line !== null && line !== undefined)
|
|
1262
|
+
.join("\n");
|
|
720
1263
|
}
|
|
721
1264
|
|
|
722
1265
|
/**
|
|
@@ -771,6 +1314,15 @@ export function renderSummaryCard(ctx) {
|
|
|
771
1314
|
* `cwd`) > hardcoded DEFAULT_MODEL.
|
|
772
1315
|
* @param {number} [opts.maxIterations] - Tool-loop bound.
|
|
773
1316
|
* @param {boolean} [opts.dryRun] - Preview without an API call.
|
|
1317
|
+
* @param {string} [opts.backend] - "cli" | "api" | "auto" (default
|
|
1318
|
+
* "auto"). Resolution order when omitted: $FORGEDOCK_BACKEND env > "auto".
|
|
1319
|
+
* "auto" prefers the local Claude Code CLI when detected (no
|
|
1320
|
+
* ANTHROPIC_API_KEY needed), else falls back to the API backend. This
|
|
1321
|
+
* precedence is intentional/shipped (issue #2003's accepted acceptance
|
|
1322
|
+
* criteria) and is NOT changed by issue #2020 — that issue only adds a
|
|
1323
|
+
* discoverability notice (below) for the case where the ladder silently
|
|
1324
|
+
* preferred `cli` while an `ANTHROPIC_API_KEY` was also available, so
|
|
1325
|
+
* users know the `--backend api`/`FORGEDOCK_BACKEND=api` override exists.
|
|
774
1326
|
* @param {{log: Function, error?: Function}} [opts.logger] - Output sink.
|
|
775
1327
|
* @returns {Promise<{status: string, command: string, [k: string]: any}>}
|
|
776
1328
|
*/
|
|
@@ -786,21 +1338,104 @@ export async function runCommand(opts = {}) {
|
|
|
786
1338
|
DEFAULT_MODEL,
|
|
787
1339
|
maxIterations = DEFAULT_MAX_ITERATIONS,
|
|
788
1340
|
dryRun = false,
|
|
1341
|
+
backend = process.env.FORGEDOCK_BACKEND || "auto",
|
|
789
1342
|
logger = console,
|
|
790
1343
|
} = opts;
|
|
791
1344
|
|
|
792
1345
|
const spec = loadCommandSpec(commandsDir, commandName);
|
|
793
1346
|
const systemPrompt = buildSystemPrompt(spec, { repoRoot: cwd });
|
|
1347
|
+
// CLI-backend-specific prompt (issue #2019) — see buildCliSystemPrompt's
|
|
1348
|
+
// doc comment for why this must NOT be the same string as `systemPrompt`
|
|
1349
|
+
// above (that one is written for the API backend's custom 3-tool loop).
|
|
1350
|
+
const cliSystemPrompt = buildCliSystemPrompt(spec);
|
|
794
1351
|
const userMessage = buildUserMessage(commandName, args);
|
|
795
1352
|
|
|
1353
|
+
// Resolve the backend before dry-run so the preview reports what would
|
|
1354
|
+
// actually run. isClaudeCliAvailable() is bounded by CLI_PROBE_TIMEOUT_MS
|
|
1355
|
+
// and never throws on detection failure, so this cannot make --dry-run (or
|
|
1356
|
+
// the start of a live run) hang or fail even when `claude` is absent or
|
|
1357
|
+
// misbehaving. An explicitly invalid `backend` value DOES throw here —
|
|
1358
|
+
// surfacing a bad --backend/FORGEDOCK_BACKEND value immediately, before any
|
|
1359
|
+
// work is attempted, rather than silently ignoring it.
|
|
1360
|
+
const resolvedBackend = resolveBackend({ requested: backend, cwd });
|
|
1361
|
+
|
|
796
1362
|
if (dryRun) {
|
|
797
|
-
logger.log(
|
|
798
|
-
|
|
1363
|
+
logger.log(
|
|
1364
|
+
renderDryRun({
|
|
1365
|
+
spec,
|
|
1366
|
+
systemPrompt: resolvedBackend === "cli" ? cliSystemPrompt : systemPrompt,
|
|
1367
|
+
userMessage,
|
|
1368
|
+
model,
|
|
1369
|
+
maxIterations,
|
|
1370
|
+
backend: resolvedBackend,
|
|
1371
|
+
}),
|
|
1372
|
+
);
|
|
1373
|
+
return {
|
|
1374
|
+
status: "dry-run",
|
|
1375
|
+
command: spec.name,
|
|
1376
|
+
args,
|
|
1377
|
+
specPath: spec.path,
|
|
1378
|
+
model,
|
|
1379
|
+
backend: resolvedBackend,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (resolvedBackend === "cli") {
|
|
1384
|
+
// Discoverability notice (issue #2020): the "auto" ladder's CLI-first
|
|
1385
|
+
// precedence is intentional, shipped behavior (issue #2003's accepted
|
|
1386
|
+
// acceptance criteria: "prefers CLI when present, falls back to SDK")
|
|
1387
|
+
// and is NOT changed here — reverting it would contradict that accepted
|
|
1388
|
+
// design, exactly as sibling issue #2023 found for the analogous
|
|
1389
|
+
// init-enrich ladder. The actual gap #2020 identified is that this
|
|
1390
|
+
// choice is invisible on a live run: --dry-run reports the resolved
|
|
1391
|
+
// backend via renderDryRun() above, but a live run previously printed
|
|
1392
|
+
// nothing, so a user who has both `claude` on PATH and
|
|
1393
|
+
// ANTHROPIC_API_KEY set has no signal that the ladder picked "cli" over
|
|
1394
|
+
// their key, nor that `--backend api`/`FORGEDOCK_BACKEND=api` exists to
|
|
1395
|
+
// pin the previous (API-only) behavior. Gate strictly on:
|
|
1396
|
+
// - `backend` being the raw, pre-resolution "auto"/default value (an
|
|
1397
|
+
// explicit `--backend cli`/`FORGEDOCK_BACKEND=cli` request needs no
|
|
1398
|
+
// "did you mean to override" hint — the user already chose).
|
|
1399
|
+
// - `apiKey` being truthy (silent for the common CLI-only case with no
|
|
1400
|
+
// key at all — nothing to "switch away from").
|
|
1401
|
+
if (backend === "auto" && apiKey) {
|
|
1402
|
+
logger.log(
|
|
1403
|
+
"Using the claude CLI backend (found on PATH). ANTHROPIC_API_KEY is also set — " +
|
|
1404
|
+
"pass --backend api or set FORGEDOCK_BACKEND=api to use the API backend instead.",
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// model/maxIterations only apply to the api backend — the cli backend
|
|
1409
|
+
// uses whatever model the `claude` CLI itself is configured for, and has
|
|
1410
|
+
// no equivalent iteration-cap concept for a single `claude --print`
|
|
1411
|
+
// invocation. Warn (rather than silently drop) when the caller explicitly
|
|
1412
|
+
// supplied either, so the behavior is discoverable outside of --dry-run.
|
|
1413
|
+
// hasOwnProperty on the raw opts (not the destructured, default-applied
|
|
1414
|
+
// values above) is required here: model/maxIterations always resolve to
|
|
1415
|
+
// a computed default, so checking their truthiness would fire this
|
|
1416
|
+
// warning on every single cli-backend run.
|
|
1417
|
+
const ignoredOptions = [];
|
|
1418
|
+
if (Object.prototype.hasOwnProperty.call(opts, "model")) ignoredOptions.push("model");
|
|
1419
|
+
if (Object.prototype.hasOwnProperty.call(opts, "maxIterations"))
|
|
1420
|
+
ignoredOptions.push("maxIterations");
|
|
1421
|
+
if (ignoredOptions.length > 0) {
|
|
1422
|
+
logger.log(
|
|
1423
|
+
`Warning: --${ignoredOptions.join(" and --")} ${
|
|
1424
|
+
ignoredOptions.length > 1 ? "are" : "is"
|
|
1425
|
+
} ignored on the cli backend (backend: cli uses whatever model the ` +
|
|
1426
|
+
`claude CLI itself is configured for). Use --backend api to honor ${
|
|
1427
|
+
ignoredOptions.length > 1 ? "these options" : "this option"
|
|
1428
|
+
}.`,
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
return runCliBackend({ spec, userMessage, systemPrompt: cliSystemPrompt, args, cwd, logger });
|
|
799
1432
|
}
|
|
800
1433
|
|
|
801
1434
|
if (!apiKey) {
|
|
802
1435
|
const err = new Error(
|
|
803
|
-
"ANTHROPIC_API_KEY is not set. Export your Anthropic API key to run the live pipeline,
|
|
1436
|
+
"ANTHROPIC_API_KEY is not set. Export your Anthropic API key to run the live pipeline, " +
|
|
1437
|
+
"pass --dry-run to preview, or use --backend cli (requires the `claude` CLI on PATH, " +
|
|
1438
|
+
"already authenticated) to run without an API key.",
|
|
804
1439
|
);
|
|
805
1440
|
err.code = "NO_API_KEY";
|
|
806
1441
|
throw err;
|
|
@@ -894,6 +1529,7 @@ export async function runCommand(opts = {}) {
|
|
|
894
1529
|
stopReason: response.stop_reason,
|
|
895
1530
|
usage,
|
|
896
1531
|
model,
|
|
1532
|
+
backend: "api",
|
|
897
1533
|
};
|
|
898
1534
|
}
|
|
899
1535
|
|
|
@@ -929,5 +1565,12 @@ export async function runCommand(opts = {}) {
|
|
|
929
1565
|
usage,
|
|
930
1566
|
}),
|
|
931
1567
|
);
|
|
932
|
-
return {
|
|
1568
|
+
return {
|
|
1569
|
+
status: "max-iterations",
|
|
1570
|
+
command: spec.name,
|
|
1571
|
+
iterations,
|
|
1572
|
+
usage,
|
|
1573
|
+
model,
|
|
1574
|
+
backend: "api",
|
|
1575
|
+
};
|
|
933
1576
|
}
|