alvin-bot 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.env.example +43 -0
  2. package/BACKLOG.md +223 -0
  3. package/CHANGELOG.md +63 -0
  4. package/CLAUDE.example.md +152 -0
  5. package/CODE_OF_CONDUCT.md +52 -0
  6. package/CONTRIBUTING.md +72 -0
  7. package/LICENSE +21 -0
  8. package/README.md +529 -0
  9. package/SECURITY.md +38 -0
  10. package/SOUL.example.md +60 -0
  11. package/TOOLS.example.md +42 -0
  12. package/alvin-bot.config.example.json +24 -0
  13. package/bin/cli.js +1088 -0
  14. package/dist/.metadata_never_index +0 -0
  15. package/dist/claude.js +102 -0
  16. package/dist/config.js +65 -0
  17. package/dist/engine.js +90 -0
  18. package/dist/find-claude-binary.js +98 -0
  19. package/dist/handlers/commands.js +1489 -0
  20. package/dist/handlers/document.js +187 -0
  21. package/dist/handlers/message.js +200 -0
  22. package/dist/handlers/photo.js +154 -0
  23. package/dist/handlers/platform-message.js +275 -0
  24. package/dist/handlers/video.js +237 -0
  25. package/dist/handlers/voice.js +148 -0
  26. package/dist/i18n.js +299 -0
  27. package/dist/index.js +442 -0
  28. package/dist/init-data-dir.js +81 -0
  29. package/dist/middleware/auth.js +215 -0
  30. package/dist/migrate.js +139 -0
  31. package/dist/paths.js +87 -0
  32. package/dist/platforms/discord.js +161 -0
  33. package/dist/platforms/index.js +130 -0
  34. package/dist/platforms/signal.js +205 -0
  35. package/dist/platforms/slack.js +318 -0
  36. package/dist/platforms/telegram.js +111 -0
  37. package/dist/platforms/types.js +8 -0
  38. package/dist/platforms/whatsapp.js +648 -0
  39. package/dist/providers/claude-sdk-provider.js +173 -0
  40. package/dist/providers/codex-cli-provider.js +121 -0
  41. package/dist/providers/index.js +7 -0
  42. package/dist/providers/openai-compatible.js +388 -0
  43. package/dist/providers/registry.js +209 -0
  44. package/dist/providers/tool-executor.js +450 -0
  45. package/dist/providers/types.js +205 -0
  46. package/dist/services/access.js +144 -0
  47. package/dist/services/asset-index.js +230 -0
  48. package/dist/services/browser-manager.js +161 -0
  49. package/dist/services/browser.js +121 -0
  50. package/dist/services/compaction.js +129 -0
  51. package/dist/services/cron.js +462 -0
  52. package/dist/services/custom-tools.js +317 -0
  53. package/dist/services/delivery-queue.js +154 -0
  54. package/dist/services/elevenlabs.js +58 -0
  55. package/dist/services/embeddings.js +386 -0
  56. package/dist/services/exec-guard.js +46 -0
  57. package/dist/services/fallback-order.js +151 -0
  58. package/dist/services/heartbeat.js +192 -0
  59. package/dist/services/hooks.js +44 -0
  60. package/dist/services/imagegen.js +72 -0
  61. package/dist/services/language-detect.js +144 -0
  62. package/dist/services/markdown.js +63 -0
  63. package/dist/services/mcp.js +252 -0
  64. package/dist/services/memory.js +133 -0
  65. package/dist/services/personality.js +227 -0
  66. package/dist/services/plugins.js +171 -0
  67. package/dist/services/reminders.js +97 -0
  68. package/dist/services/restart.js +48 -0
  69. package/dist/services/security-audit.js +66 -0
  70. package/dist/services/self-search.js +129 -0
  71. package/dist/services/session.js +93 -0
  72. package/dist/services/skills.js +287 -0
  73. package/dist/services/standing-orders.js +29 -0
  74. package/dist/services/subagents.js +142 -0
  75. package/dist/services/sudo.js +243 -0
  76. package/dist/services/telegram.js +113 -0
  77. package/dist/services/tool-discovery.js +214 -0
  78. package/dist/services/usage-tracker.js +137 -0
  79. package/dist/services/users.js +199 -0
  80. package/dist/services/voice.js +95 -0
  81. package/dist/tui/index.js +507 -0
  82. package/dist/web/canvas.js +30 -0
  83. package/dist/web/doctor-api.js +606 -0
  84. package/dist/web/openai-compat.js +252 -0
  85. package/dist/web/server.js +1351 -0
  86. package/dist/web/setup-api.js +1078 -0
  87. package/docs/mcp.example.json +16 -0
  88. package/docs/screenshots/00-Login.png +0 -0
  89. package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
  90. package/docs/screenshots/02-Chat.png +0 -0
  91. package/docs/screenshots/03-Dashboard-Overview.png +0 -0
  92. package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
  93. package/docs/screenshots/05-Personality-Editor.png +0 -0
  94. package/docs/screenshots/06-Memory-Manager.png +0 -0
  95. package/docs/screenshots/07-Active-Sessions.png +0 -0
  96. package/docs/screenshots/08-File-Browser.png +0 -0
  97. package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
  98. package/docs/screenshots/10-Custom-Tools.png +0 -0
  99. package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
  100. package/docs/screenshots/12-Messaging-Platforms.png +0 -0
  101. package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
  102. package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
  103. package/docs/screenshots/13-User-Management.png +0 -0
  104. package/docs/screenshots/14-Web-Terminal.png +0 -0
  105. package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
  106. package/docs/screenshots/16-Settings-and-Env.png +0 -0
  107. package/docs/screenshots/TG-commands.png +0 -0
  108. package/docs/screenshots/TG.png +0 -0
  109. package/docs/screenshots/_Mac-Installer.png +0 -0
  110. package/docs/tools.example.json +33 -0
  111. package/install.sh +165 -0
  112. package/package.json +190 -0
  113. package/plugins/calendar/index.js +270 -0
  114. package/plugins/email/index.js +231 -0
  115. package/plugins/finance/index.js +254 -0
  116. package/plugins/notes/index.js +227 -0
  117. package/plugins/smarthome/index.js +230 -0
  118. package/plugins/weather/index.js +122 -0
  119. package/skills/apple-notes/SKILL.md +31 -0
  120. package/skills/browse/SKILL.md +136 -0
  121. package/skills/code-project/SKILL.md +43 -0
  122. package/skills/data-analysis/SKILL.md +39 -0
  123. package/skills/document-creation/SKILL.md +48 -0
  124. package/skills/email-summary/SKILL.md +46 -0
  125. package/skills/github/SKILL.md +42 -0
  126. package/skills/summarize/SKILL.md +28 -0
  127. package/skills/system-admin/SKILL.md +39 -0
  128. package/skills/weather/SKILL.md +34 -0
  129. package/skills/web-research/SKILL.md +35 -0
  130. package/web/public/canvas.html +52 -0
  131. package/web/public/css/style.css +555 -0
  132. package/web/public/index.html +189 -0
  133. package/web/public/js/app.js +3102 -0
  134. package/web/public/js/i18n.js +1048 -0
  135. package/web/public/js/icons.js +104 -0
  136. package/web/public/login.html +48 -0
