fraim 2.0.129 → 2.0.130

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.
@@ -10,18 +10,17 @@
10
10
  <main class="page">
11
11
  <header class="page-header">
12
12
  <h1>Set up FRAIM</h1>
13
- <p class="lede" id="lede">We're getting your machine ready. Sit tight — we'll only need you for one step.</p>
13
+ <p class="lede" id="lede">We are getting your machine ready for FRAIM.</p>
14
14
  </header>
15
15
 
16
16
  <section class="card">
17
17
  <ul class="checklist" id="checklist" data-testid="setup-checklist" aria-label="FRAIM setup checklist">
18
18
  <!-- Skeleton rows shown until the async session-load completes.
19
19
  Replaced wholesale by script.js once /api/first-run/session returns. -->
20
- <li class="row skeleton-row" data-row-id="node" data-row-status="pending"><span class="icon" aria-hidden="true">·</span><span class="label">Node.js</span><span class="verb" data-testid="row-verb">checking…</span></li>
21
- <li class="row skeleton-row" data-row-id="git" data-row-status="pending"><span class="icon" aria-hidden="true">·</span><span class="label">git</span><span class="verb" data-testid="row-verb">checking…</span></li>
22
- <li class="row skeleton-row" data-row-id="agent" data-row-status="pending"><span class="icon" aria-hidden="true">·</span><span class="label">AI agent</span><span class="verb" data-testid="row-verb">checking…</span></li>
23
- <li class="row skeleton-row" data-row-id="agent-login" data-row-status="pending"><span class="icon" aria-hidden="true">·</span><span class="label">Sign in</span><span class="verb" data-testid="row-verb">checking…</span></li>
24
- <li class="row skeleton-row" data-row-id="project" data-row-status="pending"><span class="icon" aria-hidden="true">·</span><span class="label">Project folder</span><span class="verb" data-testid="row-verb">checking…</span></li>
20
+ <li class="row skeleton-row" data-row-id="node" data-row-status="pending"><span class="icon" aria-hidden="true">.</span><span class="label">Node.js</span><span class="verb" data-testid="row-verb">checking...</span></li>
21
+ <li class="row skeleton-row" data-row-id="git" data-row-status="pending"><span class="icon" aria-hidden="true">.</span><span class="label">git</span><span class="verb" data-testid="row-verb">checking...</span></li>
22
+ <li class="row skeleton-row" data-row-id="fraim" data-row-status="pending"><span class="icon" aria-hidden="true">.</span><span class="label">FRAIM</span><span class="verb" data-testid="row-verb">checking...</span></li>
23
+ <li class="row skeleton-row" data-row-id="agent" data-row-status="pending"><span class="icon" aria-hidden="true">.</span><span class="label">AI Employees</span><span class="verb" data-testid="row-verb">checking...</span></li>
25
24
  </ul>
26
25
  <div class="actions">
27
26
  <button id="primary-button" class="primary-button" data-testid="primary-button">Set up FRAIM</button>
@@ -8,40 +8,43 @@
8
8
 
9
9
  const state = {
10
10
  session: null,
11
- activeAgentPickerRowId: null,
12
11
  runningRowId: null,
13
12
  };
14
13
 
15
14
  function setStatus(text, tone) {
16
15
  STATUS_EL.textContent = text || '';
17
- if (tone) {
18
- STATUS_EL.setAttribute('data-tone', tone);
19
- } else {
20
- STATUS_EL.removeAttribute('data-tone');
21
- }
16
+ if (tone) STATUS_EL.setAttribute('data-tone', tone);
17
+ else STATUS_EL.removeAttribute('data-tone');
18
+ }
19
+
20
+ function setHeader(title, lede) {
21
+ const h1El = document.querySelector('.page-header h1');
22
+ if (h1El) h1El.textContent = title;
23
+ if (LEDE_EL) LEDE_EL.textContent = lede;
22
24
  }
23
25
 
