fraim 2.0.129 → 2.0.131

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.
@@ -43,9 +43,9 @@ const path_1 = __importDefault(require("path"));
43
43
  const crypto_1 = __importDefault(require("crypto"));
44
44
  const child_process_1 = require("child_process");
45
45
  const ide_detector_1 = require("../cli/setup/ide-detector");
46
+ const ide_global_integration_1 = require("../cli/setup/ide-global-integration");
46
47
  const auto_mcp_setup_1 = require("../cli/setup/auto-mcp-setup");
47
48
  const setup_1 = require("../cli/commands/setup");
48
- const init_project_1 = require("../cli/commands/init-project");
49
49
  const script_sync_utils_1 = require("../cli/utils/script-sync-utils");
50
50
  const types_1 = require("./types");
51
51
  Object.defineProperty(exports, "FIRST_RUN_ROW_IDS", { enumerable: true, get: function () { return types_1.FIRST_RUN_ROW_IDS; } });
@@ -54,31 +54,23 @@ function getFakeStateMode() {
54
54
  if (process.env.FRAIM_FIRST_RUN_FAKE !== '1')
55
55
  return null;
56
56
  const explicit = process.env.FRAIM_FIRST_RUN_FAKE_STATE;
57
- if (explicit === 'all-pending' || explicit === 'all-detected' || explicit === 'agent-install-fails') {
57
+ if (explicit === 'all-pending' || explicit === 'all-detected' || explicit === 'agent-install-fails' || explicit === 'no-agents') {
58
58
  return explicit;
59
59
  }
60
60
  return 'default';
61
61
  }
62
- function commandExists(command) {
63
- const executable = process.platform === 'win32' ? 'cmd.exe' : command;
64
- const args = process.platform === 'win32'
65
- ? ['/d', '/s', '/c', `${command} --version`]
66
- : ['--version'];
67
- const result = (0, child_process_1.spawnSync)(executable, args, {
68
- encoding: 'utf8',
69
- stdio: ['ignore', 'ignore', 'ignore'],
70
- shell: false,
71
- });
72
- return result.status === 0;
73
- }
74
- function commandVersion(command) {
62
+ function commandVersion(command, extraBinDir) {
75
63
  const executable = process.platform === 'win32' ? 'cmd.exe' : command;
76
64
  const args = process.platform === 'win32'
77
65
  ? ['/d', '/s', '/c', `${command} --version`]
78
66
  : ['--version'];
67
+ const env = extraBinDir
68
+ ? { ...process.env, PATH: `${extraBinDir}${path_1.default.delimiter}${process.env.PATH || ''}` }
69
+ : undefined;
79
70
  const result = (0, child_process_1.spawnSync)(executable, args, {
80
71
  encoding: 'utf8',
81
72
  timeout: 5000,
73
+ ...(env ? { env } : {}),
82
74
  });
83
75
  if (result.status !== 0)
84
76
  return null;
@@ -89,6 +81,78 @@ function ensureOutputDirs() {
89
81
  fs_1.default.mkdirSync((0, script_sync_utils_1.getUserFraimDir)(), { recursive: true });
90
82
  fs_1.default.mkdirSync(path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'last-install'), { recursive: true });
91
83
  }
84
+ /**
85
+ * Returns the directory that contains node/npm/npx executables for FRAIM's
86
+ * portable Node installation.
87
+ *
88
+ * - Mac/Linux: ~/.fraim/node/bin (standard Unix layout)
89
+ * - Windows: ~/.fraim/node/node-v<version>-win-x64/ if extracted, else
90
+ * ~/.fraim/node/ as fallback (executables live at the root on Windows)
91
+ */
92
+ function getFraimNodeBinPath() {
93
+ const nodeRoot = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
94
+ if (process.platform === 'win32') {
95
+ if (fs_1.default.existsSync(nodeRoot)) {
96
+ const extractedDir = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
97
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v'))
98
+ .sort((a, b) => b.name.localeCompare(a.name))[0];
99
+ if (extractedDir) {
100
+ return path_1.default.join(nodeRoot, extractedDir.name);
101
+ }
102
+ }
103
+ return nodeRoot;
104
+ }
105
+ return path_1.default.join(nodeRoot, 'bin');
106
+ }
107
+ // Prepend the portable Node bin dir to process PATH once at module load so
108
+ // every spawnSync call (detection, login probe, change-agent) finds binaries
109
+ // installed there without needing per-call path overrides.
110
+ (function bootstrapFraimNodeBin() {
111
+ const fraimNodeBin = getFraimNodeBinPath();
112
+ const current = process.env.PATH || '';
113
+ if (!current.split(path_1.default.delimiter).includes(fraimNodeBin)) {
114
+ process.env.PATH = `${fraimNodeBin}${path_1.default.delimiter}${current}`;
115
+ }
116
+ })();
117
+ function persistShellPath() {
118
+ const fraimNodeBin = getFraimNodeBinPath();
119
+ const marker = '# FRAIM managed binaries';
120
+ const exportLine = 'export PATH="$HOME/.fraim/node/bin:$PATH"';
121
+ const stanza = `\n${marker}\n${exportLine}\n`;
122
+ if (process.platform === 'win32') {
123
+ const escapedBin = fraimNodeBin.replace(/'/g, "''");
124
+ // Guard pattern matches both the versioned subdirectory and the root fallback.
125
+ const psCmd = [
126
+ `$bin = '${escapedBin}'`,
127
+ `$cur = [Environment]::GetEnvironmentVariable('PATH', 'User')`,
128
+ `if ($cur -notlike "*$bin*") { [Environment]::SetEnvironmentVariable('PATH', "$bin;$cur", 'User') }`,
129
+ ].join('; ');
130
+ (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8' });
131
+ return;
132
+ }
133
+ const profiles = [
134
+ path_1.default.join(os_1.default.homedir(), '.zshrc'),
135
+ path_1.default.join(os_1.default.homedir(), '.bash_profile'),
136
+ path_1.default.join(os_1.default.homedir(), '.bashrc'),
137
+ ];
138
+ // macOS defaults to zsh since Catalina — create .zshrc if it doesn't exist.
139
+ if (process.platform === 'darwin' && !fs_1.default.existsSync(profiles[0])) {
140
+ fs_1.default.writeFileSync(profiles[0], stanza, 'utf8');
141
+ }
142
+ for (const profile of profiles) {
143
+ try {
144
+ if (!fs_1.default.existsSync(profile))
145
+ continue;
146
+ const content = fs_1.default.readFileSync(profile, 'utf8');
147
+ if (content.includes(marker))
148
+ continue;
149
+ fs_1.default.appendFileSync(profile, stanza, 'utf8');
150
+ }
151
+ catch {
152
+ // Ignore unreadable or missing profiles.
153
+ }
154
+ }
155
+ }
92
156
  function getNextPromptPath() {
93
157
  return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'last-install', 'next-prompt.txt');
94
158
  }
@@ -99,16 +163,46 @@ function appendInstallLog(line) {
99
163
  ensureOutputDirs();
100
164
  fs_1.default.appendFileSync(getInstallLogPath(), `[${new Date().toISOString()}] ${line}${os_1.default.EOL}`);
101
165
  }
166
+ function runProcess(command, args, env) {
167
+ return new Promise((resolve, reject) => {
168
+ const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
169
+ const child = (0, child_process_1.spawn)(executable, args, {
170
+ env: { ...process.env, ...(env || {}) },
171
+ shell: false,
172
+ });
173
+ let stdout = '';
174
+ let stderr = '';
175
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
176
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
177
+ child.on('error', reject);
178
+ child.on('close', (code) => {
179
+ if (code === 0) {
180
+ resolve({ stdout, stderr });
181
+ return;
182
+ }
183
+ reject(new Error(`${command} ${args.join(' ')} failed with code ${code}\n${stdout}${stderr}`.trim()));
184
+ });
185
+ });
186
+ }
102
187
  function findAgentOption(agentId) {
103
188
  return types_1.FIRST_RUN_AGENT_OPTIONS.find((option) => option.id === agentId);
104
189
  }
105
- function pendingVerbForAgent(agentId) {
106
- const option = findAgentOption(agentId);
107
- if (!option)
108
- return "we'll set up the recommended agent";
109
- if (agentId === 'claude-code')
110
- return "we'll set up Claude Code (recommended)";
111
- return `we'll set up ${option.label}`;
190
+ function normalizeRows(rows) {
191
+ const existingById = new Map(rows.map((row) => [row.id, row]));
192
+ return (0, types_1.createInitialRows)().map((canonical) => ({
193
+ ...canonical,
194
+ ...(existingById.get(canonical.id) || {}),
195
+ }));
196
+ }
197
+ function buildConfiguredSurfaces() {
198
+ const ides = (0, ide_detector_1.detectInstalledIDEs)();
199
+ const hints = (0, ide_global_integration_1.describeOnboardingInvocationSurfaces)(ides);
200
+ return ides.map((ide, index) => ({
201
+ id: ide.configType,
202
+ name: ide.name,
203
+ invocationHint: hints[index] || '/fraim onboard this project',
204
+ status: 'configured',
205
+ }));
112
206
  }
113
207
  class FirstRunSessionService {
114
208
  constructor(options) {
@@ -120,6 +214,7 @@ class FirstRunSessionService {
120
214
  ['npm error code EACCES', 'npm error syscall mkdir'].join('\n');
121
215
  this.requestToken = crypto_1.default.randomUUID();
122
216
  this.state = options.resume ? (0, install_state_1.loadFirstRunState)() || (0, install_state_1.createInitialFirstRunState)(options.key) : (0, install_state_1.createInitialFirstRunState)(options.key);
217
+ this.state.rows = normalizeRows(this.state.rows);
123
218
  if (options.projectRoot) {
124
219
  this.state.workspacePath = path_1.default.resolve(options.projectRoot);
125
220
  (0, install_state_1.saveFirstRunState)(this.state);
@@ -131,7 +226,7 @@ class FirstRunSessionService {
131
226
  getRow(rowId) {
132
227
  const row = this.state.rows.find((r) => r.id === rowId);
133
228
  if (!row) {
134
- throw new Error(`Row ${rowId} not found in state initial rows must include all canonical IDs`);
229
+ throw new Error(`Row ${rowId} not found in state — initial rows must include all canonical IDs`);
135
230
  }
136
231
  return row;
137
232
  }
@@ -141,13 +236,13 @@ class FirstRunSessionService {
141
236
  }
142
237
  setRowError(row, whatTried, whatHappened, actions, hint) {
143
238
  row.status = 'error';
144
- row.verb = 'failed see below';
239
+ row.verb = 'failed — see below';
145
240
  row.errorFrame = { whatTried, whatHappened, actions, ...(hint ? { hint } : {}) };
146
241
  }
147
242
  /**
148
243
  * Classify a thrown init/sync error into a non-tech-friendly hint.
149
244
  * The verbatim stderr is preserved separately in `whatHappened` per
150
- * R6.3 the hint is additive context, not a replacement.
245
+ * R6.3 — the hint is additive context, not a replacement.
151
246
  */
152
247
  classifyInitError(detail) {
153
248
  if (/status code 401|Unauthorized|401\b/i.test(detail)) {
@@ -157,13 +252,13 @@ class FirstRunSessionService {
157
252
  return "The FRAIM registry refused this key for this project. Check that your install key has access to the registry you're syncing from.";
158
253
  }
159
254
  if (/status code 5\d\d/i.test(detail) || /5\d\d\b/.test(detail)) {
160
- return 'The FRAIM registry returned a server error. This is usually transient wait a minute and click Retry. If it keeps failing, the registry may be down.';
255
+ return 'The FRAIM registry returned a server error. This is usually transient — wait a minute and click Retry. If it keeps failing, the registry may be down.';
161
256
  }
162
257
  if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|getaddrinfo|network/i.test(detail)) {
163
258
  return "Couldn't reach the FRAIM registry. Check your internet connection (or if you're testing locally, that the local FRAIM server is running) and click Retry.";
164
259
  }
165
260
  if (/Global FRAIM setup not found/i.test(detail)) {
166
- return 'FRAIM\'s global config is missing. This usually means the agent step didn\'t complete cleanly go back and re-run the AI agent row.';
261
+ return 'FRAIM\'s global config is missing. This usually means the agent step didn\'t complete cleanly — go back and re-run the AI agent row.';
167
262
  }
168
263
  return undefined;
169
264
  }
@@ -172,7 +267,7 @@ class FirstRunSessionService {
172
267
  this.applyFakeStateOnLoad(this.fakeMode);
173
268
  return;
174
269
  }
175
- // Real detection populate row statuses from `command -v` style probes.
270
+ // Real detection — populate row statuses from `command -v` style probes.
176
271
  const nodeRow = this.getRow('node');
177
272
  const nodeVer = commandVersion('node');
178
273
  if (nodeVer) {
@@ -193,53 +288,47 @@ class FirstRunSessionService {
193
288
  gitRow.status = 'pending';
194
289
  gitRow.verb = "we'll install";
195
290
  }
196
- const agentRow = this.getRow('agent');
197
- const detected = this.detectAnyKnownAgent();
198
- if (detected) {
199
- this.state.agentId = detected.id;
200
- agentRow.status = 'ok';
201
- agentRow.verb = `${detected.label} detected`;
202
- agentRow.detail = detected.versionLine ?? undefined;
203
- }
204
- else {
205
- agentRow.status = 'pending';
206
- agentRow.verb = pendingVerbForAgent(this.state.agentId);
207
- }
208
- const loginRow = this.getRow('agent-login');
209
- if (agentRow.status === 'ok' && this.probeAgentReady(this.state.agentId).ok) {
210
- loginRow.status = 'ok';
211
- loginRow.verb = 'Signed in (verified)';
212
- }
213
- else {
214
- loginRow.status = 'pending';
215
- loginRow.verb = "you'll sign in after install";
216
- }
217
- const projectRow = this.getRow('project');
218
- if (this.state.workspacePath && fs_1.default.existsSync(this.state.workspacePath)) {
219
- projectRow.status = 'ok';
220
- projectRow.verb = this.state.workspacePath;
291
+ const fraimRow = this.getRow('fraim');
292
+ if (commandVersion('npx') !== null) {
293
+ // Only mark ok if setup has previously completed (config.json is written after autoConfigureMCP).
294
+ // Without this, the wizard skips runFraimRow entirely and autoConfigureMCP never runs for IDEs.
295
+ const globalConfigPath = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'config.json');
296
+ if (fs_1.default.existsSync(globalConfigPath)) {
297
+ fraimRow.status = 'ok';
298
+ fraimRow.verb = 'ready';
299
+ }
300
+ else {
301
+ fraimRow.status = 'pending';
302
+ fraimRow.verb = "we'll configure your IDEs";
303
+ }
304
+ persistShellPath();
221
305
  }
222
306
  else {
223
- projectRow.status = 'manual-required';
224
- projectRow.verb = 'pick a folder where FRAIM should work';
307
+ fraimRow.status = 'pending';
308
+ fraimRow.verb = "we'll set up FRAIM";
225
309
  }
310
+ this.updateAgentSummaryRow();
226
311
  }
227
- detectAnyKnownAgent() {
228
- // Priority order matches FIRST_RUN_AGENT_OPTIONS ordering.
229
- for (const option of types_1.FIRST_RUN_AGENT_OPTIONS) {
230
- if (commandExists(option.launchCommand)) {
231
- return { id: option.id, label: option.label, versionLine: commandVersion(option.launchCommand) };
232
- }
233
- }
234
- // Also accept matches via the IDE detector for desktop variants.
235
- const ides = (0, ide_detector_1.detectInstalledIDEs)();
236
- for (const option of types_1.FIRST_RUN_AGENT_OPTIONS) {
237
- const ide = ides.find((entry) => entry.aliases?.some((a) => option.detectAliases.includes(a)));
238
- if (ide) {
239
- return { id: option.id, label: option.label, versionLine: null };
240
- }
312
+ updateAgentSummaryRow() {
313
+ const agentRow = this.getRow('agent');
314
+ const surfaces = this.fakeMode === 'no-agents' ? [] : buildConfiguredSurfaces();
315
+ if (surfaces.length > 0) {
316
+ agentRow.status = 'ok';
317
+ agentRow.verb = `${surfaces.length} AI Employee${surfaces.length === 1 ? '' : 's'} ready`;
318
+ agentRow.detail = surfaces.map((surface) => surface.name).join(', ');
241
319
  }
242
- return null;
320
+ else {
321
+ agentRow.status = 'manual-required';
322
+ agentRow.verb = "let's recruit AI Employees";
323
+ delete agentRow.detail;
324
+ }
325
+ this.state.setupResult = {
326
+ mode: 'conversational',
327
+ configuredSurfaces: surfaces,
328
+ failedSurfaces: [],
329
+ detectedSurfaceCount: surfaces.length,
330
+ completedAt: new Date().toISOString(),
331
+ };
243
332
  }
244
333
  applyFakeStateOnLoad(mode) {
245
334
  const setStatus = (rowId, status, verb, detail) => {
@@ -252,33 +341,63 @@ class FirstRunSessionService {
252
341
  if (mode === 'all-pending') {
253
342
  setStatus('node', 'pending', "we'll install");
254
343
  setStatus('git', 'pending', "we'll install");
255
- setStatus('agent', 'pending', pendingVerbForAgent(this.state.agentId));
256
- setStatus('agent-login', 'pending', "you'll sign in after install");
257
- setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
344
+ setStatus('fraim', 'pending', "we'll install");
345
+ setStatus('agent', 'pending', "we'll check for AI Employees");
346
+ delete this.state.setupResult;
258
347
  return;
259
348
  }
260
349
  if (mode === 'all-detected') {
261
350
  setStatus('node', 'ok', 'v20.11.1 detected');
262
351
  setStatus('git', 'ok', 'git version 2.45 detected');
263
- setStatus('agent', 'ok', 'Claude Code v1.4.2 detected');
264
- setStatus('agent-login', 'ok', 'Signed in (verified by claude -p probe)');
265
- setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
352
+ setStatus('fraim', 'ok', 'fraim-framework detected');
353
+ setStatus('agent', 'ok', '1 AI Employee ready', 'Claude Code');
354
+ this.state.setupResult = {
355
+ mode: 'conversational',
356
+ configuredSurfaces: [
357
+ { id: 'claude-code', name: 'Claude Code', invocationHint: 'Claude Code: /fraim onboard this project', status: 'configured' },
358
+ ],
359
+ failedSurfaces: [],
360
+ detectedSurfaceCount: 1,
361
+ completedAt: new Date().toISOString(),
362
+ };
363
+ return;
364
+ }
365
+ if (mode === 'no-agents') {
366
+ setStatus('node', 'ok', 'v20.11.1 detected');
367
+ setStatus('git', 'ok', 'git version 2.45 detected');
368
+ setStatus('fraim', 'ok', 'fraim-framework detected');
369
+ setStatus('agent', 'manual-required', "let's recruit AI Employees");
370
+ this.state.setupResult = {
371
+ mode: 'conversational',
372
+ configuredSurfaces: [],
373
+ failedSurfaces: [],
374
+ detectedSurfaceCount: 0,
375
+ completedAt: new Date().toISOString(),
376
+ };
266
377
  return;
267
378
  }
268
379
  if (mode === 'agent-install-fails') {
269
380
  setStatus('node', 'ok', 'v20.11.1 installed');
270
381
  setStatus('git', 'ok', '2.45 installed');
271
- setStatus('agent', 'pending', pendingVerbForAgent(this.state.agentId));
272
- setStatus('agent-login', 'pending', "you'll sign in after install");
273
- setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
382
+ setStatus('fraim', 'ok', 'fraim-framework installed');
383
+ setStatus('agent', 'pending', "we'll check for AI Employees");
384
+ delete this.state.setupResult;
274
385
  return;
275
386
  }
276
- // 'default' fake mode everything ok, project still requires a pick.
387
+ // 'default' fake mode — infra rows all ok.
277
388
  setStatus('node', 'ok', 'v20.11.1 detected');
278
389
  setStatus('git', 'ok', 'git version 2.45 detected');
279
- setStatus('agent', 'ok', 'fake-mode agent detected');
280
- setStatus('agent-login', 'ok', 'fake-mode signed in');
281
- setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
390
+ setStatus('fraim', 'ok', 'fake-mode fraim installed');
391
+ setStatus('agent', 'ok', 'fake-mode AI Employee ready', 'Claude Code');
392
+ this.state.setupResult = {
393
+ mode: 'conversational',
394
+ configuredSurfaces: [
395
+ { id: 'claude-code', name: 'Claude Code', invocationHint: 'Claude Code: /fraim onboard this project', status: 'configured' },
396
+ ],
397
+ failedSurfaces: [],
398
+ detectedSurfaceCount: 1,
399
+ completedAt: new Date().toISOString(),
400
+ };
282
401
  }
283
402
  getRequestToken() {
284
403
  return this.requestToken;
@@ -326,16 +445,14 @@ class FirstRunSessionService {
326
445
  return await this.runNodeRow();
327
446
  case 'git':
328
447
  return await this.runGitRow();
448
+ case 'fraim':
449
+ return await this.runFraimRow();
329
450
  case 'agent':
330
451
  return await this.runAgentRow(request);
331
- case 'agent-login':
332
- return await this.runAgentLoginRow(request);
333
- case 'project':
334
- return await this.runProjectRow(request);
335
452
  }
336
453
  }
337
454
  catch (error) {
338
- // Last-ditch safety net every row's runner is supposed to surface
455
+ // Last-ditch safety net — every row's runner is supposed to surface
339
456
  // its own R6 frame via setRowError before throwing, but if anything
340
457
  // slips past (e.g. a sync layer that calls process.exit and we narrowly
341
458
  // converted it to throw), we still surface a frame here rather than
@@ -343,7 +460,7 @@ class FirstRunSessionService {
343
460
  // user is never wedged on a non-recoverable failure of one row.
344
461
  const message = error instanceof Error ? error.message : 'Unknown error';
345
462
  row.status = 'error';
346
- row.verb = 'failed see below';
463
+ row.verb = 'failed — see below';
347
464
  row.errorFrame = {
348
465
  whatTried: `Running ${row.label} step`,
349
466
  whatHappened: message,
@@ -396,250 +513,83 @@ class FirstRunSessionService {
396
513
  this.persist();
397
514
  return this.respond('git not detected.', false);
398
515
  }
399
- async runAgentRow(request) {
400
- const row = this.getRow('agent');
401
- // If the user picked an alternative through the error frame, switch and continue.
402
- if (request.errorActionId === 'alternative' && request.alternativeAgentId) {
403
- this.state.agentId = request.alternativeAgentId;
404
- row.verb = pendingVerbForAgent(this.state.agentId);
405
- }
406
- if (request.errorActionId === 'skip') {
407
- row.status = 'manual-required';
408
- row.verb = 'you said you\'ll set up the agent yourself';
409
- // If the user skipped agent install, sign-in is also their problem —
410
- // there's no agent to sign into. Mark agent-login manual-required so
411
- // the auto-progress loop doesn't try to probe a nonexistent CLI on
412
- // the next Continue click.
413
- const loginRow = this.getRow('agent-login');
414
- if (loginRow.status !== 'ok') {
415
- loginRow.status = 'manual-required';
416
- loginRow.verb = "you'll handle sign-in when you set up the agent";
417
- }
418
- this.persist();
419
- return this.respond('Agent setup deferred.', true);
420
- }
421
- const option = findAgentOption(this.state.agentId);
422
- if (!option) {
423
- this.setRowError(row, 'Looking up the AI agent install command', `Unknown agent id: ${this.state.agentId}`, [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
424
- this.persist();
425
- return this.respond('Unknown agent.', false);
426
- }
427
- // Fake mode short-circuit — never call real OS commands. The whole point
428
- // of fake mode is hermetic, deterministic test rendering.
429
- if (this.fakeMode === 'agent-install-fails') {
430
- this.setRowError(row, `Installing ${option.label} via npm install -g ${option.installPackage}`, this.fakeStderr, this.buildAgentInstallActions(this.state.agentId));
431
- this.persist();
432
- return this.respond('Fake-mode agent install failed.', false);
433
- }
516
+ async runFraimRow() {
517
+ const row = this.getRow('fraim');
434
518
  if (this.fakeMode) {
435
519
  row.status = 'ok';
436
- row.verb = `${option.label} installed (fake-mode)`;
437
- this.persist();
438
- return this.respond('Fake-mode agent installed.', true);
439
- }
440
- // Real path: detect first — if already installed we skip install entirely.
441
- const existingVer = commandVersion(option.launchCommand);
442
- if (existingVer) {
443
- row.status = 'ok';
444
- row.verb = `${option.label} ${existingVer} detected`;
445
- row.detail = existingVer;
520
+ row.verb = 'FRAIM ready (fake-mode)';
446
521
  this.persist();
447
- return this.respond(`${option.label} already installed.`, true);
522
+ return this.respond('Fake-mode fraim ok.', true);
448
523
  }
449
- // Real install — npm install -g <package>. Stream output into row.streamOutput.
450
- const result = (0, child_process_1.spawnSync)('npm', ['install', '-g', option.installPackage], {
451
- encoding: 'utf8',
452
- timeout: 300000,
453
- });
454
- const combined = `${result.stdout || ''}${result.stderr || ''}`.trim();
455
- row.streamOutput = combined.slice(-4000);
456
- if (result.status === 0) {
457
- const ver = commandVersion(option.launchCommand) ?? 'installed';
524
+ try {
525
+ if (!commandVersion('fraim')) {
526
+ const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
527
+ fs_1.default.mkdirSync(prefix, { recursive: true });
528
+ row.streamOutput = 'Installing FRAIM on this machine...';
529
+ this.persist();
530
+ await runProcess('npm', ['install', '-g', 'fraim-framework@latest'], { npm_config_prefix: prefix });
531
+ }
532
+ persistShellPath();
533
+ const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
534
+ if (detectedIDEs.length > 0) {
535
+ await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, {}, detectedIDEs.map((ide) => ide.name), {});
536
+ }
537
+ // Write config.json after autoConfigureMCP so detectRowsOnLoad can use its
538
+ // existence as a signal that IDE setup completed. Writing it before would cause
539
+ // the wizard to mark this row 'ok' on retry before IDEs are actually configured.
540
+ (0, setup_1.saveGlobalConfig)(this.key, 'conversational', {}, {});
541
+ const { syncUserLevelArtifacts } = await Promise.resolve().then(() => __importStar(require('../cli/setup/user-level-sync')));
542
+ await syncUserLevelArtifacts();
543
+ const { installSlashCommands, installGlobalRules } = await Promise.resolve().then(() => __importStar(require('../cli/setup/ide-global-integration')));
544
+ await installSlashCommands();
545
+ await installGlobalRules();
458
546
  row.status = 'ok';
459
- row.verb = `${option.label} ${ver}`;
460
- row.detail = ver;
461
- this.persist();
462
- return this.respond(`${option.label} installed.`, true);
463
- }
464
- this.setRowError(row, `Installing ${option.label} via npm install -g ${option.installPackage}`, combined || `npm install -g ${option.installPackage} exited ${result.status}`, this.buildAgentInstallActions(this.state.agentId));
465
- this.persist();
466
- return this.respond(`${option.label} install failed.`, false);
467
- }
468
- buildAgentInstallActions(currentAgentId) {
469
- const others = types_1.FIRST_RUN_AGENT_OPTIONS.filter((option) => option.id !== currentAgentId);
470
- const actions = [
471
- { id: 'retry', label: 'Retry', variant: 'primary' },
472
- ];
473
- if (others.length > 0) {
474
- actions.push({
475
- id: 'alternative',
476
- label: `Try ${others[0].label} instead`,
477
- variant: 'secondary',
478
- alternativeAgentId: others[0].id,
479
- });
480
- }
481
- actions.push({ id: 'skip', label: 'Skip and continue', variant: 'ghost' });
482
- return actions;
483
- }
484
- async runAgentLoginRow(request) {
485
- const row = this.getRow('agent-login');
486
- if (request.errorActionId === 'skip') {
487
- row.status = 'manual-required';
488
- row.verb = "you'll sign in later from the Hub";
489
- this.persist();
490
- return this.respond('Agent login deferred.', true);
491
- }
492
- const option = findAgentOption(this.state.agentId);
493
- if (!option) {
494
- this.setRowError(row, 'Resolving agent login command', `Unknown agent id: ${this.state.agentId}`, [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
547
+ row.verb = 'Ready.';
548
+ delete row.streamOutput;
495
549
  this.persist();
496
- return this.respond('Unknown agent for login.', false);
550
+ return this.respond('FRAIM is ready.', true);
497
551
  }
498
- if (this.fakeMode) {
499
- row.status = 'ok';
500
- row.verb = 'Signed in (fake-mode)';
552
+ catch (error) {
553
+ const detail = error instanceof Error ? error.message : 'Unknown error';
554
+ this.setRowError(row, 'Setting up FRAIM on this machine', detail, [
555
+ { id: 'retry', label: 'Retry', variant: 'primary' },
556
+ { id: 'skip', label: 'Skip and continue', variant: 'ghost' },
557
+ ]);
501
558
  this.persist();
502
- return this.respond('Fake-mode signed in.', true);
559
+ return this.respond('FRAIM setup failed.', false);
503
560
  }
504
- // First, see whether the agent is already authenticated.
505
- const probe = this.probeAgentReady(this.state.agentId);
506
- if (probe.ok) {
507
- row.status = 'ok';
508
- row.verb = 'Signed in (verified)';
509
- this.persist();
510
- return this.respond(`${option.label} signed in.`, true);
511
- }
512
- // Not authenticated yet — surface as manual-required with a clear message.
513
- // We do NOT auto-spawn `claude login` from here because OAuth flows differ
514
- // by agent and need the user's actual browser. The client renders a
515
- // "Sign in to Claude in the new tab" prompt and polls back via Retry.
516
- row.status = 'manual-required';
517
- row.verb = `Sign in to ${option.label} (then click Retry)`;
518
- row.manualMessage = `Open a terminal and run \`${option.loginCommand}\`. We'll detect when you're signed in.`;
519
- this.persist();
520
- return this.respond(`Awaiting ${option.label} login.`, true);
521
561
  }
522
- probeAgentReady(agentId) {
523
- if (this.fakeMode)
524
- return { ok: this.fakeMode !== 'agent-install-fails', output: 'fake' };
525
- const option = findAgentOption(agentId);
526
- if (!option)
527
- return { ok: false, output: 'unknown agent' };
528
- if (agentId === 'codex') {
529
- const result = (0, child_process_1.spawnSync)('codex', ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'], {
530
- cwd: this.state.workspacePath || process.cwd(),
531
- input: 'Reply exactly "FRAIM_READY" if FRAIM is available in this workspace. Otherwise reply exactly "NOT_READY".',
532
- encoding: 'utf8',
533
- timeout: 60000,
534
- });
535
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
536
- return { ok: result.status === 0 && output.toUpperCase().includes('FRAIM'), output };
537
- }
538
- if (agentId === 'claude-code') {
539
- const result = (0, child_process_1.spawnSync)('claude', ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'], {
540
- cwd: this.state.workspacePath || process.cwd(),
541
- input: 'Reply exactly "FRAIM_READY" if FRAIM is available in this workspace. Otherwise reply exactly "NOT_READY".',
542
- encoding: 'utf8',
543
- timeout: 60000,
544
- });
545
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
546
- return { ok: result.status === 0 && output.toUpperCase().includes('FRAIM'), output };
547
- }
548
- // Gemini probe is `--help` based until Gemini ships a stronger non-interactive path.
549
- const result = (0, child_process_1.spawnSync)('gemini', ['--help'], {
550
- cwd: this.state.workspacePath || process.cwd(),
551
- encoding: 'utf8',
552
- timeout: 30000,
553
- });
554
- return { ok: result.status === 0, output: `${result.stdout || ''}${result.stderr || ''}`.trim() };
555
- }
556
- async runProjectRow(request) {
557
- const row = this.getRow('project');
558
- // Skip-and-continue contract: a user who explicitly skips this row chose
559
- // to set the project up themselves later. Mark manual-required so the
560
- // primary-button derivation can route to Open Hub via the skip-path rule.
562
+ async runAgentRow(request) {
563
+ const row = this.getRow('agent');
561
564
  if (request.errorActionId === 'skip') {
562
565
  row.status = 'manual-required';
563
- row.verb = "you'll pick a project folder later";
564
- this.clearRowError(row);
565
- this.persist();
566
- return this.respond('Project deferred.', true);
567
- }
568
- const projectPath = request.projectPath ?? this.state.workspacePath;
569
- if (!projectPath) {
570
- row.status = 'manual-required';
571
- row.verb = 'pick a folder where FRAIM should work';
566
+ row.verb = "let's recruit AI Employees";
567
+ this.state.setupResult = {
568
+ mode: 'conversational',
569
+ configuredSurfaces: [],
570
+ failedSurfaces: [],
571
+ detectedSurfaceCount: 0,
572
+ completedAt: new Date().toISOString(),
573
+ };
572
574
  this.persist();
573
- return this.respond('Project path required.', false);
574
- }
575
- const resolvedPath = path_1.default.resolve(projectPath);
576
- fs_1.default.mkdirSync(resolvedPath, { recursive: true });
577
- if (commandExists('git')) {
578
- try {
579
- (0, child_process_1.execSync)('git rev-parse --git-dir', { cwd: resolvedPath, stdio: 'ignore' });
580
- }
581
- catch {
582
- try {
583
- (0, child_process_1.execSync)('git init', { cwd: resolvedPath, stdio: 'ignore' });
584
- }
585
- catch (gitError) {
586
- // Surface but don't fail the row — init-project will still work.
587
- appendInstallLog(`git init failed at ${resolvedPath}: ${gitError instanceof Error ? gitError.message : 'unknown'}`);
588
- }
589
- }
575
+ return this.respond('AI Employee recruiting deferred.', true);
590
576
  }
591
- if (!this.fakeMode) {
592
- // Configure FRAIM globals + agent MCP wiring before init-project runs.
593
- (0, setup_1.saveGlobalConfig)(this.key, 'conversational', {}, {});
594
- const selectedAgent = (0, ide_detector_1.findIDEByName)(this.state.agentId);
595
- const targetNames = selectedAgent ? [selectedAgent.name] : undefined;
596
- try {
597
- await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, {}, targetNames, {});
598
- }
599
- catch (mcpError) {
600
- appendInstallLog(`autoConfigureMCP non-fatal error: ${mcpError instanceof Error ? mcpError.message : 'unknown'}`);
601
- }
602
- // Pass failHard:'throw' so a sync 401 / network error / missing-key
603
- // bubbles back here instead of calling process.exit(1) and killing the
604
- // FRE server. The catch below converts it into the R6 error frame.
605
- try {
606
- await (0, init_project_1.runInitProject)({ projectRoot: resolvedPath, failHard: 'throw' });
607
- }
608
- catch (initError) {
609
- const detail = initError instanceof Error ? initError.message : 'Unknown init error';
610
- // Skip is intentionally omitted on the project row — without a
611
- // project there is no Hub to open, so deferring this row would
612
- // leave the user wedged. Retry is always offered; the hint
613
- // translates the verbatim error (e.g. "Request failed with
614
- // status code 401") into something a non-tech user can act on.
615
- const hint = this.classifyInitError(detail);
616
- this.setRowError(row, `Initializing FRAIM in ${resolvedPath}`, detail, [{ id: 'retry', label: 'Retry', variant: 'primary' }], hint);
617
- appendInstallLog(`project-init-failed ${resolvedPath}: ${detail}`);
618
- this.persist();
619
- return this.respond(`Initialization failed: ${detail}`, false);
620
- }
621
- }
622
- this.state.workspacePath = resolvedPath;
623
- row.status = 'ok';
624
- row.verb = resolvedPath;
577
+ this.updateAgentSummaryRow();
625
578
  this.persist();
626
- appendInstallLog(`project-initialized ${resolvedPath}`);
627
- return this.respond(`Initialized FRAIM in ${resolvedPath}.`, true);
579
+ const count = this.state.setupResult?.detectedSurfaceCount || 0;
580
+ return this.respond(count > 0 ? 'AI Employees are ready.' : 'No AI Employees found.', true);
628
581
  }
629
582
  /**
630
- * Update the current agent selection (inline `Change…` picker). Recomputes
631
- * the agent and agent-login row verbs but does not run any installs.
583
+ * Update the current agent selection (inline `Change…` picker).
584
+ * Records the preference for future recruiting workflows; does not run any installs.
632
585
  */
633
586
  changeAgent(req) {
634
587
  if (req.customAgent) {
635
588
  this.state.customAgent = { name: req.customAgent.name, invocationPrefix: req.customAgent.invocationPrefix };
636
589
  const agentRow = this.getRow('agent');
637
- const loginRow = this.getRow('agent-login');
638
- agentRow.status = 'ok';
590
+ agentRow.status = 'manual-required';
639
591
  agentRow.verb = `Custom CLI recorded: ${req.customAgent.name}`;
640
- agentRow.detail = `(advanced Hub will not auto-invoke unknown CLIs in v1)`;
641
- loginRow.status = 'manual-required';
642
- loginRow.verb = `Sign in to ${req.customAgent.name} yourself`;
592
+ agentRow.detail = 'Run npx fraim add-ide after installing it.';
643
593
  this.persist();
644
594
  appendInstallLog(`custom-agent recorded ${req.customAgent.name}`);
645
595
  return this.respond(`Custom agent "${req.customAgent.name}" recorded.`, true);
@@ -651,53 +601,9 @@ class FirstRunSessionService {
651
601
  if (!option) {
652
602
  return this.respond(`Unknown agent: ${req.agentId}`, false);
653
603
  }
654
- const previousAgentId = this.state.agentId;
655
604
  this.state.agentId = req.agentId;
656
605
  delete this.state.customAgent;
657
- const agentRow = this.getRow('agent');
658
- const loginRow = this.getRow('agent-login');
659
- // If the user actually switched to a different agent, the row state from
660
- // the previous agent is now stale — even if it was `ok`. The new agent
661
- // has not been detected, installed, verified, or signed in. Re-detect
662
- // the new agent and reset both rows so the next run executes against
663
- // the new selection rather than silently keeping the previous verbs.
664
- if (previousAgentId !== req.agentId) {
665
- this.clearRowError(agentRow);
666
- const detectedVer = this.fakeMode ? null : commandVersion(option.launchCommand);
667
- if (detectedVer) {
668
- agentRow.status = 'ok';
669
- agentRow.verb = `${option.label} ${detectedVer} detected`;
670
- agentRow.detail = detectedVer;
671
- }
672
- else {
673
- agentRow.status = 'pending';
674
- agentRow.verb = pendingVerbForAgent(req.agentId);
675
- delete agentRow.detail;
676
- }
677
- // Login state never carries across agents.
678
- this.clearRowError(loginRow);
679
- const loginReady = agentRow.status === 'ok' && this.probeAgentReady(req.agentId).ok;
680
- if (loginReady) {
681
- loginRow.status = 'ok';
682
- loginRow.verb = 'Signed in (verified)';
683
- }
684
- else {
685
- loginRow.status = 'pending';
686
- loginRow.verb = "you'll sign in after install";
687
- }
688
- }
689
- else {
690
- // Same agent re-selected — only normalize verbs if rows were not yet ok.
691
- if (agentRow.status !== 'ok') {
692
- agentRow.verb = pendingVerbForAgent(req.agentId);
693
- this.clearRowError(agentRow);
694
- agentRow.status = 'pending';
695
- }
696
- if (loginRow.status !== 'ok') {
697
- loginRow.verb = "you'll sign in after install";
698
- loginRow.status = 'pending';
699
- }
700
- }
606
+ this.updateAgentSummaryRow();
701
607
  this.persist();
702
608
  appendInstallLog(`agent-changed ${req.agentId}`);
703
609
  return this.respond(`Selected ${option.label}.`, true);
@@ -713,29 +619,26 @@ class FirstRunSessionService {
713
619
  /**
714
620
  * Start the Hub server for the chosen project and open the user's browser
715
621
  * to it. Returns the Hub URL so the client can show a clear "we're at <url>"
716
- * message instead of a CLI hand-off the whole point of v1 is to never
622
+ * message instead of a CLI hand-off — the whole point of v1 is to never
717
623
  * leave the user typing a command. The single-file launcher binary in v2
718
624
  * (#355) replaces this in-process spawn with a durable tray-icon launcher.
719
625
  */
720
626
  async openHub() {
721
- if (!this.state.workspacePath) {
722
- return { ok: false, message: 'Pick a project folder before opening the Hub.' };
723
- }
724
627
  if (this.fakeMode) {
725
- // Tests don't actually want a Hub server running just confirm intent.
726
- return { ok: true, message: 'Fake-mode Hub open requested.', hubUrl: 'http://127.0.0.1:0/ai-hub/' };
628
+ // Tests don't actually want a Hub server running — just confirm intent.
629
+ return { ok: true, message: 'Fake-mode Hub open requested.', hubUrl: 'http://127.0.0.1:0/ai-hub/?firstRun=true' };
727
630
  }
728
631
  try {
729
632
  const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('../ai-hub/server')));
730
633
  const port = await findAvailablePort(43091);
731
634
  const hubServer = new AiHubServer({ projectPath: this.state.workspacePath });
732
635
  await hubServer.start(port);
733
- const hubUrl = `http://127.0.0.1:${port}/ai-hub/`;
636
+ const hubUrl = `http://127.0.0.1:${port}/ai-hub/?firstRun=true`;
734
637
  this.openBrowser(hubUrl);
735
638
  appendInstallLog(`hub-opened ${hubUrl}`);
736
639
  return {
737
640
  ok: true,
738
- message: `Hub is open at ${hubUrl}. From now on, run \`npx fraim-framework@latest hub --browser\` to launch it again the standalone launcher binary ships in v2 (#355).`,
641
+ message: `Hub is open at ${hubUrl}. From now on, run \`npx fraim-framework@latest hub --browser\` to launch it again — the standalone launcher binary ships in v2 (#355).`,
739
642
  hubUrl,
740
643
  };
741
644
  }
@@ -765,7 +668,7 @@ class FirstRunSessionService {
765
668
  (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
766
669
  }
767
670
  catch {
768
- // Best-effort fall through; the URL is already in the response so the
671
+ // Best-effort — fall through; the URL is already in the response so the
769
672
  // client surfaces it for the user.
770
673
  }
771
674
  }