brainctl 0.1.6 → 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 +215 -136
- package/dist/cli.js +40 -0
- package/dist/commands/mcp.js +35 -0
- package/dist/commands/profile.js +35 -2
- package/dist/executor/resolver.js +1 -38
- package/dist/mcp/server.js +82 -2
- package/dist/services/agent-config-service.d.ts +20 -3
- package/dist/services/agent-config-service.js +84 -16
- 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 +26 -0
- package/dist/services/mcp-preflight-service.js +238 -0
- package/dist/services/plugin-install-service.d.ts +135 -0
- package/dist/services/plugin-install-service.js +601 -0
- 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 +266 -11
- package/dist/services/profile-service.d.ts +1 -0
- package/dist/services/profile-service.js +128 -32
- package/dist/services/runtime-detector.d.ts +9 -0
- package/dist/services/runtime-detector.js +130 -0
- package/dist/services/skill-paths.d.ts +4 -0
- package/dist/services/skill-paths.js +26 -0
- package/dist/services/skill-preflight-service.d.ts +23 -0
- package/dist/services/skill-preflight-service.js +40 -0
- package/dist/services/sync/agent-reader.d.ts +14 -0
- package/dist/services/sync/agent-reader.js +198 -45
- package/dist/services/sync/claude-writer.js +4 -7
- package/dist/services/sync/codex-writer.d.ts +1 -0
- package/dist/services/sync/codex-writer.js +25 -8
- package/dist/services/sync/gemini-writer.js +9 -8
- package/dist/services/sync/managed-plugin-registry.d.ts +17 -0
- package/dist/services/sync/managed-plugin-registry.js +75 -0
- package/dist/services/sync/plugin-skill-reader.d.ts +7 -0
- package/dist/services/sync/plugin-skill-reader.js +174 -0
- package/dist/services/sync-service.js +6 -1
- package/dist/services/update-check-service.d.ts +33 -0
- package/dist/services/update-check-service.js +128 -0
- package/dist/system/executables.d.ts +1 -0
- package/dist/system/executables.js +38 -0
- package/dist/types.d.ts +62 -5
- package/dist/ui/routes.js +293 -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 +9 -1
- package/dist/web/assets/index-364NYWPA.css +0 -1
- package/dist/web/assets/index-BmfE7rus.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
|
-
if (mcp.
|
|
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
|
+
}
|
|
@@ -38,3 +38,4 @@ export interface ProfileService {
|
|
|
38
38
|
}
|
|
39
39
|
export declare function createProfileService(): ProfileService;
|
|
40
40
|
export declare function parseProfile(source: string, name: string): ProfileConfig;
|
|
41
|
+
export declare function normalizeProfileConfig(value: unknown, name: string): ProfileConfig;
|
|
@@ -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';
|
|
@@ -70,12 +71,13 @@ export function createProfileService() {
|
|
|
70
71
|
if (!(await pathExists(profilePath))) {
|
|
71
72
|
throw new ProfileNotFoundError(`Profile "${options.name}" not found.`);
|
|
72
73
|
}
|
|
74
|
+
const normalized = normalizeProfileConfig(options.config, options.name);
|
|
73
75
|
const data = {
|
|
74
|
-
name:
|
|
75
|
-
...(
|
|
76
|
-
skills:
|
|
77
|
-
mcps:
|
|
78
|
-
memory:
|
|
76
|
+
name: normalized.name,
|
|
77
|
+
...(normalized.description ? { description: normalized.description } : {}),
|
|
78
|
+
skills: normalized.skills,
|
|
79
|
+
mcps: normalized.mcps,
|
|
80
|
+
memory: normalized.memory,
|
|
79
81
|
};
|
|
80
82
|
await writeFile(profilePath, YAML.stringify(data), 'utf8');
|
|
81
83
|
},
|
|
@@ -137,7 +139,13 @@ export function parseProfile(source, name) {
|
|
|
137
139
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
138
140
|
throw new ProfileError(`Profile "${name}" has invalid structure.`);
|
|
139
141
|
}
|
|
140
|
-
|
|
142
|
+
return normalizeProfileConfig(parsed, name);
|
|
143
|
+
}
|
|
144
|
+
export function normalizeProfileConfig(value, name) {
|
|
145
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
146
|
+
throw new ProfileError(`Profile "${name}" has invalid structure.`);
|
|
147
|
+
}
|
|
148
|
+
const data = value;
|
|
141
149
|
const skills = {};
|
|
142
150
|
if (data.skills && typeof data.skills === 'object' && !Array.isArray(data.skills)) {
|
|
143
151
|
for (const [key, value] of Object.entries(data.skills)) {
|
|
@@ -152,31 +160,7 @@ export function parseProfile(source, name) {
|
|
|
152
160
|
}
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
|
-
const mcps =
|
|
156
|
-
if (data.mcps && typeof data.mcps === 'object' && !Array.isArray(data.mcps)) {
|
|
157
|
-
for (const [key, value] of Object.entries(data.mcps)) {
|
|
158
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
159
|
-
const m = value;
|
|
160
|
-
if (m.type === 'npm' && typeof m.package === 'string') {
|
|
161
|
-
mcps[key] = {
|
|
162
|
-
type: 'npm',
|
|
163
|
-
package: m.package,
|
|
164
|
-
env: parseEnv(m.env),
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
else if (m.type === 'bundled' && typeof m.command === 'string') {
|
|
168
|
-
mcps[key] = {
|
|
169
|
-
type: 'bundled',
|
|
170
|
-
path: typeof m.path === 'string' ? m.path : '.',
|
|
171
|
-
install: typeof m.install === 'string' ? m.install : undefined,
|
|
172
|
-
command: m.command,
|
|
173
|
-
args: Array.isArray(m.args) ? m.args.map(String) : undefined,
|
|
174
|
-
env: parseEnv(m.env),
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
163
|
+
const mcps = normalizeMcps(data.mcps, name);
|
|
180
164
|
const memoryPaths = [];
|
|
181
165
|
if (data.memory && typeof data.memory === 'object' && !Array.isArray(data.memory)) {
|
|
182
166
|
const mem = data.memory;
|
|
@@ -196,7 +180,106 @@ export function parseProfile(source, name) {
|
|
|
196
180
|
memory: { paths: memoryPaths },
|
|
197
181
|
};
|
|
198
182
|
}
|
|
199
|
-
function
|
|
183
|
+
function normalizeMcps(value, profileName) {
|
|
184
|
+
if (value === undefined || value === null) {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
188
|
+
throw new ProfileError(`Profile "${profileName}" has an invalid "mcps" section.`);
|
|
189
|
+
}
|
|
190
|
+
const mcps = {};
|
|
191
|
+
for (const [key, rawValue] of Object.entries(value)) {
|
|
192
|
+
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
|
193
|
+
throw new ProfileError(`MCP "${key}" must be an object.`);
|
|
194
|
+
}
|
|
195
|
+
const mcp = rawValue;
|
|
196
|
+
// Local profile files may still use the older type-based shape.
|
|
197
|
+
if (mcp.type === 'npm') {
|
|
198
|
+
if (typeof mcp.package !== 'string' || mcp.package.trim().length === 0) {
|
|
199
|
+
throw new ProfileError(`Local MCP "${key}" must include a non-empty package.`);
|
|
200
|
+
}
|
|
201
|
+
mcps[key] = {
|
|
202
|
+
kind: 'local',
|
|
203
|
+
source: 'npm',
|
|
204
|
+
package: mcp.package,
|
|
205
|
+
env: parseStringMap(mcp.env),
|
|
206
|
+
};
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (mcp.type === 'bundled') {
|
|
210
|
+
if (typeof mcp.path !== 'string' ||
|
|
211
|
+
mcp.path.trim().length === 0 ||
|
|
212
|
+
typeof mcp.command !== 'string' ||
|
|
213
|
+
mcp.command.trim().length === 0) {
|
|
214
|
+
throw new ProfileError(`Bundled local MCP "${key}" must include non-empty path and command fields.`);
|
|
215
|
+
}
|
|
216
|
+
mcps[key] = {
|
|
217
|
+
kind: 'local',
|
|
218
|
+
source: 'bundled',
|
|
219
|
+
runtime: parseMcpRuntime(mcp.runtime),
|
|
220
|
+
path: mcp.path,
|
|
221
|
+
install: typeof mcp.install === 'string' ? mcp.install : undefined,
|
|
222
|
+
command: mcp.command,
|
|
223
|
+
args: parseStringArray(mcp.args),
|
|
224
|
+
...(Array.isArray(mcp.exclude) ? { exclude: mcp.exclude.filter((v) => typeof v === 'string') } : {}),
|
|
225
|
+
env: parseStringMap(mcp.env),
|
|
226
|
+
};
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (mcp.kind !== 'local' && mcp.kind !== 'remote') {
|
|
230
|
+
throw new ProfileError(`MCP "${key}" must declare kind "local" or "remote".`);
|
|
231
|
+
}
|
|
232
|
+
if (mcp.kind === 'remote') {
|
|
233
|
+
if ((mcp.transport !== 'http' && mcp.transport !== 'sse') ||
|
|
234
|
+
typeof mcp.url !== 'string' ||
|
|
235
|
+
mcp.url.trim().length === 0) {
|
|
236
|
+
throw new ProfileError(`Remote MCP "${key}" must include transport ("http" or "sse") and a url.`);
|
|
237
|
+
}
|
|
238
|
+
mcps[key] = {
|
|
239
|
+
kind: 'remote',
|
|
240
|
+
transport: mcp.transport,
|
|
241
|
+
url: mcp.url,
|
|
242
|
+
headers: parseStringMap(mcp.headers),
|
|
243
|
+
env: parseStringMap(mcp.env),
|
|
244
|
+
};
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (mcp.source !== 'npm' && mcp.source !== 'bundled') {
|
|
248
|
+
throw new ProfileError(`Local MCP "${key}" must declare source "npm" or "bundled".`);
|
|
249
|
+
}
|
|
250
|
+
if (mcp.source === 'npm') {
|
|
251
|
+
if (typeof mcp.package !== 'string' || mcp.package.trim().length === 0) {
|
|
252
|
+
throw new ProfileError(`Local MCP "${key}" must include a non-empty package.`);
|
|
253
|
+
}
|
|
254
|
+
mcps[key] = {
|
|
255
|
+
kind: 'local',
|
|
256
|
+
source: 'npm',
|
|
257
|
+
package: mcp.package,
|
|
258
|
+
env: parseStringMap(mcp.env),
|
|
259
|
+
};
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (typeof mcp.path !== 'string' ||
|
|
263
|
+
mcp.path.trim().length === 0 ||
|
|
264
|
+
typeof mcp.command !== 'string' ||
|
|
265
|
+
mcp.command.trim().length === 0) {
|
|
266
|
+
throw new ProfileError(`Bundled local MCP "${key}" must include non-empty path and command fields.`);
|
|
267
|
+
}
|
|
268
|
+
mcps[key] = {
|
|
269
|
+
kind: 'local',
|
|
270
|
+
source: 'bundled',
|
|
271
|
+
runtime: parseMcpRuntime(mcp.runtime),
|
|
272
|
+
path: mcp.path,
|
|
273
|
+
install: typeof mcp.install === 'string' ? mcp.install : undefined,
|
|
274
|
+
command: mcp.command,
|
|
275
|
+
args: parseStringArray(mcp.args),
|
|
276
|
+
...(Array.isArray(mcp.exclude) ? { exclude: mcp.exclude.filter((v) => typeof v === 'string') } : {}),
|
|
277
|
+
env: parseStringMap(mcp.env),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return mcps;
|
|
281
|
+
}
|
|
282
|
+
function parseStringMap(value) {
|
|
200
283
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
201
284
|
return undefined;
|
|
202
285
|
}
|
|
@@ -206,6 +289,19 @@ function parseEnv(value) {
|
|
|
206
289
|
}
|
|
207
290
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
208
291
|
}
|
|
292
|
+
function parseMcpRuntime(value) {
|
|
293
|
+
if (typeof value === 'string' && VALID_RUNTIMES.has(value)) {
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
return 'node';
|
|
297
|
+
}
|
|
298
|
+
function parseStringArray(value) {
|
|
299
|
+
if (!Array.isArray(value)) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
const items = value.map(String);
|
|
303
|
+
return items.length > 0 ? items : undefined;
|
|
304
|
+
}
|
|
209
305
|
async function pathExists(targetPath) {
|
|
210
306
|
try {
|
|
211
307
|
await stat(targetPath);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AgentName } from '../types.js';
|
|
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;
|