vibespot 0.4.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/ui/settings.js ADDED
@@ -0,0 +1,927 @@
1
+ /**
2
+ * Settings panel — environment detection, AI engine selection, API keys, tool install, auth flows.
3
+ */
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // State
7
+ // ---------------------------------------------------------------------------
8
+
9
+ let settingsData = null;
10
+ const activePolls = {};
11
+
12
+ const ENGINE_LABELS = {
13
+ "claude-code": "Claude Code",
14
+ "anthropic-api": "Anthropic API",
15
+ "openai-api": "OpenAI API",
16
+ "gemini-cli": "Gemini CLI",
17
+ "gemini-api": "Gemini API",
18
+ "codex-cli": "OpenAI Codex",
19
+ };
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Open / Close
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function openSettings() {
26
+ // Close menu if open
27
+ if (typeof closeMenu === "function") closeMenu();
28
+ document.getElementById("settings-overlay").classList.remove("hidden");
29
+ refreshSettings();
30
+ }
31
+
32
+ function closeSettings() {
33
+ document.getElementById("settings-overlay").classList.add("hidden");
34
+ Object.keys(activePolls).forEach((id) => {
35
+ clearInterval(activePolls[id]);
36
+ delete activePolls[id];
37
+ });
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Fetch and render
42
+ // ---------------------------------------------------------------------------
43
+
44
+ async function refreshSettings() {
45
+ const body = document.getElementById("settings-body");
46
+ body.innerHTML = `<div class="settings__loading"><div class="settings__spinner-lg"></div><span>Loading environment...</span></div>`;
47
+
48
+ try {
49
+ const res = await fetch("/api/settings/status");
50
+ settingsData = await res.json();
51
+ renderSettings(settingsData);
52
+ } catch (err) {
53
+ body.innerHTML = `<div class="settings__loading" style="color:var(--error)">Failed to load settings</div>`;
54
+ }
55
+ }
56
+
57
+ function renderSettings(data) {
58
+ const body = document.getElementById("settings-body");
59
+ const env = data.environment;
60
+ const config = data.config;
61
+
62
+ body.innerHTML = "";
63
+
64
+ // --- AI Engines section ---
65
+ const aiSection = el("section", "settings__section");
66
+ aiSection.appendChild(sectionTitle("AI Engine"));
67
+
68
+ // Engine selector pills
69
+ const selectEl = el("div", "settings__engine-select");
70
+ const allEngines = [
71
+ { id: "claude-code", label: "Claude Code" },
72
+ { id: "anthropic-api", label: "Anthropic API" },
73
+ { id: "openai-api", label: "OpenAI API" },
74
+ { id: "gemini-cli", label: "Gemini CLI" },
75
+ { id: "gemini-api", label: "Gemini API" },
76
+ { id: "codex-cli", label: "Codex CLI" },
77
+ ];
78
+
79
+ for (const eng of allEngines) {
80
+ const available = env.availableEngines.includes(eng.id);
81
+ const btn = el("button", "settings__engine-option");
82
+ btn.textContent = eng.label;
83
+ btn.disabled = !available;
84
+ if (config.aiEngine === eng.id) btn.classList.add("active");
85
+ if (!config.aiEngine && available && env.availableEngines[0] === eng.id) {
86
+ // Highlight first available if none selected
87
+ }
88
+ btn.addEventListener("click", () => setEngine(eng.id));
89
+ selectEl.appendChild(btn);
90
+ }
91
+ aiSection.appendChild(selectEl);
92
+
93
+ // Model selector (for the active engine)
94
+ const activeEngine = config.aiEngine || (env.availableEngines.length > 0 ? env.availableEngines[0] : null);
95
+ if (activeEngine) {
96
+ const modelRow = el("div", "settings__model-row");
97
+ const modelLabel = el("span", "settings__card-label");
98
+ modelLabel.textContent = "Model";
99
+ modelRow.appendChild(modelLabel);
100
+
101
+ const modelSelect = el("select", "settings__model-select");
102
+ const models = getModelsForEngine(activeEngine);
103
+ const currentModel = getCurrentModel(activeEngine, config);
104
+
105
+ for (const m of models) {
106
+ const opt = document.createElement("option");
107
+ opt.value = m.id;
108
+ opt.textContent = m.label;
109
+ if (m.id === currentModel) opt.selected = true;
110
+ modelSelect.appendChild(opt);
111
+ }
112
+
113
+ // Custom model option
114
+ const customOpt = document.createElement("option");
115
+ customOpt.value = "__custom__";
116
+ customOpt.textContent = "Custom...";
117
+ if (currentModel && !models.find((m) => m.id === currentModel)) {
118
+ customOpt.selected = true;
119
+ }
120
+ modelSelect.appendChild(customOpt);
121
+
122
+ modelSelect.addEventListener("change", async () => {
123
+ if (modelSelect.value === "__custom__") {
124
+ const custom = await vibePrompt("Enter model name");
125
+ if (custom) setEngineModel(activeEngine, custom);
126
+ else refreshSettings();
127
+ } else {
128
+ setEngineModel(activeEngine, modelSelect.value);
129
+ }
130
+ });
131
+
132
+ modelRow.appendChild(modelSelect);
133
+ aiSection.appendChild(modelRow);
134
+ }
135
+
136
+ // CLI tools subsection
137
+ aiSection.appendChild(subsectionTitle("CLI Tools"));
138
+ const cliTools = [
139
+ { key: "claudeCode", name: "Claude Code", installId: "claude", url: "https://claude.ai/code" },
140
+ { key: "geminiCli", name: "Gemini CLI", installId: "gemini", url: "https://github.com/google-gemini/gemini-cli" },
141
+ { key: "codexCli", name: "Codex CLI", installId: "codex", url: "https://github.com/openai/codex" },
142
+ ];
143
+
144
+ for (const tool of cliTools) {
145
+ const info = env.tools[tool.key];
146
+ aiSection.appendChild(createToolCard(tool.name, info, tool.installId, tool.url));
147
+ }
148
+
149
+ // API keys subsection
150
+ aiSection.appendChild(subsectionTitle("API Keys"));
151
+ const providers = [
152
+ { key: "anthropic", name: "Anthropic", placeholder: "sk-ant-api03-..." },
153
+ { key: "openai", name: "OpenAI", placeholder: "sk-..." },
154
+ { key: "gemini", name: "Google AI", placeholder: "AIza..." },
155
+ ];
156
+
157
+ for (const prov of providers) {
158
+ const keyInfo = env.apiKeys[prov.key];
159
+ aiSection.appendChild(createApiKeyCard(prov.key, prov.name, prov.placeholder, keyInfo));
160
+ }
161
+
162
+ body.appendChild(aiSection);
163
+
164
+ // --- HubSpot section ---
165
+ const hsSection = el("section", "settings__section");
166
+ hsSection.appendChild(sectionTitle("HubSpot"));
167
+ hsSection.appendChild(createHubSpotCard(env.tools.hubspot));
168
+ body.appendChild(hsSection);
169
+
170
+ // --- GitHub section ---
171
+ const ghSection = el("section", "settings__section");
172
+ ghSection.appendChild(sectionTitle("GitHub"));
173
+ ghSection.appendChild(createGitHubCard(env.tools.github));
174
+ body.appendChild(ghSection);
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Tool card
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function createToolCard(name, info, installId, url) {
182
+ const card = el("div", "settings__card");
183
+
184
+ const row = el("div", "settings__card-row");
185
+
186
+ if (info.found) {
187
+ row.appendChild(dot(info.authenticated ? "success" : "warn"));
188
+ } else {
189
+ row.appendChild(dot("muted"));
190
+ }
191
+
192
+ const label = el("span", "settings__card-label");
193
+ label.textContent = name;
194
+ row.appendChild(label);
195
+
196
+ if (info.found) {
197
+ const meta = el("span", "settings__card-meta");
198
+ meta.textContent = `v${info.version}`;
199
+ row.appendChild(meta);
200
+ } else {
201
+ const installBtn = el("button", "settings__btn");
202
+ installBtn.textContent = "Install";
203
+ installBtn.addEventListener("click", () => installTool(installId, installBtn));
204
+ row.appendChild(installBtn);
205
+
206
+ if (url) {
207
+ const link = el("a", "settings__btn");
208
+ link.textContent = "Docs";
209
+ link.href = url;
210
+ link.target = "_blank";
211
+ link.style.textDecoration = "none";
212
+ row.appendChild(link);
213
+ }
214
+ }
215
+
216
+ card.appendChild(row);
217
+
218
+ // Auth action row for installed but not authenticated CLI tools
219
+ if (info.found && !info.authenticated) {
220
+ const authRow = el("div", "settings__card-row settings__card-row--sub");
221
+
222
+ {
223
+ // Claude Code / Gemini CLI — browser-based sign in
224
+ const authLabel = el("span", "settings__card-meta");
225
+ authLabel.textContent = info.authDetail || "Not authenticated";
226
+ authLabel.style.color = "var(--warning, #f59e0b)";
227
+ authRow.appendChild(authLabel);
228
+
229
+ const authBtn = el("button", "settings__btn settings__btn--primary");
230
+ authBtn.textContent = "Sign in";
231
+ authBtn.addEventListener("click", () => authCLI(installId, authBtn));
232
+ authRow.appendChild(authBtn);
233
+
234
+ card.appendChild(authRow);
235
+ }
236
+ } else if (info.found && info.authenticated) {
237
+ const authRow = el("div", "settings__card-row settings__card-row--sub");
238
+ const authLabel = el("span", "settings__card-meta");
239
+ authLabel.textContent = "Authenticated";
240
+ authLabel.style.color = "var(--success, #22c55e)";
241
+ authRow.appendChild(authLabel);
242
+ card.appendChild(authRow);
243
+ }
244
+
245
+ return card;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // API key card
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function createApiKeyCard(provider, name, placeholder, keyInfo) {
253
+ const card = el("div", "settings__apikey-row");
254
+
255
+ const label = el("span", "settings__apikey-label");
256
+ label.textContent = name;
257
+ card.appendChild(label);
258
+
259
+ if (keyInfo.configured) {
260
+ // Show masked key + edit/clear buttons
261
+ const value = el("span", "settings__apikey-value");
262
+ value.textContent = keyInfo.masked;
263
+ card.appendChild(value);
264
+
265
+ const actions = el("div", "settings__apikey-actions");
266
+
267
+ const editBtn = el("button", "settings__btn");
268
+ editBtn.textContent = "Edit";
269
+ editBtn.addEventListener("click", () => {
270
+ // Replace card content with input
271
+ showApiKeyInput(card, provider, name, placeholder);
272
+ });
273
+ actions.appendChild(editBtn);
274
+
275
+ const clearBtn = el("button", "settings__btn");
276
+ clearBtn.textContent = "Clear";
277
+ clearBtn.addEventListener("click", async () => {
278
+ await fetch("/api/settings/apikey", {
279
+ method: "POST",
280
+ headers: { "Content-Type": "application/json" },
281
+ body: JSON.stringify({ provider, apiKey: null }),
282
+ });
283
+ refreshSettings();
284
+ });
285
+ actions.appendChild(clearBtn);
286
+
287
+ card.appendChild(actions);
288
+ } else {
289
+ showApiKeyInput(card, provider, name, placeholder);
290
+ }
291
+
292
+ return card;
293
+ }
294
+
295
+ function showApiKeyInput(container, provider, name, placeholder) {
296
+ // Clear existing content except label
297
+ const label = container.querySelector(".settings__apikey-label");
298
+ container.innerHTML = "";
299
+ if (label) container.appendChild(label);
300
+ else {
301
+ const lbl = el("span", "settings__apikey-label");
302
+ lbl.textContent = name;
303
+ container.appendChild(lbl);
304
+ }
305
+
306
+ const input = el("input", "settings__apikey-input");
307
+ input.type = "password";
308
+ input.placeholder = placeholder;
309
+ container.appendChild(input);
310
+
311
+ const saveBtn = el("button", "settings__btn settings__btn--primary");
312
+ saveBtn.textContent = "Save";
313
+ saveBtn.addEventListener("click", () => saveApiKey(provider, input.value, saveBtn));
314
+ container.appendChild(saveBtn);
315
+
316
+ input.addEventListener("keydown", (e) => {
317
+ if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
318
+ });
319
+
320
+ input.focus();
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // HubSpot card
325
+ // ---------------------------------------------------------------------------
326
+
327
+ function createHubSpotCard(hs) {
328
+ const card = el("div", "settings__card");
329
+
330
+ // CLI status
331
+ const cliRow = el("div", "settings__card-row");
332
+ cliRow.appendChild(dot(hs.found ? "success" : "warn"));
333
+ const cliLabel = el("span", "settings__card-label");
334
+ cliLabel.textContent = "HubSpot CLI";
335
+ cliRow.appendChild(cliLabel);
336
+
337
+ if (hs.found) {
338
+ const meta = el("span", "settings__card-meta");
339
+ meta.textContent = `v${hs.version}`;
340
+ cliRow.appendChild(meta);
341
+ } else {
342
+ const installBtn = el("button", "settings__btn");
343
+ installBtn.textContent = "Install";
344
+ installBtn.addEventListener("click", () => installTool("hubspot", installBtn));
345
+ cliRow.appendChild(installBtn);
346
+ }
347
+ card.appendChild(cliRow);
348
+
349
+ // Accounts list (if CLI is installed)
350
+ if (hs.found) {
351
+ const accounts = hs.accounts || [];
352
+
353
+ if (accounts.length > 0) {
354
+ // Show each account with switch/remove actions
355
+ accounts.forEach((acct) => {
356
+ const row = el("div", "settings__card-row");
357
+ row.appendChild(dot(acct.isDefault ? "success" : "muted"));
358
+ const label = el("span", "settings__card-label");
359
+ label.textContent = `${acct.name} (${acct.portalId})`;
360
+ if (acct.isDefault) label.textContent += " — active";
361
+ row.appendChild(label);
362
+
363
+ const actions = el("span", "settings__card-actions");
364
+
365
+ if (!acct.isDefault) {
366
+ const useBtn = el("button", "settings__btn settings__btn--small");
367
+ useBtn.textContent = "Use";
368
+ useBtn.addEventListener("click", () => switchHsAccount(acct.portalId, useBtn));
369
+ actions.appendChild(useBtn);
370
+ }
371
+
372
+ const removeBtn = el("button", "settings__btn settings__btn--small settings__btn--danger");
373
+ removeBtn.textContent = "Remove";
374
+ removeBtn.addEventListener("click", () => removeHsAccount(acct.portalId, removeBtn));
375
+ actions.appendChild(removeBtn);
376
+
377
+ row.appendChild(actions);
378
+ card.appendChild(row);
379
+ });
380
+ } else if (!hs.authenticated) {
381
+ const authRow = el("div", "settings__card-row");
382
+ authRow.appendChild(dot("warn"));
383
+ const authLabel = el("span", "settings__card-label");
384
+ authLabel.textContent = "Not authenticated";
385
+ authRow.appendChild(authLabel);
386
+ card.appendChild(authRow);
387
+ }
388
+
389
+ // "Add account" button (always available when CLI is installed)
390
+ const addRow = el("div", "settings__card-row");
391
+ const addBtn = el("button", "settings__btn settings__btn--primary");
392
+ addBtn.textContent = "Add Account";
393
+ addBtn.addEventListener("click", () => startHsAuth(addBtn, card));
394
+ addRow.appendChild(addBtn);
395
+ card.appendChild(addRow);
396
+ }
397
+
398
+ return card;
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // GitHub card
403
+ // ---------------------------------------------------------------------------
404
+
405
+ function createGitHubCard(gh) {
406
+ const card = el("div", "settings__card");
407
+
408
+ // CLI status
409
+ const cliRow = el("div", "settings__card-row");
410
+ cliRow.appendChild(dot(gh.found ? "success" : "muted"));
411
+ const cliLabel = el("span", "settings__card-label");
412
+ cliLabel.textContent = "GitHub CLI";
413
+ cliRow.appendChild(cliLabel);
414
+
415
+ if (gh.found) {
416
+ const meta = el("span", "settings__card-meta");
417
+ meta.textContent = `v${gh.version}`;
418
+ cliRow.appendChild(meta);
419
+ } else {
420
+ const installBtn = el("button", "settings__btn");
421
+ installBtn.textContent = "Install";
422
+ installBtn.addEventListener("click", () => installTool("gh", installBtn));
423
+ cliRow.appendChild(installBtn);
424
+ }
425
+ card.appendChild(cliRow);
426
+
427
+ // Auth status
428
+ if (gh.found) {
429
+ const authRow = el("div", "settings__card-row");
430
+ authRow.appendChild(dot(gh.authenticated ? "success" : "muted"));
431
+ const authLabel = el("span", "settings__card-label");
432
+ authLabel.textContent = gh.authenticated
433
+ ? `Logged in as ${gh.username}`
434
+ : "Not authenticated";
435
+ authRow.appendChild(authLabel);
436
+
437
+ const actions = el("span", "settings__card-actions");
438
+
439
+ if (gh.authenticated) {
440
+ // Switch account = logout + login
441
+ const switchBtn = el("button", "settings__btn settings__btn--small");
442
+ switchBtn.textContent = "Switch";
443
+ switchBtn.addEventListener("click", () => switchGhAccount(switchBtn));
444
+ actions.appendChild(switchBtn);
445
+
446
+ const logoutBtn = el("button", "settings__btn settings__btn--small settings__btn--danger");
447
+ logoutBtn.textContent = "Log out";
448
+ logoutBtn.addEventListener("click", () => logoutGh(logoutBtn));
449
+ actions.appendChild(logoutBtn);
450
+ } else {
451
+ const authBtn = el("button", "settings__btn settings__btn--primary");
452
+ authBtn.textContent = "Log in";
453
+ authBtn.addEventListener("click", () => startGhAuth(authBtn));
454
+ actions.appendChild(authBtn);
455
+ }
456
+
457
+ authRow.appendChild(actions);
458
+ card.appendChild(authRow);
459
+ }
460
+
461
+ return card;
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Actions
466
+ // ---------------------------------------------------------------------------
467
+
468
+ async function setEngine(engineId) {
469
+ await fetch("/api/settings/engine", {
470
+ method: "POST",
471
+ headers: { "Content-Type": "application/json" },
472
+ body: JSON.stringify({ engine: engineId }),
473
+ });
474
+
475
+ // Update statusbar engine label
476
+ const statusEngine = document.getElementById("status-engine");
477
+ if (statusEngine) {
478
+ statusEngine.textContent = ENGINE_LABELS[engineId] || engineId;
479
+ }
480
+
481
+ refreshSettings();
482
+ }
483
+
484
+ async function saveApiKey(provider, key, btn) {
485
+ if (!key || !key.trim()) return;
486
+ btn.disabled = true;
487
+ btn.textContent = "Saving...";
488
+
489
+ try {
490
+ const res = await fetch("/api/settings/apikey", {
491
+ method: "POST",
492
+ headers: { "Content-Type": "application/json" },
493
+ body: JSON.stringify({ provider, apiKey: key.trim() }),
494
+ });
495
+ const data = await res.json();
496
+ if (data.ok) {
497
+ refreshSettings();
498
+ } else {
499
+ btn.textContent = "Error";
500
+ setTimeout(() => { btn.textContent = "Save"; btn.disabled = false; }, 2000);
501
+ }
502
+ } catch {
503
+ btn.textContent = "Error";
504
+ setTimeout(() => { btn.textContent = "Save"; btn.disabled = false; }, 2000);
505
+ }
506
+ }
507
+
508
+ async function installTool(toolId, btn) {
509
+ btn.disabled = true;
510
+ btn.innerHTML = '<span class="settings__spinner"></span>';
511
+
512
+ try {
513
+ const res = await fetch("/api/settings/install", {
514
+ method: "POST",
515
+ headers: { "Content-Type": "application/json" },
516
+ body: JSON.stringify({ tool: toolId }),
517
+ });
518
+ const data = await res.json();
519
+
520
+ if (data.jobId) {
521
+ pollJob(data.jobId, () => {
522
+ refreshSettings();
523
+ }, (err) => {
524
+ btn.textContent = "Failed";
525
+ btn.disabled = false;
526
+ });
527
+ }
528
+ } catch {
529
+ btn.textContent = "Failed";
530
+ btn.disabled = false;
531
+ }
532
+ }
533
+
534
+ async function switchHsAccount(portalId, btn) {
535
+ btn.disabled = true;
536
+ btn.innerHTML = '<span class="settings__spinner"></span>';
537
+
538
+ try {
539
+ const res = await fetch("/api/settings/hs-switch", {
540
+ method: "POST",
541
+ headers: { "Content-Type": "application/json" },
542
+ body: JSON.stringify({ portalId }),
543
+ });
544
+ const data = await res.json();
545
+ if (data.jobId) {
546
+ pollJob(data.jobId, () => refreshSettings(), () => {
547
+ btn.textContent = "Failed";
548
+ btn.disabled = false;
549
+ });
550
+ } else {
551
+ refreshSettings();
552
+ }
553
+ } catch {
554
+ btn.textContent = "Failed";
555
+ btn.disabled = false;
556
+ }
557
+ }
558
+
559
+ async function removeHsAccount(portalId, btn) {
560
+ btn.disabled = true;
561
+ btn.innerHTML = '<span class="settings__spinner"></span>';
562
+
563
+ try {
564
+ const res = await fetch("/api/settings/hs-switch", {
565
+ method: "POST",
566
+ headers: { "Content-Type": "application/json" },
567
+ body: JSON.stringify({ portalId, action: "remove" }),
568
+ });
569
+ const data = await res.json();
570
+ if (data.jobId) {
571
+ pollJob(data.jobId, () => refreshSettings(), () => {
572
+ btn.textContent = "Failed";
573
+ btn.disabled = false;
574
+ });
575
+ } else {
576
+ refreshSettings();
577
+ }
578
+ } catch {
579
+ btn.textContent = "Failed";
580
+ btn.disabled = false;
581
+ }
582
+ }
583
+
584
+ async function logoutGh(btn) {
585
+ btn.disabled = true;
586
+ btn.innerHTML = '<span class="settings__spinner"></span>';
587
+
588
+ try {
589
+ const res = await fetch("/api/settings/gh-logout", {
590
+ method: "POST",
591
+ headers: { "Content-Type": "application/json" },
592
+ body: JSON.stringify({}),
593
+ });
594
+ const data = await res.json();
595
+ if (data.jobId) {
596
+ pollJob(data.jobId, () => refreshSettings(), () => {
597
+ btn.textContent = "Failed";
598
+ btn.disabled = false;
599
+ });
600
+ } else {
601
+ refreshSettings();
602
+ }
603
+ } catch {
604
+ btn.textContent = "Failed";
605
+ btn.disabled = false;
606
+ }
607
+ }
608
+
609
+ async function switchGhAccount(btn) {
610
+ btn.disabled = true;
611
+ btn.innerHTML = '<span class="settings__spinner"></span>';
612
+
613
+ try {
614
+ // Logout first, then trigger login
615
+ const res = await fetch("/api/settings/gh-logout", {
616
+ method: "POST",
617
+ headers: { "Content-Type": "application/json" },
618
+ body: JSON.stringify({}),
619
+ });
620
+ const data = await res.json();
621
+ if (data.jobId) {
622
+ pollJob(data.jobId, () => {
623
+ // After logout completes, start login flow
624
+ startGhAuth(btn);
625
+ }, () => {
626
+ btn.textContent = "Failed";
627
+ btn.disabled = false;
628
+ });
629
+ }
630
+ } catch {
631
+ btn.textContent = "Failed";
632
+ btn.disabled = false;
633
+ }
634
+ }
635
+
636
+ async function startHsAuth(btn, card) {
637
+ btn.disabled = true;
638
+ btn.innerHTML = '<span class="settings__spinner"></span>';
639
+
640
+ try {
641
+ const res = await fetch("/api/settings/hs-auth", {
642
+ method: "POST",
643
+ headers: { "Content-Type": "application/json" },
644
+ body: JSON.stringify({ force: true }),
645
+ });
646
+ const data = await res.json();
647
+
648
+ if (data.needsKey) {
649
+ // Show instructions to get a personal access key
650
+ btn.textContent = "Connect";
651
+ btn.disabled = false;
652
+
653
+ const instructions = el("div", "settings__instructions");
654
+ instructions.innerHTML = `
655
+ <strong>Connect your HubSpot account:</strong>
656
+ <ol>
657
+ ${data.steps.map((s) => `<li>${escSettings(s)}</li>`).join("")}
658
+ </ol>
659
+ <a href="${escSettings(data.url)}" target="_blank">Open HubSpot &rarr;</a>
660
+ <div class="settings__pak-row">
661
+ <input type="password" class="settings__apikey-input" id="hs-pak-input" placeholder="Personal access key..." />
662
+ <button class="settings__btn settings__btn--primary" id="hs-pak-save">Save</button>
663
+ </div>
664
+ `;
665
+ card.appendChild(instructions);
666
+
667
+ document.getElementById("hs-pak-save").addEventListener("click", async () => {
668
+ const key = document.getElementById("hs-pak-input").value.trim();
669
+ if (!key) return;
670
+ const saveBtn = document.getElementById("hs-pak-save");
671
+ saveBtn.disabled = true;
672
+ saveBtn.innerHTML = '<span class="settings__spinner"></span>';
673
+
674
+ const authRes = await fetch("/api/settings/hs-auth", {
675
+ method: "POST",
676
+ headers: { "Content-Type": "application/json" },
677
+ body: JSON.stringify({ personalAccessKey: key }),
678
+ });
679
+ const authData = await authRes.json();
680
+
681
+ if (authData.jobId) {
682
+ pollJob(authData.jobId, () => refreshSettings(), () => {
683
+ saveBtn.textContent = "Failed";
684
+ saveBtn.disabled = false;
685
+ });
686
+ } else {
687
+ refreshSettings();
688
+ }
689
+ });
690
+ return;
691
+ }
692
+
693
+ if (data.jobId) {
694
+ pollJob(data.jobId, () => refreshSettings(), () => {
695
+ btn.textContent = "Failed";
696
+ btn.disabled = false;
697
+ });
698
+ }
699
+ } catch {
700
+ btn.textContent = "Failed";
701
+ btn.disabled = false;
702
+ }
703
+ }
704
+
705
+ async function startGhAuth(btn) {
706
+ btn.disabled = true;
707
+ btn.innerHTML = '<span class="settings__spinner"></span> Check browser...';
708
+
709
+ try {
710
+ const res = await fetch("/api/settings/gh-auth", {
711
+ method: "POST",
712
+ headers: { "Content-Type": "application/json" },
713
+ body: JSON.stringify({}),
714
+ });
715
+ const data = await res.json();
716
+
717
+ if (data.alreadyAuthenticated) {
718
+ refreshSettings();
719
+ return;
720
+ }
721
+
722
+ if (data.jobId) {
723
+ pollJob(data.jobId, () => refreshSettings(), () => {
724
+ btn.textContent = "Failed";
725
+ btn.disabled = false;
726
+ });
727
+ }
728
+ } catch {
729
+ btn.textContent = "Failed";
730
+ btn.disabled = false;
731
+ }
732
+ }
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // Job polling
736
+ // ---------------------------------------------------------------------------
737
+
738
+ function pollJob(jobId, onComplete, onError) {
739
+ const interval = setInterval(async () => {
740
+ try {
741
+ const res = await fetch(`/api/settings/job/${jobId}`);
742
+ const job = await res.json();
743
+
744
+ if (job.status === "completed") {
745
+ clearInterval(interval);
746
+ delete activePolls[jobId];
747
+ onComplete();
748
+ } else if (job.status === "failed") {
749
+ clearInterval(interval);
750
+ delete activePolls[jobId];
751
+ onError(job.output);
752
+ }
753
+ } catch {
754
+ clearInterval(interval);
755
+ delete activePolls[jobId];
756
+ onError("Connection lost");
757
+ }
758
+ }, 2000);
759
+
760
+ activePolls[jobId] = interval;
761
+ }
762
+
763
+ // ---------------------------------------------------------------------------
764
+ // CLI auth
765
+ // ---------------------------------------------------------------------------
766
+
767
+ async function authCLI(cli, btn, apiKey) {
768
+ btn.disabled = true;
769
+ btn.innerHTML = '<span class="settings__spinner"></span>';
770
+
771
+ try {
772
+ const payload = { cli };
773
+ if (apiKey) payload.apiKey = apiKey;
774
+
775
+ const res = await fetch("/api/settings/cli-auth", {
776
+ method: "POST",
777
+ headers: { "Content-Type": "application/json" },
778
+ body: JSON.stringify(payload),
779
+ });
780
+ const data = await res.json();
781
+
782
+ if (data.error) {
783
+ btn.textContent = "Failed";
784
+ setTimeout(() => { btn.textContent = "Sign in"; btn.disabled = false; }, 2000);
785
+ return;
786
+ }
787
+
788
+ if (data.hint) {
789
+ // Show hint to user (e.g., check browser)
790
+ btn.innerHTML = '<span class="settings__spinner"></span> Check browser...';
791
+ }
792
+
793
+ if (data.jobId) {
794
+ pollJob(data.jobId, () => refreshSettings(), () => {
795
+ btn.textContent = "Failed — try again";
796
+ btn.disabled = false;
797
+ });
798
+ } else {
799
+ // Immediate success (e.g., Codex API key saved)
800
+ refreshSettings();
801
+ }
802
+ } catch {
803
+ btn.textContent = "Failed";
804
+ setTimeout(() => { btn.textContent = "Sign in"; btn.disabled = false; }, 2000);
805
+ }
806
+ }
807
+
808
+ // ---------------------------------------------------------------------------
809
+ // Model selection helpers
810
+ // ---------------------------------------------------------------------------
811
+
812
+ function getModelsForEngine(engine) {
813
+ switch (engine) {
814
+ case "claude-code":
815
+ return [
816
+ { id: "sonnet", label: "Claude Sonnet (default)" },
817
+ { id: "opus", label: "Claude Opus" },
818
+ { id: "haiku", label: "Claude Haiku" },
819
+ ];
820
+ case "anthropic-api":
821
+ return [
822
+ { id: "claude-sonnet-4-20250514", label: "Claude Sonnet 4 (default)" },
823
+ { id: "claude-opus-4-20250514", label: "Claude Opus 4" },
824
+ { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
825
+ { id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
826
+ ];
827
+ case "openai-api":
828
+ return [
829
+ { id: "gpt-4o", label: "GPT-4o (default)" },
830
+ { id: "gpt-4o-mini", label: "GPT-4o Mini" },
831
+ { id: "o3", label: "o3" },
832
+ { id: "o4-mini", label: "o4 Mini" },
833
+ ];
834
+ case "gemini-cli":
835
+ case "gemini-api":
836
+ return [
837
+ { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash (default)" },
838
+ { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
839
+ { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
840
+ ];
841
+ case "codex-cli":
842
+ return [
843
+ { id: "o4-mini", label: "o4 Mini (default)" },
844
+ { id: "o3", label: "o3" },
845
+ { id: "gpt-4o", label: "GPT-4o" },
846
+ ];
847
+ default:
848
+ return [];
849
+ }
850
+ }
851
+
852
+ function getCurrentModel(engine, config) {
853
+ switch (engine) {
854
+ case "claude-code": return config.claudeCodeModel || "sonnet";
855
+ case "anthropic-api": return config.anthropicApiModel || "claude-sonnet-4-20250514";
856
+ case "openai-api": return config.openaiApiModel || "gpt-4o";
857
+ default: return null;
858
+ }
859
+ }
860
+
861
+ async function setEngineModel(engine, model) {
862
+ await fetch("/api/settings/engine", {
863
+ method: "POST",
864
+ headers: { "Content-Type": "application/json" },
865
+ body: JSON.stringify({ engine, model }),
866
+ });
867
+
868
+ // Update statusbar
869
+ const statusEngine = document.getElementById("status-engine");
870
+ if (statusEngine) {
871
+ const label = ENGINE_LABELS[engine] || engine;
872
+ statusEngine.textContent = label;
873
+ }
874
+
875
+ refreshSettings();
876
+ }
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // DOM helpers
880
+ // ---------------------------------------------------------------------------
881
+
882
+ function el(tag, className) {
883
+ const e = document.createElement(tag);
884
+ if (className) e.className = className;
885
+ return e;
886
+ }
887
+
888
+ function dot(variant) {
889
+ const d = el("span", `settings__dot settings__dot--${variant}`);
890
+ return d;
891
+ }
892
+
893
+ function sectionTitle(text) {
894
+ const h = el("h3", "settings__section-title");
895
+ h.textContent = text;
896
+ return h;
897
+ }
898
+
899
+ function subsectionTitle(text) {
900
+ const h = el("h4", "settings__subsection-title");
901
+ h.textContent = text;
902
+ return h;
903
+ }
904
+
905
+ function escSettings(str) {
906
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
907
+ }
908
+
909
+ // ---------------------------------------------------------------------------
910
+ // Event listeners
911
+ // ---------------------------------------------------------------------------
912
+
913
+ document.getElementById("btn-settings").addEventListener("click", openSettings);
914
+ document.getElementById("settings-close").addEventListener("click", closeSettings);
915
+ document.getElementById("settings-overlay").addEventListener("click", (e) => {
916
+ if (e.target.id === "settings-overlay") closeSettings();
917
+ });
918
+
919
+ // Setup screen settings button
920
+ document.getElementById("btn-setup-settings").addEventListener("click", openSettings);
921
+
922
+ // Escape key
923
+ document.addEventListener("keydown", (e) => {
924
+ if (e.key === "Escape" && !document.getElementById("settings-overlay").classList.contains("hidden")) {
925
+ closeSettings();
926
+ }
927
+ });