24
26
  function applyHeading(rows) {
25
27
  if (!LEDE_EL) return;
26
- if (rows.every((r) => r.status === 'ok')) {
27
- LEDE_EL.textContent = 'You\'re ready. Open the Hub to get started.';
28
+ const complete = rows.every((r) => r.status === 'ok' || r.status === 'manual-required') && rows.some((r) => r.status === 'ok');
29
+ if (complete) {
30
+ const setupResult = state.session && state.session.state ? state.session.state.setupResult : null;
31
+ if (setupResult && setupResult.detectedSurfaceCount > 0) {
32
+ LEDE_EL.textContent = 'The following AI agents are now ready to function as your AI Employees.';
33
+ } else {
34
+ LEDE_EL.textContent = "You do not seem to have any AI agents on this machine.";
35
+ }
28
36
  return;
29
37
  }
30
38
  const errored = rows.find((r) => r.status === 'error');
31
39
  if (errored) {
32
- LEDE_EL.textContent = 'Something didn\'t go through. Pick the right next step below.';
33
- return;
34
- }
35
- const onlyProjectLeft = rows.filter((r) => r.status !== 'ok').every((r) => r.id === 'project');
36
- if (onlyProjectLeft) {
37
- LEDE_EL.textContent = 'Almost there — pick a project folder where FRAIM should work.';
40
+ LEDE_EL.textContent = "Something did not go through. Pick the right next step below.";
38
41
  return;
39
42
  }
40
43
  if (rows.some((r) => r.status === 'ok')) {
41
- LEDE_EL.textContent = 'Some pieces are already on your machine. We\'re finishing the rest now.';
44
+ LEDE_EL.textContent = 'FRAIM is finishing setup on this machine.';
42
45
  return;
43
46
  }
44
- LEDE_EL.textContent = 'We\'re getting your machine ready. Sit tight — we\'ll only need you for one step.';
47
+ LEDE_EL.textContent = 'We are getting your machine ready for FRAIM.';
45
48
  }
46
49
 
47
50
  async function api(path, method, body) {
@@ -74,8 +77,6 @@
74
77
  }
75
78
 
76
79
  function setSessionFromActionResponse(actionResp) {
77
- // Action responses include rows + primaryButtonLabel + state but not
78
- // every session-level field. Merge into the held session view.
79
80
  if (!state.session) return;
80
81
  state.session.state = actionResp.state;
81
82
  state.session.rows = actionResp.rows;
@@ -83,20 +84,16 @@
83
84
  state.session.currentAgentId = actionResp.state.agentId;
84
85
  }
85
86
 
86
- function findRow(rowId) {
87
- return state.session.rows.find((row) => row.id === rowId);
88
- }
89
-
90
87
  function makeIcon(status) {
91
88
  const icon = document.createElement('span');
92
89
  icon.className = 'icon';
93
90
  icon.setAttribute('aria-hidden', 'true');
94
91
  icon.textContent =
95
92
  status === 'ok' ? '✓' :
96
- status === 'in-progress' ? '' :
93
+ status === 'in-progress' ? '...' :
97
94
  status === 'manual-required' ? '!' :
98
95
  status === 'error' ? '!' :
99
- '·';
96
+ '.';
100
97
  return icon;
101
98
  }
102
99
 
@@ -127,75 +124,6 @@
127
124
  li.appendChild(detail);
128
125
  }
129
126
 
