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
package/README.md CHANGED
@@ -112,6 +112,32 @@ brainctl run analyze ./report.md --with codex --fallback claude
112
112
  brainctl run review ./code.md --with gemini
113
113
  ```
114
114
 
115
+ ### Profile MCP Format
116
+
117
+ Packed/published profiles should classify every MCP as either `local` or `remote`.
118
+
119
+ ```yaml
120
+ mcps:
121
+ github:
122
+ kind: local
123
+ source: npm
124
+ package: "@modelcontextprotocol/server-github"
125
+
126
+ internal-docs:
127
+ kind: remote
128
+ transport: http
129
+ url: "https://mcp.example.com"
130
+ ```
131
+
132
+ Rules:
133
+
134
+ - local profile files may still use the older `type: npm` / `type: bundled` MCP shape
135
+ - `brainctl profile export` writes the packed profile using the explicit format below
136
+ - `local` MCPs must declare `source: npm` or `source: bundled`
137
+ - `remote` MCPs must declare `transport` and `url`
138
+ - bundled local MCPs must declare `path` and `command`
139
+ - `brainctl sync` currently supports local MCPs only; remote MCPs remain in the profile package but are not written into agent configs yet
140
+
115
141
  ---
116
142
 
117
143
  ## 🧠 Config: `ai-stack.yaml`
@@ -1,9 +1,7 @@
1
- import { access } from 'node:fs/promises';
2
- import { constants } from 'node:fs';
3
- import path from 'node:path';
4
1
  import { AgentNotAvailableError } from '../errors.js';
5
2
  import { ClaudeExecutor } from './claude.js';
6
3
  import { CodexExecutor } from './codex.js';
4
+ import { findExecutable } from '../system/executables.js';
7
5
  const SUPPORTED_AGENTS = ['claude', 'codex', 'gemini'];
8
6
  const AGENT_COMMANDS = {
9
7
  claude: 'claude',
@@ -60,38 +58,3 @@ async function checkAvailability(agentName) {
60
58
  resolvedPath: resolvedPath ?? undefined
61
59
  };
62
60
  }
63
- async function findExecutable(command) {
64
- if (command.includes(path.sep)) {
65
- return (await isExecutable(command)) ? command : null;
66
- }
67
- const pathEntries = (process.env.PATH ?? '')
68
- .split(path.delimiter)
69
- .filter((entry) => entry.length > 0);
70
- const extensions = process.platform === 'win32'
71
- ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM')
72
- .split(';')
73
- .filter((entry) => entry.length > 0)
74
- : [''];
75
- for (const pathEntry of pathEntries) {
76
- for (const extension of extensions) {
77
- const candidate = process.platform === 'win32' &&
78
- extension.length > 0 &&
79
- !command.toLowerCase().endsWith(extension.toLowerCase())
80
- ? path.join(pathEntry, `${command}${extension}`)
81
- : path.join(pathEntry, command);
82
- if (await isExecutable(candidate)) {
83
- return candidate;
84
- }
85
- }
86
- }
87
- return null;
88
- }
89
- async function isExecutable(filePath) {
90
- try {
91
- await access(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
92
- return true;
93
- }
94
- catch {
95
- return false;
96
- }
97
- }
@@ -6,6 +6,7 @@ import { loadConfig } from '../config.js';
6
6
  import { loadMemory } from '../context/memory.js';
7
7
  import { createAgentConfigService } from '../services/agent-config-service.js';
8
8
  import { createDoctorService } from '../services/doctor-service.js';
9
+ import { startUiServer } from '../ui/server.js';
9
10
  import { createMemoryWriteService } from '../services/memory-write-service.js';
10
11
  import { createProfileExportService } from '../services/profile-export-service.js';
11
12
  import { createProfileImportService } from '../services/profile-import-service.js';
@@ -317,6 +318,39 @@ export function createMcpServer(options = {}) {
317
318
  return JSON.stringify(result, null, 2);
318
319
  },
319
320
  });
321
+ let uiServerInstance = null;
322
+ server.addTool({
323
+ name: 'brainctl_open_ui',
324
+ description: 'Start the brainctl web dashboard. Returns the URL to open in a browser. If already running, returns the existing URL.',
325
+ parameters: z.object({
326
+ port: z.number().default(3333).describe('Port number for the UI server'),
327
+ }),
328
+ execute: async (args) => {
329
+ if (uiServerInstance) {
330
+ return JSON.stringify({ url: uiServerInstance.url, status: 'already_running' });
331
+ }
332
+ try {
333
+ uiServerInstance = await startUiServer({ cwd, port: args.port });
334
+ return JSON.stringify({ url: uiServerInstance.url, status: 'started' });
335
+ }
336
+ catch (err) {
337
+ return JSON.stringify({ error: err.message, status: 'failed' });
338
+ }
339
+ },
340
+ });
341
+ server.addTool({
342
+ name: 'brainctl_close_ui',
343
+ description: 'Stop the brainctl web dashboard if it is running.',
344
+ parameters: z.object({}),
345
+ execute: async () => {
346
+ if (!uiServerInstance) {
347
+ return JSON.stringify({ status: 'not_running' });
348
+ }
349
+ await uiServerInstance.close();
350
+ uiServerInstance = null;
351
+ return JSON.stringify({ status: 'stopped' });
352
+ },
353
+ });
320
354
  server.addTool({
321
355
  name: 'brainctl_read_agent_configs',
322
356
  description: 'Read the live MCP configs from all agents (Claude, Codex, Gemini). Shows what is actually configured in each agent right now, by reading their real config files.',
@@ -1,5 +1,7 @@
1
1
  import type { AgentName } from '../types.js';
2
2
  import { type AgentLiveConfig, type AgentMcpEntry } from './sync/agent-reader.js';
3
+ import { type McpPreflightService } from './mcp-preflight-service.js';
4
+ import { type SkillPreflightService } from './skill-preflight-service.js';
3
5
  export type { AgentLiveConfig, AgentMcpEntry, AgentSkillEntry } from './sync/agent-reader.js';
4
6
  export interface AgentConfigService {
5
7
  readAll(options: {
@@ -16,5 +18,18 @@ export interface AgentConfigService {
16
18
  agent: AgentName;
17
19
  key: string;
18
20
  }): Promise<void>;
21
+ copySkill(options: {
22
+ sourceAgent: AgentName;
23
+ targetAgent: AgentName;
24
+ skillName: string;
25
+ }): Promise<void>;
26
+ removeSkill(options: {
27
+ agent: AgentName;
28
+ skillName: string;
29
+ }): Promise<void>;
30
+ }
31
+ interface AgentConfigServiceDependencies {
32
+ mcpPreflightService?: McpPreflightService;
33
+ skillPreflightService?: SkillPreflightService;
19
34
  }
20
- export declare function createAgentConfigService(): AgentConfigService;
35
+ export declare function createAgentConfigService(dependencies?: AgentConfigServiceDependencies): AgentConfigService;
@@ -1,14 +1,20 @@
1
- import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { copyFile, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
+ import { ValidationError } from '../errors.js';
4
5
  import { createClaudeReader, createCodexReader, createGeminiReader, } from './sync/agent-reader.js';
5
6
  import { formatTimestamp } from './sync/agent-writer.js';
7
+ import { createMcpPreflightService } from './mcp-preflight-service.js';
8
+ import { createSkillPreflightService } from './skill-preflight-service.js';
9
+ import { getSkillDir } from './skill-paths.js';
6
10
  const readers = {
7
11
  claude: createClaudeReader(),
8
12
  codex: createCodexReader(),
9
13
  gemini: createGeminiReader(),
10
14
  };
11
- export function createAgentConfigService() {
15
+ export function createAgentConfigService(dependencies = {}) {
16
+ const mcpPreflightService = dependencies.mcpPreflightService ?? createMcpPreflightService();
17
+ const skillPreflightService = dependencies.skillPreflightService ?? createSkillPreflightService();
12
18
  return {
13
19
  async readAll(options) {
14
20
  const results = await Promise.all([
@@ -20,6 +26,11 @@ export function createAgentConfigService() {
20
26
  },
21
27
  async addMcp(options) {
22
28
  const { cwd, agent, key, entry } = options;
29
+ const preflight = await mcpPreflightService.execute({ cwd, agent, key, entry });
30
+ const firstError = preflight.checks.find((check) => check.status === 'error');
31
+ if (firstError) {
32
+ throw new ValidationError(`MCP "${key}" cannot be added to ${agent}: ${firstError.message}`);
33
+ }
23
34
  if (agent === 'claude') {
24
35
  await mutateClaudeConfig(cwd, (servers) => {
25
36
  servers[key] = toClaudeEntry(entry);
@@ -54,6 +65,28 @@ export function createAgentConfigService() {
54
65
  });
55
66
  }
56
67
  },
68
+ async copySkill(options) {
69
+ const { sourceAgent, targetAgent, skillName } = options;
70
+ const preflight = await skillPreflightService.execute({
71
+ sourceAgent,
72
+ targetAgent,
73
+ skillName,
74
+ source: 'local',
75
+ });
76
+ const firstError = preflight.checks.find((check) => check.status === 'error');
77
+ if (firstError) {
78
+ throw new ValidationError(`Skill "${skillName}" cannot be copied from ${sourceAgent} to ${targetAgent}: ${firstError.message}`);
79
+ }
80
+ const sourceDir = getSkillDir(sourceAgent, skillName);
81
+ const targetDir = getSkillDir(targetAgent, skillName);
82
+ await mkdir(path.dirname(targetDir), { recursive: true });
83
+ await cp(sourceDir, targetDir, { recursive: true });
84
+ },
85
+ async removeSkill(options) {
86
+ const { agent, skillName } = options;
87
+ const skillDir = getSkillDir(agent, skillName);
88
+ await rm(skillDir, { recursive: true, force: true });
89
+ },
57
90
  };
58
91
  }
59
92
  /* ---- Claude: JSON with projects[cwd].mcpServers ---- */
@@ -0,0 +1,25 @@
1
+ import type { AgentName } from '../types.js';
2
+ import type { AgentMcpEntry } from './agent-config-service.js';
3
+ export interface McpPreflightCheck {
4
+ label: string;
5
+ status: 'ok' | 'warn' | 'error';
6
+ message: string;
7
+ }
8
+ export interface McpPreflightResult {
9
+ ok: boolean;
10
+ checks: McpPreflightCheck[];
11
+ }
12
+ export interface McpPreflightService {
13
+ execute(options: {
14
+ cwd: string;
15
+ agent: AgentName;
16
+ key: string;
17
+ entry: AgentMcpEntry;
18
+ }): Promise<McpPreflightResult>;
19
+ }
20
+ interface McpPreflightDependencies {
21
+ resolveExecutable?: (command: string) => Promise<string | null>;
22
+ pathExists?: (targetPath: string) => Promise<boolean>;
23
+ }
24
+ export declare function createMcpPreflightService(dependencies?: McpPreflightDependencies): McpPreflightService;
25
+ export {};
@@ -0,0 +1,84 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { findExecutable } from '../system/executables.js';
4
+ const SCRIPT_RUNNERS = new Set(['node', 'nodejs', 'python', 'python3', 'bash', 'sh', 'zsh', 'deno', 'bun']);
5
+ export function createMcpPreflightService(dependencies = {}) {
6
+ const resolveExecutable = dependencies.resolveExecutable ?? findExecutable;
7
+ const pathExists = dependencies.pathExists ?? defaultPathExists;
8
+ return {
9
+ async execute(options) {
10
+ const checks = [];
11
+ const resolvedCommand = await resolveExecutable(options.entry.command);
12
+ if (!resolvedCommand) {
13
+ checks.push({
14
+ label: 'Command',
15
+ status: 'error',
16
+ message: `Command "${options.entry.command}" is not available on PATH.`,
17
+ });
18
+ return { ok: false, checks };
19
+ }
20
+ checks.push({
21
+ label: 'Command',
22
+ status: 'ok',
23
+ message: `Command "${options.entry.command}" resolved to ${resolvedCommand}.`,
24
+ });
25
+ if (options.entry.command === 'npx') {
26
+ const nonFlagArg = options.entry.args?.find((arg) => !arg.startsWith('-'));
27
+ if (!nonFlagArg) {
28
+ checks.push({
29
+ label: 'Package',
30
+ status: 'error',
31
+ message: 'npx-based MCP entries must include a package or executable argument.',
32
+ });
33
+ }
34
+ else {
35
+ checks.push({
36
+ label: 'Package',
37
+ status: 'ok',
38
+ message: `npx will attempt to launch ${nonFlagArg}.`,
39
+ });
40
+ }
41
+ }
42
+ const entrypointPath = resolveEntrypointPath(options.cwd, options.entry);
43
+ if (entrypointPath) {
44
+ const exists = await pathExists(entrypointPath);
45
+ checks.push({
46
+ label: 'Entrypoint',
47
+ status: exists ? 'ok' : 'error',
48
+ message: exists
49
+ ? `Entrypoint script was found: ${entrypointPath}`
50
+ : `Entrypoint script was not found: ${entrypointPath}`,
51
+ });
52
+ }
53
+ return {
54
+ ok: checks.every((check) => check.status !== 'error'),
55
+ checks,
56
+ };
57
+ },
58
+ };
59
+ }
60
+ function resolveEntrypointPath(cwd, entry) {
61
+ if (!SCRIPT_RUNNERS.has(entry.command)) {
62
+ return null;
63
+ }
64
+ const firstArg = entry.args?.[0];
65
+ if (!firstArg || !looksLikeLocalPath(firstArg)) {
66
+ return null;
67
+ }
68
+ return path.isAbsolute(firstArg) ? firstArg : path.resolve(cwd, firstArg);
69
+ }
70
+ function looksLikeLocalPath(value) {
71
+ return (value.startsWith('.') ||
72
+ value.startsWith('/') ||
73
+ value.includes(path.sep) ||
74
+ /\.(cjs|cts|js|json|jsx|mjs|mts|py|sh|ts|tsx)$/i.test(value));
75
+ }
76
+ async function defaultPathExists(targetPath) {
77
+ try {
78
+ await stat(targetPath);
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
@@ -0,0 +1,92 @@
1
+ import type { AgentName } from '../types.js';
2
+ import type { AgentLiveConfig, AgentMcpEntry, AgentSkillEntry } from './agent-config-service.js';
3
+ export interface PluginInstallCheck {
4
+ label: string;
5
+ status: 'ok' | 'warn' | 'error';
6
+ message: string;
7
+ }
8
+ export interface PluginInstallPlan {
9
+ ok: boolean;
10
+ checks: PluginInstallCheck[];
11
+ skills: string[];
12
+ mcps: Record<string, AgentMcpEntry>;
13
+ }
14
+ export interface PluginInstallResult {
15
+ installedSkills: string[];
16
+ installedMcps: string[];
17
+ }
18
+ export interface PluginUninstallPlan {
19
+ ok: boolean;
20
+ checks: PluginInstallCheck[];
21
+ skills: string[];
22
+ mcps: string[];
23
+ }
24
+ export interface PluginUninstallResult {
25
+ removedSkills: string[];
26
+ removedMcps: string[];
27
+ }
28
+ export interface PluginInstallService {
29
+ plan(options: {
30
+ cwd: string;
31
+ targetAgent: AgentName;
32
+ sourceAgent: AgentName;
33
+ plugin: AgentSkillEntry;
34
+ }): Promise<PluginInstallPlan>;
35
+ execute(options: {
36
+ cwd: string;
37
+ targetAgent: AgentName;
38
+ sourceAgent: AgentName;
39
+ plugin: AgentSkillEntry;
40
+ }): Promise<PluginInstallResult>;
41
+ planRemoval(options: {
42
+ cwd: string;
43
+ targetAgent: AgentName;
44
+ plugin: AgentSkillEntry;
45
+ }): Promise<PluginUninstallPlan>;
46
+ remove(options: {
47
+ cwd: string;
48
+ targetAgent: AgentName;
49
+ plugin: AgentSkillEntry;
50
+ }): Promise<PluginUninstallResult>;
51
+ }
52
+ interface PluginBundle {
53
+ skills: string[];
54
+ mcps: Record<string, AgentMcpEntry>;
55
+ }
56
+ interface PluginInstallDependencies {
57
+ readInstalledPluginBundle?: (installPath: string) => Promise<PluginBundle>;
58
+ readTargetState?: (options: {
59
+ cwd: string;
60
+ agent: AgentName;
61
+ }) => Promise<Pick<AgentLiveConfig, 'skills' | 'mcpServers'>>;
62
+ copySkillDirectory?: (options: {
63
+ sourceInstallPath: string;
64
+ skillName: string;
65
+ targetAgent: AgentName;
66
+ }) => Promise<void>;
67
+ addMcpEntry?: (options: {
68
+ cwd: string;
69
+ agent: AgentName;
70
+ key: string;
71
+ entry: AgentMcpEntry;
72
+ }) => Promise<void>;
73
+ recordManagedPluginInstall?: (options: {
74
+ agent: AgentName;
75
+ plugin: AgentSkillEntry;
76
+ }) => Promise<void>;
77
+ removeSkillDirectory?: (options: {
78
+ targetAgent: AgentName;
79
+ skillName: string;
80
+ }) => Promise<void>;
81
+ removeMcpEntry?: (options: {
82
+ cwd: string;
83
+ agent: AgentName;
84
+ key: string;
85
+ }) => Promise<void>;
86
+ removeManagedPluginInstall?: (options: {
87
+ agent: AgentName;
88
+ pluginName: string;
89
+ }) => Promise<void>;
90
+ }
91
+ export declare function createPluginInstallService(dependencies?: PluginInstallDependencies): PluginInstallService;
92
+ export {};
@@ -0,0 +1,243 @@
1
+ import { cp, mkdir, readFile, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ValidationError } from '../errors.js';
4
+ import { createAgentConfigService } from './agent-config-service.js';
5
+ import { getSkillDir } from './skill-paths.js';
6
+ import { removeManagedPluginInstall, writeManagedPluginInstall, } from './sync/managed-plugin-registry.js';
7
+ export function createPluginInstallService(dependencies = {}) {
8
+ const agentConfigService = createAgentConfigService();
9
+ const readInstalledPluginBundle = dependencies.readInstalledPluginBundle ?? defaultReadInstalledPluginBundle;
10
+ const readTargetState = dependencies.readTargetState ?? (async ({ cwd, agent }) => {
11
+ const configs = await agentConfigService.readAll({ cwd });
12
+ const match = configs.find((config) => config.agent === agent);
13
+ return {
14
+ skills: match?.skills ?? [],
15
+ mcpServers: match?.mcpServers ?? {},
16
+ };
17
+ });
18
+ const copySkillDirectory = dependencies.copySkillDirectory ?? defaultCopySkillDirectory;
19
+ const addMcpEntry = dependencies.addMcpEntry ?? (async ({ cwd, agent, key, entry }) => {
20
+ await agentConfigService.addMcp({ cwd, agent, key, entry });
21
+ });
22
+ const recordManagedPluginInstall = dependencies.recordManagedPluginInstall ??
23
+ (async ({ agent, plugin }) => {
24
+ await writeManagedPluginInstall({ agent, plugin });
25
+ });
26
+ const removeSkillDirectory = dependencies.removeSkillDirectory ?? defaultRemoveSkillDirectory;
27
+ const removeMcpEntry = dependencies.removeMcpEntry ?? (async ({ cwd, agent, key }) => {
28
+ await agentConfigService.removeMcp({ cwd, agent, key });
29
+ });
30
+ const removeRecordedManagedPluginInstall = dependencies.removeManagedPluginInstall ??
31
+ (async ({ agent, pluginName }) => {
32
+ await removeManagedPluginInstall({ agent, pluginName });
33
+ });
34
+ return {
35
+ async plan(options) {
36
+ const checks = [];
37
+ if (options.plugin.kind !== 'plugin' || !options.plugin.installPath) {
38
+ checks.push({
39
+ label: 'Source plugin',
40
+ status: 'error',
41
+ message: `Plugin "${options.plugin.name}" is missing an install path and cannot be installed as a bundle.`,
42
+ });
43
+ return { ok: false, checks, skills: [], mcps: {} };
44
+ }
45
+ const bundle = await readInstalledPluginBundle(options.plugin.installPath);
46
+ const targetState = await readTargetState({
47
+ cwd: options.cwd,
48
+ agent: options.targetAgent,
49
+ });
50
+ checks.push({
51
+ label: 'Bundle',
52
+ status: 'ok',
53
+ message: `Discovered ${bundle.skills.length} skills and ${Object.keys(bundle.mcps).length} MCPs in plugin "${options.plugin.name}".`,
54
+ });
55
+ if (bundle.skills.length === 0 && Object.keys(bundle.mcps).length === 0) {
56
+ checks.push({
57
+ label: 'Bundle',
58
+ status: 'error',
59
+ message: `Plugin "${options.plugin.name}" does not expose portable skills or MCPs for installation.`,
60
+ });
61
+ }
62
+ for (const skillName of bundle.skills) {
63
+ if (targetState.skills.some((skill) => skill.name === skillName)) {
64
+ checks.push({
65
+ label: 'Target skill',
66
+ status: 'error',
67
+ message: `Skill "${skillName}" already exists in ${options.targetAgent}.`,
68
+ });
69
+ }
70
+ }
71
+ for (const key of Object.keys(bundle.mcps)) {
72
+ if (targetState.mcpServers[key]) {
73
+ checks.push({
74
+ label: 'Target MCP',
75
+ status: 'error',
76
+ message: `MCP "${key}" already exists in ${options.targetAgent}.`,
77
+ });
78
+ }
79
+ }
80
+ return {
81
+ ok: checks.every((check) => check.status !== 'error'),
82
+ checks,
83
+ skills: bundle.skills,
84
+ mcps: bundle.mcps,
85
+ };
86
+ },
87
+ async execute(options) {
88
+ const plan = await this.plan(options);
89
+ if (!plan.ok) {
90
+ const firstError = plan.checks.find((check) => check.status === 'error');
91
+ throw new ValidationError(firstError?.message ?? 'Plugin install plan failed.');
92
+ }
93
+ const installPath = options.plugin.installPath;
94
+ for (const skillName of plan.skills) {
95
+ await copySkillDirectory({
96
+ sourceInstallPath: installPath,
97
+ skillName,
98
+ targetAgent: options.targetAgent,
99
+ });
100
+ }
101
+ for (const [key, entry] of Object.entries(plan.mcps)) {
102
+ await addMcpEntry({
103
+ cwd: options.cwd,
104
+ agent: options.targetAgent,
105
+ key,
106
+ entry,
107
+ });
108
+ }
109
+ await recordManagedPluginInstall({
110
+ agent: options.targetAgent,
111
+ plugin: {
112
+ ...options.plugin,
113
+ kind: 'plugin',
114
+ pluginSkills: plan.skills,
115
+ pluginMcps: Object.keys(plan.mcps),
116
+ managed: true,
117
+ },
118
+ });
119
+ return {
120
+ installedSkills: plan.skills,
121
+ installedMcps: Object.keys(plan.mcps),
122
+ };
123
+ },
124
+ async planRemoval(options) {
125
+ const checks = [];
126
+ if (options.plugin.kind !== 'plugin') {
127
+ checks.push({
128
+ label: 'Target plugin',
129
+ status: 'error',
130
+ message: `"${options.plugin.name}" is not a plugin entry.`,
131
+ });
132
+ return { ok: false, checks, skills: [], mcps: [] };
133
+ }
134
+ if (!options.plugin.managed) {
135
+ checks.push({
136
+ label: 'Target plugin',
137
+ status: 'error',
138
+ message: `Only Brainctl-managed plugin installs can be removed today. "${options.plugin.name}" is not managed by Brainctl on ${options.targetAgent}.`,
139
+ });
140
+ return { ok: false, checks, skills: [], mcps: [] };
141
+ }
142
+ let skills = [...(options.plugin.pluginSkills ?? [])];
143
+ let mcps = [...(options.plugin.pluginMcps ?? [])];
144
+ if ((skills.length === 0 || mcps.length === 0) && options.plugin.installPath) {
145
+ const bundle = await readInstalledPluginBundle(options.plugin.installPath);
146
+ if (skills.length === 0) {
147
+ skills = bundle.skills;
148
+ }
149
+ if (mcps.length === 0) {
150
+ mcps = Object.keys(bundle.mcps);
151
+ }
152
+ }
153
+ checks.push({
154
+ label: 'Bundle',
155
+ status: 'ok',
156
+ message: `Will remove ${skills.length} skills and ${mcps.length} MCPs from plugin "${options.plugin.name}".`,
157
+ });
158
+ return {
159
+ ok: true,
160
+ checks,
161
+ skills,
162
+ mcps,
163
+ };
164
+ },
165
+ async remove(options) {
166
+ const plan = await this.planRemoval(options);
167
+ if (!plan.ok) {
168
+ const firstError = plan.checks.find((check) => check.status === 'error');
169
+ throw new ValidationError(firstError?.message ?? 'Plugin removal plan failed.');
170
+ }
171
+ for (const skillName of plan.skills) {
172
+ await removeSkillDirectory({
173
+ targetAgent: options.targetAgent,
174
+ skillName,
175
+ });
176
+ }
177
+ for (const key of plan.mcps) {
178
+ await removeMcpEntry({
179
+ cwd: options.cwd,
180
+ agent: options.targetAgent,
181
+ key,
182
+ });
183
+ }
184
+ await removeRecordedManagedPluginInstall({
185
+ agent: options.targetAgent,
186
+ pluginName: options.plugin.name,
187
+ });
188
+ return {
189
+ removedSkills: plan.skills,
190
+ removedMcps: plan.mcps,
191
+ };
192
+ },
193
+ };
194
+ }
195
+ async function defaultReadInstalledPluginBundle(installPath) {
196
+ const skillsDir = path.join(installPath, 'skills');
197
+ let skills = [];
198
+ try {
199
+ const { readdir } = await import('node:fs/promises');
200
+ const entries = await readdir(skillsDir, { withFileTypes: true });
201
+ skills = entries
202
+ .filter((entry) => !entry.name.startsWith('.') && entry.isDirectory())
203
+ .map((entry) => entry.name)
204
+ .sort((left, right) => left.localeCompare(right));
205
+ }
206
+ catch {
207
+ skills = [];
208
+ }
209
+ let mcps = {};
210
+ try {
211
+ const mcpSource = await readFile(path.join(installPath, '.mcp.json'), 'utf8');
212
+ const parsed = JSON.parse(mcpSource);
213
+ mcps = Object.fromEntries(Object.entries(parsed)
214
+ .filter(([, value]) => typeof value?.command === 'string')
215
+ .map(([key, value]) => [
216
+ key,
217
+ {
218
+ command: String(value.command),
219
+ args: Array.isArray(value.args) ? value.args.map(String) : undefined,
220
+ env: value.env && typeof value.env === 'object' && !Array.isArray(value.env)
221
+ ? Object.fromEntries(Object.entries(value.env).map(([envKey, envValue]) => [
222
+ envKey,
223
+ String(envValue),
224
+ ]))
225
+ : undefined,
226
+ },
227
+ ]));
228
+ }
229
+ catch {
230
+ mcps = {};
231
+ }
232
+ return { skills, mcps };
233
+ }
234
+ async function defaultCopySkillDirectory(options) {
235
+ const sourceDir = path.join(options.sourceInstallPath, 'skills', options.skillName);
236
+ const targetDir = getSkillDir(options.targetAgent, options.skillName);
237
+ await mkdir(path.dirname(targetDir), { recursive: true });
238
+ await cp(sourceDir, targetDir, { recursive: true });
239
+ }
240
+ async function defaultRemoveSkillDirectory(options) {
241
+ const targetDir = getSkillDir(options.targetAgent, options.skillName);
242
+ await rm(targetDir, { recursive: true, force: true });
243
+ }