brainctl 0.1.7 → 0.1.9

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 (51) hide show
  1. package/README.md +210 -157
  2. package/dist/cli.js +40 -0
  3. package/dist/commands/mcp.js +35 -0
  4. package/dist/commands/profile.js +35 -2
  5. package/dist/mcp/server.js +51 -5
  6. package/dist/services/agent-config-service.d.ts +4 -2
  7. package/dist/services/agent-config-service.js +50 -15
  8. package/dist/services/agent-converter-service.d.ts +21 -0
  9. package/dist/services/agent-converter-service.js +182 -0
  10. package/dist/services/credential-redaction-service.d.ts +13 -0
  11. package/dist/services/credential-redaction-service.js +89 -0
  12. package/dist/services/credential-resolution-service.d.ts +11 -0
  13. package/dist/services/credential-resolution-service.js +69 -0
  14. package/dist/services/mcp-preflight-service.d.ts +3 -2
  15. package/dist/services/mcp-preflight-service.js +159 -5
  16. package/dist/services/plugin-install-service.d.ts +43 -0
  17. package/dist/services/plugin-install-service.js +379 -21
  18. package/dist/services/portable-mcp-classifier.d.ts +12 -0
  19. package/dist/services/portable-mcp-classifier.js +116 -0
  20. package/dist/services/portable-profile-pack-service.d.ts +26 -0
  21. package/dist/services/portable-profile-pack-service.js +264 -0
  22. package/dist/services/profile-export-service.d.ts +15 -3
  23. package/dist/services/profile-export-service.js +10 -57
  24. package/dist/services/profile-import-service.d.ts +9 -1
  25. package/dist/services/profile-import-service.js +265 -10
  26. package/dist/services/profile-service.js +11 -0
  27. package/dist/services/runtime-detector.d.ts +9 -0
  28. package/dist/services/runtime-detector.js +130 -0
  29. package/dist/services/skill-paths.d.ts +2 -0
  30. package/dist/services/skill-paths.js +14 -0
  31. package/dist/services/sync/agent-reader.d.ts +9 -0
  32. package/dist/services/sync/agent-reader.js +177 -35
  33. package/dist/services/sync/claude-writer.js +0 -6
  34. package/dist/services/sync/codex-writer.d.ts +1 -0
  35. package/dist/services/sync/codex-writer.js +21 -8
  36. package/dist/services/sync/gemini-writer.js +5 -7
  37. package/dist/services/sync/plugin-skill-reader.d.ts +5 -0
  38. package/dist/services/sync/plugin-skill-reader.js +142 -1
  39. package/dist/services/sync-service.js +1 -1
  40. package/dist/services/update-check-service.d.ts +33 -0
  41. package/dist/services/update-check-service.js +128 -0
  42. package/dist/types.d.ts +47 -0
  43. package/dist/ui/routes.js +35 -8
  44. package/dist/web/assets/index-Cdb5hbxM.css +1 -0
  45. package/dist/web/assets/index-gN83hZYA.js +65 -0
  46. package/dist/web/favicon-light.svg +13 -0
  47. package/dist/web/favicon.svg +13 -0
  48. package/dist/web/index.html +7 -2
  49. package/package.json +5 -1
  50. package/dist/web/assets/index-BCkorugl.css +0 -1
  51. package/dist/web/assets/index-sGnTMhkX.js +0 -16
@@ -1,8 +1,13 @@
1
- import { cp, mkdir, readFile, rm } from 'node:fs/promises';
1
+ import { spawn } from 'node:child_process';
2
+ import { copyFile, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
2
4
  import path from 'node:path';
3
5
  import { ValidationError } from '../errors.js';
6
+ import { formatTimestamp } from './sync/agent-writer.js';
7
+ import { stripPluginSection } from './sync/codex-writer.js';
4
8
  import { createAgentConfigService } from './agent-config-service.js';
5
- import { getSkillDir } from './skill-paths.js';
9
+ import { claudeAgentMdToCodexToml, claudeCommandMdToCodexSkill, codexAgentTomlToClaudeMd, } from './agent-converter-service.js';
10
+ import { getAgentFilePath, getCommandFilePath, getSkillDir } from './skill-paths.js';
6
11
  import { removeManagedPluginInstall, writeManagedPluginInstall, } from './sync/managed-plugin-registry.js';
7
12
  export function createPluginInstallService(dependencies = {}) {
8
13
  const agentConfigService = createAgentConfigService();
@@ -16,6 +21,8 @@ export function createPluginInstallService(dependencies = {}) {
16
21
  };
17
22
  });
