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/auth.js CHANGED
@@ -20,12 +20,11 @@ async function ensureSession() {
20
20
  return null;
21
21
  }
22
22
 
23
+ /**
24
+ * Login por device code. Com TTY abre a tela cheia (logo grande, link no meio);
25
+ * sem TTY (pipe/CI) mantem o fluxo de texto, que continua funcionando.
26
+ */
23
27
  async function login() {
24
- console.log(MASCOT);
25
- console.log(
26
- chalk.cyan(" Kafox CLI - login by device code (SSO)\n")
27
- );
28
-
29
28
  const res = await fetchApi("/api/cli/device", {
30
29
  method: "POST",
31
30
  body: "{}",
@@ -36,10 +35,15 @@ async function login() {
36
35
  return;
37
36
  }
38
37
 
39
- console.log(
40
- chalk.bold("\n Open this URL in your browser to approve:\n") +
41
- chalk.cyan(` ${storage.getApiUrl()}/cli?code=${data.user_code}\n`)
42
- );
38
+ const url = `${storage.getApiUrl()}/cli?code=${data.user_code}`;
39
+
40
+ if (process.stdout.isTTY && process.stdin.isTTY) {
41
+ return loginWithScreen({ url, data });
42
+ }
43
+
44
+ console.log(MASCOT);
45
+ console.log(chalk.cyan(" Kafox CLI - login by device code (SSO)\n"));
46
+ console.log(chalk.bold(" Open this URL in your browser to approve:\n") + chalk.cyan(` ${url}\n`));
43
47
  console.log(` Code: ${chalk.bold(data.user_code)} (expires in ${data.expires_in}s)\n`);
44
48
 
45
49
  const interval = Math.max(1, data.interval) * 1000;
@@ -50,32 +54,75 @@ async function login() {
50
54
  process.stdout.write(`\r Waiting for approval${".".repeat(dots % 4)} `);
51
55
  dots += 1;
52
56
  await new Promise((r) => setTimeout(r, interval));
53
- try {
54
- const poll = await fetchApi(`/api/cli/poll/${encodeURIComponent(data.device_code)}`);
55
- const pd = await poll.json().catch(() => ({}));
56
- if (poll.status === 200 && pd.status === "approved") {
57
- const previous = storage.getToken() || {};
58
- storage.setToken({ session: pd.session, csrf: previous.csrf });
59
- console.log(
60
- chalk.green("\n Approved! Session saved to ~/.kafox/token.json")
61
- );
62
- return;
63
- }
64
- if (pd.status === "denied" || poll.status === 403) {
65
- console.log(chalk.red("\n Login denied."));
66
- return;
67
- }
68
- if (poll.status === 404 || poll.status === 410) {
69
- console.log(chalk.red("\n Device code expired. Run `kafox login` again."));
70
- return;
71
- }
72
- } catch {
73
- // ignore transient errors, keep polling
57
+ const outcome = await pollDevice(data);
58
+ if (outcome.ok) {
59
+ console.log(chalk.green("\n Approved! Session saved to ~/.kafox/token.json"));
60
+ return;
61
+ }
62
+ if (outcome.stop) {
63
+ console.log(chalk.red(`\n ${outcome.error}`));
64
+ return;
74
65
  }
75
66
  }
76
67
  console.log(chalk.red("\n Device code expired. Run `kafox login` again."));
77
68
  }
78
69
 
70
+ /** Uma consulta do device code; grava a sessao quando aprovada. */
71
+ async function pollDevice(data) {
72
+ let poll;
73
+ try {
74
+ poll = await fetchApi(`/api/cli/poll/${encodeURIComponent(data.device_code)}`);
75
+ } catch {
76
+ return { ok: false, stop: false };
77
+ }
78
+ const pd = await poll.json().catch(() => ({}));
79
+ if (poll.status === 200 && pd.status === "approved") {
80
+ const previous = storage.getToken() || {};
81
+ storage.setToken({ session: pd.session, csrf: previous.csrf });
82
+ return { ok: true, stop: true };
83
+ }
84
+ if (pd.status === "denied" || poll.status === 403) {
85
+ return { ok: false, stop: true, error: "Login denied." };
86
+ }
87
+ if (poll.status === 404 || poll.status === 410) {
88
+ return { ok: false, stop: true, error: "Device code expired. Run `kafox login` again." };
89
+ }
90
+ return { ok: false, stop: false };
91
+ }
92
+
93
+ async function loginWithScreen({ url, data }) {
94
+ const { runLoginScreen } = require("./login-screen");
95
+ const { version } = require("../package.json");
96
+ const theme = storage.getConfig().theme || "default";
97
+
98
+ const result = await runLoginScreen({
99
+ url,
100
+ code: data.user_code,
101
+ interval: data.interval,
102
+ expiresIn: data.expires_in,
103
+ theme,
104
+ version,
105
+ poll: async () => {
106
+ const outcome = await pollDevice(data);
107
+ if (outcome.ok) return { ok: true };
108
+ if (outcome.stop) return { ok: false, error: outcome.error };
109
+ return { ok: false };
110
+ },
111
+ });
112
+
113
+ if (result.ok) {
114
+ console.log(chalk.green(" Aprovado! Sessao salva em ~/.kafox/token.json"));
115
+ console.log(chalk.dim(" Bom proveito.\n"));
116
+ return true;
117
+ }
118
+ if (result.cancelled) {
119
+ console.log(chalk.dim(" Login cancelado.\n"));
120
+ return false;
121
+ }
122
+ console.log(chalk.red(` ${result.error || "Nao foi possivel entrar"}.\n`));
123
+ return false;
124
+ }
125
+
79
126
  async function logout() {
80
127
  storage.clearToken();
81
128
  console.log(chalk.green(" Session ended."));
package/src/cli.js CHANGED
@@ -1,19 +1,23 @@
1
1
  const readline = require("readline");
2
+ const os = require("os");
3
+ const path = require("path");
2
4
  const inquirer = require("inquirer");
3
5
  const chalk = require("chalk");
4
6
  const storage = require("./storage");
5
7
  const { ensureSession, fetchApi } = require("./auth");
6
8
  const { ask } = require("./gateway");
7
9
  const { runTUI } = require("./tui");
8
- const { runTheme, setContentBackground, paint } = require("./theme");
9
- const { mascot } = require("./mascot");
10
+ const { runTheme, setContentBackground } = require("./theme");
11
+ const { logoText, logoLines } = require("./logo");
12
+
13
+ const mascot = logoText();
10
14
 
11
15
  const { version: VERSION } = require("../package.json");
12
16
 
13
17
  const COMMANDS = {
14
18
  "/help": "show this help",
15
- "/models": "list models and prices",
16
- "/model": "choose the default model (picker)",
19
+ "/models": "escolhe o modelo (setas, mouse ou Enter)",
20
+ "/model": "escolhe o modelo no seletor, ou direto com /model <id>",
17
21
  "/balance": "show balance",
18
22
  "/pricing": "show recharge packages",
19
23
  "/key": "create an API key",
@@ -21,6 +25,9 @@ const COMMANDS = {
21
25
  "/logout": "end the session",
22
26
  "/whoami": "show the linked account",
23
27
  "/theme": "open theme selector",
28
+ "/agents": "lista os subagentes desta sessao (objetivo, ferramentas, tempo)",
29
+ "/mcp": "list MCP servers and tools (/mcp reload reconecta)",
30
+ "/allow": "aprovacao automatica nesta sessao (/allow off volta a perguntar)",
24
31
  "/background": "set background image (modal asks for URL)",
25
32
  "/clear": "clear conversation context",
26
33
  "/compact": "compact conversation context",
@@ -45,6 +52,13 @@ async function handleCommand(line) {
45
52
  for (const [key, value] of Object.entries(COMMANDS)) {
46
53
  console.log(` ${chalk.bold(key)} ${chalk.dim(value)}`);
47
54
  }
55
+ console.log(chalk.cyan("\nKeys:\n"));
56
+ console.log(` ${chalk.bold("Enter")} ${chalk.dim("manda a mensagem; durante a resposta ela entra na fila")}`);
57
+ console.log(` ${chalk.bold("Esc Esc")} ${chalk.dim("para a IA na hora (o primeiro Esc avisa)")}`);
58
+ console.log(` ${chalk.bold("PgUp/PgDn")} ${chalk.dim("ou a roda do mouse rola a conversa")}`);
59
+ console.log(` ${chalk.bold("Ctrl+Y")} ${chalk.dim("copia a ultima resposta para a area de transferencia")}`);
60
+ console.log(` ${chalk.bold("F2")} ${chalk.dim("solta o mouse para selecionar texto com o terminal")}`);
61
+ console.log(` ${chalk.bold("Ctrl+C")} ${chalk.dim("sai")}`);
48
62
  console.log("");
49
63
  return true;
50
64
  case "/models": {
@@ -94,6 +108,61 @@ async function handleCommand(line) {
94
108
  case "/theme":
95
109
  await runTheme();
96
110
  return true;
111
+ case "/allow": {
112
+ console.log(
113
+ chalk.dim(
114
+ "Na interface, /allow liga e /allow off desliga a aprovacao automatica.\n" +
115
+ "Para ja abrir assim: kafox --allow-all (ou KAFOX_ALLOW_ALL=1)."
116
+ )
117
+ );
118
+ return true;
119
+ }
120
+ case "/agents": {
121
+ const { listRuns } = require("./subagents");
122
+ const runs = listRuns();
123
+ if (!runs.length) {
124
+ console.log(chalk.dim("\nNenhum subagente nesta sessao."));
125
+ console.log(
126
+ chalk.dim(
127
+ "O agente abre subagentes sozinho quando dividir ajuda: objetivo proprio,\n" +
128
+ "ate 3 em paralelo, cada um em contexto separado. Para pedir na mao:\n" +
129
+ ' "lanca dois subagentes: um revisa src/agent.js e outro roda os testes do backend"'
130
+ )
131
+ );
132
+ return true;
133
+ }
134
+ console.log(chalk.cyan(`\nSubagentes desta sessao (${runs.length}):\n`));
135
+ for (const run of runs) {
136
+ const status = run.ok ? chalk.green("ok") : chalk.red("falhou");
137
+ console.log(` ${status} ${chalk.bold(run.objective.slice(0, 90))}`);
138
+ console.log(
139
+ chalk.dim(
140
+ ` ${run.tools.join(", ") || "read_file"} · ${run.steps} passo(s) · ${Math.round(run.ms / 1000)}s`
141
+ )
142
+ );
143
+ }
144
+ console.log("");
145
+ return true;
146
+ }
147
+ case "/mcp": {
148
+ const { McpHub } = require("./mcp");
149
+ const hub = new McpHub({ cwd: process.cwd() });
150
+ const statuses = hub.statuses();
151
+ hub.close();
152
+ if (!statuses.length) {
153
+ console.log(chalk.dim("\nNenhum servidor MCP configurado."));
154
+ const file = path.join(os.homedir(), ".kafox", "mcp.json");
155
+ console.log(chalk.dim(`Crie ${file} com:`));
156
+ console.log(chalk.dim('{ "mcp": { "playwright": { "type": "local", "command": ["npx","-y","@playwright/mcp@latest"] } } }'));
157
+ return true;
158
+ }
159
+ console.log(chalk.cyan("\nServidores MCP:\n"));
160
+ for (const status of statuses) {
161
+ console.log(` ${chalk.bold(status.name)} ${chalk.dim(status.kind)}`);
162
+ }
163
+ console.log(chalk.dim("\nAbra a TUI (kafox) e use /mcp para conectar e ver as ferramentas."));
164
+ return true;
165
+ }
97
166
  case "/background":
98
167
  await setContentBackground(arg || null);
99
168
  return true;
@@ -168,8 +237,8 @@ async function runPlainREPL() {
168
237
  const cfg = storage.getConfig();
169
238
  const theme = cfg.theme || "default";
170
239
 
171
- console.log(paint(theme, mascot));
172
- console.log(paint(theme, `\n Kafox CLI v${VERSION} - opencode style`));
240
+ console.log(logoLines(theme).join("\n"));
241
+ console.log(chalk.bold(`\n Kafox CLI v${VERSION}`));
173
242
  console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
174
243
 
175
244
  const token = await ensureSession().catch(() => null);
@@ -224,7 +293,7 @@ async function runPlainREPL() {
224
293
  });
225
294
  }
226
295
 
227
- async function runREPL() {
296
+ async function runREPL(options = {}) {
228
297
  // Sem TTY (pipe/CI), mantem o REPL linha a linha.
229
298
  if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL();
230
299
 
@@ -232,8 +301,8 @@ async function runREPL() {
232
301
  const token = await ensureSession().catch(() => null);
233
302
 
234
303
  if (!token) {
235
- console.log(paint(cfg.theme || "default", mascot));
236
- console.log(paint(cfg.theme || "default", `\n Kafox CLI v${VERSION}`));
304
+ console.log(logoLines(cfg.theme || "default").join("\n"));
305
+ console.log(chalk.bold(`\n Kafox CLI v${VERSION}`));
237
306
  console.log(chalk.yellow(" You are not logged in."));
238
307
  const { doLogin } = await inquirer.prompt([
239
308
  { type: "confirm", name: "doLogin", message: "Login now?", default: true },
@@ -246,7 +315,11 @@ async function runREPL() {
246
315
  return runTUI({
247
316
  commands: COMMANDS,
248
317
  onCommand: handleCommand,
249
- interactiveCommands: new Set(["/login", "/theme", "/key", "/background"]),
318
+ // /theme roda dentro da TUI (modal com preview); os demais abrem prompt no terminal.
319
+ interactiveCommands: new Set(["/login", "/key", "/background"]),
320
+ autoApprove: Boolean(options?.allowAll),
321
+ // kafox --resume <codigo> volta para a conversa salva em ~/.kafox/chats.
322
+ resume: options?.resume || null,
250
323
  });
251
324
  }
252
325
 
package/src/diff.js ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Diff de linhas para o preview de edicao (estilo `git diff`, enxuto).
3
+ *
4
+ * Usa LCS quando os trechos sao pequenos e cai para um resumo grosseiro em
5
+ * arquivos grandes, para nao travar a TUI.
6
+ */
7
+ const MAX_LCS_LINES = 700;
8
+
9
+ function splitLines(text) {
10
+ return String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
11
+ }
12
+
13
+ function commonPrefix(a, b) {
14
+ const limit = Math.min(a.length, b.length);
15
+ let index = 0;
16
+ while (index < limit && a[index] === b[index]) index += 1;
17
+ return index;
18
+ }
19
+
20
+ function commonSuffix(a, b, prefix) {
21
+ const limit = Math.min(a.length, b.length) - prefix;
22
+ let count = 0;
23
+ while (count < limit && a[a.length - 1 - count] === b[b.length - 1 - count]) count += 1;
24
+ return count;
25
+ }
26
+
27
+ /** LCS classico: devolve as operacoes (del/add) do trecho que mudou. */
28
+ function lcsOps(left, right) {
29
+ const rows = left.length;
30
+ const cols = right.length;
31
+ const table = new Uint16Array((rows + 1) * (cols + 1));
32
+ const width = cols + 1;
33
+
34
+ for (let i = rows - 1; i >= 0; i -= 1) {
35
+ for (let j = cols - 1; j >= 0; j -= 1) {
36
+ table[i * width + j] =
37
+ left[i] === right[j]
38
+ ? table[(i + 1) * width + j + 1] + 1
39
+ : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
40
+ }
41
+ }
42
+
43
+ const ops = [];
44
+ let i = 0;
45
+ let j = 0;
46
+ while (i < rows && j < cols) {
47
+ if (left[i] === right[j]) {
48
+ ops.push({ type: "ctx", text: left[i] });
49
+ i += 1;
50
+ j += 1;
51
+ } else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
52
+ ops.push({ type: "del", text: left[i] });
53
+ i += 1;
54
+ } else {
55
+ ops.push({ type: "add", text: right[j] });
56
+ j += 1;
57
+ }
58
+ }
59
+ while (i < rows) ops.push({ type: "del", text: left[i++] });
60
+ while (j < cols) ops.push({ type: "add", text: right[j++] });
61
+ return ops;
62
+ }
63
+
64
+ function coarseOps(left, right) {
65
+ return [
66
+ ...left.map((text) => ({ type: "del", text })),
67
+ ...right.map((text) => ({ type: "add", text })),
68
+ ];
69
+ }
70
+
71
+ /**
72
+ * Todas as operacoes, com numero de linha nos dois lados.
73
+ * `truncated` avisa que o miolo foi tratado de forma grosseira.
74
+ */
75
+ function buildOps(before, after) {
76
+ const left = splitLines(before);
77
+ const right = splitLines(after);
78
+ const prefix = commonPrefix(left, right);
79
+ const suffix = commonSuffix(left, right, prefix);
80
+
81
+ const leftMiddle = left.slice(prefix, left.length - suffix);
82
+ const rightMiddle = right.slice(prefix, right.length - suffix);
83
+ const truncated = leftMiddle.length > MAX_LCS_LINES || rightMiddle.length > MAX_LCS_LINES;
84
+ const middle = truncated ? coarseOps(leftMiddle, rightMiddle) : lcsOps(leftMiddle, rightMiddle);
85
+
86
+ const ops = [];
87
+ for (let index = 0; index < prefix; index += 1) {
88
+ ops.push({ type: "ctx", text: left[index], beforeNo: index + 1, afterNo: index + 1 });
89
+ }
90
+
91
+ let beforeNo = prefix + 1;
92
+ let afterNo = prefix + 1;
93
+ let changed = false;
94
+ for (const op of middle) {
95
+ if (op.type === "ctx") {
96
+ ops.push({ ...op, beforeNo, afterNo });
97
+ beforeNo += 1;
98
+ afterNo += 1;
99
+ } else if (op.type === "del") {
100
+ ops.push({ ...op, beforeNo });
101
+ beforeNo += 1;
102
+ changed = true;
103
+ } else {
104
+ ops.push({ ...op, afterNo });
105
+ afterNo += 1;
106
+ changed = true;
107
+ }
108
+ }
109
+
110
+ for (let index = 0; index < suffix; index += 1) {
111
+ const line = left.length - suffix + index;
112
+ ops.push({
113
+ type: "ctx",
114
+ text: left[line],
115
+ beforeNo: line + 1,
116
+ afterNo: right.length - suffix + index + 1,
117
+ });
118
+ }
119
+
120
+ return { ops, truncated, changed };
121
+ }
122
+
123
+ function count(lines) {
124
+ return {
125
+ added: lines.filter((line) => line.type === "add").length,
126
+ removed: lines.filter((line) => line.type === "del").length,
127
+ };
128
+ }
129
+
130
+ /** Mantem so as mudancas e `context` linhas em volta (com o indice original). */
131
+ function keptWithContext(ops, context) {
132
+ const keep = new Set();
133
+ ops.forEach((op, index) => {
134
+ if (op.type === "ctx") return;
135
+ for (let cursor = index - context; cursor <= index + context; cursor += 1) {
136
+ if (cursor >= 0 && cursor < ops.length) keep.add(cursor);
137
+ }
138
+ });
139
+ return ops.map((op, index) => ({ op, index })).filter((entry) => keep.has(entry.index));
140
+ }
141
+
142
+ /**
143
+ * Diff enxuto, com numeros de linha e `context` linhas de contexto.
144
+ * Arquivo identico devolve lista vazia.
145
+ */
146
+ function diffLines(before, after, { context = 2 } = {}) {
147
+ const { ops, truncated, changed } = buildOps(before, after);
148
+ if (!changed) return { lines: [], added: 0, removed: 0, truncated: false };
149
+ const lines = keptWithContext(ops, context).map((entry) => entry.op);
150
+ return { lines, ...count(lines), truncated };
151
+ }
152
+
153
+ /**
154
+ * Diff agrupado em blocos separados (cada regiao que mudou vira um bloco),
155
+ * limitado a `maxLines` linhas para caber no preview.
156
+ */
157
+ function diffBlocks(before, after, { context = 2, maxLines = 60 } = {}) {
158
+ const { ops, truncated, changed } = buildOps(before, after);
159
+ if (!changed) return { blocks: [], added: 0, removed: 0, truncated: false, total: 0 };
160
+
161
+ const kept = keptWithContext(ops, context);
162
+ const blocks = [];
163
+ let current = null;
164
+ let previousIndex = null;
165
+
166
+ for (const entry of kept) {
167
+ if (previousIndex == null || entry.index !== previousIndex + 1) {
168
+ current = [];
169
+ blocks.push(current);
170
+ }
171
+ current.push(entry.op);
172
+ previousIndex = entry.index;
173
+ }
174
+
175
+ let budget = maxLines;
176
+ const limited = [];
177
+ for (const block of blocks) {
178
+ if (budget <= 0) break;
179
+ const slice = block.slice(0, budget);
180
+ limited.push(slice);
181
+ budget -= slice.length;
182
+ if (slice.length < block.length) limited.push([{ type: "gap", text: "..." }]);
183
+ }
184
+
185
+ return {
186
+ blocks: limited,
187
+ ...count(kept.map((entry) => entry.op)),
188
+ truncated,
189
+ total: blocks.length,
190
+ };
191
+ }
192
+
193
+ module.exports = { diffLines, diffBlocks, splitLines, lcsOps, buildOps };