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,12 +1,16 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
2
|
+
import { copyFile, cp, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import YAML from 'yaml';
|
|
6
6
|
import { ProfileError } from '../errors.js';
|
|
7
|
+
import { formatTimestamp } from './sync/agent-writer.js';
|
|
8
|
+
import { resolvePortableMcpCredentials } from './credential-resolution-service.js';
|
|
9
|
+
import { createMcpPreflightService } from './mcp-preflight-service.js';
|
|
7
10
|
import { parseProfile } from './profile-service.js';
|
|
8
11
|
const PROFILES_DIR = '.brainctl/profiles';
|
|
9
|
-
export function createProfileImportService() {
|
|
12
|
+
export function createProfileImportService(deps = {}) {
|
|
13
|
+
const mcpPreflightService = deps.mcpPreflightService ?? createMcpPreflightService();
|
|
10
14
|
return {
|
|
11
15
|
async execute(options) {
|
|
12
16
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -22,9 +26,13 @@ export function createProfileImportService() {
|
|
|
22
26
|
execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, {
|
|
23
27
|
stdio: 'pipe',
|
|
24
28
|
});
|
|
29
|
+
const manifest = await readPortableManifest(extractDir);
|
|
25
30
|
const profileSource = await readFile(path.join(extractDir, 'profile.yaml'), 'utf8');
|
|
26
31
|
const profile = parseProfile(profileSource, 'imported');
|
|
27
32
|
const profileName = profile.name;
|
|
33
|
+
if (manifest.profileName !== profileName) {
|
|
34
|
+
throw new ProfileError(`Portable profile manifest name "${manifest.profileName}" does not match profile name "${profileName}".`);
|
|
35
|
+
}
|
|
28
36
|
const profilePath = path.join(cwd, PROFILES_DIR, `${profileName}.yaml`);
|
|
29
37
|
if (!options.force) {
|
|
30
38
|
try {
|
|
@@ -36,12 +44,27 @@ export function createProfileImportService() {
|
|
|
36
44
|
throw err;
|
|
37
45
|
}
|
|
38
46
|
}
|
|
47
|
+
const missingCredentials = new Map();
|
|
48
|
+
for (const [name, mcp] of Object.entries(profile.mcps)) {
|
|
49
|
+
const resolution = resolvePortableMcpCredentials(mcp, {
|
|
50
|
+
credentials: options.credentials,
|
|
51
|
+
credentialSpecs: manifest.credentials,
|
|
52
|
+
environment: process.env,
|
|
53
|
+
});
|
|
54
|
+
profile.mcps[name] = resolution.resolved;
|
|
55
|
+
for (const credential of resolution.missing) {
|
|
56
|
+
missingCredentials.set(credential.key, credential.description ?? credential.key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (missingCredentials.size > 0) {
|
|
60
|
+
throw new ProfileError(`Missing required credentials: ${Array.from(missingCredentials.keys()).join(', ')}.`);
|
|
61
|
+
}
|
|
39
62
|
const installedMcps = [];
|
|
40
63
|
const mcpsBaseDir = path.join(cwd, PROFILES_DIR, profileName, 'mcps');
|
|
41
64
|
for (const [name, mcp] of Object.entries(profile.mcps)) {
|
|
42
65
|
if (!(mcp.kind === 'local' && mcp.source === 'bundled'))
|
|
43
66
|
continue;
|
|
44
|
-
const extractedMcpPath =
|
|
67
|
+
const extractedMcpPath = resolveBundledArchivePath(extractDir, mcp.path);
|
|
45
68
|
const destMcpPath = path.join(mcpsBaseDir, name);
|
|
46
69
|
try {
|
|
47
70
|
await stat(extractedMcpPath);
|
|
@@ -49,19 +72,44 @@ export function createProfileImportService() {
|
|
|
49
72
|
catch {
|
|
50
73
|
throw new ProfileError(`Bundled MCP "${name}" source not found in archive.`);
|
|
51
74
|
}
|
|
75
|
+
await rm(destMcpPath, { recursive: true, force: true });
|
|
52
76
|
await mkdir(destMcpPath, { recursive: true });
|
|
53
77
|
await cp(extractedMcpPath, destMcpPath, { recursive: true });
|
|
54
|
-
const installCmd = mcp.install
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
const installCmd = mcp.install;
|
|
79
|
+
if (!installCmd) {
|
|
80
|
+
profile.mcps[name] = {
|
|
81
|
+
...mcp,
|
|
82
|
+
path: destMcpPath,
|
|
83
|
+
};
|
|
84
|
+
installedMcps.push(name);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
execSync(installCmd, {
|
|
89
|
+
cwd: destMcpPath,
|
|
90
|
+
stdio: 'pipe',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new ProfileError(`Bundled MCP "${name}" install failed: ${formatExecError(error)}`);
|
|
95
|
+
}
|
|
59
96
|
profile.mcps[name] = {
|
|
60
97
|
...mcp,
|
|
61
98
|
path: destMcpPath,
|
|
62
99
|
};
|
|
63
100
|
installedMcps.push(name);
|
|
64
101
|
}
|
|
102
|
+
await validateImportedMcps(profile, cwd, mcpPreflightService);
|
|
103
|
+
const installedPlugins = [];
|
|
104
|
+
for (const plugin of manifest.plugins ?? []) {
|
|
105
|
+
await restorePlugin(extractDir, plugin);
|
|
106
|
+
installedPlugins.push(`${plugin.agent}:${plugin.name}`);
|
|
107
|
+
}
|
|
108
|
+
const installedUserSkills = [];
|
|
109
|
+
for (const skill of manifest.userSkills ?? []) {
|
|
110
|
+
await restoreUserSkill(extractDir, skill);
|
|
111
|
+
installedUserSkills.push(`${skill.agent}:${skill.name}`);
|
|
112
|
+
}
|
|
65
113
|
const outputYaml = {
|
|
66
114
|
name: profile.name,
|
|
67
115
|
...(profile.description ? { description: profile.description } : {}),
|
|
@@ -71,7 +119,7 @@ export function createProfileImportService() {
|
|
|
71
119
|
};
|
|
72
120
|
await mkdir(path.dirname(profilePath), { recursive: true });
|
|
73
121
|
await writeFile(profilePath, YAML.stringify(outputYaml), 'utf8');
|
|
74
|
-
return { profileName, installedMcps };
|
|
122
|
+
return { profileName, installedMcps, installedPlugins, installedUserSkills };
|
|
75
123
|
}
|
|
76
124
|
finally {
|
|
77
125
|
await rm(extractDir, { recursive: true, force: true });
|
|
@@ -79,3 +127,210 @@ export function createProfileImportService() {
|
|
|
79
127
|
},
|
|
80
128
|
};
|
|
81
129
|
}
|
|
130
|
+
async function validateImportedMcps(profile, cwd, mcpPreflightService) {
|
|
131
|
+
for (const [name, mcp] of Object.entries(profile.mcps)) {
|
|
132
|
+
if (mcp.kind === 'remote') {
|
|
133
|
+
validateRemoteMcp(name, mcp);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const validation = await mcpPreflightService.execute({
|
|
137
|
+
cwd: mcp.source === 'bundled' ? mcp.path : cwd,
|
|
138
|
+
agent: 'claude',
|
|
139
|
+
key: name,
|
|
140
|
+
entry: toAgentMcpEntry(mcp),
|
|
141
|
+
});
|
|
142
|
+
const firstError = validation.checks.find((check) => check.status === 'error');
|
|
143
|
+
if (firstError) {
|
|
144
|
+
throw new ProfileError(`Imported MCP "${name}" failed validation: ${firstError.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function toAgentMcpEntry(mcp) {
|
|
149
|
+
if (mcp.source === 'npm') {
|
|
150
|
+
return {
|
|
151
|
+
command: 'npx',
|
|
152
|
+
args: ['-y', mcp.package],
|
|
153
|
+
...(mcp.env ? { env: mcp.env } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
command: mcp.command,
|
|
158
|
+
...(mcp.args ? { args: mcp.args } : {}),
|
|
159
|
+
...(mcp.env ? { env: mcp.env } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function validateRemoteMcp(name, mcp) {
|
|
163
|
+
let parsedUrl;
|
|
164
|
+
try {
|
|
165
|
+
parsedUrl = new URL(mcp.url);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
throw new ProfileError(`Remote MCP "${name}" must include an absolute http(s) url.`);
|
|
169
|
+
}
|
|
170
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
171
|
+
throw new ProfileError(`Remote MCP "${name}" must include an absolute http(s) url.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function formatExecError(error) {
|
|
175
|
+
if (error && typeof error === 'object') {
|
|
176
|
+
const stderr = 'stderr' in error && typeof error.stderr === 'string'
|
|
177
|
+
? error.stderr.trim()
|
|
178
|
+
: 'stderr' in error && Buffer.isBuffer(error.stderr)
|
|
179
|
+
? error.stderr.toString('utf8').trim()
|
|
180
|
+
: '';
|
|
181
|
+
if (stderr.length > 0) {
|
|
182
|
+
return stderr;
|
|
183
|
+
}
|
|
184
|
+
if ('message' in error && typeof error.message === 'string') {
|
|
185
|
+
return error.message;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return 'Unknown install error.';
|
|
189
|
+
}
|
|
190
|
+
async function readPortableManifest(extractDir) {
|
|
191
|
+
let source;
|
|
192
|
+
try {
|
|
193
|
+
source = await readFile(path.join(extractDir, 'manifest.yaml'), 'utf8');
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new ProfileError('Portable profile archive is missing manifest.yaml.');
|
|
197
|
+
}
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
parsed = YAML.parse(source) ?? {};
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw new ProfileError('Portable profile manifest has invalid YAML.');
|
|
204
|
+
}
|
|
205
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
206
|
+
throw new ProfileError('Portable profile manifest has invalid structure.');
|
|
207
|
+
}
|
|
208
|
+
const manifest = parsed;
|
|
209
|
+
if (manifest.schemaVersion !== 1 && manifest.schemaVersion !== 2) {
|
|
210
|
+
throw new ProfileError(`Unsupported portable profile schema version: ${String(manifest.schemaVersion)}.`);
|
|
211
|
+
}
|
|
212
|
+
if (typeof manifest.profileName !== 'string' || manifest.profileName.trim().length === 0) {
|
|
213
|
+
throw new ProfileError('Portable profile manifest must include profileName.');
|
|
214
|
+
}
|
|
215
|
+
return manifest;
|
|
216
|
+
}
|
|
217
|
+
function resolveBundledArchivePath(extractDir, bundlePath) {
|
|
218
|
+
if (!bundlePath || path.isAbsolute(bundlePath)) {
|
|
219
|
+
throw new ProfileError('Bundled MCP path must be a relative archive path.');
|
|
220
|
+
}
|
|
221
|
+
const resolved = path.resolve(extractDir, bundlePath);
|
|
222
|
+
const relative = path.relative(extractDir, resolved);
|
|
223
|
+
if (relative.startsWith(`..${path.sep}`) ||
|
|
224
|
+
relative === '..' ||
|
|
225
|
+
path.isAbsolute(relative)) {
|
|
226
|
+
throw new ProfileError(`Bundled MCP path "${bundlePath}" escapes the archive root.`);
|
|
227
|
+
}
|
|
228
|
+
return resolved;
|
|
229
|
+
}
|
|
230
|
+
async function restorePlugin(extractDir, plugin) {
|
|
231
|
+
const sourceDir = resolveBundledArchivePath(extractDir, plugin.archivePath);
|
|
232
|
+
try {
|
|
233
|
+
await stat(sourceDir);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
throw new ProfileError(`Bundled plugin "${plugin.name}" source missing in archive at ${plugin.archivePath}.`);
|
|
237
|
+
}
|
|
238
|
+
if (plugin.agent === 'gemini') {
|
|
239
|
+
// Gemini has no plugin cache concept. Treat as user-skill-equivalent no-op.
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const marketplace = plugin.marketplace ?? plugin.source;
|
|
243
|
+
const version = plugin.version ?? 'unknown';
|
|
244
|
+
const cacheRoot = path.join(homedir(), `.${plugin.agent}`, 'plugins', 'cache');
|
|
245
|
+
const targetDir = path.join(cacheRoot, marketplace, plugin.name, version);
|
|
246
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
247
|
+
await mkdir(path.dirname(targetDir), { recursive: true });
|
|
248
|
+
await cp(sourceDir, targetDir, { recursive: true });
|
|
249
|
+
if (plugin.agent === 'claude') {
|
|
250
|
+
await registerClaudePlugin({
|
|
251
|
+
pluginKey: `${plugin.name}@${marketplace}`,
|
|
252
|
+
installPath: targetDir,
|
|
253
|
+
version,
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (plugin.agent === 'codex') {
|
|
258
|
+
await registerCodexPlugin({
|
|
259
|
+
pluginKey: `${plugin.name}@${marketplace}`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function restoreUserSkill(extractDir, skill) {
|
|
264
|
+
const sourceDir = resolveBundledArchivePath(extractDir, skill.archivePath);
|
|
265
|
+
try {
|
|
266
|
+
await stat(sourceDir);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
throw new ProfileError(`Bundled user skill "${skill.name}" source missing in archive at ${skill.archivePath}.`);
|
|
270
|
+
}
|
|
271
|
+
const targetDir = path.join(homedir(), `.${skill.agent}`, 'skills', skill.name);
|
|
272
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
273
|
+
await mkdir(path.dirname(targetDir), { recursive: true });
|
|
274
|
+
await cp(sourceDir, targetDir, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
async function registerClaudePlugin(options) {
|
|
277
|
+
const filePath = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
278
|
+
let existing = { version: 2, plugins: {} };
|
|
279
|
+
try {
|
|
280
|
+
const source = await readFile(filePath, 'utf8');
|
|
281
|
+
existing = JSON.parse(source);
|
|
282
|
+
await backupFile(filePath);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// fresh file
|
|
286
|
+
}
|
|
287
|
+
const plugins = (existing.plugins ?? {});
|
|
288
|
+
const now = new Date().toISOString();
|
|
289
|
+
const entry = {
|
|
290
|
+
scope: 'user',
|
|
291
|
+
installPath: options.installPath,
|
|
292
|
+
version: options.version,
|
|
293
|
+
installedAt: now,
|
|
294
|
+
lastUpdated: now,
|
|
295
|
+
};
|
|
296
|
+
plugins[options.pluginKey] = [entry];
|
|
297
|
+
existing.plugins = plugins;
|
|
298
|
+
if (typeof existing.version !== 'number')
|
|
299
|
+
existing.version = 2;
|
|
300
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
301
|
+
await atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
|
|
302
|
+
}
|
|
303
|
+
async function registerCodexPlugin(options) {
|
|
304
|
+
const filePath = path.join(homedir(), '.codex', 'config.toml');
|
|
305
|
+
let existing = '';
|
|
306
|
+
try {
|
|
307
|
+
existing = await readFile(filePath, 'utf8');
|
|
308
|
+
await backupFile(filePath);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
existing = '';
|
|
312
|
+
}
|
|
313
|
+
const header = `[plugins."${options.pluginKey}"]`;
|
|
314
|
+
if (existing.includes(header))
|
|
315
|
+
return;
|
|
316
|
+
const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
317
|
+
const separator = existing.length > 0 ? '\n' : '';
|
|
318
|
+
const block = `${header}\nenabled = true\n`;
|
|
319
|
+
const next = existing + prefix + separator + block;
|
|
320
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
321
|
+
await atomicWrite(filePath, next);
|
|
322
|
+
}
|
|
323
|
+
async function backupFile(filePath) {
|
|
324
|
+
const backupPath = `${filePath}.bak.${formatTimestamp()}`;
|
|
325
|
+
try {
|
|
326
|
+
await copyFile(filePath, backupPath);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// file may not exist
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function atomicWrite(filePath, content) {
|
|
333
|
+
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
|
334
|
+
await writeFile(tmpPath, content, 'utf8');
|
|
335
|
+
await rename(tmpPath, filePath);
|
|
336
|
+
}
|
|
@@ -2,6 +2,7 @@ import { readdir, readFile, writeFile, mkdir, stat, unlink } from 'node:fs/promi
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import YAML from 'yaml';
|
|
4
4
|
import { ProfileError, ProfileNotFoundError } from '../errors.js';
|
|
5
|
+
const VALID_RUNTIMES = new Set(['node', 'python', 'java', 'go', 'rust', 'binary']);
|
|
5
6
|
const BRAINCTL_DIR = '.brainctl';
|
|
6
7
|
const PROFILES_DIR = '.brainctl/profiles';
|
|
7
8
|
const META_CONFIG = '.brainctl/config.yaml';
|
|
@@ -215,10 +216,12 @@ function normalizeMcps(value, profileName) {
|
|
|
215
216
|
mcps[key] = {
|
|
216
217
|
kind: 'local',
|
|
217
218
|
source: 'bundled',
|
|
219
|
+
runtime: parseMcpRuntime(mcp.runtime),
|
|
218
220
|
path: mcp.path,
|
|
219
221
|
install: typeof mcp.install === 'string' ? mcp.install : undefined,
|
|
220
222
|
command: mcp.command,
|
|
221
223
|
args: parseStringArray(mcp.args),
|
|
224
|
+
...(Array.isArray(mcp.exclude) ? { exclude: mcp.exclude.filter((v) => typeof v === 'string') } : {}),
|
|
222
225
|
env: parseStringMap(mcp.env),
|
|
223
226
|
};
|
|
224
227
|
continue;
|
|
@@ -265,10 +268,12 @@ function normalizeMcps(value, profileName) {
|
|
|
265
268
|
mcps[key] = {
|
|
266
269
|
kind: 'local',
|
|
267
270
|
source: 'bundled',
|
|
271
|
+
runtime: parseMcpRuntime(mcp.runtime),
|
|
268
272
|
path: mcp.path,
|
|
269
273
|
install: typeof mcp.install === 'string' ? mcp.install : undefined,
|
|
270
274
|
command: mcp.command,
|
|
271
275
|
args: parseStringArray(mcp.args),
|
|
276
|
+
...(Array.isArray(mcp.exclude) ? { exclude: mcp.exclude.filter((v) => typeof v === 'string') } : {}),
|
|
272
277
|
env: parseStringMap(mcp.env),
|
|
273
278
|
};
|
|
274
279
|
}
|
|
@@ -284,6 +289,12 @@ function parseStringMap(value) {
|
|
|
284
289
|
}
|
|
285
290
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
286
291
|
}
|
|
292
|
+
function parseMcpRuntime(value) {
|
|
293
|
+
if (typeof value === 'string' && VALID_RUNTIMES.has(value)) {
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
return 'node';
|
|
297
|
+
}
|
|
287
298
|
function parseStringArray(value) {
|
|
288
299
|
if (!Array.isArray(value)) {
|
|
289
300
|
return undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { McpRuntime } from '../types.js';
|
|
2
|
+
export declare function detectMcpRuntime(command: string): McpRuntime | null;
|
|
3
|
+
export declare function extractEntrypoint(command: string, args: string[]): string | null;
|
|
4
|
+
export declare function findProjectRoot(entrypointPath: string, runtime: McpRuntime): {
|
|
5
|
+
root: string;
|
|
6
|
+
marker: string | null;
|
|
7
|
+
};
|
|
8
|
+
export declare function getDefaultInstall(runtime: McpRuntime, marker: string | null, projectRoot: string, entrypoint?: string): string | undefined;
|
|
9
|
+
export declare function getDefaultExclude(runtime: McpRuntime, marker?: string | null): string[] | undefined;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const COMMAND_RUNTIME_MAP = {
|
|
4
|
+
node: 'node',
|
|
5
|
+
nodejs: 'node',
|
|
6
|
+
python: 'python',
|
|
7
|
+
python3: 'python',
|
|
8
|
+
java: 'java',
|
|
9
|
+
go: 'go',
|
|
10
|
+
cargo: 'rust',
|
|
11
|
+
};
|
|
12
|
+
const RUNTIME_MARKERS = {
|
|
13
|
+
node: ['package.json'],
|
|
14
|
+
python: ['pyproject.toml', 'requirements.txt', 'setup.py'],
|
|
15
|
+
java: ['pom.xml', 'build.gradle'],
|
|
16
|
+
go: ['go.mod'],
|
|
17
|
+
rust: ['Cargo.toml'],
|
|
18
|
+
binary: [],
|
|
19
|
+
};
|
|
20
|
+
const MAX_WALK_DEPTH = 5;
|
|
21
|
+
export function detectMcpRuntime(command) {
|
|
22
|
+
const basename = path.basename(command);
|
|
23
|
+
const mapped = COMMAND_RUNTIME_MAP[basename];
|
|
24
|
+
if (mapped) {
|
|
25
|
+
return mapped;
|
|
26
|
+
}
|
|
27
|
+
if (command.startsWith('./') || command.startsWith('/') || command.startsWith('.\\')) {
|
|
28
|
+
return 'binary';
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function extractEntrypoint(command, args) {
|
|
33
|
+
const runtime = detectMcpRuntime(command);
|
|
34
|
+
if (runtime === 'binary') {
|
|
35
|
+
return command;
|
|
36
|
+
}
|
|
37
|
+
if (runtime === 'java') {
|
|
38
|
+
const jarIndex = args.indexOf('-jar');
|
|
39
|
+
if (jarIndex !== -1 && jarIndex + 1 < args.length) {
|
|
40
|
+
return args[jarIndex + 1];
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (runtime === 'go') {
|
|
45
|
+
const runIndex = args.indexOf('run');
|
|
46
|
+
if (runIndex !== -1 && runIndex + 1 < args.length) {
|
|
47
|
+
return args[runIndex + 1];
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (runtime === 'rust') {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
// node, python: first non-flag arg
|
|
55
|
+
for (const arg of args) {
|
|
56
|
+
if (!arg.startsWith('-')) {
|
|
57
|
+
return arg;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
export function findProjectRoot(entrypointPath, runtime) {
|
|
63
|
+
const markers = RUNTIME_MARKERS[runtime];
|
|
64
|
+
if (markers.length === 0) {
|
|
65
|
+
return { root: path.dirname(entrypointPath), marker: null };
|
|
66
|
+
}
|
|
67
|
+
let current = path.dirname(entrypointPath);
|
|
68
|
+
for (let depth = 0; depth <= MAX_WALK_DEPTH; depth++) {
|
|
69
|
+
for (const marker of markers) {
|
|
70
|
+
if (existsSync(path.join(current, marker))) {
|
|
71
|
+
return { root: current, marker };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const parent = path.dirname(current);
|
|
75
|
+
if (parent === current) {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
current = parent;
|
|
79
|
+
}
|
|
80
|
+
return { root: path.dirname(entrypointPath), marker: null };
|
|
81
|
+
}
|
|
82
|
+
export function getDefaultInstall(runtime, marker, projectRoot, entrypoint) {
|
|
83
|
+
switch (runtime) {
|
|
84
|
+
case 'node':
|
|
85
|
+
return 'npm install';
|
|
86
|
+
case 'python':
|
|
87
|
+
if (marker === 'requirements.txt') {
|
|
88
|
+
return 'pip install -r requirements.txt';
|
|
89
|
+
}
|
|
90
|
+
if (marker === 'pyproject.toml') {
|
|
91
|
+
return existsSync(path.join(projectRoot, 'uv.lock')) ? 'uv sync' : 'pip install -e .';
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
case 'java':
|
|
95
|
+
if (entrypoint && entrypoint.endsWith('.jar')) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
if (marker === 'pom.xml')
|
|
99
|
+
return 'mvn package -q';
|
|
100
|
+
if (marker === 'build.gradle')
|
|
101
|
+
return 'gradle build';
|
|
102
|
+
return undefined;
|
|
103
|
+
case 'go':
|
|
104
|
+
return 'go build ./...';
|
|
105
|
+
case 'rust':
|
|
106
|
+
return 'cargo build --release';
|
|
107
|
+
case 'binary':
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function getDefaultExclude(runtime, marker) {
|
|
112
|
+
switch (runtime) {
|
|
113
|
+
case 'node':
|
|
114
|
+
return ['node_modules'];
|
|
115
|
+
case 'python':
|
|
116
|
+
return ['.venv', '__pycache__', '*.pyc'];
|
|
117
|
+
case 'rust':
|
|
118
|
+
return ['target'];
|
|
119
|
+
case 'java':
|
|
120
|
+
if (marker === 'build.gradle')
|
|
121
|
+
return ['build'];
|
|
122
|
+
if (marker === 'pom.xml')
|
|
123
|
+
return ['target'];
|
|
124
|
+
return undefined;
|
|
125
|
+
case 'go':
|
|
126
|
+
return undefined;
|
|
127
|
+
case 'binary':
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { AgentName } from '../types.js';
|
|
2
2
|
export declare function getSkillDir(agent: AgentName, skillName: string): string;
|
|
3
|
+
export declare function getAgentFilePath(agent: AgentName, agentName: string): string;
|
|
4
|
+
export declare function getCommandFilePath(agent: AgentName, commandName: string): string;
|
|
@@ -10,3 +10,17 @@ export function getSkillDir(agent, skillName) {
|
|
|
10
10
|
return path.join(homedir(), '.gemini', 'skills', safeName);
|
|
11
11
|
throw new Error(`Skill management is not supported for ${agent}`);
|
|
12
12
|
}
|
|
13
|
+
export function getAgentFilePath(agent, agentName) {
|
|
14
|
+
const safeName = path.basename(agentName);
|
|
15
|
+
if (agent === 'claude')
|
|
16
|
+
return path.join(homedir(), '.claude', 'agents', `${safeName}.md`);
|
|
17
|
+
if (agent === 'codex')
|
|
18
|
+
return path.join(homedir(), '.codex', 'agents', `${safeName}.toml`);
|
|
19
|
+
throw new Error(`Subagent management is not supported for ${agent}`);
|
|
20
|
+
}
|
|
21
|
+
export function getCommandFilePath(agent, commandName) {
|
|
22
|
+
const safeName = path.basename(commandName);
|
|
23
|
+
if (agent === 'claude')
|
|
24
|
+
return path.join(homedir(), '.claude', 'commands', `${safeName}.md`);
|
|
25
|
+
throw new Error(`Slash-command management is not supported for ${agent}`);
|
|
26
|
+
}
|
|
@@ -4,12 +4,20 @@ export interface AgentMcpEntry {
|
|
|
4
4
|
args?: string[];
|
|
5
5
|
env?: Record<string, string>;
|
|
6
6
|
}
|
|
7
|
+
export interface PortableRemoteMcpMetadata {
|
|
8
|
+
transport: 'http' | 'sse';
|
|
9
|
+
url: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
}
|
|
7
13
|
export interface AgentSkillEntry {
|
|
8
14
|
name: string;
|
|
9
15
|
source?: string;
|
|
10
16
|
kind?: 'skill' | 'plugin';
|
|
11
17
|
pluginSkills?: string[];
|
|
12
18
|
pluginMcps?: string[];
|
|
19
|
+
pluginAgents?: string[];
|
|
20
|
+
pluginCommands?: string[];
|
|
13
21
|
installPath?: string;
|
|
14
22
|
managed?: boolean;
|
|
15
23
|
}
|
|
@@ -18,6 +26,7 @@ export interface AgentLiveConfig {
|
|
|
18
26
|
configPath: string;
|
|
19
27
|
exists: boolean;
|
|
20
28
|
mcpServers: Record<string, AgentMcpEntry>;
|
|
29
|
+
remoteMcpServers: Record<string, PortableRemoteMcpMetadata>;
|
|
21
30
|
skills: AgentSkillEntry[];
|
|
22
31
|
}
|
|
23
32
|
export interface AgentConfigReader {
|