fraim 2.0.128 → 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.
@@ -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
  }
@@ -139,17 +234,40 @@ class FirstRunSessionService {
139
234
  delete row.errorFrame;
140
235
  delete row.streamOutput;
141
236
  }
142
- setRowError(row, whatTried, whatHappened, actions) {
237
+ setRowError(row, whatTried, whatHappened, actions, hint) {
143
238
  row.status = 'error';
144
- row.verb = 'failed see below';
145
- row.errorFrame = { whatTried, whatHappened, actions };
239
+ row.verb = 'failed — see below';
240
+ row.errorFrame = { whatTried, whatHappened, actions, ...(hint ? { hint } : {}) };
241
+ }
242
+ /**
243
+ * Classify a thrown init/sync error into a non-tech-friendly hint.
244
+ * The verbatim stderr is preserved separately in `whatHappened` per
245
+ * R6.3 — the hint is additive context, not a replacement.
246
+ */
247
+ classifyInitError(detail) {
248
+ if (/status code 401|Unauthorized|401\b/i.test(detail)) {
249
+ return "Your FRAIM install key was rejected by the registry. If your key expired, get a new one from your account page and re-run setup with the new key. If you're testing locally, set `FRAIM_LOCAL_SYNC=1` so the project row syncs from your local FRAIM server instead of the production registry.";
250
+ }
251
+ if (/status code 403|Forbidden/i.test(detail)) {
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.";
253
+ }
254
+ if (/status code 5\d\d/i.test(detail) || /5\d\d\b/.test(detail)) {
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.';
256
+ }
257
+ if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|getaddrinfo|network/i.test(detail)) {
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.";
259
+ }
260
+ if (/Global FRAIM setup not found/i.test(detail)) {
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.';
262
+ }
263
+ return undefined;
146
264
  }
