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/gateway.js CHANGED
@@ -3,26 +3,20 @@ const storage = require("./storage");
3
3
  const { ensureSession, fetchApi } = require("./auth");
4
4
 
5
5
  /**
6
- * Envia a pergunta ao gateway e devolve o resultado estruturado.
7
- * A TUI usa o retorno; o comando `kafox ask` so imprime.
6
+ * Garante sessao, saldo, modelo e chave antes de qualquer chamada.
7
+ * A chave `kafox_live_...` e da propria conta: o gateway cobra por ela.
8
8
  */
9
- async function askModel(question, options = {}) {
10
- const text = typeof question === "string" ? question : (question || []).join(" ");
11
- if (!text.trim()) {
12
- return { ok: false, error: 'Usage: kafox ask "your question" [-m model]' };
13
- }
14
-
9
+ async function ensureReady(options = {}) {
15
10
  const token = await ensureSession();
16
11
  if (!token) {
17
12
  return { ok: false, error: "Login first: `kafox login`" };
18
13
  }
19
14
 
20
- // Saldo decide se a CLI responde: sem credito, nem chega no gateway.
21
15
  const boot = await fetchApi("/api/bootstrap");
22
16
  if (!boot.ok) {
23
17
  return { ok: false, error: "Session invalid. Run `kafox login`" };
24
18
  }
25
- const account = await boot.json();
19
+ const account = await boot.json().catch(() => null);
26
20
  const balance = Number(account?.user?.balance_micros || 0);
27
21
  if (balance <= 0) {
28
22
  return {
@@ -38,9 +32,7 @@ async function askModel(question, options = {}) {
38
32
  .then((response) => response.json())
39
33
  .catch(() => null);
40
34
  model = pricing?.pricing?.[0]?.model;
41
- if (!model) {
42
- return { ok: false, error: "Could not load model catalog." };
43
- }
35
+ if (!model) return { ok: false, error: "Could not load model catalog." };
44
36
  }
45
37
 
46
38
  let keyCreated = false;
@@ -59,51 +51,237 @@ async function askModel(question, options = {}) {
59
51
  keyCreated = true;
60
52
  }
61
53
 
62
- const response = await fetchApi("/api/public/playground", {
63
- method: "POST",
64
- body: JSON.stringify({ api_key: apiKey, model, message: text }),
54
+ return { ok: true, model, apiKey, keyCreated, balance };
55
+ }
56
+
57
+ function mergeToolCalls(target, deltas) {
58
+ for (const delta of deltas) {
59
+ const index = Number.isInteger(delta?.index) ? delta.index : target.length;
60
+ const current = target[index] || {
61
+ id: "",
62
+ type: "function",
63
+ function: { name: "", arguments: "" },
64
+ };
65
+ if (delta.id) current.id = delta.id;
66
+ if (delta.type) current.type = delta.type;
67
+ if (delta.function?.name) current.function.name += delta.function.name;
68
+ if (delta.function?.arguments) current.function.arguments += delta.function.arguments;
69
+ target[index] = current;
70
+ }
71
+ }
72
+
73
+ /* ---------------------------------------------------------------- retry */
74
+
75
+ // Rede do provedor cai e o fetch devolve "fetch failed": em vez de morrer na
76
+ // primeira tentativa, espera e tenta de novo dobrando o intervalo.
77
+ const RETRY_MAX = 20;
78
+ const RETRY_BASE_MS = Number(process.env.KAFOX_RETRY_BASE_MS) || 3000;
79
+ const RETRY_MAX_MS = Number(process.env.KAFOX_RETRY_MAX_MS) || 60_000;
80
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 521, 522, 523, 524]);
81
+
82
+ /** Espera do retry: 3s, 6s, 12s, 24s... ate 60s. */
83
+ function backoffMs(attempt) {
84
+ return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1));
85
+ }
86
+
87
+ /** Vale a pena tentar de novo? Erro de rede, timeout e 5xx sim; 4xx de
88
+ * conteudo (saldo, chave, modelo) nao adianta repetir. */
89
+ function retryableMessage(message) {
90
+ return /fetch failed|failed to fetch|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|socket hang up|network|stream interrompido|terminated|other side closed/i.test(
91
+ String(message || "")
92
+ );
93
+ }
94
+
95
+ /** Dorme respeitando o abort: o Esc mata a espera na hora. */
96
+ function wait(ms, signal) {
97
+ return new Promise((resolve) => {
98
+ if (signal?.aborted) return resolve(false);
99
+ const done = (value) => {
100
+ clearTimeout(timer);
101
+ signal?.removeEventListener?.("abort", onAbort);
102
+ resolve(value);
103
+ };
104
+ const timer = setTimeout(() => done(true), ms);
105
+ timer.unref?.();
106
+ function onAbort() {
107
+ done(false);
108
+ }
109
+ signal?.addEventListener?.("abort", onAbort, { once: true });
65
110
  });
66
- const data = await response.json().catch(() => ({}));
111
+ }
112
+
113
+ function errorFrom(data, status) {
114
+ const message =
115
+ data?.error?.message || data?.error || data?.body?.error?.message || `gateway error (${status})`;
116
+ return {
117
+ ok: false,
118
+ code: data?.code || (status === 402 ? "no_balance" : "gateway_error"),
119
+ error: String(message),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Uma tentativa: request + leitura do stream.
125
+ * Devolve `retryable` quando o erro foi de rede/servidor e ainda nao veio texto.
126
+ */
127
+ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
128
+ const ready = await ensureReady({ model });
129
+ if (!ready.ok) return ready;
130
+
131
+ const started = Date.now();
132
+ let response;
133
+ try {
134
+ response = await fetchApi("/api/chat", {
135
+ method: "POST",
136
+ headers: { accept: "text/event-stream" },
137
+ body: JSON.stringify({
138
+ api_key: ready.apiKey,
139
+ model: ready.model,
140
+ messages,
141
+ tools: tools && tools.length ? tools : undefined,
142
+ stream: true,
143
+ }),
144
+ signal,
145
+ });
146
+ } catch (error) {
147
+ if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
148
+ return {
149
+ ok: false,
150
+ error: `Falha de rede: ${error.message}`,
151
+ retryable: retryableMessage(error.message),
152
+ keyCreated: ready.keyCreated,
153
+ };
154
+ }
67
155
 
68
156
  if (!response.ok) {
69
- if (response.status === 402) {
70
- return {
71
- ok: false,
72
- code: "no_balance",
73
- error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
74
- };
75
- }
76
- return { ok: false, error: `Failed: ${data.error || "gateway error"}` };
157
+ const data = await response.json().catch(() => ({}));
158
+ return {
159
+ ...errorFrom(data, response.status),
160
+ status: response.status,
161
+ keyCreated: ready.keyCreated,
162
+ retryable: RETRYABLE_STATUS.has(response.status),
163
+ };
77
164
  }
78
165
 
79
- // O playground devolve 200 mesmo quando o gateway recusa a chamada.
80
- if (data.ok === false) {
81
- const detail = data.body?.error?.message || data.body?.error || "gateway error";
82
- if (data.status === 402) {
83
- return {
84
- ok: false,
85
- code: "no_balance",
86
- error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
87
- };
166
+ const decoder = new TextDecoder();
167
+ let buffer = "";
168
+ let text = "";
169
+ let reasoning = "";
170
+ let usage = null;
171
+ const toolCalls = [];
172
+
173
+ const handleLine = (line) => {
174
+ if (!line.startsWith("data:")) return null;
175
+ const data = line.slice(5).trim();
176
+ if (!data || data === "[DONE]") return null;
177
+ let event;
178
+ try {
179
+ event = JSON.parse(data);
180
+ } catch {
181
+ return null;
88
182
  }
89
- return { ok: false, error: `Failed (${data.status}): ${detail}` };
183
+ if (event?.error) {
184
+ const message = event.error.message || String(event.error);
185
+ return { error: message, retryable: retryableMessage(message) };
186
+ }
187
+ if (event?.usage) usage = event.usage;
188
+ const delta = event?.choices?.[0]?.delta;
189
+ if (!delta) return null;
190
+ // Cada provedor manda o "pensamento" com um nome diferente.
191
+ const think = delta.reasoning_content ?? delta.reasoning ?? delta.thinking;
192
+ if (typeof think === "string" && think) {
193
+ reasoning += think;
194
+ onReasoning?.(think);
195
+ }
196
+ if (delta.content) {
197
+ text += delta.content;
198
+ onDelta?.(delta.content);
199
+ }
200
+ if (Array.isArray(delta.tool_calls)) mergeToolCalls(toolCalls, delta.tool_calls);
201
+ return null;
202
+ };
203
+
204
+ try {
205
+ for await (const chunk of response.body) {
206
+ buffer += decoder.decode(chunk, { stream: true });
207
+ let index = buffer.indexOf("\n");
208
+ while (index >= 0) {
209
+ const line = buffer.slice(0, index).replace(/\r$/, "");
210
+ buffer = buffer.slice(index + 1);
211
+ const failure = handleLine(line);
212
+ if (failure) return { ok: false, error: failure.error, retryable: failure.retryable };
213
+ index = buffer.indexOf("\n");
214
+ }
215
+ }
216
+ if (buffer.trim()) handleLine(buffer.trim());
217
+ } catch (error) {
218
+ if (error?.name === "AbortError") {
219
+ return { ok: true, text, reasoning, toolCalls, ms: Date.now() - started, aborted: true };
220
+ }
221
+ return {
222
+ ok: false,
223
+ error: `Stream interrompido: ${error.message}`,
224
+ text,
225
+ retryable: retryableMessage(error.message),
226
+ };
227
+ }
228
+
229
+ return {
230
+ ok: true,
231
+ text,
232
+ reasoning,
233
+ toolCalls: toolCalls.filter(Boolean),
234
+ usage,
235
+ ms: Date.now() - started,
236
+ model: ready.model,
237
+ keyCreated: ready.keyCreated,
238
+ provider: response.headers?.get?.("x-kafox-provider") || "",
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Chat completions com stream (SSE) via /api/chat, com retry em falha de rede.
244
+ * onRetry avisa a TUI (tentativa, espera) para ela mostrar a contagem.
245
+ */
246
+ async function chat({ messages, model, tools, onDelta, onReasoning, onRetry, signal, retries = RETRY_MAX } = {}) {
247
+ const maxAttempts = Math.max(1, Number(retries) || RETRY_MAX);
248
+ let attempt = 0;
249
+ let last = null;
250
+
251
+ while (attempt < maxAttempts) {
252
+ attempt += 1;
253
+ const result = await attemptChat({ messages, model, tools, onDelta, onReasoning, signal });
254
+
255
+ if (result.ok || result.code === "aborted") return result;
256
+ // Ja saiu texto: repetir duplicaria a resposta na tela.
257
+ if (!result.retryable || result.text) return result;
258
+ last = result;
259
+
260
+ if (attempt >= maxAttempts) break;
261
+
262
+ const delay = backoffMs(attempt);
263
+ onRetry?.({ attempt, maxAttempts, delay, error: result.error });
264
+ const slept = await wait(delay, signal);
265
+ if (!slept) return { ok: false, code: "aborted", error: "Cancelado.", keyCreated: result.keyCreated };
90
266
  }
91
267
 
92
- const body = data.body;
93
- const output =
94
- typeof body === "string"
95
- ? body
96
- : body?.choices?.[0]?.message?.content ||
97
- body?.choices?.[0]?.text ||
98
- JSON.stringify(body, null, 2);
268
+ return { ...last, attempts: attempt };
269
+ }
99
270
 
271
+ /** Compatibilidade: resposta unica, sem callbacks (usada por `kafox ask`). */
272
+ async function askModel(question, options = {}) {
273
+ const text = typeof question === "string" ? question : (question || []).join(" ");
274
+ if (!text.trim()) {
275
+ return { ok: false, error: 'Usage: kafox ask "your question" [-m model]' };
276
+ }
277
+ const result = await chat({ messages: [{ role: "user", content: text }], model: options.model });
278
+ if (!result.ok) return result;
100
279
  return {
101
280
  ok: true,
102
- text: String(output || "").trim() || "(resposta vazia)",
103
- ms: data.ms,
104
- provider: data.provider,
105
- model,
106
- keyCreated,
281
+ text: String(result.text || "").trim() || "(resposta vazia)",
282
+ ms: result.ms,
283
+ model: result.model,
284
+ keyCreated: result.keyCreated,
107
285
  };
108
286
  }
109
287
 
@@ -124,4 +302,13 @@ async function ask(question, options = {}) {
124
302
  }
125
303
  }
126
304
 
127
- module.exports = { ask, askModel };
305
+ module.exports = {
306
+ ask,
307
+ askModel,
308
+ chat,
309
+ ensureReady,
310
+ mergeToolCalls,
311
+ backoffMs,
312
+ RETRY_MAX,
313
+ RETRY_BASE_MS,
314
+ };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Tela de login do kafox (estilo Cline): ocupa o terminal inteiro, com a arte
3
+ * grande em cima, o link centralizado e o status de aprovacao animado.
4
+ *
5
+ * Sem TTY (pipe/CI) quem chama usa o fluxo de texto do auth.
6
+ */
7
+ const chalk = require("chalk");
8
+ const { logoLines } = require("./logo");
9
+ const { borderColor } = require("./theme");
10
+
11
+ const ANSI = /\x1b\[[0-9;]*m/g;
12
+ const visible = (value) => String(value).replace(ANSI, "").length;
13
+ const center = (text, width) => {
14
+ const pad = Math.max(0, Math.floor((width - visible(text)) / 2));
15
+ return " ".repeat(pad) + text;
16
+ };
17
+ const rule = (width) => "─".repeat(Math.max(0, width));
18
+
19
+ function boxed(lines, width, theme) {
20
+ const border = borderColor(theme);
21
+ const inner = Math.max(20, Math.min(width, Math.max(...lines.map(visible)) + 4));
22
+ const out = [border(`┌${rule(inner)}┐`)];
23
+ for (const line of lines) {
24
+ out.push(border("│") + " " + line + " ".repeat(Math.max(0, inner - visible(line) - 1)) + border("│"));
25
+ }
26
+ out.push(border(`└${rule(inner)}┘`));
27
+ return out;
28
+ }
29
+
30
+ /**
31
+ * @param {object} options
32
+ * @param {string} options.url URL que o usuario abre no navegador
33
+ * @param {string} options.code codigo curto (para digitar no site)
34
+ * @param {number} options.interval segundos entre consultas
35
+ * @param {number} options.expiresIn segundos de validade
36
+ * @param {(signal:AbortSignal) => Promise<{ok:boolean, session?:string, error?:string}>} options.poll
37
+ * @param {string} [options.theme]
38
+ * @param {string} [options.account] email ja conhecido (opcional)
39
+ */
40
+ function runLoginScreen({
41
+ url,
42
+ code,
43
+ interval = 3,
44
+ expiresIn = 300,
45
+ poll,
46
+ theme = "default",
47
+ version = "",
48
+ } = {}) {
49
+ const stdout = process.stdout;
50
+ const stdin = process.stdin;
51
+ const controller = new AbortController();
52
+ const columns = Math.max(40, stdout.columns || 80);
53
+ const rows = Math.max(16, stdout.rows || 24);
54
+ const startedAt = Date.now();
55
+ const deadline = startedAt + (Number(expiresIn) || 300) * 1000;
56
+
57
+ let dots = 0;
58
+ let done = false;
59
+ let cancelled = false;
60
+ let status = "aguardando aprovacao";
61
+ let statusColor = chalk.cyan;
62
+
63
+ const logo = logoLines(theme);
64
+ const logoWidth = Math.max(...logo.map(visible));
65
+
66
+ function lines() {
67
+ const out = [];
68
+ const spin = "⠋⠙⠹⠸⠼⠴⠦⠧".charAt(dots % 8);
69
+ if (columns >= logoWidth + 4 && rows >= 22) {
70
+ out.push("");
71
+ for (const line of logo) out.push(center(line, columns));
72
+ out.push("");
73
+ }
74
+ out.push(center(chalk.bold("Entre na sua conta"), columns));
75
+ out.push(center(chalk.dim("Aprove o acesso no navegador para continuar."), columns));
76
+ out.push("");
77
+ for (const line of boxed([chalk.cyan(url)], columns - 4, theme)) out.push(center(line, columns));
78
+ out.push("");
79
+ out.push(center(chalk.dim("codigo") + " " + chalk.bold(code), columns));
80
+ out.push("");
81
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
82
+ const left = Math.max(0, Math.floor((deadline - Date.now()) / 1000));
83
+ out.push(
84
+ center(
85
+ statusColor(`${spin} ${status}${".".repeat(dots % 4)}`) +
86
+ chalk.dim(` ${left}s para expirar · ${elapsed}s esperando`),
87
+ columns
88
+ )
89
+ );
90
+ out.push("");
91
+ out.push(
92
+ center(
93
+ chalk.dim("Esc cancela") + " · " + chalk.dim("abra o link em qualquer navegador"),
94
+ columns
95
+ )
96
+ );
97
+ if (version) out.push(center(chalk.dim(`kafox v${version}`), columns));
98
+
99
+ // Centraliza o bloco inteiro na vertical, sem estourar a altura.
100
+ const slice = out.slice(0, rows - 1);
101
+ const top = Math.max(0, Math.floor((rows - slice.length) / 2));
102
+ return [...Array(top).fill(""), ...slice].slice(0, rows);
103
+ }
104
+
105
+ function paint() {
106
+ const frame = lines();
107
+ // Atualizacao sincronizada: o terminal troca o frame inteiro de uma vez,
108
+ // sem piscar entre as linhas.
109
+ stdout.write(
110
+ "\x1b[?2026h\x1b[?25l\x1b[H" +
111
+ frame.map((line) => line + "\x1b[K").join("\n") +
112
+ "\x1b[?2026l"
113
+ );
114
+ }
115
+
116
+ const enter = () => stdout.write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
117
+ const leave = () => stdout.write("\x1b[?25h\x1b[?1049l");
118
+
119
+ return new Promise((resolve) => {
120
+ let ticker = null;
121
+ let poller = null;
122
+ let finished = false;
123
+
124
+ function finish(result) {
125
+ if (finished) return;
126
+ finished = true;
127
+ done = result.ok;
128
+ cancelled = result.cancelled;
129
+ if (result.ok) {
130
+ status = "aprovado";
131
+ statusColor = chalk.green;
132
+ paint();
133
+ }
134
+ clearInterval(ticker);
135
+ clearInterval(poller);
136
+ stdin.removeListener("data", onData);
137
+ stdin.removeListener("end", onCancel);
138
+ try {
139
+ stdin.setRawMode(false);
140
+ } catch {}
141
+ stdin.pause();
142
+
143
+ setTimeout(() => {
144
+ leave();
145
+ resolve(result);
146
+ }, result.ok ? 450 : 0);
147
+ }
148
+
149
+ function onCancel() {
150
+ controller.abort();
151
+ finish({ ok: false, cancelled: true, error: "login cancelado" });
152
+ }
153
+
154
+ function onData(chunk) {
155
+ const text = String(chunk);
156
+ // Ctrl+C ou Esc solto cancelam (setas tambem comecam com ESC, entao so
157
+ // um chunk que e exatamente o Esc conta).
158
+ if (text.includes("\x03") || text === "\x1b") return onCancel();
159
+ }
160
+
161
+ if (stdin.isTTY) stdin.setRawMode(true);
162
+ stdin.setEncoding("utf8");
163
+ stdin.resume();
164
+ stdin.on("data", onData);
165
+ stdin.on("end", onCancel);
166
+
167
+ enter();
168
+ paint();
169
+
170
+ ticker = setInterval(() => {
171
+ dots += 1;
172
+ if (Date.now() > deadline) {
173
+ status = "codigo expirado";
174
+ statusColor = chalk.red;
175
+ paint();
176
+ return finish({ ok: false, error: "codigo expirado" });
177
+ }
178
+ if (typeof stdout.rows === "number") paint();
179
+ }, 140);
180
+
181
+ async function check() {
182
+ if (finished) return;
183
+ try {
184
+ const result = await poll(controller.signal);
185
+ if (finished) return;
186
+ if (result?.ok) return finish({ ok: true, session: result.session });
187
+ if (result?.cancelled) return finish(result);
188
+ if (result?.error) {
189
+ status = result.error;
190
+ statusColor = chalk.yellow;
191
+ paint();
192
+ }
193
+ } catch {
194
+ // erro passageiro: continua tentando ate expirar
195
+ }
196
+ }
197
+
198
+ poller = setInterval(check, Math.max(1, Number(interval) || 3) * 1000);
199
+ check();
200
+ });
201
+ }
202
+
203
+ module.exports = { runLoginScreen };
package/src/logo.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Logo do kafox: a arte "KAFOX" em blocos (fundo azul), adaptada por tema.
3
+ *
4
+ * A arte original vem com codigos ANSI por trecho; aqui ela e decodificada em
5
+ * segmentos {fg, bg, texto} e repintada com chalk, para que o fundo do tema
6
+ * entre nos espacos transparentes e o texto continue legivel no tema claro.
7
+ */
8
+ const chalk = require("chalk");
9
+ const { themeColors } = require("./theme");
10
+
11
+ const SGR = /\x1b\[([0-9;]*)m/g;
12
+
13
+ // Paleta da arte. 97 = tinta clara, 94 = azul, 34 = azul fundo, 37 = neutro,
14
+ // 44 = fundo azul (aplicado junto de 94).
15
+ const PALETTES = {
16
+ dark: { ink: "#ffffff", gray: "#c8c8c8", blue: "#3f6ef0", deep: "#1c2fc4", fill: "#1c2fc4" },
17
+ light: { ink: "#2323b8", gray: "#6f6f6f", blue: "#4b52e8", deep: "#2a2ab0", fill: "#c7cfff" },
18
+ };
19
+
20
+ const STYLE_KEYS = {
21
+ 37: { fg: "gray" },
22
+ 34: { fg: "deep" },
23
+ 94: { fg: "blue" },
24
+ 97: { fg: "ink" },
25
+ 44: { bg: "fill" },
26
+ };
27
+
28
+ const RAW_LOGO = [
29
+ "\x1b[0;97m█\x1b[0;94m█\x1b[0;94;44m▓▓\x1b[0;37m \x1b[0;94;44m█▓▒░\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m███▄\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m████\x1b[0;37m \x1b[0;97m▄\x1b[0;94m█\x1b[0;94;44m▓▓▒░\x1b[0;34m███▄\x1b[0;37m \x1b[0;97m█\x1b[0;94m█\x1b[0;94;44m▓▓\x1b[0;37m \x1b[0;94;44m▓▒░\x1b[0;34m█\x1b[0m",
30
+ "\x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;94;44m▓▒░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m \x1b[0;34m██\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m░░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▓▓▒▒\x1b[0;37m \x1b[0;94;44m▒░░\x1b[0;34m█\x1b[0m",
31
+ "\x1b[0;94;44m▒▒░░\x1b[0;34m█\x1b[0;94;44m░▒░▒\x1b[0;34m▄\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;34m██████\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m▒▒░░\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▒▒\x1b[0;34m█\x1b[0;37m \x1b[0;94m▀\x1b[0;94;44m▒░░\x1b[0;34m██\x1b[0;94;44m░▒▒\x1b[0;94m▀\x1b[0m",
32
+ "\x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;94;44m░▓▓ \x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓\x1b[0;34m█\x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓ \x1b[0;37m \x1b[0;94;44m░░\x1b[0;34m██\x1b[0;37m \x1b[0;34m█\x1b[0;94;44m▓▓ \x1b[0m",
33
+ "\x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m▀████████▀\x1b[0;37m \x1b[0;34m████\x1b[0;37m \x1b[0;34m████\x1b[0m",
34
+ ];
35
+
36
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
37
+
38
+ /** Decodifica uma linha crua em segmentos com estilo e texto. */
39
+ function parseLine(raw) {
40
+ const segments = [];
41
+ let style = { fg: null, bg: null };
42
+ let last = 0;
43
+ let match;
44
+ SGR.lastIndex = 0;
45
+
46
+ while ((match = SGR.exec(raw)) !== null) {
47
+ if (match.index > last) {
48
+ segments.push({ ...style, text: raw.slice(last, match.index) });
49
+ }
50
+ const codes = match[1].split(";").filter(Boolean).map(Number);
51
+ for (const code of codes) {
52
+ if (code === 0) style = { fg: null, bg: null };
53
+ else if (STYLE_KEYS[code]) Object.assign(style, STYLE_KEYS[code]);
54
+ }
55
+ last = match.index + match[0].length;
56
+ }
57
+ if (last < raw.length) segments.push({ ...style, text: raw.slice(last) });
58
+ return segments;
59
+ }
60
+
61
+ const SEGMENTS = RAW_LOGO.map(parseLine);
62
+
63
+ const LOGO_TEXT = SEGMENTS.map((line) => line.map((part) => part.text).join(""));
64
+ const LOGO_WIDTH = Math.max(...LOGO_TEXT.map((line) => line.length));
65
+ const LOGO_HEIGHT = LOGO_TEXT.length;
66
+
67
+ /** Temas de fundo claro precisam de tinta escura para o desenho nao sumir. */
68
+ function paletteFor(theme) {
69
+ const { bg } = themeColors(theme);
70
+ if (!bg) return PALETTES.dark;
71
+ const [r, g, b] = [1, 3, 5].map((index) => parseInt(bg.slice(index, index + 2), 16));
72
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
73
+ return luminance > 0.6 ? PALETTES.light : PALETTES.dark;
74
+ }
75
+
76
+ function paintSegment(text, style, palette) {
77
+ if (!text) return "";
78
+ const fg = style.fg ? palette[style.fg] : null;
79
+ const bg = style.bg ? palette[style.bg] : null;
80
+ if (fg && bg) return chalk.bgHex(bg).hex(fg)(text);
81
+ if (bg) return chalk.bgHex(bg)(text);
82
+ if (fg) return chalk.hex(fg)(text);
83
+ return text;
84
+ }
85
+
86
+ /** Linhas da arte ja coloridas (sem a cor de fundo da tela). */
87
+ function logoLines(theme = "default") {
88
+ const palette = paletteFor(theme);
89
+ return SEGMENTS.map((segments) =>
90
+ segments.map((part) => paintSegment(part.text, part, palette)).join("")
91
+ );
92
+ }
93
+
94
+ /** Versao em texto puro (sem cor), para saidas nao-TTY e testes. */
95
+ function logoText() {
96
+ return LOGO_TEXT.join("\n");
97
+ }
98
+
99
+ module.exports = {
100
+ logoLines,
101
+ logoText,
102
+ parseLine,
103
+ LOGO_WIDTH,
104
+ LOGO_HEIGHT,
105
+ PALETTES,
106
+ paletteFor,
107
+ stripAnsi: (value) => String(value).replace(ANSI_RE, ""),
108
+ };
package/src/mascot.js CHANGED
@@ -1,32 +1,4 @@
1
- // Kafox mascot art (ASCII only, no accents).
2
- const MASCOT = [
3
- "████ ████",
4
- " ▒▒░░░░▓▓ ▓▓░░░░▒▒",
5
- " ██░░▒▒░░██ ██░░▒▒░░██",
6
- " ██░░▓▓▒▒░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░▒▒▓▓░░██",
7
- " ██░░▓▓▓▓▒▒▓▓ ▓▓▒▒▓▓▓▓░░██",
8
- " ██░░▒▒▒▒▓▓░░░░░░░░░░░░░░▓▓▒▒▒▒░░██",
9
- " ██▒▒▓▓▓▓░░░░░░░░░░░░░░░░░░▓▓▓▓▒▒██",
10
- "▒▒ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓▓",
11
- "██ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ██",
12
- "██ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ██",
13
- "██ ░░▓▓▓▓░░░░░░░░░░░░░░░░░░▓▓▓▓░░ ██",
14
- "██ ░░░░░░▓▓░░░░░░▒▒░░░░░░▓▓░░░░░░ ██",
15
- "██░░░░░░░░░░▓▓░░▒▒▒▒▒▒░░▓▓░░░░░░░░░░██",
16
- "██░░ ░░░░░░░░░░░░░░ ░░██ ▒▒▓▓",
17
- " ██ ▓▓▓▓▓▓ ██ ██ ██",
18
- " ▓▓ ▓▓ ▓▓ ▓▓▒▒ ██",
19
- " ██ ▓▓ ▓▓ ██ ██ ██",
20
- " ▓▓▓▓ ▓▓▓▓ ▓▓░░░░ ██",
21
- " ██▒▒░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░▒▒██░░▒▒▒▒░░░░░░██",
22
- " ██░░░░ ░░░░▓▓▒▒▒▒▒▒▒▒░░░░██",
23
- " ██░░░░░░ ░░░░░░▓▓▒▒▒▒▒▒░░██",
24
- " ██░░░░ ▒▒ ▒▒ ░░░░▓▓▒▒▒▒▒▒▓▓",
25
- " ██░░░░ ▒▒ ▒▒ ░░░░▓▓▒▒████",
26
- " ██▒▒░░ ▓▓ ▒▒ ▓▓ ░░▒▒██▓▓",
27
- " ████ ▓▓ ▓▓ ▓▓ ████",
28
- " ▒▒██▓▓ ▓▓ ▓▓██▒▒",
29
- " ██████████",
30
- ].join("\n");
1
+ // Compatibilidade: o "mascote" agora e o logo KAFOX em blocos (logo.js).
2
+ const { logoText, logoLines } = require("./logo");
31
3
 
32
- module.exports = { mascot: MASCOT };
4
+ module.exports = { mascot: logoText(), logoLines };