alvin-bot 4.23.1 → 4.25.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/bin/cli.js +787 -2
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,86 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.25.0] — 2026-05-13
6
+
7
+ ### Auto-bootstrap media tools (yt-dlp + ffmpeg) on every setup and update
8
+
9
+ `yt-dlp` and `ffmpeg` are now installed (or upgraded) automatically whenever the user runs `alvin-bot setup` or `alvin-bot update` — silently, one line of status per tool, non-fatal on failure. These are the two foundational media tools used by the transcribe / download / video-generate / voice skills and they break or need patches often enough that "whatever brew shipped last week" is materially worse than "current latest" for ordinary users.
10
+
11
+ **No bundling. No redistribution.** alvin-bot shells out to the user's own system package manager (Homebrew on macOS, `pipx`/`apt` on Linux). The user remains the licensee under each tool's upstream license. License notes are recorded in `BOOTSTRAP_TOOLS` for transparency:
12
+
13
+ - **yt-dlp** — Unlicense (public-domain equivalent)
14
+ - **ffmpeg** — LGPL-2.1+ (Brew default build; no GPL-only codecs are pulled in by the default formula)
15
+
16
+ ### Behaviour
17
+
18
+ | User action | What happens |
19
+ |---|---|
20
+ | `alvin-bot setup` (interactive) | At the very start, before any prompt: tries `brew install yt-dlp` + `brew install ffmpeg` (or `pipx`/`apt` on Linux) if missing, runs `brew upgrade` if already installed. One status line per tool. |
21
+ | `alvin-bot update` | After `npm install -g` / `git pull` completes, the same bootstrap step runs — so an update of the bot also refreshes media tools. |
22
+ | `alvin-bot setup --no-bootstrap-tools` | Skips the bootstrap step entirely. For users who want full manual control. |
23
+ | Failure (no brew, no network, etc.) | Setup/update continues; user sees a `⚠` warning line; bot still works for everything that doesn't need media tools. |
24
+
25
+ ### Implementation detail that matters
26
+
27
+ On macOS, `brew upgrade <pkg>` does **not** automatically refresh the formula database before checking versions — meaning a user on stale brew (haven't run `brew update` in weeks) would see "already at latest" even when newer versions exist. The bootstrap step runs `brew update --quiet` **once per session** before any brew upgrade, memoized so multiple tools share the refresh cost. Cost: 5-30 s on first invocation, zero on subsequent calls.
28
+
29
+ ### End-to-end verified on a real Apple Silicon Mac
30
+
31
+ Two scenarios run manually after the code change:
32
+
33
+ 1. **Both tools present, brew formulas stale.** Bootstrap ran `brew update --quiet` + `brew upgrade yt-dlp/ffmpeg`. yt-dlp's brew-listed and upstream latest both confirmed at `2026.03.17` (verified via `brew info yt-dlp` and `yt-dlp -U`'s self-check) — code correctly reported "up-to-date". ffmpeg's pre-existing dylib-link failure (`libx265.215.dylib not found`) was incidentally fixed by `brew upgrade ffmpeg`, which is the exact kind of bit-rot the bootstrap is meant to catch.
34
+
35
+ 2. **yt-dlp deliberately uninstalled, run `alvin-bot update` again.** Bootstrap correctly detected `command -v yt-dlp` → not found, ran `brew install yt-dlp`, reported "installed (2026.03.17)" (note the differentiated wording vs "up-to-date" — same versioning convention as `npm install` vs `npm update`). Post-state: `/opt/homebrew/bin/yt-dlp` back and working.
36
+
37
+ ## [4.24.0] — 2026-05-12
38
+
39
+ ### Switch AI providers cleanly after install — new `alvin-bot provider` command
40
+
41
+ End-to-end UX gap closed: until now, the only way to change AI provider after the initial install was to re-run the full setup wizard (Telegram, AI provider, tools menu, …) or hand-edit `~/.alvin-bot/.env` without guidance. Both options leak past the user's actual intent — "I just want to change the AI provider, please."
42
+
43
+ ```bash
44
+ alvin-bot provider list # show all providers + per-provider install/key status
45
+ alvin-bot provider show # detailed info on the current provider
46
+ alvin-bot provider switch <key> # interactive setup + targeted .env merge
47
+ alvin-bot provider doctor # validate current provider's auth/key against its API
48
+ ```
49
+
50
+ `<key>` accepts both canonical slugs (`claude-sdk`, `groq`, `gemini-2.5-flash`, …) and short aliases (`claude`, `codex`, `gemini`, `nvidia`, `gpt`, `gemma`).
51
+
52
+ ### Provider-specific guided setup, reused from the setup wizard
53
+
54
+ The same install-the-CLI / run-OAuth-login / prompt-for-API-key logic that the setup wizard already uses for first-time configuration is now reusable. Refactored into three small primitives:
55
+
56
+ - **`configureClaudeSdkInline()`** — detects native `claude` binary, offers `curl -fsSL https://claude.ai/install.sh | sh` if missing, runs `claude auth login --claudeai` if not authenticated, re-validates.
57
+ - **`configureCodexCliInline()`** — new. Detects `codex` binary, offers `npm install -g @openai/codex` if missing, runs `codex login` (ChatGPT OAuth) if not authenticated.
58
+ - **`configureApiKeyInline(provider, opts)`** — for groq / nvidia / gemini / openai / openrouter. Tests an existing key in `.env` first, prompts for a new one if invalid, retries once, "save anyway" path for offline first-time setup.
59
+
60
+ ### Safe `.env` merge — never destroys, never duplicates
61
+
62
+ The new `setEnvKey()` + `commentEnvKey()` helpers do **byte-preserving** in-place updates of `~/.alvin-bot/.env`:
63
+
64
+ - `PRIMARY_PROVIDER` line is **updated in place**, not appended. No duplicates.
65
+ - The new provider's API key (if any) is set the same way — also handles "previously commented out by an earlier switch" → uncomment + update.
66
+ - The previous provider's API key is **parked, not deleted**: e.g. `# GROQ_API_KEY=... # parked 2026-05-12: was for groq, kept for rollback`. Rollback by un-commenting; secret never lost.
67
+ - Everything else (`BOT_TOKEN`, `ALLOWED_USERS`, `FALLBACK_PROVIDERS`, `WEB_*`, custom comments, blank lines, custom additions) is preserved byte-for-byte.
68
+
69
+ ### Auto-restart under launchd
70
+
71
+ After a successful switch, on macOS with `~/Library/LaunchAgents/com.alvinbot.app.plist` present, the command offers to run `launchctl kickstart -k gui/$UID/com.alvinbot.app` to apply the new provider without manual intervention. Default is Yes; press Enter to restart, `n` to defer.
72
+
73
+ ### Robustness: `ask()` no longer crashes on stdin EOF
74
+
75
+ `readline.question` throws `ERR_USE_AFTER_CLOSE` once stdin emits 'close' (piped input drained, terminal closed). The `ask()` helper now catches this and returns `""` — same semantic as "user pressed Enter on a prompt", so the calling wizard step picks its default. Fixes a class of crashes for users running setup wizards over `expect` / SSH-piped install scripts / CI.
76
+
77
+ ### Manual end-to-end test on a fresh-ish Apple Silicon Mac (.75)
78
+
79
+ - `provider list` shows all 8 providers with per-provider status (CLI installed, API key present, OAuth-authenticated)
80
+ - `provider show` reports current = `claude-sdk` with model + key var + fallbacks
81
+ - `provider doctor` validates auth/key against the live API
82
+ - `provider switch groq` with a deliberately invalid key: API returns 401, "Save anyway" path is taken, `.env` is mutated correctly: `PRIMARY_PROVIDER=groq`, `GROQ_API_KEY=…` set, all other vars + comments preserved byte-for-byte
83
+ - `provider switch claude-sdk` (rollback): existing claude binary detected, auth-status warning surfaced, login prompt declined, `.env` mutated: `PRIMARY_PROVIDER=claude-sdk` back, `GROQ_API_KEY` line **commented and stamped** `# GROQ_API_KEY=... # parked 2026-05-12: was for groq, kept for rollback`, every other line untouched
84
+
5
85
  ## [4.23.1] — 2026-05-12