130
- // Inline `Change…` link on the agent row
131
- if (row.id === 'agent') {
132
- const change = document.createElement('button');
133
- change.type = 'button';
134
- change.className = 'change-link';
135
- change.setAttribute('data-testid', 'change-agent');
136
- change.textContent = 'Change…';
137
- change.addEventListener('click', () => toggleAgentPicker(row.id));
138
- li.appendChild(change);
139
- if (state.activeAgentPickerRowId === row.id) {
140
- li.appendChild(renderAgentPicker());
141
- }
142
- }
143
-
144
- // Project picker affordance for the project row
145
- if (row.id === 'project' && row.status !== 'ok') {
146
- const picker = document.createElement('div');
147
- picker.className = 'project-picker';
148
- picker.setAttribute('data-testid', 'project-picker');
149
- const input = document.createElement('input');
150
- input.type = 'text';
151
- input.placeholder = 'C:\\Projects\\my-project or /Users/you/Projects/my-project';
152
- input.value = state.session.state.workspacePath || '';
153
- input.id = 'project-path-input';
154
- const browse = document.createElement('button');
155
- browse.type = 'button';
156
- browse.className = 'browse';
157
- browse.textContent = 'Browse';
158
- browse.addEventListener('click', async () => {
159
- try {
160
- const picked = await api('/api/first-run/project-path/pick', 'POST');
161
- if (picked && picked.path) input.value = picked.path;
162
- } catch (err) {
163
- setStatus(err.message, 'error');
164
- }
165
- });
166
- picker.appendChild(input);
167
- picker.appendChild(browse);
168
- li.appendChild(picker);
169
-
170
- // Preflight summary — show the user what FRAIM will do to their machine
171
- // before they click Continue. Without this, a non-tech user has no way
172
- // to know we're about to write to ~/.claude.json, drop GitHub workflow
173
- // files, and call the registry. The "no surprises" half of the
174
- // non-tech-user invariant lives here.
175
- const preflight = document.createElement('div');
176
- preflight.className = 'preflight-summary';
177
- preflight.setAttribute('data-testid', 'preflight-summary');
178
- const heading = document.createElement('div');
179
- heading.className = 'preflight-heading';
180
- heading.textContent = 'When you click Continue, FRAIM will:';
181
- preflight.appendChild(heading);
182
- const items = [
183
- 'Configure your AI agent so it can talk to FRAIM (a backup of your existing config is saved first).',
184
- 'Create a fraim/ folder inside the project you picked, with the workflow templates.',
185
- 'If the project has a GitHub remote, install the FRAIM GitHub Action and labels.',
186
- 'Download the latest FRAIM jobs and skills from the registry (uses your install key).',
187
- ];
188
- const ul = document.createElement('ul');
189
- ul.className = 'preflight-list';
190
- for (const text of items) {
191
- const item = document.createElement('li');
192
- item.textContent = text;
193
- ul.appendChild(item);
194
- }
195
- preflight.appendChild(ul);
196
- li.appendChild(preflight);
197
- }
198
-
199
127
  if (row.streamOutput) {
200
128
  const stream = document.createElement('pre');
201
129
  stream.className = 'row-stream';
@@ -219,105 +147,45 @@
219
147
  return li;
220
148
  }
221
149
 