@@ -0,0 +1,1078 @@
1
+ /**
2
+ * Setup API — Platform & Model configuration endpoints.
3
+ *
4
+ * Handles:
5
+ * - Platform setup (Discord, WhatsApp, Signal tokens + dependency installation)
6
+ * - Model/Provider management (API keys, custom models, presets)
7
+ * - Runtime activation/deactivation
8
+ */
9
+ import fs from "fs";
10
+ import { resolve } from "path";
11
+ import { execSync } from "child_process";
12
+ import { getRegistry } from "../engine.js";
13
+ import { listJobs, createJob, deleteJob, toggleJob, updateJob, runJobNow, formatNextRun, humanReadableSchedule } from "../services/cron.js";
14
+ import { storePassword, revokePassword, getSudoStatus, verifyPassword, sudoExec, requestAdminViaDialog, openSystemSettings } from "../services/sudo.js";
15
+ import { ENV_FILE, CUSTOM_MODELS as CUSTOM_MODELS_FILE, BOT_ROOT, WHATSAPP_AUTH } from "../paths.js";
16
+ // ── Env Helpers ─────────────────────────────────────────
17
+ function readEnv() {
18
+ if (!fs.existsSync(ENV_FILE))
19
+ return {};
20
+ const lines = fs.readFileSync(ENV_FILE, "utf-8").split("\n");
21
+ const env = {};
22
+ for (const line of lines) {
23
+ if (line.startsWith("#") || !line.includes("="))
24
+ continue;
25
+ const idx = line.indexOf("=");
26
+ env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
27
+ }
28
+ return env;
29
+ }
30
+ function writeEnvVar(key, value) {
31
+ let content = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
32
+ const regex = new RegExp(`^${key}=.*$`, "m");
33
+ if (regex.test(content)) {
34
+ content = content.replace(regex, `${key}=${value}`);
35
+ }
36
+ else {
37
+ content = content.trimEnd() + `\n${key}=${value}\n`;
38
+ }
39
+ fs.writeFileSync(ENV_FILE, content);
40
+ }
41
+ function removeEnvVar(key) {
42
+ if (!fs.existsSync(ENV_FILE))
43
+ return;
44
+ let content = fs.readFileSync(ENV_FILE, "utf-8");
45
+ content = content.replace(new RegExp(`^${key}=.*\n?`, "m"), "");
46
+ fs.writeFileSync(ENV_FILE, content);
47
+ }
48
+ function loadCustomModels() {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(CUSTOM_MODELS_FILE, "utf-8"));
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
56
+ function saveCustomModels(models) {
57
+ fs.writeFileSync(CUSTOM_MODELS_FILE, JSON.stringify(models, null, 2));
58
+ }
59
+ const PLATFORMS = [
60
+ {
61
+ id: "telegram",
62
+ name: "Telegram",
63
+ icon: "📱",
64
+ description: "Telegram Bot via BotFather. The default messaging channel.",
65
+ envVars: [
66
+ { key: "BOT_TOKEN", label: "Bot Token", placeholder: "123456:ABC-DEF...", secret: true },
67
+ { key: "ALLOWED_USERS", label: "Allowed User IDs", placeholder: "123456789,987654321" },
68
+ ],
69
+ setupUrl: "https://t.me/BotFather",
70
+ setupSteps: [
71
+ "Open @BotFather on Telegram",
72
+ "Send /newbot and follow the instructions",
73
+ "Copy the bot token here",
74
+ "For your User ID: Send a message to @userinfobot",
75
+ ],
76
+ },
77
+ {
78
+ id: "discord",
79
+ name: "Discord",
80
+ icon: "🎮",
81
+ description: "Discord bot for servers and DMs. Requires discord.js.",
82
+ envVars: [
83
+ { key: "DISCORD_TOKEN", label: "Bot Token", placeholder: "MTIz...abc", secret: true },
84
+ ],
85
+ npmPackages: ["discord.js"],
86
+ setupUrl: "https://discord.com/developers/applications",
87
+ setupSteps: [
88
+ "Create an Application on discord.com/developers",
89
+ "Go to Bot → Reset Token → Copy token",
90
+ "Enable Message Content Intent under Bot → Privileged Intents",
91
+ "Invite the bot to your server: OAuth2 → URL Generator → bot + messages.read + messages.write",
92
+ ],
93
+ },
94
+ {
95
+ id: "whatsapp",
96
+ name: "WhatsApp",
97
+ icon: "💬",
98
+ description: "WhatsApp Multi-Device connection via Baileys (no Chrome needed). QR code scan on first start.",
99
+ envVars: [
100
+ { key: "WHATSAPP_ENABLED", label: "Enable", placeholder: "true", type: "toggle" },
101
+ { key: "WHATSAPP_SELF_CHAT_ONLY", label: "Self-chat only (recommended)", placeholder: "true", type: "toggle" },
102
+ { key: "WHATSAPP_ALLOW_GROUPS", label: "Reply in groups (on @mention)", placeholder: "", type: "toggle" },
103
+ { key: "WHATSAPP_ALLOW_DMS", label: "Reply to private messages", placeholder: "", type: "toggle" },
104
+ ],
105
+ npmPackages: ["@whiskeysockets/baileys"],
106
+ setupSteps: [
107
+ "Click 'Install Dependencies' (if needed)",
108
+ "Enable WhatsApp (toggle above) and click 'Save'",
109
+ "Restart the bot (Maintenance → Restart bot)",
110
+ "The QR code will appear below — scan it with WhatsApp → Linked Devices → Link a Device",
111
+ "The connection is persisted (data/whatsapp-auth/)",
112
+ ],
113
+ },
114
+ {
115
+ id: "slack",
116
+ name: "Slack",
117
+ icon: "💼",
118
+ description: "Slack workspace integration via Socket Mode (no public URL needed). DMs and @mentions in channels.",
119
+ envVars: [
120
+ { key: "SLACK_BOT_TOKEN", label: "Bot Token (xoxb-...)", placeholder: "xoxb-...", secret: true },
121
+ { key: "SLACK_APP_TOKEN", label: "App Token (xapp-...)", placeholder: "xapp-...", secret: true },
122
+ ],
123
+ npmPackages: ["@slack/bolt"],
124
+ setupUrl: "https://api.slack.com/apps",
125
+ setupSteps: [
126
+ "Create a new App at api.slack.com/apps (From scratch)",
127
+ "Enable Socket Mode (Settings → Socket Mode → Enable)",
128
+ "Generate App-Level Token with 'connections:write' scope → copy as SLACK_APP_TOKEN",
129
+ "Go to OAuth & Permissions → add Bot Token Scopes: chat:write, channels:history, groups:history, im:history, mpim:history, app_mentions:read, files:write, reactions:write",
130
+ "Install App to Workspace → copy Bot User OAuth Token as SLACK_BOT_TOKEN",
131
+ "Subscribe to Events: message.im, message.groups, message.channels, app_mention",
132
+ "Invite the bot to channels with /invite @botname",
133
+ ],
134
+ },
135
+ {
136
+ id: "signal",
137
+ name: "Signal",
138
+ icon: "🔒",
139
+ description: "Signal Messenger via signal-cli REST API. Requires a separate signal-cli container.",
140
+ envVars: [
141
+ { key: "SIGNAL_API_URL", label: "signal-cli REST API URL", placeholder: "http://localhost:8080" },
142
+ { key: "SIGNAL_NUMBER", label: "Signal Number", placeholder: "+491234567890" },
143
+ ],
144
+ setupUrl: "https://github.com/bbernhard/signal-cli-rest-api",
145
+ setupSteps: [
146
+ "Start signal-cli REST API (Docker recommended):",
147
+ "docker run -p 8080:8080 bbernhard/signal-cli-rest-api",
148
+ "Register your number via the API",
149
+ "Enter URL and number above",
150
+ ],
151
+ },
152
+ ];
153
+ const PROVIDERS = [
154
+ {
155
+ id: "claude-sdk",
156
+ name: "Claude Agent SDK",
157
+ icon: "🟣",
158
+ description: "Full tool use via Agent SDK. Requires Claude CLI login (Max plan or API key).",
159
+ envKey: "",
160
+ models: [
161
+ { key: "claude-sdk", name: "Claude (Agent SDK)", model: "claude-opus-4-6" },
162
+ ],
163
+ signupUrl: "https://console.anthropic.com",
164
+ docsUrl: "https://docs.anthropic.com/en/docs/claude-code",
165
+ setupSteps: [
166
+ "npm install -g @anthropic-ai/claude-code",
167
+ "claude login (browser auth or API key)",
168
+ "Full tool use: read/write files, shell commands, browser",
169
+ ],
170
+ },
171
+ {
172
+ id: "anthropic",
173
+ name: "Anthropic API",
174
+ icon: "🟣",
175
+ description: "Claude Opus, Sonnet, Haiku directly via API key. OpenAI-compatible.",
176
+ envKey: "ANTHROPIC_API_KEY",
177
+ models: [
178
+ { key: "claude-opus", name: "Claude Opus 4", model: "claude-opus-4-6" },
179
+ { key: "claude-sonnet", name: "Claude Sonnet 4", model: "claude-sonnet-4-20250514" },
180
+ { key: "claude-haiku", name: "Claude 3.5 Haiku", model: "claude-3-5-haiku-20241022" },
181
+ ],
182
+ signupUrl: "https://console.anthropic.com/settings/keys",
183
+ docsUrl: "https://docs.anthropic.com/en/api",
184
+ setupSteps: [
185
+ "Create account on console.anthropic.com",
186
+ "Generate API key under Settings → API Keys",
187
+ "Add credits (pay-as-you-go) or use subscription",
188
+ ],
189
+ },
190
+ {
191
+ id: "openai",
192
+ name: "OpenAI",
193
+ icon: "🟢",
194
+ description: "GPT-4o, GPT-4.1, o3/o4 and other OpenAI models.",
195
+ envKey: "OPENAI_API_KEY",
196
+ models: [
197
+ { key: "gpt-4o", name: "GPT-4o", model: "gpt-4o" },
198
+ { key: "gpt-4o-mini", name: "GPT-4o Mini", model: "gpt-4o-mini" },
199
+ { key: "gpt-4.1", name: "GPT-4.1", model: "gpt-4.1" },
200
+ { key: "gpt-4.1-mini", name: "GPT-4.1 Mini", model: "gpt-4.1-mini" },
201
+ { key: "o3-mini", name: "o3 Mini", model: "o3-mini" },
202
+ ],
203
+ signupUrl: "https://platform.openai.com/api-keys",
204
+ docsUrl: "https://platform.openai.com/docs",
205
+ setupSteps: [
206
+ "Create account on platform.openai.com",
207
+ "Generate API key under API Keys",
208
+ "Add credits (pay-as-you-go)",
209
+ ],
210
+ },
211
+ {
212
+ id: "google",
213
+ name: "Google Gemini",
214
+ icon: "🔵",
215
+ description: "Gemini 2.5/3 Pro/Flash via Google AI Studio. Free tier available.",
216
+ envKey: "GOOGLE_API_KEY",
217
+ models: [
218
+ { key: "gemini-2.5-pro", name: "Gemini 2.5 Pro", model: "gemini-2.5-pro" },
219
+ { key: "gemini-2.5-flash", name: "Gemini 2.5 Flash", model: "gemini-2.5-flash" },
220
+ { key: "gemini-3-pro", name: "Gemini 3 Pro (Preview)", model: "gemini-3-pro-preview" },
221
+ { key: "gemini-3-flash", name: "Gemini 3 Flash (Preview)", model: "gemini-3-flash-preview" },
222
+ ],
223
+ signupUrl: "https://aistudio.google.com/apikey",
224
+ docsUrl: "https://ai.google.dev/docs",
225
+ setupSteps: [
226
+ "Open Google AI Studio (aistudio.google.com)",
227
+ "Create API key → ready to use immediately",
228
+ "Free tier: 15 RPM, 1M TPM",
229
+ ],
230
+ free: true,
231
+ },
232
+ {
233
+ id: "nvidia",
234
+ name: "NVIDIA NIM",
235
+ icon: "🟩",
236
+ description: "150+ models free (Llama, Kimi, Mistral, etc.) via NVIDIA API.",
237
+ envKey: "NVIDIA_API_KEY",
238
+ models: [
239
+ { key: "nvidia-llama-3.3-70b", name: "Llama 3.3 70B", model: "meta/llama-3.3-70b-instruct" },
240
+ { key: "nvidia-kimi-k2.5", name: "Kimi K2.5", model: "moonshotai/kimi-k2.5" },
241
+ ],
242
+ signupUrl: "https://build.nvidia.com",
243
+ docsUrl: "https://docs.api.nvidia.com",
244
+ setupSteps: [
245
+ "Create account on build.nvidia.com",
246
+ "Generate free API key",
247
+ "150+ models available for free (1000 credits/month)",
248
+ ],
249
+ free: true,
250
+ },
251
+ {
252
+ id: "groq",
253
+ name: "Groq",
254
+ icon: "⚡",
255
+ description: "Ultra-fast inference. Llama, Mixtral, Gemma — free and lightning fast.",
256
+ envKey: "GROQ_API_KEY",
257
+ models: [
258
+ { key: "groq", name: "Llama 3.3 70B (Groq)", model: "llama-3.3-70b-versatile" },
259
+ { key: "groq-llama-3.1-8b", name: "Llama 3.1 8B (Groq)", model: "llama-3.1-8b-instant" },
260
+ { key: "groq-mixtral", name: "Mixtral 8x7B (Groq)", model: "mixtral-8x7b-32768" },
261
+ ],
262
+ signupUrl: "https://console.groq.com",
263
+ docsUrl: "https://console.groq.com/docs",
264
+ setupSteps: [
265
+ "Create account on console.groq.com (no credit card needed)",
266
+ "Generate API key",
267
+ "Ready to use immediately — free tier with rate limits",
268
+ ],
269
+ free: true,
270
+ },
271
+ {
272
+ id: "openrouter",
273
+ name: "OpenRouter",
274
+ icon: "🌐",
275
+ description: "One API key, 200+ models. Claude, GPT, Gemini, Llama — all via one API.",
276
+ envKey: "OPENROUTER_API_KEY",
277
+ models: [
278
+ { key: "openrouter", name: "OpenRouter (Standard)", model: "anthropic/claude-sonnet-4" },
279
+ ],
280
+ signupUrl: "https://openrouter.ai/keys",
281
+ docsUrl: "https://openrouter.ai/docs",
282
+ setupSteps: [
283
+ "Create account on openrouter.ai",
284
+ "Generate API key",
285
+ "Add credits or use free models",
286
+ ],
287
+ },
288
+ {
289
+ id: "ollama",
290
+ name: "Ollama (Local)",
291
+ icon: "🦙",
292
+ description: "Local models on your machine. No API key needed, runs offline.",
293
+ envKey: "",
294
+ models: [
295
+ { key: "ollama", name: "Ollama (Local)", model: "llama3.2" },
296
+ ],
297
+ signupUrl: "https://ollama.com/download",
298
+ docsUrl: "https://ollama.com/library",
299
+ setupSteps: [
300
+ "Install Ollama: brew install ollama (macOS) or ollama.com/download",
301
+ "Pull a model: ollama pull llama3.2",
302
+ "Runs automatically on localhost:11434",
303
+ ],
304
+ free: true,
305
+ },
306
+ ];
307
+ // ── API Handler ─────────────────────────────────────────
308
+ export async function handleSetupAPI(req, res, urlPath, body) {
309
+ res.setHeader("Content-Type", "application/json");
310
+ // ── Platforms ───────────────────────────────────────
311
+ // GET /api/platforms/setup — full setup info for all platforms
312
+ if (urlPath === "/api/platforms/setup") {
313
+ const env = readEnv();
314
+ const platforms = PLATFORMS.map(p => ({
315
+ ...p,
316
+ configured: (() => {
317
+ // A platform is "configured" if its primary env var(s) are set
318
+ // Toggles: the first toggle being true is enough (e.g., WHATSAPP_ENABLED)
319
+ // Text fields: all non-toggle fields must have a value
320
+ const required = p.envVars.filter(v => v.type !== "toggle");
321
+ const toggles = p.envVars.filter(v => v.type === "toggle");
322
+ if (required.length > 0)
323
+ return required.every(v => !!env[v.key]);
324
+ if (toggles.length > 0)
325
+ return toggles[0] && env[toggles[0].key] === "true";
326
+ return false;
327
+ })(),
328
+ values: Object.fromEntries(p.envVars.map(v => [v.key, v.secret && env[v.key] ? maskSecret(env[v.key]) : (env[v.key] || "")])),
329
+ depsInstalled: p.npmPackages ? checkNpmDeps(p.npmPackages) : true,
330
+ }));
331
+ res.end(JSON.stringify({ platforms }));
332
+ return true;
333
+ }
334
+ // POST /api/platforms/configure — save platform env vars
335
+ if (urlPath === "/api/platforms/configure" && req.method === "POST") {
336
+ try {
337
+ const { platformId, values } = JSON.parse(body);
338
+ const platform = PLATFORMS.find(p => p.id === platformId);
339
+ if (!platform) {
340
+ res.statusCode = 400;
341
+ res.end(JSON.stringify({ error: "Unknown platform" }));
342
+ return true;
343
+ }
344
+ for (const v of platform.envVars) {
345
+ if (values[v.key] !== undefined && values[v.key] !== "") {
346
+ writeEnvVar(v.key, values[v.key]);
347
+ process.env[v.key] = values[v.key]; // Hot-apply for toggle changes
348
+ }
349
+ else if (values[v.key] === "") {
350
+ removeEnvVar(v.key);
351
+ delete process.env[v.key]; // Hot-remove
352
+ }
353
+ }
354
+ // WhatsApp toggle-only changes (self-chat, groups, DMs) don't need restart
355
+ const onlyToggles = platform.envVars.every(v => v.type === "toggle") ||
356
+ (platformId === "whatsapp" && platform.envVars.filter(v => v.type !== "toggle").every(v => !values[v.key]));
357
+ const restartNeeded = !onlyToggles;
358
+ res.end(JSON.stringify({ ok: true, restartNeeded, note: restartNeeded ? "Restart required to apply changes." : "Saved." }));
359
+ }
360
+ catch {
361
+ res.statusCode = 400;
362
+ res.end(JSON.stringify({ error: "Invalid request" }));
363
+ }
364
+ return true;
365
+ }
366
+ // POST /api/platforms/install-deps — install npm packages for a platform
367
+ if (urlPath === "/api/platforms/install-deps" && req.method === "POST") {
368
+ try {
369
+ const { platformId } = JSON.parse(body);
370
+ const platform = PLATFORMS.find(p => p.id === platformId);
371
+ if (!platform?.npmPackages?.length) {
372
+ res.end(JSON.stringify({ ok: true, note: "No dependencies needed." }));
373
+ return true;
374
+ }
375
+ const pkgs = platform.npmPackages.join(" ");
376
+ const output = execSync(`cd "${BOT_ROOT}" && npm install ${pkgs} --save-optional 2>&1`, {
377
+ timeout: 120000,
378
+ env: { ...process.env, PATH: process.env.PATH + ":/opt/homebrew/bin:/usr/local/bin" },
379
+ }).toString();
380
+ res.end(JSON.stringify({ ok: true, output: output.slice(0, 5000) }));
381
+ }
382
+ catch (err) {
383
+ const error = err instanceof Error ? err.message : String(err);
384
+ res.end(JSON.stringify({ error }));
385
+ }
386
+ return true;
387
+ }
388
+ // ── Models / Providers ─────────────────────────────
389
+ // GET /api/providers/setup — full setup info for all providers
390
+ if (urlPath === "/api/providers/setup") {
391
+ const env = readEnv();
392
+ const registry = getRegistry();
393
+ const activeKey = registry.getActiveKey();
394
+ const registeredModels = await registry.listAll();
395
+ const providers = PROVIDERS.map(p => ({
396
+ ...p,
397
+ hasKey: p.envKey ? !!env[p.envKey] : true, // Ollama doesn't need key
398
+ keyPreview: p.envKey && env[p.envKey] ? maskSecret(env[p.envKey]) : "",
399
+ modelsActive: p.models.map(m => ({
400
+ ...m,
401
+ registered: registeredModels.some(rm => rm.key === m.key),
402
+ active: activeKey === m.key,
403
+ status: registeredModels.find(rm => rm.key === m.key)?.status || "not configured",
404
+ })),
405
+ }));
406
+ const customModels = loadCustomModels();
407
+ res.end(JSON.stringify({ providers, customModels, activeModel: activeKey }));
408
+ return true;
409
+ }
410
+ // POST /api/providers/set-key — save an API key
411
+ if (urlPath === "/api/providers/set-key" && req.method === "POST") {
412
+ try {
413
+ const { providerId, apiKey } = JSON.parse(body);
414
+ const provider = PROVIDERS.find(p => p.id === providerId);
415
+ if (!provider?.envKey) {
416
+ res.statusCode = 400;
417
+ res.end(JSON.stringify({ error: "Provider does not need an API key" }));
418
+ return true;
419
+ }
420
+ writeEnvVar(provider.envKey, apiKey);
421
+ res.end(JSON.stringify({ ok: true, note: "Restart required to activate the new key." }));
422
+ }
423
+ catch {
424
+ res.statusCode = 400;
425
+ res.end(JSON.stringify({ error: "Invalid request" }));
426
+ }
427
+ return true;
428
+ }
429
+ // POST /api/providers/set-primary — set primary provider
430
+ if (urlPath === "/api/providers/set-primary" && req.method === "POST") {
431
+ try {
432
+ const { key } = JSON.parse(body);
433
+ writeEnvVar("PRIMARY_PROVIDER", key);
434
+ // Also switch runtime
435
+ const registry = getRegistry();
436
+ registry.switchTo(key);
437
+ res.end(JSON.stringify({ ok: true }));
438
+ }
439
+ catch {
440
+ res.statusCode = 400;
441
+ res.end(JSON.stringify({ error: "Invalid request" }));
442
+ }
443
+ return true;
444
+ }
445
+ // POST /api/providers/set-fallbacks — set fallback chain
446
+ if (urlPath === "/api/providers/set-fallbacks" && req.method === "POST") {
447
+ try {
448
+ const { keys } = JSON.parse(body);
449
+ writeEnvVar("FALLBACK_PROVIDERS", keys.join(","));
450
+ res.end(JSON.stringify({ ok: true, note: "Restart required." }));
451
+ }
452
+ catch {
453
+ res.statusCode = 400;
454
+ res.end(JSON.stringify({ error: "Invalid request" }));
455
+ }
456
+ return true;
457
+ }
458
+ // GET /api/providers/live-models?id=<providerId> — fetch available models from provider API
459
+ if (urlPath?.startsWith("/api/providers/live-models") && req.method === "GET") {
460
+ try {
461
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
462
+ const providerId = url.searchParams.get("id") || "";
463
+ const models = await fetchLiveModels(providerId);
464
+ res.end(JSON.stringify({ ok: true, providerId, models }));
465
+ }
466
+ catch (err) {
467
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err), models: [] }));
468
+ }
469
+ return true;
470
+ }
471
+ // POST /api/providers/add-custom — add a custom model
472
+ if (urlPath === "/api/providers/add-custom" && req.method === "POST") {
473
+ try {
474
+ const model = JSON.parse(body);
475
+ if (!model.key || !model.name || !model.baseUrl || !model.model) {
476
+ res.statusCode = 400;
477
+ res.end(JSON.stringify({ error: "key, name, baseUrl and model are required fields" }));
478
+ return true;
479
+ }
480
+ model.type = "openai-compatible";
481
+ const models = loadCustomModels();
482
+ // Upsert
483
+ const idx = models.findIndex(m => m.key === model.key);
484
+ if (idx >= 0)
485
+ models[idx] = model;
486
+ else
487
+ models.push(model);
488
+ saveCustomModels(models);
489
+ // Save API key if provided
490
+ if (model.apiKeyEnv && model.apiKey) {
491
+ writeEnvVar(model.apiKeyEnv, model.apiKey);
492
+ }
493
+ res.end(JSON.stringify({ ok: true, note: "Restart required to activate the model." }));
494
+ }
495
+ catch {
496
+ res.statusCode = 400;
497
+ res.end(JSON.stringify({ error: "Invalid request" }));
498
+ }
499
+ return true;
500
+ }
501
+ // DELETE /api/providers/remove-custom — remove a custom model
502
+ if (urlPath === "/api/providers/remove-custom" && req.method === "POST") {
503
+ try {
504
+ const { key } = JSON.parse(body);
505
+ const models = loadCustomModels().filter(m => m.key !== key);
506
+ saveCustomModels(models);
507
+ res.end(JSON.stringify({ ok: true }));
508
+ }
509
+ catch {
510
+ res.statusCode = 400;
511
+ res.end(JSON.stringify({ error: "Invalid request" }));
512
+ }
513
+ return true;
514
+ }
515
+ // POST /api/providers/test-key — quick API key validation
516
+ if (urlPath === "/api/providers/test-key" && req.method === "POST") {
517
+ try {
518
+ const { providerId, apiKey } = JSON.parse(body);
519
+ const result = await testApiKey(providerId, apiKey);
520
+ res.end(JSON.stringify(result));
521
+ }
522
+ catch (err) {
523
+ const error = err instanceof Error ? err.message : String(err);
524
+ res.end(JSON.stringify({ ok: false, error }));
525
+ }
526
+ return true;
527
+ }
528
+ // ── Sudo / Elevated Access ─────────────────────────
529
+ // GET /api/sudo/status — check sudo configuration
530
+ if (urlPath === "/api/sudo/status") {
531
+ const status = await getSudoStatus();
532
+ res.end(JSON.stringify(status));
533
+ return true;
534
+ }
535
+ // POST /api/sudo/setup — store sudo password
536
+ if (urlPath === "/api/sudo/setup" && req.method === "POST") {
537
+ try {
538
+ const { password } = JSON.parse(body);
539
+ if (!password) {
540
+ res.statusCode = 400;
541
+ res.end(JSON.stringify({ error: "Password required" }));
542
+ return true;
543
+ }
544
+ const result = storePassword(password);
545
+ if (result.ok) {
546
+ // Verify it works
547
+ const verify = await verifyPassword();
548
+ if (verify.ok) {
549
+ res.end(JSON.stringify({ ok: true, method: result.method, verified: true }));
550
+ }
551
+ else {
552
+ revokePassword(); // Clean up if wrong password
553
+ res.end(JSON.stringify({ ok: false, error: "Password stored but verification failed: " + verify.error }));
554
+ }
555
+ }
556
+ else {
557
+ res.end(JSON.stringify({ ok: false, error: result.error }));
558
+ }
559
+ }
560
+ catch {
561
+ res.statusCode = 400;
562
+ res.end(JSON.stringify({ error: "Invalid request" }));
563
+ }
564
+ return true;
565
+ }
566
+ // POST /api/sudo/revoke — delete stored password
567
+ if (urlPath === "/api/sudo/revoke" && req.method === "POST") {
568
+ const ok = revokePassword();
569
+ res.end(JSON.stringify({ ok }));
570
+ return true;
571
+ }
572
+ // POST /api/sudo/verify — test if stored password works
573
+ if (urlPath === "/api/sudo/verify" && req.method === "POST") {
574
+ const result = await verifyPassword();
575
+ res.end(JSON.stringify(result));
576
+ return true;
577
+ }
578
+ // POST /api/sudo/exec — execute a command with sudo
579
+ if (urlPath === "/api/sudo/exec" && req.method === "POST") {
580
+ try {
581
+ const { command } = JSON.parse(body);
582
+ if (!command) {
583
+ res.statusCode = 400;
584
+ res.end(JSON.stringify({ error: "No command specified" }));
585
+ return true;
586
+ }
587
+ const result = await sudoExec(command);
588
+ res.end(JSON.stringify(result));
589
+ }
590
+ catch {
591
+ res.statusCode = 400;
592
+ res.end(JSON.stringify({ error: "Invalid request" }));
593
+ }
594
+ return true;
595
+ }
596
+ // POST /api/sudo/admin-dialog — show macOS admin dialog
597
+ if (urlPath === "/api/sudo/admin-dialog" && req.method === "POST") {
598
+ try {
599
+ const { reason } = JSON.parse(body);
600
+ const result = await requestAdminViaDialog(reason || "Alvin Bot requires administrator privileges");
601
+ res.end(JSON.stringify(result));
602
+ }
603
+ catch {
604
+ res.statusCode = 400;
605
+ res.end(JSON.stringify({ error: "Invalid request" }));
606
+ }
607
+ return true;
608
+ }
609
+ // POST /api/sudo/open-settings — open macOS system settings
610
+ if (urlPath === "/api/sudo/open-settings" && req.method === "POST") {
611
+ try {
612
+ const { pane } = JSON.parse(body);
613
+ const ok = openSystemSettings(pane || "security");
614
+ res.end(JSON.stringify({ ok }));
615
+ }
616
+ catch {
617
+ res.statusCode = 400;
618
+ res.end(JSON.stringify({ error: "Invalid request" }));
619
+ }
620
+ return true;
621
+ }
622
+ // ── Skills ────────────────────────────────────────────
623
+ // GET /api/skills — list all loaded skills
624
+ if (urlPath === "/api/skills") {
625
+ const { getSkills } = await import("../services/skills.js");
626
+ const skills = getSkills().map(s => ({
627
+ id: s.id,
628
+ name: s.name,
629
+ description: s.description,
630
+ triggers: s.triggers,
631
+ priority: s.priority,
632
+ category: s.category,
633
+ }));
634
+ res.end(JSON.stringify({ skills }));
635
+ return true;
636
+ }
637
+ // ── Cron Jobs ───────────────────────────────────────
638
+ // GET /api/cron — list all jobs
639
+ if (urlPath === "/api/cron") {
640
+ const jobs = listJobs();
641
+ const enriched = jobs.map(j => ({
642
+ ...j,
643
+ nextRunFormatted: formatNextRun(j.nextRunAt),
644
+ lastRunFormatted: j.lastRunAt ? new Date(j.lastRunAt).toLocaleString("de-DE") : null,
645
+ scheduleReadable: humanReadableSchedule(j.schedule),
646
+ }));
647
+ res.end(JSON.stringify({ jobs: enriched }));
648
+ return true;
649
+ }
650
+ // POST /api/cron/create — create a new job
651
+ if (urlPath === "/api/cron/create" && req.method === "POST") {
652
+ try {
653
+ const data = JSON.parse(body);
654
+ const job = createJob({
655
+ name: data.name,
656
+ type: data.type,
657
+ schedule: data.schedule,
658
+ oneShot: data.oneShot || false,
659
+ payload: data.payload || {},
660
+ target: data.target || { platform: "web", chatId: "dashboard" },
661
+ createdBy: "web-ui",
662
+ });
663
+ res.end(JSON.stringify({ ok: true, job }));
664
+ }
665
+ catch (err) {
666
+ res.statusCode = 400;
667
+ const error = err instanceof Error ? err.message : "Invalid request";
668
+ res.end(JSON.stringify({ error }));
669
+ }
670
+ return true;
671
+ }
672
+ // POST /api/cron/delete — delete a job
673
+ if (urlPath === "/api/cron/delete" && req.method === "POST") {
674
+ try {
675
+ const { id } = JSON.parse(body);
676
+ const ok = deleteJob(id);
677
+ res.end(JSON.stringify({ ok }));
678
+ }
679
+ catch {
680
+ res.statusCode = 400;
681
+ res.end(JSON.stringify({ error: "Invalid request" }));
682
+ }
683
+ return true;
684
+ }
685
+ // POST /api/cron/update — update job fields (schedule, name, oneShot)
686
+ if (urlPath === "/api/cron/update" && req.method === "POST") {
687
+ try {
688
+ const { id, ...updates } = JSON.parse(body);
689
+ if (!id) {
690
+ res.statusCode = 400;
691
+ res.end(JSON.stringify({ error: "id required" }));
692
+ return true;
693
+ }
694
+ // Only allow safe fields
695
+ const allowed = {};
696
+ if (updates.schedule !== undefined)
697
+ allowed.schedule = updates.schedule;
698
+ if (updates.name !== undefined)
699
+ allowed.name = updates.name;
700
+ if (updates.oneShot !== undefined)
701
+ allowed.oneShot = updates.oneShot;
702
+ const job = updateJob(id, allowed);
703
+ if (!job) {
704
+ res.statusCode = 404;
705
+ res.end(JSON.stringify({ error: "Job not found" }));
706
+ return true;
707
+ }
708
+ res.end(JSON.stringify({ ok: true, job }));
709
+ }
710
+ catch (err) {
711
+ res.statusCode = 400;
712
+ const error = err instanceof Error ? err.message : "Invalid request";
713
+ res.end(JSON.stringify({ error }));
714
+ }
715
+ return true;
716
+ }
717
+ // POST /api/cron/toggle — enable/disable a job
718
+ if (urlPath === "/api/cron/toggle" && req.method === "POST") {
719
+ try {
720
+ const { id } = JSON.parse(body);
721
+ const job = toggleJob(id);
722
+ res.end(JSON.stringify({ ok: !!job, job }));
723
+ }
724
+ catch {
725
+ res.statusCode = 400;
726
+ res.end(JSON.stringify({ error: "Invalid request" }));
727
+ }
728
+ return true;
729
+ }
730
+ // POST /api/cron/run — run a job immediately
731
+ if (urlPath === "/api/cron/run" && req.method === "POST") {
732
+ try {
733
+ const { id } = JSON.parse(body);
734
+ const result = await (runJobNow(id) || Promise.resolve({ output: "", error: "Job not found" }));
735
+ res.end(JSON.stringify(result));
736
+ }
737
+ catch (err) {
738
+ const error = err instanceof Error ? err.message : String(err);
739
+ res.end(JSON.stringify({ error }));
740
+ }
741
+ return true;
742
+ }
743
+ // ── Platform Connection Status ─────────────────────────
744
+ // GET /api/platforms/status — live connection status for all platforms
745
+ if (urlPath === "/api/platforms/status") {
746
+ const statuses = {};
747
+ // Telegram
748
+ try {
749
+ const { getTelegramState } = await import("../platforms/telegram.js");
750
+ statuses.telegram = getTelegramState();
751
+ }
752
+ catch {
753
+ statuses.telegram = { status: !!process.env.BOT_TOKEN ? "unknown" : "not_configured" };
754
+ }
755
+ // Discord
756
+ try {
757
+ const { getDiscordState } = await import("../platforms/discord.js");
758
+ statuses.discord = getDiscordState();
759
+ }
760
+ catch {
761
+ statuses.discord = { status: !!process.env.DISCORD_TOKEN ? "unknown" : "not_configured" };
762
+ }
763
+ // WhatsApp
764
+ try {
765
+ const { getWhatsAppState } = await import("../platforms/whatsapp.js");
766
+ statuses.whatsapp = getWhatsAppState();
767
+ }
768
+ catch {
769
+ statuses.whatsapp = { status: process.env.WHATSAPP_ENABLED === "true" ? "unknown" : "not_configured" };
770
+ }
771
+ // Signal
772
+ try {
773
+ const { getSignalState } = await import("../platforms/signal.js");
774
+ statuses.signal = getSignalState();
775
+ }
776
+ catch {
777
+ statuses.signal = { status: !!process.env.SIGNAL_API_URL ? "unknown" : "not_configured" };
778
+ }
779
+ res.end(JSON.stringify(statuses));
780
+ return true;
781
+ }
782
+ // GET /api/whatsapp/status — WhatsApp-specific (QR code needs its own endpoint)
783
+ if (urlPath === "/api/whatsapp/status") {
784
+ try {
785
+ const { getWhatsAppState } = await import("../platforms/whatsapp.js");
786
+ const state = getWhatsAppState();
787
+ res.end(JSON.stringify(state));
788
+ }
789
+ catch {
790
+ res.end(JSON.stringify({ status: "disconnected", qrString: null, error: "WhatsApp adapter not loaded" }));
791
+ }
792
+ return true;
793
+ }
794
+ // POST /api/whatsapp/disconnect — clear auth and disconnect
795
+ if (urlPath === "/api/whatsapp/disconnect" && req.method === "POST") {
796
+ try {
797
+ const authDir = WHATSAPP_AUTH;
798
+ if (fs.existsSync(authDir)) {
799
+ fs.rmSync(authDir, { recursive: true });
800
+ }
801
+ res.end(JSON.stringify({ ok: true, note: "Auth data cleared. Restart required for new connection." }));
802
+ }
803
+ catch (err) {
804
+ const error = err instanceof Error ? err.message : String(err);
805
+ res.end(JSON.stringify({ ok: false, error }));
806
+ }
807
+ return true;
808
+ }
809
+ // POST /api/platforms/test-connection — test a specific platform
810
+ if (urlPath === "/api/platforms/test-connection" && req.method === "POST") {
811
+ try {
812
+ const { platformId } = JSON.parse(body);
813
+ if (platformId === "telegram") {
814
+ const token = process.env.BOT_TOKEN;
815
+ if (!token) {
816
+ res.end(JSON.stringify({ ok: false, error: "BOT_TOKEN not set" }));
817
+ return true;
818
+ }
819
+ const apiRes = await fetch(`https://api.telegram.org/bot${token}/getMe`);
820
+ const data = await apiRes.json();
821
+ if (data.ok) {
822
+ res.end(JSON.stringify({ ok: true, info: `@${data.result.username} (${data.result.first_name})` }));
823
+ }
824
+ else {
825
+ res.end(JSON.stringify({ ok: false, error: data.description || "Invalid token" }));
826
+ }
827
+ return true;
828
+ }
829
+ if (platformId === "discord") {
830
+ const token = process.env.DISCORD_TOKEN;
831
+ if (!token) {
832
+ res.end(JSON.stringify({ ok: false, error: "DISCORD_TOKEN not set" }));
833
+ return true;
834
+ }
835
+ const apiRes = await fetch("https://discord.com/api/v10/users/@me", {
836
+ headers: { Authorization: `Bot ${token}` },
837
+ });
838
+ const data = await apiRes.json();
839
+ if (data.id) {
840
+ res.end(JSON.stringify({ ok: true, info: `${data.username}#${data.discriminator || '0'} (ID: ${data.id})` }));
841
+ }
842
+ else {
843
+ res.end(JSON.stringify({ ok: false, error: data.message || "Invalid token" }));
844
+ }
845
+ return true;
846
+ }
847
+ if (platformId === "signal") {
848
+ const apiUrl = process.env.SIGNAL_API_URL;
849
+ if (!apiUrl) {
850
+ res.end(JSON.stringify({ ok: false, error: "SIGNAL_API_URL not set" }));
851
+ return true;
852
+ }
853
+ const apiRes = await fetch(`${apiUrl.replace(/\/$/, '')}/v1/about`);
854
+ if (apiRes.ok) {
855
+ const data = await apiRes.json();
856
+ res.end(JSON.stringify({ ok: true, info: `signal-cli API v${data.version || '?'} reachable` }));
857
+ }
858
+ else {
859
+ res.end(JSON.stringify({ ok: false, error: `API responded with ${apiRes.status}` }));
860
+ }
861
+ return true;
862
+ }
863
+ if (platformId === "whatsapp") {
864
+ try {
865
+ const { getWhatsAppState } = await import("../platforms/whatsapp.js");
866
+ const state = getWhatsAppState();
867
+ res.end(JSON.stringify({ ok: state.status === "connected", info: `Status: ${state.status}` }));
868
+ }
869
+ catch {
870
+ res.end(JSON.stringify({ ok: false, error: "WhatsApp adapter not loaded" }));
871
+ }
872
+ return true;
873
+ }
874
+ res.end(JSON.stringify({ ok: false, error: "Unknown platform" }));
875
+ }
876
+ catch (err) {
877
+ const error = err instanceof Error ? err.message : String(err);
878
+ res.end(JSON.stringify({ ok: false, error }));
879
+ }
880
+ return true;
881
+ }
882
+ return false; // Not handled
883
+ }
884
+ // ── Helpers ─────────────────────────────────────────────
885
+ function maskSecret(value) {
886
+ if (value.length <= 8)
887
+ return "****";
888
+ return value.slice(0, 4) + "..." + value.slice(-4);
889
+ }
890
+ function checkNpmDeps(packages) {
891
+ const nodeModules = resolve(BOT_ROOT, "node_modules");
892
+ return packages.every(pkg => {
893
+ try {
894
+ return fs.existsSync(resolve(nodeModules, pkg.split("/")[0]));
895
+ }
896
+ catch {
897
+ return false;
898
+ }
899
+ });
900
+ }
901
+ async function testApiKey(providerId, apiKey) {
902
+ try {
903
+ const provider = PROVIDERS.find(p => p.id === providerId);
904
+ if (!provider)
905
+ return { ok: false, error: "Unknown provider" };
906
+ // Use stored key if requested (input was empty but key already configured)
907
+ // Skip for providers that don't use API keys (e.g. claude-sdk uses CLI auth)
908
+ if (apiKey === "__USE_STORED__") {
909
+ if (providerId === "claude-sdk" || providerId === "ollama") {
910
+ apiKey = ""; // These don't need keys — test will check CLI/service availability
911
+ }
912
+ else {
913
+ const envKey = provider.envKey;
914
+ const storedKey = envKey ? process.env[envKey] : undefined;
915
+ if (!storedKey)
916
+ return { ok: false, error: "No stored key available" };
917
+ apiKey = storedKey;
918
+ }
919
+ }
920
+ switch (providerId) {
921
+ case "openai": {
922
+ const r = await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${apiKey}` } });
923
+ if (!r.ok)
924
+ return { ok: false, error: `HTTP ${r.status}: ${await r.text()}` };
925
+ return { ok: true, model: "gpt-4o" };
926
+ }
927
+ case "google": {
928
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
929
+ if (!r.ok)
930
+ return { ok: false, error: `HTTP ${r.status}: ${await r.text()}` };
931
+ return { ok: true, model: "gemini-2.5-pro" };
932
+ }
933
+ case "nvidia": {
934
+ const r = await fetch("https://integrate.api.nvidia.com/v1/models", { headers: { Authorization: `Bearer ${apiKey}` } });
935
+ if (!r.ok)
936
+ return { ok: false, error: `HTTP ${r.status}: ${await r.text()}` };
937
+ return { ok: true, model: "meta/llama-3.3-70b-instruct" };
938
+ }
939
+ case "openrouter": {
940
+ const r = await fetch("https://openrouter.ai/api/v1/models", { headers: { Authorization: `Bearer ${apiKey}` } });
941
+ if (!r.ok)
942
+ return { ok: false, error: `HTTP ${r.status}: ${await r.text()}` };
943
+ return { ok: true, model: "anthropic/claude-sonnet-4" };
944
+ }
945
+ case "groq": {
946
+ const r = await fetch("https://api.groq.com/openai/v1/models", { headers: { Authorization: `Bearer ${apiKey}` } });
947
+ if (!r.ok)
948
+ return { ok: false, error: `HTTP ${r.status}: ${await r.text()}` };
949
+ return { ok: true, model: "llama-3.3-70b-versatile" };
950
+ }
951
+ case "claude-sdk": {
952
+ // Claude SDK uses CLI auth, not an API key — check if CLI is available
953
+ const { execSync } = await import("child_process");
954
+ try {
955
+ execSync("claude --version", { timeout: 5000, stdio: "pipe" });
956
+ return { ok: true, model: "claude-opus-4-6" };
957
+ }
958
+ catch {
959
+ return { ok: false, error: "Claude CLI not installed or not logged in" };
960
+ }
961
+ }
962
+ case "anthropic": {
963
+ // Anthropic API via OpenAI-compatible endpoint
964
+ const r = await fetch("https://api.anthropic.com/v1/models", {
965
+ headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
966
+ });
967
+ if (!r.ok)
968
+ return { ok: false, error: `HTTP ${r.status}: ${(await r.text()).substring(0, 200)}` };
969
+ return { ok: true, model: "claude-sonnet-4" };
970
+ }
971
+ default:
972
+ return { ok: false, error: "Key test not available for this provider" };
973
+ }
974
+ }
975
+ catch (err) {
976
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
977
+ }
978
+ }
979
+ async function fetchLiveModels(providerId) {
980
+ const env = process.env;
981
+ switch (providerId) {
982
+ case "anthropic": {
983
+ const key = env.ANTHROPIC_API_KEY;
984
+ if (!key)
985
+ return [];
986
+ const r = await fetch("https://api.anthropic.com/v1/models", {
987
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
988
+ });
989
+ if (!r.ok)
990
+ return [];
991
+ const data = await r.json();
992
+ return (data.data || [])
993
+ .filter((m) => m.id && !m.id.includes("pdfs"))
994
+ .map((m) => ({ id: m.id, name: m.display_name || m.id, owned_by: "anthropic" }))
995
+ .sort((a, b) => a.id.localeCompare(b.id));
996
+ }
997
+ case "openai": {
998
+ const key = env.OPENAI_API_KEY;
999
+ if (!key)
1000
+ return [];
1001
+ const r = await fetch("https://api.openai.com/v1/models", {
1002
+ headers: { Authorization: `Bearer ${key}` },
1003
+ });
1004
+ if (!r.ok)
1005
+ return [];
1006
+ const data = await r.json();
1007
+ // Filter to chat-relevant models only
1008
+ const chatPrefixes = ["gpt-4", "gpt-3.5", "o1", "o3", "o4", "chatgpt"];
1009
+ return (data.data || [])
1010
+ .filter((m) => chatPrefixes.some(p => m.id.startsWith(p)))
1011
+ .map((m) => ({ id: m.id, name: m.id, owned_by: m.owned_by || "openai" }))
1012
+ .sort((a, b) => a.id.localeCompare(b.id));
1013
+ }
1014
+ case "google": {
1015
+ const key = env.GOOGLE_API_KEY;
1016
+ if (!key)
1017
+ return [];
1018
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${key}`);
1019
+ if (!r.ok)
1020
+ return [];
1021
+ const data = await r.json();
1022
+ return (data.models || [])
1023
+ .filter((m) => m.name && m.supportedGenerationMethods?.includes("generateContent"))
1024
+ .map((m) => ({
1025
+ id: m.name.replace("models/", ""),
1026
+ name: m.displayName || m.name.replace("models/", ""),
1027
+ owned_by: "google",
1028
+ }))
1029
+ .sort((a, b) => a.id.localeCompare(b.id));
1030
+ }
1031
+ case "groq": {
1032
+ const key = env.GROQ_API_KEY;
1033
+ if (!key)
1034
+ return [];
1035
+ const r = await fetch("https://api.groq.com/openai/v1/models", {
1036
+ headers: { Authorization: `Bearer ${key}` },
1037
+ });
1038
+ if (!r.ok)
1039
+ return [];
1040
+ const data = await r.json();
1041
+ return (data.data || [])
1042
+ .filter((m) => m.id && m.active !== false)
1043
+ .map((m) => ({ id: m.id, name: m.id, owned_by: m.owned_by || "groq" }))
1044
+ .sort((a, b) => a.id.localeCompare(b.id));
1045
+ }
1046
+ case "nvidia": {
1047
+ const key = env.NVIDIA_API_KEY;
1048
+ if (!key)
1049
+ return [];
1050
+ const r = await fetch("https://integrate.api.nvidia.com/v1/models", {
1051
+ headers: { Authorization: `Bearer ${key}` },
1052
+ });
1053
+ if (!r.ok)
1054
+ return [];
1055
+ const data = await r.json();
1056
+ return (data.data || [])
1057
+ .map((m) => ({ id: m.id, name: m.id, owned_by: m.owned_by || "nvidia" }))
1058
+ .sort((a, b) => a.id.localeCompare(b.id));
1059
+ }
1060
+ case "openrouter": {
1061
+ const key = env.OPENROUTER_API_KEY;
1062
+ if (!key)
1063
+ return [];
1064
+ const r = await fetch("https://openrouter.ai/api/v1/models", {
1065
+ headers: { Authorization: `Bearer ${key}` },
1066
+ });
1067
+ if (!r.ok)
1068
+ return [];
1069
+ const data = await r.json();
1070
+ return (data.data || [])
1071
+ .slice(0, 100) // OpenRouter has 200+ models, limit display
1072
+ .map((m) => ({ id: m.id, name: m.name || m.id, owned_by: "openrouter" }))
1073
+ .sort((a, b) => a.id.localeCompare(b.id));
1074
+ }
1075
+ default:
1076
+ return [];
1077
+ }
1078
+ }