6
86
 
7
87
  ### Fixed: install.sh now installs via npm (no more git clone collision)
package/bin/cli.js CHANGED
@@ -42,7 +42,21 @@ const ensureRL = () => {
42
42
  return rl;
43
43
  };
44
44
  const closeRL = () => { if (rl) { rl.close(); rl = null; } };
45
- const ask = (q) => new Promise((r) => ensureRL().question(q, r));
45
+ // Robust ask(): if stdin has already closed (piped input EOF, or a previous
46
+ // ask completed and the interface emitted 'close'), `rl.question` throws
47
+ // ERR_USE_AFTER_CLOSE. Treat that as empty input rather than crashing the
48
+ // wizard — the caller can decide whether empty means "default" or "skip".
49
+ const ask = (q) => new Promise((resolve, reject) => {
50
+ try {
51
+ ensureRL().question(q, resolve);
52
+ } catch (err) {
53
+ if (err && err.code === "ERR_USE_AFTER_CLOSE") {
54
+ resolve("");
55
+ } else {
56
+ reject(err);
57
+ }
58
+ }
59
+ });
46
60
 
47
61
  const LOGO = `
48
62
  ╔══════════════════════════════════════╗
@@ -221,6 +235,165 @@ function hasCommand(name) {
221
235
  }
222
236
  }
223
237
 
238
+ // ─── Auto-bootstrap media tools ───────────────────────────────────────────
239
+ //
240
+ // These two are universally useful for the media-related skills (transcribe,
241
+ // download, video-generate, voice) and they break or need patches often enough
242
+ // (yt-dlp especially — YouTube changes its extractors weekly) that "always
243
+ // latest" is materially better than "whatever brew shipped last week".
244
+ //
245
+ // The installer here is NOT redistribution: we shell out to the user's own
246
+ // system package manager (brew/apt) to install or upgrade. The user remains
247
+ // the licensee under the tool's upstream license. License notes per tool are
248
+ // in BOOTSTRAP_TOOLS for transparency — and `--no-bootstrap-tools` lets users
249
+ // opt out entirely.
250
+ //
251
+ // Output is intentionally quiet: a single status line per tool, install output
252
+ // only surfaces on failure. Setup-flow is never blocked by a bootstrap-tool
253
+ // failure — the user gets a warning, the wizard continues.
254
+
255
+ const BOOTSTRAP_TOOLS = [
256
+ {
257
+ cmd: "yt-dlp",
258
+ name: "yt-dlp",
259
+ license: "Unlicense (public domain equivalent)",
260
+ install: { macos: "brew install yt-dlp", linux: "pipx install yt-dlp" },
261
+ // yt-dlp ships its own `-U` self-updater but it bails out for binaries
262
+ // installed via brew (can't write to /opt/homebrew/). Always go through
263
+ // the package manager that originally installed it.
264
+ upgrade: { macos: "brew upgrade yt-dlp", linux: "pipx upgrade yt-dlp" },
265
+ // Linux fallback if pipx isn't available
266
+ linuxSkipIf: "pipx",
267
+ },
268
+ {
269
+ cmd: "ffmpeg",
270
+ name: "ffmpeg",
271
+ license: "LGPL-2.1+ (brew default; auto-install via your package manager — you remain the licensee)",
272
+ install: { macos: "brew install ffmpeg", linux: "sudo apt-get install -y ffmpeg" },
273
+ upgrade: { macos: "brew upgrade ffmpeg", linux: "sudo apt-get install --only-upgrade -y ffmpeg" },
274
+ },
275
+ ];
276
+
277
+ // Memoized: `brew update` is slow (5-30s) but needs to run at least once
278
+ // per session before `brew upgrade` so we get the latest formula info,
279
+ // not whatever was cached locally weeks ago. Without this we hit the
280
+ // "brew thinks yt-dlp is at 2026-03-17 because formula DB is stale" trap.
281
+ let brewFormulasRefreshed = false;
282
+ function refreshBrewFormulas() {
283
+ if (brewFormulasRefreshed) return;
284
+ if (!hasCommand("brew")) return;
285
+ try {
286
+ execSync("brew update --quiet", { stdio: "pipe", timeout: 90_000 });
287
+ } catch {
288
+ // brew update can fail transiently (rate limits, network). Non-fatal:
289
+ // we'll fall through with whatever cached formulas brew has.
290
+ }
291
+ brewFormulasRefreshed = true;
292
+ }
293
+
294
+ /**
295
+ * Detect the major package manager available for non-interactive installs.
296
+ * Returns "macos" (homebrew), "linux" (apt-based), or null.
297
+ */
298
+ function detectPlatformPm() {
299
+ if (process.platform === "darwin") return "macos";
300
+ if (process.platform === "linux") return "linux";
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * Quietly install or upgrade a single bootstrap tool. Returns a brief status
306
+ * string for the caller to print. Never throws — failures are reported as
307
+ * { ok: false, message } so the calling step keeps going.
308
+ */
309
+ function bootstrapOneTool(tool, platform) {
310
+ const cmdAvailable = hasCommand(tool.cmd);
311
+
312
+ // Linux-only prerequisite check (e.g. pipx for yt-dlp).
313
+ if (platform === "linux" && tool.linuxSkipIf && !hasCommand(tool.linuxSkipIf)) {
314
+ return { ok: false, message: `${tool.name} skipped — needs '${tool.linuxSkipIf}' on Linux` };
315
+ }
316
+
317
+ // Decide whether the install/upgrade command goes through brew on macOS.
318
+ // If so, refresh the formula DB once before any upgrade attempts so we
319
+ // don't end up running `brew upgrade` against weeks-old formula info.
320
+ const installCmd = tool.install[platform];
321
+ const upgradeCmd = tool.upgrade && tool.upgrade[platform];
322
+ if (platform === "macos" && (String(installCmd).startsWith("brew") || String(upgradeCmd).startsWith("brew"))) {
323
+ refreshBrewFormulas();
324
+ }
325
+
326
+ // ── Upgrade path: tool is already installed ────────────────────────────
327
+ if (cmdAvailable) {
328
+ if (!upgradeCmd) {
329
+ return { ok: true, message: `${tool.name} (already installed, no upgrade path on ${platform})` };
330
+ }
331
+ let beforeVersion = "";
332
+ try {
333
+ beforeVersion = execSync(`${tool.cmd} --version 2>&1 | head -1`, { stdio: "pipe", encoding: "utf-8", timeout: 5000 }).trim().split("\n")[0];
334
+ } catch {}
335
+ try {
336
+ execSync(upgradeCmd, { stdio: "pipe", timeout: 120_000, encoding: "utf-8" });
337
+ let afterVersion = "";
338
+ try {
339
+ afterVersion = execSync(`${tool.cmd} --version 2>&1 | head -1`, { stdio: "pipe", encoding: "utf-8", timeout: 5000 }).trim().split("\n")[0];
340
+ } catch {}
341
+ const upgraded = beforeVersion && afterVersion && beforeVersion !== afterVersion;
342
+ const verbInfix = upgraded ? "upgraded" : "up-to-date";
343
+ const versionTag = afterVersion ? ` (${afterVersion})` : "";
344
+ return { ok: true, message: `${tool.name} ${verbInfix}${versionTag}` };
345
+ } catch (err) {
346
+ const msg = String(err.stderr || err.message || err).split("\n")[0].slice(0, 120);
347
+ return { ok: true, message: `${tool.name} present (upgrade attempt failed: ${msg})` };
348
+ }
349
+ }
350
+
351
+ // ── Fresh install ──────────────────────────────────────────────────────
352
+ if (!installCmd) {
353
+ return { ok: false, message: `${tool.name} skipped — no install command for ${platform}` };
354
+ }
355
+ try {
356
+ execSync(installCmd, { stdio: "pipe", timeout: 240_000, encoding: "utf-8" });
357
+ let version = "";
358
+ try {
359
+ version = execSync(`${tool.cmd} --version 2>&1 | head -1`, { stdio: "pipe", encoding: "utf-8", timeout: 5000 }).trim().split("\n")[0];
360
+ } catch {}
361
+ return { ok: true, message: `${tool.name} installed${version ? ` (${version})` : ""}` };
362
+ } catch (err) {
363
+ const msg = String(err.stderr || err.message || err).split("\n")[0].slice(0, 120);
364
+ return { ok: false, message: `${tool.name} install failed (${msg})` };
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Run all bootstrap tools quietly. Used at the start of `alvin-bot setup` and
370
+ * in `alvin-bot update`. Skipped entirely if the caller passes
371
+ * `--no-bootstrap-tools`. On unsupported platforms (Windows etc.) this is a
372
+ * silent no-op — the user can install manually.
373
+ */
374
+ async function ensureBootstrapTools(opts = {}) {
375
+ if (opts.skip) return;
376
+ const platform = detectPlatformPm();
377
+ if (!platform) return;
378
+
379
+ console.log("\n🎬 Setting up media tools (yt-dlp + ffmpeg)...");
380
+
381
+ // macOS needs brew on PATH — same trick as ensureBrewOnPath() uses.
382
+ if (platform === "macos" && !hasCommand("brew")) {
383
+ if (!ensureBrewOnPath()) {
384
+ console.log(" ⚠️ Skipping media-tool bootstrap — Homebrew not installed.");
385
+ console.log(" To enable: install brew from https://brew.sh and re-run setup.");
386
+ return;
387
+ }
388
+ }
389
+
390
+ for (const tool of BOOTSTRAP_TOOLS) {
391
+ const result = bootstrapOneTool(tool, platform);
392
+ console.log(` ${result.ok ? "✓" : "⚠"} ${result.message}`);
393
+ }
394
+ console.log("");
395
+ }
396
+
224
397
  function tryNpx(args) {
225
398
  try {
226
399
  execSync(`npx --no-install ${args}`, { stdio: "pipe", timeout: 5000 });
@@ -727,6 +900,578 @@ async function validateProviderKey(providerKey, apiKey) {
727
900
  }
728
901
  }
729
902
 
903
+ // ─── Provider config (shared between setup wizard and `provider switch`) ─────
904
+ //
905
+ // Encapsulates the CLI-install + auth-login flow for OAuth-style providers
906
+ // (claude-sdk, codex-cli) and the API-key prompt+validate flow for everything
907
+ // else. Returns a `{ ok, apiKey }` so callers can decide what to do with the
908
+ // .env (the setup wizard writes everything at once; `provider switch` does a
909
+ // targeted merge).
910
+
911
+ const PROVIDER_KEY_MAP = {
912
+ groq: "GROQ_API_KEY",
913
+ "nvidia-llama-3.3-70b": "NVIDIA_API_KEY",
914
+ "nvidia-kimi-k2.5": "NVIDIA_API_KEY",
915
+ "gemini-2.5-flash": "GOOGLE_API_KEY",
916
+ openai: "OPENAI_API_KEY",
917
+ "gpt-4o": "OPENAI_API_KEY",
918
+ openrouter: "OPENROUTER_API_KEY",
919
+ };
920
+
921
+ /**
922
+ * Find the codex CLI binary if installed. Returns the absolute path or null.
923
+ */
924
+ function findCodexBinary() {
925
+ try {
926
+ const out = execSync("command -v codex", { stdio: "pipe", encoding: "utf-8" });
927
+ return out.trim() || null;
928
+ } catch {
929
+ return null;
930
+ }
931
+ }
932
+
933
+ /**
934
+ * Find the claude CLI binary, checking PATH and common install paths.
935
+ * Returns the absolute path or null. Mirrors the search in
936
+ * validateProviderKey("claude-sdk", ...).
937
+ */
938
+ function findClaudeBinary() {
939
+ try {
940
+ const out = execSync("command -v claude", { stdio: "pipe", encoding: "utf-8" });
941
+ if (out.trim()) return out.trim();
942
+ } catch {}
943
+ for (const p of [join(homedir(), ".local", "bin", "claude"), "/usr/local/bin/claude"]) {
944
+ if (existsSync(p)) return p;
945
+ }
946
+ return null;
947
+ }
948
+
949
+ /**
950
+ * Interactive Claude SDK setup: ensures the native claude CLI is installed,
951
+ * runs `claude auth login` if not authenticated. Returns { ok: bool }.
952
+ *
953
+ * Designed to be called both from the setup wizard and from
954
+ * `alvin-bot provider switch claude-sdk`.
955
+ */
956
+ async function configureClaudeSdkInline() {
957
+ let cliPath = findClaudeBinary();
958
+ if (cliPath) {
959
+ console.log(` ✅ Claude CLI found at ${cliPath}`);
960
+ } else {
961
+ console.log(` ⚠️ Claude CLI not found (native binary required).`);
962
+ console.log(`\n The Claude Agent SDK needs the native Claude Code binary.`);
963
+ console.log(` Install with:\n curl -fsSL https://claude.ai/install.sh | sh`);
964
+ console.log(` (npm install @anthropic-ai/claude-code is the alternative path.)\n`);
965
+
966
+ const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
967
+ const doInstall = (await ask(` Install now via the official installer? (y/n): `)).trim().toLowerCase();
968
+ if (yc.includes(doInstall)) {
969
+ console.log(`\n Installing Claude CLI...`);
970
+ try {
971
+ execSync("curl -fsSL https://claude.ai/install.sh | sh", { stdio: "inherit", timeout: 120_000 });
972
+ const localBin = join(homedir(), ".local", "bin");
973
+ if (!process.env.PATH.includes(localBin)) {
974
+ process.env.PATH = `${localBin}:${process.env.PATH || ""}`;
975
+ }
976
+ cliPath = findClaudeBinary();
977
+ if (cliPath) console.log(` ✅ Claude CLI installed at ${cliPath}\n`);
978
+ } catch {
979
+ console.log(` ❌ Installation failed. Run manually: curl -fsSL https://claude.ai/install.sh | sh`);
980
+ }
981
+ }
982
+ }
983
+
984
+ if (!cliPath) return { ok: false };
985
+
986
+ console.log(` Checking Claude SDK authentication...`);
987
+ const authResult = await validateProviderKey("claude-sdk", null);
988
+ if (authResult.ok && !authResult.warning) {
989
+ console.log(` ✅ ${authResult.detail}\n`);
990
+ return { ok: true };
991
+ }
992
+
993
+ // ok-with-warning OR not-ok — offer login.
994
+ if (authResult.warning) {
995
+ console.log(` ⚠️ ${authResult.warning}\n`);
996
+ } else {
997
+ console.log(` ⚠️ ${authResult.error}\n`);
998
+ }
999
+ const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
1000
+ const doLogin = (await ask(` Run 'claude auth login' now? (Y/n): `)).trim().toLowerCase();
1001
+ if (doLogin === "" || yc.includes(doLogin)) {
1002
+ try {
1003
+ execSync(`"${cliPath}" auth login --claudeai`, { stdio: "inherit", timeout: 180_000 });
1004
+ } catch {
1005
+ console.log(`\n ⚠️ Auto-login failed/cancelled. Run in another terminal: claude auth login`);
1006
+ await ask(` Press Enter when you've logged in (or to continue without): `);
1007
+ }
1008
+ const recheck = await validateProviderKey("claude-sdk", null);
1009
+ if (recheck.ok && !recheck.warning) {
1010
+ console.log(`\n ✅ Claude SDK authenticated.\n`);
1011
+ return { ok: true };
1012
+ }
1013
+ if (recheck.ok && recheck.warning) {
1014
+ console.log(`\n ⚠️ ${recheck.warning}`);
1015
+ console.log(` The bot will still try to use the Agent SDK at runtime.\n`);
1016
+ return { ok: true };
1017
+ }
1018
+ console.log(`\n ❌ Still not authenticated: ${recheck.error}`);
1019
+ return { ok: false };
1020
+ }
1021
+ console.log(` Skipping login. Run 'claude auth login' later to enable AI responses.`);
1022
+ return { ok: true }; // CLI present, user explicitly skipped login — let it through
1023
+ }
1024
+
1025
+ /**
1026
+ * Interactive OpenAI Codex CLI setup: ensures `codex` is installed, runs
1027
+ * `codex login` if not authenticated (ChatGPT-Plus/Pro/Max OAuth).
1028
+ */
1029
+ async function configureCodexCliInline() {
1030
+ let cliPath = findCodexBinary();
1031
+ if (cliPath) {
1032
+ console.log(` ✅ codex CLI found at ${cliPath}`);
1033
+ } else {
1034
+ console.log(` ⚠️ codex CLI not installed.`);
1035
+ console.log(`\n Install with one of:`);
1036
+ console.log(` npm install -g @openai/codex (recommended)`);
1037
+ console.log(` brew install codex\n`);
1038
+ const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
1039
+ const doInstall = (await ask(` Install via npm now? (y/n): `)).trim().toLowerCase();
1040
+ if (yc.includes(doInstall)) {
1041
+ console.log(`\n Installing @openai/codex...`);
1042
+ try {
1043
+ execSync("npm install -g @openai/codex", { stdio: "inherit", timeout: 120_000 });
1044
+ cliPath = findCodexBinary();
1045
+ if (cliPath) console.log(` ✅ codex CLI installed at ${cliPath}\n`);
1046
+ } catch {
1047
+ console.log(` ❌ Installation failed. Run manually: npm install -g @openai/codex`);
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ if (!cliPath) return { ok: false };
1053
+
1054
+ // Check login status — `codex login status` exits 0 when logged in.
1055
+ let loggedIn = false;
1056
+ try {
1057
+ const out = execSync(`"${cliPath}" login status 2>&1`, { stdio: "pipe", encoding: "utf-8", timeout: 5000 });
1058
+ if (/Logged in/i.test(out)) loggedIn = true;
1059
+ } catch {}
1060
+
1061
+ if (loggedIn) {
1062
+ console.log(` ✅ codex CLI is logged in.\n`);
1063
+ return { ok: true };
1064
+ }
1065
+
1066
+ console.log(` ⚠️ codex CLI not logged in.`);
1067
+ console.log(` 'codex login' opens a browser — sign in with your ChatGPT account.\n`);
1068
+ const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
1069
+ const doLogin = (await ask(` Run 'codex login' now? (Y/n): `)).trim().toLowerCase();
1070
+ if (doLogin === "" || yc.includes(doLogin)) {
1071
+ try {
1072
+ execSync(`"${cliPath}" login`, { stdio: "inherit", timeout: 240_000 });
1073
+ } catch {
1074
+ console.log(`\n ⚠️ codex login failed/cancelled.`);
1075
+ }
1076
+ try {
1077
+ const out = execSync(`"${cliPath}" login status 2>&1`, { stdio: "pipe", encoding: "utf-8" });
1078
+ if (/Logged in/i.test(out)) {
1079
+ console.log(`\n ✅ codex CLI is logged in.\n`);
1080
+ return { ok: true };
1081
+ }
1082
+ } catch {}
1083
+ console.log(`\n ⚠️ Still not logged in — run 'codex login' later to enable.`);
1084
+ return { ok: false };
1085
+ }
1086
+ console.log(` Skipping login. Run 'codex login' later to enable AI responses.`);
1087
+ return { ok: true };
1088
+ }
1089
+
1090
+ /**
1091
+ * Interactive API-key flow for providers that authenticate with a key
1092
+ * (groq, nvidia, gemini, openai, openrouter, …). Validates the key against
1093
+ * the provider's API where possible. Returns { ok, apiKey }.
1094
+ */
1095
+ async function configureApiKeyInline(provider, opts = {}) {
1096
+ const envKey = provider.envKey;
1097
+ if (!envKey) return { ok: true, apiKey: "" };
1098
+
1099
+ const existing = opts.existingKey || "";
1100
+ if (existing) {
1101
+ console.log(`\n Existing ${envKey} found in .env — validating first...`);
1102
+ const test = await validateProviderKey(provider.key, existing);
1103
+ if (test.ok) {
1104
+ console.log(` ✅ ${test.detail}\n`);
1105
+ return { ok: true, apiKey: existing };
1106
+ }
1107
+ console.log(` ⚠️ Existing key invalid: ${test.error}\n`);
1108
+ }
1109
+
1110
+ console.log(` Get an API key for ${provider.name}: ${provider.signup || "(see provider docs)"}\n`);
1111
+ let apiKey = (await ask(` ${envKey}: `)).trim();
1112
+ if (!apiKey) {
1113
+ console.log(` ⚠️ No API key provided — provider won't work until configured.`);
1114
+ return { ok: false, apiKey: "" };
1115
+ }
1116
+
1117
+ console.log(`\n Validating...`);
1118
+ let result = await validateProviderKey(provider.key, apiKey);
1119
+ if (result.ok) {
1120
+ console.log(` ✅ ${result.detail}\n`);
1121
+ return { ok: true, apiKey };
1122
+ }
1123
+ console.log(` ❌ ${result.error}\n`);
1124
+
1125
+ const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
1126
+ const retry = (await ask(` Try a different key? (y/n): `)).trim().toLowerCase();
1127
+ if (yc.includes(retry)) {
1128
+ apiKey = (await ask(` ${envKey}: `)).trim();
1129
+ if (apiKey) {
1130
+ result = await validateProviderKey(provider.key, apiKey);
1131
+ if (result.ok) {
1132
+ console.log(` ✅ ${result.detail}\n`);
1133
+ return { ok: true, apiKey };
1134
+ }
1135
+ console.log(` ❌ ${result.error}`);
1136
+ }
1137
+ }
1138
+ console.log(` ⚠️ Saving the key anyway — fix later in ~/.alvin-bot/.env if it's wrong.`);
1139
+ return { ok: !!apiKey, apiKey };
1140
+ }
1141
+
1142
+ // ─── .env merge helpers ──────────────────────────────────────────────────────
1143
+ //
1144
+ // `provider switch` mutates ~/.alvin-bot/.env by touching exactly two lines:
1145
+ // PRIMARY_PROVIDER and the new provider's API-key entry. Everything else
1146
+ // (BOT_TOKEN, ALLOWED_USERS, FALLBACK_PROVIDERS, WEB_*, comments, blank
1147
+ // lines, custom additions) is preserved byte-for-byte. The OLD provider's
1148
+ // API key is commented out — never deleted — so the user can roll back
1149
+ // without re-pasting secrets they may not have anywhere else.
1150
+
1151
+ /**
1152
+ * Set (or append) `KEY=value` in .env content. If a matching commented line
1153
+ * (`# KEY=...`) exists, it's replaced with the active form. Otherwise the
1154
+ * new line is appended at the end with a leading blank line if needed.
1155
+ */
1156
+ function setEnvKey(content, key, value) {
1157
+ const lines = content.split("\n");
1158
+ const activeRe = new RegExp(`^${key}\\s*=`);
1159
+ const commentedRe = new RegExp(`^#\\s*${key}\\s*=`);
1160
+ for (let i = 0; i < lines.length; i++) {
1161
+ if (activeRe.test(lines[i]) || commentedRe.test(lines[i])) {
1162
+ lines[i] = `${key}=${value}`;
1163
+ return lines.join("\n");
1164
+ }
1165
+ }
1166
+ // Append. Ensure there's a separating blank line.
1167
+ if (lines.length && lines[lines.length - 1].trim() !== "") lines.push("");
1168
+ lines.push(`${key}=${value}`);
1169
+ return lines.join("\n");
1170
+ }
1171
+
1172
+ /**
1173
+ * Comment out an active `KEY=...` line with a note. Leaves already-commented
1174
+ * lines alone, leaves the line absent if it wasn't there. Returns the new
1175
+ * content + a `changed` flag for caller logging.
1176
+ */
1177
+ function commentEnvKey(content, key, note) {
1178
+ const lines = content.split("\n");
1179
+ const activeRe = new RegExp(`^${key}\\s*=`);
1180
+ let changed = false;
1181
+ for (let i = 0; i < lines.length; i++) {
1182
+ if (activeRe.test(lines[i])) {
1183
+ const today = new Date().toISOString().slice(0, 10);
1184
+ const stamp = note ? ` # parked ${today}: ${note}` : ` # parked ${today}`;
1185
+ lines[i] = `# ${lines[i]}${stamp}`;
1186
+ changed = true;
1187
+ break;
1188
+ }
1189
+ }
1190
+ return { content: lines.join("\n"), changed };
1191
+ }
1192
+
1193
+ /**
1194
+ * Read PRIMARY_PROVIDER from .env content (handles "...=value" with optional
1195
+ * quotes/whitespace). Returns the key or null if not set.
1196
+ */
1197
+ function readPrimaryProvider(content) {
1198
+ const m = content.match(/^PRIMARY_PROVIDER\s*=\s*"?([^"#\s]+)"?\s*$/m);
1199
+ return m ? m[1] : null;
1200
+ }
1201
+
1202
+ /**
1203
+ * Read an arbitrary key from .env content. Returns the value or null.
1204
+ */
1205
+ function readEnvValue(content, key) {
1206
+ const re = new RegExp(`^${key}\\s*=\\s*"?([^"#\\n]+?)"?\\s*(?:#.*)?$`, "m");
1207
+ const m = content.match(re);
1208
+ return m ? m[1].trim() : null;
1209
+ }
1210
+
1211
+ // ─── `alvin-bot provider` subcommands ────────────────────────────────────────
1212
+
1213
+ /**
1214
+ * Resolve a provider key (or aliases) to a PROVIDERS entry. Returns null if
1215
+ * unknown. Lets users use "claude" → "claude-sdk", "codex" → "codex-cli",
1216
+ * "nvidia" → "nvidia-kimi-k2.5", "gemini" → "gemini-2.5-flash" — so they
1217
+ * don't have to remember the exact slug.
1218
+ */
1219
+ function resolveProviderKey(input) {
1220
+ const lower = input.trim().toLowerCase();
1221
+ const aliases = {
1222
+ "claude": "claude-sdk",
1223
+ "claudesdk": "claude-sdk",
1224
+ "codex": "codex-cli",
1225
+ "nvidia": "nvidia-kimi-k2.5",
1226
+ "nim": "nvidia-kimi-k2.5",
1227
+ "kimi": "nvidia-kimi-k2.5",
1228
+ "gemini": "gemini-2.5-flash",
1229
+ "google": "gemini-2.5-flash",
1230
+ "gpt": "openai",
1231
+ "gpt4": "openai",
1232
+ "gpt4o": "openai",
1233
+ "offline": "offline-gemma4",
1234
+ "gemma": "offline-gemma4",
1235
+ "gemma4": "offline-gemma4",
1236
+ };
1237
+ const resolved = aliases[lower] || lower;
1238
+ // Codex-cli isn't in PROVIDERS but is supported by switch/list.
1239
+ if (resolved === "codex-cli") {
1240
+ return {
1241
+ key: "codex-cli",
1242
+ name: "OpenAI Codex CLI (ChatGPT OAuth)",
1243
+ desc: () => "Use your ChatGPT Plus/Pro/Max subscription via the codex CLI. OAuth login, no API key needed.",
1244
+ free: false,
1245
+ envKey: null,
1246
+ signup: "https://chat.openai.com",
1247
+ model: "gpt-5",
1248
+ needsCLI: false,
1249
+ needsCodexCLI: true,
1250
+ };
1251
+ }
1252
+ return PROVIDERS.find(p => p.key === resolved) || null;
1253
+ }
1254
+
1255
+ /**
1256
+ * Compute installed / authenticated / has-key status for a provider, given
1257
+ * the current .env content. Returns a short status string with an icon.
1258
+ */
1259
+ function providerStatus(provider, envContent) {
1260
+ if (provider.offline) {
1261
+ return hasOllama() ? "✓ Ollama installed" : "· Ollama not installed";
1262
+ }
1263
+ if (provider.needsCLI) {
1264
+ return findClaudeBinary() ? "✓ Claude CLI installed" : "· Claude CLI not installed";
1265
+ }
1266
+ if (provider.needsCodexCLI) {
1267
+ return findCodexBinary() ? "✓ codex CLI installed" : "· codex CLI not installed";
1268
+ }
1269
+ if (provider.envKey) {
1270
+ const v = readEnvValue(envContent, provider.envKey);
1271
+ return v ? `✓ ${provider.envKey} set` : `· ${provider.envKey} not set`;
1272
+ }
1273
+ return "—";
1274
+ }
1275
+
1276
+ function envPath() {
1277
+ return join(DATA_DIR, ".env");
1278
+ }
1279
+
1280
+ function loadEnvOrExit(silent = false) {
1281
+ const p = envPath();
1282
+ if (!existsSync(p)) {
1283
+ if (!silent) {
1284
+ console.log(`\n❌ No .env at ${p}`);
1285
+ console.log(` Run 'alvin-bot setup' first to configure the bot.\n`);
1286
+ }
1287
+ return null;
1288
+ }
1289
+ return { path: p, content: readFileSync(p, "utf-8") };
1290
+ }
1291
+
1292
+ async function providerList() {
1293
+ const loaded = loadEnvOrExit(true);
1294
+ const env = loaded?.content || "";
1295
+ const current = readPrimaryProvider(env);
1296
+
1297
+ console.log(`\n📋 AI providers — current: ${current ? `🟢 ${current}` : "(not configured)"}\n`);
1298
+
1299
+ // Build a uniform list: wizard PROVIDERS + codex-cli alias.
1300
+ const ALL = [...PROVIDERS, resolveProviderKey("codex-cli")];
1301
+ for (const p of ALL) {
1302
+ const marker = p.key === current ? "→" : " ";
1303
+ const badge = p.offline ? "🔒" : p.free ? "🆓" : "💰";
1304
+ const status = providerStatus(p, env);
1305
+ console.log(` ${marker} ${badge} ${p.key.padEnd(22)} ${status}`);
1306
+ console.log(` ${p.name}`);
1307
+ }
1308
+ console.log(`\n Switch with: alvin-bot provider switch <key>`);
1309
+ console.log(` Aliases: claude, codex, gemini, nvidia, gpt, gemma\n`);
1310
+ }
1311
+
1312
+ async function providerShow() {
1313
+ const loaded = loadEnvOrExit();
1314
+ if (!loaded) return;
1315
+ const current = readPrimaryProvider(loaded.content);
1316
+ if (!current) {
1317
+ console.log(`\n⚠️ PRIMARY_PROVIDER not set in .env.\n`);
1318
+ return;
1319
+ }
1320
+ const provider = resolveProviderKey(current);
1321
+ if (!provider) {
1322
+ console.log(`\n⚠️ PRIMARY_PROVIDER='${current}' but no matching provider definition found.\n`);
1323
+ return;
1324
+ }
1325
+ console.log(`\n📍 Current AI provider\n`);
1326
+ console.log(` Key: ${provider.key}`);
1327
+ console.log(` Name: ${provider.name}`);
1328
+ console.log(` Model: ${provider.model || "(default)"}`);
1329
+ console.log(` Status: ${providerStatus(provider, loaded.content)}`);
1330
+ if (provider.envKey) {
1331
+ const v = readEnvValue(loaded.content, provider.envKey);
1332
+ console.log(` Key var: ${provider.envKey}${v ? " (set)" : " (missing!)"}`);
1333
+ }
1334
+ console.log(``);
1335
+ const fb = readEnvValue(loaded.content, "FALLBACK_PROVIDERS");
1336
+ if (fb) console.log(` Fallbacks: ${fb}\n`);
1337
+ console.log(` Doctor: alvin-bot provider doctor`);
1338
+ console.log(` Switch: alvin-bot provider switch <key>\n`);
1339
+ }
1340
+
1341
+ /**
1342
+ * Switch the bot to a different AI provider. Runs the same interactive
1343
+ * configuration as the setup wizard (CLI install + auth login or API-key
1344
+ * prompt) but only touches the provider-related lines in .env. The previous
1345
+ * provider's API key is commented out (never deleted) so rollback is trivial.
1346
+ */
1347
+ async function providerSwitch(targetKey) {
1348
+ if (!targetKey) {
1349
+ console.log(`Usage: alvin-bot provider switch <key>`);
1350
+ console.log(`Available keys: see 'alvin-bot provider list'`);
1351
+ return;
1352
+ }
1353
+ const target = resolveProviderKey(targetKey);
1354
+ if (!target) {
1355
+ console.log(`\n❌ Unknown provider: '${targetKey}'`);
1356
+ console.log(` See 'alvin-bot provider list' for valid keys.\n`);
1357
+ return;
1358
+ }
1359
+ const loaded = loadEnvOrExit();
1360
+ if (!loaded) return;
1361
+
1362
+ let content = loaded.content;
1363
+ const fromKey = readPrimaryProvider(content);
1364
+ const fromProvider = fromKey ? resolveProviderKey(fromKey) : null;
1365
+
1366
+ console.log(`\n🔁 Switching AI provider`);
1367
+ console.log(` from: ${fromKey || "(not set)"}${fromProvider ? ` (${fromProvider.name})` : ""}`);
1368
+ console.log(` to: ${target.key} (${target.name})\n`);
1369
+
1370
+ if (fromKey === target.key) {
1371
+ console.log(` ℹ️ Already on ${target.key}. Nothing to do.\n`);
1372
+ return;
1373
+ }
1374
+
1375
+ // ── Run provider-specific configuration ────────────────────────────────
1376
+ let newKey = "";
1377
+ if (target.offline) {
1378
+ const ok = await setupOfflineGemma4();
1379
+ if (!ok) {
1380
+ console.log(` ❌ Offline setup didn't complete — keeping current provider.\n`);
1381
+ return;
1382
+ }
1383
+ } else if (target.needsCLI) {
1384
+ const res = await configureClaudeSdkInline();
1385
+ if (!res.ok) {
1386
+ console.log(` ❌ Claude SDK setup didn't complete — keeping current provider.\n`);
1387
+ return;
1388
+ }
1389
+ } else if (target.needsCodexCLI) {
1390
+ const res = await configureCodexCliInline();
1391
+ if (!res.ok) {
1392
+ console.log(` ❌ Codex CLI setup didn't complete — keeping current provider.\n`);
1393
+ return;
1394
+ }
1395
+ } else if (target.envKey) {
1396
+ const existingKey = readEnvValue(content, target.envKey) || "";
1397
+ const res = await configureApiKeyInline(target, { existingKey });
1398
+ if (!res.ok) {
1399
+ console.log(` ❌ No usable API key — keeping current provider.\n`);
1400
+ return;
1401
+ }
1402
+ newKey = res.apiKey;
1403
+ }
1404
+
1405
+ // ── Merge-update .env ──────────────────────────────────────────────────
1406
+ content = setEnvKey(content, "PRIMARY_PROVIDER", target.offline ? "ollama" : target.key);
1407
+ if (target.envKey && newKey) {
1408
+ content = setEnvKey(content, target.envKey, newKey);
1409
+ }
1410
+ // Park the previous provider's API key (if it had one and the new
1411
+ // provider uses a *different* key). Same-key reuse (e.g. switching
1412
+ // between OpenAI and gpt-4o which both use OPENAI_API_KEY) is left alone.
1413
+ if (fromProvider && fromProvider.envKey && fromProvider.envKey !== target.envKey) {
1414
+ const parked = commentEnvKey(content, fromProvider.envKey, `was for ${fromProvider.key}, kept for rollback`);
1415
+ if (parked.changed) {
1416
+ console.log(` 📦 Parked old ${fromProvider.envKey} (commented out, not deleted).`);
1417
+ }
1418
+ content = parked.content;
1419
+ }
1420
+
1421
+ writeFileSync(loaded.path, content, { mode: 0o600 });
1422
+ console.log(`\n ✅ ${loaded.path} updated.`);
1423
+
1424
+ // ── Offer restart under launchd ─────────────────────────────────────────
1425
+ if (process.platform === "darwin") {
1426
+ const { plistPath, label } = launchdPaths();
1427
+ if (existsSync(plistPath)) {
1428
+ const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
1429
+ const doRestart = (await ask(` Restart the launchd-managed bot to apply? (Y/n): `)).trim().toLowerCase();
1430
+ if (doRestart === "" || yc.includes(doRestart)) {
1431
+ try {
1432
+ execSync(`launchctl kickstart -k gui/$UID/${label}`, { stdio: "inherit" });
1433
+ console.log(` ✅ Bot restarted.`);
1434
+ } catch (err) {
1435
+ console.log(` ⚠️ Restart failed — run manually: launchctl kickstart -k gui/$(id -u)/${label}`);
1436
+ }
1437
+ } else {
1438
+ console.log(` ℹ️ Restart later with: launchctl kickstart -k gui/$(id -u)/${label}`);
1439
+ }
1440
+ } else {
1441
+ console.log(` ℹ️ Restart the bot to apply (whatever your process manager is).`);
1442
+ }
1443
+ }
1444
+ console.log(``);
1445
+ }
1446
+
1447
+ async function providerDoctor() {
1448
+ const loaded = loadEnvOrExit();
1449
+ if (!loaded) return;
1450
+ const current = readPrimaryProvider(loaded.content);
1451
+ if (!current) {
1452
+ console.log(`\n⚠️ PRIMARY_PROVIDER not set.\n`);
1453
+ return;
1454
+ }
1455
+ const provider = resolveProviderKey(current);
1456
+ console.log(`\n🩺 Provider health: ${current}\n`);
1457
+ if (!provider) {
1458
+ console.log(` ❌ No definition for '${current}' in PROVIDERS.\n`);
1459
+ return;
1460
+ }
1461
+ const key = provider.envKey ? readEnvValue(loaded.content, provider.envKey) : null;
1462
+ const result = await validateProviderKey(provider.key, key);
1463
+ if (result.ok && result.warning) {
1464
+ console.log(` ⚠️ ${result.warning}`);
1465
+ console.log(` ${result.detail}\n`);
1466
+ } else if (result.ok) {
1467
+ console.log(` ✅ ${result.detail}\n`);
1468
+ } else {
1469
+ console.log(` ❌ ${result.error}\n`);
1470
+ console.log(` Fix: alvin-bot provider switch ${provider.key}`);
1471
+ console.log(` (or: alvin-bot provider switch <other>)\n`);
1472
+ }
1473
+ }
1474
+
730
1475
  /**
731
1476
  * Validate a Telegram bot token by calling getMe.
732
1477
  * Returns { ok: true, botName: "@username" } or { ok: false, error: "reason" }.
@@ -819,7 +1564,7 @@ async function runPostSetupValidation(providerKey, apiKey, botToken, webPort) {
819
1564
  */
820
1565
  function parseSetupArgs(argv) {
821
1566
  const args = {};
822
- const flags = new Set(["--non-interactive", "-y", "--yes", "--skip-validation"]);
1567
+ const flags = new Set(["--non-interactive", "-y", "--yes", "--skip-validation", "--no-bootstrap-tools"]);
823
1568
  const valueFlags = [
824
1569
  "--bot-token", "--allowed-users", "--primary-provider",
825
1570
  "--groq-key", "--google-key", "--openai-key", "--nvidia-key",
@@ -972,6 +1717,11 @@ async function setup() {
972
1717
  return;
973
1718
  }
974
1719
 
1720
+ // ── Auto-bootstrap media tools (yt-dlp + ffmpeg) ───────────────────────
1721
+ // Runs before any prompts so the user doesn't have to wait at the end.
1722
+ // Single-line status per tool. Failure is non-fatal — setup continues.
1723
+ await ensureBootstrapTools({ skip: !!args["no-bootstrap-tools"] });
1724
+
975
1725
  // ── Step 1: Telegram Bot
976
1726
  console.log(`\n━━━ ${t("setup.step1")} ━━━`);
977
1727
  console.log(t("setup.step1.intro"));
@@ -1774,6 +2524,8 @@ async function doctor() {
1774
2524
  async function update() {
1775
2525
  console.log(`${t("update.title")}\n`);
1776
2526
 
2527
+ const skipBootstrap = process.argv.includes("--no-bootstrap-tools");
2528
+
1777
2529
  try {
1778
2530
  const isGit = existsSync(resolve(process.cwd(), ".git"));
1779
2531
 
@@ -1790,6 +2542,10 @@ async function update() {
1790
2542
  execSync("npm update alvin-bot", { stdio: "inherit" });
1791
2543
  console.log(`\n ✅ ${t("update.done")}`);
1792
2544
  }
2545
+
2546
+ // Refresh bootstrap media tools (yt-dlp / ffmpeg) — keeps them in sync
2547
+ // with the alvin-bot version that just got installed.
2548
+ await ensureBootstrapTools({ skip: skipBootstrap });
1793
2549
  } catch (err) {
1794
2550
  console.error(`\n ❌ ${t("update.failed")} ${err.message}`);
1795
2551
  }
@@ -2179,6 +2935,34 @@ switch (cmd) {
2179
2935
  }
2180
2936
  break;
2181
2937
  }
2938
+ case "provider": {
2939
+ const sub = (process.argv[3] || "list").toLowerCase();
2940
+ const arg = process.argv[4];
2941
+ const done = (p) => p.then(() => closeRL()).catch(err => { console.error(err); closeRL(); });
2942
+ if (sub === "list" || sub === "ls") {
2943
+ done(providerList());
2944
+ } else if (sub === "show" || sub === "current") {
2945
+ done(providerShow());
2946
+ } else if (sub === "switch" || sub === "use" || sub === "set") {
2947
+ done(providerSwitch(arg));
2948
+ } else if (sub === "doctor" || sub === "check" || sub === "validate") {
2949
+ done(providerDoctor());
2950
+ } else {
2951
+ console.log("Usage: alvin-bot provider <list|show|switch <key>|doctor>");
2952
+ console.log("");
2953
+ console.log(" list — Show all providers + current selection + per-provider status");
2954
+ console.log(" show — Detailed info on the currently configured provider");
2955
+ console.log(" switch <key> — Switch to a different provider (interactive setup + .env merge)");
2956
+ console.log(" doctor — Validate the current provider's auth/key against its API");
2957
+ console.log("");
2958
+ console.log("Aliases accepted for <key>: claude, codex, gemini, nvidia, gpt, gemma");
2959
+ console.log("Examples:");
2960
+ console.log(" alvin-bot provider switch groq");
2961
+ console.log(" alvin-bot provider switch claude-sdk");
2962
+ console.log(" alvin-bot provider switch codex");
2963
+ }
2964
+ break;
2965
+ }
2182
2966
  case "update":
2183
2967
  update().catch(console.error);
2184
2968
  break;
@@ -2542,6 +3326,7 @@ ${t("cli.commands")}
2542
3326
  audit Security health check (permissions, secrets, config)
2543
3327
  search Search your assets, memories, and skills
2544
3328
  tools List / install optional capability tools (ffmpeg, pandoc, …)
3329
+ provider List / switch AI provider (claude, codex, groq, gemini, …)
2545
3330
  browser Manage bot-owned Chromium (start/stop/goto/shot/doctor)
2546
3331
  update ${t("cli.updateDesc")}
2547
3332
  start ${t("cli.startDesc")} (background via PM2)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.23.1",
3
+ "version": "4.25.0",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",