alvin-bot 4.23.1 → 4.24.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 +48 -0
- package/bin/cli.js +616 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.24.0] — 2026-05-12
|
|
6
|
+
|
|
7
|
+
### Switch AI providers cleanly after install — new `alvin-bot provider` command
|
|
8
|
+
|
|
9
|
+
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."
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
alvin-bot provider list # show all providers + per-provider install/key status
|
|
13
|
+
alvin-bot provider show # detailed info on the current provider
|
|
14
|
+
alvin-bot provider switch <key> # interactive setup + targeted .env merge
|
|
15
|
+
alvin-bot provider doctor # validate current provider's auth/key against its API
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`<key>` accepts both canonical slugs (`claude-sdk`, `groq`, `gemini-2.5-flash`, …) and short aliases (`claude`, `codex`, `gemini`, `nvidia`, `gpt`, `gemma`).
|
|
19
|
+
|
|
20
|
+
### Provider-specific guided setup, reused from the setup wizard
|
|
21
|
+
|
|
22
|
+
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:
|
|
23
|
+
|
|
24
|
+
- **`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.
|
|
25
|
+
- **`configureCodexCliInline()`** — new. Detects `codex` binary, offers `npm install -g @openai/codex` if missing, runs `codex login` (ChatGPT OAuth) if not authenticated.
|
|
26
|
+
- **`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.
|
|
27
|
+
|
|
28
|
+
### Safe `.env` merge — never destroys, never duplicates
|
|
29
|
+
|
|
30
|
+
The new `setEnvKey()` + `commentEnvKey()` helpers do **byte-preserving** in-place updates of `~/.alvin-bot/.env`:
|
|
31
|
+
|
|
32
|
+
- `PRIMARY_PROVIDER` line is **updated in place**, not appended. No duplicates.
|
|
33
|
+
- The new provider's API key (if any) is set the same way — also handles "previously commented out by an earlier switch" → uncomment + update.
|
|
34
|
+
- 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.
|
|
35
|
+
- Everything else (`BOT_TOKEN`, `ALLOWED_USERS`, `FALLBACK_PROVIDERS`, `WEB_*`, custom comments, blank lines, custom additions) is preserved byte-for-byte.
|
|
36
|
+
|
|
37
|
+
### Auto-restart under launchd
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
### Robustness: `ask()` no longer crashes on stdin EOF
|
|
42
|
+
|
|
43
|
+
`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.
|
|
44
|
+
|
|
45
|
+
### Manual end-to-end test on a fresh-ish Apple Silicon Mac (.75)
|
|
46
|
+
|
|
47
|
+
- `provider list` shows all 8 providers with per-provider status (CLI installed, API key present, OAuth-authenticated)
|
|
48
|
+
- `provider show` reports current = `claude-sdk` with model + key var + fallbacks
|
|
49
|
+
- `provider doctor` validates auth/key against the live API
|
|
50
|
+
- `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
|
|
51
|
+
- `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
|
|
52
|
+
|
|
5
53
|
## [4.23.1] — 2026-05-12
|
|
6
54
|
|
|
7
55
|
### 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
|
╔══════════════════════════════════════╗
|
|
@@ -727,6 +741,578 @@ async function validateProviderKey(providerKey, apiKey) {
|
|
|
727
741
|
}
|
|
728
742
|
}
|
|
729
743
|
|
|
744
|
+
// ─── Provider config (shared between setup wizard and `provider switch`) ─────
|
|
745
|
+
//
|
|
746
|
+
// Encapsulates the CLI-install + auth-login flow for OAuth-style providers
|
|
747
|
+
// (claude-sdk, codex-cli) and the API-key prompt+validate flow for everything
|
|
748
|
+
// else. Returns a `{ ok, apiKey }` so callers can decide what to do with the
|
|
749
|
+
// .env (the setup wizard writes everything at once; `provider switch` does a
|
|
750
|
+
// targeted merge).
|
|
751
|
+
|
|
752
|
+
const PROVIDER_KEY_MAP = {
|
|
753
|
+
groq: "GROQ_API_KEY",
|
|
754
|
+
"nvidia-llama-3.3-70b": "NVIDIA_API_KEY",
|
|
755
|
+
"nvidia-kimi-k2.5": "NVIDIA_API_KEY",
|
|
756
|
+
"gemini-2.5-flash": "GOOGLE_API_KEY",
|
|
757
|
+
openai: "OPENAI_API_KEY",
|
|
758
|
+
"gpt-4o": "OPENAI_API_KEY",
|
|
759
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Find the codex CLI binary if installed. Returns the absolute path or null.
|
|
764
|
+
*/
|
|
765
|
+
function findCodexBinary() {
|
|
766
|
+
try {
|
|
767
|
+
const out = execSync("command -v codex", { stdio: "pipe", encoding: "utf-8" });
|
|
768
|
+
return out.trim() || null;
|
|
769
|
+
} catch {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Find the claude CLI binary, checking PATH and common install paths.
|
|
776
|
+
* Returns the absolute path or null. Mirrors the search in
|
|
777
|
+
* validateProviderKey("claude-sdk", ...).
|
|
778
|
+
*/
|
|
779
|
+
function findClaudeBinary() {
|
|
780
|
+
try {
|
|
781
|
+
const out = execSync("command -v claude", { stdio: "pipe", encoding: "utf-8" });
|
|
782
|
+
if (out.trim()) return out.trim();
|
|
783
|
+
} catch {}
|
|
784
|
+
for (const p of [join(homedir(), ".local", "bin", "claude"), "/usr/local/bin/claude"]) {
|
|
785
|
+
if (existsSync(p)) return p;
|
|
786
|
+
}
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Interactive Claude SDK setup: ensures the native claude CLI is installed,
|
|
792
|
+
* runs `claude auth login` if not authenticated. Returns { ok: bool }.
|
|
793
|
+
*
|
|
794
|
+
* Designed to be called both from the setup wizard and from
|
|
795
|
+
* `alvin-bot provider switch claude-sdk`.
|
|
796
|
+
*/
|
|
797
|
+
async function configureClaudeSdkInline() {
|
|
798
|
+
let cliPath = findClaudeBinary();
|
|
799
|
+
if (cliPath) {
|
|
800
|
+
console.log(` ✅ Claude CLI found at ${cliPath}`);
|
|
801
|
+
} else {
|
|
802
|
+
console.log(` ⚠️ Claude CLI not found (native binary required).`);
|
|
803
|
+
console.log(`\n The Claude Agent SDK needs the native Claude Code binary.`);
|
|
804
|
+
console.log(` Install with:\n curl -fsSL https://claude.ai/install.sh | sh`);
|
|
805
|
+
console.log(` (npm install @anthropic-ai/claude-code is the alternative path.)\n`);
|
|
806
|
+
|
|
807
|
+
const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
|
|
808
|
+
const doInstall = (await ask(` Install now via the official installer? (y/n): `)).trim().toLowerCase();
|
|
809
|
+
if (yc.includes(doInstall)) {
|
|
810
|
+
console.log(`\n Installing Claude CLI...`);
|
|
811
|
+
try {
|
|
812
|
+
execSync("curl -fsSL https://claude.ai/install.sh | sh", { stdio: "inherit", timeout: 120_000 });
|
|
813
|
+
const localBin = join(homedir(), ".local", "bin");
|
|
814
|
+
if (!process.env.PATH.includes(localBin)) {
|
|
815
|
+
process.env.PATH = `${localBin}:${process.env.PATH || ""}`;
|
|
816
|
+
}
|
|
817
|
+
cliPath = findClaudeBinary();
|
|
818
|
+
if (cliPath) console.log(` ✅ Claude CLI installed at ${cliPath}\n`);
|
|
819
|
+
} catch {
|
|
820
|
+
console.log(` ❌ Installation failed. Run manually: curl -fsSL https://claude.ai/install.sh | sh`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (!cliPath) return { ok: false };
|
|
826
|
+
|
|
827
|
+
console.log(` Checking Claude SDK authentication...`);
|
|
828
|
+
const authResult = await validateProviderKey("claude-sdk", null);
|
|
829
|
+
if (authResult.ok && !authResult.warning) {
|
|
830
|
+
console.log(` ✅ ${authResult.detail}\n`);
|
|
831
|
+
return { ok: true };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ok-with-warning OR not-ok — offer login.
|
|
835
|
+
if (authResult.warning) {
|
|
836
|
+
console.log(` ⚠️ ${authResult.warning}\n`);
|
|
837
|
+
} else {
|
|
838
|
+
console.log(` ⚠️ ${authResult.error}\n`);
|
|
839
|
+
}
|
|
840
|
+
const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
|
|
841
|
+
const doLogin = (await ask(` Run 'claude auth login' now? (Y/n): `)).trim().toLowerCase();
|
|
842
|
+
if (doLogin === "" || yc.includes(doLogin)) {
|
|
843
|
+
try {
|
|
844
|
+
execSync(`"${cliPath}" auth login --claudeai`, { stdio: "inherit", timeout: 180_000 });
|
|
845
|
+
} catch {
|
|
846
|
+
console.log(`\n ⚠️ Auto-login failed/cancelled. Run in another terminal: claude auth login`);
|
|
847
|
+
await ask(` Press Enter when you've logged in (or to continue without): `);
|
|
848
|
+
}
|
|
849
|
+
const recheck = await validateProviderKey("claude-sdk", null);
|
|
850
|
+
if (recheck.ok && !recheck.warning) {
|
|
851
|
+
console.log(`\n ✅ Claude SDK authenticated.\n`);
|
|
852
|
+
return { ok: true };
|
|
853
|
+
}
|
|
854
|
+
if (recheck.ok && recheck.warning) {
|
|
855
|
+
console.log(`\n ⚠️ ${recheck.warning}`);
|
|
856
|
+
console.log(` The bot will still try to use the Agent SDK at runtime.\n`);
|
|
857
|
+
return { ok: true };
|
|
858
|
+
}
|
|
859
|
+
console.log(`\n ❌ Still not authenticated: ${recheck.error}`);
|
|
860
|
+
return { ok: false };
|
|
861
|
+
}
|
|
862
|
+
console.log(` Skipping login. Run 'claude auth login' later to enable AI responses.`);
|
|
863
|
+
return { ok: true }; // CLI present, user explicitly skipped login — let it through
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Interactive OpenAI Codex CLI setup: ensures `codex` is installed, runs
|
|
868
|
+
* `codex login` if not authenticated (ChatGPT-Plus/Pro/Max OAuth).
|
|
869
|
+
*/
|
|
870
|
+
async function configureCodexCliInline() {
|
|
871
|
+
let cliPath = findCodexBinary();
|
|
872
|
+
if (cliPath) {
|
|
873
|
+
console.log(` ✅ codex CLI found at ${cliPath}`);
|
|
874
|
+
} else {
|
|
875
|
+
console.log(` ⚠️ codex CLI not installed.`);
|
|
876
|
+
console.log(`\n Install with one of:`);
|
|
877
|
+
console.log(` npm install -g @openai/codex (recommended)`);
|
|
878
|
+
console.log(` brew install codex\n`);
|
|
879
|
+
const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
|
|
880
|
+
const doInstall = (await ask(` Install via npm now? (y/n): `)).trim().toLowerCase();
|
|
881
|
+
if (yc.includes(doInstall)) {
|
|
882
|
+
console.log(`\n Installing @openai/codex...`);
|
|
883
|
+
try {
|
|
884
|
+
execSync("npm install -g @openai/codex", { stdio: "inherit", timeout: 120_000 });
|
|
885
|
+
cliPath = findCodexBinary();
|
|
886
|
+
if (cliPath) console.log(` ✅ codex CLI installed at ${cliPath}\n`);
|
|
887
|
+
} catch {
|
|
888
|
+
console.log(` ❌ Installation failed. Run manually: npm install -g @openai/codex`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (!cliPath) return { ok: false };
|
|
894
|
+
|
|
895
|
+
// Check login status — `codex login status` exits 0 when logged in.
|
|
896
|
+
let loggedIn = false;
|
|
897
|
+
try {
|
|
898
|
+
const out = execSync(`"${cliPath}" login status 2>&1`, { stdio: "pipe", encoding: "utf-8", timeout: 5000 });
|
|
899
|
+
if (/Logged in/i.test(out)) loggedIn = true;
|
|
900
|
+
} catch {}
|
|
901
|
+
|
|
902
|
+
if (loggedIn) {
|
|
903
|
+
console.log(` ✅ codex CLI is logged in.\n`);
|
|
904
|
+
return { ok: true };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
console.log(` ⚠️ codex CLI not logged in.`);
|
|
908
|
+
console.log(` 'codex login' opens a browser — sign in with your ChatGPT account.\n`);
|
|
909
|
+
const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
|
|
910
|
+
const doLogin = (await ask(` Run 'codex login' now? (Y/n): `)).trim().toLowerCase();
|
|
911
|
+
if (doLogin === "" || yc.includes(doLogin)) {
|
|
912
|
+
try {
|
|
913
|
+
execSync(`"${cliPath}" login`, { stdio: "inherit", timeout: 240_000 });
|
|
914
|
+
} catch {
|
|
915
|
+
console.log(`\n ⚠️ codex login failed/cancelled.`);
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
const out = execSync(`"${cliPath}" login status 2>&1`, { stdio: "pipe", encoding: "utf-8" });
|
|
919
|
+
if (/Logged in/i.test(out)) {
|
|
920
|
+
console.log(`\n ✅ codex CLI is logged in.\n`);
|
|
921
|
+
return { ok: true };
|
|
922
|
+
}
|
|
923
|
+
} catch {}
|
|
924
|
+
console.log(`\n ⚠️ Still not logged in — run 'codex login' later to enable.`);
|
|
925
|
+
return { ok: false };
|
|
926
|
+
}
|
|
927
|
+
console.log(` Skipping login. Run 'codex login' later to enable AI responses.`);
|
|
928
|
+
return { ok: true };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Interactive API-key flow for providers that authenticate with a key
|
|
933
|
+
* (groq, nvidia, gemini, openai, openrouter, …). Validates the key against
|
|
934
|
+
* the provider's API where possible. Returns { ok, apiKey }.
|
|
935
|
+
*/
|
|
936
|
+
async function configureApiKeyInline(provider, opts = {}) {
|
|
937
|
+
const envKey = provider.envKey;
|
|
938
|
+
if (!envKey) return { ok: true, apiKey: "" };
|
|
939
|
+
|
|
940
|
+
const existing = opts.existingKey || "";
|
|
941
|
+
if (existing) {
|
|
942
|
+
console.log(`\n Existing ${envKey} found in .env — validating first...`);
|
|
943
|
+
const test = await validateProviderKey(provider.key, existing);
|
|
944
|
+
if (test.ok) {
|
|
945
|
+
console.log(` ✅ ${test.detail}\n`);
|
|
946
|
+
return { ok: true, apiKey: existing };
|
|
947
|
+
}
|
|
948
|
+
console.log(` ⚠️ Existing key invalid: ${test.error}\n`);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
console.log(` Get an API key for ${provider.name}: ${provider.signup || "(see provider docs)"}\n`);
|
|
952
|
+
let apiKey = (await ask(` ${envKey}: `)).trim();
|
|
953
|
+
if (!apiKey) {
|
|
954
|
+
console.log(` ⚠️ No API key provided — provider won't work until configured.`);
|
|
955
|
+
return { ok: false, apiKey: "" };
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
console.log(`\n Validating...`);
|
|
959
|
+
let result = await validateProviderKey(provider.key, apiKey);
|
|
960
|
+
if (result.ok) {
|
|
961
|
+
console.log(` ✅ ${result.detail}\n`);
|
|
962
|
+
return { ok: true, apiKey };
|
|
963
|
+
}
|
|
964
|
+
console.log(` ❌ ${result.error}\n`);
|
|
965
|
+
|
|
966
|
+
const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
|
|
967
|
+
const retry = (await ask(` Try a different key? (y/n): `)).trim().toLowerCase();
|
|
968
|
+
if (yc.includes(retry)) {
|
|
969
|
+
apiKey = (await ask(` ${envKey}: `)).trim();
|
|
970
|
+
if (apiKey) {
|
|
971
|
+
result = await validateProviderKey(provider.key, apiKey);
|
|
972
|
+
if (result.ok) {
|
|
973
|
+
console.log(` ✅ ${result.detail}\n`);
|
|
974
|
+
return { ok: true, apiKey };
|
|
975
|
+
}
|
|
976
|
+
console.log(` ❌ ${result.error}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
console.log(` ⚠️ Saving the key anyway — fix later in ~/.alvin-bot/.env if it's wrong.`);
|
|
980
|
+
return { ok: !!apiKey, apiKey };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ─── .env merge helpers ──────────────────────────────────────────────────────
|
|
984
|
+
//
|
|
985
|
+
// `provider switch` mutates ~/.alvin-bot/.env by touching exactly two lines:
|
|
986
|
+
// PRIMARY_PROVIDER and the new provider's API-key entry. Everything else
|
|
987
|
+
// (BOT_TOKEN, ALLOWED_USERS, FALLBACK_PROVIDERS, WEB_*, comments, blank
|
|
988
|
+
// lines, custom additions) is preserved byte-for-byte. The OLD provider's
|
|
989
|
+
// API key is commented out — never deleted — so the user can roll back
|
|
990
|
+
// without re-pasting secrets they may not have anywhere else.
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Set (or append) `KEY=value` in .env content. If a matching commented line
|
|
994
|
+
* (`# KEY=...`) exists, it's replaced with the active form. Otherwise the
|
|
995
|
+
* new line is appended at the end with a leading blank line if needed.
|
|
996
|
+
*/
|
|
997
|
+
function setEnvKey(content, key, value) {
|
|
998
|
+
const lines = content.split("\n");
|
|
999
|
+
const activeRe = new RegExp(`^${key}\\s*=`);
|
|
1000
|
+
const commentedRe = new RegExp(`^#\\s*${key}\\s*=`);
|
|
1001
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1002
|
+
if (activeRe.test(lines[i]) || commentedRe.test(lines[i])) {
|
|
1003
|
+
lines[i] = `${key}=${value}`;
|
|
1004
|
+
return lines.join("\n");
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// Append. Ensure there's a separating blank line.
|
|
1008
|
+
if (lines.length && lines[lines.length - 1].trim() !== "") lines.push("");
|
|
1009
|
+
lines.push(`${key}=${value}`);
|
|
1010
|
+
return lines.join("\n");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Comment out an active `KEY=...` line with a note. Leaves already-commented
|
|
1015
|
+
* lines alone, leaves the line absent if it wasn't there. Returns the new
|
|
1016
|
+
* content + a `changed` flag for caller logging.
|
|
1017
|
+
*/
|
|
1018
|
+
function commentEnvKey(content, key, note) {
|
|
1019
|
+
const lines = content.split("\n");
|
|
1020
|
+
const activeRe = new RegExp(`^${key}\\s*=`);
|
|
1021
|
+
let changed = false;
|
|
1022
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1023
|
+
if (activeRe.test(lines[i])) {
|
|
1024
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1025
|
+
const stamp = note ? ` # parked ${today}: ${note}` : ` # parked ${today}`;
|
|
1026
|
+
lines[i] = `# ${lines[i]}${stamp}`;
|
|
1027
|
+
changed = true;
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return { content: lines.join("\n"), changed };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Read PRIMARY_PROVIDER from .env content (handles "...=value" with optional
|
|
1036
|
+
* quotes/whitespace). Returns the key or null if not set.
|
|
1037
|
+
*/
|
|
1038
|
+
function readPrimaryProvider(content) {
|
|
1039
|
+
const m = content.match(/^PRIMARY_PROVIDER\s*=\s*"?([^"#\s]+)"?\s*$/m);
|
|
1040
|
+
return m ? m[1] : null;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Read an arbitrary key from .env content. Returns the value or null.
|
|
1045
|
+
*/
|
|
1046
|
+
function readEnvValue(content, key) {
|
|
1047
|
+
const re = new RegExp(`^${key}\\s*=\\s*"?([^"#\\n]+?)"?\\s*(?:#.*)?$`, "m");
|
|
1048
|
+
const m = content.match(re);
|
|
1049
|
+
return m ? m[1].trim() : null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ─── `alvin-bot provider` subcommands ────────────────────────────────────────
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Resolve a provider key (or aliases) to a PROVIDERS entry. Returns null if
|
|
1056
|
+
* unknown. Lets users use "claude" → "claude-sdk", "codex" → "codex-cli",
|
|
1057
|
+
* "nvidia" → "nvidia-kimi-k2.5", "gemini" → "gemini-2.5-flash" — so they
|
|
1058
|
+
* don't have to remember the exact slug.
|
|
1059
|
+
*/
|
|
1060
|
+
function resolveProviderKey(input) {
|
|
1061
|
+
const lower = input.trim().toLowerCase();
|
|
1062
|
+
const aliases = {
|
|
1063
|
+
"claude": "claude-sdk",
|
|
1064
|
+
"claudesdk": "claude-sdk",
|
|
1065
|
+
"codex": "codex-cli",
|
|
1066
|
+
"nvidia": "nvidia-kimi-k2.5",
|
|
1067
|
+
"nim": "nvidia-kimi-k2.5",
|
|
1068
|
+
"kimi": "nvidia-kimi-k2.5",
|
|
1069
|
+
"gemini": "gemini-2.5-flash",
|
|
1070
|
+
"google": "gemini-2.5-flash",
|
|
1071
|
+
"gpt": "openai",
|
|
1072
|
+
"gpt4": "openai",
|
|
1073
|
+
"gpt4o": "openai",
|
|
1074
|
+
"offline": "offline-gemma4",
|
|
1075
|
+
"gemma": "offline-gemma4",
|
|
1076
|
+
"gemma4": "offline-gemma4",
|
|
1077
|
+
};
|
|
1078
|
+
const resolved = aliases[lower] || lower;
|
|
1079
|
+
// Codex-cli isn't in PROVIDERS but is supported by switch/list.
|
|
1080
|
+
if (resolved === "codex-cli") {
|
|
1081
|
+
return {
|
|
1082
|
+
key: "codex-cli",
|
|
1083
|
+
name: "OpenAI Codex CLI (ChatGPT OAuth)",
|
|
1084
|
+
desc: () => "Use your ChatGPT Plus/Pro/Max subscription via the codex CLI. OAuth login, no API key needed.",
|
|
1085
|
+
free: false,
|
|
1086
|
+
envKey: null,
|
|
1087
|
+
signup: "https://chat.openai.com",
|
|
1088
|
+
model: "gpt-5",
|
|
1089
|
+
needsCLI: false,
|
|
1090
|
+
needsCodexCLI: true,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
return PROVIDERS.find(p => p.key === resolved) || null;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Compute installed / authenticated / has-key status for a provider, given
|
|
1098
|
+
* the current .env content. Returns a short status string with an icon.
|
|
1099
|
+
*/
|
|
1100
|
+
function providerStatus(provider, envContent) {
|
|
1101
|
+
if (provider.offline) {
|
|
1102
|
+
return hasOllama() ? "✓ Ollama installed" : "· Ollama not installed";
|
|
1103
|
+
}
|
|
1104
|
+
if (provider.needsCLI) {
|
|
1105
|
+
return findClaudeBinary() ? "✓ Claude CLI installed" : "· Claude CLI not installed";
|
|
1106
|
+
}
|
|
1107
|
+
if (provider.needsCodexCLI) {
|
|
1108
|
+
return findCodexBinary() ? "✓ codex CLI installed" : "· codex CLI not installed";
|
|
1109
|
+
}
|
|
1110
|
+
if (provider.envKey) {
|
|
1111
|
+
const v = readEnvValue(envContent, provider.envKey);
|
|
1112
|
+
return v ? `✓ ${provider.envKey} set` : `· ${provider.envKey} not set`;
|
|
1113
|
+
}
|
|
1114
|
+
return "—";
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function envPath() {
|
|
1118
|
+
return join(DATA_DIR, ".env");
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function loadEnvOrExit(silent = false) {
|
|
1122
|
+
const p = envPath();
|
|
1123
|
+
if (!existsSync(p)) {
|
|
1124
|
+
if (!silent) {
|
|
1125
|
+
console.log(`\n❌ No .env at ${p}`);
|
|
1126
|
+
console.log(` Run 'alvin-bot setup' first to configure the bot.\n`);
|
|
1127
|
+
}
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
return { path: p, content: readFileSync(p, "utf-8") };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
async function providerList() {
|
|
1134
|
+
const loaded = loadEnvOrExit(true);
|
|
1135
|
+
const env = loaded?.content || "";
|
|
1136
|
+
const current = readPrimaryProvider(env);
|
|
1137
|
+
|
|
1138
|
+
console.log(`\n📋 AI providers — current: ${current ? `🟢 ${current}` : "(not configured)"}\n`);
|
|
1139
|
+
|
|
1140
|
+
// Build a uniform list: wizard PROVIDERS + codex-cli alias.
|
|
1141
|
+
const ALL = [...PROVIDERS, resolveProviderKey("codex-cli")];
|
|
1142
|
+
for (const p of ALL) {
|
|
1143
|
+
const marker = p.key === current ? "→" : " ";
|
|
1144
|
+
const badge = p.offline ? "🔒" : p.free ? "🆓" : "💰";
|
|
1145
|
+
const status = providerStatus(p, env);
|
|
1146
|
+
console.log(` ${marker} ${badge} ${p.key.padEnd(22)} ${status}`);
|
|
1147
|
+
console.log(` ${p.name}`);
|
|
1148
|
+
}
|
|
1149
|
+
console.log(`\n Switch with: alvin-bot provider switch <key>`);
|
|
1150
|
+
console.log(` Aliases: claude, codex, gemini, nvidia, gpt, gemma\n`);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function providerShow() {
|
|
1154
|
+
const loaded = loadEnvOrExit();
|
|
1155
|
+
if (!loaded) return;
|
|
1156
|
+
const current = readPrimaryProvider(loaded.content);
|
|
1157
|
+
if (!current) {
|
|
1158
|
+
console.log(`\n⚠️ PRIMARY_PROVIDER not set in .env.\n`);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const provider = resolveProviderKey(current);
|
|
1162
|
+
if (!provider) {
|
|
1163
|
+
console.log(`\n⚠️ PRIMARY_PROVIDER='${current}' but no matching provider definition found.\n`);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
console.log(`\n📍 Current AI provider\n`);
|
|
1167
|
+
console.log(` Key: ${provider.key}`);
|
|
1168
|
+
console.log(` Name: ${provider.name}`);
|
|
1169
|
+
console.log(` Model: ${provider.model || "(default)"}`);
|
|
1170
|
+
console.log(` Status: ${providerStatus(provider, loaded.content)}`);
|
|
1171
|
+
if (provider.envKey) {
|
|
1172
|
+
const v = readEnvValue(loaded.content, provider.envKey);
|
|
1173
|
+
console.log(` Key var: ${provider.envKey}${v ? " (set)" : " (missing!)"}`);
|
|
1174
|
+
}
|
|
1175
|
+
console.log(``);
|
|
1176
|
+
const fb = readEnvValue(loaded.content, "FALLBACK_PROVIDERS");
|
|
1177
|
+
if (fb) console.log(` Fallbacks: ${fb}\n`);
|
|
1178
|
+
console.log(` Doctor: alvin-bot provider doctor`);
|
|
1179
|
+
console.log(` Switch: alvin-bot provider switch <key>\n`);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Switch the bot to a different AI provider. Runs the same interactive
|
|
1184
|
+
* configuration as the setup wizard (CLI install + auth login or API-key
|
|
1185
|
+
* prompt) but only touches the provider-related lines in .env. The previous
|
|
1186
|
+
* provider's API key is commented out (never deleted) so rollback is trivial.
|
|
1187
|
+
*/
|
|
1188
|
+
async function providerSwitch(targetKey) {
|
|
1189
|
+
if (!targetKey) {
|
|
1190
|
+
console.log(`Usage: alvin-bot provider switch <key>`);
|
|
1191
|
+
console.log(`Available keys: see 'alvin-bot provider list'`);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const target = resolveProviderKey(targetKey);
|
|
1195
|
+
if (!target) {
|
|
1196
|
+
console.log(`\n❌ Unknown provider: '${targetKey}'`);
|
|
1197
|
+
console.log(` See 'alvin-bot provider list' for valid keys.\n`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const loaded = loadEnvOrExit();
|
|
1201
|
+
if (!loaded) return;
|
|
1202
|
+
|
|
1203
|
+
let content = loaded.content;
|
|
1204
|
+
const fromKey = readPrimaryProvider(content);
|
|
1205
|
+
const fromProvider = fromKey ? resolveProviderKey(fromKey) : null;
|
|
1206
|
+
|
|
1207
|
+
console.log(`\n🔁 Switching AI provider`);
|
|
1208
|
+
console.log(` from: ${fromKey || "(not set)"}${fromProvider ? ` (${fromProvider.name})` : ""}`);
|
|
1209
|
+
console.log(` to: ${target.key} (${target.name})\n`);
|
|
1210
|
+
|
|
1211
|
+
if (fromKey === target.key) {
|
|
1212
|
+
console.log(` ℹ️ Already on ${target.key}. Nothing to do.\n`);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ── Run provider-specific configuration ────────────────────────────────
|
|
1217
|
+
let newKey = "";
|
|
1218
|
+
if (target.offline) {
|
|
1219
|
+
const ok = await setupOfflineGemma4();
|
|
1220
|
+
if (!ok) {
|
|
1221
|
+
console.log(` ❌ Offline setup didn't complete — keeping current provider.\n`);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
} else if (target.needsCLI) {
|
|
1225
|
+
const res = await configureClaudeSdkInline();
|
|
1226
|
+
if (!res.ok) {
|
|
1227
|
+
console.log(` ❌ Claude SDK setup didn't complete — keeping current provider.\n`);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
} else if (target.needsCodexCLI) {
|
|
1231
|
+
const res = await configureCodexCliInline();
|
|
1232
|
+
if (!res.ok) {
|
|
1233
|
+
console.log(` ❌ Codex CLI setup didn't complete — keeping current provider.\n`);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
} else if (target.envKey) {
|
|
1237
|
+
const existingKey = readEnvValue(content, target.envKey) || "";
|
|
1238
|
+
const res = await configureApiKeyInline(target, { existingKey });
|
|
1239
|
+
if (!res.ok) {
|
|
1240
|
+
console.log(` ❌ No usable API key — keeping current provider.\n`);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
newKey = res.apiKey;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// ── Merge-update .env ──────────────────────────────────────────────────
|
|
1247
|
+
content = setEnvKey(content, "PRIMARY_PROVIDER", target.offline ? "ollama" : target.key);
|
|
1248
|
+
if (target.envKey && newKey) {
|
|
1249
|
+
content = setEnvKey(content, target.envKey, newKey);
|
|
1250
|
+
}
|
|
1251
|
+
// Park the previous provider's API key (if it had one and the new
|
|
1252
|
+
// provider uses a *different* key). Same-key reuse (e.g. switching
|
|
1253
|
+
// between OpenAI and gpt-4o which both use OPENAI_API_KEY) is left alone.
|
|
1254
|
+
if (fromProvider && fromProvider.envKey && fromProvider.envKey !== target.envKey) {
|
|
1255
|
+
const parked = commentEnvKey(content, fromProvider.envKey, `was for ${fromProvider.key}, kept for rollback`);
|
|
1256
|
+
if (parked.changed) {
|
|
1257
|
+
console.log(` 📦 Parked old ${fromProvider.envKey} (commented out, not deleted).`);
|
|
1258
|
+
}
|
|
1259
|
+
content = parked.content;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
writeFileSync(loaded.path, content, { mode: 0o600 });
|
|
1263
|
+
console.log(`\n ✅ ${loaded.path} updated.`);
|
|
1264
|
+
|
|
1265
|
+
// ── Offer restart under launchd ─────────────────────────────────────────
|
|
1266
|
+
if (process.platform === "darwin") {
|
|
1267
|
+
const { plistPath, label } = launchdPaths();
|
|
1268
|
+
if (existsSync(plistPath)) {
|
|
1269
|
+
const yc = getLocale() === "de" ? ["j", "ja", "y", "yes"] : ["y", "yes"];
|
|
1270
|
+
const doRestart = (await ask(` Restart the launchd-managed bot to apply? (Y/n): `)).trim().toLowerCase();
|
|
1271
|
+
if (doRestart === "" || yc.includes(doRestart)) {
|
|
1272
|
+
try {
|
|
1273
|
+
execSync(`launchctl kickstart -k gui/$UID/${label}`, { stdio: "inherit" });
|
|
1274
|
+
console.log(` ✅ Bot restarted.`);
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
console.log(` ⚠️ Restart failed — run manually: launchctl kickstart -k gui/$(id -u)/${label}`);
|
|
1277
|
+
}
|
|
1278
|
+
} else {
|
|
1279
|
+
console.log(` ℹ️ Restart later with: launchctl kickstart -k gui/$(id -u)/${label}`);
|
|
1280
|
+
}
|
|
1281
|
+
} else {
|
|
1282
|
+
console.log(` ℹ️ Restart the bot to apply (whatever your process manager is).`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
console.log(``);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function providerDoctor() {
|
|
1289
|
+
const loaded = loadEnvOrExit();
|
|
1290
|
+
if (!loaded) return;
|
|
1291
|
+
const current = readPrimaryProvider(loaded.content);
|
|
1292
|
+
if (!current) {
|
|
1293
|
+
console.log(`\n⚠️ PRIMARY_PROVIDER not set.\n`);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const provider = resolveProviderKey(current);
|
|
1297
|
+
console.log(`\n🩺 Provider health: ${current}\n`);
|
|
1298
|
+
if (!provider) {
|
|
1299
|
+
console.log(` ❌ No definition for '${current}' in PROVIDERS.\n`);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const key = provider.envKey ? readEnvValue(loaded.content, provider.envKey) : null;
|
|
1303
|
+
const result = await validateProviderKey(provider.key, key);
|
|
1304
|
+
if (result.ok && result.warning) {
|
|
1305
|
+
console.log(` ⚠️ ${result.warning}`);
|
|
1306
|
+
console.log(` ${result.detail}\n`);
|
|
1307
|
+
} else if (result.ok) {
|
|
1308
|
+
console.log(` ✅ ${result.detail}\n`);
|
|
1309
|
+
} else {
|
|
1310
|
+
console.log(` ❌ ${result.error}\n`);
|
|
1311
|
+
console.log(` Fix: alvin-bot provider switch ${provider.key}`);
|
|
1312
|
+
console.log(` (or: alvin-bot provider switch <other>)\n`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
730
1316
|
/**
|
|
731
1317
|
* Validate a Telegram bot token by calling getMe.
|
|
732
1318
|
* Returns { ok: true, botName: "@username" } or { ok: false, error: "reason" }.
|
|
@@ -2179,6 +2765,34 @@ switch (cmd) {
|
|
|
2179
2765
|
}
|
|
2180
2766
|
break;
|
|
2181
2767
|
}
|
|
2768
|
+
case "provider": {
|
|
2769
|
+
const sub = (process.argv[3] || "list").toLowerCase();
|
|
2770
|
+
const arg = process.argv[4];
|
|
2771
|
+
const done = (p) => p.then(() => closeRL()).catch(err => { console.error(err); closeRL(); });
|
|
2772
|
+
if (sub === "list" || sub === "ls") {
|
|
2773
|
+
done(providerList());
|
|
2774
|
+
} else if (sub === "show" || sub === "current") {
|
|
2775
|
+
done(providerShow());
|
|
2776
|
+
} else if (sub === "switch" || sub === "use" || sub === "set") {
|
|
2777
|
+
done(providerSwitch(arg));
|
|
2778
|
+
} else if (sub === "doctor" || sub === "check" || sub === "validate") {
|
|
2779
|
+
done(providerDoctor());
|
|
2780
|
+
} else {
|
|
2781
|
+
console.log("Usage: alvin-bot provider <list|show|switch <key>|doctor>");
|
|
2782
|
+
console.log("");
|
|
2783
|
+
console.log(" list — Show all providers + current selection + per-provider status");
|
|
2784
|
+
console.log(" show — Detailed info on the currently configured provider");
|
|
2785
|
+
console.log(" switch <key> — Switch to a different provider (interactive setup + .env merge)");
|
|
2786
|
+
console.log(" doctor — Validate the current provider's auth/key against its API");
|
|
2787
|
+
console.log("");
|
|
2788
|
+
console.log("Aliases accepted for <key>: claude, codex, gemini, nvidia, gpt, gemma");
|
|
2789
|
+
console.log("Examples:");
|
|
2790
|
+
console.log(" alvin-bot provider switch groq");
|
|
2791
|
+
console.log(" alvin-bot provider switch claude-sdk");
|
|
2792
|
+
console.log(" alvin-bot provider switch codex");
|
|
2793
|
+
}
|
|
2794
|
+
break;
|
|
2795
|
+
}
|
|
2182
2796
|
case "update":
|
|
2183
2797
|
update().catch(console.error);
|
|
2184
2798
|
break;
|
|
@@ -2542,6 +3156,7 @@ ${t("cli.commands")}
|
|
|
2542
3156
|
audit Security health check (permissions, secrets, config)
|
|
2543
3157
|
search Search your assets, memories, and skills
|
|
2544
3158
|
tools List / install optional capability tools (ffmpeg, pandoc, …)
|
|
3159
|
+
provider List / switch AI provider (claude, codex, groq, gemini, …)
|
|
2545
3160
|
browser Manage bot-owned Chromium (start/stop/goto/shot/doctor)
|
|
2546
3161
|
update ${t("cli.updateDesc")}
|
|
2547
3162
|
start ${t("cli.startDesc")} (background via PM2)
|