18
23
  const copySkillDirectory = dependencies.copySkillDirectory ?? defaultCopySkillDirectory;
24
+ const installAgent = dependencies.installAgent ?? defaultInstallAgent;
25
+ const installCommand = dependencies.installCommand ?? defaultInstallCommand;
19
26
  const addMcpEntry = dependencies.addMcpEntry ?? (async ({ cwd, agent, key, entry }) => {
20
27
  await agentConfigService.addMcp({ cwd, agent, key, entry });
21
28
  });
@@ -24,9 +31,13 @@ export function createPluginInstallService(dependencies = {}) {
24
31
  await writeManagedPluginInstall({ agent, plugin });
25
32
  });
26
33
  const removeSkillDirectory = dependencies.removeSkillDirectory ?? defaultRemoveSkillDirectory;
34
+ const removeAgentFile = dependencies.removeAgentFile ?? defaultRemoveAgentFile;
35
+ const removeCommandFile = dependencies.removeCommandFile ?? defaultRemoveCommandFile;
27
36
  const removeMcpEntry = dependencies.removeMcpEntry ?? (async ({ cwd, agent, key }) => {
28
37
  await agentConfigService.removeMcp({ cwd, agent, key });
29
38
  });
39
+ const uninstallCodexPlugin = dependencies.uninstallCodexPlugin ?? defaultUninstallCodexPlugin;
40
+ const uninstallClaudePlugin = dependencies.uninstallClaudePlugin ?? defaultUninstallClaudePlugin;
30
41
  const removeRecordedManagedPluginInstall = dependencies.removeManagedPluginInstall ??
31
42
  (async ({ agent, pluginName }) => {
32
43
  await removeManagedPluginInstall({ agent, pluginName });
@@ -40,25 +51,39 @@ export function createPluginInstallService(dependencies = {}) {
40
51
  status: 'error',
41
52
  message: `Plugin "${options.plugin.name}" is missing an install path and cannot be installed as a bundle.`,
42
53
  });
43
- return { ok: false, checks, skills: [], mcps: {} };
54
+ return { ok: false, checks, skills: [], mcps: {}, agents: [], commands: [] };
44
55
  }
45
56
  const bundle = await readInstalledPluginBundle(options.plugin.installPath);
46
57
  const targetState = await readTargetState({
47
58
  cwd: options.cwd,
48
59
  agent: options.targetAgent,
49
60
  });
61
+ const bundleAgents = bundle.agents ?? [];
62
+ const bundleCommands = bundle.commands ?? [];
63
+ const agentsForTarget = bundleAgents.filter(() => isAgentInstallableOnTarget(options.targetAgent));
64
+ const commandsForTarget = bundleCommands.filter(() => isCommandInstallableOnTarget(options.targetAgent));
50
65
  checks.push({
51
66
  label: 'Bundle',
52
67
  status: 'ok',
53
- message: `Discovered ${bundle.skills.length} skills and ${Object.keys(bundle.mcps).length} MCPs in plugin "${options.plugin.name}".`,
68
+ message: `Discovered ${bundle.skills.length} skills, ${Object.keys(bundle.mcps).length} MCPs, ${agentsForTarget.length} agents, and ${commandsForTarget.length} commands in plugin "${options.plugin.name}".`,
54
69
  });
