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.
Files changed (37) hide show
  1. package/README.md +181 -131
  2. package/dist/executor/resolver.js +1 -38
  3. package/dist/mcp/server.js +183 -0
  4. package/dist/services/agent-config-service.d.ts +35 -0
  5. package/dist/services/agent-config-service.js +222 -0
  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 +10 -0
  13. package/dist/services/profile-service.js +140 -28
  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 +30 -0
  19. package/dist/services/sync/agent-reader.js +232 -0
  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 +423 -1
  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 +7 -1
  36. package/dist/web/assets/index-CRJ6cM0Q.css +0 -1
  37. 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.type === 'bundled') {
33
+ if (mcp.kind === 'local' && mcp.source === 'bundled') {
34
34
  const sourcePath = path.isAbsolute(mcp.path)
35
35
  ? mcp.path
36
36
  : path.resolve(cwd, mcp.path);
@@ -41,17 +41,17 @@ async function stageProfile(profile, cwd, stagingDir) {
41
41
  filter: (src) => !src.includes('node_modules'),
42
42
  });
43
43
  exportMcps[name] = {
44
- type: 'bundled',
44
+ kind: 'local',
45
+ source: 'bundled',
45
46
  path: `./mcps/${name}`,
46
47
  ...(mcp.install ? { install: mcp.install } : {}),
47
48
  command: mcp.command,
48
49
  ...(mcp.args ? { args: mcp.args } : {}),
49
50
  ...(mcp.env ? { env: mcp.env } : {}),
50
51
  };
52
+ continue;
51
53
  }
52
- else {
53
- exportMcps[name] = mcp;
54
- }
54
+ exportMcps[name] = mcp;
55
55
  }
