@methatech-cli/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 CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # MethaTech CLI
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
+ Chat with MethaTech models from your terminal. Black + lime, Node.js + npm only.
4
+
5
+ ## Install (local, not published yet)
6
+
7
+ ```sh
8
+ npm install
9
+ npm run build
10
+ node dist/cli.js --help
11
+ # or: npm link (then `metatech` works globally)
12
+ ```
13
+
14
+ Requires Node.js 20+.
15
+
16
+ ## Login
17
+
18
+ ```sh
19
+ metatech login
20
+ ```
21
+
22
+ 1. The CLI prints the login URL (`https://console.methatech.eu.org/login`)
23
+ and tries to open it in your browser. Sign in and pick your account.
24
+ 2. Open the token page (`https://console.methatech.eu.org/panel/token`),
25
+ copy an API key, and paste it into the CLI (input is masked).
26
+ 3. The key is saved to `~/.config/metatech/credentials.json` (0600, dir 0700).
27
+
28
+ The panel does not have a device-code/polling endpoint yet, so this phase
29
+ uses manual key paste. A full browser login + polling flow is a separate
30
+ panel follow-up. You can also use the env var instead:
31
+
32
+ ```sh
33
+ export METATECH_API_KEY="sk-..."
34
+ ```
35
+
36
+ ## Commands
37
+
38
+ | Command | What it does |
39
+ |---|---|
40
+ | `metatech` (no args) | Chat TUI with the default model |
41
+ | `metatech chat [--model <id>]` | Chat TUI |
42
+ | `metatech models` | Model list: FREE $0.00 section + paid prices in $/1M |
43
+ | `metatech account` | Spending limit, used, balance (all $) |
44
+ | `metatech keys` | How to manage API keys in the panel |
45
+ | `metatech topup` | Open the top-up page |
46
+ | `metatech logout` | Delete the saved key |
47
+
48
+ Chat keys: `Enter` send · `↑↓` change model · `/models` list · `/model <id>`
49
+ switch · `/clear` clear · `/help` help · `/exit` quit · `Ctrl+C` quit.
50
+
51
+ ## Prices
52
+
53
+ All prices are in dollars. The model list is fetched live from the panel
54
+ (`/api/available_model`, $ = ratio × 2). If the server cannot be reached,
55
+ a compiled-in price table (2026-11-08) is used. The 10 FREE models are
56
+ always listed first at $0.00 — they run on your free credits.
57
+
58
+ ## Config
59
+
60
+ - `~/.config/metatech/credentials.json` — API key + last used model
61
+ (override dir with `METATECH_CONFIG_DIR`)
62
+ - `METATECH_API_KEY` — env var takes precedence over the file
63
+ - `METATECH_API_BASE` (default `https://api.methatech.eu.org`) and
64
+ `METATECH_CONSOLE_BASE` (default `https://console.methatech.eu.org`)
65
+ — override for local testing
66
+
67
+ ## Not built yet (later phases)
68
+
69
+ - Agent coding mode (file tools, run commands, confirm before dangerous ones)
70
+ - Full browser login + polling (needs a panel device-code endpoint)
71
+ - npm publish (`@methatech-cli/cli`) — only when Reza says go
package/dist/api.js ADDED
@@ -0,0 +1,146 @@
1
+ import { apiBase } from "./config.js";
2
+ export class ApiError extends Error {
3
+ status;
4
+ code;
5
+ constructor(status, message, code = "") {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ }
10
+ }
11
+ function friendlyError(status, bodyText) {
12
+ let message = bodyText.slice(0, 300);
13
+ let code = "";
14
+ try {
15
+ const j = JSON.parse(bodyText);
16
+ if (j.error?.message)
17
+ message = j.error.message;
18
+ else if (typeof j.message === "string")
19
+ message = j.message;
20
+ if (j.error?.code)
21
+ code = j.error.code;
22
+ }
23
+ catch {
24
+ // keep raw text
25
+ }
26
+ if (status === 401)
27
+ return new ApiError(401, "API key tidak valid. Jalankan: metatech login", code);
28
+ if (status === 402) {
29
+ return new ApiError(402, "Saldo dan free credits habis. Top up dulu: metatech topup", code);
30
+ }
31
+ if (status === 429)
32
+ return new ApiError(429, `Terlalu banyak request / model sibuk (${message})`, code);
33
+ return new ApiError(status, `API error ${status}: ${message}`, code);
34
+ }
35
+ export async function listModels(apiKey) {
36
+ const res = await fetch(`${apiBase()}/v1/models`, {
37
+ headers: { Authorization: `Bearer ${apiKey}` },
38
+ signal: AbortSignal.timeout(20000),
39
+ });
40
+ if (!res.ok)
41
+ throw friendlyError(res.status, await res.text());
42
+ const json = (await res.json());
43
+ return (json.data ?? []).map((m) => ({ id: m.id ?? "", ownedBy: m.owned_by ?? "" }));
44
+ }
45
+ export async function getSubscription(apiKey) {
46
+ const res = await fetch(`${apiBase()}/v1/dashboard/billing/subscription`, {
47
+ headers: { Authorization: `Bearer ${apiKey}` },
48
+ signal: AbortSignal.timeout(20000),
49
+ });
50
+ if (!res.ok)
51
+ throw friendlyError(res.status, await res.text());
52
+ const j = (await res.json());
53
+ return {
54
+ hasPaymentMethod: j.has_payment_method ?? false,
55
+ softLimitUsd: j.soft_limit_usd ?? 0,
56
+ hardLimitUsd: j.hard_limit_usd ?? 0,
57
+ systemHardLimitUsd: j.system_hard_limit_usd ?? 0,
58
+ accessUntil: j.access_until ?? 0,
59
+ };
60
+ }
61
+ /**
62
+ * Total usage as reported by the billing endpoint. The panel reports this
63
+ * in cents (used_quota / 5000): divide by 100 to get dollars.
64
+ * Verified against the live panel: used_quota 191,137 -> total_usage 38.2274.
65
+ */
66
+ export async function getTotalUsageCents(apiKey) {
67
+ const res = await fetch(`${apiBase()}/v1/dashboard/billing/usage`, {
68
+ headers: { Authorization: `Bearer ${apiKey}` },
69
+ signal: AbortSignal.timeout(20000),
70
+ });
71
+ if (!res.ok)
72
+ throw friendlyError(res.status, await res.text());
73
+ const j = (await res.json());
74
+ return typeof j.total_usage === "number" ? j.total_usage : 0;
75
+ }
76
+ /**
77
+ * Stream one chat completion. The panel reports usage in the final SSE
78
+ * chunk when stream_options.include_usage is set (verified live 2026-11-08).
79
+ */
80
+ export async function streamChat(apiKey, model, messages, handlers, maxTokens = 1024) {
81
+ const res = await fetch(`${apiBase()}/v1/chat/completions`, {
82
+ method: "POST",
83
+ headers: {
84
+ Authorization: `Bearer ${apiKey}`,
85
+ "Content-Type": "application/json",
86
+ },
87
+ body: JSON.stringify({
88
+ model,
89
+ messages,
90
+ max_tokens: maxTokens,
91
+ stream: true,
92
+ stream_options: { include_usage: true },
93
+ }),
94
+ signal: AbortSignal.timeout(120000),
95
+ });
96
+ if (!res.ok)
97
+ throw friendlyError(res.status, await res.text());
98
+ if (!res.body)
99
+ throw new ApiError(0, "Tidak ada body stream dari server");
100
+ const reader = res.body.getReader();
101
+ const decoder = new TextDecoder();
102
+ let buffer = "";
103
+ let usage = null;
104
+ const handleLine = (line) => {
105
+ if (!line.startsWith("data:"))
106
+ return;
107
+ const payload = line.slice(5).trim();
108
+ if (payload === "[DONE]")
109
+ return;
110
+ try {
111
+ const chunk = JSON.parse(payload);
112
+ const delta = chunk.choices?.[0]?.delta?.content;
113
+ if (typeof delta === "string" && delta.length > 0)
114
+ handlers.onDelta(delta);
115
+ if (chunk.usage && typeof chunk.usage.prompt_tokens === "number") {
116
+ usage = {
117
+ promptTokens: chunk.usage.prompt_tokens,
118
+ completionTokens: chunk.usage.completion_tokens ?? 0,
119
+ totalTokens: chunk.usage.total_tokens ?? 0,
120
+ };
121
+ handlers.onUsage(usage);
122
+ }
123
+ }
124
+ catch {
125
+ // A split JSON line is reassembled by the buffer below; ignore the rest.
126
+ }
127
+ };
128
+ while (true) {
129
+ const { done, value } = await reader.read();
130
+ if (done)
131
+ break;
132
+ buffer += decoder.decode(value, { stream: true });
133
+ let idx = buffer.indexOf("\n\n");
134
+ while (idx !== -1) {
135
+ const event = buffer.slice(0, idx);
136
+ buffer = buffer.slice(idx + 2);
137
+ for (const line of event.split("\n"))
138
+ handleLine(line.trim());
139
+ idx = buffer.indexOf("\n\n");
140
+ }
141
+ }
142
+ // Flush any trailing partial event.
143
+ for (const line of buffer.split("\n"))
144
+ handleLine(line.trim());
145
+ return { usage };
146
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./commands.js";
3
+ main();
@@ -0,0 +1,233 @@
1
+ import { spawn } from "node:child_process";
2
+ import { render } from "ink";
3
+ import React from "react";
4
+ import { Command } from "commander";
5
+ import { readCredentials, writeCredentials, deleteCredentials, resolveApiKey, resolveDefaultModel, LOGIN_URL, TOKEN_PAGE_URL, TOPUP_URL, } from "./config.js";
6
+ import { fetchCatalog, DEFAULT_MODEL, FALLBACK_MODEL } from "./models.js";
7
+ import { getSubscription, getTotalUsageCents, listModels, ApiError } from "./api.js";
8
+ import ChatApp from "./ui/ChatApp.js";
9
+ import ModelsScreen from "./ui/ModelsScreen.js";
10
+ import AccountScreen from "./ui/AccountScreen.js";
11
+ function tryOpenUrl(url) {
12
+ const opener = process.platform === "darwin"
13
+ ? "open"
14
+ : process.env.TERMUX_VERSION || process.env.PREFIX?.includes("com.termux")
15
+ ? "termux-open-url"
16
+ : "xdg-open";
17
+ try {
18
+ const child = spawn(opener, [url], { stdio: "ignore", detached: true });
19
+ child.unref();
20
+ }
21
+ catch {
22
+ // Opening the browser is best-effort; the URL is always printed.
23
+ }
24
+ }
25
+ function askHidden(question) {
26
+ return new Promise((resolve) => {
27
+ process.stdout.write(question);
28
+ const stdin = process.stdin;
29
+ // Raw mode disables tty echo, so the pasted key never appears on screen.
30
+ stdin.setRawMode(true);
31
+ stdin.resume();
32
+ let value = "";
33
+ const onData = (chunk) => {
34
+ const s = chunk.toString("utf-8");
35
+ for (const ch of s) {
36
+ if (ch === "\r" || ch === "\n") {
37
+ stdin.setRawMode(false);
38
+ stdin.removeListener("data", onData);
39
+ stdin.pause();
40
+ process.stdout.write("\n");
41
+ resolve(value.trim());
42
+ return;
43
+ }
44
+ if (ch === "\u0003") {
45
+ stdin.setRawMode(false);
46
+ process.stdout.write("\n");
47
+ process.exit(130);
48
+ }
49
+ if (ch === "\u007f") {
50
+ value = value.slice(0, -1);
51
+ process.stdout.write("\b \b");
52
+ }
53
+ else if (ch >= " ") {
54
+ value += ch;
55
+ process.stdout.write("*");
56
+ }
57
+ }
58
+ };
59
+ stdin.on("data", onData);
60
+ });
61
+ }
62
+ async function cmdLogin() {
63
+ console.log("MethaTech login");
64
+ console.log("");
65
+ console.log("1. Buka URL ini di browser dan pilih akun Google / GitHub:");
66
+ console.log(` ${LOGIN_URL}`);
67
+ tryOpenUrl(LOGIN_URL);
68
+ console.log("");
69
+ console.log("2. Setelah login, buka halaman token dan salin API key kamu:");
70
+ console.log(` ${TOKEN_PAGE_URL}`);
71
+ console.log("");
72
+ console.log("Catatan: panel belum punya endpoint device-code/polling, jadi");
73
+ console.log("untuk fase ini key ditempel manual. Alur login browser + polling");
74
+ console.log("penuh jadi follow-up panel terpisah.");
75
+ console.log("");
76
+ const existing = readCredentials();
77
+ if (existing)
78
+ console.log("Sudah ada key tersimpan — menempel key baru akan menggantinya.");
79
+ const key = await askHidden("Tempel API key: ");
80
+ if (!key) {
81
+ console.log("Key kosong, dibatalkan.");
82
+ return;
83
+ }
84
+ // Validate the key once before saving.
85
+ try {
86
+ await listModels(key);
87
+ }
88
+ catch (e) {
89
+ const message = e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e);
90
+ console.log(`Key tidak bisa dipakai: ${message}`);
91
+ return;
92
+ }
93
+ writeCredentials({ apiKey: key, defaultModel: existing?.defaultModel ?? DEFAULT_MODEL, savedAt: new Date().toISOString() });
94
+ console.log("Login berhasil. Key tersimpan di ~/.config/metatech/credentials.json (0600).");
95
+ }
96
+ function requireKey() {
97
+ const key = resolveApiKey();
98
+ if (!key) {
99
+ console.log("Belum login. Jalankan: metatech login");
100
+ console.log("atau set env METATECH_API_KEY.");
101
+ process.exit(1);
102
+ }
103
+ return key;
104
+ }
105
+ async function balanceOf(apiKey) {
106
+ try {
107
+ const [sub, cents] = await Promise.all([getSubscription(apiKey), getTotalUsageCents(apiKey)]);
108
+ return Math.max(0, sub.hardLimitUsd - cents / 100);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ async function cmdChat(modelOpt) {
115
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
116
+ console.log("metatech chat butuh terminal interaktif.");
117
+ process.exit(1);
118
+ }
119
+ const apiKey = requireKey();
120
+ const savedModel = resolveDefaultModel();
121
+ const requested = modelOpt ?? savedModel ?? DEFAULT_MODEL;
122
+ const catalog = await fetchCatalog();
123
+ const known = catalog.entries.some((e) => e.id === requested);
124
+ // Retired/unknown ids are coerced to the fallback instead of erroring
125
+ // (Freebuff lesson, incident #1801).
126
+ const model = known ? requested : FALLBACK_MODEL;
127
+ if (!known)
128
+ console.log(`Model ${requested} tidak dikenal, memakai ${FALLBACK_MODEL}.`);
129
+ const balance = await balanceOf(apiKey);
130
+ const app = render(React.createElement(ChatApp, { apiKey, initialModel: model, catalog, balanceUsd: balance }));
131
+ await app.waitUntilExit();
132
+ }
133
+ async function cmdModels() {
134
+ const apiKey = resolveApiKey();
135
+ const catalog = await fetchCatalog();
136
+ let selected = null;
137
+ if (apiKey) {
138
+ try {
139
+ const models = await listModels(apiKey);
140
+ if (models.length > 0)
141
+ selected = resolveDefaultModel() ?? DEFAULT_MODEL;
142
+ }
143
+ catch {
144
+ // Key invalid: the list still renders from the public catalog.
145
+ }
146
+ }
147
+ else {
148
+ selected = resolveDefaultModel() ?? DEFAULT_MODEL;
149
+ }
150
+ void selected;
151
+ const app = render(React.createElement(ModelsScreen, { catalog, selectedModel: selected }));
152
+ await app.waitUntilExit();
153
+ }
154
+ async function cmdAccount() {
155
+ const apiKey = requireKey();
156
+ const keySource = process.env.METATECH_API_KEY ? "env" : "file";
157
+ let subscription = null;
158
+ let usedUsd = null;
159
+ try {
160
+ subscription = await getSubscription(apiKey);
161
+ const cents = await getTotalUsageCents(apiKey);
162
+ usedUsd = cents / 100;
163
+ }
164
+ catch (e) {
165
+ const message = e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e);
166
+ console.log(`Tidak bisa membaca akun: ${message}`);
167
+ process.exit(1);
168
+ }
169
+ const app = render(React.createElement(AccountScreen, { subscription, usedUsd, keySource }));
170
+ await app.waitUntilExit();
171
+ }
172
+ function cmdKeys() {
173
+ const has = readCredentials() !== null || !!process.env.METATECH_API_KEY;
174
+ console.log("MethaTech API keys");
175
+ console.log("");
176
+ console.log(`Key tersimpan di CLI ini: ${has ? "ada" : "tidak ada"}`);
177
+ console.log("Kelola dan buat key baru di panel:");
178
+ console.log(` ${TOKEN_PAGE_URL}`);
179
+ console.log("Lalu jalankan: metatech login");
180
+ }
181
+ function cmdTopup() {
182
+ console.log("Top up MethaTech lewat:");
183
+ console.log(` ${TOPUP_URL}`);
184
+ tryOpenUrl(TOPUP_URL);
185
+ }
186
+ function cmdLogout() {
187
+ if (deleteCredentials())
188
+ console.log("Logout berhasil. Key tersimpan dihapus.");
189
+ else
190
+ console.log("Tidak ada key tersimpan.");
191
+ }
192
+ export function main() {
193
+ const program = new Command();
194
+ program
195
+ .name("metatech")
196
+ .description("MethaTech CLI — chat with MethaTech models from your terminal")
197
+ .version("0.1.0");
198
+ program
199
+ .command("login")
200
+ .description("Login lewat browser, lalu tempel API key dari panel")
201
+ .action(() => void cmdLogin());
202
+ program
203
+ .command("chat")
204
+ .description("Chat TUI dengan model MethaTech")
205
+ .option("--model <id>", "model yang dipakai")
206
+ .action((opts) => void cmdChat(opts.model));
207
+ program
208
+ .command("models")
209
+ .description("Daftar model + harga")
210
+ .action(() => void cmdModels());
211
+ program
212
+ .command("account")
213
+ .description("Lihat akun dan saldo")
214
+ .action(() => void cmdAccount());
215
+ program
216
+ .command("keys")
217
+ .description("Lihat cara mengelola API key")
218
+ .action(() => cmdKeys());
219
+ program
220
+ .command("topup")
221
+ .description("Buka halaman top up")
222
+ .action(() => cmdTopup());
223
+ program
224
+ .command("logout")
225
+ .description("Hapus key tersimpan")
226
+ .action(() => cmdLogout());
227
+ // No command = chat with the default model (Freebuff behaviour).
228
+ if (process.argv.length <= 2) {
229
+ void cmdChat();
230
+ return;
231
+ }
232
+ program.parse(process.argv);
233
+ }
package/dist/config.js ADDED
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ // Config dir: ~/.config/metatech (override with METATECH_CONFIG_DIR).
5
+ // Follows the Freebuff pattern: credentials.json, dir 0700, file 0600.
6
+ export function configDir() {
7
+ const override = process.env.METATECH_CONFIG_DIR;
8
+ if (override)
9
+ return override;
10
+ return path.join(os.homedir(), ".config", "metatech");
11
+ }
12
+ export function credentialsPath() {
13
+ return path.join(configDir(), "credentials.json");
14
+ }
15
+ export function readCredentials() {
16
+ try {
17
+ const raw = fs.readFileSync(credentialsPath(), "utf-8");
18
+ const parsed = JSON.parse(raw);
19
+ if (typeof parsed.apiKey === "string" && parsed.apiKey.length > 0) {
20
+ return parsed;
21
+ }
22
+ return null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export function writeCredentials(creds) {
29
+ const dir = configDir();
30
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ try {
32
+ fs.chmodSync(dir, 0o700);
33
+ }
34
+ catch {
35
+ // best effort; some filesystems ignore chmod
36
+ }
37
+ const file = credentialsPath();
38
+ fs.writeFileSync(file, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
39
+ try {
40
+ fs.chmodSync(file, 0o600);
41
+ }
42
+ catch {
43
+ // best effort
44
+ }
45
+ }
46
+ export function deleteCredentials() {
47
+ try {
48
+ fs.unlinkSync(credentialsPath());
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** Resolution order: env var first, then the saved credentials file. */
56
+ export function resolveApiKey() {
57
+ const env = process.env.METATECH_API_KEY;
58
+ if (env && env.trim().length > 0)
59
+ return env.trim();
60
+ return readCredentials()?.apiKey ?? null;
61
+ }
62
+ export function resolveDefaultModel() {
63
+ return readCredentials()?.defaultModel ?? null;
64
+ }
65
+ export function saveDefaultModel(model) {
66
+ const creds = readCredentials();
67
+ if (!creds)
68
+ return;
69
+ writeCredentials({ ...creds, defaultModel: model });
70
+ }
71
+ export function apiBase() {
72
+ return (process.env.METATECH_API_BASE ?? "https://api.methatech.eu.org").replace(/\/$/, "");
73
+ }
74
+ export function consoleBase() {
75
+ return (process.env.METATECH_CONSOLE_BASE ?? "https://console.methatech.eu.org").replace(/\/$/, "");
76
+ }
77
+ export const LOGIN_URL = "https://console.methatech.eu.org/login";
78
+ export const TOKEN_PAGE_URL = "https://console.methatech.eu.org/panel/token";
79
+ export const TOPUP_URL = "https://pay.methatech.eu.org";
package/dist/cost.js ADDED
@@ -0,0 +1,14 @@
1
+ /** Dollar cost of one answer at the model's per-1M-token rates. */
2
+ export function costOf(usage, price) {
3
+ if (!price)
4
+ return null;
5
+ return (usage.promptTokens / 1_000_000) * price.input + (usage.completionTokens / 1_000_000) * price.output;
6
+ }
7
+ export function formatUsd(value) {
8
+ return `$${value.toFixed(4)}`;
9
+ }
10
+ export function formatUsageLine(usage, price) {
11
+ const cost = costOf(usage, price);
12
+ const costText = cost === null ? "cost —" : formatUsd(cost);
13
+ return `${usage.promptTokens} in / ${usage.completionTokens} out · ${costText}`;
14
+ }
package/dist/models.js ADDED
@@ -0,0 +1,103 @@
1
+ import { consoleBase } from "./config.js";
2
+ // The 10 FREE models. Same set as the panel's free allowlist.
3
+ export const FREE_MODEL_IDS = [
4
+ "deepseek-v4-flash",
5
+ "glm-5.3-flash",
6
+ "gpt-6-luna",
7
+ "gemini-3.8-flash",
8
+ "gpt-6-astra",
9
+ "gemini-3.1-pro",
10
+ "qwen3.8-flash",
11
+ "qwen3.7-max",
12
+ "kimi-k2.7-code",
13
+ "deepseek-v4.1-flash",
14
+ ];
15
+ export const DEFAULT_MODEL = "glm-5.3-flash";
16
+ export const FALLBACK_MODEL = "deepseek-v4-flash";
17
+ // Compiled-in fallback, taken from the live /api/available_model on
18
+ // 2026-11-08. Live price = ratio * 2 ($1 = 500,000 quota). The CLI prefers
19
+ // the live fetch; this table is only used when the server cannot be reached.
20
+ export const COMPILED_PRICES = {
21
+ "claude-opus-4-6-thinking": { input: 8.35, output: 41.6 },
22
+ "claude-sonnet-4-6": { input: 5.0, output: 25.0 },
23
+ "command-a-03-2025": { input: 4.2, output: 16.65 },
24
+ "command-r-08-2024": { input: 0.25, output: 1.0 },
25
+ "command-r-plus-08-2024": { input: 4.2, output: 16.65 },
26
+ "command-r7b-12-2024": { input: 0.065, output: 0.25 },
27
+ "deepseek-v4-flash": { input: 0.5, output: 2.0 },
28
+ "deepseek-v4-pro": { input: 2.2, output: 6.6 },
29
+ "deepseek-v4.1-flash": { input: 0.5, output: 2.0 },
30
+ "gemini-3.1-pro": { input: 3.35, output: 20.0 },
31
+ "gemini-3.8-flash": { input: 2.5, output: 12.5 },
32
+ "gemini-4-argon": { input: 6.7, output: 33.3 },
33
+ "glm-5.2": { input: 2.35, output: 7.35 },
34
+ "glm-5.3": { input: 2.35, output: 7.35 },
35
+ "glm-5.3-flash": { input: 0.25, output: 0.85 },
36
+ "gpt-6-astra": { input: 17.0, output: 83.2 },
37
+ "gpt-6-luna": { input: 0.17, output: 0.85 },
38
+ "gpt-6.1-sol": { input: 3.35, output: 16.65 },
39
+ "gpt-6.1-terra": { input: 1.7, output: 8.35 },
40
+ "gpt-oss-120b-medium": { input: 0.25, output: 1.0 },
41
+ "grok-4.7": { input: 3.35, output: 10.0 },
42
+ "kimi-k3.1-pro": { input: 3.35, output: 16.65 },
43
+ "methatech/uncensored": { input: 0.2, output: 0.6 },
44
+ "qwen3.6-plus": { input: 0.85, output: 5.0 },
45
+ "qwen3.7-max": { input: 4.2, output: 12.5 },
46
+ "qwen3.7-plus": { input: 0.7, output: 2.7 },
47
+ "qwen3.8-flash": { input: 0.25, output: 0.8 },
48
+ };
49
+ function buildCatalog(prices) {
50
+ const ids = new Set([...Object.keys(prices), ...FREE_MODEL_IDS]);
51
+ const entries = [...ids].map((id) => ({
52
+ id,
53
+ ownedBy: "",
54
+ price: prices[id] ?? null,
55
+ free: FREE_MODEL_IDS.includes(id),
56
+ }));
57
+ // FREE first, then cheapest input price first (Freebuff pattern).
58
+ entries.sort((a, b) => {
59
+ if (a.free !== b.free)
60
+ return a.free ? -1 : 1;
61
+ const pa = a.price?.input ?? Number.POSITIVE_INFINITY;
62
+ const pb = b.price?.input ?? Number.POSITIVE_INFINITY;
63
+ return pa - pb;
64
+ });
65
+ return { entries, live: false };
66
+ }
67
+ /** Fetch the live price table from the console. Falls back to the compiled table. */
68
+ export async function fetchCatalog() {
69
+ try {
70
+ const res = await fetch(`${consoleBase()}/api/available_model`, {
71
+ signal: AbortSignal.timeout(15000),
72
+ });
73
+ if (!res.ok)
74
+ return buildCatalog(COMPILED_PRICES);
75
+ const json = (await res.json());
76
+ const data = json.data ?? {};
77
+ const prices = {};
78
+ const ownedBy = {};
79
+ for (const [id, m] of Object.entries(data)) {
80
+ if (typeof m.price?.input === "number" && typeof m.price?.output === "number") {
81
+ prices[id] = { input: m.price.input * 2, output: m.price.output * 2 };
82
+ }
83
+ if (typeof m.owned_by === "string")
84
+ ownedBy[id] = m.owned_by;
85
+ }
86
+ if (Object.keys(prices).length === 0)
87
+ return buildCatalog(COMPILED_PRICES);
88
+ const catalog = buildCatalog(prices);
89
+ catalog.live = true;
90
+ for (const e of catalog.entries)
91
+ e.ownedBy = ownedBy[e.id] ?? "";
92
+ return catalog;
93
+ }
94
+ catch {
95
+ return buildCatalog(COMPILED_PRICES);
96
+ }
97
+ }
98
+ export function priceFor(catalog, modelId) {
99
+ return catalog.entries.find((e) => e.id === modelId)?.price ?? COMPILED_PRICES[modelId] ?? null;
100
+ }
101
+ export function isFreeModel(modelId) {
102
+ return FREE_MODEL_IDS.includes(modelId);
103
+ }
package/dist/theme.js ADDED
@@ -0,0 +1,11 @@
1
+ // MethaTech identity tokens. EXACT values, do not change.
2
+ // Source of truth: panel theme (black glassmorphism + neon lime).
3
+ export const theme = {
4
+ background: "#050807",
5
+ secondary: "#0A100D",
6
+ accent: "#A3FF45",
7
+ accentSecondary: "#78E62E",
8
+ text: "#F5F7F5",
9
+ muted: "#A1AAA4",
10
+ dim: "#69746D",
11
+ };
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { theme } from "../theme.js";
4
+ import { formatUsd } from "../cost.js";
5
+ import { TOPUP_URL, LOGIN_URL } from "../config.js";
6
+ /**
7
+ * Account screen. Shows only what the Bearer API can prove:
8
+ * spending limit + used + derived balance. Email and free-credit balance
9
+ * are NOT available through any Bearer endpoint, so they are not invented
10
+ * here — the panel link is shown instead.
11
+ */
12
+ export default function AccountScreen({ subscription, usedUsd, keySource }) {
13
+ const { exit } = useApp();
14
+ useInput((input, key) => {
15
+ if (input === "q" || key.escape || (key.ctrl && input === "c"))
16
+ exit();
17
+ });
18
+ const limit = subscription?.hardLimitUsd ?? null;
19
+ const balance = limit !== null && usedUsd !== null ? Math.max(0, limit - usedUsd) : null;
20
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: "MethaTech" }), _jsx(Text, { color: theme.muted, children: " \u00B7 account" })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Signed in " }), _jsx(Text, { color: theme.text, bold: true, children: "yes" }), _jsxs(Text, { color: theme.dim, children: [" (key dari ", keySource === "env" ? "METATECH_API_KEY" : "file tersimpan", ")"] })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Spending limit " }), _jsx(Text, { color: theme.text, bold: true, children: limit === null ? "—" : formatUsd(limit) })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Used " }), _jsx(Text, { color: theme.text, bold: true, children: usedUsd === null ? "—" : formatUsd(usedUsd) })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Balance " }), _jsx(Text, { color: theme.accent, bold: true, children: balance === null ? "—" : formatUsd(balance) })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Top up " }), _jsx(Text, { color: theme.accent, children: TOPUP_URL })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.dim, children: "Email dan free credits tidak tersedia lewat API key." }), _jsxs(Text, { color: theme.dim, children: ["Lihat detail lengkap di panel: ", LOGIN_URL] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.dim, children: "q keluar \u00B7 metatech topup untuk isi saldo \u00B7 metatech logout untuk keluar" }) })] }));
21
+ }
@@ -0,0 +1,185 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useRef } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import { theme } from "../theme.js";
5
+ import { streamChat, ApiError } from "../api.js";
6
+ import { priceFor, isFreeModel, DEFAULT_MODEL, FALLBACK_MODEL } from "../models.js";
7
+ import { formatUsageLine, formatUsd } from "../cost.js";
8
+ import { saveDefaultModel } from "../config.js";
9
+ const HELP_LINES = [
10
+ "/models — lihat daftar model",
11
+ "/model <id> — ganti model",
12
+ "/clear — bersihkan layar chat",
13
+ "/help — bantuan ini",
14
+ "/exit — keluar",
15
+ "↑↓ ganti model · Enter kirim · Ctrl+C keluar",
16
+ ];
17
+ export default function ChatApp({ apiKey, initialModel, catalog, balanceUsd }) {
18
+ const { exit } = useApp();
19
+ const [model, setModel] = useState(initialModel);
20
+ const [messages, setMessages] = useState([]);
21
+ const [input, setInput] = useState("");
22
+ const [sending, setSending] = useState(false);
23
+ const [lastUsage, setLastUsage] = useState(null);
24
+ const [error, setError] = useState(null);
25
+ const sendingRef = useRef(false);
26
+ const modelRef = useRef(model);
27
+ modelRef.current = model;
28
+ const modelIds = catalog.entries.map((e) => e.id);
29
+ const price = priceFor(catalog, model);
30
+ const free = isFreeModel(model);
31
+ const cycleModel = (dir) => {
32
+ const idx = modelIds.indexOf(modelRef.current);
33
+ const next = modelIds[(idx + dir + modelIds.length) % modelIds.length] ?? DEFAULT_MODEL;
34
+ modelRef.current = next;
35
+ setModel(next);
36
+ saveDefaultModel(next);
37
+ };
38
+ const setModelById = (id) => {
39
+ if (!modelIds.includes(id)) {
40
+ // Retired/unknown ids are coerced to the fallback instead of erroring
41
+ // (Freebuff lesson, incident #1801).
42
+ setMessages((m) => [
43
+ ...m,
44
+ { role: "assistant", content: `Model ${id} tidak dikenal, dialihkan ke ${FALLBACK_MODEL}.`, kind: "system" },
45
+ ]);
46
+ modelRef.current = FALLBACK_MODEL;
47
+ setModel(FALLBACK_MODEL);
48
+ return false;
49
+ }
50
+ modelRef.current = id;
51
+ setModel(id);
52
+ saveDefaultModel(id);
53
+ return true;
54
+ };
55
+ const send = async (text) => {
56
+ if (sendingRef.current)
57
+ return;
58
+ sendingRef.current = true;
59
+ setSending(true);
60
+ setError(null);
61
+ const history = [...messages.filter((m) => m.kind === "chat"), { role: "user", content: text }];
62
+ setMessages((m) => [...m, { role: "user", content: text, kind: "chat" }]);
63
+ // Placeholder assistant message, filled by the stream.
64
+ setMessages((m) => [...m, { role: "assistant", content: "", kind: "chat" }]);
65
+ const currentModel = modelRef.current;
66
+ try {
67
+ const { usage } = await streamChat(apiKey, currentModel, history, {
68
+ onDelta: (delta) => {
69
+ setMessages((m) => {
70
+ const copy = [...m];
71
+ const last = copy[copy.length - 1];
72
+ if (last && last.role === "assistant") {
73
+ copy[copy.length - 1] = { ...last, content: last.content + delta };
74
+ }
75
+ return copy;
76
+ });
77
+ },
78
+ onUsage: (u) => setLastUsage({ usage: u, model: currentModel }),
79
+ });
80
+ if (!usage) {
81
+ setLastUsage((prev) => prev);
82
+ }
83
+ }
84
+ catch (e) {
85
+ const message = e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e);
86
+ setError(message);
87
+ setMessages((m) => {
88
+ const copy = [...m];
89
+ const last = copy[copy.length - 1];
90
+ if (last && last.role === "assistant" && last.content === "")
91
+ copy.pop();
92
+ return copy;
93
+ });
94
+ }
95
+ finally {
96
+ sendingRef.current = false;
97
+ setSending(false);
98
+ }
99
+ };
100
+ const handleSlash = (raw) => {
101
+ const [cmd, ...rest] = raw.slice(1).split(/\s+/);
102
+ const arg = rest.join(" ").trim();
103
+ switch (cmd) {
104
+ case "exit":
105
+ case "quit":
106
+ exit();
107
+ break;
108
+ case "clear":
109
+ setMessages([]);
110
+ setLastUsage(null);
111
+ break;
112
+ case "help":
113
+ setMessages((m) => [...m, { role: "assistant", content: HELP_LINES.join("\n"), kind: "system" }]);
114
+ break;
115
+ case "models": {
116
+ const freeList = catalog.entries.filter((e) => e.free).map((e) => e.id);
117
+ const paidList = catalog.entries
118
+ .filter((e) => !e.free && e.price)
119
+ .map((e) => `${e.id} $${e.price.input.toFixed(2)}/$${e.price.output.toFixed(2)}`);
120
+ setMessages((m) => [
121
+ ...m,
122
+ {
123
+ role: "assistant",
124
+ content: "FREE $0.00\n" +
125
+ freeList.join("\n") +
126
+ "\n\nPAID ($ per 1M in/out)\n" +
127
+ paidList.join("\n"),
128
+ kind: "system",
129
+ },
130
+ ]);
131
+ break;
132
+ }
133
+ case "model":
134
+ if (arg)
135
+ setModelById(arg);
136
+ else
137
+ setMessages((m) => [...m, { role: "assistant", content: `Model aktif: ${modelRef.current}`, kind: "system" }]);
138
+ break;
139
+ default:
140
+ setMessages((m) => [
141
+ ...m,
142
+ { role: "assistant", content: `Perintah tidak dikenal: /${cmd}. Ketik /help.`, kind: "system" },
143
+ ]);
144
+ }
145
+ };
146
+ useInput((inputKey, key) => {
147
+ if (key.ctrl && inputKey === "c") {
148
+ exit();
149
+ return;
150
+ }
151
+ if (sendingRef.current)
152
+ return;
153
+ if (key.return) {
154
+ const text = input.trim();
155
+ if (!text)
156
+ return;
157
+ setInput("");
158
+ if (text.startsWith("/"))
159
+ handleSlash(text);
160
+ else
161
+ void send(text);
162
+ return;
163
+ }
164
+ if (key.upArrow) {
165
+ cycleModel(-1);
166
+ return;
167
+ }
168
+ if (key.downArrow) {
169
+ cycleModel(1);
170
+ return;
171
+ }
172
+ if (key.backspace || key.delete) {
173
+ setInput((v) => v.slice(0, -1));
174
+ return;
175
+ }
176
+ if (key.escape) {
177
+ setInput("");
178
+ return;
179
+ }
180
+ if (inputKey && !key.ctrl && !key.meta) {
181
+ setInput((v) => v + inputKey);
182
+ }
183
+ });
184
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: "MethaTech" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, bold: true, children: model }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), free ? (_jsx(Text, { color: theme.accent, bold: true, children: "FREE $0.00" })) : price ? (_jsxs(Text, { color: theme.accent, children: ["$", price.input.toFixed(2), " / $", price.output.toFixed(2), " per 1M"] })) : (_jsx(Text, { color: theme.muted, children: "harga \u2014" })), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsxs(Text, { color: balanceUsd === null ? theme.dim : theme.text, children: ["Balance ", balanceUsd === null ? "—" : formatUsd(balanceUsd)] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [messages.length === 0 && (_jsx(Text, { color: theme.dim, children: "Ketik pesan lalu Enter. /help untuk perintah. \u2191\u2193 ganti model." })), messages.map((m, i) => (_jsx(Box, { marginBottom: 1, flexDirection: "column", children: m.kind === "system" ? (_jsx(Text, { color: theme.dim, children: m.content })) : m.role === "user" ? (_jsxs(Text, { children: [_jsxs(Text, { color: "#7DD3FC", bold: true, children: ["You:", " "] }), _jsx(Text, { color: theme.text, children: m.content })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.accent, bold: true, children: "Muse:" }), _jsx(Text, { color: theme.text, children: m.content || (sending && i === messages.length - 1 ? "…" : "") })] })) }, i)))] }), lastUsage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.dim, children: formatUsageLine(lastUsage.usage, priceFor(catalog, lastUsage.model)) }) })), error && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#FF6B6B", children: error }) })), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.accent, bold: true, children: ["\u276F", " "] }), _jsx(Text, { color: theme.text, children: input }), !sending && (_jsx(Text, { color: theme.accent, bold: true, children: "\u258B" })), sending && _jsx(Text, { color: theme.dim, children: " mengirim\u2026" })] }), _jsx(Box, { children: _jsx(Text, { color: theme.dim, children: "\u2191\u2193 ganti model \u00B7 /models daftar \u00B7 /help bantuan \u00B7 Ctrl+C keluar" }) })] }));
185
+ }
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { theme } from "../theme.js";
4
+ /** Static model list. Any key (or q) exits. */
5
+ export default function ModelsScreen({ catalog, selectedModel }) {
6
+ const { exit } = useApp();
7
+ useInput((input, key) => {
8
+ if (input === "q" || key.escape || (key.ctrl && input === "c"))
9
+ exit();
10
+ });
11
+ const free = catalog.entries.filter((e) => e.free);
12
+ const paid = catalog.entries.filter((e) => !e.free);
13
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: "MethaTech" }), _jsx(Text, { color: theme.muted, children: " \u00B7 models" }), _jsxs(Text, { color: theme.dim, children: [" ", catalog.live ? "(live)" : "(offline, harga tersimpan)"] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.text, bold: true, children: ["FREE models", " ", _jsx(Text, { color: theme.accent, bold: true, children: "$0.00" })] }), free.map((e) => (_jsxs(Text, { color: e.id === selectedModel ? theme.accent : theme.text, children: [e.id === selectedModel ? "▸ " : " ", e.id] }, e.id)))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.text, bold: true, children: "PAID models ($ per 1M in / out)" }), paid.map((e) => (_jsxs(Text, { color: e.id === selectedModel ? theme.accent : theme.text, children: [e.id === selectedModel ? "▸ " : " ", e.id, e.price ? ` · $${e.price.input.toFixed(2)} / $${e.price.output.toFixed(2)}` : " · harga —", e.ownedBy ? ` · ${e.ownedBy}` : ""] }, e.id)))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.dim, children: [catalog.entries.length, " models \u00B7 \u25B8 = model aktif \u00B7 q keluar \u00B7 metatech chat --model <id> untuk chat"] }) })] }));
14
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@methatech-cli/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "MethaTech CLI \u2014 chat with MethaTech models from your terminal",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "README.md"
9
+ ],
10
+ "bin": {
11
+ "metatech": "./dist/cli.js"
12
+ },
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "dev": "tsc --watch",
19
+ "start": "node ./dist/cli.js"
20
+ },
21
+ "dependencies": {
22
+ "commander": "^14.0.2",
23
+ "ink": "^6.8.0",
24
+ "react": "^19.3.1"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.0.0",
28
+ "@types/react": "^19.0.0",
29
+ "typescript": "^5.9.0"
30
+ }
31
+ }