brainctl 0.1.6 → 0.1.7

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 (37) hide show
  1. package/README.md +26 -0
  2. package/dist/executor/resolver.js +1 -38
  3. package/dist/mcp/server.js +34 -0
  4. package/dist/services/agent-config-service.d.ts +16 -1
  5. package/dist/services/agent-config-service.js +35 -2
  6. package/dist/services/mcp-preflight-service.d.ts +25 -0
  7. package/dist/services/mcp-preflight-service.js +84 -0
  8. package/dist/services/plugin-install-service.d.ts +92 -0
  9. package/dist/services/plugin-install-service.js +243 -0
  10. package/dist/services/profile-export-service.js +5 -5
  11. package/dist/services/profile-import-service.js +1 -1
  12. package/dist/services/profile-service.d.ts +1 -0
  13. package/dist/services/profile-service.js +117 -32
  14. package/dist/services/skill-paths.d.ts +2 -0
  15. package/dist/services/skill-paths.js +12 -0
  16. package/dist/services/skill-preflight-service.d.ts +23 -0
  17. package/dist/services/skill-preflight-service.js +40 -0
  18. package/dist/services/sync/agent-reader.d.ts +5 -0
  19. package/dist/services/sync/agent-reader.js +26 -15
  20. package/dist/services/sync/claude-writer.js +4 -1
  21. package/dist/services/sync/codex-writer.js +6 -2
  22. package/dist/services/sync/gemini-writer.js +4 -1
  23. package/dist/services/sync/managed-plugin-registry.d.ts +17 -0
  24. package/dist/services/sync/managed-plugin-registry.js +75 -0
  25. package/dist/services/sync/plugin-skill-reader.d.ts +2 -0
  26. package/dist/services/sync/plugin-skill-reader.js +33 -0
  27. package/dist/services/sync-service.js +5 -0
  28. package/dist/system/executables.d.ts +1 -0
  29. package/dist/system/executables.js +38 -0
  30. package/dist/types.d.ts +15 -5
  31. package/dist/ui/routes.js +264 -6
  32. package/dist/web/assets/index-BCkorugl.css +1 -0
  33. package/dist/web/assets/index-sGnTMhkX.js +16 -0
  34. package/dist/web/index.html +2 -2
  35. package/package.json +5 -1
  36. package/dist/web/assets/index-364NYWPA.css +0 -1
  37. package/dist/web/assets/index-BmfE7rus.js +0 -16
