fraim-framework 2.0.126 → 2.0.127

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.
Files changed (33) hide show
  1. package/dist/src/ai-hub/catalog.js +280 -44
  2. package/dist/src/ai-hub/desktop-main.js +2 -2
  3. package/dist/src/ai-hub/hosts.js +384 -10
  4. package/dist/src/ai-hub/server.js +255 -9
  5. package/dist/src/cli/commands/add-ide.js +4 -3
  6. package/dist/src/cli/commands/first-run.js +61 -0
  7. package/dist/src/cli/commands/hub.js +4 -4
  8. package/dist/src/cli/commands/init-project.js +4 -4
  9. package/dist/src/cli/commands/setup.js +4 -3
  10. package/dist/src/cli/commands/sync.js +21 -2
  11. package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
  12. package/dist/src/cli/fraim.js +2 -0
  13. package/dist/src/cli/mcp/ide-formats.js +29 -1
  14. package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
  15. package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
  16. package/dist/src/cli/setup/ide-detector.js +32 -1
  17. package/dist/src/cli/setup/ide-global-integration.js +5 -1
  18. package/dist/src/cli/setup/ide-invocation-surfaces.js +14 -0
  19. package/dist/src/cli/setup/mcp-config-generator.js +12 -1
  20. package/dist/src/cli/utils/agent-adapters.js +10 -0
  21. package/dist/src/core/utils/git-utils.js +14 -6
  22. package/dist/src/first-run/install-state.js +68 -0
  23. package/dist/src/first-run/server.js +153 -0
  24. package/dist/src/first-run/session-service.js +302 -0
  25. package/dist/src/first-run/types.js +40 -0
  26. package/dist/src/local-mcp-server/otlp-metrics-receiver.js +7 -1
  27. package/dist/src/local-mcp-server/stdio-server.js +41 -9
  28. package/package.json +3 -1
  29. package/public/ai-hub/index.html +149 -102
  30. package/public/ai-hub/script.js +1154 -271
  31. package/public/ai-hub/styles.css +753 -450
  32. package/public/first-run/index.html +221 -0
  33. package/public/first-run/script.js +361 -0
@@ -12,6 +12,7 @@ const net_1 = __importDefault(require("net"));
12
12
  const crypto_1 = require("crypto");
13
13
  const child_process_1 = require("child_process");
14
14
  const catalog_1 = require("./catalog");
15
+ const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
15
16
  const hosts_1 = require("./hosts");
16
17
  const preferences_1 = require("./preferences");
