@ayoxx/kundex 0.1.7 → 0.1.9

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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # Kundex CLI & SDK
2
2
 
3
3
  The Kundex terminal AI coding agent — usable as a global CLI (`kundex`) or as
4
- an SDK (`import { ... } from "kundex"`).
4
+ an SDK (`import { ... } from "@ayoxx/kundex"`).
5
5
 
6
6
  ## Install
7
7
 
8
8
  ```bash
9
- npm install -g kundex
9
+ npm install -g @ayoxx/kundex
10
10
  ```
11
11
 
12
12
  ## Usage
@@ -20,15 +20,15 @@ kundex # start an interactive agent session
20
20
 
21
21
  This export ships a GitHub Actions workflow at
22
22
  `.github/workflows/publish-cli.yml` that publishes to npm whenever you push a
23
- git tag matching `cli-v*` (e.g. `v0.2.0`):
23
+ git tag matching `cli-v*` (e.g. `cli-v0.2.0`):
24
24
 
25
25
  1. Push this directory to a GitHub repo.
26
26
  2. In the repo's Settings > Secrets and variables > Actions, add an
27
27
  `NPM_TOKEN` secret (an npm automation token with publish rights).
28
28
  3. Bump `version` in `package.json`, commit, then:
29
29
  ```bash
30
- git tag v0.2.0
31
- git push origin v0.2.0
30
+ git tag cli-v0.2.0
31
+ git push origin cli-v0.2.0
32
32
  ```
33
33
  4. The workflow installs dependencies, type-checks, and runs `npm publish`.
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayoxx/kundex",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Kundex — a terminal-based AI coding agent CLI and SDK, powered by Groq.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/repl.ts CHANGED
@@ -5,12 +5,177 @@ import { Kundex, type AgentTurn } from "./sdk";
5
5
  import { executeToolCall } from "./tools";
6
6
  import type { KundexConfig } from "./config";
7
7
 
