kafox-cli 0.0.0-stage → 0.1.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 +56 -2
- package/bin/kafox.js +31 -0
- package/package.json +19 -3
- package/src/auth.js +128 -0
- package/src/cli.js +243 -0
- package/src/gateway.js +102 -0
- package/src/mascot.js +32 -0
- package/src/models.js +61 -0
- package/src/storage.js +91 -0
- package/src/theme.js +64 -0
package/README.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# kafox
|
|
2
2
|
|
|
3
|
-
|
|
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-0.1.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,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kafox-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Kafox CLI in opencode style: REPL, account 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
|
+
}
|
|
6
22
|
}
|
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,243 @@
|
|
|
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 { runTheme, setContentBackground, paint } = require("./theme");
|
|
8
|
+
const { mascot } = require("./mascot");
|
|
9
|
+
|
|
10
|
+
const COMMANDS = {
|
|
11
|
+
"/help": "show this help",
|
|
12
|
+
"/models": "list models and prices",
|
|
13
|
+
"/model <id>": "set default model",
|
|
14
|
+
"/balance": "show balance",
|
|
15
|
+
"/pricing": "show recharge packages",
|
|
16
|
+
"/key": "create an API key",
|
|
17
|
+
"/login": "login (device code SSO)",
|
|
18
|
+
"/logout": "end the session",
|
|
19
|
+
"/whoami": "show the linked account",
|
|
20
|
+
"/theme": "open theme selector",
|
|
21
|
+
"/background [url]": "set background image (modal asks for URL)",
|
|
22
|
+
"/clear": "clear conversation context",
|
|
23
|
+
"/compact": "compact conversation context",
|
|
24
|
+
"/status": "show account, balance and gateway status",
|
|
25
|
+
"/exit": "exit the CLI",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let history = [];
|
|
29
|
+
let modelOverride = null;
|
|
30
|
+
|
|
31
|
+
function pushHistory(role, content) {
|
|
32
|
+
history.push({ role, content, at: new Date().toISOString() });
|
|
33
|
+
if (history.length > 200) history = history.slice(-200);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function handleCommand(line) {
|
|
37
|
+
const [cmd, ...rest] = line.trim().split(/\s+/);
|
|
38
|
+
const arg = rest.join(" ");
|
|
39
|
+
switch (cmd) {
|
|
40
|
+
case "/help":
|
|
41
|
+
console.log(chalk.cyan("\nCommands:\n"));
|
|
42
|
+
for (const [k, v] of Object.entries(COMMANDS)) {
|
|
43
|
+
console.log(` ${chalk.bold(k)} ${chalk.dim(v)}`);
|
|
44
|
+
}
|
|
45
|
+
console.log("");
|
|
46
|
+
return true;
|
|
47
|
+
case "/models": {
|
|
48
|
+
const { listModels } = require("./models");
|
|
49
|
+
await listModels();
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
case "/model":
|
|
53
|
+
if (!arg) {
|
|
54
|
+
console.log(chalk.dim("Usage: /model <id> (see /models)"));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
modelOverride = arg;
|
|
58
|
+
storage.setConfig({ model: arg });
|
|
59
|
+
console.log(chalk.green(`Default model: ${arg}`));
|
|
60
|
+
return true;
|
|
61
|
+
case "/balance": {
|
|
62
|
+
const { showBalance } = require("./models");
|
|
63
|
+
await showBalance();
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
case "/pricing": {
|
|
67
|
+
const { showPricing } = require("./models");
|
|
68
|
+
await showPricing();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
case "/key": {
|
|
72
|
+
const { createKey } = require("./auth");
|
|
73
|
+
await createKey();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
case "/login": {
|
|
77
|
+
const { login } = require("./auth");
|
|
78
|
+
await login();
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
case "/logout": {
|
|
82
|
+
const { logout } = require("./auth");
|
|
83
|
+
await logout();
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
case "/whoami": {
|
|
87
|
+
const { whoami } = require("./auth");
|
|
88
|
+
await whoami();
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
case "/theme":
|
|
92
|
+
await runTheme();
|
|
93
|
+
return true;
|
|
94
|
+
case "/background":
|
|
95
|
+
await setContentBackground(arg || null);
|
|
96
|
+
return true;
|
|
97
|
+
case "/clear":
|
|
98
|
+
await clearContext();
|
|
99
|
+
return true;
|
|
100
|
+
case "/compact":
|
|
101
|
+
await compact();
|
|
102
|
+
return true;
|
|
103
|
+
case "/status":
|
|
104
|
+
await showStatus();
|
|
105
|
+
return true;
|
|
106
|
+
case "/exit":
|
|
107
|
+
case "/quit":
|
|
108
|
+
process.exit(0);
|
|
109
|
+
default:
|
|
110
|
+
console.log(chalk.dim(`Unknown command: ${cmd}. Type /help`));
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function clearContext() {
|
|
116
|
+
history = [];
|
|
117
|
+
console.log(chalk.green("Context cleared."));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function compact() {
|
|
121
|
+
if (!history.length) {
|
|
122
|
+
console.log(chalk.dim("Nothing to compact."));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const turns = history.length;
|
|
126
|
+
const last = history.slice(-6);
|
|
127
|
+
history = [
|
|
128
|
+
{
|
|
129
|
+
role: "system",
|
|
130
|
+
content: `(context compacted: ${turns} messages summarized)`,
|
|
131
|
+
at: new Date().toISOString(),
|
|
132
|
+
},
|
|
133
|
+
...last,
|
|
134
|
+
];
|
|
135
|
+
console.log(
|
|
136
|
+
chalk.green(
|
|
137
|
+
`Context compacted: ${turns} -> ${history.length} messages (auto summary).`
|
|
138
|
+
)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function showStatus() {
|
|
143
|
+
const token = await ensureSession();
|
|
144
|
+
if (!token) {
|
|
145
|
+
console.log(chalk.yellow("Not logged in. Run /login"));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const res = await fetchApi("/api/bootstrap");
|
|
149
|
+
const data = await res.json().catch(() => ({}));
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
console.log(chalk.red("Session invalid. Run /login"));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const cfg = storage.getConfig();
|
|
155
|
+
console.log(chalk.cyan("\nKafox status\n"));
|
|
156
|
+
console.log(` Account: ${data.user.email} (${data.user.name})`);
|
|
157
|
+
console.log(
|
|
158
|
+
` Balance: ${chalk.green(
|
|
159
|
+
(data.user.balance_micros / 1_000_000).toFixed(2) + " R$"
|
|
160
|
+
)}`
|
|
161
|
+
);
|
|
162
|
+
console.log(
|
|
163
|
+
` Model: ${modelOverride || cfg.model || "(auto)"}`
|
|
164
|
+
);
|
|
165
|
+
console.log(` Keys: ${data.keys.active}/${data.max_active_keys} active`);
|
|
166
|
+
console.log(` Gateway: ${data.gateway_url}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function runREPL() {
|
|
170
|
+
const cfg = storage.getConfig();
|
|
171
|
+
const theme = cfg.theme || "default";
|
|
172
|
+
|
|
173
|
+
console.log(paint(theme, mascot));
|
|
174
|
+
console.log(paint(theme, "\n Kafox CLI v0.1.0 - opencode style"));
|
|
175
|
+
console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
|
|
176
|
+
|
|
177
|
+
const token = await ensureSession().catch(() => null);
|
|
178
|
+
if (!token) {
|
|
179
|
+
console.log(chalk.yellow(" You are not logged in."));
|
|
180
|
+
const { doLogin } = await inquirer.prompt([
|
|
181
|
+
{
|
|
182
|
+
type: "confirm",
|
|
183
|
+
name: "doLogin",
|
|
184
|
+
message: "Login now?",
|
|
185
|
+
default: true,
|
|
186
|
+
},
|
|
187
|
+
]);
|
|
188
|
+
if (doLogin) {
|
|
189
|
+
const { login } = require("./auth");
|
|
190
|
+
await login();
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
const res = await fetchApi("/api/bootstrap").catch(() => null);
|
|
194
|
+
const data = res && res.ok ? await res.json().catch(() => null) : null;
|
|
195
|
+
if (data?.user) {
|
|
196
|
+
console.log(
|
|
197
|
+
chalk.dim(
|
|
198
|
+
` Logged in as ${data.user.email} - balance ${(data.user.balance_micros / 1_000_000).toFixed(2)} R$`
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rl = readline.createInterface({
|
|
205
|
+
input: process.stdin,
|
|
206
|
+
output: process.stdout,
|
|
207
|
+
prompt: chalk.cyan("kafox> "),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
rl.prompt();
|
|
211
|
+
rl.on("line", async (raw) => {
|
|
212
|
+
const line = raw.trim();
|
|
213
|
+
if (!line) return rl.prompt();
|
|
214
|
+
try {
|
|
215
|
+
if (line.startsWith("/")) {
|
|
216
|
+
await handleCommand(line);
|
|
217
|
+
} else {
|
|
218
|
+
pushHistory("user", line);
|
|
219
|
+
process.stdout.write(chalk.dim("thinking..."));
|
|
220
|
+
await ask(line, { model: modelOverride || storage.getConfig().model });
|
|
221
|
+
pushHistory("assistant", "(answer above)");
|
|
222
|
+
process.stdout.write("\r");
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.log(chalk.red(`\nError: ${err.message}`));
|
|
226
|
+
}
|
|
227
|
+
rl.prompt();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
rl.on("close", () => {
|
|
231
|
+
console.log(chalk.dim("\nSee you."));
|
|
232
|
+
process.exit(0);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
runREPL,
|
|
238
|
+
compact,
|
|
239
|
+
clearContext,
|
|
240
|
+
showStatus,
|
|
241
|
+
handleCommand,
|
|
242
|
+
COMMANDS,
|
|
243
|
+
};
|
package/src/gateway.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const chalk = require("chalk");
|
|
2
|
+
const storage = require("./storage");
|
|
3
|
+
const { ensureSession, fetchApi } = require("./auth");
|
|
4
|
+
|
|
5
|
+
async function ask(question, options = {}) {
|
|
6
|
+
const text = typeof question === "string" ? question : (question || []).join(" ");
|
|
7
|
+
if (!text.trim()) {
|
|
8
|
+
console.log(chalk.dim('Usage: kafox ask "your question" [-m model]'));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const token = await ensureSession();
|
|
13
|
+
if (!token) {
|
|
14
|
+
console.log(chalk.yellow("Login first: `kafox login`"));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Saldo decide se a CLI responde: sem credito, nem chega no gateway.
|
|
19
|
+
const boot = await fetchApi("/api/bootstrap");
|
|
20
|
+
if (!boot.ok) {
|
|
21
|
+
console.log(chalk.red("Session invalid. Run `kafox login`"));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const account = await boot.json();
|
|
25
|
+
const balance = Number(account?.user?.balance_micros || 0);
|
|
26
|
+
if (balance <= 0) {
|
|
27
|
+
console.log(
|
|
28
|
+
chalk.red("\nSem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard")
|
|
29
|
+
);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let model = options.model || storage.getConfig().model;
|
|
34
|
+
if (!model) {
|
|
35
|
+
const pricing = await fetchApi("/api/public/pricing")
|
|
36
|
+
.then((r) => r.json())
|
|
37
|
+
.catch(() => null);
|
|
38
|
+
model = pricing?.pricing?.[0]?.model;
|
|
39
|
+
if (!model) {
|
|
40
|
+
console.log(chalk.red("Could not load model catalog."));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let apiKey = storage.getConfig().api_key;
|
|
46
|
+
if (!apiKey) {
|
|
47
|
+
const kr = await fetchApi("/api/keys", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
body: JSON.stringify({ name: "kafox-cli" }),
|
|
50
|
+
});
|
|
51
|
+
const kd = await kr.json().catch(() => ({}));
|
|
52
|
+
if (!kr.ok) {
|
|
53
|
+
console.log(chalk.red(`Failed to create key: ${kd.error || "error"}`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
apiKey = kd.secret;
|
|
57
|
+
storage.setConfig({ api_key: apiKey });
|
|
58
|
+
console.log(chalk.dim("(key created and saved to ~/.kafox/config.json)"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(chalk.dim(`Model: ${model}`));
|
|
62
|
+
const res = await fetchApi("/api/public/playground", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
body: JSON.stringify({ api_key: apiKey, model, message: text }),
|
|
65
|
+
});
|
|
66
|
+
const data = await res.json().catch(() => ({}));
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
if (res.status === 402) {
|
|
69
|
+
console.log(
|
|
70
|
+
chalk.red("\nNo balance! Recharge at https://kafox.pypag.com/dashboard")
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
console.log(chalk.red(`\nFailed: ${data.error || "gateway error"}`));
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// O playground devolve 200 mesmo quando o gateway recusa a chamada.
|
|
78
|
+
if (data.ok === false) {
|
|
79
|
+
const detail =
|
|
80
|
+
data.body?.error?.message || data.body?.error || "gateway error";
|
|
81
|
+
if (data.status === 402) {
|
|
82
|
+
console.log(
|
|
83
|
+
chalk.red("\nNo balance! Recharge at https://kafox.pypag.com/dashboard")
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
console.log(chalk.red(`\nFailed (${data.status}): ${detail}`));
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const body = data.body;
|
|
91
|
+
const out =
|
|
92
|
+
typeof body === "string"
|
|
93
|
+
? body
|
|
94
|
+
: body?.choices?.[0]?.message?.content
|
|
95
|
+
|| body?.choices?.[0]?.text
|
|
96
|
+
|| JSON.stringify(body, null, 2);
|
|
97
|
+
console.log(chalk.green("\nAnswer:"));
|
|
98
|
+
console.log(out);
|
|
99
|
+
if (data.ms) console.log(chalk.dim(`\n(${data.ms}ms provider ${data.provider || "?"})`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { ask };
|
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 };
|