@@ -30,7 +30,7 @@ async function stageProfile(profile, cwd, stagingDir) {
30
30
  const mcpsDir = path.join(stagingDir, 'mcps');
31
31
  const exportMcps = {};
32
32
  for (const [name, mcp] of Object.entries(profile.mcps)) {
33
- if (mcp.type === 'bundled') {
33
+ if (mcp.kind === 'local' && mcp.source === 'bundled') {
34
34
  const sourcePath = path.isAbsolute(mcp.path)
35
35
  ? mcp.path
36
36
  : path.resolve(cwd, mcp.path);
@@ -41,17 +41,17 @@ async function stageProfile(profile, cwd, stagingDir) {
41
41
  filter: (src) => !src.includes('node_modules'),
42
42
  });
43
43
  exportMcps[name] = {
44
- type: 'bundled',
44
+ kind: 'local',
45
+ source: 'bundled',
45
46
  path: `./mcps/${name}`,
46
47
  ...(mcp.install ? { install: mcp.install } : {}),
47
48
  command: mcp.command,
48
49
  ...(mcp.args ? { args: mcp.args } : {}),
49
50
  ...(mcp.env ? { env: mcp.env } : {}),
50
51
  };
52
+ continue;
51
53
  }
52
- else {
53
- exportMcps[name] = mcp;
54
- }
54
+ exportMcps[name] = mcp;
55
55
  }
56
56
  return {
57
57
  name: profile.name,
@@ -39,7 +39,7 @@ export function createProfileImportService() {
39
39
  const installedMcps = [];
40
40
  const mcpsBaseDir = path.join(cwd, PROFILES_DIR, profileName, 'mcps');
41
41
  for (const [name, mcp] of Object.entries(profile.mcps)) {
42
- if (mcp.type !== 'bundled')
42
+ if (!(mcp.kind === 'local' && mcp.source === 'bundled'))
43
43
  continue;
44
44
  const extractedMcpPath = path.join(extractDir, 'mcps', name);
45
45
  const destMcpPath = path.join(mcpsBaseDir, name);
@@ -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;
@@ -70,12 +70,13 @@ export function createProfileService() {
70
70
  if (!(await pathExists(profilePath))) {
71
71
  throw new ProfileNotFoundError(`Profile "${options.name}" not found.`);
72
72
  }
73
+ const normalized = normalizeProfileConfig(options.config, options.name);
73
74
  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,
75
+ name: normalized.name,
76
+ ...(normalized.description ? { description: normalized.description } : {}),
77
+ skills: normalized.skills,
78
+ mcps: normalized.mcps,
79
+ memory: normalized.memory,
79
80
  };
80
81
  await writeFile(profilePath, YAML.stringify(data), 'utf8');
81
82
  },
@@ -137,7 +138,13 @@ export function parseProfile(source, name) {
137
138
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
138
139
  throw new ProfileError(`Profile "${name}" has invalid structure.`);
139
140
  }
140
- const data = parsed;
141
+ return normalizeProfileConfig(parsed, name);
142
+ }
143
+ export function normalizeProfileConfig(value, name) {
144
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
145
+ throw new ProfileError(`Profile "${name}" has invalid structure.`);
146
+ }
147
+ const data = value;
141
148
  const skills = {};
142
149
  if (data.skills && typeof data.skills === 'object' && !Array.isArray(data.skills)) {
143
150
  for (const [key, value] of Object.entries(data.skills)) {
@@ -152,31 +159,7 @@ export function parseProfile(source, name) {
152
159
  }
153
160
  }
154
161
  }
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
- }
162
+ const mcps = normalizeMcps(data.mcps, name);
180
163
  const memoryPaths = [];
181
164
  if (data.memory && typeof data.memory === 'object' && !Array.isArray(data.memory)) {
182
165
  const mem = data.memory;
@@ -196,7 +179,102 @@ export function parseProfile(source, name) {
196
179
  memory: { paths: memoryPaths },
197
180
  };
198
181
  }
199
- function parseEnv(value) {
182
+ function normalizeMcps(value, profileName) {
183
+ if (value === undefined || value === null) {
184
+ return {};
185
+ }
186
+ if (typeof value !== 'object' || Array.isArray(value)) {
187
+ throw new ProfileError(`Profile "${profileName}" has an invalid "mcps" section.`);
188
+ }
189
+ const mcps = {};
190
+ for (const [key, rawValue] of Object.entries(value)) {
191
+ if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
192
+ throw new ProfileError(`MCP "${key}" must be an object.`);
193
+ }
194
+ const mcp = rawValue;
195
+ // Local profile files may still use the older type-based shape.
196
+ if (mcp.type === 'npm') {
197
+ if (typeof mcp.package !== 'string' || mcp.package.trim().length === 0) {
198
+ throw new ProfileError(`Local MCP "${key}" must include a non-empty package.`);
199
+ }
200
+ mcps[key] = {
201
+ kind: 'local',
202
+ source: 'npm',
203
+ package: mcp.package,
204
+ env: parseStringMap(mcp.env),
205
+ };
206
+ continue;
207
+ }
208
+ if (mcp.type === 'bundled') {
209
+ if (typeof mcp.path !== 'string' ||
210
+ mcp.path.trim().length === 0 ||
211
+ typeof mcp.command !== 'string' ||
212
+ mcp.command.trim().length === 0) {
213
+ throw new ProfileError(`Bundled local MCP "${key}" must include non-empty path and command fields.`);
214
+ }
215
+ mcps[key] = {
216
+ kind: 'local',
217
+ source: 'bundled',
218
+ path: mcp.path,
219
+ install: typeof mcp.install === 'string' ? mcp.install : undefined,
220
+ command: mcp.command,
221
+ args: parseStringArray(mcp.args),
222
+ env: parseStringMap(mcp.env),
223
+ };
224
+ continue;
225
+ }
226
+ if (mcp.kind !== 'local' && mcp.kind !== 'remote') {
227
+ throw new ProfileError(`MCP "${key}" must declare kind "local" or "remote".`);
228
+ }
229
+ if (mcp.kind === 'remote') {
230
+ if ((mcp.transport !== 'http' && mcp.transport !== 'sse') ||
231
+ typeof mcp.url !== 'string' ||
232
+ mcp.url.trim().length === 0) {
233
+ throw new ProfileError(`Remote MCP "${key}" must include transport ("http" or "sse") and a url.`);
234
+ }
235
+ mcps[key] = {
236
+ kind: 'remote',
237
+ transport: mcp.transport,
238
+ url: mcp.url,
239
+ headers: parseStringMap(mcp.headers),
240
+ env: parseStringMap(mcp.env),
241
+ };
242
+ continue;
243
+ }
244
+ if (mcp.source !== 'npm' && mcp.source !== 'bundled') {
245
+ throw new ProfileError(`Local MCP "${key}" must declare source "npm" or "bundled".`);
246
+ }
247
+ if (mcp.source === 'npm') {
248
+ if (typeof mcp.package !== 'string' || mcp.package.trim().length === 0) {
249
+ throw new ProfileError(`Local MCP "${key}" must include a non-empty package.`);
250
+ }
251
+ mcps[key] = {
252
+ kind: 'local',
253
+ source: 'npm',
254
+ package: mcp.package,
255
+ env: parseStringMap(mcp.env),
256
+ };
257
+ continue;
258
+ }
259
+ if (typeof mcp.path !== 'string' ||
260
+ mcp.path.trim().length === 0 ||
261
+ typeof mcp.command !== 'string' ||
262
+ mcp.command.trim().length === 0) {
263
+ throw new ProfileError(`Bundled local MCP "${key}" must include non-empty path and command fields.`);
264
+ }
265
+ mcps[key] = {
266
+ kind: 'local',
267
+ source: 'bundled',
268
+ path: mcp.path,
269
+ install: typeof mcp.install === 'string' ? mcp.install : undefined,
270
+ command: mcp.command,
271
+ args: parseStringArray(mcp.args),
272
+ env: parseStringMap(mcp.env),
273
+ };
274
+ }
275
+ return mcps;
276
+ }
277
+ function parseStringMap(value) {
200
278
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
201
279
  return undefined;
202
280
  }
@@ -206,6 +284,13 @@ function parseEnv(value) {
206
284
  }
207
285
  return Object.keys(result).length > 0 ? result : undefined;
208
286
  }