17
18
  class AiHubRunRegistry {
@@ -25,6 +26,9 @@ class AiHubRunRegistry {
25
26
  get(runId) {
26
27
  return this.runs.get(runId);
27
28
  }
29
+ all() {
30
+ return [...this.runs.values()];
31
+ }
28
32
  create(run, child) {
29
33
  this.runs.set(run.id, run);
30
34
  this.children.set(run.id, child);
@@ -35,14 +39,190 @@ class AiHubRunRegistry {
35
39
  if (!run) {
36
40
  throw new Error(`Run ${runId} not found`);
37
41
  }
42
+ const priorStatus = run.status;
38
43
  updater(run);
39
- run.updatedAt = new Date().toISOString();
44
+ const now = new Date().toISOString();
45
+ // Issue #347: when status flipped, accumulate the previous status's
46
+ // duration into the appropriate working/waiting bucket. lastStatusChangeAt
47
+ // is the wall-clock timestamp of the most recent flip; on the first
48
+ // call it falls back to createdAt.
49
+ if (run.status !== priorStatus) {
50
+ const lastAt = run.lastStatusChangeAt || run.createdAt;
51
+ const elapsedMs = Date.parse(now) - Date.parse(lastAt);
52
+ if (Number.isFinite(elapsedMs) && elapsedMs > 0) {
53
+ run.totals = run.totals || emptyTotals();
54
+ if (priorStatus === 'running') {
55
+ run.totals.workingDurationMs += elapsedMs;
56
+ }
57
+ else {
58
+ run.totals.waitingDurationMs += elapsedMs;
59
+ }
60
+ }
61
+ run.lastStatusChangeAt = now;
62
+ }
63
+ run.updatedAt = now;
40
64
  return run;
41
65
  }
42
66
  dispose(runId) {
43
67
  this.children.delete(runId);
44
68
  }
45
69
  }
70
+ function emptyTotals() {
71
+ return {
72
+ totalDurationMs: 0,
73
+ workingDurationMs: 0,
74
+ waitingDurationMs: 0,
75
+ tokenTotals: {
76
+ inputTokens: null,
77
+ outputTokens: null,
78
+ costUsd: null,
79
+ // Issue #347: Hub server cannot read tokenSnapshot from the local
80
+ // proxy in-process. Cross-process attribution is a follow-up;
81
+ // until then, every Hub-driven run reports unavailable. The Hub UI
82
+ // renders "—" per spec R4.6.
83
+ coverage: 'unavailable',
84
+ },
85
+ };
86
+ }
87
+ // Issue #347 — apply per-turn usage from the host stream into the
88
+ // run's tokenTotals. Idempotent on the same turn (we replace, not add)
89
+ // because Codex's `turn.completed.usage` is cumulative, not delta.
90
+ // When the host doesn't emit costUsd directly (Codex), we compute it
91
+ // from the captured agent identity via the price table.
92
+ function applyUsageSignal(run, usage) {
93
+ run.totals = run.totals || emptyTotals();
94
+ const tt = run.totals.tokenTotals;
95
+ // Total input tokens (for the user-facing display) = non-cached + cached.
96
+ // Cumulative across the whole run on both hosts; take max to guard
97
+ // against any out-of-order delivery.
98
+ const totalInput = usage.nonCachedInputTokens + usage.cachedInputTokens;
99
+ tt.inputTokens = Math.max(tt.inputTokens || 0, totalInput);
100
+ tt.outputTokens = Math.max(tt.outputTokens || 0, usage.outputTokens);
101
+ // Cost: prefer the host's own number (Claude). For hosts that don't
102
+ // emit it (Codex), compute from the captured agent identity + price
103
+ // table. If neither source applies, leave costUsd null and coverage
104
+ // stays 'partial'.
105
+ let computedCost = null;
106
+ if (typeof usage.costUsd === 'number') {
107
+ computedCost = usage.costUsd;
108
+ }
109
+ else if (run.agentName && run.agentModel) {
110
+ const price = (0, agent_token_prices_1.lookupPrice)(run.agentName.toLowerCase(), run.agentModel.toLowerCase());
111
+ if (price) {
112
+ computedCost =
113
+ (usage.nonCachedInputTokens / 1_000_000) * price.inputPerMTok +
114
+ (usage.cachedInputTokens / 1_000_000) * price.cacheReadPerMTok +
115
+ (usage.cacheCreationTokens / 1_000_000) * price.cacheCreationPerMTok +
116
+ (usage.outputTokens / 1_000_000) * price.outputPerMTok;
117
+ }
118
+ }
119
+ if (computedCost !== null) {
120
+ tt.costUsd = Math.max(tt.costUsd || 0, computedCost);
121
+ tt.coverage = 'complete';
122
+ }
123
+ else if (tt.coverage !== 'complete') {
124
+ tt.coverage = 'partial';
125
+ }
126
+ }
127
+ // Issue #347 — capture agent identity from the host stream's fraim_connect
128
+ // call. Used downstream by applyUsageSignal to compute cost via the price
129
+ // table when the host does not emit costUsd directly.
130
+ function applyAgentIdentitySignal(run, identity) {
131
+ run.agentName = identity.agentName;
132
+ run.agentModel = identity.agentModel;
133
+ }
134
+ // Apply a parsed seekMentoring tool-use signal from the host stream to
135
+ // the run state. Returns the updated currentPhase.
136
+ function applySeekMentoringSignal(run, signal) {
137
+ // Issue #347: filter cross-job pollution. The agent may call
138
+ // seekMentoring for jobs OTHER than the one this run is tracking
139
+ // (e.g., consulting `organizational-learning-synthesis` mid-run).
140
+ // Only apply signals whose jobId/jobName matches this run's jobId.
141
+ // Match against either field because the agent passes both and they
142
+ // typically agree, but defenses are cheap.
143
+ const targetJobId = run.jobId;
144
+ const callJobId = signal.jobId || signal.jobName;
145
+ if (callJobId && targetJobId && callJobId !== targetJobId)
146
+ return;
147
+ // Discriminant signals are routing hints only — they don't move the
148
+ // tracker, but they do change which phase id will be considered
149
+ // reachable next time stages are derived. Persist on the run.
150
+ if (signal.phaseId === '__discriminant__') {
151
+ if (signal.discriminant)
152
+ run.runDiscriminant = signal.discriminant;
153
+ return;
154
+ }
155
+ if (signal.discriminant)
156
+ run.runDiscriminant = signal.discriminant;
157
+ // The seekMentoring workflow uses `currentPhase: "starting"` as a
158
+ // sentinel for the very first call ("give me phase 1 instructions").
159
+ // It is not an actual phase id — skip it so the tracker doesn't
160
+ // render an extra "starting" stage at the end of the path.
161
+ if (signal.phaseId === 'starting')
162
+ return;
163
+ run.phaseHistory = run.phaseHistory || [];
164
+ const existing = run.phaseHistory.find((entry) => entry.phaseId === signal.phaseId);
165
+ if (existing) {
166
+ existing.latestStatus = signal.phaseStatus;
167
+ if (signal.findingsText)
168
+ existing.latestText = signal.findingsText;
169
+ }
170
+ else {
171
+ const entry = {
172
+ phaseId: signal.phaseId,
173
+ enteredAt: new Date().toISOString(),
174
+ latestStatus: signal.phaseStatus,
175
+ latestText: signal.findingsText || null,
176
+ };
177
+ run.phaseHistory.push(entry);
178
+ }
179
+ run.currentPhase = signal.phaseId;
180
+ }
181
+ // Build the stage list for a run. Combines the FSM's reachable path with
182
+ // any phases the run has actually visited (in case the run took an
183
+ // onFailure back-edge into a phase the simple onSuccess walk wouldn't
184
+ // surface). Each stage is marked done / current / upcoming based on
185
+ // whether it precedes / matches / follows the current phase along the
186
+ // rendered order.
187
+ function deriveStages(run, projectPath) {
188
+ const declaredPath = (0, catalog_1.loadJobPhases)(run.jobId, projectPath, run.runDiscriminant || 'feature');
189
+ if (declaredPath.length === 0)
190
+ return [];
191
+ // Merge in any visited phase that's not on the declared path BUT is
192
+ // declared in the job's frontmatter (e.g., a phase reached via an
193
+ // onFailure back-edge that the simple onSuccess walk didn't surface).
194
+ // Skip the 'starting' sentinel, the __discriminant__ marker, and any
195
+ // phase id NOT in the frontmatter — that last filter prevents
196
+ // cross-job pollution from showing up on the tracker.
197
+ const allDeclared = (0, catalog_1.loadAllJobPhaseIds)(run.jobId, projectPath);
198
+ const visited = (run.phaseHistory || []).map((entry) => entry.phaseId);
199
+ const known = new Set(declaredPath.map((p) => p.id));
200
+ for (const visitedId of visited) {
201
+ if (visitedId === 'starting' || visitedId === '__discriminant__')
202
+ continue;
203
+ if (!allDeclared.has(visitedId))
204
+ continue;
205
+ if (!known.has(visitedId)) {
206
+ declaredPath.push({ id: visitedId, label: (0, catalog_1.labelForPhaseId)(visitedId, run.jobId, projectPath) });
207
+ known.add(visitedId);
208
+ }
209
+ }
210
+ const currentIndex = run.currentPhase
211
+ ? declaredPath.findIndex((p) => p.id === run.currentPhase)
212
+ : -1;
213
+ return declaredPath.map((phase, index) => {
214
+ let state;
215
+ if (currentIndex < 0)
216
+ state = 'upcoming';
217
+ else if (index < currentIndex)
218
+ state = 'done';
219
+ else if (index === currentIndex)
220
+ state = 'current';
221
+ else
222
+ state = 'upcoming';
223
+ return { phaseId: phase.id, label: phase.label, state };
224
+ });
225
+ }
46
226
  function ensureDirectoryPath(projectPath) {
47
227
  const trimmed = (projectPath || '').trim();
48
228
  if (!trimmed) {
@@ -99,15 +279,20 @@ class AiHubServer {
99
279
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
100
280
  const jobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
101
281
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
282
+ // Issue #347: enrich the activeRun the same way GET /runs/:id does
283
+ // so the bootstrap surface (used on first paint) carries stages and
284
+ // live totals — not just the raw run state.
285
+ const latest = this.runRegistry.listLatest();
286
+ const activeRun = latest ? this.enrichRunForResponse(latest) : undefined;
102
287
  return {
103
- title: 'Visa AI Hub',
288
+ title: 'AI Hub',
104
289
  project,
105
290
  preferences,
106
- categories: (0, catalog_1.getAiHubCategories)(),
291
+ categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath),
107
292
  jobs,
108
293
  managerTemplates,
109
294
  employees: this.hostRuntime.detectEmployees(),
110
- activeRun: this.runRegistry.listLatest(),
295
+ activeRun,
111
296
  };
112
297
  }
113
298
  registerRoutes() {
@@ -148,16 +333,22 @@ class AiHubServer {
148
333
  if (!employee?.available) {
149
334
  throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
150
335
  }
336
+ const startTimestamp = new Date().toISOString();
151
337
  const run = {
152
338
  id: (0, crypto_1.randomUUID)(),
153
339
  jobId,
154
340
  hostId,
155
341
  projectPath,
156
342
  status: 'running',
157
- createdAt: new Date().toISOString(),
158
- updatedAt: new Date().toISOString(),
343
+ createdAt: startTimestamp,
344
+ updatedAt: startTimestamp,
159
345
  messages: [(0, hosts_1.createHubMessage)('manager', message)],
160
346
  events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} in ${projectPath}`)],
347
+ // Issue #347 — seed phase + totals state on creation.
348
+ currentPhase: null,
349
+ phaseHistory: [],
350
+ totals: emptyTotals(),
351
+ lastStatusChangeAt: startTimestamp,
161
352
  };
162
353
  this.runRegistry.create(run, {});
163
354
  const child = this.hostRuntime.startRun(hostId, projectPath, message, {
@@ -169,6 +360,12 @@ class AiHubServer {
169
360
  current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
170
361
  if (event.raw)
171
362
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
363
+ if (event.agentIdentity)
364
+ applyAgentIdentitySignal(current, event.agentIdentity);
365
+ if (event.seekMentoring)
366
+ applySeekMentoringSignal(current, event.seekMentoring);
367
+ if (event.usage)
368
+ applyUsageSignal(current, event.usage);
172
369
  });
173
370
  },
174
371
  onExit: (exitCode) => {
@@ -188,7 +385,7 @@ class AiHubServer {
188
385
  employeeId: hostId,
189
386
  recentJobIds: existingPreferences.recentJobIds,
190
387
  }, jobId);
191
- res.status(201).json(run);
388
+ res.status(201).json(this.enrichRunForResponse(run));
192
389
  }
193
390
  catch (error) {
194
391
  res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
@@ -221,6 +418,12 @@ class AiHubServer {
221
418
  current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
222
419
  if (event.raw)
223
420
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
421
+ if (event.agentIdentity)
422
+ applyAgentIdentitySignal(current, event.agentIdentity);
423
+ if (event.seekMentoring)
424
+ applySeekMentoringSignal(current, event.seekMentoring);
425
+ if (event.usage)
426
+ applyUsageSignal(current, event.usage);
224
427
  });
225
428
  },
226
429
  onExit: (exitCode) => {
@@ -233,7 +436,8 @@ class AiHubServer {
233
436
  },
234
437
  });
235
438
  this.runRegistry.create(run, child);
236
- res.json(this.runRegistry.get(run.id));
439
+ const refreshed = this.runRegistry.get(run.id);
440
+ res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
237
441
  }
238
442
  catch (error) {
239
443
  res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
@@ -244,9 +448,51 @@ class AiHubServer {
244
448
  if (!run) {
245
449
  return res.status(404).json({ error: 'Run not found.' });
246
450
  }
247
- return res.json(run);
451
+ return res.json(this.enrichRunForResponse(run));
248
452
  });
249
453
  }
454
+ // Issue #347 — assemble the read-side projection of a run. Stages are
455
+ // derived from job frontmatter + visited phases; totalDurationMs ticks
456
+ // forward while the run is still running so the UI's totals line
457
+ // updates each poll without the server having to rewrite the run on
458
+ // every tick.
459
+ enrichRunForResponse(run) {
460
+ const stages = deriveStages(run, run.projectPath);
461
+ const now = Date.now();
462
+ const created = Date.parse(run.createdAt);
463
+ const updated = Date.parse(run.updatedAt);
464
+ const liveTotalMs = run.status === 'running'
465
+ ? Math.max(0, now - created)
466
+ : Math.max(0, updated - created);
467
+ const baseTotals = run.totals || emptyTotals();
468
+ // While the run is still in its current status (not yet flipped), the
469
+ // current bucket needs to include the in-flight delta since the last
470
+ // flip. Compute it here so the client sees a smoothly increasing
471
+ // working/waiting figure instead of frozen counters between flips.
472
+ const lastFlipMs = run.lastStatusChangeAt
473
+ ? Date.parse(run.lastStatusChangeAt)
474
+ : created;
475
+ const inflightMs = Math.max(0, now - lastFlipMs);
476
+ const liveTotals = {
477
+ totalDurationMs: liveTotalMs,
478
+ workingDurationMs: baseTotals.workingDurationMs + (run.status === 'running' ? inflightMs : 0),
479
+ waitingDurationMs: baseTotals.waitingDurationMs + (run.status !== 'running' ? inflightMs : 0),
480
+ tokenTotals: baseTotals.tokenTotals,
481
+ };
482
+ // Defensive cap: working + waiting should never exceed total. Trim
483
+ // the trailing bucket if rounding pushes us past.
484
+ const sum = liveTotals.workingDurationMs + liveTotals.waitingDurationMs;
485
+ if (sum > liveTotalMs) {
486
+ const overflow = sum - liveTotalMs;
487
+ if (run.status === 'running') {
488
+ liveTotals.workingDurationMs = Math.max(0, liveTotals.workingDurationMs - overflow);
489
+ }
490
+ else {
491
+ liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
492
+ }
493
+ }
494
+ return { ...run, stages, totals: liveTotals };
495
+ }
250
496
  }
251
497
  exports.AiHubServer = AiHubServer;
252
498
  async function findAvailablePort(preferredPort) {
@@ -187,7 +187,7 @@ const configureIDEMCP = async (ide, fraimKey, tokens, providerConfigs) => {
187
187
  }
188
188
  }
189
189
  if (ide.configFormat === 'toml') {
190
- // Handle TOML format (e.g., Codex, Zed)
190
+ // Handle TOML format (e.g., Codex)
191
191
  let existingTomlContent = '';
192
192
  if (fs_1.default.existsSync(configPath)) {
193
193
  existingTomlContent = fs_1.default.readFileSync(configPath, 'utf8');
@@ -270,6 +270,7 @@ const listSupportedIDEs = () => {
270
270
  console.log(chalk_1.default.yellow('💡 Use "fraim add-ide --ide <name>" to configure a specific IDE'));
271
271
  console.log(chalk_1.default.yellow(' Example: fraim add-ide --ide claude-code'));
272
272
  console.log(chalk_1.default.yellow(' Anthropic aliases: claude, claude-code, claude-desktop, claude-cowork'));
273
+ console.log(chalk_1.default.yellow(' Gemini aliases: gemini, gemini-cli, gemini cli'));
273
274
  };
274
275
  const promptForIDESelection = async (availableIDEs, tokens) => {
275
276
  console.log(chalk_1.default.green(`✅ Found ${availableIDEs.length} IDEs that can be configured:\n`));
@@ -357,7 +358,7 @@ const runAddIDE = async (options) => {
357
358
  const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
358
359
  if (detectedIDEs.length === 0) {
359
360
  console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected on your system.'));
360
- console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Kiro, Cursor, VSCode, Codex, Windsurf'));
361
+ console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Windsurf'));
361
362
  console.log(chalk_1.default.blue('\n💡 Install an IDE and run this command again.'));
362
363
  return;
363
364
  }
@@ -435,7 +436,7 @@ const runAddIDE = async (options) => {
435
436
  exports.runAddIDE = runAddIDE;
436
437
  exports.addIDECommand = new commander_1.Command('add-ide')
437
438
  .description('Add FRAIM configuration to additional IDEs')
438
- .option('--ide <name>', 'Configure specific IDE (claude, claude-code, claude-desktop, claude-cowork, antigravity, kiro, cursor, vscode, codex, windsurf)')
439
+ .option('--ide <name>', 'Configure specific IDE (claude, claude-code, claude-desktop, claude-cowork, antigravity, gemini, gemini-cli, kiro, cursor, vscode, codex, windsurf)')
439
440
  .option('--all', 'Configure all detected IDEs')
440
441
  .option('--list', 'List all supported IDEs and their detection status')
441
442
  .action(exports.runAddIDE);
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.firstRunCommand = exports.runFirstRun = void 0;
7
+ const commander_1 = require("commander");
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ const child_process_1 = require("child_process");
10
+ const server_1 = require("../../first-run/server");
11
+ const session_service_1 = require("../../first-run/session-service");
12
+ const server_2 = require("../../ai-hub/server");
13
+ function openBrowser(url) {
14
+ try {
15
+ if (process.platform === 'win32') {
16
+ (0, child_process_1.spawn)('cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`], { detached: true, stdio: 'ignore' }).unref();
17
+ return;
18
+ }
19
+ if (process.platform === 'darwin') {
20
+ (0, child_process_1.spawn)('open', [url], { detached: true, stdio: 'ignore' }).unref();
21
+ return;
22
+ }
23
+ (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
24
+ }
25
+ catch {
26
+ // Best effort only.
27
+ }
28
+ }
29
+ const runFirstRun = async (options) => {
30
+ const key = options.key || process.env.FRAIM_API_KEY || process.env.FRAIM_SETUP_KEY || process.env.FRAIM_INSTALL_KEY;
31
+ if (!key) {
32
+ console.log(chalk_1.default.red('FRAIM key is required for first-run.'));
33
+ process.exit(1);
34
+ }
35
+ const sessionService = new session_service_1.FirstRunSessionService({
36
+ key,
37
+ headless: options.headless,
38
+ resume: options.resume,
39
+ projectRoot: options.projectRoot,
40
+ });
41
+ const server = new server_1.FirstRunServer({ sessionService });
42
+ const port = await (0, server_2.findAvailablePort)(43120);
43
+ const url = `http://127.0.0.1:${port}/first-run/`;
44
+ await server.start(port);
45
+ console.log(chalk_1.default.blue('Starting FRAIM first-run...'));
46
+ console.log(chalk_1.default.white(`First run available at ${url}`));
47
+ if (!options.headless) {
48
+ openBrowser(url);
49
+ }
50
+ await server.waitForFinish();
51
+ await server.stop();
52
+ console.log(chalk_1.default.green('FRAIM first-run completed.'));
53
+ };
54
+ exports.runFirstRun = runFirstRun;
55
+ exports.firstRunCommand = new commander_1.Command('first-run')
56
+ .description('Run the guided first-run installer experience')
57
+ .option('--key <key>', 'FRAIM API key')
58
+ .option('--resume', 'Resume an interrupted first-run session')
59
+ .option('--headless', 'Do not attempt to open a browser automatically')
60
+ .option('--project-root <path>', 'Seed the first-run flow with an explicit project root')
61
+ .action(exports.runFirstRun);
@@ -63,7 +63,7 @@ function openBrowser(url) {
63
63
  child.unref();
64
64
  }
65
65
  exports.hubCommand = new commander_1.Command('hub')
66
- .description('Start the Visa AI Hub local companion for running FRAIM jobs through Codex or Claude Code')
66
+ .description('Start the AI Hub local companion for running FRAIM jobs through Codex or Claude Code')
67
67
  .option('--port <port>', 'Preferred local port for the hub', (value) => Number(value), 43091)
68
68
  .option('--project-path <path>', 'Initial project path for job discovery', process.cwd())
69
69
  .option('--no-open', 'Do not open the hub after startup')
@@ -78,12 +78,12 @@ exports.hubCommand = new commander_1.Command('hub')
78
78
  const server = new server_1.AiHubServer({ projectPath });
79
79
  await server.start(port);
80
80
  const url = `http://127.0.0.1:${port}/ai-hub/`;
81
- console.log(`Visa AI Hub running at ${url}`);
81
+ console.log(`AI Hub running at ${url}`);
82
82
  console.log(`Project path: ${projectPath}`);
83
83
  openBrowser(url);
84
84
  return;
85
85
  }