55
- if (bundle.skills.length === 0 && Object.keys(bundle.mcps).length === 0) {
70
+ if (bundle.skills.length === 0 &&
71
+ Object.keys(bundle.mcps).length === 0 &&
72
+ agentsForTarget.length === 0 &&
73
+ commandsForTarget.length === 0) {
56
74
  checks.push({
57
75
  label: 'Bundle',
58
76
  status: 'error',
59
77
  message: `Plugin "${options.plugin.name}" does not expose portable skills or MCPs for installation.`,
60
78
  });
61
79
  }
80
+ const incompatible = await detectIncompatibleArtifacts(options.plugin.installPath);
81
+ for (const warning of formatCompatibilityWarnings(incompatible, {
82
+ sourceAgent: options.sourceAgent,
83
+ targetAgent: options.targetAgent,
84
+ })) {
85
+ checks.push(warning);
86
+ }
62
87
  for (const skillName of bundle.skills) {
63
88
  if (targetState.skills.some((skill) => skill.name === skillName)) {
64
89
  checks.push({
@@ -77,11 +102,45 @@ export function createPluginInstallService(dependencies = {}) {
77
102
  });
78
103
  }
79
104
  }
105
+ for (const agent of agentsForTarget) {
106
+ const targetPath = getAgentFilePath(options.targetAgent, agent.name);
107
+ if (await pathExists(targetPath)) {
108
+ checks.push({
109
+ label: 'Target agent',
110
+ status: 'error',
111
+ message: `Agent "${agent.name}" already exists in ${options.targetAgent}.`,
112
+ });
113
+ }
114
+ }
115
+ for (const command of commandsForTarget) {
116
+ if (options.targetAgent === 'claude') {
117
+ const targetPath = getCommandFilePath('claude', command.name);
118
+ if (await pathExists(targetPath)) {
119
+ checks.push({
120
+ label: 'Target command',
121
+ status: 'error',
122
+ message: `Command "${command.name}" already exists in claude.`,
123
+ });
124
+ }
125
+ }
126
+ else if (options.targetAgent === 'codex') {
127
+ const skillDir = getSkillDir('codex', command.name);
128
+ if (await pathExists(skillDir) || targetState.skills.some((s) => s.name === command.name)) {
129
+ checks.push({
130
+ label: 'Target command',
131
+ status: 'error',
132
+ message: `Command "${command.name}" already exists as a skill in codex.`,
133
+ });
134
+ }
135
+ }
136
+ }
80
137
  return {
81
138
  ok: checks.every((check) => check.status !== 'error'),
82
139
  checks,
83
140
  skills: bundle.skills,
84
141
  mcps: bundle.mcps,
142
+ agents: agentsForTarget.map((a) => a.name),
143
+ commands: commandsForTarget.map((c) => c.name),
85
144
  };
86
145
  },
87
146
  async execute(options) {
@@ -91,6 +150,7 @@ export function createPluginInstallService(dependencies = {}) {
91
150
  throw new ValidationError(firstError?.message ?? 'Plugin install plan failed.');
92
151
  }
93
152
  const installPath = options.plugin.installPath;
153
+ const bundle = await readInstalledPluginBundle(installPath);
94
154
  for (const skillName of plan.skills) {
95
155
  await copySkillDirectory({
96
156
  sourceInstallPath: installPath,
@@ -106,6 +166,18 @@ export function createPluginInstallService(dependencies = {}) {
106
166
  entry,
107
167
  });
108
168
  }
169
+ for (const agentName of plan.agents) {
170
+ const agent = (bundle.agents ?? []).find((a) => a.name === agentName);
171
+ if (!agent)
172
+ continue;
173
+ await installAgent({ targetAgent: options.targetAgent, agent });
174
+ }
175
+ for (const commandName of plan.commands) {
176
+ const command = (bundle.commands ?? []).find((c) => c.name === commandName);
177
+ if (!command)
178
+ continue;
179
+ await installCommand({ targetAgent: options.targetAgent, command });
180
+ }
109
181
  await recordManagedPluginInstall({
110
182
  agent: options.targetAgent,
111
183
  plugin: {
@@ -113,12 +185,16 @@ export function createPluginInstallService(dependencies = {}) {
113
185
  kind: 'plugin',
114
186
  pluginSkills: plan.skills,
115
187
  pluginMcps: Object.keys(plan.mcps),
188
+ pluginAgents: plan.agents,
189
+ pluginCommands: plan.commands,
116
190
  managed: true,
117
191
  },
118
192
  });
119
193
  return {
120
194
  installedSkills: plan.skills,
121
195
  installedMcps: Object.keys(plan.mcps),
196
+ installedAgents: plan.agents,
197
+ installedCommands: plan.commands,
122
198
  };
123
199
  },
124
200
  async planRemoval(options) {
@@ -129,37 +205,36 @@ export function createPluginInstallService(dependencies = {}) {
129
205
  status: 'error',
130
206
  message: `"${options.plugin.name}" is not a plugin entry.`,
131
207
  });
132
- return { ok: false, checks, skills: [], mcps: [] };
208
+ return { ok: false, checks, skills: [], mcps: [], agents: [], commands: [] };
133
209
  }
134
- if (!options.plugin.managed) {
210
+ const unmanagedCodex = isUnmanagedCodexPlugin(options.targetAgent, options.plugin);
211
+ const unmanagedClaude = isUnmanagedClaudePlugin(options.targetAgent, options.plugin);
212
+ if (!options.plugin.managed && !unmanagedCodex && !unmanagedClaude) {
135
213
  checks.push({
136
214
  label: 'Target plugin',
137
215
  status: 'error',
138
216
  message: `Only Brainctl-managed plugin installs can be removed today. "${options.plugin.name}" is not managed by Brainctl on ${options.targetAgent}.`,
139
217
  });
140
- return { ok: false, checks, skills: [], mcps: [] };
141
- }
142
- let skills = [...(options.plugin.pluginSkills ?? [])];
143
- let mcps = [...(options.plugin.pluginMcps ?? [])];
144
- if ((skills.length === 0 || mcps.length === 0) && options.plugin.installPath) {
145
- const bundle = await readInstalledPluginBundle(options.plugin.installPath);
146
- if (skills.length === 0) {
147
- skills = bundle.skills;
148
- }
149
- if (mcps.length === 0) {
150
- mcps = Object.keys(bundle.mcps);
151
- }
218
+ return { ok: false, checks, skills: [], mcps: [], agents: [], commands: [] };
152
219
  }
220
+ const skills = [...(options.plugin.pluginSkills ?? [])];
221
+ const mcps = [...(options.plugin.pluginMcps ?? [])];
222
+ const agents = [...(options.plugin.pluginAgents ?? [])];
223
+ const commands = [...(options.plugin.pluginCommands ?? [])];
153
224
  checks.push({
154
225
  label: 'Bundle',
155
226
  status: 'ok',
156
- message: `Will remove ${skills.length} skills and ${mcps.length} MCPs from plugin "${options.plugin.name}".`,
227
+ message: unmanagedCodex || unmanagedClaude
228
+ ? `Will uninstall ${options.targetAgent} plugin "${options.plugin.name}" (${skills.length} skills, ${mcps.length} MCPs, ${agents.length} agents, ${commands.length} commands) and remove its cache directory.`
229
+ : `Will remove ${skills.length} skills, ${mcps.length} MCPs, ${agents.length} agents, and ${commands.length} commands from plugin "${options.plugin.name}".`,
157
230
  });
158
231
  return {
159
232
  ok: true,
160
233
  checks,
161
234
  skills,
162
235
  mcps,
236
+ agents,
237
+ commands,
163
238
  };
164
239
  },
165
240
  async remove(options) {
@@ -168,6 +243,32 @@ export function createPluginInstallService(dependencies = {}) {
168
243
  const firstError = plan.checks.find((check) => check.status === 'error');
169
244
  throw new ValidationError(firstError?.message ?? 'Plugin removal plan failed.');
170
245
  }
246
+ if (isUnmanagedCodexPlugin(options.targetAgent, options.plugin)) {
247
+ const pluginKey = `${options.plugin.name}@${options.plugin.source}`;
248
+ await uninstallCodexPlugin({
249
+ pluginKey,
250
+ installPath: options.plugin.installPath,
251
+ });
252
+ return {
253
+ removedSkills: plan.skills,
254
+ removedMcps: plan.mcps,
255
+ removedAgents: plan.agents,
256
+ removedCommands: plan.commands,
257
+ };
258
+ }
259
+ if (isUnmanagedClaudePlugin(options.targetAgent, options.plugin)) {
260
+ const pluginKey = `${options.plugin.name}@${options.plugin.source}`;
261
+ await uninstallClaudePlugin({
262
+ pluginKey,
263
+ installPath: options.plugin.installPath,
264
+ });
265
+ return {
266
+ removedSkills: plan.skills,
267
+ removedMcps: plan.mcps,
268
+ removedAgents: plan.agents,
269
+ removedCommands: plan.commands,
270
+ };
271
+ }
171
272
  for (const skillName of plan.skills) {
172
273
  await removeSkillDirectory({
173
274
  targetAgent: options.targetAgent,
@@ -181,6 +282,12 @@ export function createPluginInstallService(dependencies = {}) {
181
282
  key,
182
283
  });
183
284
  }
285
+ for (const agentName of plan.agents) {
286
+ await removeAgentFile({ targetAgent: options.targetAgent, agentName });
287
+ }
288
+ for (const commandName of plan.commands) {
289
+ await removeCommandFile({ targetAgent: options.targetAgent, commandName });
290
+ }
184
291
  await removeRecordedManagedPluginInstall({
185
292
  agent: options.targetAgent,
186
293
  pluginName: options.plugin.name,
@@ -188,10 +295,81 @@ export function createPluginInstallService(dependencies = {}) {
188
295
  return {
189
296
  removedSkills: plan.skills,
190
297
  removedMcps: plan.mcps,
298
+ removedAgents: plan.agents,
299
+ removedCommands: plan.commands,
191
300
  };
192
301
  },
193
302
  };
194
303
  }
304
+ function isUnmanagedCodexPlugin(targetAgent, plugin) {
305
+ return (!plugin.managed &&
306
+ targetAgent === 'codex' &&
307
+ typeof plugin.installPath === 'string' &&
308
+ typeof plugin.source === 'string' &&
309
+ plugin.source.length > 0);
310
+ }
311
+ function isUnmanagedClaudePlugin(targetAgent, plugin) {
312
+ return (!plugin.managed &&
313
+ targetAgent === 'claude' &&
314
+ typeof plugin.installPath === 'string' &&
315
+ typeof plugin.source === 'string' &&
316
+ plugin.source.length > 0);
317
+ }
318
+ async function defaultUninstallClaudePlugin(options) {
319
+ // Delegate to `claude plugin uninstall` so a running Claude Code session
320
+ // drops the plugin from its in-memory state (direct fs mutation gets
321
+ // resurrected by live sessions that still have the plugin loaded).
322
+ await new Promise((resolve, reject) => {
323
+ const child = spawn('claude', ['plugin', 'uninstall', options.pluginKey, '--scope', 'user'], { stdio: ['ignore', 'pipe', 'pipe'] });
324
+ let stderr = '';
325
+ let stdout = '';
326
+ child.stdout?.on('data', (chunk) => {
327
+ stdout += chunk.toString();
328
+ });
329
+ child.stderr?.on('data', (chunk) => {
330
+ stderr += chunk.toString();
331
+ });
332
+ child.on('error', (error) => {
333
+ reject(new ValidationError(`Failed to invoke \`claude\` CLI: ${error.message}. Is Claude Code installed on PATH?`));
334
+ });
335
+ child.on('exit', (code) => {
336
+ if (code === 0) {
337
+ resolve();
338
+ return;
339
+ }
340
+ const detail = (stderr || stdout).trim();
341
+ reject(new ValidationError(`\`claude plugin uninstall ${options.pluginKey}\` exited ${code}${detail ? `: ${detail}` : ''}`));
342
+ });
343
+ });
344
+ }
345
+ async function defaultUninstallCodexPlugin(options) {
346
+ const home = homedir();
347
+ const cacheRoot = path.join(home, '.codex', 'plugins', 'cache');
348
+ const resolvedInstall = path.resolve(options.installPath);
349
+ if (!resolvedInstall.startsWith(cacheRoot + path.sep)) {
350
+ throw new ValidationError(`Refusing to remove Codex plugin files outside ${cacheRoot}: ${resolvedInstall}`);
351
+ }
352
+ const pluginRoot = path.dirname(resolvedInstall);
353
+ const configPath = path.join(home, '.codex', 'config.toml');
354
+ let existing = '';
355
+ try {
356
+ existing = await readFile(configPath, 'utf8');
357
+ }
358
+ catch {
359
+ existing = '';
360
+ }
361
+ if (existing.length > 0) {
362
+ const next = stripPluginSection(existing, options.pluginKey);
363
+ if (next !== existing) {
364
+ const backupPath = `${configPath}.bak.${formatTimestamp()}`;
365
+ await copyFile(configPath, backupPath);
366
+ const tmpPath = `${configPath}.tmp.${Date.now()}`;
367
+ await writeFile(tmpPath, next, 'utf8');
368
+ await rename(tmpPath, configPath);
369
+ }
370
+ }
371
+ await rm(pluginRoot, { recursive: true, force: true });
372
+ }
195
373
  async function defaultReadInstalledPluginBundle(installPath) {
196
374
  const skillsDir = path.join(installPath, 'skills');
197
375
  let skills = [];
@@ -229,7 +407,98 @@ async function defaultReadInstalledPluginBundle(installPath) {
229
407
  catch {
230
408
  mcps = {};
231
409
  }
232
- return { skills, mcps };
410
+ const agents = [];
411
+ try {
412
+ const entries = await readdir(path.join(installPath, 'agents'), { withFileTypes: true });
413
+ for (const entry of entries) {
414
+ if (!entry.isFile())
415
+ continue;
416
+ if (entry.name.endsWith('.md')) {
417
+ const content = await readFile(path.join(installPath, 'agents', entry.name), 'utf8');
418
+ agents.push({ name: entry.name.replace(/\.md$/, ''), sourceFormat: 'claude-md', content });
419
+ }
420
+ else if (entry.name.endsWith('.toml')) {
421
+ const content = await readFile(path.join(installPath, 'agents', entry.name), 'utf8');
422
+ agents.push({ name: entry.name.replace(/\.toml$/, ''), sourceFormat: 'codex-toml', content });
423
+ }
424
+ }
425
+ agents.sort((left, right) => left.name.localeCompare(right.name));
426
+ }
427
+ catch {
428
+ // no agents dir
429
+ }
430
+ const commands = [];
431
+ try {
432
+ const entries = await readdir(path.join(installPath, 'commands'), { withFileTypes: true });
433
+ for (const entry of entries) {
434
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
435
+ continue;
436
+ const content = await readFile(path.join(installPath, 'commands', entry.name), 'utf8');
437
+ commands.push({ name: entry.name.replace(/\.md$/, ''), content });
438
+ }
439
+ commands.sort((left, right) => left.name.localeCompare(right.name));
440
+ }
441
+ catch {
442
+ // no commands dir
443
+ }
444
+ return { skills, mcps, agents, commands };
445
+ }
446
+ function isAgentInstallableOnTarget(target) {
447
+ return target === 'claude' || target === 'codex';
448
+ }
449
+ function isCommandInstallableOnTarget(target) {
450
+ return target === 'claude' || target === 'codex';
451
+ }
452
+ async function defaultInstallAgent(options) {
453
+ const targetPath = getAgentFilePath(options.targetAgent, options.agent.name);
454
+ await mkdir(path.dirname(targetPath), { recursive: true });
455
+ let output;
456
+ if (options.targetAgent === 'claude') {
457
+ output = options.agent.sourceFormat === 'claude-md'
458
+ ? options.agent.content
459
+ : codexAgentTomlToClaudeMd(options.agent.content);
460
+ }
461
+ else if (options.targetAgent === 'codex') {
462
+ output = options.agent.sourceFormat === 'codex-toml'
463
+ ? options.agent.content
464
+ : claudeAgentMdToCodexToml(options.agent.content);
465
+ }
466
+ else {
467
+ throw new Error(`Agent install is not supported for ${options.targetAgent}`);
468
+ }
469
+ await writeFile(targetPath, output, 'utf8');
470
+ }
471
+ async function defaultInstallCommand(options) {
472
+ if (options.targetAgent === 'claude') {
473
+ const targetPath = getCommandFilePath('claude', options.command.name);
474
+ await mkdir(path.dirname(targetPath), { recursive: true });
475
+ await writeFile(targetPath, options.command.content, 'utf8');
476
+ return;
477
+ }
478
+ if (options.targetAgent === 'codex') {
479
+ const skillDir = getSkillDir('codex', options.command.name);
480
+ await mkdir(skillDir, { recursive: true });
481
+ const { skillMarkdown } = claudeCommandMdToCodexSkill(options.command.content);
482
+ await writeFile(path.join(skillDir, 'SKILL.md'), skillMarkdown, 'utf8');
483
+ return;
484
+ }
485
+ throw new Error(`Command install is not supported for ${options.targetAgent}`);
486
+ }
487
+ async function defaultRemoveAgentFile(options) {
488
+ const targetPath = getAgentFilePath(options.targetAgent, options.agentName);
489
+ await rm(targetPath, { force: true });
490
+ }
491
+ async function defaultRemoveCommandFile(options) {
492
+ if (options.targetAgent === 'claude') {
493
+ const targetPath = getCommandFilePath('claude', options.commandName);
494
+ await rm(targetPath, { force: true });
495
+ return;
496
+ }
497
+ if (options.targetAgent === 'codex') {
498
+ const skillDir = getSkillDir('codex', options.commandName);
499
+ await rm(skillDir, { recursive: true, force: true });
500
+ return;
501
+ }
233
502
  }
234
503
  async function defaultCopySkillDirectory(options) {
235
504
  const sourceDir = path.join(options.sourceInstallPath, 'skills', options.skillName);
@@ -241,3 +510,92 @@ async function defaultRemoveSkillDirectory(options) {
241
510
  const targetDir = getSkillDir(options.targetAgent, options.skillName);
242
511
  await rm(targetDir, { recursive: true, force: true });
243
512
  }
513
+ async function detectIncompatibleArtifacts(installPath) {
514
+ const [hasAppConnector, hasHooks, hasCommands, codexAgentSkills, claudeAgents] = await Promise.all([
515
+ pathExists(path.join(installPath, '.app.json')),
516
+ pathExists(path.join(installPath, 'hooks')),
517
+ pathExists(path.join(installPath, 'commands')),
518
+ listCodexAgentSkills(installPath),
519
+ listClaudeAgentFiles(installPath),
520
+ ]);
521
+ return { hasAppConnector, hasHooks, hasCommands, codexAgentSkills, claudeAgents };
522
+ }
523
+ async function pathExists(target) {
524
+ try {
525
+ await stat(target);
526
+ return true;
527
+ }
528
+ catch {
529
+ return false;
530
+ }
531
+ }
532
+ async function listCodexAgentSkills(installPath) {
533
+ const skillsDir = path.join(installPath, 'skills');
534
+ try {
535
+ const entries = await readdir(skillsDir, { withFileTypes: true });
536
+ const matches = [];
537
+ for (const entry of entries) {
538
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
539
+ continue;
540
+ if (await pathExists(path.join(skillsDir, entry.name, 'agents'))) {
541
+ matches.push(entry.name);
542
+ }
543
+ }
544
+ return matches.sort((left, right) => left.localeCompare(right));
545
+ }
546
+ catch {
547
+ return [];
548
+ }
549
+ }
550
+ async function listClaudeAgentFiles(installPath) {
551
+ const agentsDir = path.join(installPath, 'agents');
552
+ try {
553
+ const entries = await readdir(agentsDir, { withFileTypes: true });
554
+ return entries
555
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
556
+ .map((entry) => entry.name.replace(/\.md$/, ''))
557
+ .sort((left, right) => left.localeCompare(right));
558
+ }
559
+ catch {
560
+ return [];
561
+ }
562
+ }
563
+ function formatCompatibilityWarnings(artifacts, context) {
564
+ const warnings = [];
565
+ if (artifacts.hasAppConnector && context.targetAgent !== 'codex') {
566
+ warnings.push({
567
+ label: 'App connector',
568
+ status: 'warn',
569
+ message: `Plugin ships a Codex app connector (.app.json) that will NOT transfer. Skill instructions will copy over but the backing integration will not work on ${context.targetAgent}.`,
570
+ });
571
+ }
572
+ if (artifacts.codexAgentSkills.length > 0 && context.targetAgent !== 'codex') {
573
+ warnings.push({
574
+ label: 'Codex agent YAML',
575
+ status: 'warn',
576
+ message: `Skills ${artifacts.codexAgentSkills.join(', ')} include Codex-specific agent YAML that will not transfer to ${context.targetAgent}.`,
577
+ });
578
+ }
579
+ if (artifacts.hasHooks && context.targetAgent !== 'claude') {
580
+ warnings.push({
581
+ label: 'Claude hooks',
582
+ status: 'warn',
583
+ message: `Plugin ships session hooks that only work on Claude and will NOT transfer to ${context.targetAgent}.`,
584
+ });
585
+ }
586
+ if (context.targetAgent === 'gemini' && artifacts.claudeAgents.length > 0) {
587
+ warnings.push({
588
+ label: 'Subagents',
589
+ status: 'warn',
590
+ message: `Plugin ships subagent definitions (${artifacts.claudeAgents.join(', ')}) that cannot be converted to ${context.targetAgent}.`,
591
+ });
592
+ }
593
+ if (context.targetAgent === 'gemini' && artifacts.hasCommands) {
594
+ warnings.push({
595
+ label: 'Slash commands',
596
+ status: 'warn',
597
+ message: `Plugin ships slash commands that cannot be converted to ${context.targetAgent}.`,
598
+ });
599
+ }
600
+ return warnings;
601
+ }
@@ -0,0 +1,12 @@
1
+ import { ValidationError } from '../errors.js';
2
+ import type { LocalBundledMcpServerConfig, LocalNpmMcpServerConfig, RemoteMcpServerConfig } from '../types.js';
3
+ import type { AgentMcpEntry, PortableRemoteMcpMetadata } from './agent-config-service.js';
4
+ export type PortableMcpClassification = LocalNpmMcpServerConfig | LocalBundledMcpServerConfig | RemoteMcpServerConfig;
5
+ export declare class PortableMcpClassificationError extends ValidationError {
6
+ }
7
+ export declare function classifyPortableMcp(options: {
8
+ cwd: string;
9
+ key: string;
10
+ entry: AgentMcpEntry;
11
+ remote?: PortableRemoteMcpMetadata;
12
+ }): PortableMcpClassification;
@@ -0,0 +1,116 @@
1
+ import path from 'node:path';
2
+ import { ValidationError } from '../errors.js';
3
+ import { detectMcpRuntime, extractEntrypoint } from './runtime-detector.js';
4
+ const NPX_LIKE_COMMANDS = new Set(['npx', 'uvx']);
5
+ export class PortableMcpClassificationError extends ValidationError {
6
+ }
7
+ export function classifyPortableMcp(options) {
8
+ if (options.remote) {
9
+ return classifyRemoteMcp(options.key, options.remote);
10
+ }
11
+ const packageName = resolveNpxPackage(options.entry);
12
+ if (packageName) {
13
+ return {
14
+ kind: 'local',
15
+ source: 'npm',
16
+ package: packageName,
17
+ ...(options.entry.env ? { env: options.entry.env } : {}),
18
+ };
19
+ }
20
+ const runtime = detectMcpRuntime(options.entry.command);
21
+ if (runtime) {
22
+ return classifyBundledMcp(options.cwd, options.key, options.entry, runtime);
23
+ }
24
+ throw new PortableMcpClassificationError(`MCP "${options.key}" cannot be packed: unrecognized command "${options.entry.command}".`);
25
+ }
26
+ function classifyBundledMcp(cwd, key, entry, runtime) {
27
+ const entrypoint = extractEntrypoint(entry.command, entry.args ?? []);
28
+ let bundlePath;
29
+ if (runtime === 'rust') {
30
+ bundlePath = cwd;
31
+ }
32
+ else if (entrypoint) {
33
+ const resolvedEntrypoint = path.resolve(cwd, entrypoint);
34
+ const entrypointDir = path.dirname(resolvedEntrypoint);
35
+ bundlePath = resolveProjectLocalPath(cwd, entrypointDir, key);
36
+ }
37
+ else {
38
+ throw new PortableMcpClassificationError(`MCP "${key}" cannot be packed: could not determine entrypoint from args.`);
39
+ }
40
+ return {
41
+ kind: 'local',
42
+ source: 'bundled',
43
+ runtime,
44
+ path: bundlePath,
45
+ command: entry.command,
46
+ ...(entry.args ? { args: entry.args } : {}),
47
+ ...(entry.env ? { env: entry.env } : {}),
48
+ };
49
+ }
50
+ function classifyRemoteMcp(key, remote) {
51
+ let parsedUrl;
52
+ try {
53
+ parsedUrl = new URL(remote.url);
54
+ }
55
+ catch {
56
+ throw new PortableMcpClassificationError(`Remote MCP "${key}" must include an absolute http(s) url.`);
57
+ }
58
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
59
+ throw new PortableMcpClassificationError(`Remote MCP "${key}" must include an absolute http(s) url.`);
60
+ }
61
+ if (remote.transport !== 'http' && remote.transport !== 'sse') {
62
+ throw new PortableMcpClassificationError(`Remote MCP "${key}" must use transport "http" or "sse".`);
63
+ }
64
+ return {
65
+ kind: 'remote',
66
+ transport: remote.transport,
67
+ url: remote.url,
68
+ ...(remote.headers ? { headers: remote.headers } : {}),
69
+ ...(remote.env ? { env: remote.env } : {}),
70
+ };
71
+ }
72
+ function resolveNpxPackage(entry) {
73
+ if (!NPX_LIKE_COMMANDS.has(entry.command)) {
74
+ return null;
75
+ }
76
+ const packageName = resolveDeclaredNpxPackage(entry.args ?? []);
77
+ if (!packageName) {
78
+ throw new PortableMcpClassificationError('npx/uvx-based MCP entries must include a package or executable argument.');
79
+ }
80
+ return packageName;
81
+ }
82
+ function resolveDeclaredNpxPackage(args) {
83
+ let packageName = null;
84
+ for (let index = 0; index < args.length; index += 1) {
85
+ const arg = args[index];
86
+ if (arg === '--package') {
87
+ const nextArg = args[index + 1];
88
+ if (nextArg && !nextArg.startsWith('-')) {
89
+ return nextArg;
90
+ }
91
+ continue;
92
+ }
93
+ if (arg.startsWith('--package=')) {
94
+ const declaredPackage = arg.slice('--package='.length).trim();
95
+ if (declaredPackage.length > 0) {
96
+ return declaredPackage;
97
+ }
98
+ continue;
99
+ }
100
+ if (arg.startsWith('-')) {
101
+ continue;
102
+ }
103
+ if (!packageName) {
104
+ packageName = arg;
105
+ }
106
+ }
107
+ return packageName;
108
+ }
109
+ function resolveProjectLocalPath(cwd, candidate, key) {
110
+ const resolved = path.resolve(cwd, candidate);
111
+ const relative = path.relative(cwd, resolved);
112
+ if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) {
113
+ throw new PortableMcpClassificationError(`MCP "${key}" cannot be packed: path "${candidate}" is outside the project directory.`);
114
+ }
115
+ return resolved;
116
+ }