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.
- package/CHANGELOG.md +80 -0
- package/bin/cli.js +787 -2
- 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
|
-
|
|
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)
|