86
- console.log('Visa AI Hub desktop shell launched.');
86
+ console.log('AI Hub desktop shell launched.');
87
87
  console.log(`Project path: ${projectPath}`);
88
88
  return;
89
89
  }
@@ -91,6 +91,6 @@ exports.hubCommand = new commander_1.Command('hub')
91
91
  const server = new server_1.AiHubServer({ projectPath });
92
92
  await server.start(port);
93
93
  const url = `http://127.0.0.1:${port}/ai-hub/`;
94
- console.log(`Visa AI Hub running at ${url}`);
94
+ console.log(`AI Hub running at ${url}`);
95
95
  console.log(`Project path: ${projectPath}`);
96
96
  });
@@ -158,7 +158,7 @@ const createGitHubLabels = (projectRoot) => {
158
158
  console.log(chalk_1.default.yellow(`Error reading labels.json: ${error.message}`));
159
159
  }
160
160
  };
161
- const runInitProject = async () => {
161
+ const runInitProject = async (options = {}) => {
162
162
  console.log(chalk_1.default.blue('Initializing FRAIM project...'));
163
163
  const globalSetup = checkGlobalSetup();
164
164
  if (!globalSetup.exists) {
@@ -167,7 +167,7 @@ const runInitProject = async () => {
167
167
  console.log(chalk_1.default.cyan(' fraim setup'));
168
168
  process.exit(1);
169
169
  }
170
- const projectRoot = process.cwd();
170
+ const projectRoot = options.projectRoot || process.cwd();
171
171
  const fraimDirDisplayPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)();
172
172
  const configDisplayPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('config.json');
173
173
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
@@ -312,7 +312,7 @@ const runInitProject = async () => {
312
312
  result.repositoryDetected = true;
313
313
  }
314
314
  if (!process.env.FRAIM_SKIP_SYNC) {
315
- await (0, sync_1.runSync)({});
315
+ await (0, sync_1.runSync)({ projectRoot });
316
316
  result.syncPerformed = true;
317
317
  }
318
318
  else {
@@ -338,4 +338,4 @@ const runInitProject = async () => {
338
338
  exports.runInitProject = runInitProject;
339
339
  exports.initProjectCommand = new commander_1.Command('init-project')
340
340
  .description('Initialize FRAIM in the current project (requires global setup)')
341
- .action(exports.runInitProject);
341
+ .action(() => (0, exports.runInitProject)());
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.setupCommandInitialization = exports.setupCommand = exports.runSetup = void 0;
39
+ exports.setupCommandInitialization = exports.setupCommand = exports.runSetup = exports.saveGlobalConfig = void 0;
40
40
  // Refactored setup.ts - Generic provider system with zero hardcoded provider knowledge
41
41
  const commander_1 = require("commander");
42
42
  const chalk_1 = __importDefault(require("chalk"));
@@ -244,6 +244,7 @@ const saveGlobalConfig = (fraimKey, mode, tokens, configs) => {
244
244
  fs_1.default.writeFileSync(globalConfigPath, JSON.stringify(config, null, 2));
245
245
  console.log(chalk_1.default.green('✅ Global FRAIM configuration saved'));
246
246
  };
247
+ exports.saveGlobalConfig = saveGlobalConfig;
247
248
  // Parse CLI options into generic format using provider registry from server
248
249
  // Tokens/config are optional - will prompt if not provided
249
250
  const parseLegacyOptions = async (options, fraimKey) => {
@@ -435,7 +436,7 @@ const runSetup = async (options) => {
435
436
  }
436
437
  // Save and configure MCP
437
438
  console.log(chalk_1.default.blue('\n💾 Saving global configuration...'));
438
- saveGlobalConfig(fraimKey, mode, tokens, configs);
439
+ (0, exports.saveGlobalConfig)(fraimKey, mode, tokens, configs);
439
440
  console.log(chalk_1.default.blue('\n🔌 Configuring MCP servers...'));
440
441
  const mcpTokens = {};
441
442
  Object.entries(tokens).forEach(([id, token]) => {
@@ -572,7 +573,7 @@ const runSetup = async (options) => {
572
573
  }
573
574
  // Save global configuration
574
575
  console.log(chalk_1.default.blue('💾 Saving global configuration...'));
575
- saveGlobalConfig(fraimKey, mode, tokens, configs);
576
+ (0, exports.saveGlobalConfig)(fraimKey, mode, tokens, configs);
576
577
  // Configure MCP servers
577
578
  if (!isUpdate) {
578
579
  // Initial setup - configure all detected IDEs
@@ -48,6 +48,24 @@ const git_utils_1 = require("../../core/utils/git-utils");
48
48
  const agent_adapters_1 = require("../utils/agent-adapters");
49
49
  const fraim_gitignore_1 = require("../utils/fraim-gitignore");
50
50
  const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
51
+ function resolveExplicitLocalSyncUrl() {
52
+ const candidates = [
53
+ process.env.FRAIM_TEST_SERVER_URL,
54
+ process.env.FRAIM_REMOTE_URL,
55
+ ].filter((value) => Boolean(value));
56
+ for (const candidate of candidates) {
57
+ try {
58
+ const parsed = new URL(candidate);
59
+ if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
60
+ return parsed.origin;
61
+ }
62
+ }
63
+ catch {
64
+ // Ignore malformed overrides and fall back to the derived loopback URL.
65
+ }
66
+ }
67
+ return undefined;
68
+ }
51
69
  /**
52
70
  * Load API key from user-level config (~/.fraim/config.json)
53
71
  */
@@ -98,7 +116,7 @@ const runSync = async (options) => {
98
116
  }
99
117
  return;
100
118
  }
101
- const projectRoot = process.cwd();
119
+ const projectRoot = options.projectRoot ? path_1.default.resolve(options.projectRoot) : process.cwd();
102
120
  const config = (0, config_loader_1.loadFraimConfig)();
103
121
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
104
122
  const refreshLocalIgnoreConfig = () => {
@@ -121,8 +139,9 @@ const runSync = async (options) => {
121
139
  if (options.local) {
122
140
  console.log(chalk_1.default.blue('Syncing FRAIM jobs from local server...'));
123
141
  const localPort = process.env.FRAIM_MCP_PORT ? parseInt(process.env.FRAIM_MCP_PORT) : (0, git_utils_1.getPort)();
142
+ const localUrl = resolveExplicitLocalSyncUrl() || `http://localhost:${localPort}`;
124
143
  const result = await syncFromRemote({
125
- remoteUrl: `http://localhost:${localPort}`,
144
+ remoteUrl: localUrl,
126
145
  apiKey: 'local-dev',
127
146
  projectRoot,
128
147
  skipUpdates: true
@@ -60,6 +60,8 @@ function checkMCPConfigsExist() {
60
60
  let existCount = 0;
61
61
  let missingCount = 0;
62
62
  const missing = [];
63
+ const bootstrapableMissing = [];
64
+ const blockingMissing = [];
63
65
  for (const ide of installedIDEs) {
64
66
  const configPath = (0, ide_detector_1.expandPath)(ide.configPath);
65
67
  if (fs_1.default.existsSync(configPath)) {
@@ -68,6 +70,12 @@ function checkMCPConfigsExist() {
68
70
  else {
69
71
  missingCount++;
70
72
  missing.push(ide.name);
73
+ if (ide.supportsConfigBootstrap) {
74
+ bootstrapableMissing.push(ide.name);
75
+ }
76
+ else {
77
+ blockingMissing.push(ide.name);
78
+ }
71
79
  }
72
80
  }
73
81
  if (missingCount === 0) {
@@ -86,11 +94,21 @@ function checkMCPConfigsExist() {
86
94
  details: { existCount, missingCount, missing }
87
95
  };
88
96
  }
97
+ if (blockingMissing.length === 0 && bootstrapableMissing.length > 0) {
98
+ return {
99
+ status: 'warning',
100
+ message: `Detected IDEs still need initial MCP config bootstrap: ${bootstrapableMissing.join(', ')}`,
101
+ suggestion: 'Run fraim add-ide to create the initial MCP configuration',
102
+ command: 'fraim add-ide',
103
+ details: { missingCount, missing, bootstrapableMissing }
104
+ };
105
+ }
89
106
  return {
90
107
  status: 'error',
91
- message: 'No MCP configs found',
108
+ message: `No MCP configs found for detected IDEs: ${blockingMissing.join(', ')}`,
92
109
  suggestion: 'Run fraim add-ide to configure MCP servers',
93
- command: 'fraim add-ide'
110
+ command: 'fraim add-ide',
111
+ details: { missingCount, missing, blockingMissing, bootstrapableMissing }
94
112
  };
95
113
  }
96
114
  };
@@ -51,6 +51,7 @@ const login_1 = require("./commands/login");
51
51
  const mcp_1 = require("./commands/mcp");
52
52
  const migrate_project_fraim_1 = require("./commands/migrate-project-fraim");
53
53
  const hub_1 = require("./commands/hub");
54
+ const first_run_1 = require("./commands/first-run");
54
55
  const fs_1 = __importDefault(require("fs"));
55
56
  const path_1 = __importDefault(require("path"));
56
57
  const program = new commander_1.Command();
@@ -91,6 +92,7 @@ program.addCommand(login_1.loginCommand);
91
92
  program.addCommand(mcp_1.mcpCommand);
92
93
  program.addCommand(migrate_project_fraim_1.migrateProjectFraimCommand);
93
94
  program.addCommand(hub_1.hubCommand);
95
+ program.addCommand(first_run_1.firstRunCommand);
94
96
  // Wait for async command initialization before parsing
95
97
  (async () => {
96
98
  // Import the initialization promise from setup command