jerob 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CLI/cli.ts +42 -0
  2. package/README.md +137 -0
  3. package/SETUP.md +584 -0
  4. package/agent/action-tracker.ts +45 -0
  5. package/agent/agent-tools.ts +111 -0
  6. package/agent/approval.ts +137 -0
  7. package/agent/diff-view.ts +26 -0
  8. package/agent/orchestrator.ts +186 -0
  9. package/agent/tool-executor.ts +463 -0
  10. package/agent/types.ts +69 -0
  11. package/ask/orchestrator.ts +244 -0
  12. package/auth/auth.ts +567 -0
  13. package/auth/config-store.ts +77 -0
  14. package/auth/crypto.ts +51 -0
  15. package/auth/env-writer.ts +82 -0
  16. package/bin/jerob.js +28 -0
  17. package/config/ai.config.ts +163 -0
  18. package/email_ops/email-tools.ts +178 -0
  19. package/email_ops/email_functions.ts +443 -0
  20. package/email_ops/email_init.ts +92 -0
  21. package/email_ops/email_pass_store.ts +61 -0
  22. package/email_ops/email_server.ts +29 -0
  23. package/email_ops/types.ts +88 -0
  24. package/index.ts +176 -0
  25. package/package.json +88 -0
  26. package/plan/browser-agent/README.md +118 -0
  27. package/plan/browser-agent/USAGE.md +308 -0
  28. package/plan/browser-agent/evaluator.ts +353 -0
  29. package/plan/browser-agent/executor.ts +372 -0
  30. package/plan/browser-agent/index.ts +13 -0
  31. package/plan/browser-agent/orchestrator.ts +323 -0
  32. package/plan/browser-agent/planner.ts +200 -0
  33. package/plan/browser-agent/types.ts +62 -0
  34. package/plan/browser-tool.ts +128 -0
  35. package/plan/index.ts +12 -0
  36. package/plan/orchestrator.ts +214 -0
  37. package/plan/planner.ts +183 -0
  38. package/plan/selection.ts +50 -0
  39. package/plan/types.ts +13 -0
  40. package/plan/web-tools.ts +119 -0
  41. package/scheduler/ARCHITECTURE.md +263 -0
  42. package/scheduler/README.md +200 -0
  43. package/scheduler/SETUP-READY.sql +84 -0
  44. package/scheduler/check-status.sql +124 -0
  45. package/scheduler/config-sync.ts +91 -0
  46. package/scheduler/db-migrate.ts +271 -0
  47. package/scheduler/db.ts +162 -0
  48. package/scheduler/debug.ts +184 -0
  49. package/scheduler/orchestrator.ts +438 -0
  50. package/scheduler/planner.ts +170 -0
  51. package/scheduler/update-task-email.ts +70 -0
  52. package/supabase/.temp/cli-latest +1 -0
  53. package/supabase/.temp/gotrue-version +1 -0
  54. package/supabase/.temp/linked-project.json +1 -0
  55. package/supabase/.temp/pooler-url +1 -0
  56. package/supabase/.temp/postgres-version +1 -0
  57. package/supabase/.temp/project-ref +1 -0
  58. package/supabase/.temp/rest-version +1 -0
  59. package/supabase/.temp/storage-migration +1 -0
  60. package/supabase/.temp/storage-version +1 -0
  61. package/supabase/deploy.ps1 +50 -0
  62. package/supabase/functions/scheduler-tick/index.ts +496 -0
  63. package/supabase/supabase/.temp/linked-project.json +1 -0
  64. package/tsconfig.json +33 -0
  65. package/tui/spinner.ts +33 -0
  66. package/tui/spinup.ts +67 -0
  67. package/tui/terminal-render.ts +16 -0
  68. package/utils/llm-error.ts +185 -0
  69. package/utils/model-validator.ts +247 -0
