kafox-cli 0.3.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.js ADDED
@@ -0,0 +1,371 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { spawnSync } = require("child_process");
4
+
5
+ // Limites para nao estourar o contexto nem travar a TUI.
6
+ const MAX_READ_CHARS = 40_000;
7
+ const MAX_READ_LINES = 400;
8
+ const MAX_WRITE_CHARS = 120_000;
9
+ const MAX_LIST_ENTRIES = 300;
10
+ const MAX_OUTPUT_CHARS = 12_000;
11
+ const COMMAND_TIMEOUT_MS = 60_000;
12
+
13
+ const TOOL_SPECS = [
14
+ {
15
+ type: "function",
16
+ function: {
17
+ name: "read_file",
18
+ description:
19
+ "Le um arquivo de texto do diretorio de trabalho. Use offset/limit para trechos grandes.",
20
+ parameters: {
21
+ type: "object",
22
+ properties: {
23
+ path: { type: "string", description: "Caminho relativo ao diretorio de trabalho" },
24
+ offset: { type: "integer", description: "Linha inicial (1-based)" },
25
+ limit: { type: "integer", description: "Quantidade de linhas (max 400)" },
26
+ },
27
+ required: ["path"],
28
+ },
29
+ },
30
+ },
31
+ {
32
+ type: "function",
33
+ function: {
34
+ name: "list_dir",
35
+ description: "Lista arquivos e pastas de um diretorio do projeto.",
36
+ parameters: {
37
+ type: "object",
38
+ properties: {
39
+ path: { type: "string", description: "Diretorio relativo (padrao: raiz do projeto)" },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ {
45
+ type: "function",
46
+ function: {
47
+ name: "write_file",
48
+ description:
49
+ "Cria um arquivo novo ou reescreve um arquivo inteiro no diretorio de trabalho. Cria as pastas necessarias.",
50
+ parameters: {
51
+ type: "object",
52
+ properties: {
53
+ path: { type: "string", description: "Caminho relativo ao diretorio de trabalho" },
54
+ content: { type: "string", description: "Conteudo completo do arquivo" },
55
+ },
56
+ required: ["path", "content"],
57
+ },
58
+ },
59
+ },
60
+ {
61
+ type: "function",
62
+ function: {
63
+ name: "edit_file",
64
+ description:
65
+ "Troca um trecho exato de um arquivo existente por outro. Prefira esta ferramenta a reescrever o arquivo inteiro.",
66
+ parameters: {
67
+ type: "object",
68
+ properties: {
69
+ path: { type: "string", description: "Caminho relativo ao diretorio de trabalho" },
70
+ old_string: { type: "string", description: "Trecho atual, exatamente como esta no arquivo" },
71
+ new_string: { type: "string", description: "Trecho que substitui o anterior" },
72
+ replace_all: {
73
+ type: "boolean",
74
+ description: "Troca todas as ocorrencias (padrao: so quando for unica)",
75
+ },
76
+ },
77
+ required: ["path", "old_string", "new_string"],
78
+ },
79
+ },
80
+ },
81
+ {
82
+ type: "function",
83
+ function: {
84
+ name: "run_command",
85
+ description:
86
+ "Executa um comando de shell no diretorio de trabalho e devolve stdout, stderr e o codigo de saida.",
87
+ parameters: {
88
+ type: "object",
89
+ properties: {
90
+ command: { type: "string", description: "Comando a executar" },
91
+ timeout_ms: { type: "integer", description: "Timeout em ms (padrao 60000, max 120000)" },
92
+ },
93
+ required: ["command"],
94
+ },
95
+ },
96
+ },
97
+ ];
98
+
99
+ const LOCAL_TOOL_NAMES = new Set(TOOL_SPECS.map((tool) => tool.function.name));
100
+ const TOOL_NAMES = LOCAL_TOOL_NAMES;
101
+ // Ferramentas que mudam o disco ou executam codigo pedem aprovacao.
102
+ const APPROVAL_REQUIRED = new Set(["write_file", "edit_file", "run_command"]);
103
+
104
+ const TOOL_META = {
105
+ // A ferramenta de subagente e montada no agent.js (nao vai para TOOL_SPECS
106
+ // porque só aparece enquanto a profundidade permite), mas o card da TUI
107
+ // precisa do mesmo rótulo das outras.
108
+ spawn_subagent: { kind: "agent", label: "Subagente" },
109
+ read_file: { kind: "read", label: "Read" },
110
+ list_dir: { kind: "list", label: "List" },
111
+ write_file: { kind: "write", label: "Write" },
112
+ edit_file: { kind: "edit", label: "Edit" },
113
+ run_command: { kind: "bash", label: "Bash" },
114
+ };
115
+
116
+ function toolMeta(name, hub) {
117
+ if (TOOL_META[name]) return { ...TOOL_META[name], mcp: null };
118
+ const entry = hub?.describe?.(name);
119
+ if (entry) return { kind: "mcp", label: "MCP", server: entry.server, tool: entry.tool };
120
+ return { kind: "tool", label: name || "tool" };
121
+ }
122
+
123
+ function resolveInside(cwd, target) {
124
+ const root = path.resolve(cwd);
125
+ const full = path.resolve(root, target || ".");
126
+ if (full !== root && !full.startsWith(root + path.sep)) {
127
+ throw new Error(`caminho fora do diretorio de trabalho: ${target}`);
128
+ }
129
+ return full;
130
+ }
131
+
132
+ function relative(cwd, full) {
133
+ const rel = path.relative(path.resolve(cwd), full);
134
+ return rel === "" ? "." : rel;
135
+ }
136
+
137
+ function truncate(text, max) {
138
+ if (text.length <= max) return text;
139
+ return `${text.slice(0, max)}\n... (cortado, ${text.length - max} caracteres a mais)`;
140
+ }
141
+
142
+ function readTextFile(cwd, target) {
143
+ const full = resolveInside(cwd, target);
144
+ if (!fs.existsSync(full)) throw new Error(`arquivo nao encontrado: ${target}`);
145
+ if (fs.statSync(full).isDirectory()) throw new Error(`${target} e um diretorio`);
146
+ return { full, content: fs.readFileSync(full, "utf8") };
147
+ }
148
+
149
+ function readFile(cwd, args) {
150
+ const { full, content: raw } = readTextFile(cwd, args.path);
151
+
152
+ const offset = Math.max(1, Number(args.offset) || 1);
153
+ const limit = Math.min(MAX_READ_LINES, Math.max(1, Number(args.limit) || MAX_READ_LINES));
154
+ const lines = raw.split("\n");
155
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
156
+ const header =
157
+ offset > 1 || slice.length < lines.length
158
+ ? `# ${relative(cwd, full)} linhas ${offset}-${offset + slice.length - 1} de ${lines.length}\n`
159
+ : "";
160
+ return truncate(header + slice.join("\n"), MAX_READ_CHARS);
161
+ }
162
+
163
+ function writeFile(cwd, args) {
164
+ const full = resolveInside(cwd, args.path);
165
+ const content = String(args.content ?? "");
166
+ if (content.length > MAX_WRITE_CHARS) {
167
+ throw new Error(`conteudo grande demais (${content.length} chars, max ${MAX_WRITE_CHARS})`);
168
+ }
169
+ fs.mkdirSync(path.dirname(full), { recursive: true });
170
+ fs.writeFileSync(full, content, "utf8");
171
+ return `ok: ${relative(cwd, full)} (${content.length} caracteres escritos)`;
172
+ }
173
+
174
+ /** Aplica a troca de trecho; separado da escrita para permitir o preview. */
175
+ function applyEdit(cwd, args) {
176
+ const oldString = String(args.old_string ?? "");
177
+ const newString = String(args.new_string ?? "");
178
+ if (!oldString) throw new Error("old_string vazio: diga qual trecho deve sair");
179
+
180
+ const { full, content } = readTextFile(cwd, args.path);
181
+ const occurrences = content.split(oldString).length - 1;
182
+ if (!occurrences) throw new Error(`trecho nao encontrado em ${args.path}`);
183
+ if (occurrences > 1 && !args.replace_all) {
184
+ throw new Error(
185
+ `o trecho aparece ${occurrences} vezes em ${args.path}: inclua mais contexto ou use replace_all`
186
+ );
187
+ }
188
+
189
+ const after = args.replace_all
190
+ ? content.split(oldString).join(newString)
191
+ : content.replace(oldString, newString);
192
+ if (after.length > MAX_WRITE_CHARS) {
193
+ throw new Error(`resultado grande demais (${after.length} chars, max ${MAX_WRITE_CHARS})`);
194
+ }
195
+ return { full, before: content, after, occurrences };
196
+ }
197
+
198
+ function editFile(cwd, args) {
199
+ const { full, after, occurrences } = applyEdit(cwd, args);
200
+ fs.writeFileSync(full, after, "utf8");
201
+ return `ok: ${relative(cwd, full)} editado (${occurrences} trecho${occurrences > 1 ? "s" : ""})`;
202
+ }
203
+
204
+ function listDir(cwd, args) {
205
+ const full = resolveInside(cwd, args.path);
206
+ if (!fs.existsSync(full)) throw new Error(`diretorio nao encontrado: ${args.path || "."}`);
207
+ const entries = fs.readdirSync(full, { withFileTypes: true });
208
+ const rows = entries
209
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
210
+ .slice(0, MAX_LIST_ENTRIES)
211
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name));
212
+ const extra = entries.length > rows.length ? `\n... (+${entries.length - rows.length})` : "";
213
+ return `${relative(cwd, full)}\n${rows.join("\n")}${extra}`;
214
+ }
215
+
216
+ function runCommand(cwd, args) {
217
+ const command = String(args.command || "").trim();
218
+ if (!command) throw new Error("comando vazio");
219
+ const timeout = Math.min(120_000, Math.max(1_000, Number(args.timeout_ms) || COMMAND_TIMEOUT_MS));
220
+
221
+ const result = spawnSync(command, {
222
+ shell: true,
223
+ cwd: path.resolve(cwd),
224
+ timeout,
225
+ encoding: "utf8",
226
+ maxBuffer: 8 * 1024 * 1024,
227
+ env: { ...process.env, KAFOX_CLI: "1" },
228
+ });
229
+
230
+ const stdout = truncate(result.stdout || "", MAX_OUTPUT_CHARS);
231
+ const stderr = truncate(result.stderr || "", MAX_OUTPUT_CHARS);
232
+ const parts = [`exit: ${result.status == null ? "?" : result.status}`];
233
+ if (result.error?.code === "ETIMEDOUT") parts.push(`timeout: ${timeout}ms`);
234
+ if (stdout.trim()) parts.push(`stdout:\n${stdout.trimEnd()}`);
235
+ if (stderr.trim()) parts.push(`stderr:\n${stderr.trimEnd()}`);
236
+ return parts.join("\n");
237
+ }
238
+
239
+ async function executeTool(name, args = {}, context = {}) {
240
+ const cwd = context.cwd || process.cwd();
241
+ if (!LOCAL_TOOL_NAMES.has(name)) {
242
+ if (context.mcp?.has?.(name)) return context.mcp.call(name, args);
243
+ throw new Error(`ferramenta desconhecida: ${name}`);
244
+ }
245
+ switch (name) {
246
+ case "read_file":
247
+ return readFile(cwd, args);
248
+ case "write_file":
249
+ return writeFile(cwd, args);
250
+ case "edit_file":
251
+ return editFile(cwd, args);
252
+ case "list_dir":
253
+ return listDir(cwd, args);
254
+ case "run_command":
255
+ return runCommand(cwd, args);
256
+ default:
257
+ throw new Error(`ferramenta desconhecida: ${name}`);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * O que a ferramenta vai mudar no arquivo, sem escrever nada.
263
+ * Usado no modal de aprovacao e no diff mostrado na conversa.
264
+ */
265
+ function previewToolChange(name, args = {}, context = {}) {
266
+ const cwd = context.cwd || process.cwd();
267
+ if (name === "write_file") {
268
+ const full = resolveInside(cwd, args.path);
269
+ const exists = fs.existsSync(full) && !fs.statSync(full).isDirectory();
270
+ return {
271
+ kind: exists ? "update" : "create",
272
+ path: relative(cwd, full),
273
+ before: exists ? fs.readFileSync(full, "utf8") : null,
274
+ after: String(args.content ?? ""),
275
+ };
276
+ }
277
+ if (name === "edit_file") {
278
+ try {
279
+ const { full, before, after, occurrences } = applyEdit(cwd, args);
280
+ return { kind: "edit", path: relative(cwd, full), before, after, occurrences };
281
+ } catch (error) {
282
+ return {
283
+ kind: "edit",
284
+ path: String(args.path || ""),
285
+ before: null,
286
+ after: null,
287
+ error: error.message,
288
+ };
289
+ }
290
+ }
291
+ if (name === "run_command") {
292
+ return { kind: "command", command: String(args.command || "") };
293
+ }
294
+ return null;
295
+ }
296
+
297
+ /** Resumo curto da chamada para mostrar na conversa. */
298
+ function describeToolCall(name, args = {}) {
299
+ if (name === "spawn_subagent") {
300
+ const tasks = Array.isArray(args.tasks) ? args.tasks : args.objective ? [args] : [];
301
+ if (!tasks.length) return "(sem objetivo)";
302
+ if (tasks.length === 1) return String(tasks[0].objective || "(objetivo)").slice(0, 120);
303
+ return `${tasks.length} subagentes: ${tasks
304
+ .map((task) => String(task.objective || "").slice(0, 40))
305
+ .join(" | ")}`;
306
+ }
307
+ if (name === "read_file") return args.path || "(arquivo)";
308
+ if (name === "write_file") return `${args.path || "(arquivo)"} (${String(args.content || "").length} chars)`;
309
+ if (name === "edit_file") return `${args.path || "(arquivo)"}`;
310
+ if (name === "list_dir") return args.path || ".";
311
+ if (name === "run_command") return args.command || "(comando)";
312
+ return "";
313
+ }
314
+
315
+ /**
316
+ * Separa a saida de run_command no que a TUI precisa mostrar:
317
+ * codigo de saida, se deu timeout, stdout e stderr.
318
+ */
319
+ function parseCommandOutput(output) {
320
+ const result = { exit: null, timedOut: false, stdout: [], stderr: [] };
321
+ let bucket = null;
322
+
323
+ for (const raw of String(output ?? "").split("\n")) {
324
+ const line = raw.replace(/\s+$/, "");
325
+ const exit = line.match(/^exit:\s*(.+)$/);
326
+ if (exit) {
327
+ result.exit = exit[1].trim() === "?" ? null : Number(exit[1]);
328
+ continue;
329
+ }
330
+ if (/^timeout:\s*\d+ms$/.test(line)) {
331
+ result.timedOut = true;
332
+ continue;
333
+ }
334
+ if (line === "stdout:") {
335
+ bucket = "stdout";
336
+ continue;
337
+ }
338
+ if (line === "stderr:") {
339
+ bucket = "stderr";
340
+ continue;
341
+ }
342
+ if (bucket) result[bucket].push(line);
343
+ }
344
+
345
+ return result;
346
+ }
347
+
348
+ /** Uma linha de resumo do resultado (primeira linha util da saida). */
349
+ function summarizeOutput(output) {
350
+ const first = String(output || "")
351
+ .split("\n")
352
+ .map((line) => line.trim())
353
+ .filter(Boolean)[0];
354
+ return first ? first.slice(0, 200) : "(sem saida)";
355
+ }
356
+
357
+ module.exports = {
358
+ TOOL_SPECS,
359
+ TOOL_NAMES,
360
+ LOCAL_TOOL_NAMES,
361
+ APPROVAL_REQUIRED,
362
+ TOOL_META,
363
+ toolMeta,
364
+ executeTool,
365
+ previewToolChange,
366
+ describeToolCall,
367
+ summarizeOutput,
368
+ parseCommandOutput,
369
+ resolveInside,
370
+ MAX_WRITE_CHARS,
371
+ };