crack-code 0.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/README.md +15 -0
- package/bun.lock +79 -0
- package/package.json +26 -0
- package/src/agent.ts +104 -0
- package/src/config.ts +410 -0
- package/src/index.ts +329 -0
- package/src/logo/crack-code.ts +13 -0
- package/src/permissions/index.ts +127 -0
- package/src/providers/anthropic.ts +22 -0
- package/src/providers/google.ts +26 -0
- package/src/providers/ollama.ts +33 -0
- package/src/providers/openai.ts +25 -0
- package/src/providers/types.ts +4 -0
- package/src/providers.ts +39 -0
- package/src/repl.ts +284 -0
- package/src/tools/file-read.ts +77 -0
- package/src/tools/file-write.ts +42 -0
- package/src/tools/glob.ts +84 -0
- package/src/tools/registry.ts +63 -0
- package/src/tools/shell.ts +70 -0
- package/src/ui/renderer.ts +208 -0
- package/tsconfig.json +29 -0
package/src/repl.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import type { ModelMessage, LanguageModel } from "ai";
|
|
3
|
+
import type { Config } from "./config.js";
|
|
4
|
+
import type { ToolRegistry } from "./tools/registry.js";
|
|
5
|
+
import type { PermissionManager } from "./permissions/index.js";
|
|
6
|
+
import { runAgent, type TokenUsage } from "./agent.js";
|
|
7
|
+
import * as ui from "./ui/renderer.js";
|
|
8
|
+
|
|
9
|
+
// Types
|
|
10
|
+
|
|
11
|
+
interface ReplContext {
|
|
12
|
+
model: LanguageModel;
|
|
13
|
+
config: Config;
|
|
14
|
+
tools: ToolRegistry;
|
|
15
|
+
permissions: PermissionManager;
|
|
16
|
+
messages: ModelMessage[];
|
|
17
|
+
totalUsage: TokenUsage;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Slash Commands
|
|
21
|
+
|
|
22
|
+
interface SlashCommand {
|
|
23
|
+
description: string;
|
|
24
|
+
handler: (ctx: ReplContext, args: string) => void | Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const commands: Record<string, SlashCommand> = {
|
|
28
|
+
"/help": {
|
|
29
|
+
description: "Show available commands",
|
|
30
|
+
handler: () => {
|
|
31
|
+
console.log();
|
|
32
|
+
for (const [name, cmd] of Object.entries(commands)) {
|
|
33
|
+
console.log(` \x1b[36m${name.padEnd(14)}\x1b[0m ${cmd.description}`);
|
|
34
|
+
}
|
|
35
|
+
console.log();
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
"/exit": {
|
|
40
|
+
description: "Exit Crack Code",
|
|
41
|
+
handler: () => {
|
|
42
|
+
ui.info("Goodbye.");
|
|
43
|
+
process.exit(0);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
"/clear": {
|
|
48
|
+
description: "Clear conversation history",
|
|
49
|
+
handler: (ctx) => {
|
|
50
|
+
ctx.messages = [];
|
|
51
|
+
ctx.totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
52
|
+
ctx.permissions.clearSession();
|
|
53
|
+
ui.success("Conversation cleared.");
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
"/usage": {
|
|
58
|
+
description: "Show token usage for this session",
|
|
59
|
+
handler: (ctx) => {
|
|
60
|
+
console.log();
|
|
61
|
+
ui.dim(` Input tokens: ${ctx.totalUsage.inputTokens}`);
|
|
62
|
+
ui.dim(` Output tokens: ${ctx.totalUsage.outputTokens}`);
|
|
63
|
+
ui.dim(` Total tokens: ${ctx.totalUsage.totalTokens}`);
|
|
64
|
+
ui.dim(` Messages in context: ${ctx.messages.length}`);
|
|
65
|
+
console.log();
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
"/mode": {
|
|
70
|
+
description: "Toggle read-only ↔ edit mode",
|
|
71
|
+
handler: (ctx) => {
|
|
72
|
+
ctx.config.allowEdits = !ctx.config.allowEdits;
|
|
73
|
+
if (ctx.config.allowEdits) {
|
|
74
|
+
ui.warn("Edit mode enabled. The AI can now modify files.");
|
|
75
|
+
} else {
|
|
76
|
+
ui.success("Read-only mode. The AI can only read and analyze.");
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
"/model": {
|
|
82
|
+
description: "Show current model and provider",
|
|
83
|
+
handler: (ctx) => {
|
|
84
|
+
console.log();
|
|
85
|
+
ui.dim(` Provider: ${ctx.config.provider}`);
|
|
86
|
+
ui.dim(` Model: ${ctx.config.model}`);
|
|
87
|
+
console.log();
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
"/policy": {
|
|
92
|
+
description: "Show or set permission policy (ask/skip/allow-all/deny-all)",
|
|
93
|
+
handler: (ctx, args) => {
|
|
94
|
+
if (!args) {
|
|
95
|
+
ui.dim(` Current policy: ${ctx.permissions.getPolicy()}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const valid = ["ask", "skip", "allow-all", "deny-all"];
|
|
99
|
+
if (!valid.includes(args)) {
|
|
100
|
+
ui.error(`Invalid policy. Use: ${valid.join(", ")}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
ctx.permissions.setPolicy(args as any);
|
|
104
|
+
ui.success(`Permission policy set to: ${args}`);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
"/compact": {
|
|
109
|
+
description: "Summarize conversation to reduce context size",
|
|
110
|
+
handler: async (ctx) => {
|
|
111
|
+
const count = ctx.messages.length;
|
|
112
|
+
if (count <= 2) {
|
|
113
|
+
ui.info("Conversation too short to compact.");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const loading = ui.spinner("Compacting conversation...");
|
|
118
|
+
|
|
119
|
+
// Keep the last exchange, summarize everything before it
|
|
120
|
+
const toSummarize = ctx.messages.slice(0, -2);
|
|
121
|
+
const recent = ctx.messages.slice(-2);
|
|
122
|
+
|
|
123
|
+
const summaryText = toSummarize
|
|
124
|
+
.map((m) => {
|
|
125
|
+
const content =
|
|
126
|
+
typeof m.content === "string"
|
|
127
|
+
? m.content
|
|
128
|
+
: JSON.stringify(m.content);
|
|
129
|
+
return `[${m.role}]: ${content.slice(0, 200)}`;
|
|
130
|
+
})
|
|
131
|
+
.join("\n");
|
|
132
|
+
|
|
133
|
+
ctx.messages = [
|
|
134
|
+
{
|
|
135
|
+
role: "user",
|
|
136
|
+
content: `[Previous conversation summary — ${count - 2} messages]\n${summaryText.slice(0, 2000)}`,
|
|
137
|
+
},
|
|
138
|
+
...recent,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
loading.stop();
|
|
142
|
+
ui.success(
|
|
143
|
+
`Compacted ${count} messages → ${ctx.messages.length} messages.`,
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Input Handling
|
|
150
|
+
|
|
151
|
+
function createInput(): {
|
|
152
|
+
readLine: () => Promise<string>;
|
|
153
|
+
close: () => void;
|
|
154
|
+
} {
|
|
155
|
+
const rl = readline.createInterface({
|
|
156
|
+
input: process.stdin,
|
|
157
|
+
output: process.stdout,
|
|
158
|
+
terminal: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Handle Ctrl+C gracefully
|
|
162
|
+
rl.on("SIGINT", () => {
|
|
163
|
+
console.log();
|
|
164
|
+
ui.info("Goodbye.");
|
|
165
|
+
process.exit(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
readLine: () =>
|
|
170
|
+
new Promise<string>((resolve) => {
|
|
171
|
+
ui.userPrompt();
|
|
172
|
+
rl.once("line", (line) => resolve(line.trim()));
|
|
173
|
+
}),
|
|
174
|
+
close: () => rl.close(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Main REPL Loop
|
|
179
|
+
|
|
180
|
+
export async function startRepl(
|
|
181
|
+
model: LanguageModel,
|
|
182
|
+
config: Config,
|
|
183
|
+
tools: ToolRegistry,
|
|
184
|
+
permissions: PermissionManager,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const ctx: ReplContext = {
|
|
187
|
+
model,
|
|
188
|
+
config,
|
|
189
|
+
tools,
|
|
190
|
+
permissions,
|
|
191
|
+
messages: [],
|
|
192
|
+
totalUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const mode = config.allowEdits ? "edits enabled" : "read-only";
|
|
196
|
+
ui.banner(config.model, mode);
|
|
197
|
+
|
|
198
|
+
const input = createInput();
|
|
199
|
+
|
|
200
|
+
while (true) {
|
|
201
|
+
const line = await input.readLine();
|
|
202
|
+
|
|
203
|
+
if (!line) continue;
|
|
204
|
+
|
|
205
|
+
// Slash command
|
|
206
|
+
const spaceIdx = line.indexOf(" ");
|
|
207
|
+
const cmdName = spaceIdx === -1 ? line : line.slice(0, spaceIdx);
|
|
208
|
+
const cmdArgs = spaceIdx === -1 ? "" : line.slice(spaceIdx + 1).trim();
|
|
209
|
+
|
|
210
|
+
if (cmdName.startsWith("/")) {
|
|
211
|
+
const command = commands[cmdName];
|
|
212
|
+
if (command) {
|
|
213
|
+
await command.handler(ctx, cmdArgs);
|
|
214
|
+
} else {
|
|
215
|
+
ui.error(
|
|
216
|
+
`Unknown command: ${cmdName}. Type /help for available commands.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Send to agent
|
|
223
|
+
ctx.messages.push({ role: "user", content: line });
|
|
224
|
+
ui.newline();
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const loading = ui.spinner("Thinking...");
|
|
228
|
+
let firstToken = true;
|
|
229
|
+
|
|
230
|
+
ctx.messages = await runAgent(
|
|
231
|
+
ctx.messages,
|
|
232
|
+
{
|
|
233
|
+
model: ctx.model,
|
|
234
|
+
tools: ctx.tools,
|
|
235
|
+
permissions: ctx.permissions,
|
|
236
|
+
systemPrompt: ctx.config.systemPrompt,
|
|
237
|
+
maxSteps: ctx.config.maxSteps,
|
|
238
|
+
maxTokens: ctx.config.maxTokens,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
onText: (delta) => {
|
|
242
|
+
if (firstToken) {
|
|
243
|
+
loading.stop();
|
|
244
|
+
firstToken = false;
|
|
245
|
+
}
|
|
246
|
+
ui.streamText(delta);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
onToolStart: (name, args) => {
|
|
250
|
+
if (firstToken) {
|
|
251
|
+
loading.stop();
|
|
252
|
+
firstToken = false;
|
|
253
|
+
}
|
|
254
|
+
ui.toolStart(name, args);
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
onToolEnd: (name, result) => {
|
|
258
|
+
ui.toolEnd(name, result);
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
onUsage: (usage) => {
|
|
262
|
+
ctx.totalUsage.inputTokens += usage.inputTokens;
|
|
263
|
+
ctx.totalUsage.outputTokens += usage.outputTokens;
|
|
264
|
+
ctx.totalUsage.totalTokens += usage.totalTokens;
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
onError: (err) => {
|
|
268
|
+
loading.stop();
|
|
269
|
+
ui.error(err);
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// If spinner never stopped (empty response), stop it now
|
|
275
|
+
if (firstToken) loading.stop();
|
|
276
|
+
|
|
277
|
+
ui.newline();
|
|
278
|
+
} catch (e: any) {
|
|
279
|
+
// Agent threw — keep previous messages intact
|
|
280
|
+
ctx.messages.pop(); // remove the user message that caused the error
|
|
281
|
+
ui.error(e.message);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resolve, relative } from "node:path";
|
|
3
|
+
import type { ToolDef } from "./registry.js";
|
|
4
|
+
|
|
5
|
+
const MAX_SIZE = 256 * 1024; // 256 KB
|
|
6
|
+
|
|
7
|
+
const schema = z.object({
|
|
8
|
+
path: z.string().describe("Relative or absolute path to the file"),
|
|
9
|
+
start_line: z.number().optional().describe("First line to read (1-based)"),
|
|
10
|
+
end_line: z
|
|
11
|
+
.number()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Last line to read (1-based, inclusive)"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const readFileTool: ToolDef<typeof schema> = {
|
|
17
|
+
name: "read_file",
|
|
18
|
+
description:
|
|
19
|
+
"Read the contents of a file. Returns numbered lines. " +
|
|
20
|
+
"Use start_line/end_line to read a specific range. " +
|
|
21
|
+
"Files larger than 256 KB are truncated.",
|
|
22
|
+
inputSchema: schema,
|
|
23
|
+
requiresApproval: false,
|
|
24
|
+
|
|
25
|
+
async execute({ path: filePath, start_line, end_line }) {
|
|
26
|
+
const cwd = process.cwd();
|
|
27
|
+
const abs = resolve(cwd, filePath);
|
|
28
|
+
const rel = relative(cwd, abs);
|
|
29
|
+
|
|
30
|
+
// Block path traversal outside cwd
|
|
31
|
+
if (rel.startsWith("..")) {
|
|
32
|
+
return `Error: path "${filePath}" is outside the working directory.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const file = Bun.file(abs);
|
|
36
|
+
if (!(await file.exists())) {
|
|
37
|
+
return `Error: file not found — ${rel}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const size = file.size;
|
|
41
|
+
if (size > MAX_SIZE) {
|
|
42
|
+
const partial = await file.text();
|
|
43
|
+
const truncated = partial.slice(0, MAX_SIZE);
|
|
44
|
+
const lines = truncated.split("\n");
|
|
45
|
+
return (
|
|
46
|
+
`⚠ File truncated (${(size / 1024).toFixed(0)} KB > 256 KB limit). Showing first ${lines.length} lines.\n\n` +
|
|
47
|
+
numberLines(lines, 1)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const content = await file.text();
|
|
52
|
+
let lines = content.split("\n");
|
|
53
|
+
|
|
54
|
+
// Apply line range
|
|
55
|
+
const start = start_line ? Math.max(1, start_line) : 1;
|
|
56
|
+
const end = end_line ? Math.min(lines.length, end_line) : lines.length;
|
|
57
|
+
lines = lines.slice(start - 1, end);
|
|
58
|
+
|
|
59
|
+
if (lines.length === 0) {
|
|
60
|
+
return `File is empty — ${rel}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const header =
|
|
64
|
+
start_line || end_line
|
|
65
|
+
? `${rel} (lines ${start}–${end})`
|
|
66
|
+
: `${rel} (${lines.length} lines)`;
|
|
67
|
+
|
|
68
|
+
return `${header}\n\n${numberLines(lines, start)}`;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function numberLines(lines: string[], startAt: number): string {
|
|
73
|
+
const width = String(startAt + lines.length - 1).length;
|
|
74
|
+
return lines
|
|
75
|
+
.map((line, i) => `${String(startAt + i).padStart(width)} │ ${line}`)
|
|
76
|
+
.join("\n");
|
|
77
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resolve, relative, dirname } from "node:path";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import type { ToolDef } from "./registry.js";
|
|
5
|
+
|
|
6
|
+
const schema = z.object({
|
|
7
|
+
path: z.string().describe("Relative or absolute path to the file"),
|
|
8
|
+
content: z.string().describe("The full content to write to the file"),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const writeFileTool: ToolDef<typeof schema> = {
|
|
12
|
+
name: "write_file",
|
|
13
|
+
description:
|
|
14
|
+
"Write content to a file. Creates the file if it doesn't exist, " +
|
|
15
|
+
"overwrites if it does. Parent directories are created automatically. " +
|
|
16
|
+
"Always requires user approval.",
|
|
17
|
+
inputSchema: schema,
|
|
18
|
+
requiresApproval: true,
|
|
19
|
+
|
|
20
|
+
async execute({ path: filePath, content }) {
|
|
21
|
+
const cwd = process.cwd();
|
|
22
|
+
const abs = resolve(cwd, filePath);
|
|
23
|
+
const rel = relative(cwd, abs);
|
|
24
|
+
|
|
25
|
+
// Block path traversal outside cwd
|
|
26
|
+
if (rel.startsWith("..")) {
|
|
27
|
+
return `Error: path "${filePath}" is outside the working directory.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Ensure parent directories exist
|
|
31
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
32
|
+
|
|
33
|
+
const existed = await Bun.file(abs).exists();
|
|
34
|
+
await Bun.write(abs, content);
|
|
35
|
+
|
|
36
|
+
const lines = content.split("\n").length;
|
|
37
|
+
const bytes = Buffer.byteLength(content, "utf-8");
|
|
38
|
+
const action = existed ? "Updated" : "Created";
|
|
39
|
+
|
|
40
|
+
return `${action} ${rel} (${lines} lines, ${bytes} bytes)`;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDef } from "./registry.js";
|
|
3
|
+
|
|
4
|
+
const MAX_RESULTS = 500;
|
|
5
|
+
|
|
6
|
+
const schema = z.object({
|
|
7
|
+
pattern: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe('Glob pattern to match (e.g. "**/*.ts", "src/**/*.js")'),
|
|
10
|
+
ignore: z
|
|
11
|
+
.array(z.string())
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Additional glob patterns to ignore"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const listFilesTool: ToolDef<typeof schema> = {
|
|
17
|
+
name: "list_files",
|
|
18
|
+
description:
|
|
19
|
+
"List files matching a glob pattern in the working directory. " +
|
|
20
|
+
"Common ignore patterns (node_modules, .git, dist, etc.) are excluded by default. " +
|
|
21
|
+
"Returns up to 500 matching paths sorted alphabetically.",
|
|
22
|
+
inputSchema: schema,
|
|
23
|
+
requiresApproval: false,
|
|
24
|
+
|
|
25
|
+
async execute({ pattern, ignore }) {
|
|
26
|
+
const cwd = process.cwd();
|
|
27
|
+
|
|
28
|
+
const defaultIgnore = [
|
|
29
|
+
"node_modules/**",
|
|
30
|
+
".git/**",
|
|
31
|
+
"dist/**",
|
|
32
|
+
"build/**",
|
|
33
|
+
"coverage/**",
|
|
34
|
+
".next/**",
|
|
35
|
+
"__pycache__/**",
|
|
36
|
+
"vendor/**",
|
|
37
|
+
"target/**",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ignorePatterns = [...defaultIgnore, ...(ignore ?? [])];
|
|
41
|
+
|
|
42
|
+
const glob = new Bun.Glob(pattern);
|
|
43
|
+
const matches: string[] = [];
|
|
44
|
+
|
|
45
|
+
for await (const path of glob.scan({ cwd, dot: false })) {
|
|
46
|
+
if (shouldIgnore(path, ignorePatterns)) continue;
|
|
47
|
+
matches.push(path);
|
|
48
|
+
if (matches.length >= MAX_RESULTS) break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
matches.sort();
|
|
52
|
+
|
|
53
|
+
if (matches.length === 0) {
|
|
54
|
+
return `No files matched pattern "${pattern}"`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const header =
|
|
58
|
+
matches.length >= MAX_RESULTS
|
|
59
|
+
? `Found ${MAX_RESULTS}+ files (showing first ${MAX_RESULTS}):`
|
|
60
|
+
: `Found ${matches.length} file${matches.length === 1 ? "" : "s"}:`;
|
|
61
|
+
|
|
62
|
+
return `${header}\n\n${matches.join("\n")}`;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function shouldIgnore(path: string, patterns: string[]): boolean {
|
|
67
|
+
for (const pattern of patterns) {
|
|
68
|
+
// Simple prefix match for directory globs like "node_modules/**"
|
|
69
|
+
if (pattern.endsWith("/**")) {
|
|
70
|
+
const dir = pattern.slice(0, -3);
|
|
71
|
+
if (path === dir || path.startsWith(dir + "/")) return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Simple extension match for patterns like "*.lock"
|
|
75
|
+
if (pattern.startsWith("*.")) {
|
|
76
|
+
const ext = pattern.slice(1);
|
|
77
|
+
if (path.endsWith(ext)) return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Exact match
|
|
81
|
+
if (path === pattern) return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ToolSet } from "ai";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { PermissionManager } from "../permissions/index.js";
|
|
4
|
+
import * as ui from "../ui/renderer.js";
|
|
5
|
+
|
|
6
|
+
// Each tool module exports one of these
|
|
7
|
+
export interface ToolDef<
|
|
8
|
+
TSchema extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>,
|
|
9
|
+
> {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
inputSchema: TSchema;
|
|
13
|
+
requiresApproval: boolean;
|
|
14
|
+
execute: (input: z.infer<TSchema>) => Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ToolRegistry {
|
|
18
|
+
private defs: ToolDef[] = [];
|
|
19
|
+
|
|
20
|
+
register(def: ToolDef): void {
|
|
21
|
+
this.defs.push(def);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
registerAll(...defs: ToolDef[]): void {
|
|
25
|
+
for (const def of defs) this.defs.push(def);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Convert to AI SDK ToolSet with permission gating and UI hooks
|
|
29
|
+
toAISDKTools(permissions: PermissionManager): ToolSet {
|
|
30
|
+
const tools: ToolSet = {};
|
|
31
|
+
|
|
32
|
+
for (const def of this.defs) {
|
|
33
|
+
tools[def.name] = {
|
|
34
|
+
description: def.description,
|
|
35
|
+
inputSchema: def.inputSchema,
|
|
36
|
+
execute: async (input: Record<string, unknown>) => {
|
|
37
|
+
ui.toolStart(def.name, input);
|
|
38
|
+
|
|
39
|
+
if (def.requiresApproval) {
|
|
40
|
+
const allowed = await permissions.check(def.name, input);
|
|
41
|
+
if (!allowed) return "⛔ Tool call denied by user.";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const result = await def.execute(input);
|
|
46
|
+
ui.toolEnd(def.name, result);
|
|
47
|
+
return result;
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
const msg = `Error: ${err.message ?? err}`;
|
|
50
|
+
ui.toolEnd(def.name, msg);
|
|
51
|
+
return msg;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return tools;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getNames(): string[] {
|
|
61
|
+
return this.defs.map((d) => d.name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDef } from "./registry.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT = 30_000; // 30 seconds
|
|
5
|
+
const MAX_OUTPUT = 128 * 1024; // 128 KB
|
|
6
|
+
|
|
7
|
+
const schema = z.object({
|
|
8
|
+
command: z.string().describe("The shell command to execute"),
|
|
9
|
+
timeout_ms: z
|
|
10
|
+
.number()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("Max runtime in milliseconds (default 30000)"),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const runCommandTool: ToolDef<typeof schema> = {
|
|
16
|
+
name: "run_command",
|
|
17
|
+
description:
|
|
18
|
+
"Execute a shell command in the working directory. " +
|
|
19
|
+
"Output is captured from stdout and stderr. " +
|
|
20
|
+
"Commands are killed after the timeout (default 30s). " +
|
|
21
|
+
"Always requires user approval.",
|
|
22
|
+
inputSchema: schema,
|
|
23
|
+
requiresApproval: true,
|
|
24
|
+
|
|
25
|
+
async execute({ command, timeout_ms }) {
|
|
26
|
+
const timeout = timeout_ms ?? DEFAULT_TIMEOUT;
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const proc = Bun.spawn(["sh", "-c", command], {
|
|
31
|
+
cwd,
|
|
32
|
+
stdout: "pipe",
|
|
33
|
+
stderr: "pipe",
|
|
34
|
+
env: process.env,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const timer = setTimeout(() => proc.kill(), timeout);
|
|
38
|
+
|
|
39
|
+
const [stdoutBuf, stderrBuf] = await Promise.all([
|
|
40
|
+
new Response(proc.stdout).arrayBuffer(),
|
|
41
|
+
new Response(proc.stderr).arrayBuffer(),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
|
|
46
|
+
const code = proc.exitCode ?? (await proc.exited);
|
|
47
|
+
let stdout = new TextDecoder().decode(stdoutBuf);
|
|
48
|
+
let stderr = new TextDecoder().decode(stderrBuf);
|
|
49
|
+
|
|
50
|
+
// Truncate if too large
|
|
51
|
+
if (stdout.length > MAX_OUTPUT) {
|
|
52
|
+
stdout = stdout.slice(0, MAX_OUTPUT) + "\n… (stdout truncated)";
|
|
53
|
+
}
|
|
54
|
+
if (stderr.length > MAX_OUTPUT) {
|
|
55
|
+
stderr = stderr.slice(0, MAX_OUTPUT) + "\n… (stderr truncated)";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parts: string[] = [`Exit code: ${code}`];
|
|
59
|
+
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
|
|
60
|
+
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
|
|
61
|
+
|
|
62
|
+
return parts.join("\n\n");
|
|
63
|
+
} catch (err: any) {
|
|
64
|
+
if (err.message?.includes("kill")) {
|
|
65
|
+
return `Error: command timed out after ${timeout}ms — "${command}"`;
|
|
66
|
+
}
|
|
67
|
+
return `Error: ${err.message ?? err}`;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|