@studio-foundation/runner 0.3.0-beta.1 → 0.3.0-beta.6
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/package.json +6 -3
- package/ARCHITECTURE.md +0 -53
- package/configs/agents/analyst.agent.yaml +0 -31
- package/configs/agents/code-generator.agent.yaml +0 -31
- package/configs/agents/generic.agent.yaml +0 -23
- package/src/__tests__/script-executor.test.ts +0 -180
- package/src/index.ts +0 -68
- package/src/integrations/integration-loader.test.ts +0 -88
- package/src/integrations/integration-loader.ts +0 -68
- package/src/middleware/anonymization.ts +0 -38
- package/src/plugins/index.ts +0 -4
- package/src/plugins/mcp-client.test.ts +0 -148
- package/src/plugins/mcp-client.ts +0 -128
- package/src/plugins/oauth-provider.test.ts +0 -167
- package/src/plugins/oauth-provider.ts +0 -175
- package/src/plugins/plugin-loader.test.ts +0 -114
- package/src/plugins/plugin-loader.ts +0 -90
- package/src/prompt-builder.test.ts +0 -167
- package/src/prompt-builder.ts +0 -332
- package/src/providers/anthropic.test.ts +0 -101
- package/src/providers/anthropic.ts +0 -135
- package/src/providers/mock.ts +0 -57
- package/src/providers/ollama.test.ts +0 -166
- package/src/providers/ollama.ts +0 -152
- package/src/providers/openai-responses.ts +0 -212
- package/src/providers/openai.test.ts +0 -67
- package/src/providers/openai.ts +0 -139
- package/src/providers/provider.ts +0 -54
- package/src/providers/registry.ts +0 -77
- package/src/runner.test.ts +0 -343
- package/src/runner.ts +0 -396
- package/src/script-executor.ts +0 -107
- package/src/tools/builtin/git.ts +0 -311
- package/src/tools/builtin/patch.ts +0 -257
- package/src/tools/builtin/repo-manager.ts +0 -142
- package/src/tools/builtin/search.ts +0 -108
- package/src/tools/builtin/shell.ts +0 -82
- package/src/tools/builtin/studio-run.ts +0 -73
- package/src/tools/builtin/web-search.test.ts +0 -122
- package/src/tools/builtin/web-search.ts +0 -101
- package/src/tools/errors.test.ts +0 -12
- package/src/tools/errors.ts +0 -6
- package/src/tools/plugin-loader.test.ts +0 -130
- package/src/tools/plugin-loader.ts +0 -203
- package/src/tools/skills/README.md +0 -49
- package/src/tools/skills/skill-loader.test.ts +0 -106
- package/src/tools/skills/skill-loader.ts +0 -62
- package/src/tools/tool-executor.test.ts +0 -88
- package/src/tools/tool-executor.ts +0 -84
- package/src/tools/tool-registry.ts +0 -130
- package/src/tools/yaml-executor.ts +0 -120
- package/src/utils/race-signal.test.ts +0 -50
- package/src/utils/race-signal.ts +0 -17
- package/templates/integrations/linear.integration.yaml +0 -35
- package/templates/integrations/slack.integration.yaml +0 -22
- package/templates/integrations/webhook.integration.yaml +0 -17
- package/templates/tools/git.tool.yaml +0 -80
- package/templates/tools/repo-manager.tool.yaml +0 -64
- package/templates/tools/search.tool.yaml +0 -22
- package/templates/tools/shell.tool.yaml +0 -19
- package/templates/tools/web-search.tool.yaml +0 -24
- package/tests/anonymization-middleware.test.ts +0 -61
- package/tests/anthropic.test.ts +0 -87
- package/tests/apply-patch.test.ts +0 -355
- package/tests/fixtures/tools/test-builtin.tool.yaml +0 -14
- package/tests/fixtures/tools/test-shell.tool.yaml +0 -19
- package/tests/mock-provider.test.ts +0 -104
- package/tests/openai.test.ts +0 -72
- package/tests/plugin-loader.test.ts +0 -54
- package/tests/prompt-builder.test.ts +0 -468
- package/tests/runner-anonymization.test.ts +0 -89
- package/tests/runner.test.ts +0 -885
- package/tests/studio-run.test.ts +0 -94
- package/tests/tool-executor.test.ts +0 -115
- package/tests/tool-registry.test.ts +0 -84
- package/tests/yaml-executor.test.ts +0 -76
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -7
package/src/tools/builtin/git.ts
DELETED
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git tools - branch, commit, push, pull, status, diff
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { exec } from 'child_process';
|
|
6
|
-
import { promisify } from 'util';
|
|
7
|
-
import type { Tool } from '../tool-registry.js';
|
|
8
|
-
|
|
9
|
-
const execAsync = promisify(exec);
|
|
10
|
-
|
|
11
|
-
const PROTECTED_BRANCHES = ['main', 'master', 'develop', 'production'];
|
|
12
|
-
|
|
13
|
-
async function isGitRepo(workingDir: string): Promise<boolean> {
|
|
14
|
-
try {
|
|
15
|
-
await execAsync('git rev-parse --git-dir', { cwd: workingDir });
|
|
16
|
-
return true;
|
|
17
|
-
} catch {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function runGit(args: string, workingDir: string): Promise<{ stdout: string; stderr: string }> {
|
|
23
|
-
return execAsync(`git ${args}`, { cwd: workingDir, timeout: 30000 });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function createGitTools(workingDir: string): Tool[] {
|
|
27
|
-
return [
|
|
28
|
-
{
|
|
29
|
-
name: 'git-checkout',
|
|
30
|
-
description: 'Checkout or create a branch',
|
|
31
|
-
parameters: {
|
|
32
|
-
type: 'object',
|
|
33
|
-
properties: {
|
|
34
|
-
branch: {
|
|
35
|
-
type: 'string',
|
|
36
|
-
description: 'Branch name to checkout or create'
|
|
37
|
-
},
|
|
38
|
-
create: {
|
|
39
|
-
type: 'boolean',
|
|
40
|
-
description: 'Create the branch if it does not exist (-b flag). Default: false'
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
required: ['branch']
|
|
44
|
-
},
|
|
45
|
-
execute: async ({ branch, create }) => {
|
|
46
|
-
const branchName = branch as string;
|
|
47
|
-
const shouldCreate = create as boolean | undefined ?? false;
|
|
48
|
-
|
|
49
|
-
if (!(await isGitRepo(workingDir))) {
|
|
50
|
-
return { success: false, output: null, error: 'Not a git repository' };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (shouldCreate && PROTECTED_BRANCHES.includes(branchName)) {
|
|
54
|
-
return {
|
|
55
|
-
success: false,
|
|
56
|
-
output: null,
|
|
57
|
-
error: `Cannot create protected branch: ${branchName}`
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
const flag = shouldCreate ? '-b ' : '';
|
|
63
|
-
const { stdout, stderr } = await runGit(`checkout ${flag}${branchName}`, workingDir);
|
|
64
|
-
return {
|
|
65
|
-
success: true,
|
|
66
|
-
output: { branch: branchName, created: shouldCreate, stdout: stdout.trim(), stderr: stderr.trim() }
|
|
67
|
-
};
|
|
68
|
-
} catch (error: unknown) {
|
|
69
|
-
const e = error as { stderr?: string; message?: string };
|
|
70
|
-
return {
|
|
71
|
-
success: false,
|
|
72
|
-
output: null,
|
|
73
|
-
error: e.stderr?.trim() || e.message || 'git checkout failed'
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
|
|
79
|
-
{
|
|
80
|
-
name: 'git-commit',
|
|
81
|
-
description: 'Stage files and commit changes',
|
|
82
|
-
parameters: {
|
|
83
|
-
type: 'object',
|
|
84
|
-
properties: {
|
|
85
|
-
message: {
|
|
86
|
-
type: 'string',
|
|
87
|
-
description: 'Commit message'
|
|
88
|
-
},
|
|
89
|
-
files: {
|
|
90
|
-
type: 'array',
|
|
91
|
-
items: { type: 'string' },
|
|
92
|
-
description: 'Files to stage. If empty or omitted, stages all changes (git add -A)'
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
required: ['message']
|
|
96
|
-
},
|
|
97
|
-
execute: async ({ message, files }) => {
|
|
98
|
-
const commitMessage = message as string;
|
|
99
|
-
const filesToStage = files as string[] | undefined;
|
|
100
|
-
|
|
101
|
-
if (!(await isGitRepo(workingDir))) {
|
|
102
|
-
return { success: false, output: null, error: 'Not a git repository' };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
// Check for merge conflicts
|
|
107
|
-
const { stdout: statusOut } = await runGit('status --porcelain', workingDir);
|
|
108
|
-
const hasConflicts = statusOut.split('\n').some(line => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'));
|
|
109
|
-
if (hasConflicts) {
|
|
110
|
-
return { success: false, output: null, error: 'Cannot commit: merge conflicts detected' };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Stage files
|
|
114
|
-
if (filesToStage && filesToStage.length > 0) {
|
|
115
|
-
await runGit(`add -- ${filesToStage.map(f => `"${f}"`).join(' ')}`, workingDir);
|
|
116
|
-
} else {
|
|
117
|
-
await runGit('add -A', workingDir);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Commit
|
|
121
|
-
const { stdout } = await runGit(`commit -m ${JSON.stringify(commitMessage)}`, workingDir);
|
|
122
|
-
return {
|
|
123
|
-
success: true,
|
|
124
|
-
output: { message: commitMessage, stdout: stdout.trim() }
|
|
125
|
-
};
|
|
126
|
-
} catch (error: unknown) {
|
|
127
|
-
const e = error as { stderr?: string; stdout?: string; message?: string };
|
|
128
|
-
return {
|
|
129
|
-
success: false,
|
|
130
|
-
output: null,
|
|
131
|
-
error: e.stderr?.trim() || e.stdout?.trim() || e.message || 'git commit failed'
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
|
|
137
|
-
{
|
|
138
|
-
name: 'git-push',
|
|
139
|
-
description: 'Push current branch to remote',
|
|
140
|
-
parameters: {
|
|
141
|
-
type: 'object',
|
|
142
|
-
properties: {
|
|
143
|
-
remote: {
|
|
144
|
-
type: 'string',
|
|
145
|
-
description: 'Remote name. Default: origin'
|
|
146
|
-
},
|
|
147
|
-
set_upstream: {
|
|
148
|
-
type: 'boolean',
|
|
149
|
-
description: 'Set upstream tracking (-u flag). Default: true'
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
required: []
|
|
153
|
-
},
|
|
154
|
-
execute: async ({ remote, set_upstream }) => {
|
|
155
|
-
const remoteName = (remote as string | undefined) ?? 'origin';
|
|
156
|
-
const setUpstream = (set_upstream as boolean | undefined) ?? true;
|
|
157
|
-
|
|
158
|
-
if (!(await isGitRepo(workingDir))) {
|
|
159
|
-
return { success: false, output: null, error: 'Not a git repository' };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
const { stdout: branchOut } = await runGit('rev-parse --abbrev-ref HEAD', workingDir);
|
|
164
|
-
const currentBranch = branchOut.trim();
|
|
165
|
-
|
|
166
|
-
const upstreamFlag = setUpstream ? `-u ` : '';
|
|
167
|
-
const { stdout, stderr } = await runGit(`push ${upstreamFlag}${remoteName} ${currentBranch}`, workingDir);
|
|
168
|
-
return {
|
|
169
|
-
success: true,
|
|
170
|
-
output: { remote: remoteName, branch: currentBranch, stdout: stdout.trim(), stderr: stderr.trim() }
|
|
171
|
-
};
|
|
172
|
-
} catch (error: unknown) {
|
|
173
|
-
const e = error as { stderr?: string; message?: string };
|
|
174
|
-
return {
|
|
175
|
-
success: false,
|
|
176
|
-
output: null,
|
|
177
|
-
error: e.stderr?.trim() || e.message || 'git push failed'
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
},
|
|
182
|
-
|
|
183
|
-
{
|
|
184
|
-
name: 'git-pull',
|
|
185
|
-
description: 'Pull latest changes from remote',
|
|
186
|
-
parameters: {
|
|
187
|
-
type: 'object',
|
|
188
|
-
properties: {
|
|
189
|
-
remote: {
|
|
190
|
-
type: 'string',
|
|
191
|
-
description: 'Remote name. Default: origin'
|
|
192
|
-
},
|
|
193
|
-
branch: {
|
|
194
|
-
type: 'string',
|
|
195
|
-
description: 'Branch to pull. If omitted, pulls current branch'
|
|
196
|
-
}
|
|
197
|
-
},
|
|
198
|
-
required: []
|
|
199
|
-
},
|
|
200
|
-
execute: async ({ remote, branch }) => {
|
|
201
|
-
const remoteName = (remote as string | undefined) ?? 'origin';
|
|
202
|
-
const branchName = branch as string | undefined;
|
|
203
|
-
|
|
204
|
-
if (!(await isGitRepo(workingDir))) {
|
|
205
|
-
return { success: false, output: null, error: 'Not a git repository' };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
const branchArg = branchName ? ` ${branchName}` : '';
|
|
210
|
-
const { stdout, stderr } = await runGit(`pull ${remoteName}${branchArg}`, workingDir);
|
|
211
|
-
return {
|
|
212
|
-
success: true,
|
|
213
|
-
output: { remote: remoteName, branch: branchName ?? 'current', stdout: stdout.trim(), stderr: stderr.trim() }
|
|
214
|
-
};
|
|
215
|
-
} catch (error: unknown) {
|
|
216
|
-
const e = error as { stderr?: string; stdout?: string; message?: string };
|
|
217
|
-
return {
|
|
218
|
-
success: false,
|
|
219
|
-
output: null,
|
|
220
|
-
error: e.stderr?.trim() || e.stdout?.trim() || e.message || 'git pull failed'
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
},
|
|
225
|
-
|
|
226
|
-
{
|
|
227
|
-
name: 'git-status',
|
|
228
|
-
description: 'Show working tree status (modified, added, deleted files)',
|
|
229
|
-
parameters: {
|
|
230
|
-
type: 'object',
|
|
231
|
-
properties: {},
|
|
232
|
-
required: []
|
|
233
|
-
},
|
|
234
|
-
execute: async () => {
|
|
235
|
-
if (!(await isGitRepo(workingDir))) {
|
|
236
|
-
return { success: false, output: null, error: 'Not a git repository' };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
try {
|
|
240
|
-
const { stdout: porcelain } = await runGit('status --porcelain', workingDir);
|
|
241
|
-
const { stdout: branch } = await runGit('rev-parse --abbrev-ref HEAD', workingDir);
|
|
242
|
-
|
|
243
|
-
const files = porcelain
|
|
244
|
-
.split('\n')
|
|
245
|
-
.filter(line => line.trim())
|
|
246
|
-
.map(line => ({ status: line.slice(0, 2).trim(), file: line.slice(3) }));
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
success: true,
|
|
250
|
-
output: {
|
|
251
|
-
branch: branch.trim(),
|
|
252
|
-
clean: files.length === 0,
|
|
253
|
-
files
|
|
254
|
-
}
|
|
255
|
-
};
|
|
256
|
-
} catch (error: unknown) {
|
|
257
|
-
const e = error as { stderr?: string; message?: string };
|
|
258
|
-
return {
|
|
259
|
-
success: false,
|
|
260
|
-
output: null,
|
|
261
|
-
error: e.stderr?.trim() || e.message || 'git status failed'
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
},
|
|
266
|
-
|
|
267
|
-
{
|
|
268
|
-
name: 'git-diff',
|
|
269
|
-
description: 'Show changes in working tree or staged files',
|
|
270
|
-
parameters: {
|
|
271
|
-
type: 'object',
|
|
272
|
-
properties: {
|
|
273
|
-
staged: {
|
|
274
|
-
type: 'boolean',
|
|
275
|
-
description: 'Show staged changes (--cached). Default: false'
|
|
276
|
-
},
|
|
277
|
-
file: {
|
|
278
|
-
type: 'string',
|
|
279
|
-
description: 'Diff a specific file. If omitted, shows all changes'
|
|
280
|
-
}
|
|
281
|
-
},
|
|
282
|
-
required: []
|
|
283
|
-
},
|
|
284
|
-
execute: async ({ staged, file }) => {
|
|
285
|
-
const showStaged = (staged as boolean | undefined) ?? false;
|
|
286
|
-
const filePath = file as string | undefined;
|
|
287
|
-
|
|
288
|
-
if (!(await isGitRepo(workingDir))) {
|
|
289
|
-
return { success: false, output: null, error: 'Not a git repository' };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
try {
|
|
293
|
-
const stagedFlag = showStaged ? '--cached ' : '';
|
|
294
|
-
const fileArg = filePath ? `-- "${filePath}"` : '';
|
|
295
|
-
const { stdout } = await runGit(`diff ${stagedFlag}${fileArg}`.trim(), workingDir);
|
|
296
|
-
return {
|
|
297
|
-
success: true,
|
|
298
|
-
output: { diff: stdout, staged: showStaged, file: filePath ?? null }
|
|
299
|
-
};
|
|
300
|
-
} catch (error: unknown) {
|
|
301
|
-
const e = error as { stderr?: string; message?: string };
|
|
302
|
-
return {
|
|
303
|
-
success: false,
|
|
304
|
-
output: null,
|
|
305
|
-
error: e.stderr?.trim() || e.message || 'git diff failed'
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
];
|
|
311
|
-
}
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Patch tool - apply unified diffs to files
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import * as fs from 'fs/promises';
|
|
6
|
-
import * as path from 'path';
|
|
7
|
-
import type { Tool, ToolResult } from '../tool-registry.js';
|
|
8
|
-
|
|
9
|
-
interface Hunk {
|
|
10
|
-
oldStart: number;
|
|
11
|
-
oldCount: number;
|
|
12
|
-
lines: HunkLine[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface HunkLine {
|
|
16
|
-
type: 'context' | 'add' | 'remove';
|
|
17
|
-
content: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface PatchResult {
|
|
21
|
-
success: boolean;
|
|
22
|
-
path: string;
|
|
23
|
-
hunks_applied: number;
|
|
24
|
-
hunks_total: number;
|
|
25
|
-
lines_added: number;
|
|
26
|
-
lines_removed: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Parse a unified diff string into hunks.
|
|
31
|
-
*/
|
|
32
|
-
function parseHunks(patch: string): Hunk[] {
|
|
33
|
-
const rawLines = patch.split('\n');
|
|
34
|
-
// Remove the trailing empty string that split() produces when the patch ends
|
|
35
|
-
// with '\n'. Without this, the '' is treated as a blank context line and gets
|
|
36
|
-
// appended to getOldBlock(), causing spurious "Ambiguous match" errors when
|
|
37
|
-
// the file contains blank lines.
|
|
38
|
-
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') {
|
|
39
|
-
rawLines.pop();
|
|
40
|
-
}
|
|
41
|
-
// Filter out --- / +++ headers
|
|
42
|
-
const lines = rawLines.filter(
|
|
43
|
-
(l) => !l.startsWith('---') && !l.startsWith('+++')
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
const hunks: Hunk[] = [];
|
|
47
|
-
let current: Hunk | null = null;
|
|
48
|
-
|
|
49
|
-
for (const line of lines) {
|
|
50
|
-
const hunkHeader = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+\d+(?:,\d+)?\s*@@/);
|
|
51
|
-
if (hunkHeader) {
|
|
52
|
-
current = {
|
|
53
|
-
oldStart: parseInt(hunkHeader[1], 10),
|
|
54
|
-
oldCount: parseInt(hunkHeader[2] ?? '1', 10),
|
|
55
|
-
lines: [],
|
|
56
|
-
};
|
|
57
|
-
hunks.push(current);
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (!current) continue;
|
|
62
|
-
|
|
63
|
-
if (line.startsWith('+')) {
|
|
64
|
-
current.lines.push({ type: 'add', content: line.slice(1) });
|
|
65
|
-
} else if (line.startsWith('-')) {
|
|
66
|
-
current.lines.push({ type: 'remove', content: line.slice(1) });
|
|
67
|
-
} else if (line.startsWith(' ') || line === '') {
|
|
68
|
-
// Context line — space prefix or empty line
|
|
69
|
-
const content = line.startsWith(' ') ? line.slice(1) : line;
|
|
70
|
-
current.lines.push({ type: 'context', content });
|
|
71
|
-
}
|
|
72
|
-
// Ignore lines like ""
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return hunks;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Build the "old block" — context + removed lines that must match the file.
|
|
80
|
-
*/
|
|
81
|
-
function getOldBlock(hunk: Hunk): string[] {
|
|
82
|
-
return hunk.lines
|
|
83
|
-
.filter((l) => l.type === 'context' || l.type === 'remove')
|
|
84
|
-
.map((l) => l.content);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Build the "new block" — context + added lines that replace the old block.
|
|
89
|
-
*/
|
|
90
|
-
function getNewBlock(hunk: Hunk): string[] {
|
|
91
|
-
return hunk.lines
|
|
92
|
-
.filter((l) => l.type === 'context' || l.type === 'add')
|
|
93
|
-
.map((l) => l.content);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Compare two strings with trailing whitespace tolerance.
|
|
98
|
-
*/
|
|
99
|
-
function fuzzyMatch(a: string, b: string): boolean {
|
|
100
|
-
return a.trimEnd() === b.trimEnd();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Check if oldBlock matches fileLines starting at position `start`.
|
|
105
|
-
*/
|
|
106
|
-
function blockMatchesAt(fileLines: string[], oldBlock: string[], start: number): boolean {
|
|
107
|
-
if (start + oldBlock.length > fileLines.length) return false;
|
|
108
|
-
return oldBlock.every((line, i) => fuzzyMatch(fileLines[start + i], line));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Find where the old block matches in the file.
|
|
113
|
-
* Tries the hinted line first, then scans the full file.
|
|
114
|
-
* Returns the 0-based start index.
|
|
115
|
-
*/
|
|
116
|
-
function findMatch(
|
|
117
|
-
fileLines: string[],
|
|
118
|
-
oldBlock: string[],
|
|
119
|
-
hintLine: number,
|
|
120
|
-
hunkIndex: number
|
|
121
|
-
): number {
|
|
122
|
-
// Convert 1-based hint to 0-based
|
|
123
|
-
const hint = hintLine - 1;
|
|
124
|
-
|
|
125
|
-
// Pure-insertion hunk: @@ -N,0 ... has no context or removed lines.
|
|
126
|
-
// Scanning for an empty block would vacuously match every position, so we
|
|
127
|
-
// insert directly at the hinted position instead.
|
|
128
|
-
if (oldBlock.length === 0) {
|
|
129
|
-
return Math.max(0, hint);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Fast path: try at the hinted position
|
|
133
|
-
if (hint >= 0 && blockMatchesAt(fileLines, oldBlock, hint)) {
|
|
134
|
-
return hint;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Slow path: scan the whole file
|
|
138
|
-
const matches: number[] = [];
|
|
139
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
140
|
-
if (blockMatchesAt(fileLines, oldBlock, i)) {
|
|
141
|
-
matches.push(i);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (matches.length === 1) return matches[0];
|
|
146
|
-
|
|
147
|
-
if (matches.length === 0) {
|
|
148
|
-
const expected = oldBlock[0] ?? '(empty)';
|
|
149
|
-
throw new Error(
|
|
150
|
-
`Context mismatch at hunk ${hunkIndex + 1}: could not find context "${expected}" in file`
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
throw new Error(
|
|
155
|
-
`Ambiguous match at hunk ${hunkIndex + 1}: context found at lines ${matches.map((m) => m + 1).join(', ')}. Add more context lines.`
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export function createPatchTools(repoPath: string): Tool[] {
|
|
160
|
-
return [
|
|
161
|
-
{
|
|
162
|
-
name: 'repo_manager-apply_patch',
|
|
163
|
-
description:
|
|
164
|
-
'Apply a unified diff patch to a file. The patch must include enough context lines for unambiguous matching. Fails loudly if context doesn\'t match the file content.',
|
|
165
|
-
parameters: {
|
|
166
|
-
type: 'object',
|
|
167
|
-
properties: {
|
|
168
|
-
path: {
|
|
169
|
-
type: 'string',
|
|
170
|
-
description: 'Relative path to the file to patch (from workspace root)',
|
|
171
|
-
},
|
|
172
|
-
patch: {
|
|
173
|
-
type: 'string',
|
|
174
|
-
description:
|
|
175
|
-
'Unified diff format patch. Must start with @@ hunk headers. Use - for removed lines, + for added lines, space for context lines. Include at least 3 context lines before and after changes.',
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
required: ['path', 'patch'],
|
|
179
|
-
},
|
|
180
|
-
execute: async ({ path: filePath, patch: patchStr }): Promise<ToolResult> => {
|
|
181
|
-
try {
|
|
182
|
-
const fullPath = path.join(repoPath, filePath as string);
|
|
183
|
-
|
|
184
|
-
// Read file
|
|
185
|
-
let fileContent: string;
|
|
186
|
-
try {
|
|
187
|
-
fileContent = await fs.readFile(fullPath, 'utf-8');
|
|
188
|
-
} catch {
|
|
189
|
-
return {
|
|
190
|
-
success: false,
|
|
191
|
-
output: null,
|
|
192
|
-
error: `File not found: ${filePath}`,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Parse hunks
|
|
197
|
-
const hunks = parseHunks(patchStr as string);
|
|
198
|
-
if (hunks.length === 0) {
|
|
199
|
-
return {
|
|
200
|
-
success: false,
|
|
201
|
-
output: null,
|
|
202
|
-
error: 'Invalid patch format: no hunks found (expected @@ headers)',
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const fileLines = fileContent.split('\n');
|
|
207
|
-
let totalAdded = 0;
|
|
208
|
-
let totalRemoved = 0;
|
|
209
|
-
|
|
210
|
-
// Find all match positions first (before any modifications)
|
|
211
|
-
const matchPositions: number[] = [];
|
|
212
|
-
for (let i = 0; i < hunks.length; i++) {
|
|
213
|
-
const oldBlock = getOldBlock(hunks[i]);
|
|
214
|
-
const pos = findMatch(fileLines, oldBlock, hunks[i].oldStart, i);
|
|
215
|
-
matchPositions.push(pos);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Apply hunks in reverse order to preserve line numbers
|
|
219
|
-
const indices = hunks.map((_, i) => i);
|
|
220
|
-
indices.sort((a, b) => matchPositions[b] - matchPositions[a]);
|
|
221
|
-
|
|
222
|
-
for (const i of indices) {
|
|
223
|
-
const hunk = hunks[i];
|
|
224
|
-
const pos = matchPositions[i];
|
|
225
|
-
const oldBlock = getOldBlock(hunk);
|
|
226
|
-
const newBlock = getNewBlock(hunk);
|
|
227
|
-
|
|
228
|
-
fileLines.splice(pos, oldBlock.length, ...newBlock);
|
|
229
|
-
totalAdded += hunk.lines.filter((l) => l.type === 'add').length;
|
|
230
|
-
totalRemoved += hunk.lines.filter((l) => l.type === 'remove').length;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Write file back
|
|
234
|
-
await fs.writeFile(fullPath, fileLines.join('\n'), 'utf-8');
|
|
235
|
-
|
|
236
|
-
const result: PatchResult = {
|
|
237
|
-
success: true,
|
|
238
|
-
path: filePath as string,
|
|
239
|
-
hunks_applied: hunks.length,
|
|
240
|
-
hunks_total: hunks.length,
|
|
241
|
-
lines_added: totalAdded,
|
|
242
|
-
lines_removed: totalRemoved,
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
return { success: true, output: result };
|
|
246
|
-
} catch (error: unknown) {
|
|
247
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
248
|
-
return {
|
|
249
|
-
success: false,
|
|
250
|
-
output: null,
|
|
251
|
-
error: errorMessage,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
},
|
|
255
|
-
},
|
|
256
|
-
];
|
|
257
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Repository manager tools - file operations
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import * as fs from 'fs/promises';
|
|
6
|
-
import * as path from 'path';
|
|
7
|
-
import type { Tool } from '../tool-registry.js';
|
|
8
|
-
|
|
9
|
-
export function createRepoManagerTools(repoPath: string): Tool[] {
|
|
10
|
-
return [
|
|
11
|
-
{
|
|
12
|
-
name: 'repo_manager-read_file',
|
|
13
|
-
description: 'Read the contents of a file in the repository',
|
|
14
|
-
parameters: {
|
|
15
|
-
type: 'object',
|
|
16
|
-
properties: {
|
|
17
|
-
path: {
|
|
18
|
-
type: 'string',
|
|
19
|
-
description: 'Relative path to the file from repository root'
|
|
20
|
-
}
|
|
21
|
-
},
|
|
22
|
-
required: ['path']
|
|
23
|
-
},
|
|
24
|
-
execute: async ({ path: filePath }) => {
|
|
25
|
-
try {
|
|
26
|
-
const fullPath = path.join(repoPath, filePath as string);
|
|
27
|
-
const content = await fs.readFile(fullPath, 'utf-8');
|
|
28
|
-
return {
|
|
29
|
-
success: true,
|
|
30
|
-
output: { path: filePath, content }
|
|
31
|
-
};
|
|
32
|
-
} catch (error: unknown) {
|
|
33
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
34
|
-
return {
|
|
35
|
-
success: false,
|
|
36
|
-
output: null,
|
|
37
|
-
error: `Failed to read file: ${errorMessage}`
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
name: 'repo_manager-write_file',
|
|
44
|
-
description: 'Write content to a file in the repository (creates or overwrites)',
|
|
45
|
-
parameters: {
|
|
46
|
-
type: 'object',
|
|
47
|
-
properties: {
|
|
48
|
-
path: {
|
|
49
|
-
type: 'string',
|
|
50
|
-
description: 'Relative path to the file from repository root'
|
|
51
|
-
},
|
|
52
|
-
content: {
|
|
53
|
-
type: 'string',
|
|
54
|
-
description: 'Complete file content to write'
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
required: ['path', 'content']
|
|
58
|
-
},
|
|
59
|
-
execute: async ({ path: filePath, content }) => {
|
|
60
|
-
try {
|
|
61
|
-
const fullPath = path.join(repoPath, filePath as string);
|
|
62
|
-
const dir = path.dirname(fullPath);
|
|
63
|
-
|
|
64
|
-
// Create parent directories if needed
|
|
65
|
-
await fs.mkdir(dir, { recursive: true });
|
|
66
|
-
|
|
67
|
-
// Write file
|
|
68
|
-
await fs.writeFile(fullPath, content as string, 'utf-8');
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
success: true,
|
|
72
|
-
output: { path: filePath, written: true }
|
|
73
|
-
};
|
|
74
|
-
} catch (error: unknown) {
|
|
75
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
76
|
-
return {
|
|
77
|
-
success: false,
|
|
78
|
-
output: null,
|
|
79
|
-
error: `Failed to write file: ${errorMessage}`
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
name: 'repo_manager-list_files',
|
|
86
|
-
description: 'List files in a directory of the repository',
|
|
87
|
-
parameters: {
|
|
88
|
-
type: 'object',
|
|
89
|
-
properties: {
|
|
90
|
-
path: {
|
|
91
|
-
type: 'string',
|
|
92
|
-
description: 'Relative path to the directory (default: root)',
|
|
93
|
-
default: '.'
|
|
94
|
-
},
|
|
95
|
-
recursive: {
|
|
96
|
-
type: 'boolean',
|
|
97
|
-
description: 'List files recursively (default: false)',
|
|
98
|
-
default: false
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
execute: async ({ path: dirPath = '.', recursive = false }) => {
|
|
103
|
-
try {
|
|
104
|
-
const fullPath = path.join(repoPath, dirPath as string);
|
|
105
|
-
|
|
106
|
-
const files: string[] = [];
|
|
107
|
-
const ignoredDirs = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage'];
|
|
108
|
-
|
|
109
|
-
const listDir = async (currentPath: string, relativeBase: string) => {
|
|
110
|
-
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
111
|
-
|
|
112
|
-
for (const entry of entries) {
|
|
113
|
-
const relativePath = path.join(relativeBase, entry.name);
|
|
114
|
-
|
|
115
|
-
if (entry.isDirectory()) {
|
|
116
|
-
if (recursive && !ignoredDirs.includes(entry.name)) {
|
|
117
|
-
await listDir(path.join(currentPath, entry.name), relativePath);
|
|
118
|
-
}
|
|
119
|
-
} else if (entry.isFile()) {
|
|
120
|
-
files.push(relativePath);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
await listDir(fullPath, '');
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
success: true,
|
|
129
|
-
output: { path: dirPath, files, count: files.length }
|
|
130
|
-
};
|
|
131
|
-
} catch (error: unknown) {
|
|
132
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
133
|
-
return {
|
|
134
|
-
success: false,
|
|
135
|
-
output: null,
|
|
136
|
-
error: `Failed to list files: ${errorMessage}`
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
];
|
|
142
|
-
}
|