pool-anything 0.1.0 → 0.1.1

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/dist/cli/repl.js CHANGED
@@ -1,248 +1,77 @@
1
1
  /**
2
- * Interactive REPL: readline loop + command handlers over the pool SQLite DB.
3
- * Reuses pool/rotation/router helpers — no duplicated quota or rotation logic.
2
+ * Plain-text frontend over the command core: readline REPL fallback + one-shot exec.
3
+ * The rich interactive shell lives in tui.tsx (Ink); this stays script-friendly.
4
4
  */
5
5
  import readline from "node:readline";
6
- import { sdb, PROVIDERS, mask, poolSummary, isMultiFieldProvider, normalizeCredentials } from "../pool/index.js";
7
- import { nextKey, recordUsage } from "../pool/rotation.js";
8
- import { MAX_KEYS_PER_POOL, MAX_API_KEY_CHARS } from "../router/api.js";
6
+ import { PROVIDERS } from "../pool/index.js";
9
7
  import { HOST, PORT } from "../config/env.js";
10
- import { parse, suggest } from "./nl.js";
11
- import { banner, poolsBox, usageBlock, helpBox, maskTip, fmtFull } from "./ui.js";
12
- function allPools() {
13
- return sdb.prepare("SELECT id, provider, name FROM pools ORDER BY id").all();
14
- }
15
- /** Resolve free-text ref: numeric id, provider id, or name substring. */
16
- export function resolvePool(ref) {
17
- const pools = allPools();
18
- if (pools.length === 0)
19
- return null;
20
- const t = ref.trim().toLowerCase();
21
- if (!t)
22
- return pools[0];
23
- if (/^\d+$/.test(t)) {
24
- const hit = pools.find((p) => p.id === Number(t));
25
- if (hit)
26
- return hit;
27
- }
28
- const byProvider = pools.filter((p) => p.provider.toLowerCase() === t);
29
- if (byProvider.length > 0)
30
- return byProvider[0];
31
- const byName = pools.filter((p) => p.name.toLowerCase().includes(t));
32
- if (byName.length > 0)
33
- return byName[0];
34
- const byProviderSub = pools.filter((p) => p.provider.toLowerCase().includes(t));
35
- if (byProviderSub.length > 0)
36
- return byProviderSub[0];
37
- return null;
38
- }
39
- function needPool(ref) {
40
- const p = resolvePool(ref);
41
- if (!p) {
42
- console.log(ref.trim() ? `No pool matches "${ref.trim()}". Try 'list pools'.` : "No pools yet — try: create pool <name> for <provider>.");
43
- }
44
- return p;
45
- }
46
- function cmdListPools() {
47
- const rows = sdb.prepare("SELECT id, provider, name FROM pools ORDER BY id").all().map((p) => {
48
- const s = poolSummary(p.id);
49
- const keyCount = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ?").get(p.id).n;
50
- return {
51
- id: p.id, name: p.name, provider: p.provider, keys: keyCount,
52
- used: s?.usedInWindow ?? 0, quota: s?.quota ?? null, active: keyCount > 0,
53
- };
54
- });
55
- console.log(poolsBox(rows));
56
- }
57
- function cmdListProviders() {
58
- for (const p of PROVIDERS)
59
- console.log(`- ${p.id} — ${p.name} (${p.quota || "no quota"})`);
60
- }
61
- function cmdListKeys(ref) {
62
- const pool = needPool(ref);
63
- if (!pool)
64
- return;
65
- const rows = sdb
66
- .prepare("SELECT id, label, api_key FROM pool_keys WHERE pool_id = ? ORDER BY id")
67
- .all(pool.id);
68
- if (rows.length === 0) {
69
- console.log(`Pool #${pool.id} "${pool.name}" has no keys. Try: add keys to ${pool.provider}.`);
70
- return;
71
- }
72
- console.log(`Keys in pool #${pool.id} "${pool.name}":`);
73
- for (const k of rows)
74
- console.log(` #${k.id} ${k.label} (${mask(k.api_key)})`);
75
- console.log(maskTip());
76
- }
77
- function cmdUsage(ref) {
78
- const pool = needPool(ref);
79
- if (!pool)
80
- return;
81
- const s = poolSummary(pool.id);
82
- if (!s) {
83
- console.log("Pool not found.");
84
- return;
85
- }
86
- const cooling = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ? AND cooldown_until > ?").get(pool.id, Date.now()).n;
87
- console.log(usageBlock({ id: s.id, name: s.name, provider: s.provider, used: s.usedInWindow, quota: s.quota, remaining: s.remaining, keys: s.keys, cooling }));
88
- for (const k of s.perKey) {
89
- const row = sdb.prepare("SELECT api_key FROM pool_keys WHERE id = ?").get(k.id);
90
- console.log(` ${k.label} (${mask(row?.api_key ?? "")}): ${fmtFull(k.used)} tok`);
91
- }
92
- }
93
- function cmdNext(ref) {
94
- const pool = needPool(ref);
95
- if (!pool)
96
- return;
97
- const sel = nextKey(pool.id);
98
- if ("error" in sel) {
99
- console.log(`Error: ${sel.error}`);
100
- return;
101
- }
102
- console.log(`Next key in pool #${pool.id}: #${sel.key_id} ${sel.label} (${sel.masked})`);
103
- }
104
- function cmdConsume(tokens, ref) {
105
- const pool = needPool(ref);
106
- if (!pool)
107
- return;
108
- const r = recordUsage(pool.id, tokens);
109
- if ("error" in r) {
110
- console.log(`Error: ${r.error}`);
111
- return;
112
- }
113
- console.log(`Recorded ${fmtFull(tokens)} tokens on #${r.key_id} ${r.label} (${r.masked}).`);
114
- }
115
- function cmdCreatePool(name, provider, ask) {
116
- return (async () => {
117
- let n = name.trim();
118
- let pv = provider.trim().toLowerCase();
119
- if (!pv)
120
- pv = (await ask("Provider id (e.g. groq)? ")).trim().toLowerCase();
121
- const known = PROVIDERS.find((p) => p.id === pv);
122
- if (!known) {
123
- console.log(`Unknown provider "${pv}". Try 'list providers'.`);
124
- return;
125
- }
126
- if (!n)
127
- n = (await ask(`Name for the new ${known.name} pool? `)).trim();
128
- if (!n) {
129
- console.log("Cancelled (empty name).");
130
- return;
131
- }
132
- if (n.length > 80) {
133
- console.log("Name must be <= 80 characters.");
134
- return;
135
- }
136
- const r = sdb.prepare("INSERT INTO pools (provider, name) VALUES (?, ?)").run(known.id, n);
137
- console.log(`✓ Created pool #${r.lastInsertRowid} "${n}" (${known.id}). Add keys with: add keys to ${known.id}`);
138
- })();
139
- }
140
- function cmdDeletePool(ref, ask) {
141
- return (async () => {
142
- const pool = needPool(ref);
143
- if (!pool)
144
- return;
145
- const ans = (await ask(`Delete pool #${pool.id} "${pool.name}" and its keys? [y/N] `)).trim().toLowerCase();
146
- if (ans !== "y" && ans !== "yes") {
147
- console.log("Cancelled.");
148
- return;
149
- }
150
- sdb.exec("BEGIN IMMEDIATE");
151
- try {
152
- sdb.prepare("DELETE FROM usage WHERE pool_id = ?").run(pool.id);
153
- sdb.prepare("DELETE FROM pool_keys WHERE pool_id = ?").run(pool.id);
154
- sdb.prepare("DELETE FROM pools WHERE id = ?").run(pool.id);
155
- sdb.exec("COMMIT");
156
- }
157
- catch (e) {
158
- try {
159
- sdb.exec("ROLLBACK");
160
- }
161
- catch { /* already rolled back */ }
162
- throw e;
163
- }
164
- console.log(`✓ Deleted pool #${pool.id} "${pool.name}".`);
165
- })();
166
- }
167
- /** Insert pasted lines as keys. Returns counts. Shared by interactive + piped import. */
168
- export function insertKeys(pool, rawLines) {
169
- const existing = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ?").get(pool.id).n;
170
- const multi = isMultiFieldProvider(pool.provider);
171
- let added = 0;
172
- let skipped = 0;
173
- for (const raw of rawLines) {
174
- if (existing + added >= MAX_KEYS_PER_POOL)
175
- break;
176
- if (multi) {
177
- let obj;
178
- try {
179
- obj = JSON.parse(raw);
180
- }
181
- catch {
182
- skipped++;
183
- continue;
184
- }
185
- const norm = normalizeCredentials(pool.provider, obj);
186
- if (!norm.ok) {
187
- skipped++;
188
- continue;
189
- }
190
- const single = norm.value["apiKey"] ?? norm.value["api_key"] ?? norm.value["apiToken"] ?? Object.values(norm.value)[0] ?? "";
191
- sdb.prepare("INSERT INTO pool_keys (pool_id, label, api_key, credentials) VALUES (?, ?, ?, ?)").run(pool.id, `key ${existing + added + 1}`, single, JSON.stringify(norm.value));
192
- added++;
193
- }
194
- else {
195
- const key = raw.trim();
196
- if (!key || /[\r\n\0]/.test(key)) {
197
- skipped++;
198
- continue;
199
- }
200
- if (key.length > MAX_API_KEY_CHARS) {
201
- skipped++;
202
- continue;
203
- }
204
- const dup = sdb.prepare("SELECT id FROM pool_keys WHERE pool_id = ? AND api_key = ?").get(pool.id, key);
205
- if (dup) {
206
- skipped++;
207
- continue;
8
+ import { parse } from "./nl.js";
9
+ import { banner, poolsBox, usageBlock, maskTip, fmtFull } from "./ui.js";
10
+ import { dispatch, insertKeys, liveStats } from "./commands.js";
11
+ /** Render one result as plain text. Returns an import state when pasting begins. */
12
+ export function renderResult(r) {
13
+ switch (r.kind) {
14
+ case "exit": return null;
15
+ case "clear":
16
+ console.clear();
17
+ return null;
18
+ case "message":
19
+ console.log(r.text);
20
+ return null;
21
+ case "pools":
22
+ console.log(poolsBox(r.pools));
23
+ return null;
24
+ case "providers":
25
+ for (const p of PROVIDERS)
26
+ console.log(`- ${p.id} — ${p.name} (${p.quota || "no quota"})`);
27
+ return null;
28
+ case "keys": {
29
+ if (r.keys.length === 0) {
30
+ console.log(`Pool #${r.pool.id} "${r.pool.name}" has no keys. Try: add keys to ${r.pool.provider}.`);
31
+ return null;
208
32
  }
209
- sdb.prepare("INSERT INTO pool_keys (pool_id, label, api_key) VALUES (?, ?, ?)").run(pool.id, `key ${existing + added + 1}`, key);
210
- added++;
33
+ console.log(`Keys in pool #${r.pool.id} "${r.pool.name}":`);
34
+ for (const k of r.keys)
35
+ console.log(` #${k.id} ${k.label} (${k.masked})`);
36
+ console.log(maskTip());
37
+ return null;
38
+ }
39
+ case "usage": {
40
+ console.log(usageBlock(r.view));
41
+ for (const k of r.perKey)
42
+ console.log(` ${k.label} (${k.masked}): ${fmtFull(k.used)} tok`);
43
+ return null;
44
+ }
45
+ case "import":
46
+ console.log(`✓ Opening key import mode for "${r.pool.name}" (pool #${r.pool.id})`);
47
+ console.log(r.hint);
48
+ return { pool: r.pool, expected: r.expected, lines: [] };
49
+ case "watch": {
50
+ void watchPlain(r.pool);
51
+ return null;
211
52
  }
53
+ case "serve":
54
+ console.log(r.already ? `Server already running on http://${HOST}:${PORT}` : `Serving on http://${HOST}:${PORT}`);
55
+ return null;
212
56
  }
213
- return { added, skipped };
214
57
  }
215
- function importPrompt(pool, expected) {
216
- console.log(`✓ Opening key import mode for "${pool.name}" (pool #${pool.id})`);
217
- if (isMultiFieldProvider(pool.provider)) {
218
- console.log("This provider needs multi-field credentials: paste one JSON object per line.");
219
- }
220
- else {
221
- console.log("Paste API keys (one per line, empty line to finish):");
58
+ export function finishImport(st) {
59
+ if (st.lines.length === 0) {
60
+ console.log("No keys pasted — nothing added.");
61
+ return;
222
62
  }
223
- if (expected !== null)
224
- console.log(`(expecting ~${expected} keys)`);
225
- }
226
- function liveStats(poolId) {
227
- const reqMin = sdb.prepare("SELECT COUNT(*) AS n FROM usage WHERE pool_id = ? AND created_at >= datetime('now','-1 minute')").get(poolId).n;
228
- const tokMin = sdb.prepare("SELECT COALESCE(SUM(tokens),0) AS t FROM usage WHERE pool_id = ? AND created_at >= datetime('now','-1 minute')").get(poolId).t;
229
- const lat = sdb.prepare("SELECT AVG(latency_ms) AS a FROM pool_keys WHERE pool_id = ? AND latency_ms IS NOT NULL").get(poolId);
230
- const perKey = sdb
231
- .prepare("SELECT k.id, k.label, k.api_key AS apiKey, COALESCE(SUM(u.tokens),0) AS used FROM pool_keys k LEFT JOIN usage u ON u.key_id = k.id WHERE k.pool_id = ? GROUP BY k.id ORDER BY k.id")
232
- .all(poolId);
233
- return { reqMin, tokMin, avgLatency: lat.a, perKey };
63
+ const { added, skipped } = insertKeys(st.pool, st.lines);
64
+ console.log(`✓ Added ${added} key${added === 1 ? "" : "s"} to pool #${st.pool.id}${skipped > 0 ? ` (${skipped} skipped: blank/duplicate/invalid)` : ""}.`);
234
65
  }
235
- async function cmdWatch(ref) {
236
- const pool = needPool(ref);
237
- if (!pool)
238
- return;
239
- console.log(`🔄 Pool #${pool.id} "${pool.name}" — live usage [auto-refresh 2s, press q to exit]`);
66
+ async function watchPlain(pool) {
67
+ const { mask } = await import("../pool/index.js");
68
+ console.log(`Pool #${pool.id} "${pool.name}" — live usage [auto-refresh 2s, press q to exit]`);
240
69
  await new Promise((resolve) => {
241
70
  const render = () => {
242
71
  const st = liveStats(pool.id);
243
72
  readline.cursorTo(process.stdout, 0, 0);
244
73
  readline.clearScreenDown(process.stdout);
245
- console.log(`🔄 Pool #${pool.id} "${pool.name}" — live usage [auto-refresh 2s, press q to exit]`);
74
+ console.log(`Pool #${pool.id} "${pool.name}" — live usage [auto-refresh 2s, press q to exit]`);
246
75
  console.log(` Requests: ${st.reqMin}/min │ Tokens: ${st.tokMin}/min │ Avg latency: ${st.avgLatency === null ? "—" : `${(st.avgLatency / 1000).toFixed(1)}s`}`);
247
76
  for (const k of st.perKey)
248
77
  console.log(` ${k.label} (${mask(k.apiKey)}): ${fmtFull(k.used)} tok`);
@@ -273,88 +102,22 @@ async function cmdWatch(ref) {
273
102
  });
274
103
  console.log("Stopped watching.");
275
104
  }
276
- let serverStarted = false;
277
- async function cmdServe() {
278
- if (serverStarted) {
279
- console.log(`Server already running on http://${HOST}:${PORT}`);
280
- return;
281
- }
282
- serverStarted = true;
283
- await import("../server.js");
284
- }
285
- export async function runLine(input, ask, hooks) {
286
- const intent = parse(input);
287
- switch (intent.kind) {
288
- case "exit": return "exit";
289
- case "help":
290
- console.log(helpBox());
291
- return "ok";
292
- case "clear":
293
- console.clear();
294
- return "ok";
295
- case "version": {
296
- const { readFileSync } = await import("node:fs");
297
- const { PACKAGE_ROOT } = await import("../config/paths.js");
298
- const { join } = await import("node:path");
299
- try {
300
- const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
301
- console.log(pkg.version ?? "0.0.0");
302
- }
303
- catch {
304
- console.log("0.0.0");
305
- }
306
- return "ok";
307
- }
308
- case "list-pools":
309
- cmdListPools();
310
- return "ok";
311
- case "list-providers":
312
- cmdListProviders();
313
- return "ok";
314
- case "list-keys":
315
- cmdListKeys(intent.ref);
316
- return "ok";
317
- case "usage":
318
- cmdUsage(intent.ref);
319
- return "ok";
320
- case "next":
321
- cmdNext(intent.ref);
322
- return "ok";
323
- case "consume":
324
- cmdConsume(intent.tokens, intent.ref);
325
- return "ok";
326
- case "create-pool":
327
- await cmdCreatePool(intent.name, intent.provider, ask);
328
- return "ok";
329
- case "delete-pool":
330
- await cmdDeletePool(intent.ref, ask);
331
- return "ok";
332
- case "add-keys": {
333
- const pool = needPool(intent.ref);
334
- if (!pool)
335
- return "ok";
336
- importPrompt(pool, intent.count);
337
- return { pool, expected: intent.count, lines: [] };
338
- }
339
- case "watch": {
340
- hooks?.pauseInput?.();
341
- try {
342
- await cmdWatch(intent.ref);
343
- }
344
- finally {
345
- hooks?.resumeInput?.();
346
- }
347
- return "ok";
348
- }
349
- case "serve":
350
- await cmdServe();
351
- return "ok";
352
- case "unknown": {
353
- const s = suggest(input, PROVIDERS.map((p) => p.id));
354
- console.log(`I didn't understand that.${s ? ` Did you mean '${s}'?` : ""} Type '.help' for commands.`);
355
- return "ok";
356
- }
357
- }
105
+ /** Execute one line in the plain frontend. Returns "exit" or a pending import. */
106
+ export async function runLine(input, ask) {
107
+ const r = await dispatch(input, ask);
108
+ if (r.kind === "exit")
109
+ return "exit";
110
+ if (r.kind === "watch") {
111
+ await watchPlain(r.pool);
112
+ return "ok";
113
+ }
114
+ const imp = renderResult(r);
115
+ // A confirmed uninstall removes the binary out from under us — show the
116
+ // result, then leave the shell instead of prompting on a dead install.
117
+ if (parse(input).kind === "uninstall" &&
118
+ r.kind === "message" && r.tone === "ok")
119
+ return "exit";
120
+ return imp ?? "ok";
358
121
  }
359
122
  export async function startRepl(version) {
360
123
  console.log(banner(version));
@@ -364,26 +127,18 @@ export async function startRepl(version) {
364
127
  rl.prompt();
365
128
  rl.on("line", (raw) => {
366
129
  void (async () => {
367
- // Key-import mode: collect pasted lines until blank / .done / Ctrl+D (close).
368
130
  if (importing) {
369
131
  if (raw.trim() === "" || raw.trim() === ".done") {
370
132
  const st = importing;
371
133
  importing = null;
372
- if (st.lines.length === 0) {
373
- console.log("No keys pasted — nothing added.");
374
- }
375
- else {
376
- const { added, skipped } = insertKeys(st.pool, st.lines);
377
- console.log(`✓ Added ${added} key${added === 1 ? "" : "s"} to pool #${st.pool.id}${skipped > 0 ? ` (${skipped} skipped: blank/duplicate/invalid)` : ""}.`);
378
- }
134
+ finishImport(st);
379
135
  }
380
136
  else {
381
137
  importing.lines.push(raw);
382
138
  if (importing.expected !== null && importing.lines.length >= importing.expected) {
383
139
  const st = importing;
384
140
  importing = null;
385
- const { added, skipped } = insertKeys(st.pool, st.lines);
386
- console.log(`✓ Added ${added} key${added === 1 ? "" : "s"} to pool #${st.pool.id}${skipped > 0 ? ` (${skipped} skipped: blank/duplicate/invalid)` : ""}.`);
141
+ finishImport(st);
387
142
  }
388
143
  else {
389
144
  rl.prompt();
@@ -394,7 +149,7 @@ export async function startRepl(version) {
394
149
  return;
395
150
  }
396
151
  try {
397
- const done = await runLine(raw, ask, { pauseInput: () => rl.pause(), resumeInput: () => rl.resume() });
152
+ const done = await runLine(raw, ask);
398
153
  if (typeof done === "object") {
399
154
  importing = done;
400
155
  rl.setPrompt("... ");
@@ -413,10 +168,9 @@ export async function startRepl(version) {
413
168
  })();
414
169
  });
415
170
  rl.on("close", () => {
416
- // Ctrl+D during import finishes with what was pasted.
417
171
  if (importing && importing.lines.length > 0) {
418
- const { added, skipped } = insertKeys(importing.pool, importing.lines);
419
- console.log(`\n✓ Added ${added} key${added === 1 ? "" : "s"} to pool #${importing.pool.id}${skipped > 0 ? ` (${skipped} skipped)` : ""}.`);
172
+ console.log("");
173
+ finishImport(importing);
420
174
  importing = null;
421
175
  }
422
176
  });
@@ -426,11 +180,13 @@ export async function startRepl(version) {
426
180
  /** One-shot: run a single NL command without the interactive loop. */
427
181
  export async function execOnce(command) {
428
182
  const intent = parse(command);
429
- // Non-interactive key import reads pasted keys from piped stdin.
430
183
  if (intent.kind === "add-keys") {
431
- const pool = needPool(intent.ref);
432
- if (!pool)
184
+ const { resolvePool } = await import("./commands.js");
185
+ const pool = resolvePool(intent.ref);
186
+ if (!pool) {
187
+ console.log(intent.ref.trim() ? `No pool matches "${intent.ref.trim()}".` : "No pools yet.");
433
188
  return 1;
189
+ }
434
190
  if (process.stdin.isTTY) {
435
191
  console.log("Interactive paste needs a TTY — run `pool-anything` and type the command there, or pipe keys via stdin.");
436
192
  return 1;
@@ -453,11 +209,19 @@ export async function execOnce(command) {
453
209
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
454
210
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
455
211
  try {
456
- const r = await runLine(command, ask);
457
- if (typeof r === "object") {
212
+ const r = await dispatch(command, ask);
213
+ if (r.kind === "import") {
458
214
  console.log("Key import needs an interactive shell — run `pool-anything` without arguments.");
459
215
  return 1;
460
216
  }
217
+ if (r.kind === "watch") {
218
+ rl.close();
219
+ await watchPlain(r.pool);
220
+ return 0;
221
+ }
222
+ if (r.kind === "exit" || r.kind === "clear")
223
+ return 0;
224
+ renderResult(r);
461
225
  return 0;
462
226
  }
463
227
  catch (e) {