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
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
75
|
-
...(
|
|
76
|
-
skills:
|
|
77
|
-
mcps:
|
|
78
|
-
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
|
-
|
|
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
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,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
|
+
}
|