package/auth/auth.ts ADDED
@@ -0,0 +1,567 @@
1
+ import chalk from "chalk";
2
+ import { password, text, isCancel, multiselect, select } from "@clack/prompts";
3
+ import { hashPassword, verifyPassword, encrypt, decrypt } from "./crypto";
4
+ import { loadConfig, saveConfig, isConfigured, removeConfig } from "./config-store";
5
+ import type { StoredConfig } from "./config-store";
6
+ import { clackModelValidator, printModelWarning, type Provider } from "../utils/model-validator";
7
+
8
+ export interface AuthResult {
9
+ config: StoredConfig;
10
+ password: string;
11
+ }
12
+
13
+ // ── Default model IDs per provider ───────────────────────────────────────────
14
+ export const DEFAULT_MODELS = {
15
+ openrouter_free: "openrouter/free",
16
+ openrouter_paid: "anthropic/claude-3.5-sonnet",
17
+ gemini: "gemini-3.1-flash-lite-preview",
18
+ claude: "claude-3-5-sonnet-20241022",
19
+ openai: "gpt-4o-mini",
20
+ groq: "llama-3.3-70b-versatile",
21
+ } as const;
22
+
23
+ // ── Model preference setup ────────────────────────────────────────────────────
24
+
25
+ export async function runModelSetup(
26
+ config: StoredConfig,
27
+ pwd: string
28
+ ): Promise<StoredConfig> {
29
+ console.log(chalk.bold("\n🤖 Model Configuration\n"));
30
+
31
+ const enc = (v: unknown) =>
32
+ v && String(v).trim() ? encrypt(String(v), pwd) : undefined;
33
+
34
+ if (!config.modelOverrides) config.modelOverrides = {};
35
+
36
+ // ── OpenRouter tier (key already collected upfront) ───────────────────────
37
+ const orTier = await select({
38
+ message: "OpenRouter subscription tier:",
39
+ options: [
40
+ { value: "paid", label: "Paid — access claude-3.5-sonnet, gpt-4o, etc." },
41
+ { value: "free", label: "Free — openrouter/free (no credits needed)" },
42
+ { value: "none", label: "Skip — I don't use OpenRouter" },
43
+ ],
44
+ });
45
+ if (isCancel(orTier)) throw new Error("Setup cancelled");
46
+
47
+ if (orTier === "paid") {
48
+ const modelChoice = await select({
49
+ message: "Paid model to use via OpenRouter:",
50
+ options: [
51
+ { value: "default", label: `Default — ${DEFAULT_MODELS.openrouter_paid}` },
52
+ { value: "custom", label: "Custom — I'll enter a model ID" },
53
+ ],
54
+ });
55
+ if (isCancel(modelChoice)) throw new Error("Setup cancelled");
56
+ if (modelChoice === "custom") {
57
+ const customModel = await text({
58
+ message: "Enter OpenRouter model ID (e.g. openai/gpt-4o):",
59
+ placeholder: "provider/model-name",
60
+ validate: clackModelValidator("openrouter"),
61
+ });
62
+ if (isCancel(customModel)) throw new Error("Setup cancelled");
63
+ const trimmed = (customModel as string).trim();
64
+ printModelWarning("openrouter", trimmed);
65
+ config.modelOverrides.openrouter_paid = trimmed;
66
+ }
67
+ }
68
+
69
+ config.openrouterTier = orTier === "none" ? undefined : (orTier as "free" | "paid");
70
+
71
+ // ── Primary model provider (for Agent, Plan, Ask, Browser Agent) ──────────
72
+ console.log(chalk.dim("\n Choose your primary AI model for agent tasks (claude, openai, or gemini).\n"));
73
+
74
+ const primaryProvider = await select({
75
+ message: "Primary model provider:",
76
+ options: [
77
+ { value: "claude", label: `Anthropic Claude (default: ${DEFAULT_MODELS.claude})` },
78
+ { value: "openai", label: `OpenAI (default: ${DEFAULT_MODELS.openai})` },
79
+ { value: "gemini", label: `Google Gemini (default: ${DEFAULT_MODELS.gemini})` },
80
+ { value: "none", label: "Skip — use OpenRouter / Groq only" },
81
+ ],
82
+ });
83
+ if (isCancel(primaryProvider)) throw new Error("Setup cancelled");
84
+
85
+ const primaryKeyFields: Record<string, keyof StoredConfig> = {
86
+ claude: "claudeKey", openai: "openaiKey", gemini: "apiKeyGemini",
87
+ };
88
+ const primaryPlaceholders: Record<string, string> = {
89
+ claude: "sk-ant-...", openai: "sk-...", gemini: "AIza...",
90
+ };
91
+ const primarySources: Record<string, string> = {
92
+ claude: "anthropic.com", openai: "platform.openai.com", gemini: "aistudio.google.com",
93
+ };
94
+
95
+ let primaryProviders: string[] = [];
96
+
97
+ if (primaryProvider !== "none") {
98
+ const p = primaryProvider as string;
99
+ // Ask for key if not stored
100
+ if (!config[primaryKeyFields[p]!]) {
101
+ const k = await text({
102
+ message: `${p} API key (${primarySources[p]}):`,
103
+ placeholder: primaryPlaceholders[p],
104
+ validate: (v) => { if (!v?.trim()) return `${p} API key required`; },
105
+ });
106
+ if (isCancel(k)) throw new Error("Setup cancelled");
107
+ (config as any)[primaryKeyFields[p]!] = enc(k);
108
+ }
109
+
110
+ // Custom model for primary
111
+ const defaultModel = DEFAULT_MODELS[p as keyof typeof DEFAULT_MODELS] as string;
112
+ const mChoice = await select({
113
+ message: `Model for ${p}:`,
114
+ options: [
115
+ { value: "default", label: `Default — ${defaultModel}` },
116
+ { value: "custom", label: "Custom — I'll enter a model ID" },
117
+ ],
118
+ });
119
+ if (isCancel(mChoice)) throw new Error("Setup cancelled");
120
+ if (mChoice === "custom") {
121
+ const customM = await text({
122
+ message: `Enter ${p} model ID:`,
123
+ placeholder: defaultModel,
124
+ validate: clackModelValidator(p as Provider),
125
+ });
126
+ if (isCancel(customM)) throw new Error("Setup cancelled");
127
+ const trimmed = (customM as string).trim();
128
+ printModelWarning(p as Provider, trimmed);
129
+ (config.modelOverrides as any)[p] = trimmed;
130
+ }
131
+ primaryProviders = [p];
132
+ }
133
+
134
+ // ── Fallback providers (optional — pick from the remaining 2) ────────────
135
+ const remaining = (["claude", "openai", "gemini"] as const).filter(
136
+ (p) => p !== primaryProvider
137
+ );
138
+
139
+ const fallbackProviders = await multiselect({
140
+ message: "Optional fallback providers (used if primary fails):",
141
+ options: remaining.map((p) => ({
142
+ value: p,
143
+ label: `${p === "claude" ? "Anthropic Claude" : p === "openai" ? "OpenAI" : "Google Gemini"} (default: ${DEFAULT_MODELS[p]})`,
144
+ })),
145
+ required: false,
146
+ }) as string[];
147
+ if (isCancel(fallbackProviders)) throw new Error("Setup cancelled");
148
+
149
+ for (const p of fallbackProviders) {
150
+ if (!config[primaryKeyFields[p]!]) {
151
+ const k = await text({
152
+ message: `${p} API key (${primarySources[p]}) — fallback:`,
153
+ placeholder: primaryPlaceholders[p],
154
+ });
155
+ if (isCancel(k)) throw new Error("Setup cancelled");
156
+ if (k && String(k).trim()) {
157
+ (config as any)[primaryKeyFields[p]!] = enc(k);
158
+ }
159
+ }
160
+
161
+ const defaultModel = DEFAULT_MODELS[p as keyof typeof DEFAULT_MODELS] as string;
162
+ const mChoice = await select({
163
+ message: `Model for ${p} (fallback):`,
164
+ options: [
165
+ { value: "default", label: `Default — ${defaultModel}` },
166
+ { value: "custom", label: "Custom — I'll enter a model ID" },
167
+ ],
168
+ });
169
+ if (isCancel(mChoice)) throw new Error("Setup cancelled");
170
+ if (mChoice === "custom") {
171
+ const customM = await text({
172
+ message: `Enter ${p} model ID:`,
173
+ placeholder: defaultModel,
174
+ validate: clackModelValidator(p as Provider),
175
+ });
176
+ if (isCancel(customM)) throw new Error("Setup cancelled");
177
+ const trimmed = (customM as string).trim();
178
+ printModelWarning(p as Provider, trimmed);
179
+ (config.modelOverrides as any)[p] = trimmed;
180
+ }
181
+ }
182
+
183
+ // Groq is always included as last-resort fallback (key collected upfront)
184
+ config.preferredProviders = [...primaryProviders, ...fallbackProviders, "groq"];
185
+ return config;
186
+ }
187
+
188
+ // ── Setup ─────────────────────────────────────────────────────────────────────
189
+
190
+ export async function setupAuth(): Promise<AuthResult> {
191
+ console.log(chalk.bold("\n🔐 Initial Setup\n"));
192
+
193
+ const username = await text({
194
+ message: "Create username",
195
+ placeholder: "your-username",
196
+ validate: (v) => {
197
+ if (!v?.trim()) return "Username required";
198
+ if (v.length < 3) return "Min 3 characters";
199
+ if (!/^[a-zA-Z0-9_-]+$/.test(v)) return "Alphanumeric + - _ only";
200
+ },
201
+ });
202
+ if (isCancel(username)) throw new Error("Setup cancelled");
203
+
204
+ const pwd = await password({
205
+ message: "Create password",
206
+ validate: (v) => { if (!v || v.length < 6) return "Min 6 characters"; },
207
+ });
208
+ if (isCancel(pwd)) throw new Error("Setup cancelled");
209
+
210
+ const pwdConfirm = await password({ message: "Confirm password" });
211
+ if (isCancel(pwdConfirm)) throw new Error("Setup cancelled");
212
+
213
+ if (pwd !== pwdConfirm) {
214
+ console.log(chalk.red("❌ Passwords don't match\n"));
215
+ return setupAuth();
216
+ }
217
+
218
+ const enc = (v: unknown) =>
219
+ v && String(v).trim() ? encrypt(String(v), pwd as string) : undefined;
220
+
221
+ console.log(chalk.dim("\n── API Keys ────────────────────────────────────────\n"));
222
+
223
+ // Always required: OpenRouter
224
+ const apiKey = await text({
225
+ message: "OpenRouter API key (openrouter.ai) — press Enter to skip",
226
+ placeholder: "sk-or-v1-...",
227
+ });
228
+ if (isCancel(apiKey)) throw new Error("Setup cancelled");
229
+
230
+ // Always required: Groq (free, used as fallback/scheduler)
231
+ const groqKey = await text({
232
+ message: "Groq API key (console.groq.com — free) — press Enter to skip",
233
+ placeholder: "gsk_...",
234
+ });
235
+ if (isCancel(groqKey)) throw new Error("Setup cancelled");
236
+
237
+ console.log(chalk.dim("\n── Integrations ────────────────────────────────────\n"));
238
+
239
+ const telegramBotToken = await text({
240
+ message: "Telegram Bot Token — press Enter to skip",
241
+ placeholder: "123456:ABC-...",
242
+ });
243
+ if (isCancel(telegramBotToken)) throw new Error("Setup cancelled");
244
+
245
+ const telegramOwnerId = await text({
246
+ message: "Telegram Owner Chat ID — press Enter to skip",
247
+ placeholder: "123456789",
248
+ });
249
+ if (isCancel(telegramOwnerId)) throw new Error("Setup cancelled");
250
+
251
+ console.log(chalk.dim("\n── Supabase (for Scheduler) ────────────────────────\n"));
252
+
253
+ const supabaseUrl = await text({
254
+ message: "Supabase project URL — press Enter to skip",
255
+ placeholder: "https://xxxx.supabase.co",
256
+ });
257
+ if (isCancel(supabaseUrl)) throw new Error("Setup cancelled");
258
+
259
+ const supabaseServiceRoleKey = await text({
260
+ message: "Supabase service role key — press Enter to skip",
261
+ placeholder: "eyJ...",
262
+ });
263
+ if (isCancel(supabaseServiceRoleKey)) throw new Error("Setup cancelled");
264
+
265
+ const supabaseAccessToken = supabaseUrl && String(supabaseUrl).trim()
266
+ ? await text({
267
+ message: "Supabase personal access token (supabase.com/dashboard/account/tokens) — press Enter to skip",
268
+ placeholder: "sbp_...",
269
+ })
270
+ : undefined;
271
+ if (isCancel(supabaseAccessToken)) throw new Error("Setup cancelled");
272
+
273
+ console.log(chalk.dim("\n── Gmail OAuth ─────────────────────────────────────\n"));
274
+
275
+ const googleClientId = await text({
276
+ message: "Google OAuth Client ID — press Enter to skip",
277
+ placeholder: "xxxx.apps.googleusercontent.com",
278
+ });
279
+ if (isCancel(googleClientId)) throw new Error("Setup cancelled");
280
+
281
+ const googleClientSecret = await text({
282
+ message: "Google OAuth Client Secret — press Enter to skip",
283
+ placeholder: "GOCSPX-...",
284
+ });
285
+ if (isCancel(googleClientSecret)) throw new Error("Setup cancelled");
286
+
287
+ console.log(chalk.dim("\n── Web / Browser ───────────────────────────────────\n"));
288
+
289
+ const firecrawlKey = await text({
290
+ message: "Firecrawl API key — press Enter to skip",
291
+ placeholder: "fc-...",
292
+ });
293
+ if (isCancel(firecrawlKey)) throw new Error("Setup cancelled");
294
+
295
+ const apifyKey = await text({
296
+ message: "Apify API key — press Enter to skip",
297
+ placeholder: "apify_api_...",
298
+ });
299
+ if (isCancel(apifyKey)) throw new Error("Setup cancelled");
300
+
301
+ const browserbaseApiKey = await text({
302
+ message: "Browserbase API key — press Enter to skip",
303
+ placeholder: "bb_live_...",
304
+ });
305
+ if (isCancel(browserbaseApiKey)) throw new Error("Setup cancelled");
306
+
307
+ const browserbaseProjectId = await text({
308
+ message: "Browserbase Project ID — press Enter to skip",
309
+ placeholder: "xxxxxxxx-...",
310
+ });
311
+ if (isCancel(browserbaseProjectId)) throw new Error("Setup cancelled");
312
+
313
+ let config: StoredConfig = {
314
+ username: username as string,
315
+ passwordHash: hashPassword(pwd as string),
316
+ apiKey: enc(apiKey) ?? "",
317
+ apiKeyGemini: "", // collected in model setup if gemini selected
318
+ groqKey: enc(groqKey),
319
+ telegramBotToken: enc(telegramBotToken),
320
+ telegramOwnerId: enc(telegramOwnerId),
321
+ supabaseUrl: enc(supabaseUrl),
322
+ supabaseServiceRoleKey: enc(supabaseServiceRoleKey),
323
+ supabaseAccessToken: enc(supabaseAccessToken),
324
+ googleClientId: enc(googleClientId),
325
+ googleClientSecret: enc(googleClientSecret),
326
+ firecrawlKey: enc(firecrawlKey),
327
+ apifyKey: enc(apifyKey),
328
+ browserbaseApiKey: enc(browserbaseApiKey),
329
+ browserbaseProjectId: enc(browserbaseProjectId),
330
+ lastLogin: Date.now(),
331
+ };
332
+
333
+ // Model selection
334
+ config = await runModelSetup(config, pwd as string);
335
+
336
+ saveConfig(config);
337
+ console.log(chalk.green("\n✓ Setup complete!\n"));
338
+
339
+ // Auto-migrate Supabase schema
340
+ const rawUrl = supabaseUrl ? String(supabaseUrl).trim() : "";
341
+ const rawKey = supabaseServiceRoleKey ? String(supabaseServiceRoleKey).trim() : "";
342
+ const rawToken = supabaseAccessToken ? String(supabaseAccessToken).trim() : "";
343
+ if (rawUrl && rawKey && rawToken) {
344
+ try {
345
+ const { runDbMigrations } = await import("../scheduler/db-migrate");
346
+ await runDbMigrations(rawUrl, rawKey, rawToken);
347
+ } catch (err) {
348
+ console.log(chalk.yellow(`⚠ Auto-migration failed: ${err instanceof Error ? err.message : String(err)}`));
349
+ console.log(chalk.dim(" Run scheduler/SETUP-READY.sql manually or `jerob setup-db` later.\n"));
350
+ }
351
+ } else if (rawUrl && rawKey && !rawToken) {
352
+ console.log(chalk.dim("\n Skipping auto-setup (no personal access token provided)."));
353
+ console.log(chalk.dim(" Run `jerob setup-db` after adding your token.\n"));
354
+ }
355
+
356
+ return { config, password: pwd as string };
357
+ }
358
+
359
+ // ── Login ─────────────────────────────────────────────────────────────────────
360
+
361
+ export async function loginAuth(): Promise<AuthResult> {
362
+ const config = loadConfig();
363
+ if (!config) return setupAuth();
364
+
365
+ console.log(chalk.bold(`\n🔑 Login\n`));
366
+
367
+ const enteredUsername = await text({
368
+ message: "Username",
369
+ placeholder: config.username,
370
+ });
371
+ if (isCancel(enteredUsername)) throw new Error("Login cancelled");
372
+
373
+ if (enteredUsername !== config.username) {
374
+ console.log(chalk.red("❌ Username mismatch\n"));
375
+ return loginAuth();
376
+ }
377
+
378
+ const enteredPassword = await password({ message: "Password" });
379
+ if (isCancel(enteredPassword)) throw new Error("Login cancelled");
380
+
381
+ if (!verifyPassword(enteredPassword as string, config.passwordHash)) {
382
+ console.log(chalk.red("❌ Wrong password\n"));
383
+ return loginAuth();
384
+ }
385
+
386
+ config.lastLogin = Date.now();
387
+ saveConfig(config);
388
+ console.log(chalk.green(`\n✓ Welcome back, ${config.username}!\n`));
389
+ return { config, password: enteredPassword as string };
390
+ }
391
+
392
+ // ── Key decryption ────────────────────────────────────────────────────────────
393
+
394
+ export function getAllKeys(config: StoredConfig, pwd: string): Record<string, string> {
395
+ const dec = (v: string | undefined) => (v ? decrypt(v, pwd) : "");
396
+ try {
397
+ const keys: Record<string, string> = {
398
+ OPENROUTER_KEY: dec(config.apiKey),
399
+ GOOGLE_GENERATIVE_AI_API_KEY: dec(config.apiKeyGemini),
400
+ GROQ_API_KEY: dec(config.groqKey),
401
+ ANTHROPIC_API_KEY: dec(config.claudeKey),
402
+ OPENAI_API_KEY: dec(config.openaiKey),
403
+ TELEGRAM_BOT_TOKEN: dec(config.telegramBotToken),
404
+ TELEGRAM_OWNER_ID: dec(config.telegramOwnerId),
405
+ SUPABASE_URL: dec(config.supabaseUrl),
406
+ SUPABASE_SERVICE_ROLE_KEY: dec(config.supabaseServiceRoleKey),
407
+ SUPABASE_ACCESS_TOKEN: dec(config.supabaseAccessToken),
408
+ GOOGLE_CLIENT_ID: dec(config.googleClientId),
409
+ GOOGLE_CLIENT_SECRET: dec(config.googleClientSecret),
410
+ FIRECRAWL_KEY: dec(config.firecrawlKey),
411
+ APIFY_API_KEY: dec(config.apifyKey),
412
+ BROWSERBASE_API_KEY: dec(config.browserbaseApiKey),
413
+ BROWSERBASE_PRODUCT_ID: dec(config.browserbaseProjectId),
414
+ OPENROUTER_TIER: config.openrouterTier ?? "free",
415
+ PREFERRED_PROVIDERS: (config.preferredProviders ?? ["groq"]).join(","),
416
+ PORT: "8787",
417
+ };
418
+
419
+ const ov = config.modelOverrides ?? {};
420
+ if (ov.openrouter_paid) keys["MODEL_OPENROUTER_PAID"] = ov.openrouter_paid;
421
+ if (ov.gemini) keys["MODEL_GEMINI"] = ov.gemini;
422
+ if (ov.claude) keys["MODEL_CLAUDE"] = ov.claude;
423
+ if (ov.openai) keys["MODEL_OPENAI"] = ov.openai;
424
+ if (ov.groq) keys["MODEL_GROQ"] = ov.groq;
425
+
426
+ return keys;
427
+ } catch {
428
+ throw new Error("Failed to decrypt keys — wrong password?");
429
+ }
430
+ }
431
+
432
+ /** @deprecated use getAllKeys */
433
+ export function getApiKey(config: StoredConfig, pwd: string): string[] {
434
+ const keys = getAllKeys(config, pwd);
435
+ return [keys.OPENROUTER_KEY!, keys.GOOGLE_GENERATIVE_AI_API_KEY!];
436
+ }
437
+
438
+ // ── Switch model flow ─────────────────────────────────────────────────────────
439
+
440
+ export async function switchModelFlow(config: StoredConfig, pwd: string): Promise<void> {
441
+ if (!config.modelOverrides) config.modelOverrides = {};
442
+
443
+ const provider = await select({
444
+ message: "Which provider's model do you want to switch?",
445
+ options: [
446
+ { value: "openrouter_paid", label: `OpenRouter (paid) — current: ${config.modelOverrides.openrouter_paid ?? DEFAULT_MODELS.openrouter_paid}` },
447
+ { value: "gemini", label: `Google Gemini — current: ${config.modelOverrides.gemini ?? DEFAULT_MODELS.gemini}` },
448
+ { value: "claude", label: `Anthropic Claude — current: ${config.modelOverrides.claude ?? DEFAULT_MODELS.claude}` },
449
+ { value: "openai", label: `OpenAI — current: ${config.modelOverrides.openai ?? DEFAULT_MODELS.openai}` },
450
+ { value: "groq", label: `Groq — current: ${config.modelOverrides.groq ?? DEFAULT_MODELS.groq}` },
451
+ ],
452
+ });
453
+ if (isCancel(provider)) throw new Error("Switch cancelled");
454
+
455
+ const p = provider as string;
456
+ const defaultId = DEFAULT_MODELS[p as keyof typeof DEFAULT_MODELS] as string;
457
+
458
+ const choice = await select({
459
+ message: `Model for ${p}:`,
460
+ options: [
461
+ { value: "default", label: `Reset to default — ${defaultId}` },
462
+ { value: "custom", label: "Enter a custom model ID" },
463
+ ],
464
+ });
465
+ if (isCancel(choice)) throw new Error("Switch cancelled");
466
+
467
+ if (choice === "default") {
468
+ delete (config.modelOverrides as any)[p];
469
+ saveConfig(config);
470
+ console.log(chalk.green(`\n✓ ${p} reset to default: ${defaultId}\n`));
471
+ return;
472
+ }
473
+
474
+ const validatorProvider = (p === "openrouter_paid" ? "openrouter" : p) as Provider;
475
+ const newModel = await text({
476
+ message: `Enter model ID for ${p}:`,
477
+ placeholder: defaultId,
478
+ validate: clackModelValidator(validatorProvider),
479
+ });
480
+ if (isCancel(newModel)) throw new Error("Switch cancelled");
481
+
482
+ const trimmed = (newModel as string).trim();
483
+ printModelWarning(validatorProvider, trimmed);
484
+ (config.modelOverrides as any)[p] = trimmed;
485
+ saveConfig(config);
486
+ console.log(chalk.green(`\n✓ ${p} switched to: ${trimmed}\n`));
487
+ console.log(chalk.dim("Run `jerob jet` to apply.\n"));
488
+ }
489
+
490
+ // ── Update flows ──────────────────────────────────────────────────────────────
491
+
492
+ export async function updateApiKey(): Promise<void> {
493
+ const config = loadConfig();
494
+ if (!config) { await setupAuth(); return; }
495
+
496
+ console.log(chalk.bold("\n🔄 Update API Keys\n"));
497
+
498
+ const enteredUsername = await text({ message: "Username", placeholder: config.username });
499
+ if (isCancel(enteredUsername)) throw new Error("Update cancelled");
500
+ if (enteredUsername !== config.username) {
501
+ console.log(chalk.red("❌ Username mismatch\n"));
502
+ return updateApiKey();
503
+ }
504
+
505
+ const enteredPassword = await password({ message: "Password" });
506
+ if (isCancel(enteredPassword)) throw new Error("Update cancelled");
507
+ if (!verifyPassword(enteredPassword as string, config.passwordHash)) {
508
+ console.log(chalk.red("❌ Wrong password\n"));
509
+ return updateApiKey();
510
+ }
511
+
512
+ const which = await select({
513
+ message: "What to update?",
514
+ options: [
515
+ { value: "openrouter", label: "OpenRouter API key" },
516
+ { value: "groq", label: "Groq API key" },
517
+ { value: "gemini", label: "Google Gemini API key" },
518
+ { value: "claude", label: "Anthropic Claude API key" },
519
+ { value: "openai", label: "OpenAI API key" },
520
+ { value: "models", label: "Model preferences + provider order" },
521
+ { value: "switch", label: "Switch active model for a provider" },
522
+ ],
523
+ });
524
+ if (isCancel(which)) throw new Error("Update cancelled");
525
+
526
+ const enc = (v: string) => encrypt(v, enteredPassword as string);
527
+
528
+ if (which === "models") {
529
+ const updated = await runModelSetup(config, enteredPassword as string);
530
+ saveConfig(updated);
531
+ console.log(chalk.green("\n✓ Model preferences updated!\n"));
532
+ return;
533
+ }
534
+
535
+ if (which === "switch") {
536
+ await switchModelFlow(config, enteredPassword as string);
537
+ return;
538
+ }
539
+
540
+ const placeholders: Record<string, string> = {
541
+ openrouter: "sk-or-v1-...", gemini: "AIza...",
542
+ claude: "sk-ant-...", openai: "sk-...", groq: "gsk_...",
543
+ };
544
+
545
+ const newKey = await text({
546
+ message: `New ${which} API key:`,
547
+ placeholder: placeholders[which as string] ?? "...",
548
+ validate: (v) => { if (!v?.trim()) return "Key required"; },
549
+ });
550
+ if (isCancel(newKey)) throw new Error("Update cancelled");
551
+
552
+ const keyMap: Record<string, keyof typeof config> = {
553
+ openrouter: "apiKey", gemini: "apiKeyGemini",
554
+ claude: "claudeKey", openai: "openaiKey", groq: "groqKey",
555
+ };
556
+
557
+ (config as any)[keyMap[which as string]!] = enc(newKey as string);
558
+ config.lastLogin = Date.now();
559
+ saveConfig(config);
560
+ console.log(chalk.green(`\n✓ ${which} key updated!\n`));
561
+ }
562
+
563
+ export function resetAuth(): void { removeConfig(); }
564
+
565
+ export async function authenticate(): Promise<AuthResult> {
566
+ return isConfigured() ? await loginAuth() : await setupAuth();
567
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ const CONFIG_DIR = path.join(homedir(), ".cccontrol");
6
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
7
+
8
+ export interface StoredConfig {
9
+ username: string;
10
+ passwordHash: string;
11
+ // encrypted keys
12
+ apiKey: string; // OpenRouter
13
+ apiKeyGemini: string; // Google Gemini
14
+ groqKey?: string;
15
+ claudeKey?: string; // Anthropic Claude
16
+ openaiKey?: string; // OpenAI
17
+ telegramBotToken?: string;
18
+ telegramOwnerId?: string;
19
+ supabaseUrl?: string;
20
+ supabaseServiceRoleKey?: string;
21
+ supabaseAccessToken?: string; // personal access token for Management API (setup only)
22
+ googleClientId?: string;
23
+ googleClientSecret?: string;
24
+ firecrawlKey?: string;
25
+ apifyKey?: string;
26
+ browserbaseApiKey?: string;
27
+ browserbaseProjectId?: string;
28
+ // model preferences
29
+ openrouterTier?: "free" | "paid"; // which OpenRouter tier to use
30
+ preferredProviders?: string[]; // ordered list: gemini, claude, openai, groq
31
+ // per-provider model overrides (encrypted)
32
+ modelOverrides?: {
33
+ openrouter_paid?: string; // e.g. "openai/gpt-4o"
34
+ gemini?: string; // e.g. "gemini-1.5-pro"
35
+ claude?: string; // e.g. "claude-3-opus-20240229"
36
+ openai?: string; // e.g. "gpt-4o"
37
+ groq?: string; // e.g. "mixtral-8x7b-32768"
38
+ };
39
+ lastLogin: number;
40
+ }
41
+
42
+ export function ensureConfigDir(): void {
43
+ if (!fs.existsSync(CONFIG_DIR)) {
44
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
45
+ }
46
+ }
47
+
48
+ export function loadConfig(): StoredConfig | null {
49
+ ensureConfigDir();
50
+ if (!fs.existsSync(CONFIG_FILE)) return null;
51
+ try {
52
+ const raw = fs.readFileSync(CONFIG_FILE, "utf8");
53
+ return JSON.parse(raw) as StoredConfig;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function saveConfig(config: StoredConfig): void {
60
+ ensureConfigDir();
61
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
62
+ fs.chmodSync(CONFIG_FILE, 0o600);
63
+ }
64
+
65
+ export function isConfigured(): boolean {
66
+ return loadConfig() !== null;
67
+ }
68
+
69
+ export function getConfigPath(): string {
70
+ return CONFIG_FILE;
71
+ }
72
+
73
+ export function removeConfig(): void {
74
+ if (fs.existsSync(CONFIG_FILE)) {
75
+ fs.unlinkSync(CONFIG_FILE);
76
+ }
77
+ }
package/auth/crypto.ts ADDED
@@ -0,0 +1,51 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const ALGORITHM = "aes-256-cbc";
4
+ const ITERATIONS = 100000;
5
+ const KEY_LENGTH = 32;
6
+
7
+ /**
8
+ * Hash a password using PBKDF2
9
+ */
10
+ export function hashPassword(password: string): string {
11
+ const salt = crypto.randomBytes(16);
12
+ const hash = crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, "sha256");
13
+ return `${salt.toString("hex")}:${hash.toString("hex")}`;
14
+ }
15
+
16
+ /**
17
+ * Verify a password against a hash
18
+ */
19
+ export function verifyPassword(password: string, hash: string): boolean {
20
+ const [saltHex, hashHex] = hash.split(":");
21
+ if (!saltHex || !hashHex) return false;
22
+ const salt = Buffer.from(saltHex, "hex");
23
+ const computed = crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, "sha256");
24
+ return computed.toString("hex") === hashHex;
25
+ }
26
+
27
+ /**
28
+ * Encrypt sensitive data (API keys, etc.)
29
+ */
30
+ export function encrypt(plaintext: string, masterPassword: string): string {
31
+ const iv = crypto.randomBytes(16);
32
+ const key = crypto.scryptSync(masterPassword, "salt", KEY_LENGTH);
33
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
34
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
35
+ encrypted += cipher.final("hex");
36
+ return `${iv.toString("hex")}:${encrypted}`;
37
+ }
38
+
39
+ /**
40
+ * Decrypt sensitive data
41
+ */
42
+ export function decrypt(ciphertext: string, masterPassword: string): string {
43
+ const [ivHex, encrypted] = ciphertext.split(":");
44
+ if (!ivHex || !encrypted) throw new Error("Invalid ciphertext format");
45
+ const iv = Buffer.from(ivHex, "hex");
46
+ const key = crypto.scryptSync(masterPassword, "salt", KEY_LENGTH);
47
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
48
+ let decrypted = decipher.update(encrypted, "hex", "utf8");
49
+ decrypted += decipher.final("utf8");
50
+ return decrypted;
51
+ }