222
- function renderAgentPicker() {
223
- const wrap = document.createElement('div');
224
- wrap.className = 'agent-picker';
225
- wrap.setAttribute('data-testid', 'agent-picker');
150
+ function renderSetupSummary() {
151
+ const setupResult = state.session && state.session.state ? state.session.state.setupResult : null;
152
+ if (!setupResult) return null;
226
153
 
227
- const header = document.createElement('div');
228
- header.textContent = 'Pick the AI agent FRAIM should set up:';
229
- header.style.color = 'var(--muted)';
230
- header.style.fontSize = '13px';
231
- wrap.appendChild(header);
232
-
233
- const optionsWrap = document.createElement('div');
234
- optionsWrap.className = 'options';
235
- for (const option of state.session.agentOptions) {
236
- const btn = document.createElement('button');
237
- btn.type = 'button';
238
- btn.className = 'option';
239
- btn.setAttribute('data-agent-id', option.id);
240
- btn.setAttribute('aria-pressed', String(option.id === state.session.currentAgentId));
241
- btn.textContent = option.label;
242
- btn.addEventListener('click', async () => {
243
- try {
244
- const resp = await api('/api/first-run/agent/change', 'POST', { agentId: option.id });
245
- setSessionFromActionResponse(resp);
246
- state.activeAgentPickerRowId = null;
247
- render();
248
- setStatus(resp.message || `Selected ${option.label}.`);
249
- } catch (err) {
250
- setStatus(err.message, 'error');
251
- }
252
- });
253
- optionsWrap.appendChild(btn);
254
- }
255
- wrap.appendChild(optionsWrap);
256
-
257
- const advanced = document.createElement('details');
258
- const summary = document.createElement('summary');
259
- summary.textContent = 'Advanced: I have a different CLI';
260
- advanced.appendChild(summary);
261
- const warning = document.createElement('div');
262
- warning.className = 'custom-warning';
263
- warning.textContent = 'FRAIM will not auto-invoke unknown CLIs in v1. You will need to invoke this CLI manually from the Hub.';
264
- advanced.appendChild(warning);
265
- const form = document.createElement('div');
266
- form.className = 'custom-form';
267
- const nameInput = document.createElement('input');
268
- nameInput.type = 'text';
269
- nameInput.placeholder = 'CLI name (e.g. my-agent)';
270
- const prefixInput = document.createElement('input');
271
- prefixInput.type = 'text';
272
- prefixInput.placeholder = 'Invocation prefix (e.g. $my-agent run)';
273
- const submit = document.createElement('button');
274
- submit.type = 'button';
275
- submit.textContent = 'Use this CLI';
276
- submit.addEventListener('click', async () => {
277
- const name = nameInput.value.trim();
278
- const invocationPrefix = prefixInput.value.trim();
279
- if (!name) {
280
- setStatus('Custom CLI name is required.', 'error');
281
- return;
282
- }
283
- try {
284
- const resp = await api('/api/first-run/agent/change', 'POST', { customAgent: { name, invocationPrefix } });
285
- setSessionFromActionResponse(resp);
286
- state.activeAgentPickerRowId = null;
287
- render();
288
- setStatus(resp.message);
289
- } catch (err) {
290
- setStatus(err.message, 'error');
154
+ const li = document.createElement('li');
155
+ li.className = 'setup-result';
156
+ li.setAttribute('data-testid', 'setup-result');
157
+
158
+ const title = document.createElement('strong');
159
+ title.textContent = setupResult.detectedSurfaceCount > 0
160
+ ? 'The following AI agents are now ready to function as your AI Employees'
161
+ : "You do not seem to have any AI agents on this machine";
162
+ li.appendChild(title);
163
+
164
+ if (setupResult.detectedSurfaceCount > 0) {
165
+ const list = document.createElement('ul');
166
+ list.className = 'surface-list';
167
+ for (const surface of setupResult.configuredSurfaces || []) {
168
+ const item = document.createElement('li');
169
+ item.textContent = surface.name;
170
+ list.appendChild(item);
291
171
  }
292
- });
293
- form.appendChild(nameInput);
294
- form.appendChild(prefixInput);
295
- form.appendChild(submit);
296
- advanced.appendChild(form);
297
- wrap.appendChild(advanced);
298
-
299
- return wrap;
300
- }
172
+ li.appendChild(list);
173
+ } else {
174
+ const copy = document.createElement('p');
175
+ copy.textContent = "Let's recruit some agents who can function as your AI Employees.";
176
+ li.appendChild(copy);
177
+ }
301
178
 
302
- function toggleAgentPicker(rowId) {
303
- state.activeAgentPickerRowId = state.activeAgentPickerRowId === rowId ? null : rowId;
304
- render();
179
+ return li;
305
180
  }
306
181
 
307
182
  async function runRow(rowId, extraBody) {
308
183
  if (state.runningRowId) return;
309
184
  state.runningRowId = rowId;
310
185
  PRIMARY_BUTTON.disabled = true;
311
- setStatus(`Running ${rowId}…`);
186
+ setStatus(`Running ${rowId}...`);
312
187
  try {
313
- const body = extraBody || {};
314
- if (rowId === 'project') {
315
- const input = document.getElementById('project-path-input');
316
- if (input && input.value.trim()) {
317
- body.projectPath = input.value.trim();
318
- }
319
- }
320
- const resp = await api(`/api/first-run/rows/${rowId}/run`, 'POST', body);
188
+ const resp = await api(`/api/first-run/rows/${rowId}/run`, 'POST', extraBody || {});
321
189
  setSessionFromActionResponse(resp);
322
190
  setStatus(resp.message, resp.ok ? null : 'error');
323
191
  } catch (err) {
@@ -339,83 +207,252 @@
339
207
 
340
208
  async function onPrimaryClick() {
341
209
  if (!state.session) return;
342
- const rows = state.session.rows;
343
- const projectRow = rows.find((r) => r.id === 'project');
344
- const projectOk = projectRow && projectRow.status === 'ok';
345
- const allOk = rows.every((r) => r.status === 'ok');
346
- // Open-Hub gate: either everything is genuinely ok, or the user has
347
- // explicitly chosen to handle the remaining steps themselves
348
- // (Skip-and-continue → manual-required) and the project is ready.
349
- const skipPathDone = projectOk && rows.every((r) => r.status === 'ok' || r.status === 'manual-required');
350
-
351
- if (allOk || skipPathDone) {
210
+
211
+ if (state.session.primaryButtonLabel === 'Get Started') {
212
+ PRIMARY_BUTTON.disabled = true;
213
+ const setupResult = state.session.state ? state.session.state.setupResult : null;
214
+ if (setupResult && setupResult.detectedSurfaceCount === 0) {
215
+ renderRecruitAgents();
216
+ return;
217
+ }
352
218
  try {
353
- // /open-hub starts the Hub server in-process AND writes the
354
- // next-prompt artifact (the work /finish used to do). We do NOT
355
- // call /finish separately — that would race against the Hub
356
- // start (server.stop() would kill the just-spawned Hub).
357
- PRIMARY_BUTTON.disabled = true;
358
- setStatus('Opening Hub…');
359
- const openResp = await api('/api/first-run/open-hub', 'POST');
360
- if (openResp && openResp.message) setStatus(openResp.message);
361
- if (openResp && openResp.hubUrl) {
362
- // Redirect this tab to the Hub. The Hub server is in-process
363
- // with the wizard server, so navigating away keeps the same
364
- // Node process serving — no broken handoff.
365
- window.location.replace(openResp.hubUrl);
366
- }
367
- } catch (err) {
368
- PRIMARY_BUTTON.disabled = false;
369
- setStatus(err.message, 'error');
219
+ const ideData = await api('/api/first-run/ide-commands');
220
+ renderStartWorking(ideData ? ideData.commands : []);
221
+ } catch (_err) {
222
+ renderStartWorking([]);
370
223
  }
371
224
  return;
372
225
  }
373
- // Find the first PENDING row (not just non-ok). Manual-required rows
374
- // represent steps the user has chosen to handle themselves (skip on
375
- // agent) or steps that are inherently manual (project pick) — both
376
- // should not be silently re-run on a Continue click.
377
- let next = rows.find((r) => r.status === 'pending' && r.id !== 'project');
378
- if (!next) {
379
- // No pending non-project rows. If the project row still needs a path,
380
- // focus the input. Otherwise run project to finalize, or fall through
381
- // to "open hub" if nothing remains.
382
- const projectRow = rows.find((r) => r.id === 'project');
383
- if (projectRow && projectRow.status !== 'ok') {
384
- next = projectRow;
385
- }
386
- }
226
+
227
+ const next = state.session.rows.find((r) => r.status === 'pending');
387
228
  if (!next) return;
388
- if (next.id === 'project') {
389
- const input = document.getElementById('project-path-input');
390
- if (input && !input.value.trim()) {
391
- input.focus();
392
- setStatus('Pick a project folder to continue.');
393
- return;
394
- }
395
- }
396
229
  await runRow(next.id);
397
230
 
398
- // Auto-progress: keep running pending non-project rows until one errors
399
- // or surfaces a manual-required state that isn't the project row (e.g.
400
- // the agent-login row asking for a vendor sign-in). The project row is
401
- // always manual by design and does not block the auto-progress loop.
402
231
  while (state.session) {
403
232
  const blockingError = state.session.rows.find((r) => r.status === 'error');
404
- const blockingManual = state.session.rows.find((r) => r.status === 'manual-required' && r.id !== 'project');
405
- if (blockingError || blockingManual) break;
406
- const nextRow = state.session.rows.find((r) => r.status === 'pending' && r.id !== 'project');
233
+ if (blockingError) break;
234
+ const nextRow = state.session.rows.find((r) => r.status === 'pending');
407
235
  if (!nextRow) break;
408
236
  await runRow(nextRow.id);
409
237
  }
410
238
  }
411
239
 
240
+ function renderRecruitAgents() {
241
+ CHECKLIST_EL.className = 'selection-container recruit-container';
242
+ CHECKLIST_EL.innerHTML = '';
243
+ PRIMARY_BUTTON.style.display = 'none';
244
+ setHeader('Recruit AI Employees', 'Choose how you want to add AI Employees to this machine.');
245
+
246
+ const options = [
247
+ { title: 'Claude Code', desc: 'Install Claude Code and connect it to FRAIM.' },
248
+ { title: 'Codex', desc: 'Install Codex and connect it to FRAIM.' },
249
+ { title: 'Gemini CLI', desc: 'Install Gemini CLI and connect it to FRAIM.' },
250
+ { title: 'Bring Your Own Agent', desc: 'Install Cursor, Windsurf, Kiro, VS Code, or another supported AI tool.' },
251
+ ];
252
+
253
+ for (const option of options) {
254
+ const li = document.createElement('li');
255
+ const card = document.createElement('div');
256
+ card.className = 'user-type-card recruit-card';
257
+ const title = document.createElement('strong');
258
+ title.className = 'card-title';
259
+ title.textContent = option.title;
260
+ const desc = document.createElement('p');
261
+ desc.className = 'card-desc';
262
+ desc.textContent = option.desc;
263
+ card.appendChild(title);
264
+ card.appendChild(desc);
265
+
266
+ if (option.title === 'Bring Your Own Agent') {
267
+ const note = document.createElement('p');
268
+ note.className = 'card-desc';
269
+ note.textContent = 'When you are done installing your agent, run:';
270
+ card.appendChild(note);
271
+ const cmd = document.createElement('div');
272
+ cmd.className = 'cmd-block';
273
+ cmd.textContent = 'npx fraim add-ide';
274
+ card.appendChild(cmd);
275
+ }
276
+
277
+ li.appendChild(card);
278
+ CHECKLIST_EL.appendChild(li);
279
+ }
280
+
281
+ const continueLi = document.createElement('li');
282
+ const continueBtn = document.createElement('button');
283
+ continueBtn.type = 'button';
284
+ continueBtn.className = 'btn btn-primary btn-block';
285
+ continueBtn.textContent = 'Continue';
286
+ continueBtn.addEventListener('click', async () => {
287
+ try {
288
+ const ideData = await api('/api/first-run/ide-commands');
289
+ renderStartWorking(ideData ? ideData.commands : []);
290
+ } catch (_err) {
291
+ renderStartWorking([]);
292
+ }
293
+ });
294
+ continueLi.appendChild(continueBtn);
295
+ CHECKLIST_EL.appendChild(continueLi);
296
+ }
297
+
298
+ function renderStartWorking(ideCommands) {
299
+ CHECKLIST_EL.className = 'selection-container start-container';
300
+ CHECKLIST_EL.innerHTML = '';
301
+ PRIMARY_BUTTON.style.display = 'none';
302
+ setHeader('Get your AI Employees to start working', 'Choose where you want to work.');
303
+
304
+ const ideLi = document.createElement('li');
305
+ const ideCard = document.createElement('div');
306
+ ideCard.className = 'user-type-card';
307
+ ideCard.appendChild(cardHeader('</>', 'In my IDE', 'Claude Code, Cursor, Codex, or another AI tool'));
308
+ const ideBtn = document.createElement('button');
309
+ ideBtn.type = 'button';
310
+ ideBtn.className = 'btn btn-secondary btn-block';
311
+ ideBtn.textContent = 'Show command';
312
+ ideBtn.addEventListener('click', async () => {
313
+ try {
314
+ await api('/api/first-run/set-preference', 'POST', { choice: 'ide' });
315
+ } catch (_err) { /* non-fatal */ }
316
+ renderIdeCommandDisplay(ideCommands);
317
+ });
318
+ ideCard.appendChild(ideBtn);
319
+ ideLi.appendChild(ideCard);
320
+ CHECKLIST_EL.appendChild(ideLi);
321
+
322
+ const orLi = document.createElement('li');
323
+ orLi.className = 'route-or';
324
+ orLi.setAttribute('role', 'separator');
325
+ orLi.setAttribute('aria-hidden', 'true');
326
+ orLi.textContent = 'or';
327
+ CHECKLIST_EL.appendChild(orLi);
328
+
329
+ const hubLi = document.createElement('li');
330
+ const hubCard = document.createElement('div');
331
+ hubCard.className = 'user-type-card user-type-card--featured';
332
+ hubCard.appendChild(cardHeader('Hub', 'In FRAIM Hub', 'Browser-based launcher'));
333
+ const hubBtn = document.createElement('button');
334
+ hubBtn.type = 'button';
335
+ hubBtn.className = 'btn btn-primary btn-block';
336
+ hubBtn.textContent = 'Open Hub';
337
+ hubBtn.addEventListener('click', async () => {
338
+ try {
339
+ await api('/api/first-run/set-preference', 'POST', { choice: 'hub' });
340
+ } catch (_err) { /* non-fatal */ }
341
+ try {
342
+ hubBtn.disabled = true;
343
+ setStatus('Opening Hub...');
344
+ const openResp = await api('/api/first-run/open-hub', 'POST');
345
+ if (openResp && openResp.message) setStatus(openResp.message);
346
+ if (openResp && openResp.hubUrl) window.location.replace(openResp.hubUrl);
347
+ } catch (err) {
348
+ hubBtn.disabled = false;
349
+ setStatus(err.message, 'error');
350
+ }
351
+ });
352
+ hubCard.appendChild(hubBtn);
353
+ hubLi.appendChild(hubCard);
354
+ CHECKLIST_EL.appendChild(hubLi);
355
+ }
356
+
357
+ function cardHeader(iconText, titleText, descText) {
358
+ const header = document.createElement('div');
359
+ header.className = 'card-header';
360
+ const icon = document.createElement('div');
361
+ icon.className = 'card-icon';
362
+ icon.textContent = iconText;
363
+ const text = document.createElement('div');
364
+ const title = document.createElement('strong');
365
+ title.className = 'card-title';
366
+ title.textContent = titleText;
367
+ const desc = document.createElement('p');
368
+ desc.className = 'card-desc';
369
+ desc.textContent = descText;
370
+ text.appendChild(title);
371
+ text.appendChild(desc);
372
+ header.appendChild(icon);
373
+ header.appendChild(text);
374
+ return header;
375
+ }
376
+
377
+ function renderIdeCommandDisplay(commands) {
378
+ CHECKLIST_EL.className = 'selection-container';
379
+ CHECKLIST_EL.innerHTML = '';
380
+ setHeader('Work in your IDE', 'Open your project in your AI tool and type:');
381
+
382
+ const cmdList = (commands && commands.length > 0) ? commands : ['/fraim onboard this project'];
383
+ for (const cmd of cmdList) {
384
+ const li = document.createElement('li');
385
+ const row = document.createElement('div');
386
+ row.className = 'command-row';
387
+ const block = document.createElement('div');
388
+ block.className = 'cmd-block';
389
+ block.textContent = cmd;
390
+ const copyBtn = document.createElement('button');
391
+ copyBtn.type = 'button';
392
+ copyBtn.className = 'btn btn-secondary';
393
+ copyBtn.textContent = 'Copy';
394
+ copyBtn.setAttribute('aria-label', `Copy command: ${cmd}`);
395
+ copyBtn.addEventListener('click', async () => {
396
+ try {
397
+ await navigator.clipboard.writeText(cmd);
398
+ copyBtn.textContent = 'Copied';
399
+ setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
400
+ } catch (_err) {
401
+ // Fallback for environments where clipboard API is unavailable.
402
+ try {
403
+ const ta = document.createElement('textarea');
404
+ ta.value = cmd;
405
+ ta.style.position = 'fixed';
406
+ ta.style.opacity = '0';
407
+ document.body.appendChild(ta);
408
+ ta.select();
409
+ document.execCommand('copy');
410
+ document.body.removeChild(ta);
411
+ copyBtn.textContent = 'Copied';
412
+ setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
413
+ } catch (_e) {
414
+ copyBtn.textContent = 'Copy failed';
415
+ setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
416
+ }
417
+ }
418
+ });
419
+ row.appendChild(block);
420
+ row.appendChild(copyBtn);
421
+ li.appendChild(row);
422
+ CHECKLIST_EL.appendChild(li);
423
+ }
424
+
425
+ const switchLi = document.createElement('li');
426
+ const switchLink = document.createElement('button');
427
+ switchLink.type = 'button';
428
+ switchLink.className = 'text-button';
429
+ switchLink.textContent = 'Switch to FRAIM Hub';
430
+ switchLink.addEventListener('click', async () => {
431
+ try {
432
+ await api('/api/first-run/set-preference', 'POST', { choice: 'hub' });
433
+ const openResp = await api('/api/first-run/open-hub', 'POST');
434
+ if (openResp && openResp.hubUrl) window.location.replace(openResp.hubUrl);
435
+ } catch (err) {
436
+ setStatus(err.message, 'error');
437
+ }
438
+ });
439
+ switchLi.appendChild(switchLink);
440
+ CHECKLIST_EL.appendChild(switchLi);
441
+ }
442
+
412
443
  function render() {
413
444
  if (!state.session) return;
445
+ CHECKLIST_EL.className = 'checklist';
446
+ PRIMARY_BUTTON.style.display = '';
414
447
  const { rows, primaryButtonLabel } = state.session;
415
448
  CHECKLIST_EL.innerHTML = '';
416
449
  for (const row of rows) {
417
450
  CHECKLIST_EL.appendChild(renderRow(row));
418
451
  }
452
+ if (primaryButtonLabel === 'Get Started') {
453
+ const summary = renderSetupSummary();
454
+ if (summary) CHECKLIST_EL.appendChild(summary);
455
+ }
419
456
  PRIMARY_BUTTON.textContent = primaryButtonLabel || 'Set up FRAIM';
420
457
  applyHeading(rows);
421
458
  }