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/mcp.js ADDED
@@ -0,0 +1,557 @@
1
+ /**
2
+ * Cliente MCP do kafox: conecta servidores configurados e transforma as tools
3
+ * deles em tools do modelo (prefixo `mcp__servidor__ferramenta`).
4
+ *
5
+ * Config (mesclada, o projeto vence a home):
6
+ * ~/.kafox/mcp.json { "mcp": { "playwright": { "type": "local", "command": ["npx","-y","@playwright/mcp@latest"] } } }
7
+ * <cwd>/mcp.json mesma forma (ou { "servers": { ... } })
8
+ * ~/.kafox/config.json { "mcp": { ... } }
9
+ *
10
+ * Servidor remoto: { "type": "remote", "url": "https://...", "headers": { } }.
11
+ */
12
+ const fs = require("fs");
13
+ const os = require("os");
14
+ const path = require("path");
15
+ const { spawn } = require("child_process");
16
+
17
+ const PROTOCOL_VERSION = "2024-11-05";
18
+ const CONNECT_TIMEOUT_MS = 20_000;
19
+ const CALL_TIMEOUT_MS = 180_000;
20
+ const MAX_TOOL_NAME = 64;
21
+ const MAX_ERROR_CHARS = 300;
22
+ const CLIENT_VERSION = (() => {
23
+ try {
24
+ return require("../package.json").version;
25
+ } catch {
26
+ return "0.0.0";
27
+ }
28
+ })();
29
+
30
+ function shorten(text, max = MAX_ERROR_CHARS) {
31
+ const value = String(text || "").replace(/\s+/g, " ").trim();
32
+ return value.length > max ? `${value.slice(0, max)}...` : value;
33
+ }
34
+
35
+ function sanitize(value) {
36
+ return String(value || "")
37
+ .trim()
38
+ .replace(/[^A-Za-z0-9_-]/g, "_")
39
+ .replace(/_+/g, "_")
40
+ .replace(/^_|_$/g, "");
41
+ }
42
+
43
+ /** Nome exposto ao modelo: cabe no limite de 64 caracteres do backend. */
44
+ function toolName(server, tool) {
45
+ const name = `mcp__${sanitize(server)}__${sanitize(tool)}`;
46
+ return name.length <= MAX_TOOL_NAME ? name : name.slice(0, MAX_TOOL_NAME);
47
+ }
48
+
49
+ function readJson(file) {
50
+ try {
51
+ return JSON.parse(fs.readFileSync(file, "utf8"));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function collectServers(raw) {
58
+ if (!raw || typeof raw !== "object") return {};
59
+ const source = raw.mcp || raw.servers || raw;
60
+ const servers = {};
61
+ for (const [name, value] of Object.entries(source)) {
62
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
63
+ if (value.command || value.url || value.type) servers[name] = value;
64
+ }
65
+ return servers;
66
+ }
67
+
68
+ function configPaths(cwd = process.cwd()) {
69
+ return [
70
+ path.join(os.homedir(), ".kafox", "mcp.json"),
71
+ path.join(os.homedir(), ".kafox", "config.json"),
72
+ path.join(cwd, "mcp.json"),
73
+ path.join(cwd, ".kafox", "mcp.json"),
74
+ ];
75
+ }
76
+
77
+ /** Junta os arquivos de config; o ultimo a definir um servidor vence. */
78
+ function loadConfig(cwd = process.cwd()) {
79
+ const merged = {};
80
+ const found = [];
81
+ for (const file of configPaths(cwd)) {
82
+ const data = readJson(file);
83
+ if (!data) continue;
84
+ const servers = collectServers(data);
85
+ if (!Object.keys(servers).length) continue;
86
+ found.push(file);
87
+ Object.assign(merged, servers);
88
+ }
89
+ return { servers: merged, files: found };
90
+ }
91
+
92
+ function normalizeServer(name, raw) {
93
+ const disabled = raw.enabled === false || raw.disabled === true;
94
+ const type = String(raw.type || (raw.url ? "remote" : "local")).toLowerCase();
95
+
96
+ if (type === "remote" || type === "sse" || type === "http" || type === "streamable-http") {
97
+ if (!raw.url) return null;
98
+ return {
99
+ name,
100
+ kind: "remote",
101
+ url: String(raw.url),
102
+ headers: { ...(raw.headers || {}) },
103
+ disabled,
104
+ };
105
+ }
106
+
107
+ const command = Array.isArray(raw.command)
108
+ ? raw.command.map(String)
109
+ : String(raw.command || "")
110
+ .trim()
111
+ .split(/\s+/)
112
+ .filter(Boolean);
113
+ if (!command.length) return null;
114
+ return {
115
+ name,
116
+ kind: "stdio",
117
+ command,
118
+ env: { ...(raw.environment || raw.env || {}) },
119
+ cwd: raw.cwd || null,
120
+ disabled,
121
+ };
122
+ }
123
+
124
+ /* ------------------------------------------------------------------ transporte */
125
+
126
+ function quoteWin(parts) {
127
+ return parts
128
+ .map((part) => (/\s/.test(part) && !part.startsWith('"') ? `"${part}"` : part))
129
+ .join(" ");
130
+ }
131
+
132
+ class StdioTransport {
133
+ constructor(server, handlers) {
134
+ this.server = server;
135
+ this.handlers = handlers;
136
+ this.child = null;
137
+ this.buffer = "";
138
+ this.stderr = "";
139
+ this.exited = false;
140
+ }
141
+
142
+ start() {
143
+ const cwd = this.server.cwd ? path.resolve(this.server.cwd) : process.cwd();
144
+ const env = { ...process.env, ...this.server.env, KAFOX_CLI: "1" };
145
+ const options = { cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true };
146
+
147
+ if (process.platform === "win32") {
148
+ this.child = spawn(quoteWin(this.server.command), { ...options, shell: true });
149
+ } else {
150
+ const [bin, ...args] = this.server.command;
151
+ this.child = spawn(bin, args, options);
152
+ }
153
+
154
+ this.child.stdout.setEncoding("utf8");
155
+ this.child.stdout.on("data", (chunk) => this.onData(chunk));
156
+ this.child.stderr.setEncoding("utf8");
157
+ this.child.stderr.on("data", (chunk) => {
158
+ this.stderr = (this.stderr + chunk).slice(-2000);
159
+ });
160
+ this.child.on("error", (error) => {
161
+ this.exited = true;
162
+ this.handlers.onExit(`nao consegui iniciar: ${error.message}`);
163
+ });
164
+ this.child.on("exit", (code, signal) => {
165
+ this.exited = true;
166
+ const detail = this.stderr ? ` (${shorten(this.stderr, 160)})` : "";
167
+ this.handlers.onExit(`processo encerrou (${signal || code})${detail}`);
168
+ });
169
+ return Promise.resolve();
170
+ }
171
+
172
+ onData(chunk) {
173
+ this.buffer += chunk;
174
+ let index = this.buffer.indexOf("\n");
175
+ while (index >= 0) {
176
+ const line = this.buffer.slice(0, index).trim();
177
+ this.buffer = this.buffer.slice(index + 1);
178
+ if (line) {
179
+ try {
180
+ this.handlers.onMessage(JSON.parse(line));
181
+ } catch {
182
+ // Linha de log do servidor: ignora.
183
+ }
184
+ }
185
+ index = this.buffer.indexOf("\n");
186
+ }
187
+ }
188
+
189
+ send(message) {
190
+ if (!this.child || this.exited) throw new Error("servidor MCP nao esta rodando");
191
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
192
+ return Promise.resolve(null);
193
+ }
194
+
195
+ close() {
196
+ if (!this.child) return;
197
+ try {
198
+ this.child.stdin.end();
199
+ this.child.kill();
200
+ } catch {}
201
+ this.child = null;
202
+ }
203
+ }
204
+
205
+ function parseSse(text) {
206
+ const events = [];
207
+ for (const block of String(text).split(/\n\n+/)) {
208
+ const data = block
209
+ .split(/\n/)
210
+ .filter((line) => line.startsWith("data:"))
211
+ .map((line) => line.slice(5).trim())
212
+ .join("");
213
+ if (!data) continue;
214
+ try {
215
+ events.push(JSON.parse(data));
216
+ } catch {}
217
+ }
218
+ return events;
219
+ }
220
+
221
+ class HttpTransport {
222
+ constructor(server) {
223
+ this.server = server;
224
+ this.sessionId = null;
225
+ this.closed = false;
226
+ }
227
+
228
+ start() {
229
+ return Promise.resolve();
230
+ }
231
+
232
+ async post(message) {
233
+ const headers = {
234
+ "content-type": "application/json",
235
+ accept: "application/json, text/event-stream",
236
+ ...this.server.headers,
237
+ };
238
+ if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
239
+
240
+ const response = await fetch(this.server.url, {
241
+ method: "POST",
242
+ headers,
243
+ body: JSON.stringify(message),
244
+ });
245
+ const session = response.headers.get("mcp-session-id");
246
+ if (session) this.sessionId = session;
247
+
248
+ const type = response.headers.get("content-type") || "";
249
+ const text = await response.text();
250
+ if (!response.ok) {
251
+ throw new Error(`HTTP ${response.status}: ${shorten(text, 160)}`);
252
+ }
253
+ if (type.includes("text/event-stream")) return parseSse(text);
254
+ try {
255
+ return [JSON.parse(text)];
256
+ } catch {
257
+ return [];
258
+ }
259
+ }
260
+
261
+ send(message) {
262
+ if (this.closed) throw new Error("servidor MCP foi encerrado");
263
+ return this.post(message);
264
+ }
265
+
266
+ close() {
267
+ this.closed = true;
268
+ }
269
+ }
270
+
271
+ /* --------------------------------------------------------------------- cliente */
272
+
273
+ class McpClient {
274
+ constructor(server, options = {}) {
275
+ this.server = server;
276
+ this.options = {
277
+ connectTimeoutMs: options.connectTimeoutMs || CONNECT_TIMEOUT_MS,
278
+ callTimeoutMs: options.callTimeoutMs || CALL_TIMEOUT_MS,
279
+ };
280
+ this.status = server.disabled ? "disabled" : "idle";
281
+ this.error = null;
282
+ this.tools = [];
283
+ this.serverInfo = null;
284
+ this.pending = new Map();
285
+ this.nextId = 1;
286
+ this.transport = null;
287
+ this.exitError = null;
288
+ }
289
+
290
+ onMessage(message) {
291
+ if (!message || typeof message !== "object") return;
292
+ const id = message.id;
293
+ if (id == null || !this.pending.has(id)) return;
294
+ const entry = this.pending.get(id);
295
+ this.pending.delete(id);
296
+ clearTimeout(entry.timer);
297
+
298
+ if (message.error) {
299
+ entry.reject(new Error(shorten(message.error.message || JSON.stringify(message.error))));
300
+ return;
301
+ }
302
+ entry.resolve(message.result ?? {});
303
+ }
304
+
305
+ request(method, params = {}, timeoutMs) {
306
+ const id = this.nextId++;
307
+ const message = { jsonrpc: "2.0", id, method, params };
308
+ const timeout = timeoutMs || this.options.callTimeoutMs;
309
+
310
+ return new Promise((resolve, reject) => {
311
+ const timer = setTimeout(() => {
312
+ this.pending.delete(id);
313
+ reject(new Error(`${method} demorou mais de ${Math.round(timeout / 1000)}s`));
314
+ }, timeout);
315
+ timer.unref?.();
316
+
317
+ const settle = (fn) => (value) => {
318
+ clearTimeout(timer);
319
+ fn(value);
320
+ };
321
+
322
+ if (this.server.kind === "stdio") {
323
+ this.pending.set(id, { resolve: settle(resolve), reject: settle(reject), timer });
324
+ try {
325
+ this.transport.send(message);
326
+ } catch (error) {
327
+ this.pending.delete(id);
328
+ clearTimeout(timer);
329
+ reject(error);
330
+ }
331
+ return;
332
+ }
333
+
334
+ this.transport
335
+ .send(message)
336
+ .then((responses) => {
337
+ clearTimeout(timer);
338
+ const match = (responses || []).find((item) => item && item.id === id);
339
+ if (!match) {
340
+ reject(new Error(`${method} sem resposta do servidor`));
341
+ return;
342
+ }
343
+ if (match.error) {
344
+ reject(new Error(shorten(match.error.message || "erro no servidor")));
345
+ return;
346
+ }
347
+ resolve(match.result ?? {});
348
+ })
349
+ .catch(settle(reject));
350
+ });
351
+ }
352
+
353
+ notify(method, params = {}) {
354
+ return this.transport.send({ jsonrpc: "2.0", method, params }).catch(() => null);
355
+ }
356
+
357
+ async connect() {
358
+ if (this.server.disabled) return this;
359
+ this.status = "connecting";
360
+
361
+ try {
362
+ this.transport =
363
+ this.server.kind === "stdio"
364
+ ? new StdioTransport(this.server, {
365
+ onMessage: (message) => this.onMessage(message),
366
+ onExit: (reason) => this.fail(reason),
367
+ })
368
+ : new HttpTransport(this.server);
369
+
370
+ await this.transport.start();
371
+
372
+ const result = await this.request(
373
+ "initialize",
374
+ {
375
+ protocolVersion: PROTOCOL_VERSION,
376
+ capabilities: {},
377
+ clientInfo: { name: "kafox-cli", version: CLIENT_VERSION },
378
+ },
379
+ this.options.connectTimeoutMs
380
+ );
381
+
382
+ this.serverInfo = result?.serverInfo || null;
383
+ await this.notify("notifications/initialized");
384
+ this.tools = await this.fetchTools();
385
+ this.status = "connected";
386
+ this.error = null;
387
+ } catch (error) {
388
+ this.fail(error.message || String(error));
389
+ }
390
+ return this;
391
+ }
392
+
393
+ fail(reason) {
394
+ if (this.status === "error") return;
395
+ this.status = "error";
396
+ this.error = shorten(reason);
397
+ this.exitError = this.error;
398
+ for (const [id, entry] of this.pending) {
399
+ clearTimeout(entry.timer);
400
+ entry.reject(new Error(this.error));
401
+ this.pending.delete(id);
402
+ }
403
+ this.close();
404
+ }
405
+
406
+ async fetchTools() {
407
+ const tools = [];
408
+ let cursor = null;
409
+ for (let page = 0; page < 20; page += 1) {
410
+ const result = await this.request(
411
+ "tools/list",
412
+ cursor ? { cursor } : {},
413
+ this.options.connectTimeoutMs
414
+ );
415
+ for (const tool of result?.tools || []) {
416
+ if (tool?.name) tools.push(tool);
417
+ }
418
+ cursor = result?.nextCursor;
419
+ if (!cursor) break;
420
+ }
421
+ return tools;
422
+ }
423
+
424
+ async call(tool, args) {
425
+ if (this.status === "error") throw new Error(this.error || "servidor MCP com erro");
426
+ const result = await this.request("tools/call", { name: tool, arguments: args || {} });
427
+ return flattenResult(result);
428
+ }
429
+
430
+ close() {
431
+ try {
432
+ this.transport?.close();
433
+ } catch {}
434
+ }
435
+ }
436
+
437
+ function flattenResult(result) {
438
+ const parts = [];
439
+ for (const item of result?.content || []) {
440
+ if (item?.type === "text" && typeof item.text === "string") parts.push(item.text);
441
+ else if (typeof item?.text === "string") parts.push(item.text);
442
+ else if (item?.type) parts.push(`[${item.type}]`);
443
+ }
444
+ if (result?.structuredContent) {
445
+ try {
446
+ parts.push(JSON.stringify(result.structuredContent, null, 2));
447
+ } catch {}
448
+ }
449
+ const text = parts.join("\n").trim() || "(sem saida)";
450
+ return result?.isError ? `erro: ${text}` : text;
451
+ }
452
+
453
+ /* ------------------------------------------------------------------ agregador */
454
+
455
+ class McpHub {
456
+ constructor({ cwd = process.cwd(), servers = null, options = {} } = {}) {
457
+ const config = servers ? { servers, files: [] } : loadConfig(cwd);
458
+ this.cwd = cwd;
459
+ this.files = config.files;
460
+ this.clients = new Map();
461
+ this.toolMap = new Map();
462
+ for (const [name, raw] of Object.entries(config.servers)) {
463
+ const server = normalizeServer(name, raw);
464
+ if (!server) continue;
465
+ this.clients.set(name, new McpClient(server, options));
466
+ }
467
+ }
468
+
469
+ get size() {
470
+ return this.clients.size;
471
+ }
472
+
473
+ async connectAll() {
474
+ await Promise.all([...this.clients.values()].map((client) => client.connect()));
475
+ this.rebuild();
476
+ return this;
477
+ }
478
+
479
+ rebuild() {
480
+ this.toolMap.clear();
481
+ for (const client of this.clients.values()) {
482
+ for (const tool of client.tools) {
483
+ this.toolMap.set(toolName(client.server.name, tool.name), {
484
+ server: client.server.name,
485
+ tool: tool.name,
486
+ client,
487
+ });
488
+ }
489
+ }
490
+ }
491
+
492
+ /** Especificacoes no formato OpenAI para mandar ao modelo. */
493
+ specs() {
494
+ const specs = [];
495
+ for (const [name, entry] of this.toolMap) {
496
+ const tool = entry.client.tools.find((item) => item.name === entry.tool) || {};
497
+ specs.push({
498
+ type: "function",
499
+ function: {
500
+ name,
501
+ description: shorten(
502
+ tool.description || `Ferramenta "${entry.tool}" do servidor MCP "${entry.server}".`,
503
+ 900
504
+ ),
505
+ parameters:
506
+ tool.inputSchema && typeof tool.inputSchema === "object"
507
+ ? tool.inputSchema
508
+ : { type: "object", properties: {} },
509
+ },
510
+ });
511
+ }
512
+ return specs;
513
+ }
514
+
515
+ has(name) {
516
+ return this.toolMap.has(name);
517
+ }
518
+
519
+ describe(name) {
520
+ const entry = this.toolMap.get(name);
521
+ if (!entry) return null;
522
+ return { server: entry.server, tool: entry.tool };
523
+ }
524
+
525
+ async call(name, args) {
526
+ const entry = this.toolMap.get(name);
527
+ if (!entry) throw new Error(`ferramenta MCP desconhecida: ${name}`);
528
+ return entry.client.call(entry.tool, args);
529
+ }
530
+
531
+ statuses() {
532
+ return [...this.clients.values()].map((client) => ({
533
+ name: client.server.name,
534
+ kind: client.server.kind,
535
+ status: client.status,
536
+ error: client.error,
537
+ tools: client.tools.length,
538
+ toolNames: client.tools.map((tool) => toolName(client.server.name, tool.name)),
539
+ serverInfo: client.serverInfo,
540
+ }));
541
+ }
542
+
543
+ close() {
544
+ for (const client of this.clients.values()) client.close();
545
+ }
546
+ }
547
+
548
+ module.exports = {
549
+ McpHub,
550
+ McpClient,
551
+ loadConfig,
552
+ configPaths,
553
+ normalizeServer,
554
+ toolName,
555
+ flattenResult,
556
+ parseSse,
557
+ };
package/src/models.js CHANGED
@@ -16,12 +16,9 @@ async function listModels() {
16
16
  const data = await res.json();
17
17
  console.log(chalk.cyan("\nKafox models:\n"));
18
18
  for (const p of data.pricing) {
19
- const inp = (p.input_micros_per_1m / 1_000_000).toFixed(2);
20
- const out = (p.output_micros_per_1m / 1_000_000).toFixed(2);
21
19
  console.log(` ${chalk.bold(p.model)}`);
22
- console.log(` in: ${inp} R$/1M out: ${out} R$/1M`);
23
20
  }
24
- console.log(`\nGateway: ${chalk.dim(data.gateway_url)}`);
21
+ console.log(chalk.dim(`\nGateway: ${data.gateway_url}`));
25
22
  }
26
23
 
27
24
  async function showBalance() {
package/src/runner.js ADDED
@@ -0,0 +1,98 @@
1
+ const chalk = require("chalk");
2
+ const { runAgent } = require("./agent");
3
+
4
+ /**
5
+ * Roda o agente sem TUI: serve para script, CI e para usar o kafox como
6
+ * "agente auditor" de outro processo.
7
+ *
8
+ * kafox agent "revise src/x.js e aponte bugs" [-m modelo] [-y] [--max-steps 12]
9
+ *
10
+ * Sem --max-steps o agente roda ate terminar (Ctrl+C aborta).
11
+ */
12
+ async function runHeadless({
13
+ prompt,
14
+ model,
15
+ allowAll = false,
16
+ maxSteps = Infinity,
17
+ cwd = process.cwd(),
18
+ quiet = false,
19
+ json = false,
20
+ } = {}) {
21
+ if (!prompt || !String(prompt).trim()) {
22
+ throw new Error("informe o objetivo do agente");
23
+ }
24
+
25
+ const controller = new AbortController();
26
+ const abort = () => controller.abort();
27
+ process.once("SIGINT", abort);
28
+ process.once("SIGTERM", abort);
29
+
30
+ const log = (line) => {
31
+ if (!quiet && !json) process.stderr.write(`${line}\n`);
32
+ };
33
+
34
+ const events = [];
35
+ const denied = [];
36
+ const subagents = [];
37
+
38
+ const result = await runAgent({
39
+ messages: [{ role: "user", content: String(prompt) }],
40
+ model,
41
+ cwd,
42
+ maxSteps,
43
+ signal: controller.signal,
44
+ autoApprove: () => allowAll,
45
+ approve: async (name, args, context) => {
46
+ if (allowAll) return true;
47
+ denied.push(name);
48
+ log(chalk.dim(`[${name}] negado (use --allow-all para liberar)`));
49
+ return false;
50
+ },
51
+ onText: (chunk) => {
52
+ if (!json) process.stdout.write(chunk);
53
+ },
54
+ onToolStart: ({ name, label, subagent }) => {
55
+ events.push({ type: "tool-start", name, label });
56
+ log(
57
+ chalk.dim(
58
+ `[${name}] ${String(label || "").slice(0, 140)}${
59
+ subagent ? ` (${subagent.count} subagente(s))` : ""
60
+ }`
61
+ )
62
+ );
63
+ },
64
+ onToolResult: ({ name, ok, ms, summary }) => {
65
+ events.push({ type: "tool-result", name, ok, ms });
66
+ log(chalk.dim(`[${name}] ${ok ? "ok" : "falhou"} · ${ms}ms · ${String(summary || "").slice(0, 120)}`));
67
+ },
68
+ onSubagentEvent: (event) => {
69
+ if (event.phase === "start") {
70
+ subagents.push({ objective: event.objective, tools: event.tools });
71
+ log(chalk.dim(`[subagente ${event.index}/${event.total}] ${event.objective}`));
72
+ } else if (event.phase === "tool") {
73
+ log(chalk.dim(`[subagente ${event.index}/${event.total}] ${event.tool}: ${String(event.text || "").slice(0, 100)}`));
74
+ } else if (event.phase === "done") {
75
+ log(chalk.dim(`[subagente ${event.index}/${event.total}] ${event.ok ? "concluído" : "falhou"} em ${event.ms}ms`));
76
+ }
77
+ },
78
+ });
79
+
80
+ if (!json && !quiet && result.text) process.stdout.write("\n");
81
+
82
+ const payload = {
83
+ ok: Boolean(result.ok),
84
+ text: result.text || "",
85
+ steps: result.steps || 0,
86
+ ms: result.ms || 0,
87
+ error: result.error || null,
88
+ aborted: Boolean(result.aborted),
89
+ tools: events,
90
+ subagents,
91
+ denied,
92
+ };
93
+ if (json) process.stdout.write(`${JSON.stringify(payload)}\n`);
94
+
95
+ return payload;
96
+ }
97
+
98
+ module.exports = { runHeadless };