@ayoxx/kundex 0.1.7 → 1.0.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/src/sdk.ts CHANGED
@@ -21,6 +21,8 @@ export interface ChatUsage {
21
21
  export interface ModelInfo {
22
22
  id: string;
23
23
  name: string;
24
+ /** Which inference provider serves this model (e.g. "heavstal", "groq", "gemini", "mistral"). */
25
+ provider?: string;
24
26
  contextWindow: number;
25
27
  inputPricePerMTok: number;
26
28
  outputPricePerMTok: number;
@@ -34,6 +36,33 @@ export interface AgentTurn {
34
36
  usage: ChatUsage | null;
35
37
  }
36
38
 
39
+ export interface AgentSession {
40
+ id: number;
41
+ cwd: string;
42
+ model: string;
43
+ name: string;
44
+ description: string;
45
+ language: string;
46
+ wsStatus: string;
47
+ pinned: boolean;
48
+ lastOpenedAt: string | null;
49
+ lastModifiedAt: string | null;
50
+ createdAt: string;
51
+ updatedAt: string;
52
+ }
53
+
54
+ export interface WorkspaceFile {
55
+ id: number;
56
+ sessionId: number;
57
+ path: string;
58
+ name: string;
59
+ mimeType: string;
60
+ size: number;
61
+ content?: string;
62
+ createdAt: string;
63
+ updatedAt: string;
64
+ }
65
+
37
66
  export interface KundexOptions {
38
67
  apiKey: string;
39
68
  baseUrl: string;
@@ -49,7 +78,7 @@ export interface KundexOptions {
49
78
  */
50
79
  export class Kundex {
51
80
  private apiKey: string;
52
- private baseUrl: string;
81
+ readonly baseUrl: string;
53
82
 
54
83
  constructor(opts: KundexOptions) {
55
84
  if (!opts.apiKey) throw new Error("Kundex: apiKey is required");
@@ -76,9 +105,37 @@ export class Kundex {
76
105
  return (await res.json()) as T;
77
106
  }
78
107
 
108
+ private async requestGet<T>(pathname: string): Promise<T> {
109
+ const res = await fetch(`${this.baseUrl}${pathname}`, {
110
+ method: "GET",
111
+ headers: { authorization: `Bearer ${this.apiKey}` },
112
+ });
113
+
114
+ if (!res.ok) {
115
+ const text = await res.text().catch(() => "");
116
+ throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
117
+ }
118
+
119
+ return (await res.json()) as T;
120
+ }
121
+
122
+ private async requestDelete(pathname: string): Promise<void> {
123
+ const res = await fetch(`${this.baseUrl}${pathname}`, {
124
+ method: "DELETE",
125
+ headers: { authorization: `Bearer ${this.apiKey}` },
126
+ });
127
+
128
+ if (!res.ok && res.status !== 204) {
129
+ const text = await res.text().catch(() => "");
130
+ throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
131
+ }
132
+ }
133
+
79
134
  models = {
80
135
  list: async (): Promise<ModelInfo[]> => {
81
- const res = await fetch(`${this.baseUrl}/v1/models`);
136
+ const res = await fetch(`${this.baseUrl}/v1/models`, {
137
+ headers: { authorization: `Bearer ${this.apiKey}` },
138
+ });
82
139
  if (!res.ok) throw new Error(`Kundex API error (${res.status})`);
83
140
  return (await res.json()) as ModelInfo[];
84
141
  },
@@ -109,6 +166,7 @@ export class Kundex {
109
166
  tools?: KundexToolDefinition[];
110
167
  },
111
168
  onDelta: (text: string) => void,
169
+ signal?: AbortSignal,
112
170
  ): Promise<{ finishReason: string; usage: ChatUsage | null }> => {
113
171
  const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
114
172
  method: "POST",
@@ -117,6 +175,7 @@ export class Kundex {
117
175
  authorization: `Bearer ${this.apiKey}`,
118
176
  },
119
177
  body: JSON.stringify({ ...params, stream: true }),
178
+ signal,
120
179
  });
121
180
 
122
181
  if (!res.ok || !res.body) {
@@ -130,25 +189,32 @@ export class Kundex {
130
189
  let finishReason = "stop";
131
190
  let usage: ChatUsage | null = null;
132
191
 
133
- for (;;) {
134
- const { done, value } = await reader.read();
135
- if (done) break;
136
- buffer += decoder.decode(value, { stream: true });
137
- const lines = buffer.split("\n");
138
- buffer = lines.pop() ?? "";
139
-
140
- for (const line of lines) {
141
- const trimmed = line.trim();
142
- if (!trimmed.startsWith("data:")) continue;
143
- const payload = trimmed.slice(5).trim();
144
- if (!payload) continue;
145
- const json = JSON.parse(payload);
146
- if (json.delta) onDelta(json.delta);
147
- if (json.done) {
148
- finishReason = json.finishReason ?? finishReason;
149
- usage = json.usage ?? usage;
192
+ try {
193
+ for (;;) {
194
+ const { done, value } = await reader.read();
195
+ if (done) break;
196
+ buffer += decoder.decode(value, { stream: true });
197
+ const lines = buffer.split("\n");
198
+ buffer = lines.pop() ?? "";
199
+
200
+ for (const line of lines) {
201
+ const trimmed = line.trim();
202
+ if (trimmed.startsWith(":")) continue; // heartbeat comment
203
+ if (!trimmed.startsWith("data:")) continue;
204
+ const payload = trimmed.slice(5).trim();
205
+ if (!payload || payload === "[DONE]") continue;
206
+ try {
207
+ const json = JSON.parse(payload);
208
+ if (json.delta) onDelta(json.delta);
209
+ if (json.done) {
210
+ finishReason = json.finishReason ?? finishReason;
211
+ usage = json.usage ?? usage;
212
+ }
213
+ } catch { /* ignore bad JSON */ }
150
214
  }
151
215
  }
216
+ } finally {
217
+ try { reader.cancel(); } catch { /* ignore */ }
152
218
  }
153
219
 
154
220
  return { finishReason, usage };
@@ -156,23 +222,242 @@ export class Kundex {
156
222
  };
157
223
 
158
224
  agent = {
159
- createSession: (params: { cwd: string; model?: string }) =>
160
- this.request<{ id: number; cwd: string; model: string; createdAt: string }>(
161
- "/v1/agent/session",
162
- params,
225
+ createSession: (params: {
226
+ cwd: string;
227
+ model?: string;
228
+ name?: string;
229
+ description?: string;
230
+ language?: string;
231
+ }) =>
232
+ this.request<AgentSession>("/v1/agent/session", params),
233
+
234
+ listSessions: () =>
235
+ this.requestGet<AgentSession[]>("/v1/agent/sessions"),
236
+
237
+ getSession: (id: number) =>
238
+ this.requestGet<AgentSession>(`/v1/agent/sessions/${id}`),
239
+
240
+ deleteSession: (id: number) =>
241
+ this.requestDelete(`/v1/agent/sessions/${id}`),
242
+
243
+ getMessages: (id: number) =>
244
+ this.requestGet<{ sessionId: number; messages: unknown[] }>(`/v1/agent/sessions/${id}/messages`),
245
+
246
+ /**
247
+ * Send a message to the agent and stream the response.
248
+ *
249
+ * The server sends SSE events — this method handles the typed format
250
+ * emitted by the backend:
251
+ * { type: "delta", delta: string }
252
+ * { type: "done", reply?, toolCalls?, done, usage? }
253
+ * { type: "error", error: string }
254
+ *
255
+ * Invokes onDelta as text tokens arrive so the caller can print them
256
+ * incrementally.
257
+ *
258
+ * Returns the completed AgentTurn including any tool calls.
259
+ */
260
+ streamMessage: async (
261
+ params: {
262
+ sessionId: number;
263
+ message: string;
264
+ context?: { fileTree?: string; gitStatus?: string; openFiles?: string[] };
265
+ },
266
+ onDelta: (text: string) => void,
267
+ signal?: AbortSignal,
268
+ ): Promise<AgentTurn> => {
269
+ const endpoint = "/v1/agent/message";
270
+ const res = await fetch(`${this.baseUrl}${endpoint}`, {
271
+ method: "POST",
272
+ headers: {
273
+ "content-type": "application/json",
274
+ authorization: `Bearer ${this.apiKey}`,
275
+ },
276
+ body: JSON.stringify(params),
277
+ signal,
278
+ });
279
+
280
+ if (!res.ok) {
281
+ const text = await res.text().catch(() => "");
282
+ throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
283
+ }
284
+
285
+ const reader = res.body?.getReader();
286
+ if (!reader) throw new Error("No response body from server");
287
+
288
+ const decoder = new TextDecoder();
289
+ let buffer = "";
290
+ let reply = "";
291
+ let toolCalls: AgentTurn["toolCalls"] = null;
292
+ let usage: AgentTurn["usage"] = null;
293
+
294
+ try {
295
+ for (;;) {
296
+ const { done, value } = await reader.read();
297
+ if (done) break;
298
+ buffer += decoder.decode(value, { stream: true });
299
+ const lines = buffer.split("\n");
300
+ buffer = lines.pop() ?? "";
301
+
302
+ for (const line of lines) {
303
+ const trimmed = line.trim();
304
+ if (trimmed.startsWith(":")) continue; // heartbeat comment
305
+ if (!trimmed.startsWith("data:")) continue;
306
+ const payload = trimmed.slice(5).trim();
307
+ if (!payload || payload === "[DONE]") continue;
308
+
309
+ try {
310
+ const ev = JSON.parse(payload);
311
+
312
+ if (ev.type === "delta" && typeof ev.delta === "string") {
313
+ reply += ev.delta;
314
+ onDelta(ev.delta);
315
+ continue;
316
+ }
317
+ if (ev.type === "done") {
318
+ return {
319
+ sessionId: params.sessionId,
320
+ reply: ev.reply !== undefined ? (ev.reply ?? reply) : reply,
321
+ toolCalls: ev.toolCalls ?? ev.tool_calls ?? null,
322
+ done: ev.done ?? true,
323
+ usage: ev.usage ?? usage,
324
+ };
325
+ }
326
+ if (ev.type === "error") {
327
+ throw new Error(ev.error ?? "Agent error");
328
+ }
329
+
330
+ // Legacy format fallback
331
+ if (typeof ev.delta === "string") {
332
+ reply += ev.delta;
333
+ onDelta(ev.delta);
334
+ }
335
+ if (ev.done === true) {
336
+ return {
337
+ sessionId: params.sessionId,
338
+ reply: ev.reply ?? reply,
339
+ toolCalls: ev.tool_calls ?? ev.toolCalls ?? null,
340
+ done: true,
341
+ usage: ev.usage ?? usage,
342
+ };
343
+ }
344
+ } catch (parseErr) {
345
+ if (parseErr instanceof SyntaxError) continue;
346
+ throw parseErr;
347
+ }
348
+ }
349
+ }
350
+ } finally {
351
+ try { reader.cancel(); } catch { /* ignore */ }
352
+ }
353
+
354
+ // Stream ended without a done event; return what we have
355
+ return { sessionId: params.sessionId, reply, toolCalls, done: true, usage };
356
+ },
357
+
358
+ submitToolResult: async (
359
+ params: {
360
+ sessionId: number;
361
+ toolCallId: string;
362
+ result: string;
363
+ isError?: boolean;
364
+ },
365
+ onDelta: (text: string) => void,
366
+ signal?: AbortSignal,
367
+ ): Promise<AgentTurn> => {
368
+ const res = await fetch(`${this.baseUrl}/v1/agent/tool-result`, {
369
+ method: "POST",
370
+ headers: {
371
+ "content-type": "application/json",
372
+ authorization: `Bearer ${this.apiKey}`,
373
+ },
374
+ body: JSON.stringify(params),
375
+ signal,
376
+ });
377
+
378
+ if (!res.ok) {
379
+ const text = await res.text().catch(() => "");
380
+ throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
381
+ }
382
+
383
+ const reader = res.body?.getReader();
384
+ if (!reader) throw new Error("No response body from server");
385
+
386
+ const decoder = new TextDecoder();
387
+ let buffer = "";
388
+ let reply = "";
389
+ let toolCalls: AgentTurn["toolCalls"] = null;
390
+ let usage: AgentTurn["usage"] = null;
391
+
392
+ try {
393
+ for (;;) {
394
+ const { done, value } = await reader.read();
395
+ if (done) break;
396
+ buffer += decoder.decode(value, { stream: true });
397
+ const lines = buffer.split("\n");
398
+ buffer = lines.pop() ?? "";
399
+
400
+ for (const line of lines) {
401
+ const trimmed = line.trim();
402
+ if (trimmed.startsWith(":")) continue;
403
+ if (!trimmed.startsWith("data:")) continue;
404
+ const payload = trimmed.slice(5).trim();
405
+ if (!payload || payload === "[DONE]") continue;
406
+
407
+ try {
408
+ const ev = JSON.parse(payload);
409
+ if (ev.type === "delta" && typeof ev.delta === "string") {
410
+ reply += ev.delta;
411
+ onDelta(ev.delta);
412
+ continue;
413
+ }
414
+ if (ev.type === "done") {
415
+ return {
416
+ sessionId: params.sessionId,
417
+ reply: ev.reply !== undefined ? (ev.reply ?? reply) : reply,
418
+ toolCalls: ev.toolCalls ?? ev.tool_calls ?? null,
419
+ done: ev.done ?? true,
420
+ usage: ev.usage ?? usage,
421
+ };
422
+ }
423
+ if (ev.type === "error") throw new Error(ev.error ?? "Agent error");
424
+ if (typeof ev.delta === "string") { reply += ev.delta; onDelta(ev.delta); }
425
+ if (ev.done === true) {
426
+ return {
427
+ sessionId: params.sessionId,
428
+ reply: ev.reply ?? reply,
429
+ toolCalls: ev.tool_calls ?? ev.toolCalls ?? null,
430
+ done: true,
431
+ usage: ev.usage ?? usage,
432
+ };
433
+ }
434
+ } catch (parseErr) {
435
+ if (parseErr instanceof SyntaxError) continue;
436
+ throw parseErr;
437
+ }
438
+ }
439
+ }
440
+ } finally {
441
+ try { reader.cancel(); } catch { /* ignore */ }
442
+ }
443
+
444
+ return { sessionId: params.sessionId, reply, toolCalls, done: true, usage };
445
+ },
446
+ };
447
+
448
+ files = {
449
+ list: (wsId: number) =>
450
+ this.requestGet<{ files: WorkspaceFile[] }>(`/v1/workspace/${wsId}/files`),
451
+
452
+ get: (wsId: number, path: string) =>
453
+ this.requestGet<WorkspaceFile>(
454
+ `/v1/workspace/${wsId}/files/content?path=${encodeURIComponent(path)}`,
163
455
  ),
164
456
 
165
- sendMessage: (params: {
166
- sessionId: number;
167
- message: string;
168
- context?: { fileTree?: string; gitStatus?: string; openFiles?: string[] };
169
- }) => this.request<AgentTurn>("/v1/agent/message", params),
170
-
171
- submitToolResult: (params: {
172
- sessionId: number;
173
- toolCallId: string;
174
- result: string;
175
- isError?: boolean;
176
- }) => this.request<AgentTurn>("/v1/agent/tool-result", params),
457
+ upsert: (wsId: number, params: { path: string; name: string; content: string; mimeType?: string }) =>
458
+ this.request<{ file: WorkspaceFile }>(`/v1/workspace/${wsId}/files`, params),
459
+
460
+ delete: (wsId: number, path: string) =>
461
+ this.requestDelete(`/v1/workspace/${wsId}/files?path=${encodeURIComponent(path)}`),
177
462
  };
178
463
  }
package/src/tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { exec } from "node:child_process";
3
+ import { exec, execFile } from "node:child_process";
4
4
  import readline from "node:readline/promises";
5
5
 
6
6
  export interface ToolCall {
@@ -14,10 +14,58 @@ export interface ToolResult {
14
14
  isError: boolean;
15
15
  }
16
16
 
17
+ const c = {
18
+ reset: "\x1b[0m",
19
+ bold: "\x1b[1m",
20
+ dim: "\x1b[2m",
21
+ cyan: "\x1b[36m",
22
+ green: "\x1b[32m",
23
+ yellow: "\x1b[33m",
24
+ red: "\x1b[31m",
25
+ blue: "\x1b[34m",
26
+ magenta: "\x1b[35m",
27
+ white: "\x1b[37m",
28
+ bgDark: "\x1b[48;5;235m",
29
+ };
30
+
31
+ function header(label: string, value: string) {
32
+ process.stdout.write(
33
+ `${c.bold}${c.cyan} ◆ ${label}${c.reset} ${c.dim}${value}${c.reset}\n`
34
+ );
35
+ }
36
+
37
+ function printDiff(oldContent: string | null, newContent: string, filePath: string) {
38
+ const oldLines = oldContent ? oldContent.split("\n") : [];
39
+ const newLines = newContent.split("\n");
40
+ const maxLines = Math.max(oldLines.length, newLines.length);
41
+ const LIMIT = 60;
42
+ const shown = Math.min(maxLines, LIMIT);
43
+
44
+ console.log(`\n${c.bold}${c.white} ┌─ ${filePath} ─────────────────────────${c.reset}`);
45
+ for (let i = 0; i < shown; i++) {
46
+ const o = oldLines[i];
47
+ const n = newLines[i];
48
+ if (o === undefined) {
49
+ process.stdout.write(`${c.green} + ${(i + 1).toString().padStart(4)} ${n ?? ""}${c.reset}\n`);
50
+ } else if (n === undefined) {
51
+ process.stdout.write(`${c.red} - ${(i + 1).toString().padStart(4)} ${o ?? ""}${c.reset}\n`);
52
+ } else if (o !== n) {
53
+ process.stdout.write(`${c.red} - ${(i + 1).toString().padStart(4)} ${o}${c.reset}\n`);
54
+ process.stdout.write(`${c.green} + ${(i + 1).toString().padStart(4)} ${n}${c.reset}\n`);
55
+ } else {
56
+ process.stdout.write(`${c.dim} ${(i + 1).toString().padStart(4)} ${o}${c.reset}\n`);
57
+ }
58
+ }
59
+ if (maxLines > LIMIT) {
60
+ console.log(`${c.dim} ... ${maxLines - LIMIT} more lines omitted${c.reset}`);
61
+ }
62
+ console.log(`${c.bold}${c.white} └──────────────────────────────────────${c.reset}\n`);
63
+ }
64
+
17
65
  async function confirm(prompt: string): Promise<boolean> {
18
66
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
19
67
  try {
20
- const answer = await rl.question(`${prompt} [y/N] `);
68
+ const answer = await rl.question(`${c.yellow}${c.bold} ? ${c.reset}${prompt} ${c.dim}[y/N]${c.reset} `);
21
69
  return answer.trim().toLowerCase().startsWith("y");
22
70
  } finally {
23
71
  rl.close();
@@ -34,40 +82,84 @@ function resolveInCwd(cwd: string, relPath: string): string {
34
82
 
35
83
  async function readFileTool(cwd: string, args: { path: string }): Promise<string> {
36
84
  const full = resolveInCwd(cwd, args.path);
85
+ header("read_file", args.path);
37
86
  return fs.readFile(full, "utf-8");
38
87
  }
39
88
 
40
89
  async function writeFileTool(cwd: string, args: { path: string; content: string }): Promise<string> {
41
- const approved = await confirm(`Write ${args.content.length} bytes to "${args.path}"?`);
42
- if (!approved) return "User declined to write this file.";
43
90
  const full = resolveInCwd(cwd, args.path);
91
+ const lines = args.content.split("\n").length;
92
+ const bytes = Buffer.byteLength(args.content, "utf-8");
93
+
94
+ console.log(`\n${c.bold}${c.magenta} ◆ write_file${c.reset} ${c.dim}${args.path}${c.reset}`);
95
+ console.log(`${c.dim} ${lines} lines · ${bytes} bytes${c.reset}`);
96
+
97
+ // Show diff vs existing file
98
+ let oldContent: string | null = null;
99
+ try {
100
+ oldContent = await fs.readFile(full, "utf-8");
101
+ console.log(`${c.dim} (modifying existing file)${c.reset}`);
102
+ } catch {
103
+ console.log(`${c.dim} (creating new file)${c.reset}`);
104
+ }
105
+
106
+ printDiff(oldContent, args.content, args.path);
107
+
108
+ const approved = await confirm(`Write ${bytes} bytes to "${args.path}"?`);
109
+ if (!approved) return "User declined to write this file.";
110
+
44
111
  await fs.mkdir(path.dirname(full), { recursive: true });
45
112
  await fs.writeFile(full, args.content, "utf-8");
46
- return `Wrote ${args.content.length} bytes to ${args.path}`;
113
+
114
+ console.log(`${c.green}${c.bold} ✓ Wrote ${args.path}${c.reset} ${c.dim}(${bytes} bytes)${c.reset}\n`);
115
+ return `Wrote ${bytes} bytes to ${args.path}`;
47
116
  }
48
117
 
49
118
  async function runCommandTool(cwd: string, args: { command: string }): Promise<string> {
50
- const approved = await confirm(`Run shell command: "${args.command}"?`);
119
+ console.log(`\n${c.bold}${c.yellow} ◆ run_command${c.reset} ${c.dim}${args.command}${c.reset}`);
120
+ const approved = await confirm(`Run: ${c.bold}${args.command}${c.reset}?`);
51
121
  if (!approved) return "User declined to run this command.";
122
+
52
123
  return new Promise((resolve) => {
53
- exec(args.command, { cwd, timeout: 60_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
54
- if (err) {
55
- resolve(`Command failed (${err.message}):\n${stdout}\n${stderr}`);
124
+ const child = exec(args.command, { cwd, timeout: 60_000, maxBuffer: 4 * 1024 * 1024 });
125
+
126
+ let stdout = "";
127
+ let stderr = "";
128
+
129
+ child.stdout?.on("data", (chunk: string) => {
130
+ process.stdout.write(`${c.dim} │ ${chunk}${c.reset}`);
131
+ stdout += chunk;
132
+ });
133
+ child.stderr?.on("data", (chunk: string) => {
134
+ process.stderr.write(`${c.red} │ ${chunk}${c.reset}`);
135
+ stderr += chunk;
136
+ });
137
+
138
+ child.on("close", (code) => {
139
+ const combined = (stdout + (stderr ? `\nstderr:\n${stderr}` : "")).trim();
140
+ if (code !== 0) {
141
+ console.log(`${c.red} ✗ exited with code ${code}${c.reset}\n`);
142
+ resolve(`Command failed (exit ${code}):\n${combined || "(no output)"}`);
56
143
  } else {
57
- resolve(stdout || stderr || "(no output)");
144
+ console.log(`${c.green} ✓ done${c.reset}\n`);
145
+ resolve(combined || "(no output)");
58
146
  }
59
147
  });
60
148
  });
61
149
  }
62
150
 
63
151
  async function gitTool(cwd: string, args: { args: string[] }): Promise<string> {
152
+ const cmd = `git ${args.args.join(" ")}`;
64
153
  const isMutating = !["status", "diff", "log", "show", "branch"].includes(args.args[0]);
154
+
155
+ header("git", cmd);
65
156
  if (isMutating) {
66
- const approved = await confirm(`Run: git ${args.args.join(" ")}?`);
157
+ const approved = await confirm(`Run: ${c.bold}${cmd}${c.reset}?`);
67
158
  if (!approved) return "User declined to run this git command.";
68
159
  }
160
+
69
161
  return new Promise((resolve) => {
70
- exec(`git ${args.args.join(" ")}`, { cwd, timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
162
+ exec(cmd, { cwd, timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
71
163
  if (err) {
72
164
  resolve(`git command failed (${err.message}):\n${stdout}\n${stderr}`);
73
165
  } else {
@@ -77,7 +169,69 @@ async function gitTool(cwd: string, args: { args: string[] }): Promise<string> {
77
169
  });
78
170
  }
79
171
 
80
- /** Executes a single tool call locally, matching the server's AGENT_TOOLS contract. */
172
+ async function listDirectoryTool(cwd: string, args: { path: string }): Promise<string> {
173
+ const full = resolveInCwd(cwd, args.path);
174
+ header("list_directory", args.path);
175
+ const entries = await fs.readdir(full, { withFileTypes: true });
176
+ const lines = entries.map(e => {
177
+ if (e.isDirectory()) return ` ${c.blue}${c.bold}${e.name}/${c.reset}`;
178
+ if (e.isSymbolicLink()) return ` ${c.cyan}${e.name}@${c.reset}`;
179
+ return ` ${e.name}`;
180
+ });
181
+ // Print inline preview
182
+ lines.forEach(l => process.stdout.write(l + "\n"));
183
+ return entries
184
+ .map(e => (e.isDirectory() ? `${e.name}/` : e.name))
185
+ .join("\n");
186
+ }
187
+
188
+ async function searchFilesTool(
189
+ cwd: string,
190
+ args: { pattern: string; glob?: string }
191
+ ): Promise<string> {
192
+ // Validate inputs: pattern must be a non-empty string; glob (if provided)
193
+ // must only contain safe filename characters to prevent shell expansion.
194
+ if (!args.pattern || typeof args.pattern !== "string") {
195
+ return "Search failed: pattern must be a non-empty string.";
196
+ }
197
+ if (args.glob !== undefined) {
198
+ if (typeof args.glob !== "string" || /[;&|`$(){}[\]<>!]/.test(args.glob)) {
199
+ return "Search failed: glob contains disallowed characters.";
200
+ }
201
+ }
202
+
203
+ header("search_files", `"${args.pattern}"${args.glob ? ` in ${args.glob}` : ""}`);
204
+
205
+ // Build the argument array passed directly to execFile — no shell involved.
206
+ const grepArgs: string[] = [
207
+ "-rn",
208
+ "--color=never",
209
+ "-m", "5",
210
+ ];
211
+ if (args.glob) {
212
+ grepArgs.push(`--include=${args.glob}`);
213
+ }
214
+ // Pattern and path as positional args (not shell-interpolated)
215
+ grepArgs.push(args.pattern, ".");
216
+
217
+ return new Promise((resolve) => {
218
+ execFile("grep", grepArgs, { cwd, timeout: 15_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
219
+ if (err && (err as NodeJS.ErrnoException & { code: number }).code === 1) {
220
+ // grep exits 1 when no matches are found — not an error
221
+ resolve("No matches found.");
222
+ return;
223
+ }
224
+ if (err) {
225
+ resolve(`Search failed: ${err.message}`);
226
+ return;
227
+ }
228
+ const lines = stdout.trim().split("\n").slice(0, 80);
229
+ lines.forEach(l => console.log(` ${c.dim}${l}${c.reset}`));
230
+ resolve(stdout.trim() || "No matches found.");
231
+ });
232
+ });
233
+ }
234
+
81
235
  export async function executeToolCall(cwd: string, call: ToolCall): Promise<ToolResult> {
82
236
  let args: any;
83
237
  try {
@@ -96,6 +250,10 @@ export async function executeToolCall(cwd: string, call: ToolCall): Promise<Tool
96
250
  return { result: await runCommandTool(cwd, args), isError: false };
97
251
  case "git":
98
252
  return { result: await gitTool(cwd, args), isError: false };
253
+ case "list_directory":
254
+ return { result: await listDirectoryTool(cwd, args), isError: false };
255
+ case "search_files":
256
+ return { result: await searchFilesTool(cwd, args), isError: false };
99
257
  default:
100
258
  return { result: `Unknown tool: ${call.name}`, isError: true };
101
259
  }