@ayoxx/kundex 0.1.9 → 2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ayoxx/kundex",
3
- "version": "0.1.9",
4
- "description": "Kundex — a terminal-based AI coding agent CLI and SDK, powered by Groq.",
3
+ "version": "2.0.0",
4
+ "description": "Kundex — a terminal-based AI coding agent CLI and SDK, powered by Mistral and Heavstal AI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "kundex": "./bin/kundex.mjs"
package/src/config.ts CHANGED
@@ -33,8 +33,24 @@ export function saveConfig(config: Partial<KundexConfig>): KundexConfig {
33
33
  return merged;
34
34
  }
35
35
 
36
- export const KUNDEX_BASE_URL = "https://kundex-api.onrender.com";
36
+ export const KUNDEX_BASE_URL = "https://api.kundex.com.ng";
37
37
 
38
- export function resolveBaseUrl(_config: KundexConfig): string | null {
39
- return KUNDEX_BASE_URL;
38
+ /**
39
+ * Resolves the base URL to use for API calls.
40
+ *
41
+ * Priority:
42
+ * 1. KUNDEX_BASE_URL environment variable (useful for local dev)
43
+ * 2. Stored baseUrl from config (~/.kundex/config.json)
44
+ * 3. Hard-coded default: https://api.kundex.com.ng
45
+ *
46
+ * Previously, this function ignored the stored baseUrl completely.
47
+ * That made `kundex config set baseUrl <url>` a no-op, which broke
48
+ * local development and self-hosted deployments.
49
+ */
50
+ export function resolveBaseUrl(config: KundexConfig): string | null {
51
+ return (
52
+ process.env.KUNDEX_BASE_URL?.trim() ||
53
+ config.baseUrl?.trim() ||
54
+ KUNDEX_BASE_URL
55
+ );
40
56
  }
package/src/index.ts CHANGED
@@ -1,41 +1,340 @@
1
- import { loadConfig, resolveBaseUrl } from "./config";
1
+ import { loadConfig, saveConfig, resolveBaseUrl } from "./config";
2
2
  import { runLogin } from "./login";
3
3
  import { runRepl } from "./repl";
4
+ import { Kundex } from "./sdk";
5
+
6
+ const VERSION = "1.0.0";
7
+
8
+ // ─── Help text ────────────────────────────────────────────────────────────────
9
+
10
+ const HELP = `
11
+ \x1b[1m\x1b[36mkundex\x1b[0m — AI coding agent CLI v${VERSION}
12
+
13
+ \x1b[1mUsage:\x1b[0m
14
+ kundex [command] [options]
15
+
16
+ \x1b[1mAuthentication:\x1b[0m
17
+ login Save your Kundex API key
18
+ logout Remove saved credentials
19
+
20
+ \x1b[1mAgent:\x1b[0m
21
+ (no command) Start the interactive AI coding agent REPL
22
+ run <message> Run a single agent turn (non-interactive)
23
+
24
+ \x1b[1mModels:\x1b[0m
25
+ models List available models
26
+
27
+ \x1b[1mConfiguration:\x1b[0m
28
+ config show Show current configuration
29
+ config set <key> <val> Set a config value
30
+ Keys: model, baseUrl
31
+
32
+ \x1b[1mOther:\x1b[0m
33
+ help, --help, -h Show this help
34
+ version, --version, -v Show version
35
+
36
+ \x1b[1mExamples:\x1b[0m
37
+ kundex login
38
+ kundex # start REPL in current directory
39
+ kundex run "Fix all TypeScript errors"
40
+ kundex models
41
+ kundex config set model codestral-latest
42
+ kundex config set baseUrl https://api.kundex.com.ng
43
+ `.trim();
44
+
45
+ // ─── Colour helpers ───────────────────────────────────────────────────────────
46
+
47
+ const c = {
48
+ reset: "\x1b[0m",
49
+ bold: "\x1b[1m",
50
+ dim: "\x1b[2m",
51
+ cyan: "\x1b[36m",
52
+ green: "\x1b[32m",
53
+ yellow: "\x1b[33m",
54
+ red: "\x1b[31m",
55
+ };
56
+
57
+ function ok(msg: string) { console.log(`${c.green}${c.bold} ✓${c.reset} ${msg}`); }
58
+ function errMsg(msg: string) { console.error(`${c.red}${c.bold} ✗${c.reset} ${msg}`); }
59
+ function dim(msg: string) { console.log(`${c.dim} ${msg}${c.reset}`); }
60
+ function header(msg: string) { console.log(`\n${c.bold}${c.cyan} ${msg}${c.reset}`); }
61
+
62
+ // ─── Commands ─────────────────────────────────────────────────────────────────
63
+
64
+ const PROVIDER_DISPLAY: Record<string, string> = {
65
+ heavstal: "Heavstal",
66
+ mistral: "Mistral",
67
+ };
68
+
69
+ async function cmdModels(config: ReturnType<typeof loadConfig>, baseUrl: string) {
70
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
71
+ header("Available models");
72
+ try {
73
+ const models = await kundex.models.list();
74
+ if (!models.length) { dim("No models found."); return; }
75
+ const nameWidth = Math.max(...models.map((m) => m.name.length), 4);
76
+ const idWidth = Math.max(...models.map((m) => m.id.length), 2);
77
+ const providerWidth = Math.max(...models.map((m) => (m.provider?.length ?? 0)), 8);
78
+ console.log(`\n ${c.dim}${"Name".padEnd(nameWidth)} ${"ID".padEnd(idWidth)} ${"Provider".padEnd(providerWidth)} Context${c.reset}`);
79
+ console.log(` ${c.dim}${"─".repeat(nameWidth + idWidth + providerWidth + 18)}${c.reset}`);
80
+
81
+ const grouped: Record<string, typeof models> = {};
82
+ const order: string[] = [];
83
+ for (const m of models) {
84
+ const p = m.provider ?? "other";
85
+ if (!grouped[p]) { grouped[p] = []; order.push(p); }
86
+ grouped[p].push(m);
87
+ }
88
+
89
+ for (const provider of order) {
90
+ for (const m of grouped[provider]) {
91
+ const ctx = m.contextWindow >= 1_000_000
92
+ ? `${(m.contextWindow / 1_000_000).toFixed(1)}M`
93
+ : m.contextWindow >= 1000
94
+ ? `${(m.contextWindow / 1000).toFixed(0)}k`
95
+ : String(m.contextWindow);
96
+ const providerLabel = (PROVIDER_DISPLAY[m.provider ?? ""] ?? m.provider ?? "").padEnd(providerWidth);
97
+ console.log(
98
+ ` ${c.bold}${m.name.padEnd(nameWidth)}${c.reset}` +
99
+ ` ${c.dim}${m.id.padEnd(idWidth)}${c.reset}` +
100
+ ` ${c.cyan}${providerLabel}${c.reset}` +
101
+ ` ${c.dim}${ctx}${c.reset}`,
102
+ );
103
+ }
104
+ }
105
+ console.log();
106
+ } catch (e) {
107
+ errMsg(`Failed to list models: ${(e as Error).message}`);
108
+ process.exitCode = 1;
109
+ }
110
+ }
111
+
112
+ // ── Run (single-turn non-interactive) ─────────────────────────────────────────
113
+
114
+ async function cmdRun(
115
+ config: ReturnType<typeof loadConfig>,
116
+ baseUrl: string,
117
+ message: string,
118
+ args: string[],
119
+ ) {
120
+ if (!message) {
121
+ errMsg("Usage: kundex run <message>");
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+
126
+ let model = config.defaultModel ?? "codestral-latest";
127
+ let cwd = process.cwd();
128
+ for (let i = 0; i < args.length; i++) {
129
+ if (args[i] === "--model" && args[i + 1]) model = args[++i];
130
+ else if (args[i] === "--cwd" && args[i + 1]) cwd = args[++i];
131
+ }
132
+
133
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
134
+
135
+ try {
136
+ const session = await kundex.agent.createSession({ cwd, model });
137
+ const sessionId = session.id;
138
+
139
+ process.stdout.write(`\n`);
140
+ const { executeToolCall } = await import("./tools");
141
+
142
+ let turn = await kundex.agent.streamMessage(
143
+ { sessionId, message },
144
+ (delta) => process.stdout.write(delta),
145
+ );
146
+
147
+ while (!turn.done && turn.toolCalls && turn.toolCalls.length > 0) {
148
+ for (const call of turn.toolCalls) {
149
+ const { result, isError } = await executeToolCall(cwd, call);
150
+ turn = await kundex.agent.submitToolResult(
151
+ { sessionId, toolCallId: call.id, result, isError },
152
+ (delta) => process.stdout.write(delta),
153
+ );
154
+ }
155
+ }
156
+
157
+ process.stdout.write("\n");
158
+ } catch (e) {
159
+ errMsg(`Agent error: ${(e as Error).message}`);
160
+ process.exitCode = 1;
161
+ }
162
+ }
163
+
164
+ // ─── Config commands ──────────────────────────────────────────────────────────
165
+
166
+ function cmdConfigShow(config: ReturnType<typeof loadConfig>, baseUrl: string) {
167
+ header("Current configuration");
168
+ console.log();
169
+ const rows = [
170
+ ["API key", config.apiKey ? `${config.apiKey.slice(0, 8)}…` : "(not set)"],
171
+ ["Base URL", baseUrl],
172
+ ["Model", config.defaultModel ?? "(default)"],
173
+ ];
174
+ for (const [k, v] of rows) {
175
+ console.log(` ${c.dim}${k.padEnd(12)}${c.reset} ${c.bold}${v}${c.reset}`);
176
+ }
177
+ console.log();
178
+ }
179
+
180
+ function cmdConfigSet(key: string, value: string) {
181
+ const allowed = ["model", "baseUrl"];
182
+ if (!allowed.includes(key)) {
183
+ errMsg(`Unknown config key "${key}". Allowed keys: ${allowed.join(", ")}`);
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+
188
+ const map: Record<string, keyof ReturnType<typeof loadConfig>> = {
189
+ model: "defaultModel",
190
+ baseUrl: "baseUrl",
191
+ };
192
+
193
+ saveConfig({ [map[key]]: value });
194
+ ok(`Set ${key} = ${value}`);
195
+ }
196
+
197
+ function cmdLogout() {
198
+ saveConfig({ apiKey: null, baseUrl: null, defaultModel: null });
199
+ ok("Logged out. Run `kundex login` to authenticate again.");
200
+ }
201
+
202
+ // ─── API key prompt ──────────────────────────────────────────────────────────
203
+
204
+ async function promptForApiKey(): Promise<string | null> {
205
+ const readline = await import("node:readline/promises");
206
+ const rl = readline.default.createInterface({ input: process.stdin, output: process.stdout });
207
+ try {
208
+ console.log(`\n${c.bold}${c.cyan} Welcome to Kundex!${c.reset}`);
209
+ console.log(`${c.dim} You need an API key to use the agent.${c.reset}`);
210
+ console.log(`${c.dim} Get one at: https://kundex.com.ng/dashboard/api-keys${c.reset}\n`);
211
+ const answer = await rl.question(`${c.bold} Enter your API key: ${c.reset}`);
212
+ const apiKey = answer.trim();
213
+ if (!apiKey) {
214
+ console.log(`${c.dim} No key entered. Run \`kundex login\` later to set one.${c.reset}\n`);
215
+ return null;
216
+ }
217
+ saveConfig({ apiKey });
218
+ ok("API key saved.");
219
+ return apiKey;
220
+ } finally {
221
+ rl.close();
222
+ }
223
+ }
224
+
225
+ // ─── Main ─────────────────────────────────────────────────────────────────────
4
226
 
5
227
  async function main(): Promise<void> {
6
- const [, , command] = process.argv;
228
+ const args = process.argv.slice(2);
229
+ const [command, sub, ...rest] = args;
7
230
 
8
- if (command === "login") {
9
- await runLogin();
231
+ // Version
232
+ if (command === "version" || command === "--version" || command === "-v") {
233
+ console.log(`kundex v${VERSION}`);
10
234
  return;
11
235
  }
12
236
 
237
+ // Help
13
238
  if (command === "help" || command === "--help" || command === "-h") {
14
- console.log(
15
- [
16
- "kundeX — terminal AI coding agent.",
17
- "",
18
- "Usage:",
19
- " kundex login Save your API key and API base URL",
20
- " kundex Start the agent REPL in the current directory",
21
- ].join("\n"),
22
- );
239
+ console.log(HELP);
23
240
  return;
24
241
  }
25
242
 
26
- const config = loadConfig();
243
+ // No command — start REPL, prompting for API key if needed
244
+ if (!command) {
245
+ let config = loadConfig();
246
+ const baseUrl = resolveBaseUrl(config);
247
+
248
+ if (!config.apiKey) {
249
+ const key = await promptForApiKey();
250
+ if (!key) {
251
+ console.log(HELP);
252
+ return;
253
+ }
254
+ config = loadConfig();
255
+ }
256
+
257
+ if (config.apiKey && baseUrl) {
258
+ await runRepl(config, config.apiKey, baseUrl);
259
+ return;
260
+ }
261
+ console.log(HELP);
262
+ return;
263
+ }
264
+
265
+ // Login / logout
266
+ if (command === "login") {
267
+ await runLogin();
268
+ return;
269
+ }
270
+ if (command === "logout") {
271
+ cmdLogout();
272
+ return;
273
+ }
274
+
275
+ // Load config — required for all remaining commands
276
+ let config = loadConfig();
27
277
  const baseUrl = resolveBaseUrl(config);
28
278
 
29
279
  if (!config.apiKey || !baseUrl) {
30
- console.error('Not logged in. Run "kundex login" first.');
31
- process.exitCode = 1;
280
+ if (!config.apiKey) {
281
+ const key = await promptForApiKey();
282
+ if (!key) {
283
+ process.exitCode = 1;
284
+ return;
285
+ }
286
+ config = loadConfig();
287
+ }
288
+ if (!config.apiKey || !resolveBaseUrl(config)) {
289
+ errMsg('Not logged in. Run "kundex login" first.');
290
+ process.exitCode = 1;
291
+ return;
292
+ }
293
+ }
294
+
295
+ // Models
296
+ if (command === "models") {
297
+ await cmdModels(config, resolveBaseUrl(config)!);
298
+ return;
299
+ }
300
+
301
+ // Config
302
+ if (command === "config") {
303
+ if (sub === "show" || !sub) {
304
+ cmdConfigShow(config, resolveBaseUrl(config)!);
305
+ } else if (sub === "set") {
306
+ const [key, value] = rest;
307
+ if (!key || !value) {
308
+ errMsg("Usage: kundex config set <key> <value>");
309
+ process.exitCode = 1;
310
+ } else {
311
+ cmdConfigSet(key, value);
312
+ }
313
+ } else {
314
+ errMsg(`Unknown config subcommand "${sub}". Use: show, set`);
315
+ process.exitCode = 1;
316
+ }
32
317
  return;
33
318
  }
34
319
 
35
- await runRepl(config, config.apiKey, baseUrl);
320
+ // Run (single-turn agent)
321
+ if (command === "run") {
322
+ const allArgs = [sub, ...rest].filter(Boolean);
323
+ const flagStart = allArgs.findIndex((a) => a.startsWith("--"));
324
+ const messageParts = flagStart >= 0 ? allArgs.slice(0, flagStart) : allArgs;
325
+ const flagArgs = flagStart >= 0 ? allArgs.slice(flagStart) : [];
326
+ const message = messageParts.join(" ").trim();
327
+
328
+ await cmdRun(config, resolveBaseUrl(config)!, message, flagArgs);
329
+ return;
330
+ }
331
+
332
+ // Unknown command — suggest help
333
+ errMsg(`Unknown command "${command}". Run \`kundex help\` to see available commands.`);
334
+ process.exitCode = 1;
36
335
  }
37
336
 
38
- main().catch((err) => {
39
- console.error(err instanceof Error ? err.message : err);
337
+ main().catch((e) => {
338
+ console.error(`${c.red} Fatal: ${e instanceof Error ? e.message : e}${c.reset}`);
40
339
  process.exitCode = 1;
41
340
  });
package/src/repl.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import readline from "node:readline/promises";
2
2
  import path from "node:path";
3
3
  import { execSync } from "node:child_process";
4
- import { Kundex, type AgentTurn } from "./sdk";
4
+ import { Kundex, type AgentTurn, type AgentSession } from "./sdk";
5
5
  import { executeToolCall } from "./tools";
6
6
  import type { KundexConfig } from "./config";
7
7
 
@@ -18,14 +18,13 @@ const c = {
18
18
  blue: "\x1b[34m",
19
19
  magenta: "\x1b[35m",
20
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
21
+ kw: "\x1b[38;5;204m",
22
+ str: "\x1b[38;5;150m",
23
+ num: "\x1b[38;5;141m",
24
+ comment: "\x1b[38;5;244m",
25
+ fn: "\x1b[38;5;117m",
26
+ type_: "\x1b[38;5;215m",
27
+ punct: "\x1b[38;5;250m",
29
28
  };
30
29
 
31
30
  // ─── Per-language syntax highlighter (ANSI, no deps) ─────────────────────────
@@ -56,33 +55,26 @@ const KEYWORDS: Record<string, string[]> = {
56
55
  function buildRules(lang: string): LangRule[] {
57
56
  const kws = KEYWORDS[lang] || KEYWORDS["javascript"];
58
57
  return [
59
- // Comments
60
58
  { pattern: /(#.*$|\/\/.*$|\/\*[\s\S]*?\*\/|"""[\s\S]*?"""|'''[\s\S]*?''')/gm, color: c.comment },
61
- // Strings
62
59
  { pattern: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, color: c.str },
63
- // Numbers
64
60
  { pattern: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/g, color: c.num },
65
- // Keywords (built from per-language list)
66
61
  { pattern: new RegExp(`\\b(${kws.join("|")})\\b`, "g"), color: c.kw },
67
- // Function calls
68
62
  { pattern: /\b([a-zA-Z_]\w*)\s*(?=\()/g, color: c.fn },
69
- // Type annotations (CamelCase identifiers)
70
63
  { pattern: /\b([A-Z][a-zA-Z0-9_]*)\b/g, color: c.type_ },
71
64
  ];
72
65
  }
73
66
 
74
- /** Apply regex-based ANSI syntax highlighting to a code string. */
75
67
  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"
68
+ const l = lang.toLowerCase();
69
+ const normalised = ["tsx","ts"].includes(l) ? "typescript"
70
+ : ["jsx","js","mjs"].includes(l) ? "javascript"
71
+ : ["python","py"].includes(l) ? "python"
72
+ : ["sh","shell","zsh","bash"].includes(l) ? "bash"
79
73
  : ["rs"].includes(l) ? "rust"
80
74
  : l;
81
75
 
82
76
  const rules = buildRules(normalised in KEYWORDS ? normalised : "javascript");
83
77
 
84
- // We work character-by-character with a simple placeholder approach:
85
- // collect all matches with their positions, sort them, then reconstruct.
86
78
  type Span = { start: number; end: number; color: string };
87
79
  const spans: Span[] = [];
88
80
 
@@ -97,7 +89,6 @@ function syntaxHighlight(code: string, lang: string): string {
97
89
 
98
90
  if (spans.length === 0) return code;
99
91
 
100
- // Sort by start; discard overlapping spans (first wins)
101
92
  spans.sort((a, b) => a.start - b.start);
102
93
  const merged: Span[] = [];
103
94
  let cursor = 0;
@@ -144,7 +135,6 @@ function printReply(text: string) {
144
135
  while ((match = codeBlockRe.exec(text)) !== null) {
145
136
  const before = text.slice(last, match.index);
146
137
  if (before.trim()) {
147
- // Wrap plain text at ~100 chars
148
138
  before.split("\n").forEach(line => {
149
139
  process.stdout.write(` ${line}\n`);
150
140
  });
@@ -166,15 +156,17 @@ function printReply(text: string) {
166
156
  // ─── Helpers ──────────────────────────────────────────────────────────────────
167
157
  const HELP = `
168
158
  ${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
159
+ /help Show this help
160
+ /model Show the model for this session
161
+ /clear Start a fresh agent session
162
+ /exit Quit kundex
173
163
 
174
164
  ${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.
165
+ • The agent can read files, search code, run commands, and use git.
166
+ • You will be asked to confirm any shell commands before they run.
167
+ • Responses stream in real-time — text appears as the model generates it.
177
168
  • Code blocks are syntax-highlighted in the terminal.
169
+ • Press Ctrl+C to abort a running request.
178
170
  `;
179
171
 
180
172
  function safeGitStatus(cwd: string): string | undefined {
@@ -196,11 +188,27 @@ function safeFileTree(cwd: string): string | undefined {
196
188
  }
197
189
  }
198
190
 
191
+ function createSpinner(label: string) {
192
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
193
+ let frame = 0;
194
+ const timer = setInterval(() => {
195
+ process.stdout.write(`\r${c.dim} ${frames[frame++ % frames.length]} ${label}${c.reset} `);
196
+ }, 80);
197
+
198
+ return {
199
+ clear() {
200
+ clearInterval(timer);
201
+ process.stdout.write("\r\x1b[2K");
202
+ },
203
+ };
204
+ }
205
+
199
206
  async function handleTurn(
200
207
  kundex: Kundex,
201
208
  cwd: string,
202
209
  sessionId: number,
203
210
  turn: AgentTurn,
211
+ signal?: AbortSignal,
204
212
  ): Promise<void> {
205
213
  let current = turn;
206
214
 
@@ -210,16 +218,36 @@ async function handleTurn(
210
218
  if (isError) {
211
219
  process.stdout.write(`${c.red} ✗ ${call.name}: ${result}${c.reset}\n`);
212
220
  }
213
- current = await kundex.agent.submitToolResult({
214
- sessionId,
215
- toolCallId: call.id,
216
- result,
217
- isError,
218
- });
221
+
222
+ const sp = createSpinner("processing tool result…");
223
+ let followUpText = "";
224
+
225
+ try {
226
+ current = await kundex.agent.submitToolResult(
227
+ { sessionId, toolCallId: call.id, result, isError },
228
+ (delta) => {
229
+ if (!followUpText) {
230
+ sp.clear();
231
+ process.stdout.write("\n");
232
+ }
233
+ followUpText += delta;
234
+ process.stdout.write(delta);
235
+ },
236
+ signal,
237
+ );
238
+ } finally {
239
+ sp.clear();
240
+ }
241
+
242
+ if (followUpText && !followUpText.endsWith("\n")) {
243
+ process.stdout.write("\n");
244
+ }
219
245
  }
220
246
  }
221
247
 
222
- if (current.reply) {
248
+ if (current.reply && !turn.toolCalls?.length) {
249
+ process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
250
+ } else if (current.reply) {
223
251
  printReply(current.reply);
224
252
  }
225
253
  }
@@ -238,7 +266,7 @@ ${c.bold}${c.cyan} ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═
238
266
  ${c.dim}cwd:${c.reset} ${c.bold}${shortCwd}${c.reset}
239
267
  ${c.dim}model:${c.reset} ${c.bold}${model}${c.reset}
240
268
 
241
- Type ${c.cyan}/help${c.reset} for commands.
269
+ Type ${c.cyan}/help${c.reset} for commands, ${c.cyan}Ctrl+C${c.reset} to stop a running request.
242
270
  `);
243
271
  }
244
272
 
@@ -246,20 +274,36 @@ ${c.bold}${c.cyan} ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═
246
274
  export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: string): Promise<void> {
247
275
  const cwd = process.cwd();
248
276
  const kundex = new Kundex({ apiKey, baseUrl });
249
- const model = config.defaultModel ?? "openai/gpt-oss-120b";
277
+ const model = config.defaultModel ?? "codestral-latest";
250
278
 
251
279
  printBanner(cwd, model);
252
280
 
253
281
  let session = await kundex.agent.createSession({ cwd, model });
254
282
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
255
283
 
284
+ let currentAbort: AbortController | null = null;
285
+
286
+ const sigintHandler = () => {
287
+ if (currentAbort && !currentAbort.signal.aborted) {
288
+ currentAbort.abort();
289
+ process.stdout.write(`\n${c.yellow} ⚡ Stopped.${c.reset}\n\n`);
290
+ currentAbort = null;
291
+ } else {
292
+ console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
293
+ rl.close();
294
+ process.exit(0);
295
+ }
296
+ };
297
+
298
+ process.on("SIGINT", sigintHandler);
299
+
256
300
  try {
257
301
  for (;;) {
258
302
  const raw = await rl.question(`${c.bold}${c.cyan}kundex${c.reset} ${c.dim}▸${c.reset} `);
259
303
  const input = raw.trim();
260
304
  if (!input) continue;
261
305
 
262
- if (input === "/exit") {
306
+ if (input === "/exit" || input === "/quit") {
263
307
  console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
264
308
  break;
265
309
  }
@@ -274,23 +318,56 @@ export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: str
274
318
  continue;
275
319
  }
276
320
 
321
+ // ── Agent message ────────────────────────────────────────────────────────
322
+ currentAbort = new AbortController();
323
+ const { signal } = currentAbort;
324
+ const spinner = createSpinner("thinking…");
325
+ let streamedAnyText = false;
326
+
277
327
  try {
278
- process.stdout.write(`\n${c.dim} thinking...${c.reset}\n`);
279
- const turn = await kundex.agent.sendMessage({
280
- sessionId: session.id,
281
- message: input,
282
- context: {
283
- gitStatus: safeGitStatus(cwd),
284
- fileTree: safeFileTree(cwd),
285
- openFiles: [],
328
+ const turn = await kundex.agent.streamMessage(
329
+ {
330
+ sessionId: session.id,
331
+ message: input,
332
+ context: {
333
+ gitStatus: safeGitStatus(cwd),
334
+ fileTree: safeFileTree(cwd),
335
+ },
336
+ },
337
+ (delta) => {
338
+ if (!streamedAnyText) {
339
+ spinner.clear();
340
+ process.stdout.write("\n");
341
+ streamedAnyText = true;
342
+ }
343
+ process.stdout.write(delta);
286
344
  },
287
- });
288
- await handleTurn(kundex, cwd, session.id, turn);
345
+ signal,
346
+ );
347
+
348
+ spinner.clear();
349
+
350
+ if (turn.toolCalls?.length) {
351
+ await handleTurn(kundex, cwd, session.id, turn, signal);
352
+ } else if (streamedAnyText) {
353
+ process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
354
+ } else if (turn.reply) {
355
+ printReply(turn.reply);
356
+ }
289
357
  } catch (err) {
290
- console.error(`\n${c.red} error: ${(err as Error).message}${c.reset}\n`);
358
+ spinner.clear();
359
+ const e = err as Error;
360
+ if (e.name === "AbortError") {
361
+ // Already printed "⚡ Stopped" via sigintHandler
362
+ } else {
363
+ console.error(`\n${c.red} error: ${e.message}${c.reset}\n`);
364
+ }
365
+ } finally {
366
+ currentAbort = null;
291
367
  }
292
368
  }
293
369
  } finally {
370
+ process.off("SIGINT", sigintHandler);
294
371
  rl.close();
295
372
  }
296
373
  }
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", "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
@@ -25,7 +25,6 @@ const c = {
25
25
  blue: "\x1b[34m",
26
26
  magenta: "\x1b[35m",
27
27
  white: "\x1b[37m",
28
- bgDark: "\x1b[48;5;235m",
29
28
  };
30
29
 
31
30
  function header(label: string, value: string) {
@@ -34,34 +33,6 @@ function header(label: string, value: string) {
34
33
  );
35
34
  }
36
35
 
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
-
65
36
  async function confirm(prompt: string): Promise<boolean> {
66
37
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
67
38
  try {
@@ -86,35 +57,6 @@ async function readFileTool(cwd: string, args: { path: string }): Promise<string
86
57
  return fs.readFile(full, "utf-8");
87
58
  }
88
59
 
89
- async function writeFileTool(cwd: string, args: { path: string; content: string }): Promise<string> {
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
-
111
- await fs.mkdir(path.dirname(full), { recursive: true });
112
- await fs.writeFile(full, args.content, "utf-8");
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}`;
116
- }
117
-
118
60
  async function runCommandTool(cwd: string, args: { command: string }): Promise<string> {
119
61
  console.log(`\n${c.bold}${c.yellow} ◆ run_command${c.reset} ${c.dim}${args.command}${c.reset}`);
120
62
  const approved = await confirm(`Run: ${c.bold}${args.command}${c.reset}?`);
@@ -150,13 +92,7 @@ async function runCommandTool(cwd: string, args: { command: string }): Promise<s
150
92
 
151
93
  async function gitTool(cwd: string, args: { args: string[] }): Promise<string> {
152
94
  const cmd = `git ${args.args.join(" ")}`;
153
- const isMutating = !["status", "diff", "log", "show", "branch"].includes(args.args[0]);
154
-
155
95
  header("git", cmd);
156
- if (isMutating) {
157
- const approved = await confirm(`Run: ${c.bold}${cmd}${c.reset}?`);
158
- if (!approved) return "User declined to run this git command.";
159
- }
160
96
 
161
97
  return new Promise((resolve) => {
162
98
  exec(cmd, { cwd, timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
@@ -178,7 +114,6 @@ async function listDirectoryTool(cwd: string, args: { path: string }): Promise<s
178
114
  if (e.isSymbolicLink()) return ` ${c.cyan}${e.name}@${c.reset}`;
179
115
  return ` ${e.name}`;
180
116
  });
181
- // Print inline preview
182
117
  lines.forEach(l => process.stdout.write(l + "\n"));
183
118
  return entries
184
119
  .map(e => (e.isDirectory() ? `${e.name}/` : e.name))
@@ -189,8 +124,6 @@ async function searchFilesTool(
189
124
  cwd: string,
190
125
  args: { pattern: string; glob?: string }
191
126
  ): 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
127
  if (!args.pattern || typeof args.pattern !== "string") {
195
128
  return "Search failed: pattern must be a non-empty string.";
196
129
  }
@@ -202,7 +135,6 @@ async function searchFilesTool(
202
135
 
203
136
  header("search_files", `"${args.pattern}"${args.glob ? ` in ${args.glob}` : ""}`);
204
137
 
205
- // Build the argument array passed directly to execFile — no shell involved.
206
138
  const grepArgs: string[] = [
207
139
  "-rn",
208
140
  "--color=never",
@@ -211,13 +143,11 @@ async function searchFilesTool(
211
143
  if (args.glob) {
212
144
  grepArgs.push(`--include=${args.glob}`);
213
145
  }
214
- // Pattern and path as positional args (not shell-interpolated)
215
146
  grepArgs.push(args.pattern, ".");
216
147
 
217
148
  return new Promise((resolve) => {
218
149
  execFile("grep", grepArgs, { cwd, timeout: 15_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
219
150
  if (err && (err as NodeJS.ErrnoException & { code: number }).code === 1) {
220
- // grep exits 1 when no matches are found — not an error
221
151
  resolve("No matches found.");
222
152
  return;
223
153
  }
@@ -244,8 +174,6 @@ export async function executeToolCall(cwd: string, call: ToolCall): Promise<Tool
244
174
  switch (call.name) {
245
175
  case "read_file":
246
176
  return { result: await readFileTool(cwd, args), isError: false };
247
- case "write_file":
248
- return { result: await writeFileTool(cwd, args), isError: false };
249
177
  case "run_command":
250
178
  return { result: await runCommandTool(cwd, args), isError: false };
251
179
  case "git":