brainctl 0.1.5 → 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 +181 -131
- package/dist/executor/resolver.js +1 -38
- package/dist/mcp/server.js +183 -0
- package/dist/services/agent-config-service.d.ts +35 -0
- package/dist/services/agent-config-service.js +222 -0
- 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 +10 -0
- package/dist/services/profile-service.js +140 -28
- 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 +30 -0
- package/dist/services/sync/agent-reader.js +232 -0
- 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 +423 -1
- 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 +7 -1
- package/dist/web/assets/index-CRJ6cM0Q.css +0 -1
- package/dist/web/assets/index-Cr8gt3VF.js +0 -9
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -17,6 +17,15 @@ export interface ProfileService {
|
|
|
17
17
|
}): Promise<{
|
|
18
18
|
profilePath: string;
|
|
19
19
|
}>;
|
|
20
|
+
update(options: {
|
|
21
|
+
cwd?: string;
|
|
22
|
+
name: string;
|
|
23
|
+
config: ProfileConfig;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
delete(options: {
|
|
26
|
+
cwd?: string;
|
|
27
|
+
name: string;
|
|
28
|
+
}): Promise<void>;
|
|
20
29
|
use(options: {
|
|
21
30
|
cwd?: string;
|
|
22
31
|
name: string;
|
|
@@ -29,3 +38,4 @@ export interface ProfileService {
|
|
|
29
38
|
}
|
|
30
39
|
export declare function createProfileService(): ProfileService;
|
|
31
40
|
export declare function parseProfile(source: string, name: string): ProfileConfig;
|
|
41
|
+
export declare function normalizeProfileConfig(value: unknown, name: string): ProfileConfig;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { readdir, readFile, writeFile, mkdir, stat, unlink } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import YAML from 'yaml';
|
|
4
4
|
import { ProfileError, ProfileNotFoundError } from '../errors.js';
|
|
@@ -64,6 +64,34 @@ export function createProfileService() {
|
|
|
64
64
|
await writeFile(profilePath, YAML.stringify(scaffold), 'utf8');
|
|
65
65
|
return { profilePath };
|
|
66
66
|
},
|
|
67
|
+
async update(options) {
|
|
68
|
+
const cwd = options.cwd ?? process.cwd();
|
|
69
|
+
const profilePath = path.join(cwd, PROFILES_DIR, `${options.name}.yaml`);
|
|
70
|
+
if (!(await pathExists(profilePath))) {
|
|
71
|
+
throw new ProfileNotFoundError(`Profile "${options.name}" not found.`);
|
|
72
|
+
}
|
|
73
|
+
const normalized = normalizeProfileConfig(options.config, options.name);
|
|
74
|
+
const data = {
|
|
75
|
+
name: normalized.name,
|
|
76
|
+
...(normalized.description ? { description: normalized.description } : {}),
|
|
77
|
+
skills: normalized.skills,
|
|
78
|
+
mcps: normalized.mcps,
|
|
79
|
+
memory: normalized.memory,
|
|
80
|
+
};
|
|
81
|
+
await writeFile(profilePath, YAML.stringify(data), 'utf8');
|
|
82
|
+
},
|
|
83
|
+
async delete(options) {
|
|
84
|
+
const cwd = options.cwd ?? process.cwd();
|
|
85
|
+
const profilePath = path.join(cwd, PROFILES_DIR, `${options.name}.yaml`);
|
|
86
|
+
if (!(await pathExists(profilePath))) {
|
|
87
|
+
throw new ProfileNotFoundError(`Profile "${options.name}" not found.`);
|
|
88
|
+
}
|
|
89
|
+
const meta = await loadMetaConfig(cwd);
|
|
90
|
+
if (meta.active_profile === options.name) {
|
|
91
|
+
throw new ProfileError('Cannot delete the active profile.');
|
|
92
|
+
}
|
|
93
|
+
await unlink(profilePath);
|
|
94
|
+
},
|
|
67
95
|
async use(options) {
|
|
68
96
|
const cwd = options.cwd ?? process.cwd();
|
|
69
97
|
// Validate profile exists
|
|
@@ -110,7 +138,13 @@ export function parseProfile(source, name) {
|
|
|
110
138
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
111
139
|
throw new ProfileError(`Profile "${name}" has invalid structure.`);
|
|
112
140
|
}
|
|
113
|
-
|
|
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;
|
|
114
148
|
const skills = {};
|
|
115
149
|
if (data.skills && typeof data.skills === 'object' && !Array.isArray(data.skills)) {
|
|
116
150
|
for (const [key, value] of Object.entries(data.skills)) {
|
|
@@ -125,31 +159,7 @@ export function parseProfile(source, name) {
|
|
|
125
159
|
}
|
|
126
160
|
}
|
|
127
161
|
}
|
|
128
|
-
const mcps =
|
|
129
|
-
if (data.mcps && typeof data.mcps === 'object' && !Array.isArray(data.mcps)) {
|
|
130
|
-
for (const [key, value] of Object.entries(data.mcps)) {
|
|
131
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
132
|
-
const m = value;
|
|
133
|
-
if (m.type === 'npm' && typeof m.package === 'string') {
|
|
134
|
-
mcps[key] = {
|
|
135
|
-
type: 'npm',
|
|
136
|
-
package: m.package,
|
|
137
|
-
env: parseEnv(m.env),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
else if (m.type === 'bundled' && typeof m.command === 'string') {
|
|
141
|
-
mcps[key] = {
|
|
142
|
-
type: 'bundled',
|
|
143
|
-
path: typeof m.path === 'string' ? m.path : '.',
|
|
144
|
-
install: typeof m.install === 'string' ? m.install : undefined,
|
|
145
|
-
command: m.command,
|
|
146
|
-
args: Array.isArray(m.args) ? m.args.map(String) : undefined,
|
|
147
|
-
env: parseEnv(m.env),
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
162
|
+
const mcps = normalizeMcps(data.mcps, name);
|
|
153
163
|
const memoryPaths = [];
|
|
154
164
|
if (data.memory && typeof data.memory === 'object' && !Array.isArray(data.memory)) {
|
|
155
165
|
const mem = data.memory;
|
|
@@ -169,7 +179,102 @@ export function parseProfile(source, name) {
|
|
|
169
179
|
memory: { paths: memoryPaths },
|
|
170
180
|
};
|
|
171
181
|
}
|
|
172
|
-
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) {
|
|
173
278
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
174
279
|
return undefined;
|
|
175
280
|
}
|
|
@@ -179,6 +284,13 @@ function parseEnv(value) {
|
|
|
179
284
|
}
|
|
180
285
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
181
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
|
+
}
|
|
182
294
|
async function pathExists(targetPath) {
|
|
183
295
|
try {
|
|
184
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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AgentName } from '../../types.js';
|
|
2
|
+
export interface AgentMcpEntry {
|
|
3
|
+
command: string;
|
|
4
|
+
args?: string[];
|
|
5
|
+
env?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export interface AgentSkillEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
source?: string;
|
|
10
|
+
kind?: 'skill' | 'plugin';
|
|
11
|
+
pluginSkills?: string[];
|
|
12
|
+
pluginMcps?: string[];
|
|
13
|
+
installPath?: string;
|
|
14
|
+
managed?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface AgentLiveConfig {
|
|
17
|
+
agent: AgentName;
|
|
18
|
+
configPath: string;
|
|
19
|
+
exists: boolean;
|
|
20
|
+
mcpServers: Record<string, AgentMcpEntry>;
|
|
21
|
+
skills: AgentSkillEntry[];
|
|
22
|
+
}
|
|
23
|
+
export interface AgentConfigReader {
|
|
24
|
+
read(options: {
|
|
25
|
+
cwd: string;
|
|
26
|
+
}): Promise<AgentLiveConfig>;
|
|
27
|
+
}
|
|
28
|
+
export declare function createClaudeReader(): AgentConfigReader;
|
|
29
|
+
export declare function createCodexReader(): AgentConfigReader;
|
|
30
|
+
export declare function createGeminiReader(): AgentConfigReader;
|