ikarmic-forge 0.0.0-stage → 4.4.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/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/acp/server.d.ts +53 -0
- package/dist/acp/server.d.ts.map +1 -0
- package/dist/acp/server.js +235 -0
- package/dist/acp/server.js.map +1 -0
- package/dist/app.d.ts +22 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +159 -0
- package/dist/app.js.map +1 -0
- package/dist/bin/forge.d.ts +3 -0
- package/dist/bin/forge.d.ts.map +1 -0
- package/dist/bin/forge.js +747 -0
- package/dist/bin/forge.js.map +1 -0
- package/dist/chat-files.d.ts +37 -0
- package/dist/chat-files.d.ts.map +1 -0
- package/dist/chat-files.js +152 -0
- package/dist/chat-files.js.map +1 -0
- package/dist/commands/acp.d.ts +4 -0
- package/dist/commands/acp.d.ts.map +1 -0
- package/dist/commands/acp.js +36 -0
- package/dist/commands/acp.js.map +1 -0
- package/dist/commands/ai-comments.d.ts +9 -0
- package/dist/commands/ai-comments.d.ts.map +1 -0
- package/dist/commands/ai-comments.js +43 -0
- package/dist/commands/ai-comments.js.map +1 -0
- package/dist/commands/architect.d.ts +9 -0
- package/dist/commands/architect.d.ts.map +1 -0
- package/dist/commands/architect.js +27 -0
- package/dist/commands/architect.js.map +1 -0
- package/dist/commands/checkpoint.d.ts +10 -0
- package/dist/commands/checkpoint.d.ts.map +1 -0
- package/dist/commands/checkpoint.js +168 -0
- package/dist/commands/checkpoint.js.map +1 -0
- package/dist/commands/config.d.ts +15 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +180 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/diff.d.ts +29 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +90 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/hook.d.ts +10 -0
- package/dist/commands/hook.d.ts.map +1 -0
- package/dist/commands/hook.js +121 -0
- package/dist/commands/hook.js.map +1 -0
- package/dist/commands/mcp.d.ts +10 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +110 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/orchestrator.d.ts +22 -0
- package/dist/commands/orchestrator.d.ts.map +1 -0
- package/dist/commands/orchestrator.js +113 -0
- package/dist/commands/orchestrator.js.map +1 -0
- package/dist/commands/plugin.d.ts +3 -0
- package/dist/commands/plugin.d.ts.map +1 -0
- package/dist/commands/plugin.js +62 -0
- package/dist/commands/plugin.js.map +1 -0
- package/dist/commands/policy.d.ts +2 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +21 -0
- package/dist/commands/policy.js.map +1 -0
- package/dist/commands/skill.d.ts +13 -0
- package/dist/commands/skill.d.ts.map +1 -0
- package/dist/commands/skill.js +194 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/spec.d.ts +13 -0
- package/dist/commands/spec.d.ts.map +1 -0
- package/dist/commands/spec.js +76 -0
- package/dist/commands/spec.js.map +1 -0
- package/dist/commands/watch.d.ts +15 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/commands/watch.js +83 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/commands/worktree.d.ts +22 -0
- package/dist/commands/worktree.d.ts.map +1 -0
- package/dist/commands/worktree.js +187 -0
- package/dist/commands/worktree.js.map +1 -0
- package/dist/components/Approval.d.ts +9 -0
- package/dist/components/Approval.d.ts.map +1 -0
- package/dist/components/Approval.js +37 -0
- package/dist/components/Approval.js.map +1 -0
- package/dist/components/Footer.d.ts +22 -0
- package/dist/components/Footer.d.ts.map +1 -0
- package/dist/components/Footer.js +59 -0
- package/dist/components/Footer.js.map +1 -0
- package/dist/components/Header.d.ts +11 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +23 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/Spinner.d.ts +3 -0
- package/dist/components/Spinner.d.ts.map +1 -0
- package/dist/components/Spinner.js +8 -0
- package/dist/components/Spinner.js.map +1 -0
- package/dist/components/Stream.d.ts +10 -0
- package/dist/components/Stream.d.ts.map +1 -0
- package/dist/components/Stream.js +13 -0
- package/dist/components/Stream.js.map +1 -0
- package/dist/components/ToolEvent.d.ts +13 -0
- package/dist/components/ToolEvent.d.ts.map +1 -0
- package/dist/components/ToolEvent.js +54 -0
- package/dist/components/ToolEvent.js.map +1 -0
- package/dist/credential-store.d.ts +8 -0
- package/dist/credential-store.d.ts.map +1 -0
- package/dist/credential-store.js +37 -0
- package/dist/credential-store.js.map +1 -0
- package/dist/git-workflow.d.ts +16 -0
- package/dist/git-workflow.d.ts.map +1 -0
- package/dist/git-workflow.js +38 -0
- package/dist/git-workflow.js.map +1 -0
- package/dist/mcp-loader.d.ts +25 -0
- package/dist/mcp-loader.d.ts.map +1 -0
- package/dist/mcp-loader.js +99 -0
- package/dist/mcp-loader.js.map +1 -0
- package/dist/notify.d.ts +12 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +47 -0
- package/dist/notify.js.map +1 -0
- package/dist/options.d.ts +407 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +792 -0
- package/dist/options.js.map +1 -0
- package/dist/repl-commands.d.ts +53 -0
- package/dist/repl-commands.d.ts.map +1 -0
- package/dist/repl-commands.js +141 -0
- package/dist/repl-commands.js.map +1 -0
- package/dist/repl.d.ts +52 -0
- package/dist/repl.d.ts.map +1 -0
- package/dist/repl.js +1174 -0
- package/dist/repl.js.map +1 -0
- package/dist/runner.d.ts +64 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +61 -0
- package/dist/runner.js.map +1 -0
- package/dist/session-checkpoints.d.ts +6 -0
- package/dist/session-checkpoints.d.ts.map +1 -0
- package/dist/session-checkpoints.js +25 -0
- package/dist/session-checkpoints.js.map +1 -0
- package/dist/specialists-loader.d.ts +22 -0
- package/dist/specialists-loader.d.ts.map +1 -0
- package/dist/specialists-loader.js +38 -0
- package/dist/specialists-loader.js.map +1 -0
- package/dist/tool-display.d.ts +19 -0
- package/dist/tool-display.d.ts.map +1 -0
- package/dist/tool-display.js +57 -0
- package/dist/tool-display.js.map +1 -0
- package/package.json +70 -3
package/dist/options.js
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { Command, Option } from 'commander';
|
|
5
|
+
import { createExploreEditRouter, createFileAuditSink, detectProvider, defaultModelFor, expandModelAlias, HooksBus, loadForgeConfigFile, resolveAutoApprove, loadHooksConfig, loadSkillsRegistryAsync, loadSteering, loadToolSandboxConfig, mergeToolSandbox, resolveModelAlias, SPEC_READONLY_TOOLS, splitModelRef, } from '@ikarmic/forge-core';
|
|
6
|
+
const REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'];
|
|
7
|
+
/**
|
|
8
|
+
* Build the `forge` commander program. Kept in its own module so tests can
|
|
9
|
+
* poke at parsing without spawning a process.
|
|
10
|
+
*/
|
|
11
|
+
export function buildProgram() {
|
|
12
|
+
const program = new Command();
|
|
13
|
+
const pkg = readPackageVersion();
|
|
14
|
+
program
|
|
15
|
+
.name('forge')
|
|
16
|
+
.description('IKarmic AI Forge: an agentic AI coding assistant.')
|
|
17
|
+
.version(pkg)
|
|
18
|
+
.argument('[task...]', 'plain-English task for the agent (omit to launch the interactive REPL)')
|
|
19
|
+
.option('-p, --provider <name>', 'provider: openai | anthropic | google | openrouter | ollama | minimax | bedrock | vertex | azure | lmstudio | openai-compatible')
|
|
20
|
+
.option('-m, --model <id>', 'model id within the chosen provider')
|
|
21
|
+
.option('-a, --auto', 'run autonomously without approval prompts', false)
|
|
22
|
+
.option('--allow-destructive', 'allow destructive tools (e.g. run_command) in --auto mode', false)
|
|
23
|
+
.option('-c, --cwd <path>', 'working directory for file tools', process.cwd())
|
|
24
|
+
.option('--max-iterations <n>', 'max agent iterations', (v) => parseInt(v, 10), 50)
|
|
25
|
+
.option('--max-duration <ms>', 'max wall-clock ms', (v) => parseInt(v, 10), 10 * 60 * 1000)
|
|
26
|
+
.option('--max-cost <usd>', 'stop the run once its cost reaches this many USD', (v) => parseFloat(v))
|
|
27
|
+
.option('--verify <cmd>', 'run this check after edits and self-fix on failure (repeatable)', (v, prev = []) => [...prev, v])
|
|
28
|
+
.option('--force', 'with `plugin install`: replace an installed plugin of the same name', false)
|
|
29
|
+
.option('--project', 'with `plugin install|remove`: use the project (.forge/plugins) instead of ~/.forge/plugins', false)
|
|
30
|
+
.option('--confirm-reads', 'ask before read-only tools too (by default read, list and search run without a prompt)', false)
|
|
31
|
+
.option('--verify-lsp', 'after edits, check the changed files with their language server and self-fix on errors', false)
|
|
32
|
+
.option('--no-project-instructions', 'do not load AGENTS.md / CLAUDE.md / CONVENTIONS.md into the system prompt')
|
|
33
|
+
.option('--dry-run', 'read-only run: the agent can read and search but cannot edit files, run commands or write memory; it describes what it would change', false)
|
|
34
|
+
.option('--notifications', 'send a desktop notification when a run finishes', false)
|
|
35
|
+
.option('--notifications-command <cmd>', 'run this command instead of the OS notifier (message in $FORGE_NOTIFY_MESSAGE); implies --notifications')
|
|
36
|
+
.option('--ai-comments', 'with `forge watch`: act on AI! (apply) and AI? (answer) comments when a file is saved', false)
|
|
37
|
+
.option('--audit', 'append a redacted record of every run and tool call to ~/.forge/audit.log')
|
|
38
|
+
.option('--browser', 'add browser tools: open a URL, screenshot, read console errors (needs Chrome/Edge/Chromium; FORGE_CHROME_PATH overrides)')
|
|
39
|
+
.option('--semantic-search', 'add the codebase_search tool (set FORGE_EMBED_PROVIDER=openai|ollama for meaning-based ranking)')
|
|
40
|
+
.option('--cheap-model <id>', 'route read-only exploration steps to this cheaper model (id, alias, or provider:model)')
|
|
41
|
+
.option('--no-cache-prompts', 'do not add prompt-cache breakpoints for Anthropic models')
|
|
42
|
+
.option('--architect', 'two-phase run: the main model plans read-only, then --editor-model applies the plan', false)
|
|
43
|
+
.option('--editor-model <id>', 'model (id, alias, or provider:model) that applies the architect plan')
|
|
44
|
+
.option('--editor-reasoning <level>', `reasoning effort for the editor model (${REASONING_EFFORTS.join(', ')}); requires --architect`)
|
|
45
|
+
.option('--auto-commit', 'commit what the agent changed after each run, with a drafted message (undo with /undo in the REPL)', false)
|
|
46
|
+
.option('--no-dirty-commits', 'with --auto-commit: do not commit your uncommitted work before the run; it stays out of the agent commit')
|
|
47
|
+
.option('--no-attribute', 'with --auto-commit: do not append "(forge)" to the commit author name')
|
|
48
|
+
.option('--git-commit-verify', 'with --auto-commit: run pre-commit hooks on Forge commits (skipped by default)', false)
|
|
49
|
+
.option('--map-tokens <n>', 'put a ranked map of the codebase (about <n> tokens) in the system prompt; 0 turns it off')
|
|
50
|
+
.option('--auto-lint', 'syntax-check each file the agent writes and feed problems back to it', false)
|
|
51
|
+
.option('--lint-cmd <cmd>', 'linter run on each file the agent writes, e.g. "eslint --fix" or "python: flake8" (repeatable; implies --auto-lint)', (v, prev = []) => [...prev, v])
|
|
52
|
+
.option('--system <prompt>', 'override the default system prompt')
|
|
53
|
+
.option('--mcp-config <path>', 'path to a forge.mcp.json file describing MCP servers to connect to')
|
|
54
|
+
.option('--resume [id]', "resume a saved session by id, or omit the value / pass 'latest' for the most recent")
|
|
55
|
+
.option('--json', 'emit structured JSON events instead of a TUI (useful for scripting)', false)
|
|
56
|
+
.option('-v, --verbose', 'enable verbose logging to stderr', false)
|
|
57
|
+
.option('--no-cost', 'disable cost tracking and display')
|
|
58
|
+
.option('--no-redact', 'disable secret redaction in tool output')
|
|
59
|
+
.option('--no-memory', 'disable project memory loading and the memory_read/memory_write tools')
|
|
60
|
+
.option('--plan', 'produce a numbered plan as text and stop (no tool execution); CLI prompts to approve/edit/cancel', false)
|
|
61
|
+
.option('--spec', 'generate requirements.md/design.md/tasks.md for the task (read-only exploration, prints text, exits 0)', false)
|
|
62
|
+
.option('--enable-compaction', 'enable automatic context compaction when approaching the model window', false)
|
|
63
|
+
.option('--compact-at <fraction>', 'compaction threshold (fraction of model window); default 0.7', (v) => parseFloat(v))
|
|
64
|
+
// Tool sandbox (allow/deny + per-tool policies)
|
|
65
|
+
.option('--allow-tools <list>', 'comma-separated whitelist of tool names (only these are exposed to the model)')
|
|
66
|
+
.option('--deny-tools <list>', 'comma-separated blacklist of tool names (these are removed from the model)')
|
|
67
|
+
.option('--allow-paths <list>', 'comma-separated glob patterns; file tools may only operate on matching paths')
|
|
68
|
+
.option('--deny-paths <list>', 'comma-separated glob patterns; file tools refuse paths matching any')
|
|
69
|
+
.option('--allow-commands <list>', 'comma-separated regexes; run_command requires command to match at least one')
|
|
70
|
+
.option('--deny-commands <list>', 'comma-separated regexes; run_command refuses commands matching any')
|
|
71
|
+
.option('--allow-urls <list>', 'comma-separated regexes; fetch_url requires URL to match at least one')
|
|
72
|
+
.option('--deny-urls <list>', 'comma-separated regexes; fetch_url refuses URLs matching any')
|
|
73
|
+
.option('--schema <path>', 'structured-output mode: path to a Zod schema (.ts/.js/.mjs) or JSON Schema (.json). ' +
|
|
74
|
+
'Agent retries its final response until it matches. Combine with --json for batch/CI use.')
|
|
75
|
+
.option('--schema-retries <n>', 'max validation retries in --schema mode (default 3)', (v) => parseInt(v, 10), 3)
|
|
76
|
+
.option('--orchestrator-worktrees <mode>', 'git worktree isolation for parallel specialists: auto | always | never (default auto). ' +
|
|
77
|
+
'When auto, only steps in a parallelGroup (>1 step) get a worktree.', (v) => {
|
|
78
|
+
if (v === 'auto' || v === 'always' || v === 'never')
|
|
79
|
+
return v;
|
|
80
|
+
throw new Error(`--orchestrator-worktrees must be auto|always|never (got "${v}")`);
|
|
81
|
+
}, 'auto')
|
|
82
|
+
.option('--orchestrator-commit', 'commit each worktree specialist\'s edits onto its branch and keep the branch, so the work can be reviewed (git diff) and merged (git merge --no-ff)')
|
|
83
|
+
.option('--orchestrator-cleanup-branches', 'after the orchestrator runs, delete the auto-created forge/<label>-<sha> branches ' +
|
|
84
|
+
'alongside their worktrees (skipped if another worktree still uses the branch). Default enabled. Pass --no-... to keep them.', true)
|
|
85
|
+
.option('--with-branches', 'forge worktree cleanup: also delete the auto-created forge/<label>-<sha> branches alongside their worktrees. Default enabled. Pass --no-branches to keep them. v2.14.', true)
|
|
86
|
+
.option('--format <fmt>', 'output format for `forge session export` (md | json)', 'md')
|
|
87
|
+
.option('--output <path>', 'write to a file instead of stdout (used by `forge session export`)')
|
|
88
|
+
.option('--context <n>', 'context lines for `forge diff` (default 3)', (v) => parseInt(v, 10))
|
|
89
|
+
.option('--summary', 'print only the one-line summary for `forge diff`', false)
|
|
90
|
+
.option('--from-stdin', 'read the second input from stdin (used by `forge diff`)', false)
|
|
91
|
+
.addOption(new Option('--list-models', 'print the resolved model and exit').conflicts(['task']));
|
|
92
|
+
// We don't register `session` as a commander subcommand: commander's
|
|
93
|
+
// strict subcommand routing would reject positional task arguments that
|
|
94
|
+
// happen to start with words like "summarize". Instead, we treat
|
|
95
|
+
// `forge session …` as a soft prefix and inspect `program.args` manually
|
|
96
|
+
// after parsing.
|
|
97
|
+
return program;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Parse argv into typed options. Model resolution is **lazy**: the caller
|
|
101
|
+
* invokes {@link resolveModelOrThrow} only when an actual model is needed
|
|
102
|
+
* (i.e. when an agent run is about to start). This lets `forge session list`,
|
|
103
|
+
* `forge session show`, etc. work without an AI provider configured.
|
|
104
|
+
*/
|
|
105
|
+
/** `--map-tokens`: a non-negative integer; 0 or absent means off. */
|
|
106
|
+
export function parseMapTokens(raw) {
|
|
107
|
+
if (raw === undefined)
|
|
108
|
+
return null;
|
|
109
|
+
const n = typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : NaN;
|
|
110
|
+
if (!Number.isInteger(n) || n < 0)
|
|
111
|
+
throw new Error(`--map-tokens must be a non-negative integer, got "${String(raw)}"`);
|
|
112
|
+
return n === 0 ? null : n;
|
|
113
|
+
}
|
|
114
|
+
/** Language names accepted before the colon in `--lint-cmd "python: flake8"`. */
|
|
115
|
+
const LINT_LANGUAGE_EXTENSIONS = {
|
|
116
|
+
typescript: ['ts', 'tsx'],
|
|
117
|
+
javascript: ['js', 'jsx', 'mjs', 'cjs'],
|
|
118
|
+
python: ['py'],
|
|
119
|
+
go: ['go'],
|
|
120
|
+
rust: ['rs'],
|
|
121
|
+
java: ['java'],
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* `--lint-cmd` values into extension -> command. `"ts: eslint"` and `"typescript: eslint"`
|
|
125
|
+
* apply to those files only; a value with no `name:` prefix applies to every file (`*`).
|
|
126
|
+
* A colon not followed by a space (a Windows path) is part of the command.
|
|
127
|
+
*/
|
|
128
|
+
export function parseLintCommands(values) {
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const value of values) {
|
|
131
|
+
const m = /^([A-Za-z][A-Za-z0-9]*):\s+(\S.*)$/.exec(value.trim());
|
|
132
|
+
if (!m) {
|
|
133
|
+
const cmd = value.trim();
|
|
134
|
+
if (!cmd)
|
|
135
|
+
throw new Error('--lint-cmd needs a command');
|
|
136
|
+
out['*'] = cmd;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const key = m[1].toLowerCase();
|
|
140
|
+
for (const ext of LINT_LANGUAGE_EXTENSIONS[key] ?? [key])
|
|
141
|
+
out[ext] = m[2].trim();
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
export function parseOptions(argv) {
|
|
146
|
+
const program = buildProgram();
|
|
147
|
+
program.parse(argv);
|
|
148
|
+
const opts = program.opts();
|
|
149
|
+
const listModels = opts.listModels === true;
|
|
150
|
+
const taskParts = program.args;
|
|
151
|
+
const task = taskParts.length > 0 ? taskParts.join(' ') : null;
|
|
152
|
+
// commander's `.conflicts()` can only reference other registered Options,
|
|
153
|
+
// never a positional `.argument(...)`: so `--list-models` combined with
|
|
154
|
+
// a task string would otherwise silently discard the task. Check by hand.
|
|
155
|
+
if (listModels && task) {
|
|
156
|
+
throw new Error(`--list-models cannot be combined with a task ("${task}")`);
|
|
157
|
+
}
|
|
158
|
+
if (opts.editorReasoning !== undefined) {
|
|
159
|
+
if (opts.architect !== true)
|
|
160
|
+
throw new Error('--editor-reasoning requires --architect');
|
|
161
|
+
if (!REASONING_EFFORTS.includes(opts.editorReasoning)) {
|
|
162
|
+
throw new Error(`--editor-reasoning must be one of: ${REASONING_EFFORTS.join(', ')}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (opts.architect === true) {
|
|
166
|
+
if (typeof opts.editorModel !== 'string' || opts.editorModel.length === 0) {
|
|
167
|
+
throw new Error('--architect requires --editor-model <id|alias>');
|
|
168
|
+
}
|
|
169
|
+
if (opts.plan === true || opts.spec === true) {
|
|
170
|
+
throw new Error('--architect cannot be combined with --plan or --spec');
|
|
171
|
+
}
|
|
172
|
+
if (opts.resume !== undefined || opts.schema !== undefined) {
|
|
173
|
+
throw new Error('--architect cannot be combined with --resume or --schema');
|
|
174
|
+
}
|
|
175
|
+
if (!task) {
|
|
176
|
+
throw new Error('--architect needs a task (it does not apply to the interactive REPL)');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const cliOptions = {
|
|
180
|
+
task,
|
|
181
|
+
provider: opts.provider ?? null,
|
|
182
|
+
model: opts.model ?? null,
|
|
183
|
+
auto: opts.auto === true,
|
|
184
|
+
allowDestructive: opts.allowDestructive === true,
|
|
185
|
+
cwd: opts.cwd ?? null,
|
|
186
|
+
maxIterations: opts.maxIterations ?? null,
|
|
187
|
+
maxDurationMs: opts.maxDuration ?? null,
|
|
188
|
+
maxCostUsd: typeof opts.maxCost === 'number' && Number.isFinite(opts.maxCost) && opts.maxCost > 0 ? opts.maxCost : null,
|
|
189
|
+
cheapModel: opts.cheapModel ?? null,
|
|
190
|
+
noCachePrompts: opts.cachePrompts === false,
|
|
191
|
+
architect: opts.architect === true,
|
|
192
|
+
editorModel: opts.editorModel ?? null,
|
|
193
|
+
editorReasoning: opts.editorReasoning ?? null,
|
|
194
|
+
autoCommit: opts.autoCommit === true,
|
|
195
|
+
noDirtyCommits: opts.dirtyCommits === false,
|
|
196
|
+
noAttribute: opts.attribute === false,
|
|
197
|
+
gitCommitVerify: opts.gitCommitVerify === true,
|
|
198
|
+
mapTokens: parseMapTokens(opts.mapTokens),
|
|
199
|
+
lintCommands: parseLintCommands(Array.isArray(opts.lintCmd) ? opts.lintCmd : []),
|
|
200
|
+
autoLint: opts.autoLint === true || (Array.isArray(opts.lintCmd) && opts.lintCmd.length > 0),
|
|
201
|
+
verify: Array.isArray(opts.verify) ? opts.verify : [],
|
|
202
|
+
verifyLsp: opts.verifyLsp === true,
|
|
203
|
+
confirmReads: opts.confirmReads === true,
|
|
204
|
+
dryRun: opts.dryRun === true,
|
|
205
|
+
notifications: opts.notifications === true || typeof opts.notificationsCommand === 'string',
|
|
206
|
+
notificationsCommand: opts.notificationsCommand ?? null,
|
|
207
|
+
aiComments: opts.aiComments === true,
|
|
208
|
+
noProjectInstructions: opts.projectInstructions === false,
|
|
209
|
+
semanticSearch: opts.semanticSearch === true,
|
|
210
|
+
browser: opts.browser === true,
|
|
211
|
+
audit: opts.audit === true,
|
|
212
|
+
verbose: opts.verbose === true,
|
|
213
|
+
systemPrompt: opts.system ?? null,
|
|
214
|
+
json: opts.json === true,
|
|
215
|
+
mcpConfig: opts.mcpConfig ?? null,
|
|
216
|
+
noCost: opts.cost === false,
|
|
217
|
+
noRedact: opts.redact === false,
|
|
218
|
+
noMemory: opts.memory === false,
|
|
219
|
+
plan: opts.plan === true,
|
|
220
|
+
spec: opts.spec === true,
|
|
221
|
+
enableCompaction: opts.enableCompaction === true,
|
|
222
|
+
compactAt: typeof opts.compactAt === 'number' ? opts.compactAt : null,
|
|
223
|
+
allowTools: opts.allowTools ?? null,
|
|
224
|
+
denyTools: opts.denyTools ?? null,
|
|
225
|
+
allowPaths: opts.allowPaths ?? null,
|
|
226
|
+
denyPaths: opts.denyPaths ?? null,
|
|
227
|
+
allowCommands: opts.allowCommands ?? null,
|
|
228
|
+
denyCommands: opts.denyCommands ?? null,
|
|
229
|
+
allowUrls: opts.allowUrls ?? null,
|
|
230
|
+
denyUrls: opts.denyUrls ?? null,
|
|
231
|
+
schema: opts.schema ?? null,
|
|
232
|
+
schemaRetries: opts.schemaRetries ?? 3,
|
|
233
|
+
orchestratorWorktrees: opts.orchestratorWorktrees ?? 'auto',
|
|
234
|
+
orchestratorCleanupBranches: opts.orchestratorCleanupBranches ?? true,
|
|
235
|
+
orchestratorCommit: opts.orchestratorCommit === true,
|
|
236
|
+
};
|
|
237
|
+
const sessionCommand = extractSessionCommand(program);
|
|
238
|
+
const skillCommand = extractSkillCommand(program, opts);
|
|
239
|
+
const pluginCommand = extractPluginCommand(program, opts);
|
|
240
|
+
const hookCommand = extractHookCommand(program);
|
|
241
|
+
const mcpCommand = extractMcpCommand(program, opts);
|
|
242
|
+
const checkpointCommand = extractCheckpointCommand(program, opts);
|
|
243
|
+
const specCommand = extractSpecCommand(program, opts);
|
|
244
|
+
// `forge watch` has no sub-forms (see options.ts's header note on
|
|
245
|
+
// ParsedWatchCommand vs. a plain boolean): a single positional-word
|
|
246
|
+
// check, not a soft-prefix parser function.
|
|
247
|
+
const watchCommand = program.args[0] === 'watch';
|
|
248
|
+
const diffCommand = extractDiffCommand(program, opts);
|
|
249
|
+
const orchestratorCommand = extractOrchestratorCommand(program);
|
|
250
|
+
const worktreeCommand = extractWorktreeCommand(program, opts);
|
|
251
|
+
const configCommand = extractConfigCommand(program);
|
|
252
|
+
const acpCommand = program.args[0] === 'acp';
|
|
253
|
+
const policyCommand = program.args[0] === 'policy';
|
|
254
|
+
const resolveModel = () => resolveModelNow(cliOptions);
|
|
255
|
+
return {
|
|
256
|
+
options: cliOptions,
|
|
257
|
+
resolveModel,
|
|
258
|
+
listModels,
|
|
259
|
+
sessionCommand,
|
|
260
|
+
skillCommand,
|
|
261
|
+
pluginCommand,
|
|
262
|
+
hookCommand,
|
|
263
|
+
mcpCommand,
|
|
264
|
+
checkpointCommand,
|
|
265
|
+
specCommand,
|
|
266
|
+
watchCommand,
|
|
267
|
+
diffCommand,
|
|
268
|
+
orchestratorCommand,
|
|
269
|
+
worktreeCommand,
|
|
270
|
+
configCommand,
|
|
271
|
+
acpCommand,
|
|
272
|
+
policyCommand,
|
|
273
|
+
version: readPackageVersion(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** Soft-prefix parser for `forge config` (no sub-forms: always just runs the wizard). */
|
|
277
|
+
function extractConfigCommand(program) {
|
|
278
|
+
const args = program.args;
|
|
279
|
+
if (args[0] !== 'config')
|
|
280
|
+
return null;
|
|
281
|
+
return { run: true };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Rewrite `--model` through the user's aliases (`~/.forge/config.json`).
|
|
285
|
+
* A `provider:` prefix (typed directly or from an alias) also sets the provider. Cheap and
|
|
286
|
+
* editor models are resolved later, against the main model's provider.
|
|
287
|
+
*/
|
|
288
|
+
export function applyModelAliases(options, aliases) {
|
|
289
|
+
if (!options.model)
|
|
290
|
+
return;
|
|
291
|
+
const { provider, model } = splitModelRef(expandModelAlias(options.model, aliases));
|
|
292
|
+
options.model = model;
|
|
293
|
+
if (provider)
|
|
294
|
+
options.provider = provider;
|
|
295
|
+
}
|
|
296
|
+
function resolveModelNow(opts) {
|
|
297
|
+
if (opts.provider && opts.model) {
|
|
298
|
+
return { provider: opts.provider, model: opts.model };
|
|
299
|
+
}
|
|
300
|
+
const provider = opts.provider ?? detectProvider();
|
|
301
|
+
if (!provider) {
|
|
302
|
+
throw new Error('No AI provider configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, ' +
|
|
303
|
+
'OPENROUTER_API_KEY, or MINIMAX_API_KEY (or run `ollama serve` for local models), or pass --provider and --model explicitly.');
|
|
304
|
+
}
|
|
305
|
+
const model = opts.model ?? process.env.FORGE_DEFAULT_MODEL ?? defaultModelFor(provider);
|
|
306
|
+
return { provider, model };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Read the package version from package.json so `forge --version` stays in
|
|
310
|
+
* sync with whatever we shipped. Falls back to "0.0.0" if package.json is
|
|
311
|
+
* missing or unreadable (e.g. someone ran the CLI directly from a checked-out
|
|
312
|
+
* tree without `pnpm build`).
|
|
313
|
+
*
|
|
314
|
+
* Note: we walk the path manually rather than `new URL('../package.json',
|
|
315
|
+
* import.meta.url)`. The URL resolver mishandles Windows drive letters: for
|
|
316
|
+
* `file:///D:/.../dist/options.js`, `..` crosses the `/D:/.../` boundary and
|
|
317
|
+
* lands at the wrong directory. Same goes for `path.join` on Windows when
|
|
318
|
+
* the path is reached via a non-`path.resolve` call.
|
|
319
|
+
*/
|
|
320
|
+
function readPackageVersion() {
|
|
321
|
+
try {
|
|
322
|
+
const here = fileURLToPath(import.meta.url);
|
|
323
|
+
// here = `<...>/packages/cli/dist/options.js`
|
|
324
|
+
// pkgPath = `<...>/packages/cli/package.json`
|
|
325
|
+
const pkgPath = join(dirname(here), '..', 'package.json');
|
|
326
|
+
const text = readFileSync(pkgPath, 'utf8');
|
|
327
|
+
const parsed = JSON.parse(text);
|
|
328
|
+
return typeof parsed.version === 'string' ? parsed.version : '0.0.0';
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return '0.0.0';
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function extractSessionCommand(program) {
|
|
335
|
+
const args = program.args;
|
|
336
|
+
const opts = program.opts();
|
|
337
|
+
const resumeFlag = parseResumeFlag(opts.resume);
|
|
338
|
+
// Path 1: explicit `forge session <subcommand> [id] [--flag]` invocation.
|
|
339
|
+
// We handle this as a "soft prefix" so it doesn't collide with positional
|
|
340
|
+
// task arguments (`forge summarize files` must keep working).
|
|
341
|
+
if (args[0] === 'session') {
|
|
342
|
+
const sub = args[1];
|
|
343
|
+
const id = args[2];
|
|
344
|
+
if (sub === 'list' || sub === 'ls') {
|
|
345
|
+
// Tail flags go to the program-level opts (e.g. --limit). We pull
|
|
346
|
+
// `--limit` out of opts after the soft prefix; everything after the
|
|
347
|
+
// subcommand is appended to opts by `parseOptions` via `parse()`.
|
|
348
|
+
const limit = opts.limit ?? 20;
|
|
349
|
+
return { list: { limit } };
|
|
350
|
+
}
|
|
351
|
+
if (sub === 'show' && id) {
|
|
352
|
+
return { show: { id, full: opts.full === true } };
|
|
353
|
+
}
|
|
354
|
+
if (sub === 'delete' && id) {
|
|
355
|
+
return { delete: id };
|
|
356
|
+
}
|
|
357
|
+
if (sub === 'export' && id) {
|
|
358
|
+
const formatRaw = opts.format ?? 'md';
|
|
359
|
+
const format = formatRaw === 'json' ? 'json' : 'md';
|
|
360
|
+
const output = opts.output ?? undefined;
|
|
361
|
+
return { export: { id, format, output } };
|
|
362
|
+
}
|
|
363
|
+
if (sub === 'tail')
|
|
364
|
+
return { tail: { id: id ?? 'latest' } };
|
|
365
|
+
if (sub === 'resume') {
|
|
366
|
+
if (opts.latest === true)
|
|
367
|
+
return { resume: { kind: 'latest' } };
|
|
368
|
+
if (id)
|
|
369
|
+
return { resume: { kind: 'id', id } };
|
|
370
|
+
return { resume: { kind: 'latest' } };
|
|
371
|
+
}
|
|
372
|
+
return {};
|
|
373
|
+
}
|
|
374
|
+
// Path 2: top-level --resume flag.
|
|
375
|
+
if (resumeFlag.kind !== 'none') {
|
|
376
|
+
return { resume: resumeFlag };
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
function parseResumeFlag(raw) {
|
|
381
|
+
if (raw === undefined || raw === false)
|
|
382
|
+
return { kind: 'none' };
|
|
383
|
+
if (raw === true || raw === '')
|
|
384
|
+
return { kind: 'latest' };
|
|
385
|
+
if (raw === 'latest')
|
|
386
|
+
return { kind: 'latest' };
|
|
387
|
+
if (typeof raw === 'string')
|
|
388
|
+
return { kind: 'id', id: raw };
|
|
389
|
+
return { kind: 'none' };
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Soft-prefix parser for `forge skill list|show|install|search|browse`.
|
|
393
|
+
* `forge skill install <source>`: `<source>` is a skill name (looked
|
|
394
|
+
* up in the marketplace catalog), a GitHub URL, or `owner/repo[#subdir]`
|
|
395
|
+
* shorthand.
|
|
396
|
+
*/
|
|
397
|
+
function extractSkillCommand(program, opts) {
|
|
398
|
+
const args = program.args;
|
|
399
|
+
if (args[0] !== 'skill')
|
|
400
|
+
return null;
|
|
401
|
+
const sub = args[1];
|
|
402
|
+
if (sub === 'list' || sub === 'ls')
|
|
403
|
+
return { list: true };
|
|
404
|
+
if (sub === 'show' && args[2])
|
|
405
|
+
return { show: { name: args[2] } };
|
|
406
|
+
if (sub === 'install' && args[2]) {
|
|
407
|
+
return {
|
|
408
|
+
install: {
|
|
409
|
+
url: args[2],
|
|
410
|
+
overwrite: opts.force === true || opts.overwrite === true,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (sub === 'search') {
|
|
415
|
+
const query = args.slice(2).join(' ').trim();
|
|
416
|
+
if (!query)
|
|
417
|
+
return {};
|
|
418
|
+
const limit = typeof opts.limit === 'number' ? opts.limit : null;
|
|
419
|
+
return { search: { query, limit } };
|
|
420
|
+
}
|
|
421
|
+
if (sub === 'browse')
|
|
422
|
+
return { browse: true };
|
|
423
|
+
return {};
|
|
424
|
+
}
|
|
425
|
+
/** Soft-prefix parser for `forge plugin …`. Returns `null` when the first positional arg isn't `plugin`. */
|
|
426
|
+
function extractPluginCommand(program, opts) {
|
|
427
|
+
const args = program.args;
|
|
428
|
+
if (args[0] !== 'plugin')
|
|
429
|
+
return null;
|
|
430
|
+
const sub = args[1];
|
|
431
|
+
const project = opts.project === true;
|
|
432
|
+
if (sub === 'list' || sub === 'ls')
|
|
433
|
+
return { list: true };
|
|
434
|
+
if (sub === 'show' && args[2])
|
|
435
|
+
return { show: { name: args[2] } };
|
|
436
|
+
if (sub === 'install' && args[2])
|
|
437
|
+
return { install: { source: args[2], overwrite: opts.force === true, project } };
|
|
438
|
+
if ((sub === 'remove' || sub === 'rm') && args[2])
|
|
439
|
+
return { remove: { name: args[2], project } };
|
|
440
|
+
return {};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Soft-prefix parser for `forge hook list|test|log`. Returns `null` when
|
|
444
|
+
* the first positional arg isn't `hook`.
|
|
445
|
+
*/
|
|
446
|
+
function extractHookCommand(program) {
|
|
447
|
+
const args = program.args;
|
|
448
|
+
const opts = program.opts();
|
|
449
|
+
if (args[0] !== 'hook')
|
|
450
|
+
return null;
|
|
451
|
+
const sub = args[1];
|
|
452
|
+
if (sub === 'list' || sub === 'ls')
|
|
453
|
+
return { list: true };
|
|
454
|
+
if (sub === 'log') {
|
|
455
|
+
const limit = opts.limit ?? 20;
|
|
456
|
+
return { log: { limit } };
|
|
457
|
+
}
|
|
458
|
+
if (sub === 'test' && args[2]) {
|
|
459
|
+
return { test: { event: args[2], tool: args[3] } };
|
|
460
|
+
}
|
|
461
|
+
return {};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Soft-prefix parser for `forge mcp list|install|remove|browse`.
|
|
465
|
+
* `forge mcp install <source> [--overwrite]`: `<source>` is a GitHub
|
|
466
|
+
* URL or `owner/repo[#subdir]` shorthand. `--force` / `--overwrite`
|
|
467
|
+
* replaces an existing install.
|
|
468
|
+
*/
|
|
469
|
+
function extractMcpCommand(program, opts) {
|
|
470
|
+
const args = program.args;
|
|
471
|
+
if (args[0] !== 'mcp')
|
|
472
|
+
return null;
|
|
473
|
+
const sub = args[1];
|
|
474
|
+
if (sub === 'list' || sub === 'ls')
|
|
475
|
+
return { list: true };
|
|
476
|
+
if (sub === 'browse')
|
|
477
|
+
return { browse: true };
|
|
478
|
+
if (sub === 'install' && args[2]) {
|
|
479
|
+
return {
|
|
480
|
+
install: {
|
|
481
|
+
source: args[2],
|
|
482
|
+
overwrite: opts.force === true || opts.overwrite === true,
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (sub === 'remove' && args[2]) {
|
|
487
|
+
return { remove: { name: args[2] } };
|
|
488
|
+
}
|
|
489
|
+
return {};
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Soft-prefix parser for `forge checkpoint list|show|restore|prune`.
|
|
493
|
+
* Restore requires `--yes` (set via the parsed opts); we read it from
|
|
494
|
+
* the program-level option bag the same way `session delete` does.
|
|
495
|
+
*/
|
|
496
|
+
function extractCheckpointCommand(program, opts) {
|
|
497
|
+
const args = program.args;
|
|
498
|
+
if (args[0] !== 'checkpoint')
|
|
499
|
+
return null;
|
|
500
|
+
const sub = args[1];
|
|
501
|
+
const sessionId = opts.session ?? undefined;
|
|
502
|
+
const base = sessionId ? { sessionId } : {};
|
|
503
|
+
if (sub === 'list' || sub === 'ls') {
|
|
504
|
+
const limit = typeof opts.limit === 'number' ? opts.limit : null;
|
|
505
|
+
return { ...base, list: { limit } };
|
|
506
|
+
}
|
|
507
|
+
if (sub === 'show' && args[2])
|
|
508
|
+
return { ...base, show: { id: args[2] } };
|
|
509
|
+
if (sub === 'restore' && args[2]) {
|
|
510
|
+
return {
|
|
511
|
+
...base,
|
|
512
|
+
restore: { id: args[2], yes: opts.yes === true },
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (sub === 'prune') {
|
|
516
|
+
const keep = typeof opts.keep === 'number' ? opts.keep : null;
|
|
517
|
+
return { ...base, prune: { keep } };
|
|
518
|
+
}
|
|
519
|
+
return base;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Soft-prefix parser for `forge spec list|show|cancel`.
|
|
523
|
+
*/
|
|
524
|
+
function extractSpecCommand(program, opts) {
|
|
525
|
+
const args = program.args;
|
|
526
|
+
if (args[0] !== 'spec')
|
|
527
|
+
return null;
|
|
528
|
+
const sub = args[1];
|
|
529
|
+
if (sub === 'list' || sub === 'ls')
|
|
530
|
+
return { list: true };
|
|
531
|
+
if (sub === 'show' && args[2])
|
|
532
|
+
return { show: { id: args[2] } };
|
|
533
|
+
if (sub === 'cancel' && args[2]) {
|
|
534
|
+
return { cancel: { id: args[2], yes: opts.yes === true } };
|
|
535
|
+
}
|
|
536
|
+
return {};
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Soft-prefix parser for `forge orchestrator list|run|plan`.
|
|
540
|
+
* `run <task...>` and `plan <task...>` take the rest of argv as the task.
|
|
541
|
+
*/
|
|
542
|
+
function extractOrchestratorCommand(program) {
|
|
543
|
+
const args = program.args;
|
|
544
|
+
if (args[0] !== 'orchestrator')
|
|
545
|
+
return null;
|
|
546
|
+
const sub = args[1];
|
|
547
|
+
if (sub === 'list' || sub === 'ls')
|
|
548
|
+
return { list: true };
|
|
549
|
+
const rest = args.slice(2).join(' ').trim();
|
|
550
|
+
if (sub === 'run' && rest)
|
|
551
|
+
return { run: { task: rest } };
|
|
552
|
+
if (sub === 'plan' && rest)
|
|
553
|
+
return { plan: { task: rest } };
|
|
554
|
+
return {};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Soft-prefix parser for `forge diff <fileA> <fileB> [--from-stdin]`.
|
|
558
|
+
* Supports comparing a file to another file, or a file to stdin
|
|
559
|
+
* (the latter for `forge diff --from-stdin package.json < new.json`).
|
|
560
|
+
*/
|
|
561
|
+
function extractDiffCommand(program, opts) {
|
|
562
|
+
const args = program.args;
|
|
563
|
+
if (args[0] !== 'diff')
|
|
564
|
+
return null;
|
|
565
|
+
const fromStdin = opts.fromStdin === true;
|
|
566
|
+
const fileA = args[1];
|
|
567
|
+
if (!fileA)
|
|
568
|
+
return null;
|
|
569
|
+
const fileB = fromStdin ? undefined : args[2];
|
|
570
|
+
const ctxRaw = opts.context;
|
|
571
|
+
const contextLines = typeof ctxRaw === 'number' ? ctxRaw : undefined;
|
|
572
|
+
const summaryOnly = opts.summary === true;
|
|
573
|
+
return {
|
|
574
|
+
fileA,
|
|
575
|
+
...(fileB ? { fileB } : {}),
|
|
576
|
+
...(contextLines !== undefined ? { contextLines } : {}),
|
|
577
|
+
...(fromStdin ? { fromStdin: true } : {}),
|
|
578
|
+
...(summaryOnly ? { summaryOnly: true } : {}),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Soft-prefix parser for `forge worktree list | cleanup | prune | sweep`.
|
|
583
|
+
* v2.11 + v2.14. Matches the other subcommand parsers in style.
|
|
584
|
+
*/
|
|
585
|
+
function extractWorktreeCommand(program, opts) {
|
|
586
|
+
const args = program.args;
|
|
587
|
+
if (args[0] !== 'worktree')
|
|
588
|
+
return null;
|
|
589
|
+
const sub = args[1];
|
|
590
|
+
if (sub === 'list' || sub === 'ls')
|
|
591
|
+
return { list: true };
|
|
592
|
+
if (sub === 'cleanup') {
|
|
593
|
+
return {
|
|
594
|
+
cleanup: {
|
|
595
|
+
force: opts.force === true,
|
|
596
|
+
// v2.14: default to cleaning up branches. Pass --no-branches
|
|
597
|
+
// to keep them. The flag is registered as a boolean on the
|
|
598
|
+
// root program so commander normalises `--no-...` flips for us.
|
|
599
|
+
withBranches: opts.withBranches !== false,
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (sub === 'prune')
|
|
604
|
+
return { prune: true };
|
|
605
|
+
// v2.14: new one-shot cleanup.
|
|
606
|
+
if (sub === 'sweep') {
|
|
607
|
+
return { sweep: { force: opts.force === true } };
|
|
608
|
+
}
|
|
609
|
+
return {};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Convert parsed CLI options into an AgentConfig. The approval callback is
|
|
613
|
+
* supplied by the UI layer (it varies between TUI and JSON modes), so it's
|
|
614
|
+
* left as a parameter. `extraTools` (from MCP, sub-agents, etc.) are merged
|
|
615
|
+
* in here.
|
|
616
|
+
*
|
|
617
|
+
* Always loads skills + hooks from disk when not opted out. Skills are
|
|
618
|
+
* scanned from `<cwd>/skills/`, `<cwd>/.forge/skills/`, `~/.forge/skills/`.
|
|
619
|
+
* Hooks are loaded from `~/.forge/settings.json` + `<cwd>/.forge/settings.json`.
|
|
620
|
+
*/
|
|
621
|
+
export async function toAgentConfig(options, model, approve, extraTools = []) {
|
|
622
|
+
const cwd = options.cwd ?? process.cwd();
|
|
623
|
+
const [skillsRegistry, hooksConfig, sandboxFromFile, sharedConfig, steering] = await Promise.all([
|
|
624
|
+
loadSkillsRegistryAsync(cwd),
|
|
625
|
+
loadHooksConfig(cwd),
|
|
626
|
+
// CLI never trusts project-level settings: matches the existing
|
|
627
|
+
// hooks precedent (trustedProjectHooks is never true in the CLI).
|
|
628
|
+
loadToolSandboxConfig(cwd, { trustedProjectSettings: false }),
|
|
629
|
+
loadForgeConfigFile(),
|
|
630
|
+
loadSteering(cwd),
|
|
631
|
+
]);
|
|
632
|
+
for (const w of steering.warnings)
|
|
633
|
+
process.stderr.write(`forge: ${w}\n`);
|
|
634
|
+
const cliSandbox = buildSandbox(options) ?? {};
|
|
635
|
+
const sandbox = mergeToolSandbox(sandboxFromFile.sandbox, cliSandbox);
|
|
636
|
+
// Populate reasoningEffort onto the actual ModelConfig handed to Agent:
|
|
637
|
+
// it's stored/editable via both settings UIs but nothing wired it onto
|
|
638
|
+
// the running model until now. Mutates the caller's `model` object (the
|
|
639
|
+
// same reference used for both AgentConfig.model and the model prop
|
|
640
|
+
// threaded to App/Repl) so the effect reaches both.
|
|
641
|
+
if (!model.reasoningEffort && sharedConfig.config.reasoningEffort) {
|
|
642
|
+
model.reasoningEffort = sharedConfig.config.reasoningEffort;
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
model,
|
|
646
|
+
cwd: options.cwd ?? undefined,
|
|
647
|
+
auto: options.auto,
|
|
648
|
+
...(options.confirmReads ? {} : { autoApproveTools: resolveAutoApprove(sharedConfig.config.autoApproveTools) }),
|
|
649
|
+
allowDestructive: options.allowDestructive,
|
|
650
|
+
maxIterations: options.maxIterations ?? undefined,
|
|
651
|
+
maxDurationMs: options.maxDurationMs ?? undefined,
|
|
652
|
+
...(options.maxCostUsd !== null ? { maxCostUsd: options.maxCostUsd } : {}),
|
|
653
|
+
...(options.audit ? { audit: createFileAuditSink() } : {}),
|
|
654
|
+
...(options.browser ? { browser: true } : {}),
|
|
655
|
+
...(options.semanticSearch ? { semanticSearch: {} } : {}),
|
|
656
|
+
...(options.verify.length > 0 || options.verifyLsp
|
|
657
|
+
? { verify: { commands: options.verify, ...(options.verifyLsp ? { lsp: true } : {}) } }
|
|
658
|
+
: {}),
|
|
659
|
+
...(options.mapTokens ? { repoMap: { tokens: options.mapTokens } } : {}),
|
|
660
|
+
...(options.autoLint ? { autoLint: { commands: options.lintCommands } } : {}),
|
|
661
|
+
...(options.noProjectInstructions ? { disableProjectInstructions: true } : {}),
|
|
662
|
+
...(options.cheapModel
|
|
663
|
+
? {
|
|
664
|
+
modelRouter: createExploreEditRouter({
|
|
665
|
+
strong: model,
|
|
666
|
+
cheap: { ...model, ...resolveModelAlias(options.cheapModel, sharedConfig.config.aliases, model.provider) },
|
|
667
|
+
}),
|
|
668
|
+
}
|
|
669
|
+
: {}),
|
|
670
|
+
...(options.noCachePrompts ? { cachePrompts: false } : {}),
|
|
671
|
+
systemPrompt: options.systemPrompt ?? undefined,
|
|
672
|
+
extraTools,
|
|
673
|
+
approve,
|
|
674
|
+
redact: options.noRedact ? { enabled: false } : {},
|
|
675
|
+
disableCost: options.noCost,
|
|
676
|
+
disableMemory: options.noMemory,
|
|
677
|
+
planMode: options.plan,
|
|
678
|
+
enableCompaction: options.enableCompaction,
|
|
679
|
+
...(options.compactAt !== null ? { compactionThreshold: options.compactAt } : {}),
|
|
680
|
+
toolSandbox: sandbox,
|
|
681
|
+
...(steering.docs.length > 0 ? { steering: { docs: steering.docs } } : {}),
|
|
682
|
+
// One-shot --spec generation needs the read-only sandbox + specMode
|
|
683
|
+
// wired together: same "caller owns the sandbox" split as core's
|
|
684
|
+
// AgentConfig.specMode itself (see specs/generate.ts's header comment).
|
|
685
|
+
// Only takes effect when --spec was actually passed; a plain task run
|
|
686
|
+
// is unaffected. Comes after the plain `sandbox` spread above so it
|
|
687
|
+
// unconditionally overrides toolSandbox: spec mode's read-only
|
|
688
|
+
// restriction always wins over --allow-tools/--deny-tools, exactly
|
|
689
|
+
// like the REPL's /spec wiring (repl.tsx's runAgent overrides).
|
|
690
|
+
...(options.spec
|
|
691
|
+
? { specMode: true, toolSandbox: { allowTools: [...SPEC_READONLY_TOOLS] } }
|
|
692
|
+
: {}),
|
|
693
|
+
skills: { registry: skillsRegistry },
|
|
694
|
+
hooks: new HooksBus(hooksConfig.config),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Build a {@link ToolSandbox} from CLI flags. Returns undefined when no
|
|
699
|
+
* sandbox-related flag was supplied (so the agent runs unrestricted).
|
|
700
|
+
*
|
|
701
|
+
* Per-tool policies are layered on top of the path/command/url lists: any
|
|
702
|
+
* field that matches a file tool gets the path policy; run_command gets
|
|
703
|
+
* both the path policy (so --allow-paths/--deny-paths also cover it) and
|
|
704
|
+
* the command policy; fetch_url gets the URL policy. This keeps the CLI
|
|
705
|
+
* surface small while letting users do most of what they'd want.
|
|
706
|
+
*/
|
|
707
|
+
export function buildSandbox(options) {
|
|
708
|
+
const allowTools = parseCsv(options.allowTools);
|
|
709
|
+
const denyTools = [...parseCsv(options.denyTools), ...(options.dryRun ? DRY_RUN_DENIED_TOOLS : [])];
|
|
710
|
+
const policies = {};
|
|
711
|
+
// Path policy: applied to every file tool. Maps allowedPaths/deniedPaths.
|
|
712
|
+
const allowedPaths = parseCsv(options.allowPaths);
|
|
713
|
+
const deniedPaths = parseCsv(options.denyPaths);
|
|
714
|
+
if (allowedPaths.length > 0 || deniedPaths.length > 0) {
|
|
715
|
+
const pathPolicy = {};
|
|
716
|
+
if (allowedPaths.length > 0)
|
|
717
|
+
pathPolicy.allowedPaths = allowedPaths;
|
|
718
|
+
if (deniedPaths.length > 0)
|
|
719
|
+
pathPolicy.deniedPaths = deniedPaths;
|
|
720
|
+
for (const name of FILE_TOOL_NAMES) {
|
|
721
|
+
policies[name] = { ...(policies[name] ?? {}), ...pathPolicy };
|
|
722
|
+
}
|
|
723
|
+
// --allow-paths/--deny-paths previously only reached the 5 file tools,
|
|
724
|
+
// so e.g. `--deny-paths "**/.env"` didn't stop `run_command({command:
|
|
725
|
+
// "cat .env"})`. Attach the same path policy here too: the sandbox
|
|
726
|
+
// check (checkPolicyViolation) evaluates deniedPaths/allowedPaths
|
|
727
|
+
// whenever the tool call supplies a resolvedPath, and this ensures
|
|
728
|
+
// run_command's policy entry carries the same patterns as the file
|
|
729
|
+
// tools even before/if run_command starts resolving paths out of its
|
|
730
|
+
// command text.
|
|
731
|
+
policies.run_command = { ...(policies.run_command ?? {}), ...pathPolicy };
|
|
732
|
+
}
|
|
733
|
+
// Command policy: applied to run_command.
|
|
734
|
+
const allowedCommands = parseRegexCsv(options.allowCommands);
|
|
735
|
+
const deniedCommands = parseRegexCsv(options.denyCommands);
|
|
736
|
+
if (allowedCommands.length > 0 || deniedCommands.length > 0) {
|
|
737
|
+
const cmdPolicy = {};
|
|
738
|
+
if (allowedCommands.length > 0)
|
|
739
|
+
cmdPolicy.allowedCommands = allowedCommands;
|
|
740
|
+
if (deniedCommands.length > 0)
|
|
741
|
+
cmdPolicy.deniedCommands = deniedCommands;
|
|
742
|
+
policies.run_command = { ...(policies.run_command ?? {}), ...cmdPolicy };
|
|
743
|
+
}
|
|
744
|
+
// URL policy: applied to fetch_url.
|
|
745
|
+
const allowedUrls = parseRegexCsv(options.allowUrls);
|
|
746
|
+
const deniedUrls = parseRegexCsv(options.denyUrls);
|
|
747
|
+
if (allowedUrls.length > 0 || deniedUrls.length > 0) {
|
|
748
|
+
const urlPolicy = {};
|
|
749
|
+
if (allowedUrls.length > 0)
|
|
750
|
+
urlPolicy.allowedUrls = allowedUrls;
|
|
751
|
+
if (deniedUrls.length > 0)
|
|
752
|
+
urlPolicy.deniedUrls = deniedUrls;
|
|
753
|
+
policies.fetch_url = { ...(policies.fetch_url ?? {}), ...urlPolicy };
|
|
754
|
+
}
|
|
755
|
+
if (allowTools.length === 0 && denyTools.length === 0 && Object.keys(policies).length === 0) {
|
|
756
|
+
return undefined;
|
|
757
|
+
}
|
|
758
|
+
const sandbox = {};
|
|
759
|
+
if (allowTools.length > 0)
|
|
760
|
+
sandbox.allowTools = allowTools;
|
|
761
|
+
if (denyTools.length > 0)
|
|
762
|
+
sandbox.denyTools = denyTools;
|
|
763
|
+
if (Object.keys(policies).length > 0)
|
|
764
|
+
sandbox.policies = policies;
|
|
765
|
+
return sandbox;
|
|
766
|
+
}
|
|
767
|
+
const FILE_TOOL_NAMES = ['read_file', 'write_file', 'edit_file', 'list_files', 'search_files'];
|
|
768
|
+
function parseCsv(raw) {
|
|
769
|
+
if (!raw)
|
|
770
|
+
return [];
|
|
771
|
+
return raw
|
|
772
|
+
.split(',')
|
|
773
|
+
.map((s) => s.trim())
|
|
774
|
+
.filter((s) => s.length > 0);
|
|
775
|
+
}
|
|
776
|
+
function parseRegexCsv(raw) {
|
|
777
|
+
if (!raw)
|
|
778
|
+
return [];
|
|
779
|
+
const out = [];
|
|
780
|
+
for (const part of parseCsv(raw)) {
|
|
781
|
+
try {
|
|
782
|
+
out.push(new RegExp(part));
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
throw new Error(`Invalid regex in sandbox flag: "${part}": ${err.message}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return out;
|
|
789
|
+
}
|
|
790
|
+
/** Tools that change anything outside the process; denied under `--dry-run`. */
|
|
791
|
+
export const DRY_RUN_DENIED_TOOLS = ['write_file', 'edit_file', 'run_command', 'browser_act', 'memory_write'];
|
|
792
|
+
//# sourceMappingURL=options.js.map
|