287
+ function parseStringArray(value) {
288
+ if (!Array.isArray(value)) {
289
+ return undefined;
290
+ }
291
+ const items = value.map(String);
292
+ return items.length > 0 ? items : undefined;
293
+ }
209
294
  async function pathExists(targetPath) {
210
295
  try {
211
296
  await stat(targetPath);
@@ -0,0 +1,2 @@
1
+ import type { AgentName } from '../types.js';
2
+ export declare function getSkillDir(agent: AgentName, skillName: string): string;
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+ export function getSkillDir(agent, skillName) {
4
+ const safeName = path.basename(skillName);
5
+ if (agent === 'claude')
6
+ return path.join(homedir(), '.claude', 'skills', safeName);
7
+ if (agent === 'codex')
8
+ return path.join(homedir(), '.codex', 'skills', safeName);
9
+ if (agent === 'gemini')
10
+ return path.join(homedir(), '.gemini', 'skills', safeName);
11
+ throw new Error(`Skill management is not supported for ${agent}`);
12
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentName } from '../types.js';
2
+ export interface SkillPreflightCheck {
3
+ label: string;
4
+ status: 'ok' | 'warn' | 'error';
5
+ message: string;
6
+ }
7
+ export interface SkillPreflightResult {
8
+ ok: boolean;
9
+ checks: SkillPreflightCheck[];
10
+ }
11
+ export interface SkillPreflightService {
12
+ execute(options: {
13
+ sourceAgent: AgentName;
14
+ targetAgent: AgentName;
15
+ skillName: string;
16
+ source?: string;
17
+ }): Promise<SkillPreflightResult>;
18
+ }
19
+ interface SkillPreflightDependencies {
20
+ pathExists?: (targetPath: string) => Promise<boolean>;
21
+ }
22
+ export declare function createSkillPreflightService(dependencies?: SkillPreflightDependencies): SkillPreflightService;
23
+ export {};
@@ -0,0 +1,40 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { getSkillDir } from './skill-paths.js';
3
+ export function createSkillPreflightService(dependencies = {}) {
4
+ const pathExists = dependencies.pathExists ?? defaultPathExists;
5
+ return {
6
+ async execute(options) {
7
+ const checks = [];
8
+ if (options.source && options.source !== 'local' && options.source !== 'linked') {
9
+ checks.push({
10
+ label: 'Source',
11
+ status: 'error',
12
+ message: `Only local skill folders can be copied today. "${options.skillName}" is a plugin/managed entry from ${options.source}.`,
13
+ });
14
+ return { ok: false, checks };
15
+ }
16
+ const sourceDir = getSkillDir(options.sourceAgent, options.skillName);
17
+ const exists = await pathExists(sourceDir);
18
+ checks.push({
19
+ label: 'Source',
20
+ status: exists ? 'ok' : 'error',
21
+ message: exists
22
+ ? `Skill folder was found: ${sourceDir}`
23
+ : `Skill folder was not found: ${sourceDir}`,
24
+ });
25
+ return {
26
+ ok: exists,
27
+ checks,
28
+ };
29
+ },
30
+ };
31
+ }
32
+ async function defaultPathExists(targetPath) {
33
+ try {
34
+ await stat(targetPath);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
@@ -7,6 +7,11 @@ export interface AgentMcpEntry {
7
7
  export interface AgentSkillEntry {
8
8
  name: string;
9
9
  source?: string;
10
+ kind?: 'skill' | 'plugin';
11
+ pluginSkills?: string[];
12
+ pluginMcps?: string[];
13
+ installPath?: string;
14
+ managed?: boolean;
10
15
  }
11
16
  export interface AgentLiveConfig {
12
17
  agent: AgentName;
@@ -1,6 +1,8 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
+ import { mergeManagedPluginsIntoSkills, readManagedPlugins, } from './managed-plugin-registry.js';
5
+ import { readInstalledPlugins } from './plugin-skill-reader.js';
4
6
  export function createClaudeReader() {
5
7
  return {
6
8
  async read(options) {
@@ -170,37 +172,46 @@ function parseTomlArray(raw) {
170
172
  }
171
173
  /* ---- Skill readers ---- */
172
174
  async function readClaudePlugins() {
175
+ const results = [];
176
+ // Read marketplace plugins
173
177
  try {
174
178
  const pluginsPath = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
175
- const source = await readFile(pluginsPath, 'utf8');
176
- const data = JSON.parse(source);
177
- if (!data.plugins)
178
- return [];
179
- return Object.keys(data.plugins).map((key) => {
180
- const [name, source] = key.split('@');
181
- return { name, source };
182
- });
179
+ results.push(...await readInstalledPlugins(pluginsPath));
183
180
  }
184
181
  catch {
185
- return [];
182
+ // no plugins file
183
+ }
184
+ // Read local skills from ~/.claude/skills/
185
+ try {
186
+ const skillsDir = path.join(homedir(), '.claude', 'skills');
187
+ const localSkills = await readSkillDirs(skillsDir);
188
+ results.push(...localSkills);
186
189
  }
190
+ catch {
191
+ // no skills dir
192
+ }
193
+ return results;
187
194
  }
188
195
  async function readCodexSkills() {
189
196
  try {
190
197
  const skillsDir = path.join(homedir(), '.codex', 'skills');
191
- return await readSkillDirs(skillsDir);
198
+ const localSkills = await readSkillDirs(skillsDir);
199
+ const managedPlugins = await readManagedPlugins({ agent: 'codex' });
200
+ return mergeManagedPluginsIntoSkills(localSkills, managedPlugins);
192
201
  }
193
202
  catch {
194
- return [];
203
+ return await readManagedPlugins({ agent: 'codex' });
195
204
  }
196
205
  }
197
206
  async function readGeminiSkills() {
198
207
  try {
199
208
  const skillsDir = path.join(homedir(), '.gemini', 'skills');
200
- return await readSkillDirs(skillsDir);
209
+ const localSkills = await readSkillDirs(skillsDir);
210
+ const managedPlugins = await readManagedPlugins({ agent: 'gemini' });
211
+ return mergeManagedPluginsIntoSkills(localSkills, managedPlugins);
201
212
  }
202
213
  catch {
203
- return [];
214
+ return await readManagedPlugins({ agent: 'gemini' });
204
215
  }
205
216
  }
206
217
  /** Shared: read skill directories (Codex and Gemini use the same SKILL.md convention) */
@@ -211,10 +222,10 @@ async function readSkillDirs(skillsDir) {
211
222
  if (entry.name.startsWith('.'))
212
223
  continue;
213
224
  if (entry.isDirectory()) {
214
- skills.push({ name: entry.name, source: 'local' });
225
+ skills.push({ name: entry.name, source: 'local', kind: 'skill' });
215
226
  }
216
227
  else if (entry.isSymbolicLink()) {
217
- skills.push({ name: entry.name, source: 'linked' });
228
+ skills.push({ name: entry.name, source: 'linked', kind: 'skill' });
218
229
  }
219
230
  }
220
231
  return skills;
@@ -65,7 +65,10 @@ export function createClaudeWriter() {
65
65
  };
66
66
  }
67
67
  function toClaudeFormat(config) {
68
- if (config.type === 'npm') {
68
+ if (config.kind === 'remote') {
69
+ throw new SyncError('Remote MCP servers are not supported in Claude sync.');
70
+ }
71
+ if (config.source === 'npm') {
69
72
  return {
70
73
  type: 'stdio',
71
74
  command: 'npx',
@@ -27,7 +27,8 @@ export function createCodexWriter() {
27
27
  const allServers = { ...options.mcpServers };
28
28
  // Always include brainctl itself
29
29
  allServers['brainctl'] = {
30
- type: 'npm',
30
+ kind: 'local',
31
+ source: 'npm',
31
32
  package: 'brainctl',
32
33
  };
33
34
  const mcpToml = buildMcpToml(allServers);
@@ -71,7 +72,10 @@ function buildMcpToml(servers) {
71
72
  const lines = [];
72
73
  for (const [name, config] of Object.entries(servers)) {
73
74
  lines.push(`[mcp_servers.${name}]`);
74
- if (config.type === 'npm') {
75
+ if (config.kind === 'remote') {
76
+ throw new SyncError('Remote MCP servers are not supported in Codex sync.');
77
+ }
78
+ if (config.source === 'npm') {
75
79
  lines.push(`command = "npx"`);
76
80
  lines.push(`args = ["-y", ${tomlString(config.package)}]`);
77
81
  }
@@ -67,7 +67,10 @@ export function createGeminiWriter() {
67
67
  };
68
68
  }
69
69
  function toGeminiFormat(config) {
70
- if (config.type === 'npm') {
70
+ if (config.kind === 'remote') {
71
+ throw new SyncError('Remote MCP servers are not supported in Gemini sync.');
72
+ }
73
+ if (config.source === 'npm') {
71
74
  return {
72
75
  command: 'npx',
73
76
  args: ['-y', config.package],
@@ -0,0 +1,17 @@
1
+ import type { AgentName } from '../../types.js';
2
+ import type { AgentSkillEntry } from './agent-reader.js';
3
+ export declare function readManagedPlugins(options: {
4
+ homeDir?: string;
5
+ agent: AgentName;
6
+ }): Promise<AgentSkillEntry[]>;
7
+ export declare function writeManagedPluginInstall(options: {
8
+ homeDir?: string;
9
+ agent: AgentName;
10
+ plugin: AgentSkillEntry;
11
+ }): Promise<void>;
12
+ export declare function removeManagedPluginInstall(options: {
13
+ homeDir?: string;
14
+ agent: AgentName;
15
+ pluginName: string;
16
+ }): Promise<void>;
17
+ export declare function mergeManagedPluginsIntoSkills(localSkills: AgentSkillEntry[], managedPlugins: AgentSkillEntry[]): AgentSkillEntry[];
@@ -0,0 +1,75 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ function getRegistryPath(homeDir) {
5
+ return path.join(homeDir, '.brainctl', 'managed-plugins.json');
6
+ }
7
+ export async function readManagedPlugins(options) {
8
+ const homeDir = options.homeDir ?? homedir();
9
+ const registryPath = getRegistryPath(homeDir);
10
+ try {
11
+ const source = await readFile(registryPath, 'utf8');
12
+ const parsed = JSON.parse(source);
13
+ return (parsed.agents?.[options.agent] ?? []).map((entry) => ({
14
+ ...entry,
15
+ kind: 'plugin',
16
+ managed: true,
17
+ }));
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ export async function writeManagedPluginInstall(options) {
24
+ const homeDir = options.homeDir ?? homedir();
25
+ const registryPath = getRegistryPath(homeDir);
26
+ const existing = await readRegistryFile(homeDir);
27
+ const currentEntries = existing.agents?.[options.agent] ?? [];
28
+ const nextEntries = [
29
+ ...currentEntries.filter((entry) => entry.name !== options.plugin.name),
30
+ {
31
+ ...options.plugin,
32
+ kind: 'plugin',
33
+ managed: true,
34
+ },
35
+ ].sort((left, right) => left.name.localeCompare(right.name));
36
+ const next = {
37
+ version: 1,
38
+ agents: {
39
+ ...existing.agents,
40
+ [options.agent]: nextEntries,
41
+ },
42
+ };
43
+ await mkdir(path.dirname(registryPath), { recursive: true });
44
+ await writeFile(registryPath, JSON.stringify(next, null, 2) + '\n', 'utf8');
45
+ }
46
+ export async function removeManagedPluginInstall(options) {
47
+ const homeDir = options.homeDir ?? homedir();
48
+ const registryPath = getRegistryPath(homeDir);
49
+ const existing = await readRegistryFile(homeDir);
50
+ const currentEntries = existing.agents?.[options.agent] ?? [];
51
+ const nextEntries = currentEntries.filter((entry) => entry.name !== options.pluginName);
52
+ const next = {
53
+ version: 1,
54
+ agents: {
55
+ ...existing.agents,
56
+ [options.agent]: nextEntries,
57
+ },
58
+ };
59
+ await mkdir(path.dirname(registryPath), { recursive: true });
60
+ await writeFile(registryPath, JSON.stringify(next, null, 2) + '\n', 'utf8');
61
+ }
62
+ export function mergeManagedPluginsIntoSkills(localSkills, managedPlugins) {
63
+ const pluginOwnedSkills = new Set(managedPlugins.flatMap((plugin) => plugin.pluginSkills ?? []));
64
+ const filteredLocalSkills = localSkills.filter((skill) => !pluginOwnedSkills.has(skill.name));
65
+ return [...managedPlugins, ...filteredLocalSkills];
66
+ }
67
+ async function readRegistryFile(homeDir) {
68
+ try {
69
+ const source = await readFile(getRegistryPath(homeDir), 'utf8');
70
+ return JSON.parse(source);
71
+ }
72
+ catch {
73
+ return { version: 1, agents: {} };
74
+ }
75
+ }
@@ -0,0 +1,2 @@
1
+ import type { AgentSkillEntry } from './agent-reader.js';
2
+ export declare function readInstalledPlugins(installedPluginsPath: string): Promise<AgentSkillEntry[]>;
@@ -0,0 +1,33 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function readInstalledPlugins(installedPluginsPath) {
4
+ const source = await readFile(installedPluginsPath, 'utf8');
5
+ const data = JSON.parse(source);
6
+ const results = [];
7
+ for (const [key, records] of Object.entries(data.plugins ?? {})) {
8
+ const [name, pluginSource] = key.split('@');
9
+ const installPath = records[0]?.installPath;
10
+ const pluginSkills = installPath ? await readPluginSkills(installPath) : [];
11
+ results.push({
12
+ name,
13
+ source: pluginSource,
14
+ kind: 'plugin',
15
+ installPath,
16
+ pluginSkills,
17
+ });
18
+ }
19
+ return results;
20
+ }
21
+ async function readPluginSkills(installPath) {
22
+ const skillsDir = path.join(installPath, 'skills');
23
+ try {
24
+ const entries = await readdir(skillsDir, { withFileTypes: true });
25
+ return entries
26
+ .filter((entry) => !entry.name.startsWith('.') && entry.isDirectory())
27
+ .map((entry) => entry.name)
28
+ .sort((left, right) => left.localeCompare(right));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
@@ -1,3 +1,4 @@
1
+ import { ProfileError } from '../errors.js';
1
2
  import { createClaudeWriter } from './sync/claude-writer.js';
2
3
  import { createCodexWriter } from './sync/codex-writer.js';
3
4
  import { createGeminiWriter } from './sync/gemini-writer.js';
@@ -21,6 +22,10 @@ export function createSyncService(dependencies = {}) {
21
22
  throw new Error('No active profile set. Run "brainctl profile use <name>" first.');
22
23
  }
23
24
  const profile = await profileService.get({ cwd, name: meta.active_profile });
25
+ const remoteMcpName = Object.entries(profile.mcps).find(([, config]) => config.kind === 'remote')?.[0];
26
+ if (remoteMcpName) {
27
+ throw new ProfileError(`Profile "${profile.name}" includes remote MCP "${remoteMcpName}". Remote MCP sync is not supported yet.`);
28
+ }
24
29
  const results = [];
25
30
  for (const agent of meta.agents) {
26
31
  const writer = writers[agent];
@@ -0,0 +1 @@
1
+ export declare function findExecutable(command: string): Promise<string | null>;
@@ -0,0 +1,38 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ export async function findExecutable(command) {
5
+ if (command.includes(path.sep)) {
6
+ return (await isExecutable(command)) ? command : null;
7
+ }
8
+ const pathEntries = (process.env.PATH ?? '')
9
+ .split(path.delimiter)
10
+ .filter((entry) => entry.length > 0);
11
+ const extensions = process.platform === 'win32'
12
+ ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM')
13
+ .split(';')
14
+ .filter((entry) => entry.length > 0)
15
+ : [''];
16
+ for (const pathEntry of pathEntries) {
17
+ for (const extension of extensions) {
18
+ const candidate = process.platform === 'win32' &&
19
+ extension.length > 0 &&
20
+ !command.toLowerCase().endsWith(extension.toLowerCase())
21
+ ? path.join(pathEntry, `${command}${extension}`)
22
+ : path.join(pathEntry, command);
23
+ if (await isExecutable(candidate)) {
24
+ return candidate;
25
+ }
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+ async function isExecutable(filePath) {
31
+ try {
32
+ await access(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }