@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 +5 -5
- package/package.json +1 -1
- package/src/repl.ts +221 -22
- package/src/tools.ts +171 -13
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
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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")
|
|
69
|
-
|
|
70
|
-
|
|
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(`
|
|
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(
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
}
|