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.
Files changed (59) hide show
  1. package/README.md +215 -136
  2. package/dist/cli.js +40 -0
  3. package/dist/commands/mcp.js +35 -0
  4. package/dist/commands/profile.js +35 -2
  5. package/dist/executor/resolver.js +1 -38
  6. package/dist/mcp/server.js +82 -2
  7. package/dist/services/agent-config-service.d.ts +20 -3
  8. package/dist/services/agent-config-service.js +84 -16
  9. package/dist/services/agent-converter-service.d.ts +21 -0
  10. package/dist/services/agent-converter-service.js +182 -0
  11. package/dist/services/credential-redaction-service.d.ts +13 -0
  12. package/dist/services/credential-redaction-service.js +89 -0
  13. package/dist/services/credential-resolution-service.d.ts +11 -0
  14. package/dist/services/credential-resolution-service.js +69 -0
  15. package/dist/services/mcp-preflight-service.d.ts +26 -0
  16. package/dist/services/mcp-preflight-service.js +238 -0
  17. package/dist/services/plugin-install-service.d.ts +135 -0
  18. package/dist/services/plugin-install-service.js +601 -0
  19. package/dist/services/portable-mcp-classifier.d.ts +12 -0
  20. package/dist/services/portable-mcp-classifier.js +116 -0
  21. package/dist/services/portable-profile-pack-service.d.ts +26 -0
  22. package/dist/services/portable-profile-pack-service.js +264 -0
  23. package/dist/services/profile-export-service.d.ts +15 -3
  24. package/dist/services/profile-export-service.js +10 -57
  25. package/dist/services/profile-import-service.d.ts +9 -1
  26. package/dist/services/profile-import-service.js +266 -11
  27. package/dist/services/profile-service.d.ts +1 -0
  28. package/dist/services/profile-service.js +128 -32
  29. package/dist/services/runtime-detector.d.ts +9 -0
  30. package/dist/services/runtime-detector.js +130 -0
  31. package/dist/services/skill-paths.d.ts +4 -0
  32. package/dist/services/skill-paths.js +26 -0
  33. package/dist/services/skill-preflight-service.d.ts +23 -0
  34. package/dist/services/skill-preflight-service.js +40 -0
  35. package/dist/services/sync/agent-reader.d.ts +14 -0
  36. package/dist/services/sync/agent-reader.js +198 -45
  37. package/dist/services/sync/claude-writer.js +4 -7
  38. package/dist/services/sync/codex-writer.d.ts +1 -0
  39. package/dist/services/sync/codex-writer.js +25 -8
  40. package/dist/services/sync/gemini-writer.js +9 -8
  41. package/dist/services/sync/managed-plugin-registry.d.ts +17 -0
  42. package/dist/services/sync/managed-plugin-registry.js +75 -0
  43. package/dist/services/sync/plugin-skill-reader.d.ts +7 -0
  44. package/dist/services/sync/plugin-skill-reader.js +174 -0
  45. package/dist/services/sync-service.js +6 -1
  46. package/dist/services/update-check-service.d.ts +33 -0
  47. package/dist/services/update-check-service.js +128 -0
  48. package/dist/system/executables.d.ts +1 -0
  49. package/dist/system/executables.js +38 -0
  50. package/dist/types.d.ts +62 -5
  51. package/dist/ui/routes.js +293 -8
  52. package/dist/web/assets/index-Cdb5hbxM.css +1 -0
  53. package/dist/web/assets/index-gN83hZYA.js +65 -0
  54. package/dist/web/favicon-light.svg +13 -0
  55. package/dist/web/favicon.svg +13 -0
  56. package/dist/web/index.html +7 -2
  57. package/package.json +9 -1
  58. package/dist/web/assets/index-364NYWPA.css +0 -1
  59. 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.type !== 'bundled')
65
+ if (!(mcp.kind === 'local' && mcp.source === 'bundled'))
43
66
  continue;
44
- const extractedMcpPath = path.join(extractDir, 'mcps', name);
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 ?? 'npm install';
55
- execSync(installCmd, {
56
- cwd: destMcpPath,
57
- stdio: 'pipe',
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: options.config.name,
75
- ...(options.config.description ? { description: options.config.description } : {}),
76
- skills: options.config.skills,
77
- mcps: options.config.mcps,
78
- memory: options.config.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
- const data = parsed;
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 parseEnv(value) {
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;