codeshark-cli 0.1.1
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 +260 -0
- package/TERMS.md +72 -0
- package/dist/agent.js +77 -0
- package/dist/ansi.js +52 -0
- package/dist/banner.js +94 -0
- package/dist/config.js +126 -0
- package/dist/index.js +190 -0
- package/dist/keysPage.js +233 -0
- package/dist/loading.js +63 -0
- package/dist/models.js +54 -0
- package/dist/project.js +56 -0
- package/dist/provider/gateway.js +19 -0
- package/dist/provider/gemini.js +184 -0
- package/dist/provider/index.js +94 -0
- package/dist/provider/nvidia.js +19 -0
- package/dist/provider/ollama.js +22 -0
- package/dist/provider/openaiCompat.js +198 -0
- package/dist/provider/openrouter.js +21 -0
- package/dist/provider/types.js +35 -0
- package/dist/provider/unorouter.js +25 -0
- package/dist/repl.js +202 -0
- package/dist/setup.js +178 -0
- package/dist/system.js +21 -0
- package/dist/terms.js +18 -0
- package/dist/tools/files.js +288 -0
- package/dist/tools/index.js +16 -0
- package/dist/tools/registry.js +34 -0
- package/dist/tools/search.js +154 -0
- package/dist/tools/shell.js +105 -0
- package/package.json +54 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { resolveProjectPath } from "../project.js";
|
|
4
|
+
function resolvePath(p, cwd) {
|
|
5
|
+
const s = String(p ?? "").trim();
|
|
6
|
+
if (!s)
|
|
7
|
+
return cwd;
|
|
8
|
+
return resolveProjectPath(s, cwd);
|
|
9
|
+
}
|
|
10
|
+
function prettyPath(p, cwd) {
|
|
11
|
+
const r = relative(cwd, p);
|
|
12
|
+
return r && !r.startsWith("..") ? r : p;
|
|
13
|
+
}
|
|
14
|
+
const MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
const MAX_LIST_ENTRIES = 300;
|
|
16
|
+
const MAX_GLOB_MATCHES = 200;
|
|
17
|
+
function looksBinary(text) {
|
|
18
|
+
return text.includes("\u0000");
|
|
19
|
+
}
|
|
20
|
+
export const readFileTool = {
|
|
21
|
+
name: "read_file",
|
|
22
|
+
description: "Read a text file. Use offset (1-based line number) and limit (number of lines) to read large files in windows. Output is line-numbered.",
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
path: { type: "string", description: "Path to the file, absolute or relative to the working directory." },
|
|
27
|
+
offset: { type: "integer", description: "1-based starting line. Default 1." },
|
|
28
|
+
limit: { type: "integer", description: "Max lines to return. Default: whole file." },
|
|
29
|
+
},
|
|
30
|
+
required: ["path"],
|
|
31
|
+
},
|
|
32
|
+
run(args, ctx) {
|
|
33
|
+
const p = resolvePath(args.path, ctx.cwd);
|
|
34
|
+
const st = statSync(p, { throwIfNoEntry: false });
|
|
35
|
+
if (!st)
|
|
36
|
+
throw new Error(`File not found: ${prettyPath(p, ctx.cwd)}`);
|
|
37
|
+
if (st.isDirectory())
|
|
38
|
+
throw new Error(`${prettyPath(p, ctx.cwd)} is a directory — use list_directory instead.`);
|
|
39
|
+
if (st.size > MAX_READ_BYTES) {
|
|
40
|
+
throw new Error(`File is ${Math.round(st.size / 1024 / 1024)} MB — too large to read whole. Use offset/limit to page through it.`);
|
|
41
|
+
}
|
|
42
|
+
const text = readFileSync(p, "utf8");
|
|
43
|
+
if (looksBinary(text))
|
|
44
|
+
throw new Error(`File appears to be binary — refusing to read as text.`);
|
|
45
|
+
const lines = text.split("\n");
|
|
46
|
+
const start = args.offset ? Math.max(1, Number(args.offset)) : 1;
|
|
47
|
+
const end = args.limit ? Math.min(lines.length, start + Number(args.limit) - 1) : lines.length;
|
|
48
|
+
const body = lines
|
|
49
|
+
.slice(start - 1, end)
|
|
50
|
+
.map((line, i) => `${String(start + i).padStart(5)} | ${line}`)
|
|
51
|
+
.join("\n");
|
|
52
|
+
const trunc = end < lines.length ? `\n... (${lines.length - end} more lines; continue with offset=${end + 1})` : "";
|
|
53
|
+
return `File: ${prettyPath(p, ctx.cwd)} (${lines.length} lines)\n${body}${trunc}`;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
export const writeFileTool = {
|
|
57
|
+
name: "write_file",
|
|
58
|
+
description: "Create a new file or overwrite an existing one with the given content. Creates parent directories. Use edit_file for surgical changes to existing files.",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
path: { type: "string", description: "Path to the file to write." },
|
|
63
|
+
content: { type: "string", description: "Full content of the file." },
|
|
64
|
+
},
|
|
65
|
+
required: ["path", "content"],
|
|
66
|
+
},
|
|
67
|
+
run(args, ctx) {
|
|
68
|
+
const p = resolvePath(args.path, ctx.cwd);
|
|
69
|
+
const content = String(args.content ?? "");
|
|
70
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
71
|
+
writeFileSync(p, content, "utf8");
|
|
72
|
+
return `Wrote ${Buffer.byteLength(content, "utf8")} bytes to ${prettyPath(p, ctx.cwd)}.`;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
export const editFileTool = {
|
|
76
|
+
name: "edit_file",
|
|
77
|
+
description: "Make a surgical change to an existing file: replace exactly one occurrence of oldString with newString. The oldString must match the file exactly (including whitespace) and appear exactly once, otherwise the edit is rejected — this prevents accidental corrupting edits.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: {
|
|
81
|
+
path: { type: "string", description: "Path to the file to edit." },
|
|
82
|
+
oldString: { type: "string", description: "The exact text to replace (must appear exactly once in the file)." },
|
|
83
|
+
newString: { type: "string", description: "The replacement text." },
|
|
84
|
+
},
|
|
85
|
+
required: ["path", "oldString", "newString"],
|
|
86
|
+
},
|
|
87
|
+
run(args, ctx) {
|
|
88
|
+
const p = resolvePath(args.path, ctx.cwd);
|
|
89
|
+
const oldString = String(args.oldString ?? "");
|
|
90
|
+
const newString = String(args.newString ?? "");
|
|
91
|
+
if (!oldString)
|
|
92
|
+
throw new Error("oldString must not be empty.");
|
|
93
|
+
const st = statSync(p, { throwIfNoEntry: false });
|
|
94
|
+
if (!st)
|
|
95
|
+
throw new Error(`File not found: ${prettyPath(p, ctx.cwd)}`);
|
|
96
|
+
const text = readFileSync(p, "utf8");
|
|
97
|
+
if (looksBinary(text))
|
|
98
|
+
throw new Error("File appears to be binary — refusing to edit as text.");
|
|
99
|
+
let count = 0;
|
|
100
|
+
let idx = -1;
|
|
101
|
+
let from = 0;
|
|
102
|
+
while (true) {
|
|
103
|
+
const hit = text.indexOf(oldString, from);
|
|
104
|
+
if (hit === -1)
|
|
105
|
+
break;
|
|
106
|
+
count++;
|
|
107
|
+
if (idx === -1)
|
|
108
|
+
idx = hit;
|
|
109
|
+
from = hit + oldString.length;
|
|
110
|
+
}
|
|
111
|
+
if (count === 0) {
|
|
112
|
+
throw new Error(`oldString not found in ${prettyPath(p, ctx.cwd)}. It must match the file exactly — check whitespace/indentation.`);
|
|
113
|
+
}
|
|
114
|
+
if (count > 1) {
|
|
115
|
+
throw new Error(`oldString matches ${count} times in ${prettyPath(p, ctx.cwd)}. Make it more specific (include surrounding lines).`);
|
|
116
|
+
}
|
|
117
|
+
const next = text.slice(0, idx) + newString + text.slice(idx + oldString.length);
|
|
118
|
+
writeFileSync(p, next, "utf8");
|
|
119
|
+
const line = (text.slice(0, idx).match(/\n/g)?.length ?? 0) + 1;
|
|
120
|
+
return `Edited ${prettyPath(p, ctx.cwd)} (line ${line}): replaced ${oldString.length} chars with ${newString.length} chars.`;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
export const listDirectoryTool = {
|
|
124
|
+
name: "list_directory",
|
|
125
|
+
description: "List the files and subdirectories in a directory. Directories are suffixed with '/'.",
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
path: { type: "string", description: "Directory to list. Defaults to the working directory." },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
run(args, ctx) {
|
|
133
|
+
const p = resolvePath(args.path, ctx.cwd);
|
|
134
|
+
const st = statSync(p, { throwIfNoEntry: false });
|
|
135
|
+
if (!st)
|
|
136
|
+
return `Directory not found: ${prettyPath(p, ctx.cwd)}`;
|
|
137
|
+
if (!st.isDirectory())
|
|
138
|
+
return `${prettyPath(p, ctx.cwd)} is not a directory.`;
|
|
139
|
+
const entries = readdirSync(p, { withFileTypes: true })
|
|
140
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1))
|
|
141
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
142
|
+
const shown = entries.slice(0, MAX_LIST_ENTRIES);
|
|
143
|
+
const trunc = entries.length > MAX_LIST_ENTRIES ? `\n... (${entries.length - MAX_LIST_ENTRIES} more entries)` : "";
|
|
144
|
+
return `Directory: ${prettyPath(p, ctx.cwd)} (${entries.length} entries)\n${shown.join("\n")}${trunc}`;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
/** Minimal glob → RegExp conversion supporting **, *, ?, and {a,b}. */
|
|
148
|
+
export function globToRegExp(glob) {
|
|
149
|
+
let re = "^";
|
|
150
|
+
let i = 0;
|
|
151
|
+
while (i < glob.length) {
|
|
152
|
+
const ch = glob[i];
|
|
153
|
+
if (ch === "*") {
|
|
154
|
+
if (glob[i + 1] === "*") {
|
|
155
|
+
// "**/" may match zero directories; bare "**" matches anything.
|
|
156
|
+
if (glob[i + 2] === "/") {
|
|
157
|
+
re += "(?:.*/)?";
|
|
158
|
+
i += 3;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
re += ".*";
|
|
162
|
+
i += 2;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
re += "[^/]*";
|
|
167
|
+
i += 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else if (ch === "?") {
|
|
171
|
+
re += "[^/]";
|
|
172
|
+
i += 1;
|
|
173
|
+
}
|
|
174
|
+
else if (ch === "{") {
|
|
175
|
+
const end = glob.indexOf("}", i);
|
|
176
|
+
if (end === -1) {
|
|
177
|
+
re += "\\{";
|
|
178
|
+
i += 1;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
const alts = glob
|
|
182
|
+
.slice(i + 1, end)
|
|
183
|
+
.split(",")
|
|
184
|
+
.map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
185
|
+
.join("|");
|
|
186
|
+
re += `(?:${alts})`;
|
|
187
|
+
i = end + 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (ch === "[") {
|
|
191
|
+
const end = glob.indexOf("]", i);
|
|
192
|
+
if (end === -1) {
|
|
193
|
+
re += "\\[";
|
|
194
|
+
i += 1;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
re += glob.slice(i, end + 1);
|
|
198
|
+
i = end + 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
203
|
+
i += 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return new RegExp(re + "$");
|
|
207
|
+
}
|
|
208
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".next", "target", "vendor", "__pycache__"]);
|
|
209
|
+
function walkFiles(dir, out, depth) {
|
|
210
|
+
if (depth > 12)
|
|
211
|
+
return;
|
|
212
|
+
let entries;
|
|
213
|
+
try {
|
|
214
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const e of entries) {
|
|
220
|
+
if (out.length >= MAX_GLOB_MATCHES)
|
|
221
|
+
return;
|
|
222
|
+
if (e.name.startsWith("."))
|
|
223
|
+
continue; // skip hidden
|
|
224
|
+
if (e.isDirectory()) {
|
|
225
|
+
if (SKIP_DIRS.has(e.name))
|
|
226
|
+
continue;
|
|
227
|
+
walkFiles(resolve(dir, e.name), out, depth + 1);
|
|
228
|
+
}
|
|
229
|
+
else if (e.isFile()) {
|
|
230
|
+
out.push(resolve(dir, e.name));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export const globTool = {
|
|
235
|
+
name: "glob",
|
|
236
|
+
description: "Find files matching a glob pattern, e.g. \"src/**/*.ts\" or \"*.json\". Skips node_modules, .git, and hidden files by default.",
|
|
237
|
+
inputSchema: {
|
|
238
|
+
type: "object",
|
|
239
|
+
properties: {
|
|
240
|
+
pattern: { type: "string", description: "Glob pattern. Supports **, *, ?, and {a,b}." },
|
|
241
|
+
cwd: { type: "string", description: "Directory to search from. Defaults to the working directory." },
|
|
242
|
+
},
|
|
243
|
+
required: ["pattern"],
|
|
244
|
+
},
|
|
245
|
+
run(args, ctx) {
|
|
246
|
+
const pattern = String(args.pattern ?? "");
|
|
247
|
+
if (!pattern)
|
|
248
|
+
return "pattern is required.";
|
|
249
|
+
const cwd = args.cwd ? resolvePath(args.cwd, ctx.cwd) : ctx.cwd;
|
|
250
|
+
const staticPrefix = pattern.slice(0, pattern.indexOf("*"));
|
|
251
|
+
const prefix = staticPrefix.split(/[\\/]/).slice(0, -1).join("/") || ".";
|
|
252
|
+
const searchRoot = resolveProjectPath(prefix, cwd);
|
|
253
|
+
if (!statSync(searchRoot, { throwIfNoEntry: false })?.isDirectory()) {
|
|
254
|
+
return `No matches for ${pattern}`;
|
|
255
|
+
}
|
|
256
|
+
const all = [];
|
|
257
|
+
walkFiles(searchRoot, all, 0);
|
|
258
|
+
const rx = globToRegExp(pattern.replace(/\\/g, "/"));
|
|
259
|
+
const matches = all
|
|
260
|
+
.map((f) => relative(cwd, f).replace(/\\/g, "/"))
|
|
261
|
+
.filter((f) => rx.test(f))
|
|
262
|
+
.slice(0, MAX_GLOB_MATCHES);
|
|
263
|
+
if (!matches.length)
|
|
264
|
+
return `No matches for ${pattern}`;
|
|
265
|
+
return `${matches.length} match${matches.length === 1 ? "" : "es"} for ${pattern}:\n${matches.join("\n")}`;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
export const finishTool = {
|
|
269
|
+
name: "finish",
|
|
270
|
+
description: "Signal that the task is complete. Call this with a short summary of what was done instead of answering in chat text.",
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: "object",
|
|
273
|
+
properties: {
|
|
274
|
+
summary: { type: "string", description: "One or two sentence summary of what was accomplished." },
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
run(args) {
|
|
278
|
+
const summary = String(args.summary ?? "").trim();
|
|
279
|
+
return summary || "Task complete.";
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
export const fileTools = [
|
|
283
|
+
readFileTool,
|
|
284
|
+
writeFileTool,
|
|
285
|
+
editFileTool,
|
|
286
|
+
listDirectoryTool,
|
|
287
|
+
globTool,
|
|
288
|
+
];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ToolRegistry } from "./registry.js";
|
|
2
|
+
import { fileTools, finishTool } from "./files.js";
|
|
3
|
+
import { codeSearchTool } from "./search.js";
|
|
4
|
+
import { runCommandTool } from "./shell.js";
|
|
5
|
+
/** Build the standard CodeShark toolset (working dir comes per-call via ToolContext). */
|
|
6
|
+
export function createRegistry() {
|
|
7
|
+
const reg = new ToolRegistry();
|
|
8
|
+
for (const t of fileTools)
|
|
9
|
+
reg.add(t);
|
|
10
|
+
reg.add(codeSearchTool);
|
|
11
|
+
reg.add(runCommandTool);
|
|
12
|
+
reg.add(finishTool);
|
|
13
|
+
return reg;
|
|
14
|
+
}
|
|
15
|
+
export { ToolRegistry } from "./registry.js";
|
|
16
|
+
export { isDangerousCommand } from "./shell.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class ToolRegistry {
|
|
2
|
+
tools = new Map();
|
|
3
|
+
add(tool) {
|
|
4
|
+
this.tools.set(tool.name, tool);
|
|
5
|
+
return this;
|
|
6
|
+
}
|
|
7
|
+
names() {
|
|
8
|
+
return [...this.tools.keys()];
|
|
9
|
+
}
|
|
10
|
+
schemas() {
|
|
11
|
+
return [...this.tools.values()].map((t) => ({
|
|
12
|
+
name: t.name,
|
|
13
|
+
description: t.description,
|
|
14
|
+
inputSchema: t.inputSchema,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
async execute(name, args, ctx) {
|
|
18
|
+
const tool = this.tools.get(name);
|
|
19
|
+
if (!tool) {
|
|
20
|
+
return {
|
|
21
|
+
content: `Unknown tool: ${name}. Available tools: ${this.names().join(", ")}`,
|
|
22
|
+
isError: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const content = await tool.run(args, ctx);
|
|
27
|
+
return { content: String(content), isError: false };
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
31
|
+
return { content: `Tool ${name} failed: ${msg}`, isError: true };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { resolveProjectPath } from "../project.js";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
const MAX_RESULTS = 200;
|
|
6
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".next", "target", "vendor", "__pycache__"]);
|
|
7
|
+
function exec(cmd, args, timeoutMs) {
|
|
8
|
+
return new Promise((resolvePromise) => {
|
|
9
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
10
|
+
let stdout = "";
|
|
11
|
+
let stderr = "";
|
|
12
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
13
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
14
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
15
|
+
child.on("close", (code) => {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
resolvePromise({ code, stdout, stderr });
|
|
18
|
+
});
|
|
19
|
+
child.on("error", () => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
resolvePromise({ code: null, stdout, stderr: "spawn failed" });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function parseFlags(flags) {
|
|
26
|
+
return {
|
|
27
|
+
caseInsensitive: /-i/.test(flags),
|
|
28
|
+
filesOnly: /-l/.test(flags),
|
|
29
|
+
word: /-w/.test(flags),
|
|
30
|
+
context: (/-C\s*(\d+)/.exec(flags)?.[1] ? Number(/-C\s*(\d+)/.exec(flags)[1]) : 0) || 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Dependency-free fallback when ripgrep isn't installed. */
|
|
34
|
+
function jsSearch(root, pattern, flags) {
|
|
35
|
+
const { caseInsensitive, filesOnly, context } = parseFlags(flags);
|
|
36
|
+
let rx;
|
|
37
|
+
try {
|
|
38
|
+
rx = new RegExp(pattern, caseInsensitive ? "i" : "");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return `Invalid regex: ${pattern}`;
|
|
42
|
+
}
|
|
43
|
+
const results = [];
|
|
44
|
+
const walk = (dir, depth) => {
|
|
45
|
+
if (depth > 12 || results.length >= MAX_RESULTS)
|
|
46
|
+
return;
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (results.length >= MAX_RESULTS)
|
|
56
|
+
return;
|
|
57
|
+
if (e.name.startsWith("."))
|
|
58
|
+
continue;
|
|
59
|
+
const full = resolveProjectPath(e.name, dir);
|
|
60
|
+
if (e.isDirectory()) {
|
|
61
|
+
if (SKIP_DIRS.has(e.name))
|
|
62
|
+
continue;
|
|
63
|
+
walk(full, depth + 1);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!e.isFile())
|
|
67
|
+
continue;
|
|
68
|
+
let text;
|
|
69
|
+
try {
|
|
70
|
+
text = readFileSync(full, "utf8");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (text.includes("\u0000"))
|
|
76
|
+
continue; // binary
|
|
77
|
+
const rel = relative(root, full).replace(/\\/g, "/");
|
|
78
|
+
const lines = text.split("\n");
|
|
79
|
+
if (filesOnly) {
|
|
80
|
+
if (lines.some((l) => rx.test(l)))
|
|
81
|
+
results.push(rel);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < lines.length; i++) {
|
|
85
|
+
const line = lines[i];
|
|
86
|
+
if (!rx.test(line))
|
|
87
|
+
continue;
|
|
88
|
+
results.push(`${rel}:${i + 1}:${line.length > 240 ? line.slice(0, 240) + "…" : line}`);
|
|
89
|
+
if (context > 0) {
|
|
90
|
+
for (let c = 1; c <= context; c++) {
|
|
91
|
+
const idx = i + c;
|
|
92
|
+
if (idx < lines.length && results.length < MAX_RESULTS) {
|
|
93
|
+
results.push(`${rel}:${idx + 1}:${lines[idx]}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
walk(root, 0);
|
|
101
|
+
return results.length ? results.join("\n") : `No matches for ${pattern} in ${relative(process.cwd(), root) || root}`;
|
|
102
|
+
}
|
|
103
|
+
export const codeSearchTool = {
|
|
104
|
+
name: "code_search",
|
|
105
|
+
description: "Search file contents with a regular expression (ripgrep if installed, a built-in fallback otherwise). Returns up to 200 matches with line numbers. Flags: -i case-insensitive, -l files only, -w whole word, -C n context lines.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
pattern: { type: "string", description: "Regular expression to search for." },
|
|
110
|
+
cwd: { type: "string", description: "Directory to search. Defaults to the working directory." },
|
|
111
|
+
flags: { type: "string", description: "Optional flags: -i, -l, -w, -C n." },
|
|
112
|
+
},
|
|
113
|
+
required: ["pattern"],
|
|
114
|
+
},
|
|
115
|
+
async run(args, ctx) {
|
|
116
|
+
const pattern = String(args.pattern ?? "");
|
|
117
|
+
if (!pattern)
|
|
118
|
+
return "pattern is required.";
|
|
119
|
+
const cwd = args.cwd ? resolveProjectPath(String(args.cwd), ctx.cwd) : ctx.cwd;
|
|
120
|
+
const flags = String(args.flags ?? "");
|
|
121
|
+
if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory())
|
|
122
|
+
return `Not a directory: ${cwd}`;
|
|
123
|
+
// Prefer ripgrep; fall back to the JS implementation.
|
|
124
|
+
const rg = await exec("rg", ["--version"], 3000);
|
|
125
|
+
if (rg.code === 0) {
|
|
126
|
+
const rgArgs = [
|
|
127
|
+
"--line-number",
|
|
128
|
+
"--no-heading",
|
|
129
|
+
"--color",
|
|
130
|
+
"never",
|
|
131
|
+
"-m",
|
|
132
|
+
String(MAX_RESULTS),
|
|
133
|
+
"--max-columns",
|
|
134
|
+
"240",
|
|
135
|
+
...(/-i/.test(flags) ? ["-i"] : []),
|
|
136
|
+
...(/-l/.test(flags) ? ["-l"] : []),
|
|
137
|
+
...(/-w/.test(flags) ? ["-w"] : []),
|
|
138
|
+
...(/-C\s*(\d+)/.test(flags) ? ["-C", /-C\s*(\d+)/.exec(flags)[1]] : []),
|
|
139
|
+
pattern,
|
|
140
|
+
cwd,
|
|
141
|
+
];
|
|
142
|
+
const res = await exec("rg", rgArgs, 15000);
|
|
143
|
+
if (res.code === 0 || res.code === 1) {
|
|
144
|
+
return res.stdout.trim() || `No matches for ${pattern}`;
|
|
145
|
+
}
|
|
146
|
+
if (res.code === 2) {
|
|
147
|
+
return `rg error: ${res.stderr.trim().slice(0, 300) || "invalid regex or path"}`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return jsSearch(cwd, pattern, flags);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
/** Exported for tests. */
|
|
154
|
+
export { jsSearch, parseFlags };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { resolveProjectPath } from "../project.js";
|
|
3
|
+
const MAX_OUTPUT_CHARS = 30_000;
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
/**
|
|
6
|
+
* Commands that a coding agent should never run on the user's machine without
|
|
7
|
+
* explicit human approval. CodeShark blocks these by default; set
|
|
8
|
+
* CODESHARK_ALLOW_DANGEROUS=1 to disable the blocklist.
|
|
9
|
+
*/
|
|
10
|
+
const DANGEROUS_PATTERNS = [
|
|
11
|
+
{ re: /\brm\s+-(?:[a-z]*r[a-z]*f|f[a-z]*r)\b/i, why: "recursive force delete (rm -rf)" },
|
|
12
|
+
{ re: /\bgit\s+push\b/, why: "git push (changes the remote)" },
|
|
13
|
+
{ re: /\bgit\s+(?:reset|rebase|clean)\b/, why: "destructive git operation" },
|
|
14
|
+
{ re: /\bsudo\b/, why: "sudo / privilege escalation" },
|
|
15
|
+
{ re: /\bdd\s+if=/, why: "dd can overwrite disks" },
|
|
16
|
+
{ re: /\bmkfs(?:\.\w+)?\b/, why: "filesystem formatting" },
|
|
17
|
+
{ re: /\b>:?\s*\/dev\/sd/, why: "raw disk writes" },
|
|
18
|
+
{ re: /\b(?:curl|wget)\b.*\|\s*(?:ba)?sh\b/i, why: "downloading and executing a script from the internet" },
|
|
19
|
+
{ re: /\bchmod\s+-R\b/, why: "recursive permission change" },
|
|
20
|
+
{ re: /\bkill\s+-9\b/, why: "force-killing processes" },
|
|
21
|
+
];
|
|
22
|
+
export function isDangerousCommand(command) {
|
|
23
|
+
const c = command.trim();
|
|
24
|
+
for (const { re, why } of DANGEROUS_PATTERNS) {
|
|
25
|
+
if (re.test(c))
|
|
26
|
+
return why;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
let shellCommand = null;
|
|
31
|
+
function pickShell() {
|
|
32
|
+
if (shellCommand)
|
|
33
|
+
return shellCommand;
|
|
34
|
+
if (process.platform === "win32") {
|
|
35
|
+
// Prefer Git Bash; fall back to cmd.exe.
|
|
36
|
+
const probe = spawnSync("bash", ["--version"], { stdio: "ignore" });
|
|
37
|
+
if (probe.status === 0) {
|
|
38
|
+
shellCommand = { cmd: "bash", args: (c) => ["-lc", c] };
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
shellCommand = { cmd: "cmd.exe", args: (c) => ["/d", "/s", "/c", c] };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
shellCommand = { cmd: "bash", args: (c) => ["-lc", c] };
|
|
46
|
+
}
|
|
47
|
+
return shellCommand;
|
|
48
|
+
}
|
|
49
|
+
function truncate(s) {
|
|
50
|
+
if (s.length <= MAX_OUTPUT_CHARS)
|
|
51
|
+
return s;
|
|
52
|
+
return `${s.slice(0, MAX_OUTPUT_CHARS)}\n… (truncated ${s.length - MAX_OUTPUT_CHARS} chars)`;
|
|
53
|
+
}
|
|
54
|
+
export const runCommandTool = {
|
|
55
|
+
name: "run_command",
|
|
56
|
+
description: "Run a shell command (bash on macOS/Linux/Git-Bash, cmd.exe as fallback on Windows) and return its output. The command runs in the working directory with a default 30s timeout (override with timeoutMs). Output is capped at ~30k chars. Destructive commands (rm -rf, git push, sudo, …) are blocked by default.",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
command: { type: "string", description: "The shell command to run." },
|
|
61
|
+
cwd: { type: "string", description: "Working directory for the command. Defaults to the working directory." },
|
|
62
|
+
timeoutMs: { type: "integer", description: "Timeout in milliseconds. Default 30000." },
|
|
63
|
+
},
|
|
64
|
+
required: ["command"],
|
|
65
|
+
},
|
|
66
|
+
async run(args, ctx) {
|
|
67
|
+
const command = String(args.command ?? "").trim();
|
|
68
|
+
if (!command)
|
|
69
|
+
return "No command provided.";
|
|
70
|
+
const cwd = args.cwd ? resolveProjectPath(String(args.cwd), ctx.cwd) : ctx.cwd;
|
|
71
|
+
const timeoutMs = args.timeoutMs ? Math.max(1000, Number(args.timeoutMs)) : DEFAULT_TIMEOUT_MS;
|
|
72
|
+
const danger = isDangerousCommand(command);
|
|
73
|
+
if (danger && !process.env.CODESHARK_ALLOW_DANGEROUS) {
|
|
74
|
+
throw new Error(`Blocked: "${command}" matches the danger pattern "${danger}". CodeShark refuses destructive commands by default (set CODESHARK_ALLOW_DANGEROUS=1 to override).`);
|
|
75
|
+
}
|
|
76
|
+
const shell = pickShell();
|
|
77
|
+
return new Promise((resolvePromise) => {
|
|
78
|
+
const child = spawn(shell.cmd, shell.args(command), { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
79
|
+
let stdout = "";
|
|
80
|
+
let stderr = "";
|
|
81
|
+
let timedOut = false;
|
|
82
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
83
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
timedOut = true;
|
|
86
|
+
child.kill("SIGKILL");
|
|
87
|
+
}, timeoutMs);
|
|
88
|
+
child.on("close", (code) => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
const parts = [`$ ${command}`, `exit code: ${timedOut ? `timed out after ${timeoutMs}ms` : code}`];
|
|
91
|
+
if (stdout.trim())
|
|
92
|
+
parts.push(`stdout:\n${truncate(stdout).trimEnd()}`);
|
|
93
|
+
if (stderr.trim())
|
|
94
|
+
parts.push(`stderr:\n${truncate(stderr).trimEnd()}`);
|
|
95
|
+
resolvePromise(parts.join("\n"));
|
|
96
|
+
});
|
|
97
|
+
child.on("error", (e) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
resolvePromise(`Failed to spawn shell: ${e.message}`);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
/** Exported for tests. */
|
|
105
|
+
export { DANGEROUS_PATTERNS };
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codeshark-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "An open-source terminal coding agent with a pixel-shark mascot. Five frontier models, zero setup, no API keys required.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codeshark": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"TERMS.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/codeshark/codeshark.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/codeshark/codeshark",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/codeshark/codeshark/issues"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.17"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"test": "npm run build && node --test \"test/**/*.test.ts\"",
|
|
34
|
+
"start": "node dist/index.js",
|
|
35
|
+
"banner": "node dist/index.js banner",
|
|
36
|
+
"worker:build": "tsc -p worker/tsconfig.json",
|
|
37
|
+
"prepublishOnly": "npm run build"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"cli",
|
|
41
|
+
"ai",
|
|
42
|
+
"agent",
|
|
43
|
+
"coding-agent",
|
|
44
|
+
"unorouter",
|
|
45
|
+
"terminal",
|
|
46
|
+
"llm"
|
|
47
|
+
],
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"author": "CodeShark contributors",
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.10.2",
|
|
52
|
+
"typescript": "^5.7.2"
|
|
53
|
+
}
|
|
54
|
+
}
|