fraim-framework 2.0.120 → 2.0.123

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.
@@ -0,0 +1,307 @@
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.AiHubServer = void 0;
7
+ exports.findAvailablePort = findAvailablePort;
8
+ const express_1 = __importDefault(require("express"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const net_1 = __importDefault(require("net"));
12
+ const crypto_1 = require("crypto");
13
+ const child_process_1 = require("child_process");
14
+ const catalog_1 = require("./catalog");
15
+ const hosts_1 = require("./hosts");
16
+ const preferences_1 = require("./preferences");
17
+ class AiHubRunRegistry {
18
+ constructor() {
19
+ this.runs = new Map();
20
+ this.children = new Map();
21
+ }
22
+ listLatest() {
23
+ return [...this.runs.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
24
+ }
25
+ get(runId) {
26
+ return this.runs.get(runId);
27
+ }
28
+ create(run, child) {
29
+ this.runs.set(run.id, run);
30
+ this.children.set(run.id, child);
31
+ return run;
32
+ }
33
+ update(runId, updater) {
34
+ const run = this.runs.get(runId);
35
+ if (!run) {
36
+ throw new Error(`Run ${runId} not found`);
37
+ }
38
+ updater(run);
39
+ run.updatedAt = new Date().toISOString();
40
+ return run;
41
+ }
42
+ dispose(runId) {
43
+ this.children.delete(runId);
44
+ }
45
+ }
46
+ function ensureDirectoryPath(projectPath) {
47
+ const trimmed = (projectPath || '').trim();
48
+ if (!trimmed) {
49
+ throw new Error('Project path is required.');
50
+ }
51
+ const resolved = path_1.default.resolve(trimmed);
52
+ const stat = fs_1.default.existsSync(resolved) ? fs_1.default.statSync(resolved) : null;
53
+ if (!stat || !stat.isDirectory()) {
54
+ throw new Error('Project path must point to an existing directory.');
55
+ }
56
+ return resolved;
57
+ }
58
+ class AiHubServer {
59
+ constructor(options = {}) {
60
+ this.app = (0, express_1.default)();
61
+ this.runRegistry = new AiHubRunRegistry();
62
+ this.projectPath = options.projectPath || process.cwd();
63
+ this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
64
+ this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
65
+ this.app.use(express_1.default.json());
66
+ this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
67
+ this.app.get('/health', (_req, res) => {
68
+ res.json({ status: 'ok', service: 'fraim-ai-hub' });
69
+ });
70
+ this.registerRoutes();
71
+ }
72
+ getApp() {
73
+ return this.app;
74
+ }
75
+ async start(port) {
76
+ await new Promise((resolve, reject) => {
77
+ this.httpServer = this.app.listen(port, '127.0.0.1');
78
+ this.httpServer.once('listening', () => resolve());
79
+ this.httpServer.once('error', (error) => reject(error));
80
+ });
81
+ }
82
+ async stop() {
83
+ if (!this.httpServer)
84
+ return;
85
+ await new Promise((resolve, reject) => {
86
+ this.httpServer.close((error) => {
87
+ if (error) {
88
+ reject(error);
89
+ return;
90
+ }
91
+ resolve();
92
+ });
93
+ });
94
+ this.httpServer = undefined;
95
+ }
96
+ bootstrapResponse(projectPath) {
97
+ const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
98
+ const preferences = this.preferencesStore.load(normalizedProjectPath);
99
+ const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
100
+ const jobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
101
+ const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
102
+ return {
103
+ title: 'Visa AI Hub',
104
+ project,
105
+ preferences,
106
+ categories: (0, catalog_1.getAiHubCategories)(),
107
+ jobs,
108
+ managerTemplates,
109
+ employees: this.hostRuntime.detectEmployees(),
110
+ activeRun: this.runRegistry.listLatest(),
111
+ };
112
+ }
113
+ registerRoutes() {
114
+ this.app.get('/api/ai-hub/bootstrap', (req, res) => {
115
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
116
+ ? req.query.projectPath
117
+ : this.projectPath;
118
+ res.json(this.bootstrapResponse(projectPath));
119
+ });
120
+ this.app.post('/api/ai-hub/project-path/pick', (_req, res) => {
121
+ try {
122
+ const projectPath = pickProjectPath();
123
+ if (!projectPath) {
124
+ return res.status(204).end();
125
+ }
126
+ return res.json({ path: projectPath });
127
+ }
128
+ catch (error) {
129
+ return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open the folder picker.' });
130
+ }
131
+ });
132
+ this.app.post('/api/ai-hub/runs', (req, res) => {
133
+ try {
134
+ const projectPath = ensureDirectoryPath(req.body.projectPath || this.projectPath);
135
+ const hostId = req.body.hostId;
136
+ const jobId = req.body.jobId;
137
+ const message = (req.body.message || '').trim();
138
+ if (hostId !== 'codex' && hostId !== 'claude') {
139
+ throw new Error('Choose an available employee before starting a job.');
140
+ }
141
+ if (!jobId) {
142
+ throw new Error('Choose a FRAIM job before starting a run.');
143
+ }
144
+ if (!message) {
145
+ throw new Error('Coach your employee before starting the run.');
146
+ }
147
+ const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
148
+ if (!employee?.available) {
149
+ throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
150
+ }
151
+ const run = {
152
+ id: (0, crypto_1.randomUUID)(),
153
+ jobId,
154
+ hostId,
155
+ projectPath,
156
+ status: 'running',
157
+ createdAt: new Date().toISOString(),
158
+ updatedAt: new Date().toISOString(),
159
+ messages: [(0, hosts_1.createHubMessage)('manager', message)],
160
+ events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} in ${projectPath}`)],
161
+ };
162
+ this.runRegistry.create(run, {});
163
+ const child = this.hostRuntime.startRun(hostId, projectPath, message, {
164
+ onEvent: (event, channel) => {
165
+ this.runRegistry.update(run.id, (current) => {
166
+ if (event.sessionId)
167
+ current.sessionId = event.sessionId;
168
+ if (event.message)
169
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
170
+ if (event.raw)
171
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
172
+ });
173
+ },
174
+ onExit: (exitCode) => {
175
+ this.runRegistry.update(run.id, (current) => {
176
+ current.exitCode = exitCode;
177
+ current.status = exitCode === 0 ? 'completed' : 'failed';
178
+ current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
179
+ });
180
+ this.runRegistry.dispose(run.id);
181
+ },
182
+ });
183
+ this.runRegistry.create(run, child);
184
+ const existingPreferences = this.preferencesStore.load(projectPath);
185
+ this.preferencesStore.remember({
186
+ ...existingPreferences,
187
+ projectPath,
188
+ employeeId: hostId,
189
+ recentJobIds: existingPreferences.recentJobIds,
190
+ }, jobId);
191
+ res.status(201).json(run);
192
+ }
193
+ catch (error) {
194
+ res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
195
+ }
196
+ });
197
+ this.app.post('/api/ai-hub/runs/:runId/messages', (req, res) => {
198
+ try {
199
+ const run = this.runRegistry.get(req.params.runId);
200
+ if (!run) {
201
+ return res.status(404).json({ error: 'Run not found.' });
202
+ }
203
+ if (!run.sessionId) {
204
+ return res.status(409).json({ error: 'This run does not have a resumable host session yet.' });
205
+ }
206
+ const message = (req.body.message || '').trim();
207
+ if (!message) {
208
+ return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
209
+ }
210
+ this.runRegistry.update(run.id, (current) => {
211
+ current.status = 'running';
212
+ current.messages.push((0, hosts_1.createHubMessage)('manager', message));
213
+ });
214
+ this.runRegistry.create(run, {});
215
+ const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
216
+ onEvent: (event, channel) => {
217
+ this.runRegistry.update(run.id, (current) => {
218
+ if (event.sessionId)
219
+ current.sessionId = event.sessionId;
220
+ if (event.message)
221
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
222
+ if (event.raw)
223
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
224
+ });
225
+ },
226
+ onExit: (exitCode) => {
227
+ this.runRegistry.update(run.id, (current) => {
228
+ current.exitCode = exitCode;
229
+ current.status = exitCode === 0 ? 'completed' : 'failed';
230
+ current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
231
+ });
232
+ this.runRegistry.dispose(run.id);
233
+ },
234
+ });
235
+ this.runRegistry.create(run, child);
236
+ res.json(this.runRegistry.get(run.id));
237
+ }
238
+ catch (error) {
239
+ res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
240
+ }
241
+ });
242
+ this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
243
+ const run = this.runRegistry.get(req.params.runId);
244
+ if (!run) {
245
+ return res.status(404).json({ error: 'Run not found.' });
246
+ }
247
+ return res.json(run);
248
+ });
249
+ }
250
+ }
251
+ exports.AiHubServer = AiHubServer;
252
+ async function findAvailablePort(preferredPort) {
253
+ let port = preferredPort;
254
+ while (port < preferredPort + 20) {
255
+ const available = await new Promise((resolve) => {
256
+ const server = net_1.default.createServer();
257
+ server.once('error', () => resolve(false));
258
+ server.once('listening', () => {
259
+ server.close(() => resolve(true));
260
+ });
261
+ server.listen(port, '127.0.0.1');
262
+ });
263
+ if (available)
264
+ return port;
265
+ port += 1;
266
+ }
267
+ throw new Error(`No free loopback port found near ${preferredPort}.`);
268
+ }
269
+ function resolveAiHubPublicDir() {
270
+ const candidates = [
271
+ path_1.default.resolve(process.cwd(), 'public/ai-hub'),
272
+ path_1.default.resolve(__dirname, '..', '..', 'public/ai-hub'),
273
+ path_1.default.resolve(__dirname, '..', '..', '..', 'public/ai-hub'),
274
+ ];
275
+ for (const candidate of candidates) {
276
+ if (fs_1.default.existsSync(candidate)) {
277
+ return candidate;
278
+ }
279
+ }
280
+ throw new Error('Could not locate public/ai-hub assets.');
281
+ }
282
+ function pickProjectPath() {
283
+ if (process.platform === 'win32') {
284
+ const script = [
285
+ 'Add-Type -AssemblyName System.Windows.Forms',
286
+ '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
287
+ '$dialog.ShowNewFolderButton = $false',
288
+ 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {',
289
+ ' Write-Output $dialog.SelectedPath',
290
+ '}',
291
+ ].join('; ');
292
+ const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-Command', script], {
293
+ encoding: 'utf8',
294
+ });
295
+ return result.status === 0 ? result.stdout.trim() || null : null;
296
+ }
297
+ if (process.platform === 'darwin') {
298
+ const result = (0, child_process_1.spawnSync)('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")'], {
299
+ encoding: 'utf8',
300
+ });
301
+ return result.status === 0 ? result.stdout.trim() || null : null;
302
+ }
303
+ const result = (0, child_process_1.spawnSync)('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null'], {
304
+ encoding: 'utf8',
305
+ });
306
+ return result.status === 0 ? result.stdout.trim() || null : null;
307
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,96 @@
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.hubCommand = void 0;
7
+ const commander_1 = require("commander");
8
+ const server_1 = require("../../ai-hub/server");
9
+ const git_utils_1 = require("../../core/utils/git-utils");
10
+ const path_1 = __importDefault(require("path"));
11
+ const child_process_1 = require("child_process");
12
+ const fs_1 = __importDefault(require("fs"));
13
+ function resolveElectronBinary() {
14
+ try {
15
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
16
+ return require('electron');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function resolveDesktopEntry() {
23
+ const candidates = [
24
+ path_1.default.resolve(__dirname, '..', '..', 'ai-hub', 'desktop-main.js'),
25
+ path_1.default.resolve(process.cwd(), 'dist', 'src', 'ai-hub', 'desktop-main.js'),
26
+ ];
27
+ for (const candidate of candidates) {
28
+ try {
29
+ fs_1.default.accessSync(candidate);
30
+ return candidate;
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ function openDesktopWindow(projectPath, preferredPort) {
39
+ const electronBinary = resolveElectronBinary();
40
+ const desktopEntry = resolveDesktopEntry();
41
+ if (!electronBinary || !desktopEntry) {
42
+ return false;
43
+ }
44
+ const child = (0, child_process_1.spawn)(electronBinary, [desktopEntry, '--project-path', projectPath, '--port', String(preferredPort)], {
45
+ detached: true,
46
+ stdio: 'ignore',
47
+ });
48
+ child.unref();
49
+ return true;
50
+ }
51
+ function openBrowser(url) {
52
+ if (process.platform === 'win32') {
53
+ const child = (0, child_process_1.spawn)('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' });
54
+ child.unref();
55
+ return;
56
+ }
57
+ if (process.platform === 'darwin') {
58
+ const child = (0, child_process_1.spawn)('open', [url], { detached: true, stdio: 'ignore' });
59
+ child.unref();
60
+ return;
61
+ }
62
+ const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
63
+ child.unref();
64
+ }
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')
67
+ .option('--port <port>', 'Preferred local port for the hub', (value) => Number(value), 43091)
68
+ .option('--project-path <path>', 'Initial project path for job discovery', process.cwd())
69
+ .option('--no-open', 'Do not open the hub after startup')
70
+ .option('--browser', 'Open in the default browser instead of the desktop shell')
71
+ .action(async (options) => {
72
+ const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
73
+ const projectPath = path_1.default.resolve(options.projectPath || process.cwd());
74
+ if (options.open) {
75
+ const openedDesktop = !options.browser && openDesktopWindow(projectPath, preferredPort);
76
+ if (!openedDesktop) {
77
+ const port = await (0, server_1.findAvailablePort)(preferredPort);
78
+ const server = new server_1.AiHubServer({ projectPath });
79
+ await server.start(port);
80
+ const url = `http://127.0.0.1:${port}/ai-hub/`;
81
+ console.log(`Visa AI Hub running at ${url}`);
82
+ console.log(`Project path: ${projectPath}`);
83
+ openBrowser(url);
84
+ return;
85
+ }
86
+ console.log('Visa AI Hub desktop shell launched.');
87
+ console.log(`Project path: ${projectPath}`);
88
+ return;
89
+ }
90
+ const port = await (0, server_1.findAvailablePort)(preferredPort);
91
+ const server = new server_1.AiHubServer({ projectPath });
92
+ await server.start(port);
93
+ const url = `http://127.0.0.1:${port}/ai-hub/`;
94
+ console.log(`Visa AI Hub running at ${url}`);
95
+ console.log(`Project path: ${projectPath}`);
96
+ });
@@ -74,7 +74,7 @@ const promptForFraimKey = async () => {
74
74
  process.exit(1);
75
75
  }
76
76
  console.log(chalk_1.default.blue('🔑 FRAIM Key Setup'));
77
- console.log('FRAIM requires a valid API key to access workflows and features.\n');
77
+ console.log('FRAIM requires a valid API key to access jobs and features.\n');
78
78
  let key = null;
79
79
  let attempts = 0;
80
80
  const maxAttempts = 3;
@@ -149,7 +149,7 @@ const promptForMode = async () => {
149
149
  {
150
150
  title: 'Conversational Mode',
151
151
  value: 'conversational',
152
- description: 'AI workflows only, no platform integration required'
152
+ description: 'AI jobs only, no platform integration required'
153
153
  }
154
154
  ],
155
155
  initial: 0
@@ -160,7 +160,7 @@ const promptForMode = async () => {
160
160
  }
161
161
  if (response.mode === 'conversational') {
162
162
  console.log(chalk_1.default.blue('\n✓ Conversational mode selected'));
163
- console.log(chalk_1.default.gray(' You can use FRAIM workflows without platform credentials.'));
163
+ console.log(chalk_1.default.gray(' You can use FRAIM jobs without platform credentials.'));
164
164
  console.log(chalk_1.default.gray(' Platform features (issues, PRs) will be unavailable.\n'));
165
165
  }
166
166
  else if (response.mode === 'split') {
@@ -584,7 +584,7 @@ const runSetup = async (options) => {
584
584
  });
585
585
  if (mode === 'conversational' && Object.keys(mcpTokens).length === 0) {
586
586
  console.log(chalk_1.default.yellow('ℹ️ Conversational mode: Configuring MCP servers without platform integration'));
587
- console.log(chalk_1.default.gray(' FRAIM workflows will work, but platform-specific features will be unavailable\n'));
587
+ console.log(chalk_1.default.gray(' FRAIM jobs will work, but platform-specific features will be unavailable\n'));
588
588
  }
589
589
  try {
590
590
  // Build providerConfigs map from configs
@@ -668,7 +668,7 @@ const runSetup = async (options) => {
668
668
  console.log(chalk_1.default.white(' layer of your AI-powered work at once: AI agents become an'));
669
669
  console.log(chalk_1.default.white(' accountable workforce, you become a capable AI manager, and'));
670
670
  console.log(chalk_1.default.white(' your leadership gains clear optics on AI proficiency.'));
671
- console.log(chalk_1.default.gray('\n 60+ jobs across engineering, marketing, fundraising,'));
671
+ console.log(chalk_1.default.gray('\n 120+ jobs across engineering, marketing, fundraising,'));
672
672
  console.log(chalk_1.default.gray(' legal, product, hiring, customer development, and more.'));
673
673
  // Show which IDEs were configured and how to use FRAIM in each
674
674
  const { detectInstalledIDEs: detectIDEs } = await Promise.resolve().then(() => __importStar(require('../setup/ide-detector')));
@@ -50,6 +50,7 @@ const list_overridable_1 = require("./commands/list-overridable");
50
50
  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
+ const hub_1 = require("./commands/hub");
53
54
  const fs_1 = __importDefault(require("fs"));
54
55
  const path_1 = __importDefault(require("path"));
55
56
  const program = new commander_1.Command();
@@ -89,6 +90,7 @@ program.addCommand(list_overridable_1.listOverridableCommand);
89
90
  program.addCommand(login_1.loginCommand);
90
91
  program.addCommand(mcp_1.mcpCommand);
91
92
  program.addCommand(migrate_project_fraim_1.migrateProjectFraimCommand);
93
+ program.addCommand(hub_1.hubCommand);
92
94
  // Wait for async command initialization before parsing
93
95
  (async () => {
94
96
  // Import the initialization promise from setup command
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.resolveManagedCommand = exports.getPortableNpxCommand = void 0;
6
+ exports.resolveManagedCommand = exports.getSystemCommandPath = exports.getPortableNpxCommand = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
@@ -36,10 +36,43 @@ const getPortableNpxCommand = () => {
36
36
  return null;
37
37
  };
38
38
  exports.getPortableNpxCommand = getPortableNpxCommand;
39
+ const getPathEntries = () => {
40
+ const rawPath = process.env.PATH || '';
41
+ return rawPath
42
+ .split(path_1.default.delimiter)
43
+ .map((entry) => entry.trim())
44
+ .filter(Boolean);
45
+ };
46
+ const getSystemCommandCandidates = (command) => {
47
+ if (!command || path_1.default.isAbsolute(command)) {
48
+ return command ? [command] : [];
49
+ }
50
+ const commandNames = process.platform === 'win32'
51
+ ? command.includes('.')
52
+ ? [command]
53
+ : [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`, `${command}.com`]
54
+ : [command];
55
+ return getPathEntries().flatMap((entry) => commandNames.map((name) => path_1.default.join(entry, name)));
56
+ };
57
+ const getSystemCommandPath = (command) => {
58
+ for (const candidate of getSystemCommandCandidates(command)) {
59
+ try {
60
+ const stats = fs_1.default.statSync(candidate);
61
+ if (stats.isFile()) {
62
+ return candidate;
63
+ }
64
+ }
65
+ catch {
66
+ // Ignore missing or inaccessible PATH entries and keep scanning.
67
+ }
68
+ }
69
+ return null;
70
+ };
71
+ exports.getSystemCommandPath = getSystemCommandPath;
39
72
  const resolveManagedCommand = (command) => {
40
73
  if (command !== 'npx') {
41
74
  return command;
42
75
  }
43
- return (0, exports.getPortableNpxCommand)() || command;
76
+ return (0, exports.getPortableNpxCommand)() || (0, exports.getSystemCommandPath)(command) || command;
44
77
  };
45
78
  exports.resolveManagedCommand = resolveManagedCommand;
@@ -21,8 +21,9 @@ function describeConfiguredInvocationSurfaces(installedIDEs) {
21
21
  return installedIDEs.map((ide) => (0, ide_invocation_surfaces_1.describeInvocationSurface)(ide.name, ide.invocationProfile));
22
22
  }
23
23
  /**
24
- * Install the FRAIM slash command for Claude Code at the user level.
25
- * Writes to ~/.claude/commands/fraim.md and does not overwrite existing files.
24
+ * Install Claude FRAIM discovery artifacts at the user level.
25
+ * Writes a skill to ~/.claude/skills/fraim/SKILL.md and a compatibility command
26
+ * to ~/.claude/commands/fraim.md. Existing user files are preserved.
26
27
  */
27
28
  async function installSlashCommands(homeDir) {
28
29
  const home = homeDir || os_1.default.homedir();
@@ -30,7 +31,8 @@ async function installSlashCommands(homeDir) {
30
31
  if (!fs_1.default.existsSync(claudeDir)) {
31
32
  return;
32
33
  }
33
- installFileIfMissing(path_1.default.join(claudeDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildClaudeSlashCommandContent)(), 'Claude slash command (~/.claude/commands/fraim.md)');
34
+ installFileIfMissing(path_1.default.join(claudeDir, 'skills', 'fraim', 'SKILL.md'), (0, ide_invocation_surfaces_1.buildClaudeSkillContent)(), 'Claude FRAIM skill (~/.claude/skills/fraim/SKILL.md)');
35
+ installFileIfMissing(path_1.default.join(claudeDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildClaudeCommandShimContent)(), 'Claude compatibility command (~/.claude/commands/fraim.md)');
34
36
  }
35
37
  /**
36
38
  * Install FRAIM invocation artifacts for non-Claude IDEs.
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FRAIM_INVOCATION_BODY = exports.CURSOR_MDC_FRONTMATTER = exports.FRAIM_LAUNCH_PHRASE = void 0;
4
+ exports.buildClaudeSkillContent = buildClaudeSkillContent;
5
+ exports.buildClaudeCommandShimContent = buildClaudeCommandShimContent;
4
6
  exports.buildClaudeSlashCommandContent = buildClaudeSlashCommandContent;
5
7
  exports.buildCursorMentionRuleContent = buildCursorMentionRuleContent;
6
8
  exports.buildCodexSkillContent = buildCodexSkillContent;
@@ -12,7 +14,7 @@ exports.CURSOR_MDC_FRONTMATTER = `---
12
14
  description: FRAIM discovery and execution contract
13
15
  alwaysApply: true
14
16
  ---`;
15
- exports.FRAIM_INVOCATION_BODY = `Follow this process:
17
+ exports.FRAIM_INVOCATION_BODY = `Follow this process:
16
18
 
17
19
  1. **If the user did not specify a FRAIM job or topic**:
18
20
  Call \`list_fraim_jobs()\` to discover available jobs. Present the results grouped by the categories returned by the server. For each group, list 3-5 of the most relevant jobs with a one-line description.
@@ -25,11 +27,23 @@ exports.FRAIM_INVOCATION_BODY = `Follow this process:
25
27
  - For skills, use the content returned by \`get_fraim_file(...)\`.
26
28
 
27
29
  4. **Execute**:
28
- - For jobs, follow the phased instructions and use \`seekMentoring\` when the job requires phase transitions.
29
- - For skills, apply the skill steps directly to the user's current context.
30
+ - For jobs, follow the phased instructions and use \`seekMentoring\` when the job requires phase transitions.
31
+ - For skills, apply the skill steps directly to the user's current context.
30
32
  `;
33
+ function buildClaudeSkillContent() {
34
+ return `# FRAIM
35
+
36
+ ${exports.FRAIM_INVOCATION_BODY}`;
37
+ }
38
+ function buildClaudeCommandShimContent() {
39
+ return `# FRAIM Compatibility Command
40
+
41
+ Use the FRAIM skill when Claude exposes skills directly. This compatibility command keeps \`/fraim\` working on surfaces that still discover legacy command files.
42
+
43
+ ${exports.FRAIM_INVOCATION_BODY}`;
44
+ }
31
45
  function buildClaudeSlashCommandContent() {
32
- return exports.FRAIM_INVOCATION_BODY;
46
+ return buildClaudeCommandShimContent();
33
47
  }
34
48
  function buildCursorMentionRuleContent() {
35
49
  return `${exports.CURSOR_MDC_FRONTMATTER}