@ikarmic/forge-core 5.0.0 → 5.1.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/dist/agent/agent.d.ts +8 -0
- package/dist/agent/agent.js +123 -22
- package/dist/agent/background.d.ts +58 -0
- package/dist/agent/background.js +139 -0
- package/dist/agent/enhance-prompt.d.ts +3 -1
- package/dist/agent/enhance-prompt.js +6 -2
- package/dist/agent/goal.d.ts +54 -0
- package/dist/agent/goal.js +65 -0
- package/dist/agents/agent-files.d.ts +43 -0
- package/dist/agents/agent-files.js +109 -0
- package/dist/checkpoints/store.js +5 -1
- package/dist/commands/custom-commands.d.ts +9 -4
- package/dist/commands/custom-commands.js +13 -2
- package/dist/config-file.d.ts +43 -0
- package/dist/config-file.js +249 -5
- package/dist/config-merge.d.ts +13 -0
- package/dist/config-merge.js +67 -0
- package/dist/config-schema.d.ts +340 -0
- package/dist/config-schema.js +126 -0
- package/dist/config.js +2 -1
- package/dist/context/compaction.js +4 -0
- package/dist/context/mask.js +1 -1
- package/dist/custom-providers.d.ts +95 -0
- package/dist/custom-providers.js +120 -0
- package/dist/git/auto-commit.d.ts +2 -2
- package/dist/git/auto-commit.js +5 -4
- package/dist/github/github-tools.d.ts +2 -2
- package/dist/hooks/runner.js +3 -2
- package/dist/import/claude-code.d.ts +39 -0
- package/dist/import/claude-code.js +254 -0
- package/dist/index.d.ts +32 -4
- package/dist/index.js +24 -3
- package/dist/lsp/client.d.ts +5 -0
- package/dist/lsp/client.js +39 -3
- package/dist/mcp/index.d.ts +5 -1
- package/dist/mcp/index.js +3 -1
- package/dist/mcp/manager.d.ts +17 -3
- package/dist/mcp/manager.js +27 -2
- package/dist/mcp/marketplace.d.ts +2 -0
- package/dist/mcp/marketplace.js +10 -4
- package/dist/mcp/mcp-client.d.ts +12 -2
- package/dist/mcp/mcp-client.js +74 -17
- package/dist/mcp/mcp-config.d.ts +2 -0
- package/dist/mcp/mcp-config.js +1 -0
- package/dist/mcp/oauth.d.ts +55 -0
- package/dist/mcp/oauth.js +197 -0
- package/dist/mcp/resource-tools.d.ts +8 -0
- package/dist/mcp/resource-tools.js +56 -0
- package/dist/memory/session-digest.d.ts +12 -0
- package/dist/memory/session-digest.js +47 -0
- package/dist/memory/stale.js +2 -1
- package/dist/model-alias.d.ts +18 -2
- package/dist/model-alias.js +27 -3
- package/dist/model-catalog.js +0 -1
- package/dist/model-list.js +2 -1
- package/dist/model-overrides.d.ts +30 -0
- package/dist/model-overrides.js +48 -0
- package/dist/orchestrator/specialists-registry.js +2 -3
- package/dist/orchestrator/specialists.js +4 -4
- package/dist/policy/ca-certs.d.ts +8 -0
- package/dist/policy/ca-certs.js +30 -0
- package/dist/policy/proxy.d.ts +10 -3
- package/dist/policy/proxy.js +33 -6
- package/dist/pricing.js +4 -0
- package/dist/providers.d.ts +7 -0
- package/dist/providers.js +39 -5
- package/dist/semantic/embedder.d.ts +15 -0
- package/dist/semantic/embedder.js +25 -0
- package/dist/semantic/semantic-index.d.ts +10 -2
- package/dist/semantic/semantic-index.js +16 -7
- package/dist/sessions/session-store.js +2 -1
- package/dist/skills/builtin.d.ts +2 -0
- package/dist/skills/builtin.js +68 -0
- package/dist/skills/index.d.ts +2 -0
- package/dist/skills/index.js +2 -0
- package/dist/skills/loader.d.ts +10 -3
- package/dist/skills/loader.js +20 -7
- package/dist/skills/marketplace.js +2 -1
- package/dist/skills/remote.d.ts +14 -0
- package/dist/skills/remote.js +131 -0
- package/dist/skills/types.d.ts +10 -0
- package/dist/steering/extra-instructions.d.ts +8 -0
- package/dist/steering/extra-instructions.js +104 -0
- package/dist/steering/folder-instructions.d.ts +8 -0
- package/dist/steering/folder-instructions.js +28 -0
- package/dist/steering/select.js +2 -1
- package/dist/timed-fetch.d.ts +12 -0
- package/dist/timed-fetch.js +83 -0
- package/dist/tools/apply-patch.d.ts +52 -0
- package/dist/tools/apply-patch.js +197 -0
- package/dist/tools/ask-user.d.ts +27 -0
- package/dist/tools/ask-user.js +32 -0
- package/dist/tools/browser-tools.d.ts +6 -6
- package/dist/tools/browser-tools.js +3 -13
- package/dist/tools/codebase-search.d.ts +4 -2
- package/dist/tools/codebase-search.js +2 -2
- package/dist/tools/edit-file.js +5 -3
- package/dist/tools/fetch-url.d.ts +7 -0
- package/dist/tools/fetch-url.js +52 -19
- package/dist/tools/find-symbol.d.ts +2 -2
- package/dist/tools/ignore-rules.js +2 -1
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +10 -1
- package/dist/tools/memory.d.ts +6 -6
- package/dist/tools/notebook.d.ts +2 -2
- package/dist/tools/permission-rules.d.ts +32 -0
- package/dist/tools/permission-rules.js +182 -0
- package/dist/tools/read-file.d.ts +5 -0
- package/dist/tools/read-file.js +7 -2
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +2 -0
- package/dist/tools/run-command.d.ts +4 -2
- package/dist/tools/run-command.js +12 -8
- package/dist/tools/sandbox-loader.js +3 -13
- package/dist/tools/search-files.d.ts +8 -2
- package/dist/tools/search-files.js +43 -0
- package/dist/tools/shared.d.ts +5 -0
- package/dist/tools/shared.js +30 -2
- package/dist/tools/shell-env.d.ts +7 -0
- package/dist/tools/shell-env.js +9 -0
- package/dist/tools/sub-agent-wait.d.ts +29 -0
- package/dist/tools/sub-agent-wait.js +60 -0
- package/dist/tools/sub-agent.d.ts +50 -4
- package/dist/tools/sub-agent.js +210 -125
- package/dist/tools/text-encoding.d.ts +10 -0
- package/dist/tools/text-encoding.js +40 -0
- package/dist/tools/todo.d.ts +36 -0
- package/dist/tools/todo.js +42 -0
- package/dist/tools/write-file.js +15 -9
- package/dist/tools/write-tools.d.ts +12 -0
- package/dist/tools/write-tools.js +18 -0
- package/dist/types.d.ts +73 -0
- package/dist/worktree/worktree.d.ts +3 -0
- package/dist/worktree/worktree.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const MAX_OBJECTIVE_CHARS = 10_000;
|
|
3
|
+
export function startGoal(objective) {
|
|
4
|
+
const text = objective.trim();
|
|
5
|
+
if (!text)
|
|
6
|
+
throw new Error('A goal needs an objective.');
|
|
7
|
+
if (text.length > MAX_OBJECTIVE_CHARS)
|
|
8
|
+
throw new Error('A goal objective can be at most 10,000 characters.');
|
|
9
|
+
return { objective: text, status: 'active', turns: 0 };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The goal after one turn. Stop, an error, no progress or an exhausted turn budget pause it; a rejection or a
|
|
13
|
+
* `blocked` report blocks it; a `complete` report finishes it. A report counts only when the turn ended cleanly.
|
|
14
|
+
*/
|
|
15
|
+
export function applyTurnOutcome(g, o, maxTurns) {
|
|
16
|
+
const turns = g.turns + 1;
|
|
17
|
+
const { report: _previous, ...rest } = g;
|
|
18
|
+
if (o.stopped || o.error)
|
|
19
|
+
return { ...rest, status: 'paused', turns };
|
|
20
|
+
if (o.rejected)
|
|
21
|
+
return { ...rest, status: 'blocked', turns, ...(o.report ? { report: o.report.reason } : {}) };
|
|
22
|
+
if (o.report) {
|
|
23
|
+
return { ...rest, status: o.report.kind === 'complete' ? 'complete' : 'blocked', turns, report: o.report.reason };
|
|
24
|
+
}
|
|
25
|
+
if (o.toolCalls === 0)
|
|
26
|
+
return { ...rest, status: 'paused', turns };
|
|
27
|
+
if (turns >= maxTurns)
|
|
28
|
+
return { ...rest, status: 'paused', turns };
|
|
29
|
+
return { ...rest, status: 'active', turns };
|
|
30
|
+
}
|
|
31
|
+
export function pauseGoal(g) {
|
|
32
|
+
return g.status === 'active' ? { ...g, status: 'paused' } : g;
|
|
33
|
+
}
|
|
34
|
+
/** Paused and blocked goals continue; a complete one restarts from zero turns. */
|
|
35
|
+
export function resumeGoal(g) {
|
|
36
|
+
if (g.status === 'complete')
|
|
37
|
+
return { objective: g.objective, status: 'active', turns: 0 };
|
|
38
|
+
const { report: _report, ...rest } = g;
|
|
39
|
+
return { ...rest, status: 'active' };
|
|
40
|
+
}
|
|
41
|
+
/** The message that drives one goal turn. */
|
|
42
|
+
export function goalPrompt(g) {
|
|
43
|
+
return [
|
|
44
|
+
`Goal: ${g.objective}`,
|
|
45
|
+
'',
|
|
46
|
+
'Keep working toward this goal. Make the next concrete step with your tools; do not stop to ask questions, choose safe, reversible options instead.',
|
|
47
|
+
'When the goal is met, call goal_report with kind "complete" and what you did. If you cannot continue safely, call goal_report with kind "blocked" and why.',
|
|
48
|
+
].join('\n');
|
|
49
|
+
}
|
|
50
|
+
const reportParams = z.object({
|
|
51
|
+
kind: z.enum(['complete', 'blocked']).describe('complete when the goal is met; blocked when something prevents safe progress.'),
|
|
52
|
+
reason: z.string().min(1).max(2000).describe('What was done, or what is blocking.'),
|
|
53
|
+
});
|
|
54
|
+
export function createGoalReportTool(onReport) {
|
|
55
|
+
return {
|
|
56
|
+
name: 'goal_report',
|
|
57
|
+
description: 'Report the outcome of the current goal: complete when the objective is met, blocked when you cannot continue safely.',
|
|
58
|
+
parameters: reportParams,
|
|
59
|
+
noApproval: true,
|
|
60
|
+
execute: async (args) => {
|
|
61
|
+
onReport({ kind: args.kind, reason: args.reason });
|
|
62
|
+
return { recorded: true };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SpecialistConfig } from '../orchestrator/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* An agent defined as a Markdown file: front matter for settings, the body as the system prompt.
|
|
4
|
+
* `~/.forge/agents/*.md` (yours) and `<workspace>/.forge/agents/*.md` (the project's); the file name is the agent name.
|
|
5
|
+
*/
|
|
6
|
+
export interface AgentDef {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
/** `subagent` runs only when delegated to, `primary` only as the chat's agent, `all` both. */
|
|
10
|
+
mode: 'subagent' | 'primary' | 'all';
|
|
11
|
+
model?: string;
|
|
12
|
+
/** Iteration cap. */
|
|
13
|
+
steps?: number;
|
|
14
|
+
temperature?: number;
|
|
15
|
+
color?: string;
|
|
16
|
+
/** Tools it may call; empty = every built-in tool except the denied ones. */
|
|
17
|
+
tools: string[];
|
|
18
|
+
/** Tools it may never call. Always contains `sub_agent`. */
|
|
19
|
+
deny: string[];
|
|
20
|
+
/** Left out of menus and the picker; still reachable by name. */
|
|
21
|
+
hidden: boolean;
|
|
22
|
+
/** Set by `disable: true`; a disabled file removes the agent of that name from the result. */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
prompt: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseAgentFile(fileName: string, text: string): {
|
|
27
|
+
agent?: AgentDef;
|
|
28
|
+
problem?: string;
|
|
29
|
+
};
|
|
30
|
+
/** Load folders in order (user first, project last); a later agent replaces an earlier one of the same name. */
|
|
31
|
+
export declare function loadAgentFiles(dirs: string[]): Promise<{
|
|
32
|
+
agents: AgentDef[];
|
|
33
|
+
problems: string[];
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* An agent file as a chat mode: modes are specialists, so a `primary` or `all` agent shows up in the mode switcher
|
|
37
|
+
* with its prompt, tools and step cap. No tools list = every tool in `allToolNames` except the denied ones.
|
|
38
|
+
*/
|
|
39
|
+
export declare function agentToSpecialist(def: AgentDef, allToolNames: readonly string[]): SpecialistConfig;
|
|
40
|
+
/** The agents of the running host, so the `sub_agent` tool can find one by name. Hosts register at the start of every run. */
|
|
41
|
+
export declare function registerAgentDefs(agents: AgentDef[]): void;
|
|
42
|
+
export declare function registeredAgent(name: string): AgentDef | undefined;
|
|
43
|
+
export declare function registeredAgents(): AgentDef[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { parseFrontmatter } from '../skills/parser.js';
|
|
4
|
+
const NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
5
|
+
const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
6
|
+
export function parseAgentFile(fileName, text) {
|
|
7
|
+
const name = basename(fileName).replace(/\.md$/i, '');
|
|
8
|
+
if (!NAME.test(name))
|
|
9
|
+
return { problem: `${fileName}: the file name "${name}" is not a valid agent name (lowercase letters, digits, - and _)` };
|
|
10
|
+
if (!text.startsWith('---'))
|
|
11
|
+
return { problem: `${fileName}: needs front matter (--- lines) with a description` };
|
|
12
|
+
const { fields, body } = parseFrontmatter(text.replace(/\r\n/g, '\n'));
|
|
13
|
+
const description = (fields.get('description') ?? '').trim();
|
|
14
|
+
if (!description)
|
|
15
|
+
return { problem: `${fileName}: description is required` };
|
|
16
|
+
const mode = fields.get('mode') ?? 'subagent';
|
|
17
|
+
if (mode !== 'subagent' && mode !== 'primary' && mode !== 'all')
|
|
18
|
+
return { problem: `${fileName}: mode must be subagent, primary or all` };
|
|
19
|
+
let steps;
|
|
20
|
+
if (fields.has('steps')) {
|
|
21
|
+
steps = Number(fields.get('steps'));
|
|
22
|
+
if (!Number.isInteger(steps) || steps < 1)
|
|
23
|
+
return { problem: `${fileName}: steps must be a positive whole number` };
|
|
24
|
+
}
|
|
25
|
+
let temperature;
|
|
26
|
+
if (fields.has('temperature')) {
|
|
27
|
+
temperature = Number(fields.get('temperature'));
|
|
28
|
+
if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2)
|
|
29
|
+
return { problem: `${fileName}: temperature must be between 0 and 2` };
|
|
30
|
+
}
|
|
31
|
+
const tools = list(fields.get('tools')).filter((t) => t !== 'sub_agent');
|
|
32
|
+
const deny = [...new Set(['sub_agent', ...list(fields.get('deny'))])];
|
|
33
|
+
const model = fields.get('model')?.trim();
|
|
34
|
+
const color = fields.get('color')?.trim();
|
|
35
|
+
return {
|
|
36
|
+
agent: {
|
|
37
|
+
name,
|
|
38
|
+
description,
|
|
39
|
+
mode,
|
|
40
|
+
...(model ? { model } : {}),
|
|
41
|
+
...(steps !== undefined ? { steps } : {}),
|
|
42
|
+
...(temperature !== undefined ? { temperature } : {}),
|
|
43
|
+
...(color ? { color } : {}),
|
|
44
|
+
tools,
|
|
45
|
+
deny,
|
|
46
|
+
hidden: fields.get('hidden') === 'true',
|
|
47
|
+
...(fields.get('disable') === 'true' ? { disabled: true } : {}),
|
|
48
|
+
prompt: body.trim(),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Load folders in order (user first, project last); a later agent replaces an earlier one of the same name. */
|
|
53
|
+
export async function loadAgentFiles(dirs) {
|
|
54
|
+
const byName = new Map();
|
|
55
|
+
const problems = [];
|
|
56
|
+
for (const dir of dirs) {
|
|
57
|
+
let names;
|
|
58
|
+
try {
|
|
59
|
+
names = (await readdir(dir)).filter((n) => n.toLowerCase().endsWith('.md')).sort();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
for (const n of names) {
|
|
65
|
+
let text;
|
|
66
|
+
try {
|
|
67
|
+
text = await readFile(join(dir, n), 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
problems.push(`${n}: ${err.message}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const { agent, problem } = parseAgentFile(n, text);
|
|
74
|
+
if (problem)
|
|
75
|
+
problems.push(problem);
|
|
76
|
+
else if (agent)
|
|
77
|
+
byName.set(agent.name, agent);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { agents: [...byName.values()].filter((a) => !a.disabled), problems };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* An agent file as a chat mode: modes are specialists, so a `primary` or `all` agent shows up in the mode switcher
|
|
84
|
+
* with its prompt, tools and step cap. No tools list = every tool in `allToolNames` except the denied ones.
|
|
85
|
+
*/
|
|
86
|
+
export function agentToSpecialist(def, allToolNames) {
|
|
87
|
+
const denied = new Set(def.deny);
|
|
88
|
+
const allow = def.tools.length > 0 ? new Set(def.tools) : undefined;
|
|
89
|
+
return {
|
|
90
|
+
id: def.name,
|
|
91
|
+
description: def.description,
|
|
92
|
+
systemPrompt: def.prompt,
|
|
93
|
+
allowedTools: allToolNames.filter((n) => !denied.has(n) && (!allow || allow.has(n))),
|
|
94
|
+
forbiddenTools: [...denied],
|
|
95
|
+
...(def.steps !== undefined ? { maxIterations: def.steps } : {}),
|
|
96
|
+
...(def.model ? { model: def.model } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
let registered = new Map();
|
|
100
|
+
/** The agents of the running host, so the `sub_agent` tool can find one by name. Hosts register at the start of every run. */
|
|
101
|
+
export function registerAgentDefs(agents) {
|
|
102
|
+
registered = new Map(agents.map((a) => [a.name, a]));
|
|
103
|
+
}
|
|
104
|
+
export function registeredAgent(name) {
|
|
105
|
+
return registered.get(name);
|
|
106
|
+
}
|
|
107
|
+
export function registeredAgents() {
|
|
108
|
+
return [...registered.values()];
|
|
109
|
+
}
|
|
@@ -16,6 +16,7 @@ import { existsSync } from 'node:fs';
|
|
|
16
16
|
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { snapshotWorktreeContext } from '../worktree/worktree.js';
|
|
19
|
+
import { isOutsideRel } from '../tools/shared.js';
|
|
19
20
|
const MANIFEST_FILENAME = 'manifest.json';
|
|
20
21
|
const FILES_SUBDIR = 'files';
|
|
21
22
|
/** Default retention for prune. Keeps the last 20 checkpoints. */
|
|
@@ -128,7 +129,7 @@ export class CheckpointStore {
|
|
|
128
129
|
const abs = path.resolve(this.cwd, options.focusPath);
|
|
129
130
|
const rel = path.relative(this.cwd, abs);
|
|
130
131
|
// Skip files outside cwd (security: never capture /etc/passwd).
|
|
131
|
-
if (!
|
|
132
|
+
if (!isOutsideRel(rel)) {
|
|
132
133
|
await this.captureFile(rel, files, dir);
|
|
133
134
|
}
|
|
134
135
|
}
|
|
@@ -316,6 +317,9 @@ export class CheckpointStore {
|
|
|
316
317
|
};
|
|
317
318
|
for (const file of checkpoint.files) {
|
|
318
319
|
const abs = path.join(this.cwd, file.path);
|
|
320
|
+
// A manifest is data on disk: an entry leading out of the workspace (damaged or edited) is never written or removed.
|
|
321
|
+
if (isOutsideRel(path.relative(this.cwd, abs)))
|
|
322
|
+
continue;
|
|
319
323
|
const contentPath = path.join(dir, FILES_SUBDIR, encodePath(file.path));
|
|
320
324
|
if (file.status === 'deleted') {
|
|
321
325
|
if (existsSync(abs)) {
|
|
@@ -2,6 +2,14 @@ export interface CustomCommand {
|
|
|
2
2
|
/** Lowercase name used after the slash. */
|
|
3
3
|
name: string;
|
|
4
4
|
description?: string;
|
|
5
|
+
/** Front matter `agent`: the agent (mode) this command runs as. */
|
|
6
|
+
agent?: string;
|
|
7
|
+
/** Front matter `model`: a model for this command. */
|
|
8
|
+
model?: string;
|
|
9
|
+
/** Front matter `variant`: reasoning effort for this command. */
|
|
10
|
+
variant?: 'low' | 'medium' | 'high';
|
|
11
|
+
/** Front matter `subtask: true`: hand the work to `agent` through sub_agent instead of switching to it. */
|
|
12
|
+
subtask?: boolean;
|
|
5
13
|
template: string;
|
|
6
14
|
source: 'user' | 'project';
|
|
7
15
|
/** Absolute path of the template file. */
|
|
@@ -12,10 +20,7 @@ export declare function commandDirs(cwd: string, home?: string): Array<{
|
|
|
12
20
|
source: 'user' | 'project';
|
|
13
21
|
}>;
|
|
14
22
|
/** Split optional `---` front matter from the body and read its `description`. */
|
|
15
|
-
export declare function parseCommandFile(text: string):
|
|
16
|
-
description?: string;
|
|
17
|
-
template: string;
|
|
18
|
-
};
|
|
23
|
+
export declare function parseCommandFile(text: string): Pick<CustomCommand, 'description' | 'agent' | 'model' | 'variant' | 'subtask' | 'template'>;
|
|
19
24
|
/** Every custom command, keyed by name; project commands replace personal ones. */
|
|
20
25
|
export declare function loadCustomCommands(cwd: string, home?: string): Promise<Map<string, CustomCommand>>;
|
|
21
26
|
/** The message a command sends for these arguments. */
|
|
@@ -34,8 +34,19 @@ export function parseCommandFile(text) {
|
|
|
34
34
|
const m = /^---\n([\s\S]*?)\n---\n?/.exec(normalized);
|
|
35
35
|
if (!m)
|
|
36
36
|
return { template: normalized.trim() };
|
|
37
|
-
const
|
|
38
|
-
|
|
37
|
+
const field = (key) => new RegExp('^' + key + ':\\s*(.+)$', 'm').exec(m[1])?.[1]?.trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
38
|
+
const desc = field('description');
|
|
39
|
+
const agent = field('agent');
|
|
40
|
+
const model = field('model');
|
|
41
|
+
const variant = field('variant');
|
|
42
|
+
return {
|
|
43
|
+
...(desc ? { description: desc } : {}),
|
|
44
|
+
...(agent ? { agent } : {}),
|
|
45
|
+
...(model ? { model } : {}),
|
|
46
|
+
...(variant === 'low' || variant === 'medium' || variant === 'high' ? { variant } : {}),
|
|
47
|
+
...(field('subtask') === 'true' ? { subtask: true } : {}),
|
|
48
|
+
template: normalized.slice(m[0].length).trim(),
|
|
49
|
+
};
|
|
39
50
|
}
|
|
40
51
|
/** Every custom command, keyed by name; project commands replace personal ones. */
|
|
41
52
|
export async function loadCustomCommands(cwd, home) {
|
package/dist/config-file.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ModelAliases } from './model-alias.js';
|
|
2
|
+
import { type CustomProviderConfig } from './custom-providers.js';
|
|
3
|
+
import type { ModelOverride } from './model-overrides.js';
|
|
4
|
+
import type { PermissionConfig } from './tools/permission-rules.js';
|
|
2
5
|
import type { ProviderName } from './types.js';
|
|
3
6
|
/**
|
|
4
7
|
* Shape of `~/.forge/config.json`: cross-surface provider/model/display
|
|
@@ -20,6 +23,31 @@ export interface ForgeConfigFile {
|
|
|
20
23
|
command: string;
|
|
21
24
|
fingerprint?: string;
|
|
22
25
|
}>>;
|
|
26
|
+
/**
|
|
27
|
+
* Ordered permission rules (`read`, `edit`, `bash`, `webfetch`, `websearch`, `task`, `external_directory`): each maps
|
|
28
|
+
* to an action or to a pattern → action map; the last matching rule wins. See tools/permission-rules.ts.
|
|
29
|
+
*/
|
|
30
|
+
permission?: PermissionConfig;
|
|
31
|
+
/**
|
|
32
|
+
* Providers beyond the built-in eleven, by id: `groq` and other presets need only a key, anything else needs a
|
|
33
|
+
* `baseUrl`. A model on one is written `id:model` (e.g. `acme:fast`). See custom-providers.ts.
|
|
34
|
+
*/
|
|
35
|
+
customProviders?: Record<string, CustomProviderConfig>;
|
|
36
|
+
/** Context and output limits and prices per model, keyed `provider:model`, `id:model` or a bare model id. */
|
|
37
|
+
modelOverrides?: Record<string, ModelOverride>;
|
|
38
|
+
/** Per provider, which model ids the pickers list: `whitelist` (only these) then `blacklist` (not these). */
|
|
39
|
+
modelFilters?: Record<string, {
|
|
40
|
+
whitelist?: string[];
|
|
41
|
+
blacklist?: string[];
|
|
42
|
+
}>;
|
|
43
|
+
/** The model sub-agents use when the model does not name one; blank means the parent's model. */
|
|
44
|
+
subagentModel?: string;
|
|
45
|
+
/** Extra instruction sources added to the system prompt: files, globs (workspace-relative) and http(s) URLs. User config only. */
|
|
46
|
+
instructions?: string[];
|
|
47
|
+
/** Extra places to read skills from: folders (absolute, `~/...`, or relative to the workspace) or http(s) URLs of a skill file or a skill index. User config only. */
|
|
48
|
+
skillPaths?: string[];
|
|
49
|
+
/** PEM files of extra certificate authorities to trust (absolute or `~/...`), for company networks that re-sign TLS. User config only. */
|
|
50
|
+
caCertificates?: string[];
|
|
23
51
|
/** Short names for models: `"fast": "anthropic:claude-haiku-4-5"`. See model-alias.ts. */
|
|
24
52
|
aliases?: ModelAliases;
|
|
25
53
|
/** Model for a chat mode that has none of its own: mode id → `provider:model`, bare model id or alias. */
|
|
@@ -30,6 +58,13 @@ export interface ForgeConfigFile {
|
|
|
30
58
|
blockDefault?: 'collapsed' | 'expanded';
|
|
31
59
|
detail?: 'compact' | 'full';
|
|
32
60
|
showReasoning?: boolean;
|
|
61
|
+
/** Per kind of block; overrides the two settings above for that kind. */
|
|
62
|
+
blocks?: {
|
|
63
|
+
reasoning?: 'hidden' | 'collapsed' | 'expanded';
|
|
64
|
+
terminal?: 'collapsed' | 'expanded';
|
|
65
|
+
edit?: 'collapsed' | 'expanded';
|
|
66
|
+
mcp?: 'collapsed' | 'expanded';
|
|
67
|
+
};
|
|
33
68
|
};
|
|
34
69
|
}
|
|
35
70
|
export interface LoadForgeConfigFileResult {
|
|
@@ -45,3 +80,11 @@ export declare function loadForgeConfigFile(configPath?: string): Promise<LoadFo
|
|
|
45
80
|
* Fields not present in `partial` are left untouched.
|
|
46
81
|
*/
|
|
47
82
|
export declare function saveForgeConfigFile(partial: ForgeConfigFile, configPath?: string): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Read-modify-write of a JSON settings file, safely:
|
|
85
|
+
* - merged over the file as written, not the cleaned config, so keys this version drops or does not know survive;
|
|
86
|
+
* - a file that exists but is not valid JSON is never overwritten (that would replace every setting with one);
|
|
87
|
+
* - writes to one path from this process run one after another, so two saves at once both land;
|
|
88
|
+
* - written to a temporary file and renamed, so a crash cannot leave half a file.
|
|
89
|
+
*/
|
|
90
|
+
export declare function updateJsonFile(filePath: string, update: (existing: Record<string, unknown>) => Record<string, unknown>): Promise<void>;
|
package/dist/config-file.js
CHANGED
|
@@ -1,7 +1,178 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { customProviderProblems } from './custom-providers.js';
|
|
6
|
+
const PERMISSION_ACTIONS = new Set(['allow', 'ask', 'deny']);
|
|
7
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
const positiveNumber = (n) => typeof n === 'number' && Number.isFinite(n) && n > 0;
|
|
9
|
+
const nonNegativeNumber = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
|
10
|
+
const stringList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : undefined);
|
|
11
|
+
const BLOCK_KINDS = {
|
|
12
|
+
reasoning: ['hidden', 'collapsed', 'expanded'],
|
|
13
|
+
terminal: ['collapsed', 'expanded'],
|
|
14
|
+
edit: ['collapsed', 'expanded'],
|
|
15
|
+
mcp: ['collapsed', 'expanded'],
|
|
16
|
+
};
|
|
17
|
+
/** `display.blocks`: keep the kinds and values the chat understands, warn once about each thing it would silently ignore. */
|
|
18
|
+
function cleanDisplayBlocks(config, filePath, warnings) {
|
|
19
|
+
const blocks = config.display?.blocks;
|
|
20
|
+
if (blocks === undefined)
|
|
21
|
+
return;
|
|
22
|
+
const display = config.display;
|
|
23
|
+
if (!isObject(blocks)) {
|
|
24
|
+
warnings.push(`${filePath}: display.blocks must be an object (reasoning, terminal, edit, mcp); ignored`);
|
|
25
|
+
delete display.blocks;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const kept = {};
|
|
29
|
+
for (const [kind, value] of Object.entries(blocks)) {
|
|
30
|
+
const allowed = BLOCK_KINDS[kind];
|
|
31
|
+
if (!allowed)
|
|
32
|
+
warnings.push(`${filePath}: display.blocks.${kind} is not a block kind (${Object.keys(BLOCK_KINDS).join(', ')}); ignored`);
|
|
33
|
+
else if (typeof value !== 'string' || !allowed.includes(value))
|
|
34
|
+
warnings.push(`${filePath}: display.blocks.${kind} must be ${allowed.join(', ')}; ignored`);
|
|
35
|
+
else
|
|
36
|
+
kept[kind] = value;
|
|
37
|
+
}
|
|
38
|
+
if (Object.keys(kept).length)
|
|
39
|
+
display.blocks = kept;
|
|
40
|
+
else
|
|
41
|
+
delete display.blocks;
|
|
42
|
+
}
|
|
43
|
+
/** Validate `customProviders`, `modelOverrides`, `modelFilters` and `subagentModel`, dropping what cannot be used with one warning each. */
|
|
44
|
+
function cleanProviderKeys(config, filePath, warnings) {
|
|
45
|
+
if (config.customProviders !== undefined) {
|
|
46
|
+
const raw = config.customProviders;
|
|
47
|
+
if (!isObject(raw)) {
|
|
48
|
+
warnings.push(`${filePath}: "customProviders" must be an object of provider id → settings; ignored`);
|
|
49
|
+
delete config.customProviders;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const kept = {};
|
|
53
|
+
const bad = new Set(customProviderProblems(raw).map((p) => p.id));
|
|
54
|
+
for (const p of customProviderProblems(raw))
|
|
55
|
+
warnings.push(`${filePath}: ${p.message} Ignored.`);
|
|
56
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
57
|
+
if (bad.has(id) || !isObject(value))
|
|
58
|
+
continue;
|
|
59
|
+
const entry = { ...value };
|
|
60
|
+
if (entry.headers !== undefined && !(isObject(entry.headers) && Object.values(entry.headers).every((h) => typeof h === 'string'))) {
|
|
61
|
+
warnings.push(`${filePath}: customProviders.${id}.headers must be text values; headers ignored`);
|
|
62
|
+
delete entry.headers;
|
|
63
|
+
}
|
|
64
|
+
kept[id] = entry;
|
|
65
|
+
}
|
|
66
|
+
if (Object.keys(kept).length)
|
|
67
|
+
config.customProviders = kept;
|
|
68
|
+
else
|
|
69
|
+
delete config.customProviders;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (config.modelOverrides !== undefined) {
|
|
73
|
+
const raw = config.modelOverrides;
|
|
74
|
+
if (!isObject(raw)) {
|
|
75
|
+
warnings.push(`${filePath}: "modelOverrides" must be an object of model → numbers; ignored`);
|
|
76
|
+
delete config.modelOverrides;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const kept = {};
|
|
80
|
+
for (const [model, value] of Object.entries(raw)) {
|
|
81
|
+
if (!isObject(value)) {
|
|
82
|
+
warnings.push(`${filePath}: modelOverrides.${model} must be an object; ignored`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const out = {};
|
|
86
|
+
const limit = isObject(value.limit) ? value.limit : undefined;
|
|
87
|
+
if (limit) {
|
|
88
|
+
const l = {};
|
|
89
|
+
for (const k of ['context', 'output']) {
|
|
90
|
+
if (limit[k] === undefined)
|
|
91
|
+
continue;
|
|
92
|
+
if (positiveNumber(limit[k]))
|
|
93
|
+
l[k] = limit[k];
|
|
94
|
+
else
|
|
95
|
+
warnings.push(`${filePath}: modelOverrides.${model}.limit.${k} must be a positive number; ignored`);
|
|
96
|
+
}
|
|
97
|
+
if (Object.keys(l).length)
|
|
98
|
+
out.limit = l;
|
|
99
|
+
}
|
|
100
|
+
const cost = isObject(value.cost) ? value.cost : undefined;
|
|
101
|
+
if (cost) {
|
|
102
|
+
const c = {};
|
|
103
|
+
for (const k of ['input', 'output']) {
|
|
104
|
+
if (cost[k] === undefined)
|
|
105
|
+
continue;
|
|
106
|
+
if (nonNegativeNumber(cost[k]))
|
|
107
|
+
c[k] = cost[k];
|
|
108
|
+
else
|
|
109
|
+
warnings.push(`${filePath}: modelOverrides.${model}.cost.${k} must be a number of 0 or more; ignored`);
|
|
110
|
+
}
|
|
111
|
+
if (Object.keys(c).length)
|
|
112
|
+
out.cost = c;
|
|
113
|
+
}
|
|
114
|
+
if (value.limit !== undefined && !limit)
|
|
115
|
+
warnings.push(`${filePath}: modelOverrides.${model}.limit must be an object; ignored`);
|
|
116
|
+
if (Object.keys(out).length)
|
|
117
|
+
kept[model] = out;
|
|
118
|
+
}
|
|
119
|
+
if (Object.keys(kept).length)
|
|
120
|
+
config.modelOverrides = kept;
|
|
121
|
+
else
|
|
122
|
+
delete config.modelOverrides;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (config.modelFilters !== undefined) {
|
|
126
|
+
const raw = config.modelFilters;
|
|
127
|
+
if (!isObject(raw)) {
|
|
128
|
+
warnings.push(`${filePath}: "modelFilters" must be an object of provider → lists; ignored`);
|
|
129
|
+
delete config.modelFilters;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const kept = {};
|
|
133
|
+
for (const [provider, value] of Object.entries(raw)) {
|
|
134
|
+
if (!isObject(value))
|
|
135
|
+
continue;
|
|
136
|
+
const whitelist = stringList(value.whitelist);
|
|
137
|
+
const blacklist = stringList(value.blacklist);
|
|
138
|
+
if (whitelist?.length || blacklist?.length)
|
|
139
|
+
kept[provider] = { ...(whitelist?.length ? { whitelist } : {}), ...(blacklist?.length ? { blacklist } : {}) };
|
|
140
|
+
}
|
|
141
|
+
if (Object.keys(kept).length)
|
|
142
|
+
config.modelFilters = kept;
|
|
143
|
+
else
|
|
144
|
+
delete config.modelFilters;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (config.instructions !== undefined) {
|
|
148
|
+
const list = stringList(config.instructions)?.map((s) => s.trim()).filter(Boolean);
|
|
149
|
+
if (list?.length)
|
|
150
|
+
config.instructions = list;
|
|
151
|
+
else
|
|
152
|
+
delete config.instructions;
|
|
153
|
+
}
|
|
154
|
+
if (config.skillPaths !== undefined) {
|
|
155
|
+
const list = stringList(config.skillPaths)?.map((s) => s.trim()).filter(Boolean);
|
|
156
|
+
if (list?.length)
|
|
157
|
+
config.skillPaths = list;
|
|
158
|
+
else
|
|
159
|
+
delete config.skillPaths;
|
|
160
|
+
}
|
|
161
|
+
if (config.caCertificates !== undefined) {
|
|
162
|
+
const list = stringList(config.caCertificates)?.map((s) => s.trim()).filter(Boolean);
|
|
163
|
+
if (list?.length)
|
|
164
|
+
config.caCertificates = list;
|
|
165
|
+
else
|
|
166
|
+
delete config.caCertificates;
|
|
167
|
+
}
|
|
168
|
+
if (config.subagentModel !== undefined) {
|
|
169
|
+
const v = typeof config.subagentModel === 'string' ? config.subagentModel.trim() : '';
|
|
170
|
+
if (v)
|
|
171
|
+
config.subagentModel = v;
|
|
172
|
+
else
|
|
173
|
+
delete config.subagentModel;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
5
176
|
/** Returns `~/.forge/config.json` for the current user. */
|
|
6
177
|
export function defaultForgeConfigPath() {
|
|
7
178
|
return path.join(homedir(), '.forge', 'config.json');
|
|
@@ -40,6 +211,41 @@ export async function loadForgeConfigFile(configPath) {
|
|
|
40
211
|
}
|
|
41
212
|
}
|
|
42
213
|
}
|
|
214
|
+
if (config.permission !== undefined) {
|
|
215
|
+
const raw = config.permission;
|
|
216
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
217
|
+
warnings.push(`${filePath}: "permission" must be an object of permission → action or pattern map; ignored`);
|
|
218
|
+
delete config.permission;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const kept = {};
|
|
222
|
+
for (const [perm, value] of Object.entries(raw)) {
|
|
223
|
+
if (typeof value === 'string') {
|
|
224
|
+
if (PERMISSION_ACTIONS.has(value))
|
|
225
|
+
kept[perm] = value;
|
|
226
|
+
else
|
|
227
|
+
warnings.push(`${filePath}: permission.${perm} is "${value}", not allow, ask or deny; ignored`);
|
|
228
|
+
}
|
|
229
|
+
else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
230
|
+
const patterns = {};
|
|
231
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
232
|
+
if (typeof action === 'string' && PERMISSION_ACTIONS.has(action))
|
|
233
|
+
patterns[pattern] = action;
|
|
234
|
+
else
|
|
235
|
+
warnings.push(`${filePath}: permission.${perm}["${pattern}"] is "${String(action)}", not allow, ask or deny; ignored`);
|
|
236
|
+
}
|
|
237
|
+
if (Object.keys(patterns).length)
|
|
238
|
+
kept[perm] = patterns;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
warnings.push(`${filePath}: permission.${perm} must be allow, ask, deny or a pattern map; ignored`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
config.permission = kept;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
cleanProviderKeys(config, filePath, warnings);
|
|
248
|
+
cleanDisplayBlocks(config, filePath, warnings);
|
|
43
249
|
return { config, warnings };
|
|
44
250
|
}
|
|
45
251
|
catch (err) {
|
|
@@ -52,8 +258,46 @@ export async function loadForgeConfigFile(configPath) {
|
|
|
52
258
|
*/
|
|
53
259
|
export async function saveForgeConfigFile(partial, configPath) {
|
|
54
260
|
const filePath = configPath ?? defaultForgeConfigPath();
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
261
|
+
await updateJsonFile(filePath, (existing) => ({ ...existing, ...partial }));
|
|
262
|
+
}
|
|
263
|
+
const fileQueues = new Map();
|
|
264
|
+
/**
|
|
265
|
+
* Read-modify-write of a JSON settings file, safely:
|
|
266
|
+
* - merged over the file as written, not the cleaned config, so keys this version drops or does not know survive;
|
|
267
|
+
* - a file that exists but is not valid JSON is never overwritten (that would replace every setting with one);
|
|
268
|
+
* - writes to one path from this process run one after another, so two saves at once both land;
|
|
269
|
+
* - written to a temporary file and renamed, so a crash cannot leave half a file.
|
|
270
|
+
*/
|
|
271
|
+
export function updateJsonFile(filePath, update) {
|
|
272
|
+
const key = path.resolve(filePath);
|
|
273
|
+
const run = async () => {
|
|
274
|
+
let existing = {};
|
|
275
|
+
let text;
|
|
276
|
+
try {
|
|
277
|
+
text = await readFile(filePath, 'utf8');
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (err.code !== 'ENOENT')
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
if (text !== undefined && text.trim()) {
|
|
284
|
+
let parsed;
|
|
285
|
+
try {
|
|
286
|
+
parsed = JSON.parse(text);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
throw new Error(`${filePath} is not valid JSON (${err.message}); fix it before saving, so your other settings are not lost`);
|
|
290
|
+
}
|
|
291
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
292
|
+
throw new Error(`${filePath} must hold a JSON object; not saving over it`);
|
|
293
|
+
existing = parsed;
|
|
294
|
+
}
|
|
295
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
296
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
297
|
+
await writeFile(tmp, JSON.stringify(update(existing), null, 2) + '\n', 'utf8');
|
|
298
|
+
await rename(tmp, filePath);
|
|
299
|
+
};
|
|
300
|
+
const next = (fileQueues.get(key) ?? Promise.resolve()).then(run, run);
|
|
301
|
+
fileQueues.set(key, next.catch(() => undefined));
|
|
302
|
+
return next;
|
|
59
303
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ForgeConfigFile, type LoadForgeConfigFileResult } from './config-file.js';
|
|
2
|
+
/**
|
|
3
|
+
* The shared config as one object: `~/.forge/config.json`, then the workspace's `.forge/config.json` on top.
|
|
4
|
+
* A project file can pick the model and add stricter rules, but it can never loosen what the user decided:
|
|
5
|
+
* its `allow` permission rules are dropped, and the keys that hold approvals or delete data are only read
|
|
6
|
+
* from the user file. An untrusted workspace contributes nothing.
|
|
7
|
+
*/
|
|
8
|
+
export declare function projectConfigPath(cwd: string): string;
|
|
9
|
+
export declare const USER_ONLY_CONFIG_KEYS: ReadonlyArray<keyof ForgeConfigFile>;
|
|
10
|
+
export declare function loadMergedForgeConfig(cwd: string, opts: {
|
|
11
|
+
trusted: boolean;
|
|
12
|
+
userPath?: string;
|
|
13
|
+
}): Promise<LoadForgeConfigFileResult>;
|