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.
- package/README.md +210 -157
- package/dist/cli.js +40 -0
- package/dist/commands/mcp.js +35 -0
- package/dist/commands/profile.js +35 -2
- package/dist/mcp/server.js +51 -5
- package/dist/services/agent-config-service.d.ts +4 -2
- package/dist/services/agent-config-service.js +50 -15
- package/dist/services/agent-converter-service.d.ts +21 -0
- package/dist/services/agent-converter-service.js +182 -0
- package/dist/services/credential-redaction-service.d.ts +13 -0
- package/dist/services/credential-redaction-service.js +89 -0
- package/dist/services/credential-resolution-service.d.ts +11 -0
- package/dist/services/credential-resolution-service.js +69 -0
- package/dist/services/mcp-preflight-service.d.ts +3 -2
- package/dist/services/mcp-preflight-service.js +159 -5
- package/dist/services/plugin-install-service.d.ts +43 -0
- package/dist/services/plugin-install-service.js +379 -21
- package/dist/services/portable-mcp-classifier.d.ts +12 -0
- package/dist/services/portable-mcp-classifier.js +116 -0
- package/dist/services/portable-profile-pack-service.d.ts +26 -0
- package/dist/services/portable-profile-pack-service.js +264 -0
- package/dist/services/profile-export-service.d.ts +15 -3
- package/dist/services/profile-export-service.js +10 -57
- package/dist/services/profile-import-service.d.ts +9 -1
- package/dist/services/profile-import-service.js +265 -10
- package/dist/services/profile-service.js +11 -0
- package/dist/services/runtime-detector.d.ts +9 -0
- package/dist/services/runtime-detector.js +130 -0
- package/dist/services/skill-paths.d.ts +2 -0
- package/dist/services/skill-paths.js +14 -0
- package/dist/services/sync/agent-reader.d.ts +9 -0
- package/dist/services/sync/agent-reader.js +177 -35
- package/dist/services/sync/claude-writer.js +0 -6
- package/dist/services/sync/codex-writer.d.ts +1 -0
- package/dist/services/sync/codex-writer.js +21 -8
- package/dist/services/sync/gemini-writer.js +5 -7
- package/dist/services/sync/plugin-skill-reader.d.ts +5 -0
- package/dist/services/sync/plugin-skill-reader.js +142 -1
- package/dist/services/sync-service.js +1 -1
- package/dist/services/update-check-service.d.ts +33 -0
- package/dist/services/update-check-service.js +128 -0
- package/dist/types.d.ts +47 -0
- package/dist/ui/routes.js +35 -8
- package/dist/web/assets/index-Cdb5hbxM.css +1 -0
- package/dist/web/assets/index-gN83hZYA.js +65 -0
- package/dist/web/favicon-light.svg +13 -0
- package/dist/web/favicon.svg +13 -0
- package/dist/web/index.html +7 -2
- package/package.json +5 -1
- package/dist/web/assets/index-BCkorugl.css +0 -1
- package/dist/web/assets/index-sGnTMhkX.js +0 -16
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|