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.
- package/README.md +26 -0
- package/dist/executor/resolver.js +1 -38
- package/dist/mcp/server.js +34 -0
- package/dist/services/agent-config-service.d.ts +16 -1
- package/dist/services/agent-config-service.js +35 -2
- package/dist/services/mcp-preflight-service.d.ts +25 -0
- package/dist/services/mcp-preflight-service.js +84 -0
- package/dist/services/plugin-install-service.d.ts +92 -0
- package/dist/services/plugin-install-service.js +243 -0
- package/dist/services/profile-export-service.js +5 -5
- package/dist/services/profile-import-service.js +1 -1
- package/dist/services/profile-service.d.ts +1 -0
- package/dist/services/profile-service.js +117 -32
- package/dist/services/skill-paths.d.ts +2 -0
- package/dist/services/skill-paths.js +12 -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 +5 -0
- package/dist/services/sync/agent-reader.js +26 -15
- package/dist/services/sync/claude-writer.js +4 -1
- package/dist/services/sync/codex-writer.js +6 -2
- package/dist/services/sync/gemini-writer.js +4 -1
- 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 +2 -0
- package/dist/services/sync/plugin-skill-reader.js +33 -0
- package/dist/services/sync-service.js +5 -0
- package/dist/system/executables.d.ts +1 -0
- package/dist/system/executables.js +38 -0
- package/dist/types.d.ts +15 -5
- package/dist/ui/routes.js +264 -6
- package/dist/web/assets/index-BCkorugl.css +1 -0
- package/dist/web/assets/index-sGnTMhkX.js +16 -0
- package/dist/web/index.html +2 -2
- package/package.json +5 -1
- package/dist/web/assets/index-364NYWPA.css +0 -1
- 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
|
-
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
+
}
|