147
265
  detectRowsOnLoad() {
148
266
  if (this.fakeMode) {
149
267
  this.applyFakeStateOnLoad(this.fakeMode);
150
268
  return;
151
269
  }
152
- // Real detection populate row statuses from `command -v` style probes.
270
+ // Real detection — populate row statuses from `command -v` style probes.
153
271
  const nodeRow = this.getRow('node');
154
272
  const nodeVer = commandVersion('node');
155
273
  if (nodeVer) {
@@ -170,53 +288,38 @@ class FirstRunSessionService {
170
288
  gitRow.status = 'pending';
171
289
  gitRow.verb = "we'll install";
172
290
  }
173
- const agentRow = this.getRow('agent');
174
- const detected = this.detectAnyKnownAgent();
175
- if (detected) {
176
- this.state.agentId = detected.id;
177
- agentRow.status = 'ok';
178
- agentRow.verb = `${detected.label} detected`;
179
- agentRow.detail = detected.versionLine ?? undefined;
180
- }
181
- else {
182
- agentRow.status = 'pending';
183
- agentRow.verb = pendingVerbForAgent(this.state.agentId);
184
- }
185
- const loginRow = this.getRow('agent-login');
186
- if (agentRow.status === 'ok' && this.probeAgentReady(this.state.agentId).ok) {
187
- loginRow.status = 'ok';
188
- loginRow.verb = 'Signed in (verified)';
189
- }
190
- else {
191
- loginRow.status = 'pending';
192
- loginRow.verb = "you'll sign in after install";
193
- }
194
- const projectRow = this.getRow('project');
195
- if (this.state.workspacePath && fs_1.default.existsSync(this.state.workspacePath)) {
196
- projectRow.status = 'ok';
197
- projectRow.verb = this.state.workspacePath;
291
+ const fraimRow = this.getRow('fraim');
292
+ if (commandVersion('npx') !== null) {
293
+ fraimRow.status = 'ok';
294
+ fraimRow.verb = 'ready';
295
+ persistShellPath();
198
296
  }
199
297
  else {
200
- projectRow.status = 'manual-required';
201
- projectRow.verb = 'pick a folder where FRAIM should work';
298
+ fraimRow.status = 'pending';
299
+ fraimRow.verb = "we'll set up FRAIM";
202
300
  }
301
+ this.updateAgentSummaryRow();
203
302
  }
204
- detectAnyKnownAgent() {
205
- // Priority order matches FIRST_RUN_AGENT_OPTIONS ordering.
206
- for (const option of types_1.FIRST_RUN_AGENT_OPTIONS) {
207
- if (commandExists(option.launchCommand)) {
208
- return { id: option.id, label: option.label, versionLine: commandVersion(option.launchCommand) };
209
- }
210
- }
211
- // Also accept matches via the IDE detector for desktop variants.
212
- const ides = (0, ide_detector_1.detectInstalledIDEs)();
213
- for (const option of types_1.FIRST_RUN_AGENT_OPTIONS) {
214
- const ide = ides.find((entry) => entry.aliases?.some((a) => option.detectAliases.includes(a)));
215
- if (ide) {
216
- return { id: option.id, label: option.label, versionLine: null };
217
- }
303
+ updateAgentSummaryRow() {
304
+ const agentRow = this.getRow('agent');
305
+ const surfaces = this.fakeMode === 'no-agents' ? [] : buildConfiguredSurfaces();
306
+ if (surfaces.length > 0) {
307
+ agentRow.status = 'ok';
308
+ agentRow.verb = `${surfaces.length} AI Employee${surfaces.length === 1 ? '' : 's'} ready`;
309
+ agentRow.detail = surfaces.map((surface) => surface.name).join(', ');
218
310
  }
219
- return null;
311
+ else {
312
+ agentRow.status = 'manual-required';
313
+ agentRow.verb = "let's recruit AI Employees";
314
+ delete agentRow.detail;
315
+ }
316
+ this.state.setupResult = {
317
+ mode: 'conversational',
318
+ configuredSurfaces: surfaces,
319
+ failedSurfaces: [],
320
+ detectedSurfaceCount: surfaces.length,
321
+ completedAt: new Date().toISOString(),
322
+ };
220
323
  }
221
324
  applyFakeStateOnLoad(mode) {
222
325
  const setStatus = (rowId, status, verb, detail) => {
@@ -229,33 +332,63 @@ class FirstRunSessionService {
229
332
  if (mode === 'all-pending') {
230
333
  setStatus('node', 'pending', "we'll install");
231
334
  setStatus('git', 'pending', "we'll install");
232
- setStatus('agent', 'pending', pendingVerbForAgent(this.state.agentId));
233
- setStatus('agent-login', 'pending', "you'll sign in after install");
234
- setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
335
+ setStatus('fraim', 'pending', "we'll install");
336
+ setStatus('agent', 'pending', "we'll check for AI Employees");
337
+ delete this.state.setupResult;
235
338
  return;
236
339
  }
237
340
  if (mode === 'all-detected') {
238
341
  setStatus('node', 'ok', 'v20.11.1 detected');
239
342
  setStatus('git', 'ok', 'git version 2.45 detected');
240
- setStatus('agent', 'ok', 'Claude Code v1.4.2 detected');
241
- setStatus('agent-login', 'ok', 'Signed in (verified by claude -p probe)');
242
- setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
343
+ setStatus('fraim', 'ok', 'fraim-framework detected');
344
+ setStatus('agent', 'ok', '1 AI Employee ready', 'Claude Code');
345
+ this.state.setupResult = {
346
+ mode: 'conversational',
347
+ configuredSurfaces: [
348
+ { id: 'claude-code', name: 'Claude Code', invocationHint: 'Claude Code: /fraim onboard this project', status: 'configured' },
349
+ ],
350
+ failedSurfaces: [],
351
+ detectedSurfaceCount: 1,
352
+ completedAt: new Date().toISOString(),
353
+ };
354
+ return;
355
+ }
356
+ if (mode === 'no-agents') {
357
+ setStatus('node', 'ok', 'v20.11.1 detected');
358
+ setStatus('git', 'ok', 'git version 2.45 detected');
359
+ setStatus('fraim', 'ok', 'fraim-framework detected');
360
+ setStatus('agent', 'manual-required', "let's recruit AI Employees");
361
+ this.state.setupResult = {
362
+ mode: 'conversational',
363
+ configuredSurfaces: [],
364
+ failedSurfaces: [],
365
+ detectedSurfaceCount: 0,
366
+ completedAt: new Date().toISOString(),
367
+ };
243
368
  return;
244
369
  }
245
370
  if (mode === 'agent-install-fails') {
246
371
  setStatus('node', 'ok', 'v20.11.1 installed');
247
372
  setStatus('git', 'ok', '2.45 installed');
248
- setStatus('agent', 'pending', pendingVerbForAgent(this.state.agentId));
249
- setStatus('agent-login', 'pending', "you'll sign in after install");
250
- setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
373
+ setStatus('fraim', 'ok', 'fraim-framework installed');
374
+ setStatus('agent', 'pending', "we'll check for AI Employees");
375
+ delete this.state.setupResult;
251
376
  return;
252
377
  }
253
- // 'default' fake mode everything ok, project still requires a pick.
378
+ // 'default' fake mode — infra rows all ok.
254
379
  setStatus('node', 'ok', 'v20.11.1 detected');
255
380
  setStatus('git', 'ok', 'git version 2.45 detected');
256
- setStatus('agent', 'ok', 'fake-mode agent detected');
257
- setStatus('agent-login', 'ok', 'fake-mode signed in');
258
- setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
381
+ setStatus('fraim', 'ok', 'fake-mode fraim installed');
382
+ setStatus('agent', 'ok', 'fake-mode AI Employee ready', 'Claude Code');
383
+ this.state.setupResult = {
384
+ mode: 'conversational',
385
+ configuredSurfaces: [
386
+ { id: 'claude-code', name: 'Claude Code', invocationHint: 'Claude Code: /fraim onboard this project', status: 'configured' },
387
+ ],
388
+ failedSurfaces: [],
389
+ detectedSurfaceCount: 1,
390
+ completedAt: new Date().toISOString(),
391
+ };
259
392
  }
260
393
  getRequestToken() {
261
394
  return this.requestToken;
@@ -303,16 +436,14 @@ class FirstRunSessionService {
303
436
  return await this.runNodeRow();
304
437
  case 'git':
305
438
  return await this.runGitRow();
439
+ case 'fraim':
440
+ return await this.runFraimRow();
306
441
  case 'agent':
307
442
  return await this.runAgentRow(request);
308
- case 'agent-login':
309
- return await this.runAgentLoginRow(request);
310
- case 'project':
311
- return await this.runProjectRow(request);
312
443
  }
313
444
  }
314
445
  catch (error) {
315
- // Last-ditch safety net every row's runner is supposed to surface
446
+ // Last-ditch safety net — every row's runner is supposed to surface
316
447
  // its own R6 frame via setRowError before throwing, but if anything
317
448
  // slips past (e.g. a sync layer that calls process.exit and we narrowly
318
449
  // converted it to throw), we still surface a frame here rather than
@@ -320,7 +451,7 @@ class FirstRunSessionService {
320
451
  // user is never wedged on a non-recoverable failure of one row.
321
452
  const message = error instanceof Error ? error.message : 'Unknown error';
322
453
  row.status = 'error';
323
- row.verb = 'failed see below';
454
+ row.verb = 'failed — see below';
324
455
  row.errorFrame = {
325
456
  whatTried: `Running ${row.label} step`,
326
457
  whatHappened: message,
@@ -373,250 +504,80 @@ class FirstRunSessionService {
373
504
  this.persist();
374
505
  return this.respond('git not detected.', false);
375
506
  }
376
- async runAgentRow(request) {
377
- const row = this.getRow('agent');
378
- // If the user picked an alternative through the error frame, switch and continue.
379
- if (request.errorActionId === 'alternative' && request.alternativeAgentId) {
380
- this.state.agentId = request.alternativeAgentId;
381
- row.verb = pendingVerbForAgent(this.state.agentId);
382
- }
383
- if (request.errorActionId === 'skip') {
384
- row.status = 'manual-required';
385
- row.verb = 'you said you\'ll set up the agent yourself';
386
- // If the user skipped agent install, sign-in is also their problem —
387
- // there's no agent to sign into. Mark agent-login manual-required so
388
- // the auto-progress loop doesn't try to probe a nonexistent CLI on
389
- // the next Continue click.
390
- const loginRow = this.getRow('agent-login');
391
- if (loginRow.status !== 'ok') {
392
- loginRow.status = 'manual-required';
393
- loginRow.verb = "you'll handle sign-in when you set up the agent";
394
- }
395
- this.persist();
396
- return this.respond('Agent setup deferred.', true);
397
- }
398
- const option = findAgentOption(this.state.agentId);
399
- if (!option) {
400
- this.setRowError(row, 'Looking up the AI agent install command', `Unknown agent id: ${this.state.agentId}`, [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
401
- this.persist();
402
- return this.respond('Unknown agent.', false);
403
- }
404
- // Fake mode short-circuit — never call real OS commands. The whole point
405
- // of fake mode is hermetic, deterministic test rendering.
406
- if (this.fakeMode === 'agent-install-fails') {
407
- this.setRowError(row, `Installing ${option.label} via npm install -g ${option.installPackage}`, this.fakeStderr, this.buildAgentInstallActions(this.state.agentId));
408
- this.persist();
409
- return this.respond('Fake-mode agent install failed.', false);
410
- }
507
+ async runFraimRow() {
508
+ const row = this.getRow('fraim');
411
509
  if (this.fakeMode) {
412
510
  row.status = 'ok';
413
- row.verb = `${option.label} installed (fake-mode)`;
511
+ row.verb = 'FRAIM ready (fake-mode)';
414
512
  this.persist();
415
- return this.respond('Fake-mode agent installed.', true);
513
+ return this.respond('Fake-mode fraim ok.', true);
416
514
  }
417
- // Real path: detect first — if already installed we skip install entirely.
418
- const existingVer = commandVersion(option.launchCommand);
419
- if (existingVer) {
515
+ try {
516
+ if (!commandVersion('fraim')) {
517
+ const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
518
+ fs_1.default.mkdirSync(prefix, { recursive: true });
519
+ row.streamOutput = 'Installing FRAIM on this machine...';
520
+ this.persist();
521
+ await runProcess('npm', ['install', '-g', 'fraim-framework@latest'], { npm_config_prefix: prefix });
522
+ }
523
+ persistShellPath();
524
+ (0, setup_1.saveGlobalConfig)(this.key, 'conversational', {}, {});
525
+ const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
526
+ if (detectedIDEs.length > 0) {
527
+ await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, {}, detectedIDEs.map((ide) => ide.name), {});
528
+ }
529
+ const { syncUserLevelArtifacts } = await Promise.resolve().then(() => __importStar(require('../cli/setup/user-level-sync')));
530
+ await syncUserLevelArtifacts();
531
+ const { installSlashCommands, installGlobalRules } = await Promise.resolve().then(() => __importStar(require('../cli/setup/ide-global-integration')));
532
+ await installSlashCommands();
533
+ await installGlobalRules();
420
534
  row.status = 'ok';
421
- row.verb = `${option.label} ${existingVer} detected`;
422
- row.detail = existingVer;
535
+ row.verb = 'Ready.';
536
+ delete row.streamOutput;
423
537
  this.persist();
424
- return this.respond(`${option.label} already installed.`, true);
538
+ return this.respond('FRAIM is ready.', true);
425
539
  }
426
- // Real install — npm install -g <package>. Stream output into row.streamOutput.
427
- const result = (0, child_process_1.spawnSync)('npm', ['install', '-g', option.installPackage], {
428
- encoding: 'utf8',
429
- timeout: 300000,
430
- });
431
- const combined = `${result.stdout || ''}${result.stderr || ''}`.trim();
432
- row.streamOutput = combined.slice(-4000);
433
- if (result.status === 0) {
434
- const ver = commandVersion(option.launchCommand) ?? 'installed';
435
- row.status = 'ok';
436
- row.verb = `${option.label} ${ver}`;
437
- row.detail = ver;
540
+ catch (error) {
541
+ const detail = error instanceof Error ? error.message : 'Unknown error';
542
+ this.setRowError(row, 'Setting up FRAIM on this machine', detail, [
543
+ { id: 'retry', label: 'Retry', variant: 'primary' },
544
+ { id: 'skip', label: 'Skip and continue', variant: 'ghost' },
545
+ ]);
438
546
  this.persist();
439
- return this.respond(`${option.label} installed.`, true);
547
+ return this.respond('FRAIM setup failed.', false);
440
548
  }
441
- 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));
442
- this.persist();
443
- return this.respond(`${option.label} install failed.`, false);
444
549
  }
445
- buildAgentInstallActions(currentAgentId) {
446
- const others = types_1.FIRST_RUN_AGENT_OPTIONS.filter((option) => option.id !== currentAgentId);
447
- const actions = [
448
- { id: 'retry', label: 'Retry', variant: 'primary' },
449
- ];
450
- if (others.length > 0) {
451
- actions.push({
452
- id: 'alternative',
453
- label: `Try ${others[0].label} instead`,
454
- variant: 'secondary',
455
- alternativeAgentId: others[0].id,
456
- });
457
- }
458
- actions.push({ id: 'skip', label: 'Skip and continue', variant: 'ghost' });
459
- return actions;
460
- }
461
- async runAgentLoginRow(request) {
462
- const row = this.getRow('agent-login');
463
- if (request.errorActionId === 'skip') {
464
- row.status = 'manual-required';
465
- row.verb = "you'll sign in later from the Hub";
466
- this.persist();
467
- return this.respond('Agent login deferred.', true);
468
- }
469
- const option = findAgentOption(this.state.agentId);
470
- if (!option) {
471
- this.setRowError(row, 'Resolving agent login command', `Unknown agent id: ${this.state.agentId}`, [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
472
- this.persist();
473
- return this.respond('Unknown agent for login.', false);
474
- }
475
- if (this.fakeMode) {
476
- row.status = 'ok';
477
- row.verb = 'Signed in (fake-mode)';
478
- this.persist();
479
- return this.respond('Fake-mode signed in.', true);
480
- }
481
- // First, see whether the agent is already authenticated.
482
- const probe = this.probeAgentReady(this.state.agentId);
483
- if (probe.ok) {
484
- row.status = 'ok';
485
- row.verb = 'Signed in (verified)';
486
- this.persist();
487
- return this.respond(`${option.label} signed in.`, true);
488
- }
489
- // Not authenticated yet — surface as manual-required with a clear message.
490
- // We do NOT auto-spawn `claude login` from here because OAuth flows differ
491
- // by agent and need the user's actual browser. The client renders a
492
- // "Sign in to Claude in the new tab" prompt and polls back via Retry.
493
- row.status = 'manual-required';
494
- row.verb = `Sign in to ${option.label} (then click Retry)`;
495
- row.manualMessage = `Open a terminal and run \`${option.loginCommand}\`. We'll detect when you're signed in.`;
496
- this.persist();
497
- return this.respond(`Awaiting ${option.label} login.`, true);
498
- }
499
- probeAgentReady(agentId) {
500
- if (this.fakeMode)
501
- return { ok: this.fakeMode !== 'agent-install-fails', output: 'fake' };
502
- const option = findAgentOption(agentId);
503
- if (!option)
504
- return { ok: false, output: 'unknown agent' };
505
- if (agentId === 'codex') {
506
- const result = (0, child_process_1.spawnSync)('codex', ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'], {
507
- cwd: this.state.workspacePath || process.cwd(),
508
- input: 'Reply exactly "FRAIM_READY" if FRAIM is available in this workspace. Otherwise reply exactly "NOT_READY".',
509
- encoding: 'utf8',
510
- timeout: 60000,
511
- });
512
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
513
- return { ok: result.status === 0 && output.toUpperCase().includes('FRAIM'), output };
514
- }
515
- if (agentId === 'claude-code') {
516
- const result = (0, child_process_1.spawnSync)('claude', ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'], {
517
- cwd: this.state.workspacePath || process.cwd(),
518
- input: 'Reply exactly "FRAIM_READY" if FRAIM is available in this workspace. Otherwise reply exactly "NOT_READY".',
519
- encoding: 'utf8',
520
- timeout: 60000,
521
- });
522
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
523
- return { ok: result.status === 0 && output.toUpperCase().includes('FRAIM'), output };
524
- }
525
- // Gemini probe is `--help` based until Gemini ships a stronger non-interactive path.
526
- const result = (0, child_process_1.spawnSync)('gemini', ['--help'], {
527
- cwd: this.state.workspacePath || process.cwd(),
528
- encoding: 'utf8',
529
- timeout: 30000,
530
- });
531
- return { ok: result.status === 0, output: `${result.stdout || ''}${result.stderr || ''}`.trim() };
532
- }
533
- async runProjectRow(request) {
534
- const row = this.getRow('project');
535
- // Skip-and-continue contract: a user who explicitly skips this row chose
536
- // to set the project up themselves later. Mark manual-required so the
537
- // primary-button derivation can route to Open Hub via the skip-path rule.
550
+ async runAgentRow(request) {
551
+ const row = this.getRow('agent');
538
552
  if (request.errorActionId === 'skip') {
539
553
  row.status = 'manual-required';
540
- row.verb = "you'll pick a project folder later";
541
- this.clearRowError(row);
542
- this.persist();
543
- return this.respond('Project deferred.', true);
544
- }
545
- const projectPath = request.projectPath ?? this.state.workspacePath;
546
- if (!projectPath) {
547
- row.status = 'manual-required';
548
- row.verb = 'pick a folder where FRAIM should work';
554
+ row.verb = "let's recruit AI Employees";
555
+ this.state.setupResult = {
556
+ mode: 'conversational',
557
+ configuredSurfaces: [],
558
+ failedSurfaces: [],
559
+ detectedSurfaceCount: 0,
560
+ completedAt: new Date().toISOString(),
561
+ };
549
562
  this.persist();
550
- return this.respond('Project path required.', false);
551
- }
552
- const resolvedPath = path_1.default.resolve(projectPath);
553
- fs_1.default.mkdirSync(resolvedPath, { recursive: true });
554
- if (commandExists('git')) {
555
- try {
556
- (0, child_process_1.execSync)('git rev-parse --git-dir', { cwd: resolvedPath, stdio: 'ignore' });
557
- }
558
- catch {
559
- try {
560
- (0, child_process_1.execSync)('git init', { cwd: resolvedPath, stdio: 'ignore' });
561
- }
562
- catch (gitError) {
563
- // Surface but don't fail the row — init-project will still work.
564
- appendInstallLog(`git init failed at ${resolvedPath}: ${gitError instanceof Error ? gitError.message : 'unknown'}`);
565
- }
566
- }
567
- }
568
- if (!this.fakeMode) {
569
- // Configure FRAIM globals + agent MCP wiring before init-project runs.
570
- (0, setup_1.saveGlobalConfig)(this.key, 'conversational', {}, {});
571
- const selectedAgent = (0, ide_detector_1.findIDEByName)(this.state.agentId);
572
- const targetNames = selectedAgent ? [selectedAgent.name] : undefined;
573
- try {
574
- await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, {}, targetNames, {});
575
- }
576
- catch (mcpError) {
577
- appendInstallLog(`autoConfigureMCP non-fatal error: ${mcpError instanceof Error ? mcpError.message : 'unknown'}`);
578
- }
579
- // Pass failHard:'throw' so a sync 401 / network error / missing-key
580
- // bubbles back here instead of calling process.exit(1) and killing the
581
- // FRE server. The catch below converts it into the R6 error frame.
582
- try {
583
- await (0, init_project_1.runInitProject)({ projectRoot: resolvedPath, failHard: 'throw' });
584
- }
585
- catch (initError) {
586
- const detail = initError instanceof Error ? initError.message : 'Unknown init error';
587
- // Skip is intentionally omitted on the project row — without a
588
- // project there is no Hub to open, so deferring this row would
589
- // leave the user wedged. Retry is always offered; if the failure
590
- // is a 401 from remote sync, the verbatim message tells the user
591
- // their API key is the problem and they need to re-run with a
592
- // valid key.
593
- this.setRowError(row, `Initializing FRAIM in ${resolvedPath}`, detail, [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
594
- appendInstallLog(`project-init-failed ${resolvedPath}: ${detail}`);
595
- this.persist();
596
- return this.respond(`Initialization failed: ${detail}`, false);
597
- }
563
+ return this.respond('AI Employee recruiting deferred.', true);
598
564
  }
599
- this.state.workspacePath = resolvedPath;
600
- row.status = 'ok';
601
- row.verb = resolvedPath;
565
+ this.updateAgentSummaryRow();
602
566
  this.persist();
603
- appendInstallLog(`project-initialized ${resolvedPath}`);
604
- return this.respond(`Initialized FRAIM in ${resolvedPath}.`, true);
567
+ const count = this.state.setupResult?.detectedSurfaceCount || 0;
568
+ return this.respond(count > 0 ? 'AI Employees are ready.' : 'No AI Employees found.', true);
605
569
  }
606
570
  /**
607
- * Update the current agent selection (inline `Change…` picker). Recomputes
608
- * the agent and agent-login row verbs but does not run any installs.
571
+ * Update the current agent selection (inline `Change…` picker).
572
+ * Records the preference for future recruiting workflows; does not run any installs.
609
573
  */
610
574
  changeAgent(req) {
611
575
  if (req.customAgent) {
612
576
  this.state.customAgent = { name: req.customAgent.name, invocationPrefix: req.customAgent.invocationPrefix };
613
577
  const agentRow = this.getRow('agent');
614
- const loginRow = this.getRow('agent-login');
615
- agentRow.status = 'ok';
578
+ agentRow.status = 'manual-required';
616
579
  agentRow.verb = `Custom CLI recorded: ${req.customAgent.name}`;
617
- agentRow.detail = `(advanced Hub will not auto-invoke unknown CLIs in v1)`;
618
- loginRow.status = 'manual-required';
619
- loginRow.verb = `Sign in to ${req.customAgent.name} yourself`;
580
+ agentRow.detail = 'Run npx fraim add-ide after installing it.';
620
581
  this.persist();
621
582
  appendInstallLog(`custom-agent recorded ${req.customAgent.name}`);
622
583
  return this.respond(`Custom agent "${req.customAgent.name}" recorded.`, true);
@@ -628,53 +589,9 @@ class FirstRunSessionService {
628
589
  if (!option) {
629
590
  return this.respond(`Unknown agent: ${req.agentId}`, false);
630
591
  }
631
- const previousAgentId = this.state.agentId;
632
592
  this.state.agentId = req.agentId;
633
593
  delete this.state.customAgent;
634
- const agentRow = this.getRow('agent');
635
- const loginRow = this.getRow('agent-login');
636
- // If the user actually switched to a different agent, the row state from
637
- // the previous agent is now stale — even if it was `ok`. The new agent
638
- // has not been detected, installed, verified, or signed in. Re-detect
639
- // the new agent and reset both rows so the next run executes against
640
- // the new selection rather than silently keeping the previous verbs.
641
- if (previousAgentId !== req.agentId) {
642
- this.clearRowError(agentRow);
643
- const detectedVer = this.fakeMode ? null : commandVersion(option.launchCommand);
644
- if (detectedVer) {
645
- agentRow.status = 'ok';
646
- agentRow.verb = `${option.label} ${detectedVer} detected`;
647
- agentRow.detail = detectedVer;
648
- }
649
- else {
650
- agentRow.status = 'pending';
651
- agentRow.verb = pendingVerbForAgent(req.agentId);
652
- delete agentRow.detail;
653
- }
654
- // Login state never carries across agents.
655
- this.clearRowError(loginRow);
656
- const loginReady = agentRow.status === 'ok' && this.probeAgentReady(req.agentId).ok;
657
- if (loginReady) {
658
- loginRow.status = 'ok';
659
- loginRow.verb = 'Signed in (verified)';
660
- }
661
- else {
662
- loginRow.status = 'pending';
663
- loginRow.verb = "you'll sign in after install";
664
- }
665
- }
666
- else {
667
- // Same agent re-selected — only normalize verbs if rows were not yet ok.
668
- if (agentRow.status !== 'ok') {
669
- agentRow.verb = pendingVerbForAgent(req.agentId);
670
- this.clearRowError(agentRow);
671
- agentRow.status = 'pending';
672
- }
673
- if (loginRow.status !== 'ok') {
674
- loginRow.verb = "you'll sign in after install";
675
- loginRow.status = 'pending';
676
- }
677
- }
594
+ this.updateAgentSummaryRow();
678
595
  this.persist();
679
596
  appendInstallLog(`agent-changed ${req.agentId}`);
680
597
  return this.respond(`Selected ${option.label}.`, true);
@@ -690,29 +607,26 @@ class FirstRunSessionService {
690
607
  /**
691
608
  * Start the Hub server for the chosen project and open the user's browser
692
609
  * to it. Returns the Hub URL so the client can show a clear "we're at <url>"
693
- * message instead of a CLI hand-off the whole point of v1 is to never
610
+ * message instead of a CLI hand-off — the whole point of v1 is to never
694
611
  * leave the user typing a command. The single-file launcher binary in v2
695
612
  * (#355) replaces this in-process spawn with a durable tray-icon launcher.
696
613
  */
697
614
  async openHub() {
698
- if (!this.state.workspacePath) {
699
- return { ok: false, message: 'Pick a project folder before opening the Hub.' };
700
- }
701
615
  if (this.fakeMode) {
702
- // Tests don't actually want a Hub server running just confirm intent.
703
- return { ok: true, message: 'Fake-mode Hub open requested.', hubUrl: 'http://127.0.0.1:0/ai-hub/' };
616
+ // Tests don't actually want a Hub server running — just confirm intent.
617
+ return { ok: true, message: 'Fake-mode Hub open requested.', hubUrl: 'http://127.0.0.1:0/ai-hub/?firstRun=true' };
704
618
  }
705
619
  try {
706
620
  const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('../ai-hub/server')));
707
621
  const port = await findAvailablePort(43091);
708
622
  const hubServer = new AiHubServer({ projectPath: this.state.workspacePath });
709
623
  await hubServer.start(port);
710
- const hubUrl = `http://127.0.0.1:${port}/ai-hub/`;
624
+ const hubUrl = `http://127.0.0.1:${port}/ai-hub/?firstRun=true`;
711
625
  this.openBrowser(hubUrl);
712
626
  appendInstallLog(`hub-opened ${hubUrl}`);
713
627
  return {
714
628
  ok: true,
715
- 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).`,
629
+ 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).`,
716
630
  hubUrl,
717
631
  };
718
632
  }
@@ -728,7 +642,11 @@ class FirstRunSessionService {
728
642
  openBrowser(url) {
729
643
  try {
730
644
  if (process.platform === 'win32') {
731
- (0, child_process_1.spawn)('cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`], { detached: true, stdio: 'ignore' }).unref();
645
+ // `cmd.exe /c start "" "<url>"` mis-parses on some Windows hosts
646
+ // (cmd interprets the URL's `//` as a UNC path -> "Windows cannot
647
+ // find" popup). rundll32 + url.dll,FileProtocolHandler is the
648
+ // documented protocol-handler entry point and bypasses cmd entirely.
649
+ (0, child_process_1.spawn)('rundll32', ['url.dll,FileProtocolHandler', url], { detached: true, stdio: 'ignore' }).unref();
732
650
  return;
733
651
  }
734
652
  if (process.platform === 'darwin') {
@@ -738,7 +656,7 @@ class FirstRunSessionService {
738
656
  (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
739
657
  }
740
658
  catch {
741
- // Best-effort fall through; the URL is already in the response so the
659
+ // Best-effort — fall through; the URL is already in the response so the
742
660
  // client surfaces it for the user.
743
661
  }
744
662
  }