fraim 2.0.281 → 2.0.283

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.
@@ -1,742 +1,747 @@
1
- (function () {
2
- 'use strict';
3
-
4
- const CHECKLIST_EL = document.getElementById('checklist');
5
- const PRIMARY_BUTTON = document.getElementById('primary-button');
6
- const STATUS_EL = document.getElementById('status');
7
- const LEDE_EL = document.getElementById('lede');
8
-
9
- const state = {
10
- session: null,
11
- activeStep: 'prereqs',
12
- configureInFlight: false,
13
- configureError: null,
14
- };
15
-
16
-
17
- const STEP_LABELS = {
18
- prereqs: 'Prerequisites',
19
- agents: 'Local Agents',
20
- configure: 'Configure FRAIM',
21
- use: 'Use FRAIM',
22
- };
23
-
24
- const STEP_ORDER = ['prereqs', 'agents', 'configure', 'use'];
25
-
26
- function setStatus(text, tone) {
27
- STATUS_EL.textContent = text || '';
28
- if (tone) STATUS_EL.setAttribute('data-tone', tone);
29
- else STATUS_EL.removeAttribute('data-tone');
30
- }
31
-
32
- function setHeader(title, lede) {
33
- const h1El = document.querySelector('.page-header h1');
34
- if (h1El) h1El.textContent = title;
35
- if (LEDE_EL) LEDE_EL.textContent = lede;
36
- }
37
-
38
- async function api(path, method, body) {
39
- const headers = {};
40
- if (body !== undefined) headers['Content-Type'] = 'application/json';
41
- if (state.session && state.session.requestToken) headers['x-fraim-first-run-token'] = state.session.requestToken;
42
- const response = await fetch(path, {
43
- method: method || 'GET',
44
- headers: Object.keys(headers).length > 0 ? headers : undefined,
45
- body: body !== undefined ? JSON.stringify(body) : undefined,
46
- });
47
- if (response.status === 204) return null;
48
- const json = await response.json();
49
- if (!response.ok) throw new Error(json && json.error ? json.error : `Request failed (status ${response.status}).`);
50
- return json;
51
- }
52
-
53
- function setSessionFromActionResponse(actionResp) {
54
- if (!state.session || !actionResp) return;
55
- state.session.state = actionResp.state;
56
- state.session.rows = actionResp.rows;
57
- state.session.primaryButtonLabel = actionResp.primaryButtonLabel;
58
- state.session.currentAgentId = actionResp.state.agentId;
59
- }
60
-
61
- function row(id) {
62
- return (state.session && state.session.rows || []).find((r) => r.id === id) || null;
63
- }
64
-
65
- function requiredPrereqsReady() {
66
- const node = row('node');
67
- return Boolean(node && node.status === 'ok');
68
- }
69
-
70
- function readyAgents() {
71
- const installs = state.session && state.session.state ? state.session.state.agentInstalls || {} : {};
72
- return Object.entries(installs)
73
- .filter(([, entry]) => entry && entry.status === 'ready')
74
- .map(([id, entry]) => ({ id, label: entry.label }));
75
- }
76
-
77
- function configuredSurfaces() {
78
- const setupResult = state.session && state.session.state ? state.session.state.setupResult : null;
79
- return setupResult && Array.isArray(setupResult.configuredSurfaces) ? setupResult.configuredSurfaces : [];
80
- }
81
-
82
- function readyAgentLabels() {
83
- const labels = new Set();
84
- for (const agent of readyAgents()) labels.add(agent.label);
85
- for (const surface of configuredSurfaces()) {
86
- if (surface && surface.name) labels.add(surface.name);
87
- }
88
- return Array.from(labels);
89
- }
90
-
91
- function slugifyAgentName(name) {
92
- return String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
93
- }
94
-
95
- function isAgentReady(opt) {
96
- const installState = state.session && state.session.state && state.session.state.agentInstalls
97
- ? state.session.state.agentInstalls[opt.id]
98
- : null;
99
- if (installState && installState.status === 'ready') return true;
100
- return configuredSurfaces().some((surface) => surface && (surface.id === opt.id || surface.name === opt.label));
101
- }
102
-
103
- function detectedAgentCount() {
104
- const setupResult = state.session && state.session.state ? state.session.state.setupResult : null;
105
- return (setupResult && setupResult.detectedSurfaceCount) || readyAgents().length;
106
- }
107
-
108
- function configureReady() {
109
- const fraim = row('fraim');
110
- return Boolean(fraim && fraim.status === 'ok' && detectedAgentCount() > 0);
111
- }
112
-
113
- function chooseActiveStep() {
114
- if (!requiredPrereqsReady()) return 'prereqs';
115
- if (detectedAgentCount() < 1) return 'agents';
116
- if (!configureReady()) return 'configure';
117
- return 'use';
118
- }
119
-
120
- function iconFor(status) {
121
- if (status === 'ok') return '✓';
122
- if (status === 'in-progress') return '...';
123
- if (status === 'manual-required' || status === 'error') return '!';
124
- return '.';
125
- }
126
-
127
- function renderShell(renderPane) {
128
- CHECKLIST_EL.className = 'setup-shell';
129
- CHECKLIST_EL.innerHTML = '';
130
- PRIMARY_BUTTON.style.display = 'none';
131
- setHeader('Set up FRAIM', 'Complete these steps in order. FRAIM configures itself after a local AI agent is ready.');
132
-
133
- const steps = document.createElement('div');
134
- steps.className = 'setup-steps';
135
- steps.setAttribute('data-testid', 'setup-stepper');
136
-
137
- for (const step of STEP_ORDER) {
138
- const btn = document.createElement('button');
139
- btn.type = 'button';
140
- btn.className = 'setup-step';
141
- btn.setAttribute('data-step', step);
142
- btn.setAttribute('aria-current', state.activeStep === step ? 'step' : 'false');
143
- btn.setAttribute('data-status', stepStatus(step));
144
- btn.disabled = (step === 'configure' && detectedAgentCount() < 1) || (step === 'use' && !configureReady());
145
- btn.addEventListener('click', () => {
146
- if (btn.disabled) return;
147
- state.activeStep = step;
148
- render();
149
- });
150
- const dot = document.createElement('span');
151
- dot.className = 'step-dot';
152
- dot.textContent = stepStatus(step) === 'done' ? '✓' : String(STEP_ORDER.indexOf(step) + 1);
153
- const label = document.createElement('span');
154
- label.textContent = STEP_LABELS[step];
155
- btn.appendChild(dot);
156
- btn.appendChild(label);
157
- steps.appendChild(btn);
158
- }
159
-
160
- const pane = document.createElement('div');
161
- pane.className = 'setup-pane';
162
- pane.setAttribute('data-testid', 'setup-pane');
163
- renderPane(pane);
164
-
165
- CHECKLIST_EL.appendChild(steps);
166
- CHECKLIST_EL.appendChild(pane);
167
- }
168
-
169
- function stepStatus(step) {
170
- if (step === 'prereqs') return requiredPrereqsReady() ? 'done' : 'active';
171
- if (step === 'agents') return detectedAgentCount() > 0 ? 'done' : 'active';
172
- if (step === 'configure') return configureReady() ? 'done' : (detectedAgentCount() > 0 ? 'active' : 'locked');
173
- if (step === 'use') return configureReady() ? 'active' : 'locked';
174
- return 'active';
175
- }
176
-
177
- function renderPrereqs(pane) {
178
- const h = document.createElement('h2');
179
- h.textContent = 'Prerequisites';
180
- pane.appendChild(h);
181
-
182
- const list = document.createElement('ul');
183
- list.className = 'row-list';
184
- for (const id of ['node', 'git']) {
185
- const r = row(id);
186
- if (!r) continue;
187
- const li = document.createElement('li');
188
- li.className = 'row';
189
- li.setAttribute('data-row-id', r.id);
190
- li.setAttribute('data-row-status', r.status);
191
- li.innerHTML = `<span class="icon" aria-hidden="true">${iconFor(r.status)}</span><span class="label"></span><span class="verb" data-testid="row-verb"></span>`;
192
- li.querySelector('.label').textContent = r.label;
193
- li.querySelector('.verb').textContent = r.verb || '';
194
- list.appendChild(li);
195
- }
196
- pane.appendChild(list);
197
-
198
- const btn = button(requiredPrereqsReady() ? 'Next' : 'Check prerequisites', 'primary');
199
- btn.setAttribute('data-testid', 'check-prereqs');
200
- btn.addEventListener('click', async () => {
201
- btn.disabled = true;
202
- try {
203
- for (const id of ['node', 'git']) {
204
- const r = row(id);
205
- if (r && r.status !== 'ok') setSessionFromActionResponse(await api(`/api/first-run/rows/${id}/run`, 'POST', {}));
206
- }
207
- state.activeStep = 'agents';
208
- setStatus('Prerequisites are ready.');
209
- } catch (err) {
210
- setStatus(err.message, 'error');
211
- } finally {
212
- btn.disabled = false;
213
- render();
214
- }
215
- });
216
- pane.appendChild(btn);
217
- }
218
-
219
- function renderAgents(pane) {
220
- const h = document.createElement('h2');
221
- h.textContent = 'Locally installed AI agents';
222
- pane.appendChild(h);
223
-
224
- const p = document.createElement('p');
225
- p.className = 'pane-copy';
226
- p.textContent = detectedAgentCount() > 0
227
- ? 'At least one local AI agent is ready. You can add more agents or continue to configure FRAIM.'
228
- : 'No problem, we will install AI agents next.';
229
- pane.appendChild(p);
230
-
231
- const readyLabels = readyAgentLabels();
232
- const readyList = document.createElement('div');
233
- readyList.className = readyLabels.length > 0 ? 'ready-strip' : 'ready-strip ready-strip--muted';
234
- readyList.setAttribute('data-testid', 'ready-agents');
235
- readyList.textContent = readyLabels.length > 0
236
- ? `Already installed: ${readyLabels.join(', ')}`
237
- : 'Already installed: none detected yet';
238
- pane.appendChild(readyList);
239
-
240
- const catalogHeading = document.createElement('h3');
241
- catalogHeading.className = 'agent-section-heading';
242
- catalogHeading.textContent = 'Supported AI tools and IDEs';
243
- pane.appendChild(catalogHeading);
244
-
245
- const catalogCopy = document.createElement('p');
246
- catalogCopy.className = 'pane-copy';
247
- catalogCopy.textContent = 'FRAIM can configure every supported local agent or IDE it detects. Download links stay available for tools not installed yet.';
248
- pane.appendChild(catalogCopy);
249
-
250
- const list = document.createElement('ul');
251
- list.className = 'agent-list';
252
- list.setAttribute('data-testid', 'supported-agent-list');
253
- const supportedAgents = (state.session && state.session.supportedAgents) || [];
254
- for (const agent of supportedAgents) {
255
- const slug = slugifyAgentName(agent.name);
256
- const detected = agent.detected || readyLabels.includes(agent.name);
257
- const li = document.createElement('li');
258
- li.className = 'agent-row' + (detected ? ' agent-row--detected' : '');
259
- li.setAttribute('data-testid', `supported-agent-row-${slug}`);
260
-
261
- const nameSpan = document.createElement('span');
262
- nameSpan.className = 'agent-row__name';
263
- nameSpan.textContent = agent.name;
264
- li.appendChild(nameSpan);
265
-
266
- const actionSpan = document.createElement('span');
267
- actionSpan.className = 'agent-row__action';
268
- if (detected) {
269
- const badge = document.createElement('span');
270
- badge.className = 'agent-badge agent-badge--installed';
271
- badge.setAttribute('data-testid', `supported-agent-installed-${slug}`);
272
- badge.textContent = 'Installed';
273
- actionSpan.appendChild(badge);
274
- } else if (agent.downloadUrl) {
275
- const link = document.createElement('a');
276
- link.className = 'agent-download-link';
277
- link.href = agent.downloadUrl;
278
- link.target = '_blank';
279
- link.rel = 'noopener noreferrer';
280
- link.textContent = 'Download';
281
- link.setAttribute('data-testid', `supported-agent-download-${slug}`);
282
- actionSpan.appendChild(link);
283
- } else {
284
- const unavailable = document.createElement('span');
285
- unavailable.className = 'agent-row__muted';
286
- unavailable.textContent = 'Manual setup';
287
- actionSpan.appendChild(unavailable);
288
- }
289
- li.appendChild(actionSpan);
290
- list.appendChild(li);
291
- }
292
- pane.appendChild(list);
293
-
294
- const rescanBtn = button('Rescan supported tools', 'ghost');
295
- rescanBtn.setAttribute('data-testid', 'rescan-agents');
296
- rescanBtn.addEventListener('click', async () => {
297
- rescanBtn.disabled = true;
298
- rescanBtn.textContent = 'Scanning...';
299
- try { await loadSession(false); } finally {
300
- state.activeStep = 'agents';
301
- render();
302
- }
303
- });
304
- pane.appendChild(rescanBtn);
305
-
306
- const hubHeading = document.createElement('h3');
307
- hubHeading.className = 'agent-section-heading';
308
- hubHeading.textContent = 'Hub-ready CLI agents';
309
- pane.appendChild(hubHeading);
310
-
311
- const hubCopy = document.createElement('p');
312
- hubCopy.className = 'pane-copy';
313
- hubCopy.textContent = 'FRAIM Hub runs jobs through CLI agents. Set up at least one of these if you plan to launch jobs in Hub.';
314
- pane.appendChild(hubCopy);
315
-
316
- const grid = document.createElement('div');
317
- grid.className = 'agent-grid';
318
- const agentOptions = state.session.agentOptions || [];
319
- for (const opt of agentOptions) {
320
- const card = document.createElement('div');
321
- card.className = 'user-type-card recruit-card';
322
- card.setAttribute('data-agent-id', opt.id);
323
- card.setAttribute('data-testid', `agent-card-${opt.id}`);
324
- const agentReady = isAgentReady(opt);
325
-
326
- const title = document.createElement('strong');
327
- title.className = 'card-title';
328
- title.textContent = opt.label;
329
-
330
- const desc = document.createElement('p');
331
- desc.className = 'card-desc';
332
- desc.textContent = agentReady
333
- ? 'Already installed and ready for FRAIM.'
334
- : `Available to set up: install, sign in, and verify ${opt.label}.`;
335
-
336
- const action = button(agentReady ? 'Ready' : 'Set up', 'secondary');
337
- action.disabled = agentReady;
338
- action.setAttribute('data-testid', `install-${opt.id}`);
339
- action.addEventListener('click', () => openAgentModal(opt));
340
-
341
- card.appendChild(title);
342
- card.appendChild(desc);
343
- card.appendChild(action);
344
- grid.appendChild(card);
345
- }
346
-
347
- const byoa = document.createElement('div');
348
- byoa.className = 'user-type-card recruit-card byoa-card';
349
- byoa.innerHTML = '<strong class="card-title">Bring Your Own Agent</strong><p class="card-desc">Use Cursor, Windsurf, Kiro, VS Code, or another supported local AI tool. If you install a new agent later and want FRAIM to use it, run this command from your project.</p><div class="cmd-block">npx fraim add-ide</div>';
350
- grid.appendChild(byoa);
351
- pane.appendChild(grid);
352
-
353
- const done = button('Next', 'primary');
354
- done.setAttribute('data-testid', 'done-installing-agents');
355
- done.disabled = detectedAgentCount() < 1;
356
- done.addEventListener('click', () => { state.activeStep = 'configure'; render(); });
357
- pane.appendChild(done);
358
-
359
- if (detectedAgentCount() < 1) {
360
- const locked = document.createElement('p');
361
- locked.className = 'locked-note';
362
- locked.textContent = 'Configure FRAIM stays locked until one local AI agent is ready.';
363
- pane.appendChild(locked);
364
- }
365
- }
366
-
367
- async function configureFraim() {
368
- if (state.configureInFlight) return;
369
- state.configureInFlight = true;
370
- state.configureError = null;
371
- render();
372
- setStatus('Configuring FRAIM for your local AI agents...');
373
- try {
374
- const resp = await api('/api/first-run/done-recruiting', 'POST', {});
375
- setSessionFromActionResponse(resp);
376
- setStatus(resp.message, resp.ok ? null : 'error');
377
- state.activeStep = 'configure';
378
- state.configureError = resp && resp.ok ? null : (resp && resp.message ? resp.message : 'FRAIM setup did not complete.');
379
- } catch (err) {
380
- state.configureError = err.message;
381
- setStatus(state.configureError, 'error');
382
- } finally {
383
- state.configureInFlight = false;
384
- render();
385
- }
386
- }
387
-
388
- function renderConfigure(pane) {
389
- const h = document.createElement('h2');
390
- h.textContent = 'Configure FRAIM';
391
- pane.appendChild(h);
392
-
393
- if (detectedAgentCount() < 1) {
394
- const p = document.createElement('p');
395
- p.className = 'pane-copy';
396
- p.textContent = 'A local AI agent is required before FRAIM setup can finish.';
397
- pane.appendChild(p);
398
- const back = button('Choose a local agent', 'primary');
399
- back.addEventListener('click', () => { state.activeStep = 'agents'; render(); });
400
- pane.appendChild(back);
401
- return;
402
- }
403
-
404
- if (!configureReady()) {
405
- const p = document.createElement('p');
406
- p.className = 'pane-copy';
407
- p.textContent = state.configureError
408
- ? 'FRAIM setup needs attention before you continue.'
409
- : 'FRAIM is configuring MCP and local setup files for every ready agent.';
410
- pane.appendChild(p);
411
- const status = document.createElement('div');
412
- status.className = state.configureError ? 'ready-strip ready-strip--error' : 'ready-strip';
413
- status.textContent = state.configureError || 'Configuring FRAIM...';
414
- pane.appendChild(status);
415
- if (state.configureError) {
416
- const retry = button('Try again', 'primary');
417
- retry.addEventListener('click', configureFraim);
418
- pane.appendChild(retry);
419
- } else if (!state.configureInFlight) {
420
- window.setTimeout(configureFraim, 0);
421
- }
422
- return;
423
- }
424
-
425
- const success = document.createElement('div');
426
- success.className = 'ready-strip';
427
- success.textContent = 'FRAIM setup succeeded for your ready local AI agents.';
428
- pane.appendChild(success);
429
- const next = button('Next', 'primary');
430
- next.addEventListener('click', () => { state.activeStep = 'use'; render(); });
431
- pane.appendChild(next);
432
- }
433
-
434
- function renderUseFraim(pane) {
435
- const h = document.createElement('h2');
436
- h.textContent = 'Use FRAIM';
437
- pane.appendChild(h);
438
-
439
- const p = document.createElement('p');
440
- p.className = 'pane-copy';
441
- p.textContent = 'FRAIM is set up to use your ready local AI agents. Choose where you want to work.';
442
- pane.appendChild(p);
443
- renderStartWorkingChoices(pane);
444
- }
445
-
446
- function renderStartWorkingChoices(pane) {
447
- const choices = document.createElement('div');
448
- choices.className = 'choice-grid';
449
-
450
- const ideCard = document.createElement('div');
451
- ideCard.className = 'user-type-card user-type-card--featured';
452
- ideCard.innerHTML = '<strong class="card-title">In my IDE</strong><p class="card-desc">Use FRAIM inside Claude Code, Codex, Cursor, or the local agent tool you already use.</p>';
453
- const ideBtn = button('Continue in my IDE', 'primary');
454
- ideBtn.addEventListener('click', async () => {
455
- try { await api('/api/first-run/set-preference', 'POST', { choice: 'ide' }); } catch (_) {}
456
- const ideData = await api('/api/first-run/ide-commands');
457
- renderIdeCommandDisplay(ideData ? ideData.commands : []);
458
- });
459
- ideCard.appendChild(ideBtn);
460
-
461
- const hubCard = document.createElement('div');
462
- hubCard.className = 'user-type-card';
463
- hubCard.innerHTML = '<strong class="card-title">In FRAIM Hub</strong><p class="card-desc">Open the Company, Manager, and Projects shell.</p>';
464
- const hubBtn = button('Open FRAIM Hub', 'secondary');
465
- hubBtn.addEventListener('click', async () => {
466
- try { await api('/api/first-run/set-preference', 'POST', { choice: 'hub' }); } catch (_) {}
467
- try {
468
- hubBtn.disabled = true;
469
- setStatus('Opening the FRAIM Hub desktop app...');
470
- const openResp = await api('/api/first-run/open-hub', 'POST');
471
- if (openResp && openResp.needsAgentSetup) {
472
- state.activeStep = 'agents';
473
- render();
474
- setStatus(openResp.message, 'error');
475
- return;
476
- }
477
- // Issue #866 R1: the Hub opens as the Electron desktop app, so the
478
- // first-run tab does not navigate itself to a browser Hub. Confirm and
479
- // let the user close this tab.
480
- setStatus((openResp && openResp.message) || 'FRAIM Hub is opening in the desktop app. You can close this tab.');
481
- } catch (err) {
482
- hubBtn.disabled = false;
483
- setStatus(err.message, 'error');
484
- }
485
- });
486
- hubCard.appendChild(hubBtn);
487
- choices.appendChild(ideCard);
488
- choices.appendChild(hubCard);
489
- pane.appendChild(choices);
490
- }
491
-
492
- function renderIdeCommandDisplay(commands) {
493
- renderShell((pane) => {
494
- const h = document.createElement('h2');
495
- h.textContent = 'Continue in your IDE';
496
- pane.appendChild(h);
497
- const copy = document.createElement('p');
498
- copy.className = 'pane-copy';
499
- copy.textContent = 'Restart your IDE so it picks up FRAIM setup, then run:';
500
- pane.appendChild(copy);
501
- const cmdList = (commands && commands.length > 0) ? commands : ['/fraim onboard this project'];
502
- for (const cmd of cmdList) {
503
- const row = document.createElement('div');
504
- row.className = 'command-row';
505
- const block = document.createElement('div');
506
- block.className = 'cmd-block';
507
- block.textContent = cmd;
508
- const copyBtn = button('Copy', 'secondary');
509
- copyBtn.setAttribute('aria-label', `Copy command: ${cmd}`);
510
- copyBtn.addEventListener('click', async () => {
511
- await navigator.clipboard.writeText(cmd);
512
- copyBtn.textContent = 'Copied';
513
- setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
514
- });
515
- row.appendChild(block);
516
- row.appendChild(copyBtn);
517
- pane.appendChild(row);
518
- }
519
- const back = button('Back to choices', 'secondary');
520
- back.setAttribute('data-testid', 'back-to-use-fraim');
521
- back.addEventListener('click', () => { state.activeStep = 'use'; render(); });
522
- pane.appendChild(back);
523
- });
524
- }
525
-
526
- function openAgentModal(opt) {
527
- const overlay = document.createElement('div');
528
- overlay.className = 'modal-backdrop';
529
- overlay.setAttribute('data-testid', 'agent-install-modal');
530
- const modal = document.createElement('div');
531
- modal.className = 'modal';
532
- modal.setAttribute('role', 'dialog');
533
- modal.setAttribute('aria-modal', 'true');
534
- modal.innerHTML = `<h2>Set up ${escapeHtml(opt.label)}</h2><p class="install-status" data-testid="agent-install-status">Ready to set up ${escapeHtml(opt.label)}.</p><p class="modal-help">Setup installs the CLI, opens sign-in, and verifies the agent is available before FRAIM uses it.</p>`;
535
- const status = modal.querySelector('[data-testid="agent-install-status"]');
536
- const actions = document.createElement('div');
537
- actions.className = 'install-actions';
538
-
539
- const install = button('Set up', 'primary');
540
- const signIn = button(`Sign In to ${opt.label}`, 'secondary');
541
- const check = button('Check readiness', 'secondary');
542
- const close = button('Close', 'ghost');
543
- signIn.disabled = true;
544
- check.disabled = true;
545
- close.addEventListener('click', () => { overlay.remove(); render(); });
546
-
547
- install.addEventListener('click', async () => {
548
- install.disabled = true;
549
- status.textContent = `Setting up ${opt.label}...`;
550
- try {
551
- const result = await api('/api/first-run/install-agent', 'POST', { agentId: opt.id });
552
- if (!result || !result.ok) throw new Error(result && result.message ? result.message : 'Setup failed.');
553
- status.textContent = `${opt.label} setup started. Sign in next.`;
554
- signIn.disabled = false;
555
- await loadSession(false);
556
- } catch (err) {
557
- install.disabled = false;
558
- install.textContent = 'Retry';
559
- status.textContent = err.message;
560
- status.setAttribute('data-tone', 'error');
561
- showAgentInstallError(modal, opt, err.message, install, close);
562
- }
563
- });
564
-
565
- signIn.addEventListener('click', async () => {
566
- signIn.disabled = true;
567
- status.textContent = 'Opening terminal for sign-in...';
568
- try {
569
- const result = await api('/api/first-run/trigger-agent-login', 'POST', { agentId: opt.id });
570
- status.textContent = result && result.message ? result.message : 'Complete sign-in, then check readiness.';
571
- check.disabled = false;
572
- } catch (err) {
573
- signIn.disabled = false;
574
- status.textContent = err.message;
575
- status.setAttribute('data-tone', 'error');
576
- }
577
- });
578
-
579
- check.addEventListener('click', async () => {
580
- check.disabled = true;
581
- status.textContent = 'Checking that the CLI is installed, signed in, and available on PATH...';
582
- try {
583
- const result = await api('/api/first-run/check-agent', 'POST', { agentId: opt.id });
584
- if (result && result.ready) {
585
- status.textContent = `${opt.label} is ready!`;
586
- await loadSession(false);
587
- setTimeout(() => { overlay.remove(); state.activeStep = 'agents'; render(); }, 250);
588
- return;
589
- }
590
- status.textContent = result && result.message ? result.message : `${opt.label} is not ready yet.`;
591
- status.setAttribute('data-tone', 'error');
592
- check.disabled = false;
593
- } catch (err) {
594
- status.textContent = err.message;
595
- status.setAttribute('data-tone', 'error');
596
- check.disabled = false;
597
- }
598
- });
599
-
600
- actions.appendChild(install);
601
- actions.appendChild(signIn);
602
- actions.appendChild(check);
603
- actions.appendChild(close);
604
- modal.appendChild(actions);
605
- overlay.appendChild(modal);
606
- document.body.appendChild(overlay);
607
- install.focus();
608
- }
609
-
610
- function showAgentInstallError(modal, opt, message, retryButton, closeButton) {
611
- const existing = modal.querySelector('[data-testid="error-frame"]');
612
- if (existing) existing.remove();
613
- if (!window.FraimErrorFrame || typeof window.FraimErrorFrame.render !== 'function') return;
614
- const frame = window.FraimErrorFrame.render({
615
- whatTried: `We tried to set up ${opt.label}.`,
616
- whatHappened: message,
617
- actions: [
618
- { id: 'retry', label: 'Retry', variant: 'primary' },
619
- { id: 'alternative', label: 'Choose another agent', variant: 'secondary' },
620
- { id: 'manual', label: 'Manual setup help', variant: 'ghost' },
621
- ],
622
- }, (action) => {
623
- if (action.id === 'retry') {
624
- retryButton.click();
625
- } else if (action.id === 'alternative') {
626
- closeButton.click();
627
- } else if (action.id === 'manual') {
628
- const status = modal.querySelector('[data-testid="agent-install-status"]');
629
- if (status) {
630
- status.textContent = 'Run npx fraim add-ide for manual setup, then return here when your local agent is ready.';
631
- status.removeAttribute('data-tone');
632
- }
633
- }
634
- });
635
- modal.appendChild(frame);
636
- }
637
-
638
- function button(text, variant) {
639
- const btn = document.createElement('button');
640
- btn.type = 'button';
641
- btn.className = variant === 'primary' ? 'btn btn-primary' : variant === 'ghost' ? 'btn btn-ghost' : 'btn btn-secondary';
642
- btn.textContent = text;
643
- return btn;
644
- }
645
-
646
- function escapeHtml(value) {
647
- return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
648
- }
649
-
650
- async function loadSession(shouldRender) {
651
- state.session = await api('/api/first-run/session');
652
- if (!state.session.state.agentInstalls) state.session.state.agentInstalls = {};
653
- if (shouldRender !== false) {
654
- state.activeStep = chooseActiveStep();
655
- render();
656
- }
657
- }
658
-
659
- // Issue #646: when first-run was launched without a key (the no-terminal macOS
660
- // installer path), gate the whole wizard behind a paste-your-key step.
661
- function renderKeyEntry() {
662
- CHECKLIST_EL.className = 'setup-shell';
663
- CHECKLIST_EL.innerHTML = '';
664
- PRIMARY_BUTTON.style.display = 'none';
665
- setHeader('Set up FRAIM', 'Paste the FRAIM key from your account page to get started.');
666
-
667
- const card = document.createElement('div');
668
- card.className = 'setup-pane';
669
- card.setAttribute('data-testid', 'key-entry');
670
-
671
- const label = document.createElement('label');
672
- label.className = 'pane-copy';
673
- label.setAttribute('for', 'fraim-key-input');
674
- label.textContent = 'Your FRAIM key';
675
- card.appendChild(label);
676
-
677
- const input = document.createElement('input');
678
- input.type = 'text';
679
- input.id = 'fraim-key-input';
680
- input.className = 'key-input';
681
- input.placeholder = 'fraim_…';
682
- input.autocapitalize = 'off';
683
- input.autocomplete = 'off';
684
- input.spellcheck = false;
685
- input.setAttribute('data-testid', 'key-input');
686
- card.appendChild(input);
687
-
688
- const err = document.createElement('p');
689
- err.className = 'locked-note';
690
- err.setAttribute('data-testid', 'key-error');
691
- err.hidden = true;
692
- card.appendChild(err);
693
-
694
- const submit = button('Continue', 'primary');
695
- submit.setAttribute('data-testid', 'key-submit');
696
- const onSubmit = async () => {
697
- const value = input.value.trim();
698
- err.hidden = true;
699
- submit.disabled = true;
700
- submit.textContent = 'Checking…';
701
- try {
702
- const resp = await api('/api/first-run/set-key', 'POST', { key: value });
703
- state.session = resp.session;
704
- if (!state.session.state.agentInstalls) state.session.state.agentInstalls = {};
705
- state.activeStep = chooseActiveStep();
706
- render();
707
- } catch (e) {
708
- err.textContent = e.message || 'That key was not accepted. Copy it again from your account page.';
709
- err.hidden = false;
710
- submit.disabled = false;
711
- submit.textContent = 'Continue';
712
- }
713
- };
714
- submit.addEventListener('click', onSubmit);
715
- input.addEventListener('keydown', (e) => { if (e.key === 'Enter') onSubmit(); });
716
- card.appendChild(submit);
717
-
718
- CHECKLIST_EL.appendChild(card);
719
- input.focus();
720
- }
721
-
722
- function render() {
723
- if (!state.session) return;
724
- if (state.session.needsKey) { renderKeyEntry(); return; }
725
- if (!STEP_ORDER.includes(state.activeStep)) state.activeStep = chooseActiveStep();
726
- renderShell((pane) => {
727
- if (state.activeStep === 'prereqs') renderPrereqs(pane);
728
- else if (state.activeStep === 'agents') renderAgents(pane);
729
- else if (state.activeStep === 'configure') renderConfigure(pane);
730
- else renderUseFraim(pane);
731
- });
732
- }
733
-
734
- PRIMARY_BUTTON.addEventListener('click', () => {
735
- state.activeStep = chooseActiveStep();
736
- render();
737
- });
738
-
739
- loadSession().catch((err) => {
740
- setStatus(err.message || 'Could not load first-run.', 'error');
741
- });
742
- }());
1
+ (function () {
2
+ 'use strict';
3
+
4
+ const CHECKLIST_EL = document.getElementById('checklist');
5
+ const PRIMARY_BUTTON = document.getElementById('primary-button');
6
+ const STATUS_EL = document.getElementById('status');
7
+ const LEDE_EL = document.getElementById('lede');
8
+
9
+ const state = {
10
+ session: null,
11
+ activeStep: 'prereqs',
12
+ configureInFlight: false,
13
+ configureError: null,
14
+ };
15
+
16
+
17
+ const STEP_LABELS = {
18
+ prereqs: 'Prerequisites',
19
+ agents: 'Local Agents',
20
+ configure: 'Configure FRAIM',
21
+ use: 'Use FRAIM',
22
+ };
23
+
24
+ const STEP_ORDER = ['prereqs', 'agents', 'configure', 'use'];
25
+
26
+ function setStatus(text, tone) {
27
+ STATUS_EL.textContent = text || '';
28
+ if (tone) STATUS_EL.setAttribute('data-tone', tone);
29
+ else STATUS_EL.removeAttribute('data-tone');
30
+ }
31
+
32
+ function setHeader(title, lede) {
33
+ const h1El = document.querySelector('.page-header h1');
34
+ if (h1El) h1El.textContent = title;
35
+ if (LEDE_EL) LEDE_EL.textContent = lede;
36
+ }
37
+
38
+ async function api(path, method, body) {
39
+ const headers = {};
40
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
41
+ if (state.session && state.session.requestToken) headers['x-fraim-first-run-token'] = state.session.requestToken;
42
+ const response = await fetch(path, {
43
+ method: method || 'GET',
44
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
45
+ body: body !== undefined ? JSON.stringify(body) : undefined,
46
+ });
47
+ if (response.status === 204) return null;
48
+ const json = await response.json();
49
+ if (!response.ok) throw new Error(json && json.error ? json.error : `Request failed (status ${response.status}).`);
50
+ return json;
51
+ }
52
+
53
+ function setSessionFromActionResponse(actionResp) {
54
+ if (!state.session || !actionResp) return;
55
+ state.session.state = actionResp.state;
56
+ state.session.rows = actionResp.rows;
57
+ state.session.primaryButtonLabel = actionResp.primaryButtonLabel;
58
+ state.session.currentAgentId = actionResp.state.agentId;
59
+ }
60
+
61
+ function row(id) {
62
+ return (state.session && state.session.rows || []).find((r) => r.id === id) || null;
63
+ }
64
+
65
+ function requiredPrereqsReady() {
66
+ const node = row('node');
67
+ return Boolean(node && node.status === 'ok');
68
+ }
69
+
70
+ function readyAgents() {
71
+ const installs = state.session && state.session.state ? state.session.state.agentInstalls || {} : {};
72
+ return Object.entries(installs)
73
+ .filter(([, entry]) => entry && entry.status === 'ready')
74
+ .map(([id, entry]) => ({ id, label: entry.label }));
75
+ }
76
+
77
+ function configuredSurfaces() {
78
+ const setupResult = state.session && state.session.state ? state.session.state.setupResult : null;
79
+ return setupResult && Array.isArray(setupResult.configuredSurfaces) ? setupResult.configuredSurfaces : [];
80
+ }
81
+
82
+ function readyAgentLabels() {
83
+ const labels = new Set();
84
+ for (const agent of readyAgents()) labels.add(agent.label);
85
+ for (const surface of configuredSurfaces()) {
86
+ if (surface && surface.name) labels.add(surface.name);
87
+ }
88
+ return Array.from(labels);
89
+ }
90
+
91
+ function slugifyAgentName(name) {
92
+ return String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
93
+ }
94
+
95
+ function isAgentReady(opt) {
96
+ const installState = state.session && state.session.state && state.session.state.agentInstalls
97
+ ? state.session.state.agentInstalls[opt.id]
98
+ : null;
99
+ if (installState && installState.status === 'ready') return true;
100
+ return configuredSurfaces().some((surface) => surface && (surface.id === opt.id || surface.name === opt.label));
101
+ }
102
+
103
+ function detectedAgentCount() {
104
+ const setupResult = state.session && state.session.state ? state.session.state.setupResult : null;
105
+ return (setupResult && setupResult.detectedSurfaceCount) || readyAgents().length;
106
+ }
107
+
108
+ function configureReady() {
109
+ const fraim = row('fraim');
110
+ return Boolean(fraim && fraim.status === 'ok' && detectedAgentCount() > 0);
111
+ }
112
+
113
+ function chooseActiveStep() {
114
+ if (!requiredPrereqsReady()) return 'prereqs';
115
+ if (detectedAgentCount() < 1) return 'agents';
116
+ if (!configureReady()) return 'configure';
117
+ return 'use';
118
+ }
119
+
120
+ function iconFor(status) {
121
+ if (status === 'ok') return '✓';
122
+ if (status === 'in-progress') return '...';
123
+ if (status === 'manual-required' || status === 'error') return '!';
124
+ return '.';
125
+ }
126
+
127
+ function renderShell(renderPane) {
128
+ CHECKLIST_EL.className = 'setup-shell';
129
+ CHECKLIST_EL.innerHTML = '';
130
+ PRIMARY_BUTTON.style.display = 'none';
131
+ setHeader('Set up FRAIM', 'Complete these steps in order. FRAIM configures itself after a local AI agent is ready.');
132
+
133
+ const steps = document.createElement('div');
134
+ steps.className = 'setup-steps';
135
+ steps.setAttribute('data-testid', 'setup-stepper');
136
+
137
+ for (const step of STEP_ORDER) {
138
+ const btn = document.createElement('button');
139
+ btn.type = 'button';
140
+ btn.className = 'setup-step';
141
+ btn.setAttribute('data-step', step);
142
+ btn.setAttribute('aria-current', state.activeStep === step ? 'step' : 'false');
143
+ btn.setAttribute('data-status', stepStatus(step));
144
+ btn.disabled = (step === 'configure' && detectedAgentCount() < 1) || (step === 'use' && !configureReady());
145
+ btn.addEventListener('click', () => {
146
+ if (btn.disabled) return;
147
+ state.activeStep = step;
148
+ render();
149
+ });
150
+ const dot = document.createElement('span');
151
+ dot.className = 'step-dot';
152
+ dot.textContent = stepStatus(step) === 'done' ? '✓' : String(STEP_ORDER.indexOf(step) + 1);
153
+ const label = document.createElement('span');
154
+ label.textContent = STEP_LABELS[step];
155
+ btn.appendChild(dot);
156
+ btn.appendChild(label);
157
+ steps.appendChild(btn);
158
+ }
159
+
160
+ const pane = document.createElement('div');
161
+ pane.className = 'setup-pane';
162
+ pane.setAttribute('data-testid', 'setup-pane');
163
+ renderPane(pane);
164
+
165
+ CHECKLIST_EL.appendChild(steps);
166
+ CHECKLIST_EL.appendChild(pane);
167
+ }
168
+
169
+ function stepStatus(step) {
170
+ if (step === 'prereqs') return requiredPrereqsReady() ? 'done' : 'active';
171
+ if (step === 'agents') return detectedAgentCount() > 0 ? 'done' : 'active';
172
+ if (step === 'configure') return configureReady() ? 'done' : (detectedAgentCount() > 0 ? 'active' : 'locked');
173
+ if (step === 'use') return configureReady() ? 'active' : 'locked';
174
+ return 'active';
175
+ }
176
+
177
+ function renderPrereqs(pane) {
178
+ const h = document.createElement('h2');
179
+ h.textContent = 'Prerequisites';
180
+ pane.appendChild(h);
181
+
182
+ const list = document.createElement('ul');
183
+ list.className = 'row-list';
184
+ for (const id of ['node', 'git']) {
185
+ const r = row(id);
186
+ if (!r) continue;
187
+ const li = document.createElement('li');
188
+ li.className = 'row';
189
+ li.setAttribute('data-row-id', r.id);
190
+ li.setAttribute('data-row-status', r.status);
191
+ li.innerHTML = `<span class="icon" aria-hidden="true">${iconFor(r.status)}</span><span class="label"></span><span class="verb" data-testid="row-verb"></span>`;
192
+ li.querySelector('.label').textContent = r.label;
193
+ li.querySelector('.verb').textContent = r.verb || '';
194
+ list.appendChild(li);
195
+ }
196
+ pane.appendChild(list);
197
+
198
+ const btn = button(requiredPrereqsReady() ? 'Next' : 'Check prerequisites', 'primary');
199
+ btn.setAttribute('data-testid', 'check-prereqs');
200
+ btn.addEventListener('click', async () => {
201
+ btn.disabled = true;
202
+ try {
203
+ for (const id of ['node', 'git']) {
204
+ const r = row(id);
205
+ if (r && r.status !== 'ok') setSessionFromActionResponse(await api(`/api/first-run/rows/${id}/run`, 'POST', {}));
206
+ }
207
+ state.activeStep = 'agents';
208
+ setStatus('Prerequisites are ready.');
209
+ } catch (err) {
210
+ setStatus(err.message, 'error');
211
+ } finally {
212
+ btn.disabled = false;
213
+ render();
214
+ }
215
+ });
216
+ pane.appendChild(btn);
217
+ }
218
+
219
+ function renderAgents(pane) {
220
+ const h = document.createElement('h2');
221
+ h.textContent = 'Locally installed AI agents';
222
+ pane.appendChild(h);
223
+
224
+ const p = document.createElement('p');
225
+ p.className = 'pane-copy';
226
+ p.textContent = detectedAgentCount() > 0
227
+ ? 'At least one local AI agent is ready. You can add more agents or continue to configure FRAIM.'
228
+ : 'No problem, we will install AI agents next.';
229
+ pane.appendChild(p);
230
+
231
+ const readyLabels = readyAgentLabels();
232
+ const readyList = document.createElement('div');
233
+ readyList.className = readyLabels.length > 0 ? 'ready-strip' : 'ready-strip ready-strip--muted';
234
+ readyList.setAttribute('data-testid', 'ready-agents');
235
+ readyList.textContent = readyLabels.length > 0
236
+ ? `Already installed: ${readyLabels.join(', ')}`
237
+ : 'Already installed: none detected yet';
238
+ pane.appendChild(readyList);
239
+
240
+ const catalogHeading = document.createElement('h3');
241
+ catalogHeading.className = 'agent-section-heading';
242
+ catalogHeading.textContent = 'Supported AI tools and IDEs';
243
+ pane.appendChild(catalogHeading);
244
+
245
+ const catalogCopy = document.createElement('p');
246
+ catalogCopy.className = 'pane-copy';
247
+ catalogCopy.textContent = 'FRAIM can configure every supported local agent or IDE it detects. Download links stay available for tools not installed yet.';
248
+ pane.appendChild(catalogCopy);
249
+
250
+ const list = document.createElement('ul');
251
+ list.className = 'agent-list';
252
+ list.setAttribute('data-testid', 'supported-agent-list');
253
+ const supportedAgents = (state.session && state.session.supportedAgents) || [];
254
+ for (const agent of supportedAgents) {
255
+ const slug = slugifyAgentName(agent.name);
256
+ const detected = agent.detected || readyLabels.includes(agent.name);
257
+ const li = document.createElement('li');
258
+ li.className = 'agent-row' + (detected ? ' agent-row--detected' : '');
259
+ li.setAttribute('data-testid', `supported-agent-row-${slug}`);
260
+
261
+ const nameSpan = document.createElement('span');
262
+ nameSpan.className = 'agent-row__name';
263
+ nameSpan.textContent = agent.name;
264
+ li.appendChild(nameSpan);
265
+
266
+ const actionSpan = document.createElement('span');
267
+ actionSpan.className = 'agent-row__action';
268
+ if (detected) {
269
+ const badge = document.createElement('span');
270
+ badge.className = 'agent-badge agent-badge--installed';
271
+ badge.setAttribute('data-testid', `supported-agent-installed-${slug}`);
272
+ badge.textContent = 'Installed';
273
+ actionSpan.appendChild(badge);
274
+ } else if (agent.downloadUrl) {
275
+ const link = document.createElement('a');
276
+ link.className = 'agent-download-link';
277
+ link.href = agent.downloadUrl;
278
+ link.target = '_blank';
279
+ link.rel = 'noopener noreferrer';
280
+ link.textContent = 'Download';
281
+ link.setAttribute('data-testid', `supported-agent-download-${slug}`);
282
+ actionSpan.appendChild(link);
283
+ } else {
284
+ const unavailable = document.createElement('span');
285
+ unavailable.className = 'agent-row__muted';
286
+ unavailable.textContent = 'Manual setup';
287
+ actionSpan.appendChild(unavailable);
288
+ }
289
+ li.appendChild(actionSpan);
290
+ list.appendChild(li);
291
+ }
292
+ pane.appendChild(list);
293
+
294
+ const rescanBtn = button('Rescan supported tools', 'ghost');
295
+ rescanBtn.setAttribute('data-testid', 'rescan-agents');
296
+ rescanBtn.addEventListener('click', async () => {
297
+ rescanBtn.disabled = true;
298
+ rescanBtn.textContent = 'Scanning...';
299
+ try { await loadSession(false); } finally {
300
+ state.activeStep = 'agents';
301
+ render();
302
+ }
303
+ });
304
+ pane.appendChild(rescanBtn);
305
+
306
+ const hubHeading = document.createElement('h3');
307
+ hubHeading.className = 'agent-section-heading';
308
+ hubHeading.textContent = 'Hub-ready CLI agents';
309
+ pane.appendChild(hubHeading);
310
+
311
+ const hubCopy = document.createElement('p');
312
+ hubCopy.className = 'pane-copy';
313
+ hubCopy.textContent = 'FRAIM Hub runs jobs through CLI agents. Set up at least one of these if you plan to launch jobs in Hub.';
314
+ pane.appendChild(hubCopy);
315
+
316
+ const grid = document.createElement('div');
317
+ grid.className = 'agent-grid';
318
+ const agentOptions = state.session.agentOptions || [];
319
+ for (const opt of agentOptions) {
320
+ const card = document.createElement('div');
321
+ card.className = 'user-type-card recruit-card';
322
+ card.setAttribute('data-agent-id', opt.id);
323
+ card.setAttribute('data-testid', `agent-card-${opt.id}`);
324
+ const agentReady = isAgentReady(opt);
325
+
326
+ const title = document.createElement('strong');
327
+ title.className = 'card-title';
328
+ title.textContent = opt.label;
329
+
330
+ const desc = document.createElement('p');
331
+ desc.className = 'card-desc';
332
+ desc.textContent = agentReady
333
+ ? 'Already installed and ready for FRAIM.'
334
+ : `Available to set up: install, sign in, and verify ${opt.label}.`;
335
+
336
+ const action = button(agentReady ? 'Ready' : 'Set up', 'secondary');
337
+ action.disabled = agentReady;
338
+ action.setAttribute('data-testid', `install-${opt.id}`);
339
+ action.addEventListener('click', () => openAgentModal(opt));
340
+
341
+ card.appendChild(title);
342
+ card.appendChild(desc);
343
+ card.appendChild(action);
344
+ grid.appendChild(card);
345
+ }
346
+
347
+ const byoa = document.createElement('div');
348
+ byoa.className = 'user-type-card recruit-card byoa-card';
349
+ byoa.innerHTML = '<strong class="card-title">Bring Your Own Agent</strong><p class="card-desc">Use Cursor, Windsurf, Kiro, VS Code, or another supported local AI tool. If you install a new agent later and want FRAIM to use it, run this command from your project.</p><div class="cmd-block">npx fraim add-ide</div>';
350
+ grid.appendChild(byoa);
351
+ pane.appendChild(grid);
352
+
353
+ const done = button('Next', 'primary');
354
+ done.setAttribute('data-testid', 'done-installing-agents');
355
+ done.disabled = detectedAgentCount() < 1;
356
+ done.addEventListener('click', () => { state.activeStep = 'configure'; render(); });
357
+ pane.appendChild(done);
358
+
359
+ if (detectedAgentCount() < 1) {
360
+ const locked = document.createElement('p');
361
+ locked.className = 'locked-note';
362
+ locked.textContent = 'Configure FRAIM stays locked until one local AI agent is ready.';
363
+ pane.appendChild(locked);
364
+ }
365
+ }
366
+
367
+ async function configureFraim() {
368
+ if (state.configureInFlight) return;
369
+ state.configureInFlight = true;
370
+ state.configureError = null;
371
+ render();
372
+ setStatus('Configuring FRAIM for your local AI agents...');
373
+ try {
374
+ const resp = await api('/api/first-run/done-recruiting', 'POST', {});
375
+ setSessionFromActionResponse(resp);
376
+ setStatus(resp.message, resp.ok ? null : 'error');
377
+ state.activeStep = 'configure';
378
+ state.configureError = resp && resp.ok ? null : (resp && resp.message ? resp.message : 'FRAIM setup did not complete.');
379
+ } catch (err) {
380
+ state.configureError = err.message;
381
+ setStatus(state.configureError, 'error');
382
+ } finally {
383
+ state.configureInFlight = false;
384
+ render();
385
+ }
386
+ }
387
+
388
+ function renderConfigure(pane) {
389
+ const h = document.createElement('h2');
390
+ h.textContent = 'Configure FRAIM';
391
+ pane.appendChild(h);
392
+
393
+ if (detectedAgentCount() < 1) {
394
+ const p = document.createElement('p');
395
+ p.className = 'pane-copy';
396
+ p.textContent = 'A local AI agent is required before FRAIM setup can finish.';
397
+ pane.appendChild(p);
398
+ const back = button('Choose a local agent', 'primary');
399
+ back.addEventListener('click', () => { state.activeStep = 'agents'; render(); });
400
+ pane.appendChild(back);
401
+ return;
402
+ }
403
+
404
+ if (!configureReady()) {
405
+ const p = document.createElement('p');
406
+ p.className = 'pane-copy';
407
+ p.textContent = state.configureError
408
+ ? 'FRAIM setup needs attention before you continue.'
409
+ : 'FRAIM is configuring MCP and local setup files for every ready agent.';
410
+ pane.appendChild(p);
411
+ const status = document.createElement('div');
412
+ status.className = state.configureError ? 'ready-strip ready-strip--error' : 'ready-strip';
413
+ status.textContent = state.configureError || 'Configuring FRAIM...';
414
+ pane.appendChild(status);
415
+ if (state.configureError) {
416
+ const retry = button('Try again', 'primary');
417
+ retry.addEventListener('click', configureFraim);
418
+ pane.appendChild(retry);
419
+ } else if (!state.configureInFlight) {
420
+ window.setTimeout(configureFraim, 0);
421
+ }
422
+ return;
423
+ }
424
+
425
+ const success = document.createElement('div');
426
+ success.className = 'ready-strip';
427
+ success.textContent = 'FRAIM setup succeeded for your ready local AI agents.';
428
+ pane.appendChild(success);
429
+ const next = button('Next', 'primary');
430
+ next.addEventListener('click', () => { state.activeStep = 'use'; render(); });
431
+ pane.appendChild(next);
432
+ }
433
+
434
+ function renderUseFraim(pane) {
435
+ const h = document.createElement('h2');
436
+ h.textContent = 'Use FRAIM';
437
+ pane.appendChild(h);
438
+
439
+ const p = document.createElement('p');
440
+ p.className = 'pane-copy';
441
+ p.textContent = 'FRAIM is set up to use your ready local AI agents. Choose where you want to work.';
442
+ pane.appendChild(p);
443
+ renderStartWorkingChoices(pane);
444
+ }
445
+
446
+ function renderStartWorkingChoices(pane) {
447
+ const choices = document.createElement('div');
448
+ choices.className = 'choice-grid';
449
+
450
+ const ideCard = document.createElement('div');
451
+ ideCard.className = 'user-type-card user-type-card--featured';
452
+ ideCard.innerHTML = '<strong class="card-title">In my IDE</strong><p class="card-desc">Use FRAIM inside Claude Code, Codex, Cursor, or the local agent tool you already use.</p>';
453
+ const ideBtn = button('Continue in my IDE', 'primary');
454
+ ideBtn.addEventListener('click', async () => {
455
+ try { await api('/api/first-run/set-preference', 'POST', { choice: 'ide' }); } catch (_) {}
456
+ const ideData = await api('/api/first-run/ide-commands');
457
+ renderIdeCommandDisplay(ideData ? ideData.commands : []);
458
+ });
459
+ ideCard.appendChild(ideBtn);
460
+
461
+ const hubCard = document.createElement('div');
462
+ hubCard.className = 'user-type-card';
463
+ hubCard.innerHTML = '<strong class="card-title">In FRAIM Hub</strong><p class="card-desc">Open the Company, Manager, and Projects shell.</p>';
464
+ const hubBtn = button('Open FRAIM Hub', 'secondary');
465
+ hubBtn.addEventListener('click', async () => {
466
+ try { await api('/api/first-run/set-preference', 'POST', { choice: 'hub' }); } catch (_) {}
467
+ try {
468
+ hubBtn.disabled = true;
469
+ setStatus('Opening the FRAIM Hub desktop app...');
470
+ const openResp = await api('/api/first-run/open-hub', 'POST');
471
+ if (openResp && openResp.needsAgentSetup) {
472
+ state.activeStep = 'agents';
473
+ render();
474
+ setStatus(openResp.message, 'error');
475
+ return;
476
+ }
477
+ if (openResp && openResp.hubUrl) {
478
+ setStatus('FRAIM is ready. Opening Hub in this app...');
479
+ window.location.replace(openResp.hubUrl);
480
+ return;
481
+ }
482
+ // Issue #866 R1: the Hub opens as the Electron desktop app, so the
483
+ // first-run tab does not navigate itself to a browser Hub. Confirm and
484
+ // let the user close this tab.
485
+ setStatus((openResp && openResp.message) || 'FRAIM Hub is opening in the desktop app. You can close this tab.');
486
+ } catch (err) {
487
+ hubBtn.disabled = false;
488
+ setStatus(err.message, 'error');
489
+ }
490
+ });
491
+ hubCard.appendChild(hubBtn);
492
+ choices.appendChild(ideCard);
493
+ choices.appendChild(hubCard);
494
+ pane.appendChild(choices);
495
+ }
496
+
497
+ function renderIdeCommandDisplay(commands) {
498
+ renderShell((pane) => {
499
+ const h = document.createElement('h2');
500
+ h.textContent = 'Continue in your IDE';
501
+ pane.appendChild(h);
502
+ const copy = document.createElement('p');
503
+ copy.className = 'pane-copy';
504
+ copy.textContent = 'Restart your IDE so it picks up FRAIM setup, then run:';
505
+ pane.appendChild(copy);
506
+ const cmdList = (commands && commands.length > 0) ? commands : ['/fraim onboard this project'];
507
+ for (const cmd of cmdList) {
508
+ const row = document.createElement('div');
509
+ row.className = 'command-row';
510
+ const block = document.createElement('div');
511
+ block.className = 'cmd-block';
512
+ block.textContent = cmd;
513
+ const copyBtn = button('Copy', 'secondary');
514
+ copyBtn.setAttribute('aria-label', `Copy command: ${cmd}`);
515
+ copyBtn.addEventListener('click', async () => {
516
+ await navigator.clipboard.writeText(cmd);
517
+ copyBtn.textContent = 'Copied';
518
+ setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
519
+ });
520
+ row.appendChild(block);
521
+ row.appendChild(copyBtn);
522
+ pane.appendChild(row);
523
+ }
524
+ const back = button('Back to choices', 'secondary');
525
+ back.setAttribute('data-testid', 'back-to-use-fraim');
526
+ back.addEventListener('click', () => { state.activeStep = 'use'; render(); });
527
+ pane.appendChild(back);
528
+ });
529
+ }
530
+
531
+ function openAgentModal(opt) {
532
+ const overlay = document.createElement('div');
533
+ overlay.className = 'modal-backdrop';
534
+ overlay.setAttribute('data-testid', 'agent-install-modal');
535
+ const modal = document.createElement('div');
536
+ modal.className = 'modal';
537
+ modal.setAttribute('role', 'dialog');
538
+ modal.setAttribute('aria-modal', 'true');
539
+ modal.innerHTML = `<h2>Set up ${escapeHtml(opt.label)}</h2><p class="install-status" data-testid="agent-install-status">Ready to set up ${escapeHtml(opt.label)}.</p><p class="modal-help">Setup installs the CLI, opens sign-in, and verifies the agent is available before FRAIM uses it.</p>`;
540
+ const status = modal.querySelector('[data-testid="agent-install-status"]');
541
+ const actions = document.createElement('div');
542
+ actions.className = 'install-actions';
543
+
544
+ const install = button('Set up', 'primary');
545
+ const signIn = button(`Sign In to ${opt.label}`, 'secondary');
546
+ const check = button('Check readiness', 'secondary');
547
+ const close = button('Close', 'ghost');
548
+ signIn.disabled = true;
549
+ check.disabled = true;
550
+ close.addEventListener('click', () => { overlay.remove(); render(); });
551
+
552
+ install.addEventListener('click', async () => {
553
+ install.disabled = true;
554
+ status.textContent = `Setting up ${opt.label}...`;
555
+ try {
556
+ const result = await api('/api/first-run/install-agent', 'POST', { agentId: opt.id });
557
+ if (!result || !result.ok) throw new Error(result && result.message ? result.message : 'Setup failed.');
558
+ status.textContent = `${opt.label} setup started. Sign in next.`;
559
+ signIn.disabled = false;
560
+ await loadSession(false);
561
+ } catch (err) {
562
+ install.disabled = false;
563
+ install.textContent = 'Retry';
564
+ status.textContent = err.message;
565
+ status.setAttribute('data-tone', 'error');
566
+ showAgentInstallError(modal, opt, err.message, install, close);
567
+ }
568
+ });
569
+
570
+ signIn.addEventListener('click', async () => {
571
+ signIn.disabled = true;
572
+ status.textContent = 'Opening terminal for sign-in...';
573
+ try {
574
+ const result = await api('/api/first-run/trigger-agent-login', 'POST', { agentId: opt.id });
575
+ status.textContent = result && result.message ? result.message : 'Complete sign-in, then check readiness.';
576
+ check.disabled = false;
577
+ } catch (err) {
578
+ signIn.disabled = false;
579
+ status.textContent = err.message;
580
+ status.setAttribute('data-tone', 'error');
581
+ }
582
+ });
583
+
584
+ check.addEventListener('click', async () => {
585
+ check.disabled = true;
586
+ status.textContent = 'Checking that the CLI is installed, signed in, and available on PATH...';
587
+ try {
588
+ const result = await api('/api/first-run/check-agent', 'POST', { agentId: opt.id });
589
+ if (result && result.ready) {
590
+ status.textContent = `${opt.label} is ready!`;
591
+ await loadSession(false);
592
+ setTimeout(() => { overlay.remove(); state.activeStep = 'agents'; render(); }, 250);
593
+ return;
594
+ }
595
+ status.textContent = result && result.message ? result.message : `${opt.label} is not ready yet.`;
596
+ status.setAttribute('data-tone', 'error');
597
+ check.disabled = false;
598
+ } catch (err) {
599
+ status.textContent = err.message;
600
+ status.setAttribute('data-tone', 'error');
601
+ check.disabled = false;
602
+ }
603
+ });
604
+
605
+ actions.appendChild(install);
606
+ actions.appendChild(signIn);
607
+ actions.appendChild(check);
608
+ actions.appendChild(close);
609
+ modal.appendChild(actions);
610
+ overlay.appendChild(modal);
611
+ document.body.appendChild(overlay);
612
+ install.focus();
613
+ }
614
+
615
+ function showAgentInstallError(modal, opt, message, retryButton, closeButton) {
616
+ const existing = modal.querySelector('[data-testid="error-frame"]');
617
+ if (existing) existing.remove();
618
+ if (!window.FraimErrorFrame || typeof window.FraimErrorFrame.render !== 'function') return;
619
+ const frame = window.FraimErrorFrame.render({
620
+ whatTried: `We tried to set up ${opt.label}.`,
621
+ whatHappened: message,
622
+ actions: [
623
+ { id: 'retry', label: 'Retry', variant: 'primary' },
624
+ { id: 'alternative', label: 'Choose another agent', variant: 'secondary' },
625
+ { id: 'manual', label: 'Manual setup help', variant: 'ghost' },
626
+ ],
627
+ }, (action) => {
628
+ if (action.id === 'retry') {
629
+ retryButton.click();
630
+ } else if (action.id === 'alternative') {
631
+ closeButton.click();
632
+ } else if (action.id === 'manual') {
633
+ const status = modal.querySelector('[data-testid="agent-install-status"]');
634
+ if (status) {
635
+ status.textContent = 'Run npx fraim add-ide for manual setup, then return here when your local agent is ready.';
636
+ status.removeAttribute('data-tone');
637
+ }
638
+ }
639
+ });
640
+ modal.appendChild(frame);
641
+ }
642
+
643
+ function button(text, variant) {
644
+ const btn = document.createElement('button');
645
+ btn.type = 'button';
646
+ btn.className = variant === 'primary' ? 'btn btn-primary' : variant === 'ghost' ? 'btn btn-ghost' : 'btn btn-secondary';
647
+ btn.textContent = text;
648
+ return btn;
649
+ }
650
+
651
+ function escapeHtml(value) {
652
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
653
+ }
654
+
655
+ async function loadSession(shouldRender) {
656
+ state.session = await api('/api/first-run/session');
657
+ if (!state.session.state.agentInstalls) state.session.state.agentInstalls = {};
658
+ if (shouldRender !== false) {
659
+ state.activeStep = chooseActiveStep();
660
+ render();
661
+ }
662
+ }
663
+
664
+ // Issue #646: when first-run was launched without a key (the no-terminal macOS
665
+ // installer path), gate the whole wizard behind a paste-your-key step.
666
+ function renderKeyEntry() {
667
+ CHECKLIST_EL.className = 'setup-shell';
668
+ CHECKLIST_EL.innerHTML = '';
669
+ PRIMARY_BUTTON.style.display = 'none';
670
+ setHeader('Set up FRAIM', 'Paste the FRAIM key from your account page to get started.');
671
+
672
+ const card = document.createElement('div');
673
+ card.className = 'setup-pane';
674
+ card.setAttribute('data-testid', 'key-entry');
675
+
676
+ const label = document.createElement('label');
677
+ label.className = 'pane-copy';
678
+ label.setAttribute('for', 'fraim-key-input');
679
+ label.textContent = 'Your FRAIM key';
680
+ card.appendChild(label);
681
+
682
+ const input = document.createElement('input');
683
+ input.type = 'text';
684
+ input.id = 'fraim-key-input';
685
+ input.className = 'key-input';
686
+ input.placeholder = 'fraim_…';
687
+ input.autocapitalize = 'off';
688
+ input.autocomplete = 'off';
689
+ input.spellcheck = false;
690
+ input.setAttribute('data-testid', 'key-input');
691
+ card.appendChild(input);
692
+
693
+ const err = document.createElement('p');
694
+ err.className = 'locked-note';
695
+ err.setAttribute('data-testid', 'key-error');
696
+ err.hidden = true;
697
+ card.appendChild(err);
698
+
699
+ const submit = button('Continue', 'primary');
700
+ submit.setAttribute('data-testid', 'key-submit');
701
+ const onSubmit = async () => {
702
+ const value = input.value.trim();
703
+ err.hidden = true;
704
+ submit.disabled = true;
705
+ submit.textContent = 'Checking…';
706
+ try {
707
+ const resp = await api('/api/first-run/set-key', 'POST', { key: value });
708
+ state.session = resp.session;
709
+ if (!state.session.state.agentInstalls) state.session.state.agentInstalls = {};
710
+ state.activeStep = chooseActiveStep();
711
+ render();
712
+ } catch (e) {
713
+ err.textContent = e.message || 'That key was not accepted. Copy it again from your account page.';
714
+ err.hidden = false;
715
+ submit.disabled = false;
716
+ submit.textContent = 'Continue';
717
+ }
718
+ };
719
+ submit.addEventListener('click', onSubmit);
720
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') onSubmit(); });
721
+ card.appendChild(submit);
722
+
723
+ CHECKLIST_EL.appendChild(card);
724
+ input.focus();
725
+ }
726
+
727
+ function render() {
728
+ if (!state.session) return;
729
+ if (state.session.needsKey) { renderKeyEntry(); return; }
730
+ if (!STEP_ORDER.includes(state.activeStep)) state.activeStep = chooseActiveStep();
731
+ renderShell((pane) => {
732
+ if (state.activeStep === 'prereqs') renderPrereqs(pane);
733
+ else if (state.activeStep === 'agents') renderAgents(pane);
734
+ else if (state.activeStep === 'configure') renderConfigure(pane);
735
+ else renderUseFraim(pane);
736
+ });
737
+ }
738
+
739
+ PRIMARY_BUTTON.addEventListener('click', () => {
740
+ state.activeStep = chooseActiveStep();
741
+ render();
742
+ });
743
+
744
+ loadSession().catch((err) => {
745
+ setStatus(err.message || 'Could not load first-run.', 'error');
746
+ });
747
+ }());