8
- const HELP = `Commands:
9
- /help Show this help
10
- /model Show the model this session is using
11
- /clear Start a new agent session
12
- /exit Quit
13
- Anything else is sent to the agent as a message.`;
8
+ // ─── ANSI colour helpers ──────────────────────────────────────────────────────
9
+ const c = {
10
+ reset: "\x1b[0m",
11
+ bold: "\x1b[1m",
12
+ dim: "\x1b[2m",
13
+ italic: "\x1b[3m",
14
+ cyan: "\x1b[36m",
15
+ green: "\x1b[32m",
16
+ yellow: "\x1b[33m",
17
+ red: "\x1b[31m",
18
+ blue: "\x1b[34m",
19
+ magenta: "\x1b[35m",
20
+ white: "\x1b[37m",
21
+ // Syntax-highlight colours (256-colour palette)
22
+ kw: "\x1b[38;5;204m", // keywords – salmon/red
23
+ str: "\x1b[38;5;150m", // strings – light green
24
+ num: "\x1b[38;5;141m", // numbers – light purple
25
+ comment: "\x1b[38;5;244m", // comments – grey
26
+ fn: "\x1b[38;5;117m", // functions – light blue
27
+ type_: "\x1b[38;5;215m", // types/decorators – orange
28
+ punct: "\x1b[38;5;250m", // punctuation – light grey
29
+ };
30
+
31
+ // ─── Per-language syntax highlighter (ANSI, no deps) ─────────────────────────
32
+ interface LangRule {
33
+ pattern: RegExp;
34
+ color: string;
35
+ }
36
+
37
+ const KEYWORDS: Record<string, string[]> = {
38
+ python: ["def","class","return","import","from","if","elif","else","for","while",
39
+ "try","except","with","as","in","not","and","or","is","None","True","False","pass","raise","yield","async","await","lambda"],
40
+ javascript: ["const","let","var","function","return","if","else","for","while",
41
+ "class","extends","import","export","from","default","new","this",
42
+ "try","catch","finally","throw","async","await","typeof","instanceof","of","in","null","undefined","true","false"],
43
+ typescript: ["const","let","var","function","return","if","else","for","while",
44
+ "class","extends","implements","interface","type","import","export",
45
+ "from","default","new","this","try","catch","finally","throw","async",
46
+ "await","typeof","instanceof","of","in","null","undefined","true","false",
47
+ "string","number","boolean","any","void","never","unknown","enum","as","namespace"],
48
+ bash: ["if","then","else","elif","fi","for","while","do","done","case","esac",
49
+ "function","return","echo","exit","local","export","source","cd","ls","grep","awk","sed"],
50
+ go: ["func","package","import","var","const","type","struct","interface","return",
51
+ "if","else","for","range","switch","case","default","go","chan","defer","select","nil","true","false","map","make","new"],
52
+ rust: ["fn","let","mut","pub","use","mod","struct","enum","impl","trait","return",
53
+ "if","else","for","while","loop","match","Some","None","Ok","Err","true","false","self","Self","super","as","in","where","async","await"],
54
+ };
55
+
56
+ function buildRules(lang: string): LangRule[] {
57
+ const kws = KEYWORDS[lang] || KEYWORDS["javascript"];
58
+ return [
59
+ // Comments
60
+ { pattern: /(#.*$|\/\/.*$|\/\*[\s\S]*?\*\/|"""[\s\S]*?"""|'''[\s\S]*?''')/gm, color: c.comment },
61
+ // Strings
62
+ { pattern: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, color: c.str },
63
+ // Numbers
64
+ { pattern: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/g, color: c.num },
65
+ // Keywords (built from per-language list)
66
+ { pattern: new RegExp(`\\b(${kws.join("|")})\\b`, "g"), color: c.kw },
67
+ // Function calls
68
+ { pattern: /\b([a-zA-Z_]\w*)\s*(?=\()/g, color: c.fn },
69
+ // Type annotations (CamelCase identifiers)
70
+ { pattern: /\b([A-Z][a-zA-Z0-9_]*)\b/g, color: c.type_ },
71
+ ];
72
+ }
73
+
74
+ /** Apply regex-based ANSI syntax highlighting to a code string. */
75
+ function syntaxHighlight(code: string, lang: string): string {
76
+ const l = lang.toLowerCase().replace(/^(tsx?|jsx?)$/, lang.includes("ts") || lang.includes("tsx") ? "typescript" : "javascript");
77
+ const normalised = ["python","py"].includes(l) ? "python"
78
+ : ["sh","shell","zsh"].includes(l) ? "bash"
79
+ : ["rs"].includes(l) ? "rust"
80
+ : l;
81
+
82
+ const rules = buildRules(normalised in KEYWORDS ? normalised : "javascript");
83
+
84
+ // We work character-by-character with a simple placeholder approach:
85
+ // collect all matches with their positions, sort them, then reconstruct.
86
+ type Span = { start: number; end: number; color: string };
87
+ const spans: Span[] = [];
88
+
89
+ for (const rule of rules) {
90
+ rule.pattern.lastIndex = 0;
91
+ let m: RegExpExecArray | null;
92
+ while ((m = rule.pattern.exec(code)) !== null) {
93
+ spans.push({ start: m.index, end: m.index + m[0].length, color: rule.color });
94
+ if (rule.pattern.lastIndex === m.index) rule.pattern.lastIndex++;
95
+ }
96
+ }
97
+
98
+ if (spans.length === 0) return code;
99
+
100
+ // Sort by start; discard overlapping spans (first wins)
101
+ spans.sort((a, b) => a.start - b.start);
102
+ const merged: Span[] = [];
103
+ let cursor = 0;
104
+ for (const span of spans) {
105
+ if (span.start < cursor) continue;
106
+ merged.push(span);
107
+ cursor = span.end;
108
+ }
109
+
110
+ let out = "";
111
+ cursor = 0;
112
+ for (const span of merged) {
113
+ out += code.slice(cursor, span.start);
114
+ out += span.color + code.slice(span.start, span.end) + c.reset;
115
+ cursor = span.end;
116
+ }
117
+ out += code.slice(cursor);
118
+ return out;
119
+ }
120
+
121
+ // ─── Code-block printer ───────────────────────────────────────────────────────
122
+ function printCodeBlock(lang: string, code: string) {
123
+ const border = "─".repeat(52);
124
+ const label = lang || "code";
125
+ const labelPad = "─".repeat(Math.max(0, 52 - label.length - 3));
126
+
127
+ process.stdout.write(`${c.dim}┌─ ${c.reset}${c.bold}${label}${c.reset}${c.dim} ${labelPad}${c.reset}\n`);
128
+
129
+ const highlighted = syntaxHighlight(code, lang);
130
+ highlighted.split("\n").forEach(line => {
131
+ process.stdout.write(`${c.dim}│${c.reset} ${line}\n`);
132
+ });
133
+
134
+ process.stdout.write(`${c.dim}└${border}${c.reset}\n`);
135
+ }
136
+
137
+ // ─── Reply printer (handles mixed text + code blocks) ────────────────────────
138
+ function printReply(text: string) {
139
+ process.stdout.write("\n");
140
+ const codeBlockRe = /```(\w*)\n?([\s\S]*?)```/g;
141
+ let last = 0;
142
+ let match: RegExpExecArray | null;
143
+
144
+ while ((match = codeBlockRe.exec(text)) !== null) {
145
+ const before = text.slice(last, match.index);
146
+ if (before.trim()) {
147
+ // Wrap plain text at ~100 chars
148
+ before.split("\n").forEach(line => {
149
+ process.stdout.write(` ${line}\n`);
150
+ });
151
+ }
152
+ printCodeBlock(match[1], match[2].trimEnd());
153
+ last = match.index + match[0].length;
154
+ }
155
+
156
+ const tail = text.slice(last);
157
+ if (tail.trim()) {
158
+ tail.split("\n").forEach(line => {
159
+ process.stdout.write(` ${line}\n`);
160
+ });
161
+ }
162
+
163
+ process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
164
+ }
165
+
166
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
167
+ const HELP = `
168
+ ${c.bold}Commands:${c.reset}
169
+ /help Show this help
170
+ /model Show the model for this session
171
+ /clear Start a fresh agent session
172
+ /exit Quit kundex
173
+
174
+ ${c.bold}Tips:${c.reset}
175
+ • The agent can read, edit and search files, run commands, and use git.
176
+ • You will be asked to confirm any writes or shell commands before they run.
177
+ • Code blocks are syntax-highlighted in the terminal.
178
+ `;
14
179
 
15
180
  function safeGitStatus(cwd: string): string | undefined {
16
181
  try {
@@ -20,6 +185,17 @@ function safeGitStatus(cwd: string): string | undefined {
20
185
  }
21
186
  }
22
187
 
188
+ function safeFileTree(cwd: string): string | undefined {
189
+ try {
190
+ return execSync(
191
+ `find . -maxdepth 3 ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' ! -path '*/build/*' -print`,
192
+ { cwd, timeout: 5000 }
193
+ ).toString().trim().split("\n").slice(0, 200).join("\n");
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
23
199
  async function handleTurn(
24
200
  kundex: Kundex,
25
201
  cwd: string,
@@ -30,10 +206,10 @@ async function handleTurn(
30
206
 
31
207
  while (!current.done && current.toolCalls && current.toolCalls.length > 0) {
32
208
  for (const call of current.toolCalls) {
33
- console.log(`\n\u2192 ${call.name}(${call.arguments})`);
34
209
  const { result, isError } = await executeToolCall(cwd, call);
35
- console.log(isError ? ` error: ${result}` : ` ${result.slice(0, 2000)}`);
36
-
210
+ if (isError) {
211
+ process.stdout.write(`${c.red} ✗ ${call.name}: ${result}${c.reset}\n`);
212
+ }
37
213
  current = await kundex.agent.submitToolResult({
38
214
  sessionId,
39
215
  toolCallId: call.id,
@@ -44,51 +220,74 @@ async function handleTurn(
44
220
  }
45
221
 
46
222
  if (current.reply) {
47
- console.log(`\nkundex: ${current.reply}\n`);
223
+ printReply(current.reply);
48
224
  }
49
225
  }
50
226
 
227
+ function printBanner(cwd: string, model: string) {
228
+ const shortCwd = cwd.replace(process.env.HOME ?? "", "~");
229
+ console.log(`
230
+ ${c.bold}${c.cyan} ██╗ ██╗██╗ ██╗███╗ ██╗██████╗ ███████╗██╗ ██╗${c.reset}
231
+ ${c.bold}${c.cyan} ██║ ██╔╝██║ ██║████╗ ██║██╔══██╗██╔════╝╚██╗██╔╝${c.reset}
232
+ ${c.bold}${c.cyan} █████╔╝ ██║ ██║██╔██╗ ██║██║ ██║█████╗ ╚███╔╝ ${c.reset}
233
+ ${c.bold}${c.cyan} ██╔═██╗ ██║ ██║██║╚██╗██║██║ ██║██╔══╝ ██╔██╗ ${c.reset}
234
+ ${c.bold}${c.cyan} ██║ ██╗╚██████╔╝██║ ╚████║██████╔╝███████╗██╔╝ ██╗${c.reset}
235
+ ${c.bold}${c.cyan} ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚══════╝╚═╝ ╚═╝${c.reset}
236
+
237
+ ${c.dim}AI coding agent — terminal edition${c.reset}
238
+ ${c.dim}cwd:${c.reset} ${c.bold}${shortCwd}${c.reset}
239
+ ${c.dim}model:${c.reset} ${c.bold}${model}${c.reset}
240
+
241
+ Type ${c.cyan}/help${c.reset} for commands.
242
+ `);
243
+ }
244
+
245
+ // ─── REPL ─────────────────────────────────────────────────────────────────────
51
246
  export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: string): Promise<void> {
52
247
  const cwd = process.cwd();
53
248
  const kundex = new Kundex({ apiKey, baseUrl });
54
249
  const model = config.defaultModel ?? "openai/gpt-oss-120b";
55
250
 
56
- console.log(`Kundex agent — cwd: ${cwd}, model: ${model}`);
57
- console.log("Type /help for commands.\n");
251
+ printBanner(cwd, model);
58
252
 
59
253
  let session = await kundex.agent.createSession({ cwd, model });
60
-
61
254
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
62
255
 
63
256
  try {
64
257
  for (;;) {
65
- const input = (await rl.question("> ")).trim();
258
+ const raw = await rl.question(`${c.bold}${c.cyan}kundex${c.reset} ${c.dim}▸${c.reset} `);
259
+ const input = raw.trim();
66
260
  if (!input) continue;
67
261
 
68
- if (input === "/exit") break;
69
- if (input === "/help") {
70
- console.log(HELP);
71
- continue;
262
+ if (input === "/exit") {
263
+ console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
264
+ break;
72
265
  }
266
+ if (input === "/help") { console.log(HELP); continue; }
73
267
  if (input === "/model") {
74
- console.log(`Model: ${session.model}`);
268
+ console.log(` ${c.dim}model:${c.reset} ${c.bold}${session.model}${c.reset}`);
75
269
  continue;
76
270
  }
77
271
  if (input === "/clear") {
78
272
  session = await kundex.agent.createSession({ cwd, model });
79
- console.log("Started a new session.");
273
+ console.log(`\n${c.green} ✓ Started a new session.${c.reset}\n`);
80
274
  continue;
81
275
  }
82
276
 
83
277
  try {
278
+ process.stdout.write(`\n${c.dim} thinking...${c.reset}\n`);
84
279
  const turn = await kundex.agent.sendMessage({
85
280
  sessionId: session.id,
86
281
  message: input,
87
- context: { gitStatus: safeGitStatus(cwd), openFiles: [] },
282
+ context: {
283
+ gitStatus: safeGitStatus(cwd),
284
+ fileTree: safeFileTree(cwd),
285
+ openFiles: [],
286
+ },
88
287
  });
89
288
  await handleTurn(kundex, cwd, session.id, turn);
90
289
  } catch (err) {
91
- console.error(`Error: ${(err as Error).message}`);
290
+ console.error(`\n${c.red} error: ${(err as Error).message}${c.reset}\n`);
92
291
  }
93
292
  }
94
293
  } finally {
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
  }