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.
@@ -0,0 +1,58 @@
1
+ /** Shared SEO constants for the public landing (tools-site) + local app.
2
+ * Canonical host is the hosted tools UI; local / just reuses the same tags
3
+ * so exported static HTML is already optimized with no per-env branching. */
4
+ export const SITE_URL = "https://pool-anything-tools.pages.dev";
5
+ export const SITE_NAME = "pool-anything";
6
+ export const SEO_TITLE = "pool-anything — Pool Free-Tier API Keys, Rotate & Track Usage";
7
+ export const SEO_DESCRIPTION = "Gather free-tier API keys into one pool and rotate them. Round-robin proxy, per-key usage & quota tracking, 36 providers, self-hosted TypeScript + SQLite.";
8
+ export const SEO_OG_IMAGE = `${SITE_URL}/logo.png`;
9
+ export const HOME_HEAD_EXTRA = `<meta name="description" content="${SEO_DESCRIPTION}" />
10
+ <link rel="canonical" href="${SITE_URL}/" />
11
+ <meta name="robots" content="index, follow, max-image-preview:large" />
12
+ <meta name="author" content="Parithosh Varma" />
13
+ <meta name="theme-color" content="#ffffff" />
14
+ <meta property="og:type" content="website" />
15
+ <meta property="og:site_name" content="${SITE_NAME}" />
16
+ <meta property="og:title" content="${SEO_TITLE}" />
17
+ <meta property="og:description" content="${SEO_DESCRIPTION}" />
18
+ <meta property="og:url" content="${SITE_URL}/" />
19
+ <meta property="og:image" content="${SEO_OG_IMAGE}" />
20
+ <meta name="twitter:card" content="summary" />
21
+ <meta name="twitter:title" content="${SEO_TITLE}" />
22
+ <meta name="twitter:description" content="${SEO_DESCRIPTION}" />
23
+ <meta name="twitter:image" content="${SEO_OG_IMAGE}" />
24
+ <script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareApplication","name":"pool-anything","applicationCategory":"DeveloperApplication","operatingSystem":"Any","url":"${SITE_URL}/","description":"${SEO_DESCRIPTION}","license":"https://www.apache.org/licenses/LICENSE-2.0","offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"author":{"@type":"Person","name":"Parithosh Varma"}}</script>`;
25
+ /** Minimal per-page head (description + canonical) for app sub-pages. */
26
+ export function subPageHead(path, description) {
27
+ const url = `${SITE_URL}${path}`;
28
+ return `<meta name="description" content="${description}" />
29
+ <link rel="canonical" href="${url}" />
30
+ <meta name="robots" content="index, follow" />
31
+ <meta property="og:type" content="website" />
32
+ <meta property="og:site_name" content="${SITE_NAME}" />
33
+ <meta property="og:url" content="${url}" />
34
+ <meta property="og:image" content="${SEO_OG_IMAGE}" />
35
+ <meta name="twitter:card" content="summary" />`;
36
+ }
37
+ export function robotsTxt() {
38
+ return `User-agent: *
39
+ Allow: /
40
+ Sitemap: ${SITE_URL}/sitemap.xml
41
+ `;
42
+ }
43
+ const STATIC_ROUTES = ["/", "/pools", "/keys", "/playground", "/analytics", "/history"];
44
+ export function sitemapXml(providerIds) {
45
+ const urls = new Set(STATIC_ROUTES);
46
+ for (const id of providerIds) {
47
+ if (/^[a-z0-9-]+$/.test(id))
48
+ urls.add(`/provider/${id}/`);
49
+ }
50
+ const withSlash = [...urls].map((p) => (p === "/" ? "/" : p.endsWith("/") ? p : `${p}/`));
51
+ const today = new Date().toISOString().slice(0, 10);
52
+ const entries = withSlash
53
+ .map((p) => ` <url><loc>${SITE_URL}${p}</loc><lastmod>${today}</lastmod><changefreq>${p === "/" ? "weekly" : "monthly"}</changefreq><priority>${p === "/" ? "1.0" : p.startsWith("/provider/") ? "0.6" : "0.8"}</priority></url>`)
54
+ .join("\n");
55
+ // Note: provider routes are exported as /provider/:id/index.html so the
56
+ // trailing-slash form is canonical on Pages.
57
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>\n`;
58
+ }
@@ -39,7 +39,7 @@ export function shellNav(active) {
39
39
  };
40
40
  return `<aside class="sidebar">
41
41
  <div class="sb-header"><a class="sb-logo" aria-label="pool-anything home" href="/"><img src="/logo.png" alt="pool-anything" width="36" height="36" /></a><div class="sb-acct" title="Local account"><span>Local account</span></div></div>
42
- <nav class="sb-nav">${item("/", ICONS.home, "Home")}${item("/pools", ICONS.pools, "Pools")}${item("/keys", ICONS.keys, "API key manager")}${item("/playground", ICONS.playground, "Playground")}${item("/analytics", ICONS.analytics, "Analytics")}${item(DOCS_URL, ICONS.docs, "Docs")}</nav>
42
+ <nav class="sb-nav">${item("/", ICONS.home, "Home")}${item("/pools", ICONS.pools, "Pools")}${item("/keys", ICONS.keys, "API key manager")}${item("/playground", ICONS.playground, "Playground")}${item("/analytics", ICONS.analytics, "Analytics")}${item("/history", ICONS.history, "History")}${item(DOCS_URL, ICONS.docs, "Docs")}</nav>
43
43
  <div class="sb-footer"><button class="collapse-btn" id="collapseBtn" type="button" data-sidebar="trigger" aria-expanded="true" aria-label="Collapse sidebar"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21.25 6.72v10.56a2.97 2.97 0 0 1-2.97 2.97H5.72a2.97 2.97 0 0 1-2.97-2.97V6.72a2.97 2.97 0 0 1 2.97-2.97h12.56a2.97 2.97 0 0 1 2.97 2.97"></path><path d="M6.25 7.25v9.5"></path></svg></button></div>
44
44
  </aside><script>
45
45
  (function () {
@@ -59,6 +59,8 @@ export function shellNav(active) {
59
59
  export const sharedHeader = `<header>
60
60
  <div></div>
61
61
  <div class="spacer">
62
+ <span class="ver-pill" id="verPill" title="pool-anything version">v…</span>
63
+ <button class="ver-update" id="verUpdate" type="button" hidden>Update</button>
62
64
  <a href="${DOCS_URL}" target="_blank" rel="noopener">Docs</a>
63
65
  </div>
64
66
  </header>`;
@@ -70,9 +72,13 @@ export const sharedLayoutCss = `
70
72
  ${tokensCss}
71
73
  .content { flex:1; min-width:0; display:flex; flex-direction:column; }
72
74
  header { height:56px; background:var(--card); border-bottom:1px solid var(--line); display:flex; align-items:center; padding:0 16px; gap:8px; position:sticky; top:0; z-index:5; }
73
- header .spacer { margin-left:auto; display:flex; gap:4px; }
75
+ header .spacer { margin-left:auto; display:flex; gap:4px; align-items:center; }
74
76
  header a { font-size:14px; padding:6px 12px; border-radius:8px; border:0; background:inherit; color:var(--ink); text-decoration:none; cursor:pointer; }
75
77
  header a:hover { background:var(--paper-2); }
78
+ .ver-pill { font-size:12px; font-weight:600; color:var(--ink-soft); background:var(--paper-2); border:1px solid var(--line); border-radius:999px; padding:4px 10px; white-space:nowrap; }
79
+ .ver-update { font-size:12px; font-weight:600; border:1px solid var(--ink); background:var(--ink); color:var(--card); border-radius:999px; padding:4px 12px; cursor:pointer; white-space:nowrap; }
80
+ .ver-update[hidden] { display:none; }
81
+ .ver-update:disabled { opacity:.6; cursor:default; }
76
82
  main { display:flex; flex-direction:column; align-items:center; justify-content:flex-start; padding:32px 16px 24px; }
77
83
  .wrap { width:100%; max-width:1400px; margin:0 auto; display:flex; flex-direction:column; align-items:stretch; gap:16px; padding:0 8px; }
78
84
  .page-head { display:flex; align-items:center; gap:10px; }
@@ -126,4 +132,41 @@ export const shellJs = `<script>
126
132
  if (!shell.classList.contains('peeking')) return;
127
133
  finishCollapse(() => shell.classList.remove('peeking'));
128
134
  });
135
+ // Version pill + self-update. GET /api/version stays public so the pill
136
+ // renders without a token; POST /api/update is a write and needs the
137
+ // bearer when POOL_API_TOKEN is set (see middleware/auth.ts).
138
+ (function () {
139
+ var pill = document.getElementById('verPill');
140
+ var btn = document.getElementById('verUpdate');
141
+ if (!pill || !btn) return;
142
+ function txt(el, s) { if (el) el.textContent = s; }
143
+ fetch('/api/version').then(function (r) { return r.json(); }).then(function (v) {
144
+ if (!v || !v.current) return;
145
+ txt(pill, 'v' + v.current);
146
+ pill.title = v.latest ? ('current v' + v.current + ', latest v' + v.latest) : ('pool-anything v' + v.current);
147
+ if (v.updateAvailable) {
148
+ btn.hidden = false;
149
+ txt(btn, 'Update to v' + v.latest);
150
+ }
151
+ }).catch(function () {});
152
+ btn.addEventListener('click', function () {
153
+ if (btn.disabled) return;
154
+ btn.disabled = true;
155
+ txt(btn, 'Updating…');
156
+ fetch('/api/update', { method: 'POST' }).then(function (r) { return r.json(); }).then(function (j) {
157
+ if (j && j.ok) {
158
+ txt(pill, 'v' + (j.current || j.latest || ''));
159
+ txt(btn, 'Updated — restart server');
160
+ pill.title = (j.text || 'updated');
161
+ } else {
162
+ txt(btn, 'Update failed — retry');
163
+ btn.disabled = false;
164
+ pill.title = ((j && (j.text || j.error)) || 'update failed');
165
+ }
166
+ }).catch(function () {
167
+ txt(btn, 'Update failed — retry');
168
+ btn.disabled = false;
169
+ });
170
+ });
171
+ })();
129
172
  </script>`;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Presentation-free command core for the assistant CLI.
3
+ * No console/readline/Ink here — dispatch() returns view-models and each
4
+ * frontend (plain-text one-shot, Ink shell) renders them its own way.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { sdb, PROVIDERS, mask, poolSummary, isMultiFieldProvider, normalizeCredentials } from "../pool/index.js";
9
+ import { nextKey, recordUsage } from "../pool/rotation.js";
10
+ import { MAX_KEYS_PER_POOL, MAX_API_KEY_CHARS } from "../router/api.js";
11
+ import { PACKAGE_ROOT } from "../config/paths.js";
12
+ import { parse, suggest } from "./nl.js";
13
+ import { fmtFull, helpBox } from "./ui.js";
14
+ export function readVersion() {
15
+ try {
16
+ const raw = fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8");
17
+ const pkg = JSON.parse(raw);
18
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
19
+ }
20
+ catch {
21
+ return "0.0.0";
22
+ }
23
+ }
24
+ function allPools() {
25
+ return sdb.prepare("SELECT id, provider, name FROM pools ORDER BY id").all();
26
+ }
27
+ /** Resolve free-text ref: numeric id, provider id, or name substring. */
28
+ export function resolvePool(ref) {
29
+ const pools = allPools();
30
+ if (pools.length === 0)
31
+ return null;
32
+ const t = ref.trim().toLowerCase();
33
+ if (!t)
34
+ return pools[0];
35
+ if (/^\d+$/.test(t)) {
36
+ const hit = pools.find((p) => p.id === Number(t));
37
+ if (hit)
38
+ return hit;
39
+ }
40
+ const byProvider = pools.filter((p) => p.provider.toLowerCase() === t);
41
+ if (byProvider.length > 0)
42
+ return byProvider[0];
43
+ const byName = pools.filter((p) => p.name.toLowerCase().includes(t));
44
+ if (byName.length > 0)
45
+ return byName[0];
46
+ const byProviderSub = pools.filter((p) => p.provider.toLowerCase().includes(t));
47
+ if (byProviderSub.length > 0)
48
+ return byProviderSub[0];
49
+ return null;
50
+ }
51
+ function needPool(ref) {
52
+ const p = resolvePool(ref);
53
+ if (!p) {
54
+ return { error: ref.trim() ? `No pool matches "${ref.trim()}". Try 'list pools'.` : "No pools yet — try: create pool <name> for <provider>." };
55
+ }
56
+ return { pool: p };
57
+ }
58
+ function poolsResult() {
59
+ const pools = sdb.prepare("SELECT id, provider, name FROM pools ORDER BY id").all().map((p) => {
60
+ const s = poolSummary(p.id);
61
+ const keyCount = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ?").get(p.id).n;
62
+ return {
63
+ id: p.id, name: p.name, provider: p.provider, keys: keyCount,
64
+ used: s?.usedInWindow ?? 0, quota: s?.quota ?? null, active: keyCount > 0,
65
+ };
66
+ });
67
+ return { kind: "pools", pools };
68
+ }
69
+ function keysResult(ref) {
70
+ const r = needPool(ref);
71
+ if ("error" in r)
72
+ return { kind: "message", tone: "err", text: r.error };
73
+ const rows = sdb
74
+ .prepare("SELECT id, label, api_key FROM pool_keys WHERE pool_id = ? ORDER BY id")
75
+ .all(r.pool.id);
76
+ return {
77
+ kind: "keys", pool: r.pool,
78
+ keys: rows.map((k) => ({ id: k.id, label: k.label, masked: mask(k.api_key) })),
79
+ };
80
+ }
81
+ function usageResult(ref) {
82
+ const r = needPool(ref);
83
+ if ("error" in r)
84
+ return { kind: "message", tone: "err", text: r.error };
85
+ const s = poolSummary(r.pool.id);
86
+ if (!s)
87
+ return { kind: "message", tone: "err", text: "Pool not found." };
88
+ const cooling = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ? AND cooldown_until > ?").get(r.pool.id, Date.now()).n;
89
+ const perKey = s.perKey.map((k) => {
90
+ const row = sdb.prepare("SELECT api_key FROM pool_keys WHERE id = ?").get(k.id);
91
+ return { label: k.label, masked: mask(row?.api_key ?? ""), used: k.used };
92
+ });
93
+ return {
94
+ kind: "usage",
95
+ view: { id: s.id, name: s.name, provider: s.provider, used: s.usedInWindow, quota: s.quota, remaining: s.remaining, keys: s.keys, cooling },
96
+ perKey,
97
+ };
98
+ }
99
+ function nextResult(ref) {
100
+ const r = needPool(ref);
101
+ if ("error" in r)
102
+ return { kind: "message", tone: "err", text: r.error };
103
+ const sel = nextKey(r.pool.id);
104
+ if ("error" in sel)
105
+ return { kind: "message", tone: "err", text: `Error: ${sel.error}` };
106
+ return { kind: "message", tone: "ok", text: `Next key in pool #${r.pool.id}: #${sel.key_id} ${sel.label} (${sel.masked})` };
107
+ }
108
+ function consumeResult(tokens, ref) {
109
+ const r = needPool(ref);
110
+ if ("error" in r)
111
+ return { kind: "message", tone: "err", text: r.error };
112
+ const res = recordUsage(r.pool.id, tokens);
113
+ if ("error" in res)
114
+ return { kind: "message", tone: "err", text: `Error: ${res.error}` };
115
+ return { kind: "message", tone: "ok", text: `Recorded ${fmtFull(tokens)} tokens on #${res.key_id} ${res.label} (${res.masked}).` };
116
+ }
117
+ async function createResult(name, provider, ask) {
118
+ const msg = (tone, text) => ({ kind: "message", tone, text });
119
+ let n = name.trim();
120
+ let pv = provider.trim().toLowerCase();
121
+ if (!pv)
122
+ pv = (await ask("Provider id (e.g. groq)? ")).trim().toLowerCase();
123
+ const known = PROVIDERS.find((p) => p.id === pv);
124
+ if (!known)
125
+ return msg("err", `Unknown provider "${pv}". Try 'list providers'.`);
126
+ if (!n)
127
+ n = (await ask(`Name for the new ${known.name} pool? `)).trim();
128
+ if (!n)
129
+ return msg("info", "Cancelled (empty name).");
130
+ if (n.length > 80)
131
+ return msg("err", "Name must be <= 80 characters.");
132
+ const r = sdb.prepare("INSERT INTO pools (provider, name) VALUES (?, ?)").run(known.id, n);
133
+ return msg("ok", `Created pool #${r.lastInsertRowid} "${n}" (${known.id}). Add keys with: add keys to ${known.id}`);
134
+ }
135
+ async function deleteResult(ref, ask) {
136
+ const msg = (tone, text) => ({ kind: "message", tone, text });
137
+ const r = needPool(ref);
138
+ if ("error" in r)
139
+ return msg("err", r.error);
140
+ const ans = (await ask(`Delete pool #${r.pool.id} "${r.pool.name}" and its keys? [y/N] `)).trim().toLowerCase();
141
+ if (ans !== "y" && ans !== "yes")
142
+ return msg("info", "Cancelled.");
143
+ sdb.exec("BEGIN IMMEDIATE");
144
+ try {
145
+ sdb.prepare("DELETE FROM usage WHERE pool_id = ?").run(r.pool.id);
146
+ sdb.prepare("DELETE FROM pool_keys WHERE pool_id = ?").run(r.pool.id);
147
+ sdb.prepare("DELETE FROM pools WHERE id = ?").run(r.pool.id);
148
+ sdb.exec("COMMIT");
149
+ }
150
+ catch (e) {
151
+ try {
152
+ sdb.exec("ROLLBACK");
153
+ }
154
+ catch { /* already rolled back */ }
155
+ throw e;
156
+ }
157
+ return msg("ok", `Deleted pool #${r.pool.id} "${r.pool.name}".`);
158
+ }
159
+ /** Insert pasted lines as keys. Returns counts. Shared by interactive + piped import. */
160
+ export function insertKeys(pool, rawLines) {
161
+ const existing = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ?").get(pool.id).n;
162
+ const multi = isMultiFieldProvider(pool.provider);
163
+ let added = 0;
164
+ let skipped = 0;
165
+ for (const raw of rawLines) {
166
+ if (existing + added >= MAX_KEYS_PER_POOL)
167
+ break;
168
+ if (multi) {
169
+ let obj;
170
+ try {
171
+ obj = JSON.parse(raw);
172
+ }
173
+ catch {
174
+ skipped++;
175
+ continue;
176
+ }
177
+ const norm = normalizeCredentials(pool.provider, obj);
178
+ if (!norm.ok) {
179
+ skipped++;
180
+ continue;
181
+ }
182
+ const single = norm.value["apiKey"] ?? norm.value["api_key"] ?? norm.value["apiToken"] ?? Object.values(norm.value)[0] ?? "";
183
+ 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));
184
+ added++;
185
+ }
186
+ else {
187
+ const key = raw.trim();
188
+ if (!key || /[\r\n\0]/.test(key)) {
189
+ skipped++;
190
+ continue;
191
+ }
192
+ if (key.length > MAX_API_KEY_CHARS) {
193
+ skipped++;
194
+ continue;
195
+ }
196
+ const dup = sdb.prepare("SELECT id FROM pool_keys WHERE pool_id = ? AND api_key = ?").get(pool.id, key);
197
+ if (dup) {
198
+ skipped++;
199
+ continue;
200
+ }
201
+ sdb.prepare("INSERT INTO pool_keys (pool_id, label, api_key) VALUES (?, ?, ?)").run(pool.id, `key ${existing + added + 1}`, key);
202
+ added++;
203
+ }
204
+ }
205
+ return { added, skipped };
206
+ }
207
+ export function importHint(pool, expected) {
208
+ const base = isMultiFieldProvider(pool.provider)
209
+ ? "This provider needs multi-field credentials: paste one JSON object per line."
210
+ : "Paste API keys (one per line, empty line to finish):";
211
+ return expected !== null ? `${base} (expecting ~${expected})` : base;
212
+ }
213
+ export function liveStats(poolId) {
214
+ const reqMin = sdb.prepare("SELECT COUNT(*) AS n FROM usage WHERE pool_id = ? AND created_at >= datetime('now','-1 minute')").get(poolId).n;
215
+ 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;
216
+ const lat = sdb.prepare("SELECT AVG(latency_ms) AS a FROM pool_keys WHERE pool_id = ? AND latency_ms IS NOT NULL").get(poolId);
217
+ const perKey = sdb
218
+ .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")
219
+ .all(poolId);
220
+ return { reqMin, tokMin, avgLatency: lat.a, perKey };
221
+ }
222
+ let serverStarted = false;
223
+ /** Execute one input line. No I/O except via ask(); returns a view-model. */
224
+ export async function dispatch(input, ask) {
225
+ const intent = parse(input);
226
+ switch (intent.kind) {
227
+ case "exit": return { kind: "exit" };
228
+ case "help": return { kind: "message", tone: "info", text: helpBox() };
229
+ case "clear": return { kind: "clear" };
230
+ case "version": return { kind: "message", tone: "info", text: readVersion() };
231
+ case "list-pools": return poolsResult();
232
+ case "list-providers": return { kind: "providers" };
233
+ case "list-keys": return keysResult(intent.ref);
234
+ case "usage": return usageResult(intent.ref);
235
+ case "next": return nextResult(intent.ref);
236
+ case "consume": return consumeResult(intent.tokens, intent.ref);
237
+ case "create-pool": return createResult(intent.name, intent.provider, ask);
238
+ case "delete-pool": return deleteResult(intent.ref, ask);
239
+ case "add-keys": {
240
+ const r = needPool(intent.ref);
241
+ if ("error" in r)
242
+ return { kind: "message", tone: "err", text: r.error };
243
+ return { kind: "import", pool: r.pool, expected: intent.count, hint: importHint(r.pool, intent.count) };
244
+ }
245
+ case "watch": {
246
+ const r = needPool(intent.ref);
247
+ if ("error" in r)
248
+ return { kind: "message", tone: "err", text: r.error };
249
+ return { kind: "watch", pool: r.pool };
250
+ }
251
+ case "serve": {
252
+ if (serverStarted)
253
+ return { kind: "serve", already: true };
254
+ serverStarted = true;
255
+ await import("../server.js");
256
+ return { kind: "serve", already: false };
257
+ }
258
+ case "update": {
259
+ const { performUpdate } = await import("../version.js");
260
+ const r = await performUpdate();
261
+ return { kind: "message", tone: r.ok ? (r.updated ? "ok" : "info") : "err", text: r.text };
262
+ }
263
+ case "uninstall": {
264
+ const ans = (await ask("Remove the pool-anything CLI (npm uninstall -g)? Your pools/keys in SQLite stay. [y/N] ")).trim().toLowerCase();
265
+ if (ans !== "y" && ans !== "yes")
266
+ return { kind: "message", tone: "info", text: "Cancelled." };
267
+ const { performUninstall } = await import("../version.js");
268
+ const r = performUninstall();
269
+ return { kind: "message", tone: r.ok ? "ok" : "err", text: r.text };
270
+ }
271
+ case "unknown": {
272
+ const s = suggest(input, PROVIDERS.map((p) => p.id));
273
+ return { kind: "message", tone: "err", text: `I didn't understand that.${s ? ` Did you mean '${s}'?` : ""} Type '.help' for commands.` };
274
+ }
275
+ }
276
+ }
package/dist/cli/nl.js CHANGED
@@ -45,6 +45,14 @@ export function parse(input) {
45
45
  return { kind: "serve" };
46
46
  if (/^(server|web ui|dashboard)$/.test(s))
47
47
  return { kind: "serve" };
48
+ // Self-maintenance: `update`/`/update` upgrades via npm; `uninstall` removes
49
+ // the global install (CLI only — no UI affordance by design).
50
+ if (/^[/.]?(update|upgrade)\b/.test(s) || /^(check for updates?|check updates?)$/.test(s))
51
+ return { kind: "update" };
52
+ if (/^[/.]?uninstall\b/.test(s) ||
53
+ /\buninstall\b/.test(s) ||
54
+ /^(remove|delete) (self|pool-anything|the app)\b/.test(s))
55
+ return { kind: "uninstall" };
48
56
  // Keys before pools: "list keys ..." contains neither "pool" word necessarily.
49
57
  if (/\bkeys?\b/.test(s) && /\b(list|ls|show|display|get)\b/.test(s)) {
50
58
  return { kind: "list-keys", ref: refOf(s, KEY_LIST_WORDS) };
@@ -107,6 +115,8 @@ const CANONICAL = [
107
115
  "delete pool",
108
116
  "watch",
109
117
  "serve",
118
+ "update",
119
+ "uninstall",
110
120
  "help",
111
121
  "exit",
112
122
  ];