kafox-cli 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,57 @@
1
- # Temporary Holding Version
1
+ # kafox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CLI oficial do Kafox no estilo opencode: login por device code (SSO), modelos,
4
+ saldo em reais, temas e conversa pelo gateway.
5
+
6
+ ## Instalar
7
+
8
+ ```bash
9
+ npm install -g kafox-cli
10
+ ```
11
+
12
+ O comando instalado e `kafox`. Alternativa sem npm:
13
+
14
+ ```bash
15
+ npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.2.0.tgz
16
+ ```
17
+
18
+ Requisito: Node.js 22.5+.
19
+
20
+ ## Login
21
+
22
+ ```bash
23
+ kafox login
24
+ ```
25
+
26
+ A CLI mostra um codigo e a URL `https://kafox.pypag.com/cli?code=...`. Abra no
27
+ navegador, entre na sua conta e clique em **Autorizar**. A sessao fica em
28
+ `~/.kafox/token.json` (permissao 600).
29
+
30
+ ## Comandos
31
+
32
+ | Comando | O que faz |
33
+ | --- | --- |
34
+ | `kafox` | abre o REPL |
35
+ | `kafox login` / `logout` / `whoami` | sessao da conta |
36
+ | `kafox models` | lista modelos e precos por 1M de tokens |
37
+ | `kafox pricing` | pacotes de recarga |
38
+ | `kafox balance` | saldo atual |
39
+ | `kafox key` | cria uma API key |
40
+ | `kafox ask "pergunta" [-m modelo]` | pergunta ao gateway |
41
+ | `kafox status` | conta, saldo e gateway |
42
+ | `kafox theme` | seletor de tema |
43
+ | `kafox background [url]` | imagem de fundo do terminal |
44
+ | `kafox compact` / `clear` | compacta / limpa o contexto do REPL |
45
+
46
+ Dentro do REPL: `/help`, `/models`, `/model <id>`, `/balance`, `/pricing`,
47
+ `/key`, `/login`, `/logout`, `/whoami`, `/theme`, `/background`, `/clear`,
48
+ `/compact`, `/status`, `/exit`.
49
+
50
+ ## Configuracao
51
+
52
+ - `KAFOX_API_URL` ou `~/.kafox/config.json` (`api_url`) troca a API.
53
+ - `~/.kafox/config.json` guarda `model`, `theme`, `background` e a API key criada
54
+ automaticamente no primeiro `ask`.
55
+
56
+ A CLI so responde se a conta tiver saldo; sem credito ela avisa e nao chama o
57
+ gateway.
package/bin/kafox.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ const { Command } = require("commander");
3
+ const { runREPL, compact, clearContext, showStatus } = require("../src/cli");
4
+ const { login, logout, whoami, createKey } = require("../src/auth");
5
+ const { listModels, showBalance, showPricing } = require("../src/models");
6
+ const { ask } = require("../src/gateway");
7
+ const { runTheme, setContentBackground } = require("../src/theme");
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name("kafox")
13
+ .description("Kafox CLI in opencode style: REPL, account login, models, balance, themes")
14
+ .version("0.1.0");
15
+
16
+ program.command("login").description("Login with your Kafox account (device code SSO)").action(login);
17
+ program.command("logout").description("End the CLI session").action(logout);
18
+ program.command("whoami").description("Show the account linked to this CLI").action(whoami);
19
+ program.command("models").description("List available models and prices").action(listModels);
20
+ program.command("balance").description("Show current balance").action(showBalance);
21
+ program.command("pricing").description("Show recharge packages").action(showPricing);
22
+ program.command("key").description("Create an API key for this CLI").action(createKey);
23
+ program.command("ask").argument("[question...]", "Question").option("-m, --model <id>", "Model").action(ask);
24
+ program.command("theme").description("Open the theme selector").action(runTheme);
25
+ program.command("background").description("Set terminal background image (URL)").argument("[url]", "Image URL").action(setContentBackground);
26
+ program.command("compact").description("Compact the current conversation context").action(compact);
27
+ program.command("clear").description("Clear the conversation context").action(clearContext);
28
+ program.command("status").description("Show account, balance and gateway status").action(showStatus);
29
+
30
+ program.action(runREPL);
31
+ program.parse();
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Kafox CLI in opencode style: full-screen terminal UI, device login, models, balance, themes",
5
+ "bin": {
6
+ "kafox": "bin/kafox.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "src",
11
+ "package.json"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.5"
15
+ },
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "chalk": "4.1.2",
19
+ "commander": "12.1.0",
20
+ "inquirer": "8.2.6"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test \"test/*.test.js\""
24
+ }
25
+ }
package/src/auth.js ADDED
@@ -0,0 +1,128 @@
1
+ const chalk = require("chalk");
2
+ const inquirer = require("inquirer");
3
+ const storage = require("./storage");
4
+ const { mascot } = require("./mascot");
5
+
6
+ const MASCOT = mascot;
7
+
8
+ async function fetchApi(pathname, options = {}) {
9
+ const url = `${storage.getApiUrl()}${pathname}`;
10
+ const res = await fetch(url, {
11
+ ...options,
12
+ headers: { ...storage.getAuthHeaders(options.headers), ...options.headers },
13
+ });
14
+ return res;
15
+ }
16
+
17
+ async function ensureSession() {
18
+ const token = storage.getToken();
19
+ if (token?.session) return token;
20
+ return null;
21
+ }
22
+
23
+ async function login() {
24
+ console.log(MASCOT);
25
+ console.log(
26
+ chalk.cyan(" Kafox CLI - login by device code (SSO)\n")
27
+ );
28
+
29
+ const res = await fetchApi("/api/cli/device", {
30
+ method: "POST",
31
+ body: "{}",
32
+ });
33
+ const data = await res.json().catch(() => ({}));
34
+ if (!res.ok) {
35
+ console.log(chalk.red(` Failed to create device code: ${data.error || "error"}`));
36
+ return;
37
+ }
38
+
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
+ );
43
+ console.log(` Code: ${chalk.bold(data.user_code)} (expires in ${data.expires_in}s)\n`);
44
+
45
+ const interval = Math.max(1, data.interval) * 1000;
46
+ const deadline = Date.now() + data.expires_in * 1000;
47
+ let dots = 0;
48
+
49
+ while (Date.now() < deadline) {
50
+ process.stdout.write(`\r Waiting for approval${".".repeat(dots % 4)} `);
51
+ dots += 1;
52
+ 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
74
+ }
75
+ }
76
+ console.log(chalk.red("\n Device code expired. Run `kafox login` again."));
77
+ }
78
+
79
+ async function logout() {
80
+ storage.clearToken();
81
+ console.log(chalk.green(" Session ended."));
82
+ }
83
+
84
+ async function whoami() {
85
+ const token = await ensureSession();
86
+ if (!token) {
87
+ console.log(chalk.yellow(" Not logged in. Run `kafox login`."));
88
+ return;
89
+ }
90
+ const res = await fetchApi("/api/auth/me");
91
+ const data = await res.json().catch(() => ({}));
92
+ if (res.status === 200) {
93
+ console.log(chalk.cyan(" Account: ") + chalk.bold(data.user?.email));
94
+ console.log(` Name: ${data.user?.name}`);
95
+ console.log(
96
+ ` Balance: ${chalk.green((data.user?.balance_micros / 1_000_000).toFixed(2) + " R$")}`
97
+ );
98
+ } else {
99
+ console.log(chalk.red(" Session invalid. Run `kafox login`."));
100
+ }
101
+ }
102
+
103
+ async function createKey() {
104
+ const token = await ensureSession();
105
+ if (!token) {
106
+ console.log(chalk.yellow(" Login first: `kafox login`"));
107
+ return;
108
+ }
109
+ const answers = await inquirer.prompt([
110
+ { type: "input", name: "name", message: "Key name:", default: "CLI" },
111
+ ]);
112
+ const res = await fetchApi("/api/keys", {
113
+ method: "POST",
114
+ body: JSON.stringify({ name: answers.name }),
115
+ });
116
+ const data = await res.json().catch(() => ({}));
117
+ if (res.status === 201) {
118
+ console.log(chalk.green("\n Key created:"));
119
+ console.log(` ${data.secret}`);
120
+ console.log(
121
+ chalk.dim(" Copy it now - it will not be shown again.")
122
+ );
123
+ } else {
124
+ console.log(chalk.red(`\n Failed: ${data.error || "error"}`));
125
+ }
126
+ }
127
+
128
+ module.exports = { login, logout, whoami, createKey, ensureSession, fetchApi };
package/src/cli.js ADDED
@@ -0,0 +1,260 @@
1
+ const readline = require("readline");
2
+ const inquirer = require("inquirer");
3
+ const chalk = require("chalk");
4
+ const storage = require("./storage");
5
+ const { ensureSession, fetchApi } = require("./auth");
6
+ const { ask } = require("./gateway");
7
+ const { runTUI } = require("./tui");
8
+ const { runTheme, setContentBackground, paint } = require("./theme");
9
+ const { mascot } = require("./mascot");
10
+
11
+ const VERSION = "0.2.0";
12
+
13
+ const COMMANDS = {
14
+ "/help": "show this help",
15
+ "/models": "list models and prices",
16
+ "/model <id>": "set default model",
17
+ "/balance": "show balance",
18
+ "/pricing": "show recharge packages",
19
+ "/key": "create an API key",
20
+ "/login": "login (device code SSO)",
21
+ "/logout": "end the session",
22
+ "/whoami": "show the linked account",
23
+ "/theme": "open theme selector",
24
+ "/background [url]": "set background image (modal asks for URL)",
25
+ "/clear": "clear conversation context",
26
+ "/compact": "compact conversation context",
27
+ "/status": "show account, balance and gateway status",
28
+ "/exit": "exit the CLI",
29
+ };
30
+
31
+ let history = [];
32
+ let modelOverride = null;
33
+
34
+ function pushHistory(role, content) {
35
+ history.push({ role, content, at: new Date().toISOString() });
36
+ if (history.length > 200) history = history.slice(-200);
37
+ }
38
+
39
+ async function handleCommand(line) {
40
+ const [cmd, ...rest] = line.trim().split(/\s+/);
41
+ const arg = rest.join(" ");
42
+ switch (cmd) {
43
+ case "/help":
44
+ console.log(chalk.cyan("\nCommands:\n"));
45
+ for (const [key, value] of Object.entries(COMMANDS)) {
46
+ console.log(` ${chalk.bold(key)} ${chalk.dim(value)}`);
47
+ }
48
+ console.log("");
49
+ return true;
50
+ case "/models": {
51
+ const { listModels } = require("./models");
52
+ await listModels();
53
+ return true;
54
+ }
55
+ case "/model":
56
+ if (!arg) {
57
+ console.log(chalk.dim("Usage: /model <id> (see /models)"));
58
+ return true;
59
+ }
60
+ modelOverride = arg;
61
+ storage.setConfig({ model: arg });
62
+ console.log(chalk.green(`Default model: ${arg}`));
63
+ return true;
64
+ case "/balance": {
65
+ const { showBalance } = require("./models");
66
+ await showBalance();
67
+ return true;
68
+ }
69
+ case "/pricing": {
70
+ const { showPricing } = require("./models");
71
+ await showPricing();
72
+ return true;
73
+ }
74
+ case "/key": {
75
+ const { createKey } = require("./auth");
76
+ await createKey();
77
+ return true;
78
+ }
79
+ case "/login": {
80
+ const { login } = require("./auth");
81
+ await login();
82
+ return true;
83
+ }
84
+ case "/logout": {
85
+ const { logout } = require("./auth");
86
+ await logout();
87
+ return true;
88
+ }
89
+ case "/whoami": {
90
+ const { whoami } = require("./auth");
91
+ await whoami();
92
+ return true;
93
+ }
94
+ case "/theme":
95
+ await runTheme();
96
+ return true;
97
+ case "/background":
98
+ await setContentBackground(arg || null);
99
+ return true;
100
+ case "/clear":
101
+ await clearContext();
102
+ return true;
103
+ case "/compact":
104
+ await compact();
105
+ return true;
106
+ case "/status":
107
+ await showStatus();
108
+ return true;
109
+ case "/exit":
110
+ case "/quit":
111
+ process.exit(0);
112
+ default:
113
+ console.log(chalk.dim(`Unknown command: ${cmd}. Type /help`));
114
+ return true;
115
+ }
116
+ }
117
+
118
+ async function clearContext() {
119
+ history = [];
120
+ console.log(chalk.green("Context cleared."));
121
+ }
122
+
123
+ async function compact() {
124
+ if (!history.length) {
125
+ console.log(chalk.dim("Nothing to compact."));
126
+ return;
127
+ }
128
+ const turns = history.length;
129
+ const last = history.slice(-6);
130
+ history = [
131
+ {
132
+ role: "system",
133
+ content: `(context compacted: ${turns} messages summarized)`,
134
+ at: new Date().toISOString(),
135
+ },
136
+ ...last,
137
+ ];
138
+ console.log(
139
+ chalk.green(`Context compacted: ${turns} -> ${history.length} messages (auto summary).`)
140
+ );
141
+ }
142
+
143
+ async function showStatus() {
144
+ const token = await ensureSession();
145
+ if (!token) {
146
+ console.log(chalk.yellow("Not logged in. Run /login"));
147
+ return;
148
+ }
149
+ const res = await fetchApi("/api/bootstrap");
150
+ const data = await res.json().catch(() => ({}));
151
+ if (!res.ok) {
152
+ console.log(chalk.red("Session invalid. Run /login"));
153
+ return;
154
+ }
155
+ const cfg = storage.getConfig();
156
+ console.log(chalk.cyan("\nKafox status\n"));
157
+ console.log(` Account: ${data.user.email} (${data.user.name})`);
158
+ console.log(
159
+ ` Balance: ${chalk.green((data.user.balance_micros / 1_000_000).toFixed(2) + " R$")}`
160
+ );
161
+ console.log(` Model: ${modelOverride || cfg.model || "(auto)"}`);
162
+ console.log(` Keys: ${data.keys.active}/${data.max_active_keys} active`);
163
+ console.log(` Gateway: ${data.gateway_url}`);
164
+ }
165
+
166
+ /** Modo simples: sem TTY (scripts, pipes) ou apos o login. */
167
+ async function runPlainREPL() {
168
+ const cfg = storage.getConfig();
169
+ const theme = cfg.theme || "default";
170
+
171
+ console.log(paint(theme, mascot));
172
+ console.log(paint(theme, `\n Kafox CLI v${VERSION} - opencode style`));
173
+ console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
174
+
175
+ const token = await ensureSession().catch(() => null);
176
+ if (!token) {
177
+ console.log(chalk.yellow(" You are not logged in."));
178
+ const { doLogin } = await inquirer.prompt([
179
+ { type: "confirm", name: "doLogin", message: "Login now?", default: true },
180
+ ]);
181
+ if (doLogin) {
182
+ const { login } = require("./auth");
183
+ await login();
184
+ }
185
+ } else {
186
+ const res = await fetchApi("/api/bootstrap").catch(() => null);
187
+ const data = res && res.ok ? await res.json().catch(() => null) : null;
188
+ if (data?.user) {
189
+ console.log(
190
+ chalk.dim(
191
+ ` Logged in as ${data.user.email} - balance ${(data.user.balance_micros / 1_000_000).toFixed(2)} R$`
192
+ )
193
+ );
194
+ }
195
+ }
196
+
197
+ const rl = readline.createInterface({
198
+ input: process.stdin,
199
+ output: process.stdout,
200
+ prompt: chalk.cyan("kafox> "),
201
+ });
202
+
203
+ rl.prompt();
204
+ rl.on("line", async (raw) => {
205
+ const line = raw.trim();
206
+ if (!line) return rl.prompt();
207
+ try {
208
+ if (line.startsWith("/")) {
209
+ await handleCommand(line);
210
+ } else {
211
+ pushHistory("user", line);
212
+ await ask(line, { model: modelOverride || storage.getConfig().model });
213
+ pushHistory("assistant", "(answer above)");
214
+ }
215
+ } catch (err) {
216
+ console.log(chalk.red(`\nError: ${err.message}`));
217
+ }
218
+ rl.prompt();
219
+ });
220
+
221
+ rl.on("close", () => {
222
+ console.log(chalk.dim("\nSee you."));
223
+ process.exit(0);
224
+ });
225
+ }
226
+
227
+ async function runREPL() {
228
+ // Sem TTY (pipe/CI), mantem o REPL linha a linha.
229
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL();
230
+
231
+ const cfg = storage.getConfig();
232
+ const token = await ensureSession().catch(() => null);
233
+
234
+ if (!token) {
235
+ console.log(paint(cfg.theme || "default", mascot));
236
+ console.log(paint(cfg.theme || "default", `\n Kafox CLI v${VERSION}`));
237
+ console.log(chalk.yellow(" You are not logged in."));
238
+ const { doLogin } = await inquirer.prompt([
239
+ { type: "confirm", name: "doLogin", message: "Login now?", default: true },
240
+ ]);
241
+ if (!doLogin) return;
242
+ const { login } = require("./auth");
243
+ await login();
244
+ }
245
+
246
+ return runTUI({
247
+ commands: COMMANDS,
248
+ onCommand: handleCommand,
249
+ interactiveCommands: new Set(["/login", "/theme", "/key", "/background"]),
250
+ });
251
+ }
252
+
253
+ module.exports = {
254
+ runREPL,
255
+ compact,
256
+ clearContext,
257
+ showStatus,
258
+ handleCommand,
259
+ COMMANDS,
260
+ };
package/src/gateway.js ADDED
@@ -0,0 +1,127 @@
1
+ const chalk = require("chalk");
2
+ const storage = require("./storage");
3
+ const { ensureSession, fetchApi } = require("./auth");
4
+
5
+ /**
6
+ * Envia a pergunta ao gateway e devolve o resultado estruturado.
7
+ * A TUI usa o retorno; o comando `kafox ask` so imprime.
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
+
15
+ const token = await ensureSession();
16
+ if (!token) {
17
+ return { ok: false, error: "Login first: `kafox login`" };
18
+ }
19
+
20
+ // Saldo decide se a CLI responde: sem credito, nem chega no gateway.
21
+ const boot = await fetchApi("/api/bootstrap");
22
+ if (!boot.ok) {
23
+ return { ok: false, error: "Session invalid. Run `kafox login`" };
24
+ }
25
+ const account = await boot.json();
26
+ const balance = Number(account?.user?.balance_micros || 0);
27
+ if (balance <= 0) {
28
+ return {
29
+ ok: false,
30
+ code: "no_balance",
31
+ error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
32
+ };
33
+ }
34
+
35
+ let model = options.model || storage.getConfig().model;
36
+ if (!model) {
37
+ const pricing = await fetchApi("/api/public/pricing")
38
+ .then((response) => response.json())
39
+ .catch(() => null);
40
+ model = pricing?.pricing?.[0]?.model;
41
+ if (!model) {
42
+ return { ok: false, error: "Could not load model catalog." };
43
+ }
44
+ }
45
+
46
+ let keyCreated = false;
47
+ let apiKey = storage.getConfig().api_key;
48
+ if (!apiKey) {
49
+ const keyResponse = await fetchApi("/api/keys", {
50
+ method: "POST",
51
+ body: JSON.stringify({ name: "kafox-cli" }),
52
+ });
53
+ const keyData = await keyResponse.json().catch(() => ({}));
54
+ if (!keyResponse.ok) {
55
+ return { ok: false, error: `Failed to create key: ${keyData.error || "error"}` };
56
+ }
57
+ apiKey = keyData.secret;
58
+ storage.setConfig({ api_key: apiKey });
59
+ keyCreated = true;
60
+ }
61
+
62
+ const response = await fetchApi("/api/public/playground", {
63
+ method: "POST",
64
+ body: JSON.stringify({ api_key: apiKey, model, message: text }),
65
+ });
66
+ const data = await response.json().catch(() => ({}));
67
+
68
+ 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"}` };
77
+ }
78
+
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
+ };
88
+ }
89
+ return { ok: false, error: `Failed (${data.status}): ${detail}` };
90
+ }
91
+
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);
99
+
100
+ return {
101
+ ok: true,
102
+ text: String(output || "").trim() || "(resposta vazia)",
103
+ ms: data.ms,
104
+ provider: data.provider,
105
+ model,
106
+ keyCreated,
107
+ };
108
+ }
109
+
110
+ async function ask(question, options = {}) {
111
+ const result = await askModel(question, options);
112
+ if (!result.ok) {
113
+ console.log(chalk.red(`\n${result.error}`));
114
+ return;
115
+ }
116
+ if (result.keyCreated) {
117
+ console.log(chalk.dim("(key created and saved to ~/.kafox/config.json)"));
118
+ }
119
+ console.log(chalk.dim(`Model: ${result.model}`));
120
+ console.log(chalk.green("\nAnswer:"));
121
+ console.log(result.text);
122
+ if (result.ms) {
123
+ console.log(chalk.dim(`\n(${result.ms}ms provider ${result.provider || "?"})`));
124
+ }
125
+ }
126
+
127
+ module.exports = { ask, askModel };
package/src/mascot.js ADDED
@@ -0,0 +1,32 @@
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");
31
+
32
+ module.exports = { mascot: MASCOT };
package/src/models.js ADDED
@@ -0,0 +1,61 @@
1
+ const chalk = require("chalk");
2
+ const storage = require("./storage");
3
+ const { ensureSession, fetchApi } = require("./auth");
4
+
5
+ function fmtBRL(micros) {
6
+ return `${(micros / 1_000_000).toFixed(2)} R$`;
7
+ }
8
+
9
+ async function listModels() {
10
+ const res = await fetchApi("/api/public/pricing");
11
+ if (!res.ok) {
12
+ const err = await res.json().catch(() => ({}));
13
+ console.log(chalk.red(`Failed: ${err.error || "error"}`));
14
+ return;
15
+ }
16
+ const data = await res.json();
17
+ console.log(chalk.cyan("\nKafox models:\n"));
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
+ console.log(` ${chalk.bold(p.model)}`);
22
+ console.log(` in: ${inp} R$/1M out: ${out} R$/1M`);
23
+ }
24
+ console.log(`\nGateway: ${chalk.dim(data.gateway_url)}`);
25
+ }
26
+
27
+ async function showBalance() {
28
+ const token = await ensureSession();
29
+ if (!token) {
30
+ console.log(chalk.yellow("Login first: `kafox login`"));
31
+ return;
32
+ }
33
+ const res = await fetchApi("/api/bootstrap");
34
+ if (!res.ok) {
35
+ console.log(chalk.red("Session invalid. Run `kafox login`."));
36
+ return;
37
+ }
38
+ const data = await res.json();
39
+ console.log(
40
+ chalk.cyan("\nBalance: ") +
41
+ chalk.bold.green(fmtBRL(data.user.balance_micros))
42
+ );
43
+ console.log(` Account: ${data.user.email} (${data.user.name})`);
44
+ console.log(` Keys: ${data.keys.active}/${data.max_active_keys} active`);
45
+ }
46
+
47
+ async function showPricing() {
48
+ const res = await fetchApi("/api/public/pricing");
49
+ const data = await res.json().catch(() => ({}));
50
+ if (!res.ok) {
51
+ console.log(chalk.red(`Failed: ${data.error || "error"}`));
52
+ return;
53
+ }
54
+ for (const pack of data.packages) {
55
+ console.log(
56
+ ` ${pack.id}: ${pack.label} -> ${fmtBRL(pack.credits_micros)} credits`
57
+ );
58
+ }
59
+ }
60
+
61
+ module.exports = { listModels, showBalance, showPricing, fmtBRL };
package/src/storage.js ADDED
@@ -0,0 +1,91 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+
6
+ const HOME = os.homedir();
7
+ const DIR = path.join(HOME, ".kafox");
8
+ const TOKEN_FILE = path.join(DIR, "token.json");
9
+ const CONFIG_FILE = path.join(DIR, "config.json");
10
+
11
+ function ensureDir() {
12
+ if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
13
+ }
14
+
15
+ function readJSON(file, fallback) {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(file, "utf8"));
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ }
22
+
23
+ function writeJSON(file, data) {
24
+ ensureDir();
25
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
26
+ }
27
+
28
+ function getApiUrl() {
29
+ const cfg = readJSON(CONFIG_FILE, {});
30
+ return cfg.api_url || process.env.KAFOX_API_URL || "https://kafox.pypag.com";
31
+ }
32
+
33
+ function getToken() {
34
+ return readJSON(TOKEN_FILE, null);
35
+ }
36
+ function setToken(data) {
37
+ writeJSON(TOKEN_FILE, data);
38
+ }
39
+ function clearToken() {
40
+ try { fs.unlinkSync(TOKEN_FILE); } catch {}
41
+ }
42
+
43
+ function getConfig() {
44
+ return readJSON(CONFIG_FILE, {});
45
+ }
46
+ function setConfig(data) {
47
+ const cfg = { ...getConfig(), ...data };
48
+ writeJSON(CONFIG_FILE, cfg);
49
+ }
50
+
51
+ function ensureCsrf() {
52
+ const token = getToken() || {};
53
+ if (token.csrf) return token.csrf;
54
+ // Double-submit: o backend so compara o cookie pf_csrf com o header
55
+ // x-csrf-token, entao a CLI pode gerar o proprio par.
56
+ const csrf = crypto.randomBytes(24).toString("hex");
57
+ setToken({ ...token, csrf });
58
+ return csrf;
59
+ }
60
+
61
+ function getAuthHeaders(extra = {}) {
62
+ const token = getToken();
63
+ const headers = { "content-type": "application/json", ...extra };
64
+ if (token?.session) {
65
+ const csrf = token.csrf || ensureCsrf();
66
+ headers.cookie = `pf_session=${token.session}; pf_csrf=${csrf}`;
67
+ headers["x-csrf-token"] = csrf;
68
+ }
69
+ return headers;
70
+ }
71
+
72
+ function parseCookies(headers) {
73
+ const list = headers["set-cookie"] || [];
74
+ const result = {};
75
+ for (const raw of list) {
76
+ const first = raw.split(";")[0];
77
+ const idx = first.indexOf("=");
78
+ if (idx === -1) continue;
79
+ const name = first.slice(0, idx).trim();
80
+ const value = first.slice(idx + 1).trim();
81
+ if (name) result[name] = value;
82
+ }
83
+ return result;
84
+ }
85
+
86
+ module.exports = {
87
+ DIR, TOKEN_FILE, CONFIG_FILE,
88
+ ensureDir, readJSON, writeJSON,
89
+ getApiUrl, getToken, setToken, clearToken,
90
+ getConfig, setConfig, getAuthHeaders, parseCookies,
91
+ };
package/src/theme.js ADDED
@@ -0,0 +1,64 @@
1
+ const inquirer = require("inquirer");
2
+ const chalk = require("chalk");
3
+ const storage = require("./storage");
4
+
5
+ const THEMES = ["default", "dark", "light", "brutalist", "neon", "paper"];
6
+
7
+ function paint(theme, text) {
8
+ switch (theme) {
9
+ case "neon":
10
+ return chalk.magentaBright(text);
11
+ case "brutalist":
12
+ return chalk.bold.bgYellow.black(text);
13
+ case "paper":
14
+ return chalk.white(text);
15
+ case "light":
16
+ return chalk.black(text);
17
+ case "dark":
18
+ return chalk.cyan(text);
19
+ default:
20
+ return chalk.green(text);
21
+ }
22
+ }
23
+
24
+ async function runTheme() {
25
+ const cfg = storage.getConfig();
26
+ const { theme } = await inquirer.prompt([
27
+ {
28
+ type: "list",
29
+ name: "theme",
30
+ message: "Choose theme:",
31
+ choices: THEMES,
32
+ default: cfg.theme || "default",
33
+ },
34
+ ]);
35
+ storage.setConfig({ theme });
36
+ console.log(chalk.green(`Theme set: ${theme}`));
37
+ console.log(paint(theme, "Kafox CLI - theme preview"));
38
+ }
39
+
40
+ async function setContentBackground(url) {
41
+ let bg = url;
42
+ if (!bg) {
43
+ const answer = await inquirer.prompt([
44
+ {
45
+ type: "input",
46
+ name: "url",
47
+ message: "Background image URL (PNG/JPG):",
48
+ validate: (v) =>
49
+ /^https?:\/\/.+\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i.test(v.trim()) ||
50
+ "Enter a valid image URL (http/https)",
51
+ },
52
+ ]);
53
+ bg = answer.url.trim();
54
+ }
55
+ storage.setConfig({ background: bg });
56
+ console.log(chalk.green(`Background set: ${bg}`));
57
+ console.log(
58
+ chalk.dim(
59
+ "Tip: terminals like Windows Terminal / iTerm2 support image backgrounds. Use `kafox background` to open the modal."
60
+ )
61
+ );
62
+ }
63
+
64
+ module.exports = { runTheme, setContentBackground, THEMES, paint };
package/src/tui.js ADDED
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Interface de terminal do kafox, no estilo opencode:
3
+ * tela cheia, mascote, menu de comandos "/", caixa de input e rodape.
4
+ *
5
+ * Tudo e desenhado a mao (sem dependencia de TUI) para manter o pacote leve e
6
+ * funcionar no Windows Terminal. Sem TTY, o cli.js usa o REPL simples.
7
+ */
8
+ const readline = require("readline");
9
+ const chalk = require("chalk");
10
+ const storage = require("./storage");
11
+ const { mascot } = require("./mascot");
12
+ const { paint } = require("./theme");
13
+ const { ensureSession, fetchApi } = require("./auth");
14
+ const { askModel } = require("./gateway");
15
+
16
+ const ANSI = /\x1b\[[0-9;]*m/g;
17
+ const stripAnsi = (value) => String(value).replace(ANSI, "");
18
+ const visible = (value) => stripAnsi(value).length;
19
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
20
+
21
+ function wrap(text, width) {
22
+ const max = Math.max(10, width);
23
+ const lines = [];
24
+ for (const paragraph of String(text).replace(/\r/g, "").split("\n")) {
25
+ if (!paragraph.length) {
26
+ lines.push("");
27
+ continue;
28
+ }
29
+ let line = "";
30
+ for (const word of paragraph.split(/\s+/)) {
31
+ if (!line) line = word;
32
+ else if (line.length + 1 + word.length <= max) line += " " + word;
33
+ else {
34
+ lines.push(line);
35
+ line = word;
36
+ }
37
+ while (line.length > max) {
38
+ lines.push(line.slice(0, max));
39
+ line = line.slice(max);
40
+ }
41
+ }
42
+ lines.push(line);
43
+ }
44
+ return lines;
45
+ }
46
+
47
+ function truncate(text, width) {
48
+ const plain = stripAnsi(text);
49
+ if (plain.length <= width) return text;
50
+ if (width <= 3) return plain.slice(0, width);
51
+ return plain.slice(0, width - 3) + "...";
52
+ }
53
+
54
+ function center(text, width) {
55
+ const pad = Math.max(0, Math.floor((width - visible(text)) / 2));
56
+ return " ".repeat(pad) + text;
57
+ }
58
+
59
+ function padRight(text, width) {
60
+ const missing = width - visible(text);
61
+ return missing > 0 ? text + " ".repeat(missing) : truncate(text, width);
62
+ }
63
+
64
+ function initialState() {
65
+ const cfg = storage.getConfig();
66
+ return {
67
+ input: "",
68
+ cursor: 0,
69
+ history: [],
70
+ historyIndex: -1,
71
+ messages: [],
72
+ busy: false,
73
+ frame: 0,
74
+ menuIndex: 0,
75
+ balance: null,
76
+ model: cfg.model || null,
77
+ theme: cfg.theme || "default",
78
+ suspended: false,
79
+ version: "0.2.0",
80
+ };
81
+ }
82
+
83
+ /* ------------------------------------------------------------------ layout */
84
+
85
+ function headerLines(state, width, rows) {
86
+ if (state.messages.length) return [];
87
+ const art = mascot.split("\n");
88
+ const visibleArt = rows >= 46 ? art : art.slice(0, 9);
89
+ const lines = visibleArt.map((line) => center(paint(state.theme, line), width));
90
+ lines.push("");
91
+ lines.push(center(chalk.bold("O que voce quer fazer?"), width));
92
+ lines.push(
93
+ center(chalk.dim("Use / para comandos | Enter envia | Ctrl+C sai"), width)
94
+ );
95
+ lines.push("");
96
+ return lines;
97
+ }
98
+
99
+ function messageLines(state, width) {
100
+ const lines = [];
101
+ for (const message of state.messages) {
102
+ if (message.role === "user") {
103
+ lines.push("");
104
+ const body = wrap(message.text, width - 2);
105
+ lines.push(chalk.cyan("> ") + chalk.bold(body[0] || ""));
106
+ for (let index = 1; index < body.length; index += 1) {
107
+ lines.push(" " + chalk.bold(body[index]));
108
+ }
109
+ } else if (message.role === "assistant") {
110
+ lines.push("");
111
+ for (const line of wrap(message.text, width - 2)) lines.push(" " + line);
112
+ const meta = [
113
+ message.ms ? `${(message.ms / 1000).toFixed(1)}s` : null,
114
+ message.provider,
115
+ ]
116
+ .filter(Boolean)
117
+ .join(" | ");
118
+ if (meta) lines.push(chalk.dim(" " + meta));
119
+ } else {
120
+ lines.push("");
121
+ const color = message.error ? chalk.red : chalk.dim;
122
+ for (const line of wrap(message.text, width - 2)) lines.push(color(" " + line));
123
+ }
124
+ }
125
+ if (state.busy) {
126
+ lines.push("");
127
+ lines.push(chalk.dim(` pensando${".".repeat(state.frame % 4)}`));
128
+ }
129
+ return lines;
130
+ }
131
+
132
+ function filteredCommands(state, commands) {
133
+ if (!state.input.startsWith("/") || state.input.includes(" ")) return [];
134
+ const query = state.input.toLowerCase();
135
+ return Object.entries(commands).filter(([name]) =>
136
+ name.toLowerCase().startsWith(query)
137
+ );
138
+ }
139
+
140
+ function menuLines(state, commands, width) {
141
+ const items = filteredCommands(state, commands).slice(0, 8);
142
+ if (!items.length) return [];
143
+ return items.map(([name, description], index) => {
144
+ const selected = index === state.menuIndex % items.length;
145
+ const text = truncate(` ${name}`.padEnd(24) + description, width);
146
+ return selected ? chalk.inverse(padRight(text, width)) : chalk.dim(text);
147
+ });
148
+ }
149
+
150
+ function footerLines(state, width) {
151
+ const name = state.version ? `kafox ${state.version}` : "kafox";
152
+ const left = chalk.dim(truncate(`${name} | ${state.model || "auto"}`, width - 16));
153
+ const right = chalk.green(
154
+ state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
155
+ );
156
+ const gap = " ".repeat(Math.max(1, width - visible(left) - visible(right)));
157
+ return [
158
+ left + gap + right,
159
+ chalk.dim(truncate(process.cwd(), width)),
160
+ chalk.dim(truncate("Enter envia | / comandos | Ctrl+C sai", width)),
161
+ ];
162
+ }
163
+
164
+ function buildFrame(state, cols, rows, commands) {
165
+ const width = Math.min(cols - 2, 108);
166
+ const margin = Math.floor((cols - width) / 2);
167
+ const gutter = " ".repeat(Math.max(0, margin));
168
+
169
+ const header = headerLines(state, width, rows);
170
+ const menu = menuLines(state, commands, width);
171
+ const footer = footerLines(state, width);
172
+
173
+ const inputWidth = width - 2;
174
+ const start = state.input.length > inputWidth
175
+ ? clamp(state.cursor - (inputWidth - 2), 0, Math.max(0, state.input.length - inputWidth))
176
+ : 0;
177
+ const shown = state.input.slice(start, start + inputWidth);
178
+ const inputLine = state.input
179
+ ? chalk.cyan("> ") + shown
180
+ : chalk.cyan("> ") + chalk.dim(truncate("Pergunte alguma coisa...", inputWidth));
181
+ const rule = chalk.dim("-".repeat(width));
182
+
183
+ const reserved = footer.length + menu.length + 3;
184
+ const available = Math.max(1, rows - header.length - reserved);
185
+ let body = messageLines(state, width);
186
+ if (body.length > available) body = body.slice(body.length - available);
187
+ while (body.length < available) body.unshift("");
188
+
189
+ const raw = [...header, ...body, "", ...menu, inputLine, rule, ...footer];
190
+ while (raw.length < rows) raw.push("");
191
+ const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
192
+
193
+ const inputRow = header.length + available + 1 + menu.length + 1;
194
+ const cursorCol = Math.max(1, margin + 2 + (state.cursor - start) + 1);
195
+ return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
196
+ }
197
+
198
+ /* --------------------------------------------------------------------- app */
199
+
200
+ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set() } = {}) {
201
+ const stdin = process.stdin;
202
+ const stdout = process.stdout;
203
+ const state = initialState();
204
+ let spinner = null;
205
+ let closed = false;
206
+
207
+ const write = (value) => stdout.write(value);
208
+ const enterScreen = () => write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
209
+ const leaveScreen = () => write("\x1b[?25h\x1b[?1049l");
210
+
211
+ async function refreshAccount() {
212
+ const token = await ensureSession();
213
+ if (!token) return;
214
+ const response = await fetchApi("/api/bootstrap").catch(() => null);
215
+ if (!response || !response.ok) return;
216
+ const data = await response.json().catch(() => null);
217
+ if (data?.user) state.balance = data.user.balance_micros;
218
+
219
+ if (!state.model) {
220
+ const pricing = await fetchApi("/api/public/pricing")
221
+ .then((res) => res.json())
222
+ .catch(() => null);
223
+ state.model = pricing?.pricing?.[0]?.model || null;
224
+ }
225
+ }
226
+
227
+ function render() {
228
+ if (closed || state.suspended) return;
229
+ const cols = Math.max(40, stdout.columns || 80);
230
+ const rows = Math.max(16, stdout.rows || 24);
231
+ const frame = buildFrame(state, cols, rows, commands);
232
+ write("\x1b[?25l\x1b[H" + frame.lines.join("\r\n"));
233
+ write(`\x1b[${frame.cursorRow};${frame.cursorCol}H\x1b[?25h`);
234
+ }
235
+
236
+ async function capture(fn) {
237
+ const original = console.log;
238
+ const collected = [];
239
+ console.log = (...args) => collected.push(args.map((item) => String(item)).join(" "));
240
+ try {
241
+ await fn();
242
+ } catch (error) {
243
+ collected.push(`Erro: ${error.message}`);
244
+ } finally {
245
+ console.log = original;
246
+ }
247
+ return collected.join("\n");
248
+ }
249
+
250
+ async function runOutside(line) {
251
+ state.suspended = true;
252
+ leaveScreen();
253
+ stdin.setRawMode(false);
254
+ stdin.pause();
255
+ try {
256
+ await onCommand(line);
257
+ } finally {
258
+ stdin.resume();
259
+ stdin.setRawMode(true);
260
+ state.suspended = false;
261
+ enterScreen();
262
+ await refreshAccount();
263
+ render();
264
+ }
265
+ }
266
+
267
+ async function runCommand(line) {
268
+ const name = line.split(/\s+/)[0];
269
+ if (name === "/exit" || name === "/quit") return quit();
270
+
271
+ if (name === "/clear") {
272
+ state.messages = [];
273
+ state.status = "";
274
+ return render();
275
+ }
276
+ if (name === "/compact") {
277
+ if (!state.messages.length) {
278
+ state.messages.push({ role: "system", text: "Nada para compactar." });
279
+ } else {
280
+ const total = state.messages.length;
281
+ state.messages = [
282
+ {
283
+ role: "system",
284
+ text: `Contexto compactado: ${total} mensagens resumidas.`,
285
+ },
286
+ ...state.messages.slice(-6),
287
+ ];
288
+ }
289
+ return render();
290
+ }
291
+
292
+ if (interactiveCommands.has(name)) return runOutside(line);
293
+
294
+ const before = state.messages.length;
295
+ const output = (await capture(() => onCommand(line))).trim();
296
+ if (output) state.messages.push({ role: "system", text: output });
297
+ else if (state.messages.length === before) {
298
+ state.messages.push({ role: "system", text: `${name}: sem saida.` });
299
+ }
300
+ state.model = storage.getConfig().model || state.model;
301
+ render();
302
+ await refreshAccount();
303
+ render();
304
+ }
305
+
306
+ async function submit() {
307
+ const line = state.input.trim();
308
+ if (!line || state.busy) return;
309
+ state.history.push(line);
310
+ state.historyIndex = state.history.length;
311
+ state.input = "";
312
+ state.cursor = 0;
313
+ state.menuIndex = 0;
314
+
315
+ if (line.startsWith("/")) return runCommand(line);
316
+
317
+ state.messages.push({ role: "user", text: line });
318
+ state.busy = true;
319
+ render();
320
+
321
+ const result = await askModel(line, { model: state.model });
322
+ state.busy = false;
323
+ if (result.ok) {
324
+ state.messages.push({
325
+ role: "assistant",
326
+ text: result.text,
327
+ ms: result.ms,
328
+ provider: result.provider,
329
+ });
330
+ } else {
331
+ state.messages.push({ role: "system", text: result.error || "Falha ao responder.", error: true });
332
+ }
333
+ render();
334
+ await refreshAccount();
335
+ render();
336
+ }
337
+
338
+ function setInput(value, cursor) {
339
+ state.input = value;
340
+ state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
341
+ state.menuIndex = 0;
342
+ render();
343
+ }
344
+
345
+ function onKey(str, key = {}) {
346
+ if (state.suspended) return;
347
+ if (key.ctrl && key.name === "c") return quit();
348
+
349
+ const items = filteredCommands(state, commands);
350
+ const menuOpen = items.length > 0;
351
+
352
+ if (key.name === "return") return submit();
353
+ if (key.name === "tab") {
354
+ if (menuOpen) {
355
+ const selected = items[state.menuIndex % items.length];
356
+ return setInput(selected[0] + " ");
357
+ }
358
+ return;
359
+ }
360
+ if (key.name === "escape") {
361
+ if (menuOpen) return setInput("");
362
+ return;
363
+ }
364
+ if (key.name === "up" || key.name === "down") {
365
+ if (menuOpen) {
366
+ const delta = key.name === "up" ? -1 : 1;
367
+ state.menuIndex = (state.menuIndex + delta + items.length) % items.length;
368
+ return render();
369
+ }
370
+ if (!state.history.length) return;
371
+ const delta = key.name === "up" ? -1 : 1;
372
+ const next = clamp(state.historyIndex + delta, 0, state.history.length);
373
+ state.historyIndex = next;
374
+ if (next === state.history.length) return setInput("");
375
+ return setInput(state.history[next]);
376
+ }
377
+ if (key.name === "left") {
378
+ return setInput(state.input, state.cursor - 1);
379
+ }
380
+ if (key.name === "right") {
381
+ return setInput(state.input, state.cursor + 1);
382
+ }
383
+ if (key.name === "home") return setInput(state.input, 0);
384
+ if (key.name === "end") return setInput(state.input, state.input.length);
385
+ if (key.name === "backspace") {
386
+ if (!state.cursor) return;
387
+ return setInput(
388
+ state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor),
389
+ state.cursor - 1
390
+ );
391
+ }
392
+ if (key.name === "delete") {
393
+ if (state.cursor >= state.input.length) return;
394
+ return setInput(
395
+ state.input.slice(0, state.cursor) + state.input.slice(state.cursor + 1),
396
+ state.cursor
397
+ );
398
+ }
399
+ if (key.ctrl && key.name === "u") return setInput("");
400
+ if (key.ctrl && key.name === "w") {
401
+ const before = state.input.slice(0, state.cursor).replace(/\s*\S*$/, "");
402
+ return setInput(before + state.input.slice(state.cursor), before.length);
403
+ }
404
+ if (key.ctrl && key.name === "l") {
405
+ write("\x1b[H\x1b[2J");
406
+ return render();
407
+ }
408
+
409
+ if (str && !key.ctrl && !key.meta) {
410
+ const text = str.replace(/[\x00-\x1f\x7f]/g, "");
411
+ if (!text) return;
412
+ setInput(
413
+ state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor),
414
+ state.cursor + text.length
415
+ );
416
+ }
417
+ }
418
+
419
+ function quit() {
420
+ if (closed) return;
421
+ closed = true;
422
+ if (spinner) clearInterval(spinner);
423
+ stdin.removeListener("keypress", onKey);
424
+ stdout.removeListener("resize", render);
425
+ try {
426
+ stdin.setRawMode(false);
427
+ } catch {}
428
+ leaveScreen();
429
+ write("\n");
430
+ process.exit(0);
431
+ }
432
+
433
+ enterScreen();
434
+ if (stdin.isTTY) stdin.setRawMode(true);
435
+ stdin.resume();
436
+ readline.emitKeypressEvents(stdin);
437
+ stdin.on("keypress", onKey);
438
+ stdout.on("resize", render);
439
+
440
+ await refreshAccount();
441
+ render();
442
+
443
+ spinner = setInterval(() => {
444
+ if (!state.busy || closed) return;
445
+ state.frame += 1;
446
+ render();
447
+ }, 110);
448
+ spinner.unref?.();
449
+
450
+ return { state, quit, render, submit };
451
+ }
452
+
453
+ module.exports = { runTUI, buildFrame, wrap, initialState, filteredCommands };