56
56
  return {
57
57
  name: profile.name,
@@ -39,7 +39,7 @@ export function createProfileImportService() {
39
39
  const installedMcps = [];
40
40
  const mcpsBaseDir = path.join(cwd, PROFILES_DIR, profileName, 'mcps');
41
41
  for (const [name, mcp] of Object.entries(profile.mcps)) {
42
- if (mcp.type !== 'bundled')
42
+ if (!(mcp.kind === 'local' && mcp.source === 'bundled'))
43
43
  continue;
44
44
  const extractedMcpPath = path.join(extractDir, 'mcps', name);
45
45
  const destMcpPath = path.join(mcpsBaseDir, name);
@@ -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
- const data = parsed;
141
+ return normalizeProfileConfig(parsed, name);
142
+ }
143
+ export function normalizeProfileConfig(value, name) {
144
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
145
+ throw new ProfileError(`Profile "${name}" has invalid structure.`);
146
+ }
147
+ const data = value;
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 parseEnv(value) {
182
+ function normalizeMcps(value, profileName) {
183
+ if (value === undefined || value === null) {
184
+ return {};
185
+ }
186
+ if (typeof value !== 'object' || Array.isArray(value)) {
187
+ throw new ProfileError(`Profile "${profileName}" has an invalid "mcps" section.`);
188
+ }
189
+ const mcps = {};
190
+ for (const [key, rawValue] of Object.entries(value)) {
191
+ if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
192
+ throw new ProfileError(`MCP "${key}" must be an object.`);
193
+ }
194
+ const mcp = rawValue;
195
+ // Local profile files may still use the older type-based shape.
196
+ if (mcp.type === 'npm') {
197
+ if (typeof mcp.package !== 'string' || mcp.package.trim().length === 0) {
198
+ throw new ProfileError(`Local MCP "${key}" must include a non-empty package.`);
199
+ }
200
+ mcps[key] = {
201
+ kind: 'local',
202
+ source: 'npm',
203
+ package: mcp.package,
204
+ env: parseStringMap(mcp.env),
205
+ };
206
+ continue;
207
+ }
208
+ if (mcp.type === 'bundled') {
209
+ if (typeof mcp.path !== 'string' ||
210
+ mcp.path.trim().length === 0 ||
211
+ typeof mcp.command !== 'string' ||
212
+ mcp.command.trim().length === 0) {
213
+ throw new ProfileError(`Bundled local MCP "${key}" must include non-empty path and command fields.`);
214
+ }
215
+ mcps[key] = {
216
+ kind: 'local',
217
+ source: 'bundled',
218
+ path: mcp.path,
219
+ install: typeof mcp.install === 'string' ? mcp.install : undefined,
220
+ command: mcp.command,
221
+ args: parseStringArray(mcp.args),
222
+ env: parseStringMap(mcp.env),
223
+ };
224
+ continue;
225
+ }
226
+ if (mcp.kind !== 'local' && mcp.kind !== 'remote') {
227
+ throw new ProfileError(`MCP "${key}" must declare kind "local" or "remote".`);
228
+ }
229
+ if (mcp.kind === 'remote') {
230
+ if ((mcp.transport !== 'http' && mcp.transport !== 'sse') ||
231
+ typeof mcp.url !== 'string' ||
232
+ mcp.url.trim().length === 0) {
233
+ throw new ProfileError(`Remote MCP "${key}" must include transport ("http" or "sse") and a url.`);
234
+ }
235
+ mcps[key] = {
236
+ kind: 'remote',
237
+ transport: mcp.transport,
238
+ url: mcp.url,
239
+ headers: parseStringMap(mcp.headers),
240
+ env: parseStringMap(mcp.env),
241
+ };
242
+ continue;
243
+ }
244
+ if (mcp.source !== 'npm' && mcp.source !== 'bundled') {
245
+ throw new ProfileError(`Local MCP "${key}" must declare source "npm" or "bundled".`);
246
+ }
247
+ if (mcp.source === 'npm') {
248
+ if (typeof mcp.package !== 'string' || mcp.package.trim().length === 0) {
249
+ throw new ProfileError(`Local MCP "${key}" must include a non-empty package.`);
250
+ }
251
+ mcps[key] = {
252
+ kind: 'local',
253
+ source: 'npm',
254
+ package: mcp.package,
255
+ env: parseStringMap(mcp.env),
256
+ };
257
+ continue;
258
+ }
259
+ if (typeof mcp.path !== 'string' ||
260
+ mcp.path.trim().length === 0 ||
261
+ typeof mcp.command !== 'string' ||
262
+ mcp.command.trim().length === 0) {
263
+ throw new ProfileError(`Bundled local MCP "${key}" must include non-empty path and command fields.`);
264
+ }
265
+ mcps[key] = {
266
+ kind: 'local',
267
+ source: 'bundled',
268
+ path: mcp.path,
269
+ install: typeof mcp.install === 'string' ? mcp.install : undefined,
270
+ command: mcp.command,
271
+ args: parseStringArray(mcp.args),
272
+ env: parseStringMap(mcp.env),
273
+ };
274
+ }
275
+ return mcps;
276
+ }
277
+ function parseStringMap(value) {
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,2 @@
1
+ import type { AgentName } from '../types.js';
2
+ export declare function getSkillDir(agent: AgentName, skillName: string): string;
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+ export function getSkillDir(agent, skillName) {
4
+ const safeName = path.basename(skillName);
5
+ if (agent === 'claude')
6
+ return path.join(homedir(), '.claude', 'skills', safeName);
7
+ if (agent === 'codex')
8
+ return path.join(homedir(), '.codex', 'skills', safeName);
9
+ if (agent === 'gemini')
10
+ return path.join(homedir(), '.gemini', 'skills', safeName);
11
+ throw new Error(`Skill management is not supported for ${agent}`);
12
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentName } from '../types.js';
2
+ export interface SkillPreflightCheck {
3
+ label: string;
4
+ status: 'ok' | 'warn' | 'error';
5
+ message: string;
6
+ }
7
+ export interface SkillPreflightResult {
8
+ ok: boolean;
9
+ checks: SkillPreflightCheck[];
10
+ }
11
+ export interface SkillPreflightService {
12
+ execute(options: {
13
+ sourceAgent: AgentName;
14
+ targetAgent: AgentName;
15
+ skillName: string;
16
+ source?: string;
17
+ }): Promise<SkillPreflightResult>;
18
+ }
19
+ interface SkillPreflightDependencies {
20
+ pathExists?: (targetPath: string) => Promise<boolean>;
21
+ }
22
+ export declare function createSkillPreflightService(dependencies?: SkillPreflightDependencies): SkillPreflightService;
23
+ export {};
@@ -0,0 +1,40 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { getSkillDir } from './skill-paths.js';
3
+ export function createSkillPreflightService(dependencies = {}) {
4
+ const pathExists = dependencies.pathExists ?? defaultPathExists;
5
+ return {
6
+ async execute(options) {
7
+ const checks = [];
8
+ if (options.source && options.source !== 'local' && options.source !== 'linked') {
9
+ checks.push({
10
+ label: 'Source',
11
+ status: 'error',
12
+ message: `Only local skill folders can be copied today. "${options.skillName}" is a plugin/managed entry from ${options.source}.`,
13
+ });
14
+ return { ok: false, checks };
15
+ }
16
+ const sourceDir = getSkillDir(options.sourceAgent, options.skillName);
17
+ const exists = await pathExists(sourceDir);
18
+ checks.push({
19
+ label: 'Source',
20
+ status: exists ? 'ok' : 'error',
21
+ message: exists
22
+ ? `Skill folder was found: ${sourceDir}`
23
+ : `Skill folder was not found: ${sourceDir}`,
24
+ });
25
+ return {
26
+ ok: exists,
27
+ checks,
28
+ };
29
+ },
30
+ };
31
+ }
32
+ async function defaultPathExists(targetPath) {
33
+ try {
34
+ await stat(targetPath);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
@@ -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;