brainctl 0.1.6 → 0.1.9

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 (59) hide show
  1. package/README.md +215 -136
  2. package/dist/cli.js +40 -0
  3. package/dist/commands/mcp.js +35 -0
  4. package/dist/commands/profile.js +35 -2
  5. package/dist/executor/resolver.js +1 -38
  6. package/dist/mcp/server.js +82 -2
  7. package/dist/services/agent-config-service.d.ts +20 -3
  8. package/dist/services/agent-config-service.js +84 -16
  9. package/dist/services/agent-converter-service.d.ts +21 -0
  10. package/dist/services/agent-converter-service.js +182 -0
  11. package/dist/services/credential-redaction-service.d.ts +13 -0
  12. package/dist/services/credential-redaction-service.js +89 -0
  13. package/dist/services/credential-resolution-service.d.ts +11 -0
  14. package/dist/services/credential-resolution-service.js +69 -0
  15. package/dist/services/mcp-preflight-service.d.ts +26 -0
  16. package/dist/services/mcp-preflight-service.js +238 -0
  17. package/dist/services/plugin-install-service.d.ts +135 -0
  18. package/dist/services/plugin-install-service.js +601 -0
  19. package/dist/services/portable-mcp-classifier.d.ts +12 -0
  20. package/dist/services/portable-mcp-classifier.js +116 -0
  21. package/dist/services/portable-profile-pack-service.d.ts +26 -0
  22. package/dist/services/portable-profile-pack-service.js +264 -0
  23. package/dist/services/profile-export-service.d.ts +15 -3
  24. package/dist/services/profile-export-service.js +10 -57
  25. package/dist/services/profile-import-service.d.ts +9 -1
  26. package/dist/services/profile-import-service.js +266 -11
  27. package/dist/services/profile-service.d.ts +1 -0
  28. package/dist/services/profile-service.js +128 -32
  29. package/dist/services/runtime-detector.d.ts +9 -0
  30. package/dist/services/runtime-detector.js +130 -0
  31. package/dist/services/skill-paths.d.ts +4 -0
  32. package/dist/services/skill-paths.js +26 -0
  33. package/dist/services/skill-preflight-service.d.ts +23 -0
  34. package/dist/services/skill-preflight-service.js +40 -0
  35. package/dist/services/sync/agent-reader.d.ts +14 -0
  36. package/dist/services/sync/agent-reader.js +198 -45
  37. package/dist/services/sync/claude-writer.js +4 -7
  38. package/dist/services/sync/codex-writer.d.ts +1 -0
  39. package/dist/services/sync/codex-writer.js +25 -8
  40. package/dist/services/sync/gemini-writer.js +9 -8
  41. package/dist/services/sync/managed-plugin-registry.d.ts +17 -0
  42. package/dist/services/sync/managed-plugin-registry.js +75 -0
  43. package/dist/services/sync/plugin-skill-reader.d.ts +7 -0
  44. package/dist/services/sync/plugin-skill-reader.js +174 -0
  45. package/dist/services/sync-service.js +6 -1
  46. package/dist/services/update-check-service.d.ts +33 -0
  47. package/dist/services/update-check-service.js +128 -0
  48. package/dist/system/executables.d.ts +1 -0
  49. package/dist/system/executables.js +38 -0
  50. package/dist/types.d.ts +62 -5
  51. package/dist/ui/routes.js +293 -8
  52. package/dist/web/assets/index-Cdb5hbxM.css +1 -0
  53. package/dist/web/assets/index-gN83hZYA.js +65 -0
  54. package/dist/web/favicon-light.svg +13 -0
  55. package/dist/web/favicon.svg +13 -0
  56. package/dist/web/index.html +7 -2
  57. package/package.json +9 -1
  58. package/dist/web/assets/index-364NYWPA.css +0 -1
  59. package/dist/web/assets/index-BmfE7rus.js +0 -16
@@ -0,0 +1,26 @@
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
+ }
13
+ export function getAgentFilePath(agent, agentName) {
14
+ const safeName = path.basename(agentName);
15
+ if (agent === 'claude')
16
+ return path.join(homedir(), '.claude', 'agents', `${safeName}.md`);
17
+ if (agent === 'codex')
18
+ return path.join(homedir(), '.codex', 'agents', `${safeName}.toml`);
19
+ throw new Error(`Subagent management is not supported for ${agent}`);
20
+ }
21
+ export function getCommandFilePath(agent, commandName) {
22
+ const safeName = path.basename(commandName);
23
+ if (agent === 'claude')
24
+ return path.join(homedir(), '.claude', 'commands', `${safeName}.md`);
25
+ throw new Error(`Slash-command management is not supported for ${agent}`);
26
+ }
@@ -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
+ }
@@ -4,15 +4,29 @@ export interface AgentMcpEntry {
4
4
  args?: string[];
5
5
  env?: Record<string, string>;
6
6
  }
7
+ export interface PortableRemoteMcpMetadata {
8
+ transport: 'http' | 'sse';
9
+ url: string;
10
+ headers?: Record<string, string>;
11
+ env?: Record<string, string>;
12
+ }
7
13
  export interface AgentSkillEntry {
8
14
  name: string;
9
15
  source?: string;
16
+ kind?: 'skill' | 'plugin';
17
+ pluginSkills?: string[];
18
+ pluginMcps?: string[];
19
+ pluginAgents?: string[];
20
+ pluginCommands?: string[];
21
+ installPath?: string;
22
+ managed?: boolean;
10
23
  }
11
24
  export interface AgentLiveConfig {
12
25
  agent: AgentName;
13
26
  configPath: string;
14
27
  exists: boolean;
15
28
  mcpServers: Record<string, AgentMcpEntry>;
29
+ remoteMcpServers: Record<string, PortableRemoteMcpMetadata>;
16
30
  skills: AgentSkillEntry[];
17
31
  }
18
32
  export interface AgentConfigReader {
@@ -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 { readCodexPlugins, readInstalledPlugins } from './plugin-skill-reader.js';
4
6
  export function createClaudeReader() {
5
7
  return {
6
8
  async read(options) {
@@ -8,11 +10,25 @@ export function createClaudeReader() {
8
10
  try {
9
11
  const source = await readFile(configPath, 'utf8');
10
12
  const data = JSON.parse(source);
13
+ // Merge user-scoped MCPs (top-level) with project-scoped MCPs (project overrides user)
14
+ const userServers = (data.mcpServers ?? {});
11
15
  const projects = (data.projects ?? {});
12
16
  const projectConfig = projects[options.cwd] ?? {};
13
- const rawServers = (projectConfig.mcpServers ?? {});
17
+ const projectServers = (projectConfig.mcpServers ?? {});
18
+ const rawServers = { ...userServers, ...projectServers };
14
19
  const mcpServers = {};
20
+ const remoteMcpServers = {};
15
21
  for (const [name, entry] of Object.entries(rawServers)) {
22
+ if (isClaudeRemoteEntry(entry)) {
23
+ const url = typeof entry.url === 'string' ? entry.url : '';
24
+ remoteMcpServers[name] = {
25
+ transport: entry.type === 'sse' ? 'sse' : 'http',
26
+ url,
27
+ headers: parseEnvObject(entry.headers),
28
+ env: parseEnvObject(entry.env),
29
+ };
30
+ continue;
31
+ }
16
32
  mcpServers[name] = {
17
33
  command: String(entry.command ?? ''),
18
34
  args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
@@ -20,11 +36,26 @@ export function createClaudeReader() {
20
36
  };
21
37
  }
22
38
  const skills = await readClaudePlugins();
23
- return { agent: 'claude', configPath, exists: true, mcpServers, skills };
39
+ const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, skills });
40
+ return {
41
+ agent: 'claude',
42
+ configPath,
43
+ exists: true,
44
+ mcpServers: filtered.mcpServers,
45
+ remoteMcpServers: filtered.remoteMcpServers,
46
+ skills,
47
+ };
24
48
  }
25
49
  catch {
26
50
  const skills = await readClaudePlugins();
27
- return { agent: 'claude', configPath, exists: false, mcpServers: {}, skills };
51
+ return {
52
+ agent: 'claude',
53
+ configPath,
54
+ exists: false,
55
+ mcpServers: {},
56
+ remoteMcpServers: {},
57
+ skills,
58
+ };
28
59
  }
29
60
  },
30
61
  };
@@ -35,13 +66,28 @@ export function createCodexReader() {
35
66
  const configPath = path.join(homedir(), '.codex', 'config.toml');
36
67
  try {
37
68
  const source = await readFile(configPath, 'utf8');
38
- const mcpServers = parseCodexToml(source);
69
+ const { mcpServers, remoteMcpServers } = parseCodexToml(source);
39
70
  const skills = await readCodexSkills();
40
- return { agent: 'codex', configPath, exists: true, mcpServers, skills };
71
+ const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, skills });
72
+ return {
73
+ agent: 'codex',
74
+ configPath,
75
+ exists: true,
76
+ mcpServers: filtered.mcpServers,
77
+ remoteMcpServers: filtered.remoteMcpServers,
78
+ skills,
79
+ };
41
80
  }
42
81
  catch {
43
82
  const skills = await readCodexSkills();
44
- return { agent: 'codex', configPath, exists: false, mcpServers: {}, skills };
83
+ return {
84
+ agent: 'codex',
85
+ configPath,
86
+ exists: false,
87
+ mcpServers: {},
88
+ remoteMcpServers: {},
89
+ skills,
90
+ };
45
91
  }
46
92
  },
47
93
  };
@@ -50,28 +96,53 @@ export function createGeminiReader() {
50
96
  return {
51
97
  async read() {
52
98
  const configPath = path.join(homedir(), '.gemini', 'settings.json');
99
+ let rawServers = {};
100
+ let exists = false;
53
101
  try {
54
102
  const source = await readFile(configPath, 'utf8');
55
103
  const data = JSON.parse(source);
56
- const rawServers = (data.mcpServers ?? {});
57
- const mcpServers = {};
58
- for (const [name, entry] of Object.entries(rawServers)) {
59
- mcpServers[name] = {
60
- command: String(entry.command ?? ''),
61
- args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
62
- env: parseEnvObject(entry.env),
63
- };
64
- }
65
- const skills = await readGeminiSkills();
66
- return { agent: 'gemini', configPath, exists: true, mcpServers, skills };
104
+ rawServers = (data.mcpServers ?? {});
105
+ exists = true;
67
106
  }
68
107
  catch {
69
- const skills = await readGeminiSkills();
70
- return { agent: 'gemini', configPath, exists: false, mcpServers: {}, skills };
108
+ // no global config
71
109
  }
110
+ const mcpServers = {};
111
+ const remoteMcpServers = {};
112
+ for (const [name, entry] of Object.entries(rawServers)) {
113
+ const remoteEntry = toGeminiRemoteEntry(entry);
114
+ if (remoteEntry) {
115
+ remoteMcpServers[name] = remoteEntry;
116
+ continue;
117
+ }
118
+ mcpServers[name] = {
119
+ command: String(entry.command ?? ''),
120
+ args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
121
+ env: parseEnvObject(entry.env),
122
+ };
123
+ }
124
+ const skills = await readGeminiSkills();
125
+ const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, skills });
126
+ return {
127
+ agent: 'gemini',
128
+ configPath,
129
+ exists,
130
+ mcpServers: filtered.mcpServers,
131
+ remoteMcpServers: filtered.remoteMcpServers,
132
+ skills,
133
+ };
72
134
  },
73
135
  };
74
136
  }
137
+ function filterPluginOwnedMcps(options) {
138
+ const pluginOwned = new Set(options.skills.flatMap((skill) => skill.pluginMcps ?? []));
139
+ if (pluginOwned.size === 0) {
140
+ return { mcpServers: options.mcpServers, remoteMcpServers: options.remoteMcpServers };
141
+ }
142
+ const mcpServers = Object.fromEntries(Object.entries(options.mcpServers).filter(([key]) => !pluginOwned.has(key)));
143
+ const remoteMcpServers = Object.fromEntries(Object.entries(options.remoteMcpServers).filter(([key]) => !pluginOwned.has(key)));
144
+ return { mcpServers, remoteMcpServers };
145
+ }
75
146
  function parseEnvObject(value) {
76
147
  if (!value || typeof value !== 'object' || Array.isArray(value))
77
148
  return undefined;
@@ -81,13 +152,40 @@ function parseEnvObject(value) {
81
152
  }
82
153
  return Object.keys(result).length > 0 ? result : undefined;
83
154
  }
155
+ function isClaudeRemoteEntry(entry) {
156
+ if (typeof entry.url !== 'string' || entry.url.trim().length === 0) {
157
+ return false;
158
+ }
159
+ return entry.type === 'http' || entry.type === 'sse' || !('command' in entry);
160
+ }
161
+ function toGeminiRemoteEntry(entry) {
162
+ if (typeof entry.httpUrl === 'string' && entry.httpUrl.trim().length > 0) {
163
+ return {
164
+ transport: 'http',
165
+ url: entry.httpUrl,
166
+ headers: parseEnvObject(entry.headers),
167
+ env: parseEnvObject(entry.env),
168
+ };
169
+ }
170
+ if (typeof entry.url === 'string' && entry.url.trim().length > 0) {
171
+ return {
172
+ transport: 'sse',
173
+ url: entry.url,
174
+ headers: parseEnvObject(entry.headers),
175
+ env: parseEnvObject(entry.env),
176
+ };
177
+ }
178
+ return null;
179
+ }
84
180
  function parseCodexToml(source) {
85
- const servers = {};
181
+ const mcpServers = {};
182
+ const remoteMcpServers = {};
86
183
  const lines = source.split('\n');
87
184
  let currentServer = null;
88
185
  let inEnv = false;
89
186
  let currentEntry = { command: '' };
90
187
  let currentEnv = {};
188
+ let currentUrl = null;
91
189
  for (const line of lines) {
92
190
  const trimmed = line.trim();
93
191
  // Match [mcp_servers.name.env]
@@ -101,25 +199,37 @@ function parseCodexToml(source) {
101
199
  if (serverMatch) {
102
200
  // Save previous server
103
201
  if (currentServer) {
104
- if (Object.keys(currentEnv).length > 0)
105
- currentEntry.env = currentEnv;
106
- servers[currentServer] = currentEntry;
202
+ flushCodexServer({
203
+ currentServer,
204
+ currentEntry,
205
+ currentEnv,
206
+ currentUrl,
207
+ mcpServers,
208
+ remoteMcpServers,
209
+ });
107
210
  }
108
211
  currentServer = serverMatch[1];
109
212
  currentEntry = { command: '' };
110
213
  currentEnv = {};
214
+ currentUrl = null;
111
215
  inEnv = false;
112
216
  continue;
113
217
  }
114
218
  // New non-mcp section — flush current server
115
219
  if (/^\[/.test(trimmed) && !/^\[mcp_servers/.test(trimmed)) {
116
220
  if (currentServer) {
117
- if (Object.keys(currentEnv).length > 0)
118
- currentEntry.env = currentEnv;
119
- servers[currentServer] = currentEntry;
221
+ flushCodexServer({
222
+ currentServer,
223
+ currentEntry,
224
+ currentEnv,
225
+ currentUrl,
226
+ mcpServers,
227
+ remoteMcpServers,
228
+ });
120
229
  currentServer = null;
121
230
  currentEntry = { command: '' };
122
231
  currentEnv = {};
232
+ currentUrl = null;
123
233
  }
124
234
  inEnv = false;
125
235
  continue;
@@ -132,6 +242,9 @@ function parseCodexToml(source) {
132
242
  if (inEnv) {
133
243
  currentEnv[key] = parseTomlValue(rawValue);
134
244
  }
245
+ else if (key === 'url') {
246
+ currentUrl = parseTomlValue(rawValue);
247
+ }
135
248
  else if (key === 'command') {
136
249
  currentEntry.command = parseTomlValue(rawValue);
137
250
  }
@@ -141,11 +254,29 @@ function parseCodexToml(source) {
141
254
  }
142
255
  // Flush last server
143
256
  if (currentServer) {
144
- if (Object.keys(currentEnv).length > 0)
145
- currentEntry.env = currentEnv;
146
- servers[currentServer] = currentEntry;
257
+ flushCodexServer({
258
+ currentServer,
259
+ currentEntry,
260
+ currentEnv,
261
+ currentUrl,
262
+ mcpServers,
263
+ remoteMcpServers,
264
+ });
265
+ }
266
+ return { mcpServers, remoteMcpServers };
267
+ }
268
+ function flushCodexServer(options) {
269
+ const { currentServer, currentEntry, currentEnv, currentUrl, mcpServers, remoteMcpServers } = options;
270
+ if (currentUrl) {
271
+ remoteMcpServers[currentServer] = {
272
+ transport: 'http',
273
+ url: currentUrl,
274
+ };
275
+ return;
147
276
  }
148
- return servers;
277
+ if (Object.keys(currentEnv).length > 0)
278
+ currentEntry.env = currentEnv;
279
+ mcpServers[currentServer] = currentEntry;
149
280
  }
150
281
  function parseTomlValue(raw) {
151
282
  const trimmed = raw.trim();
@@ -170,37 +301,59 @@ function parseTomlArray(raw) {
170
301
  }
171
302
  /* ---- Skill readers ---- */
172
303
  async function readClaudePlugins() {
304
+ const results = [];
305
+ // Read marketplace plugins
173
306
  try {
174
307
  const pluginsPath = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
175
- const source = await readFile(pluginsPath, 'utf8');
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
- });
308
+ results.push(...await readInstalledPlugins(pluginsPath));
183
309
  }
184
310
  catch {
185
- return [];
311
+ // no plugins file
186
312
  }
313
+ // Read local skills from ~/.claude/skills/
314
+ try {
315
+ const skillsDir = path.join(homedir(), '.claude', 'skills');
316
+ const localSkills = await readSkillDirs(skillsDir);
317
+ results.push(...localSkills);
318
+ }
319
+ catch {
320
+ // no skills dir
321
+ }
322
+ return results;
187
323
  }
188
324
  async function readCodexSkills() {
325
+ const configTomlPath = path.join(homedir(), '.codex', 'config.toml');
326
+ const pluginsCacheDir = path.join(homedir(), '.codex', 'plugins', 'cache');
327
+ const nativePlugins = await readCodexPlugins({ configTomlPath, pluginsCacheDir });
328
+ const managedPlugins = await readManagedPlugins({ agent: 'codex' });
329
+ let localSkills = [];
189
330
  try {
190
331
  const skillsDir = path.join(homedir(), '.codex', 'skills');
191
- return await readSkillDirs(skillsDir);
332
+ localSkills = await readSkillDirs(skillsDir);
192
333
  }
193
334
  catch {
194
- return [];
335
+ localSkills = [];
195
336
  }
337
+ const allPlugins = dedupePluginsByName([...managedPlugins, ...nativePlugins]);
338
+ return mergeManagedPluginsIntoSkills(localSkills, allPlugins);
339
+ }
340
+ function dedupePluginsByName(plugins) {
341
+ const seen = new Map();
342
+ for (const plugin of plugins) {
343
+ if (!seen.has(plugin.name))
344
+ seen.set(plugin.name, plugin);
345
+ }
346
+ return Array.from(seen.values());
196
347
  }
197
348
  async function readGeminiSkills() {
198
349
  try {
199
350
  const skillsDir = path.join(homedir(), '.gemini', 'skills');
200
- return await readSkillDirs(skillsDir);
351
+ const localSkills = await readSkillDirs(skillsDir);
352
+ const managedPlugins = await readManagedPlugins({ agent: 'gemini' });
353
+ return mergeManagedPluginsIntoSkills(localSkills, managedPlugins);
201
354
  }
202
355
  catch {
203
- return [];
356
+ return await readManagedPlugins({ agent: 'gemini' });
204
357
  }
205
358
  }
206
359
  /** Shared: read skill directories (Codex and Gemini use the same SKILL.md convention) */
@@ -211,10 +364,10 @@ async function readSkillDirs(skillsDir) {
211
364
  if (entry.name.startsWith('.'))
212
365
  continue;
213
366
  if (entry.isDirectory()) {
214
- skills.push({ name: entry.name, source: 'local' });
367
+ skills.push({ name: entry.name, source: 'local', kind: 'skill' });
215
368
  }
216
369
  else if (entry.isSymbolicLink()) {
217
- skills.push({ name: entry.name, source: 'linked' });
370
+ skills.push({ name: entry.name, source: 'linked', kind: 'skill' });
218
371
  }
219
372
  }
220
373
  return skills;
@@ -28,12 +28,6 @@ export function createClaudeWriter() {
28
28
  for (const [name, config] of Object.entries(options.mcpServers)) {
29
29
  mcpServers[name] = toClaudeFormat(config);
30
30
  }
31
- // Always include brainctl itself
32
- mcpServers['brainctl'] = {
33
- type: 'stdio',
34
- command: 'npx',
35
- args: ['-y', 'brainctl', 'mcp'],
36
- };
37
31
  // Merge into existing config (preserve other projects)
38
32
  const projects = (existing.projects ?? {});
39
33
  const projectConfig = projects[options.cwd] ?? {};
@@ -65,7 +59,10 @@ export function createClaudeWriter() {
65
59
  };
66
60
  }
67
61
  function toClaudeFormat(config) {
68
- if (config.type === 'npm') {
62
+ if (config.kind === 'remote') {
63
+ throw new SyncError('Remote MCP servers are not supported in Claude sync.');
64
+ }
65
+ if (config.source === 'npm') {
69
66
  return {
70
67
  type: 'stdio',
71
68
  command: 'npx',
@@ -1,2 +1,3 @@
1
1
  import type { AgentConfigWriter } from './agent-writer.js';
2
2
  export declare function createCodexWriter(): AgentConfigWriter;
3
+ export declare function stripPluginSection(content: string, pluginKey: string): string;
@@ -24,13 +24,7 @@ export function createCodexWriter() {
24
24
  backedUpTo = backupPath;
25
25
  }
26
26
  // Build MCP servers section
27
- const allServers = { ...options.mcpServers };
28
- // Always include brainctl itself
29
- allServers['brainctl'] = {
30
- type: 'npm',
31
- package: 'brainctl',
32
- };
33
- const mcpToml = buildMcpToml(allServers);
27
+ const mcpToml = buildMcpToml(options.mcpServers);
34
28
  // Preserve non-mcp_servers content from existing config
35
29
  const existingNonMcp = stripMcpSections(existingContent);
36
30
  const finalContent = existingNonMcp.trim().length > 0
@@ -71,7 +65,10 @@ function buildMcpToml(servers) {
71
65
  const lines = [];
72
66
  for (const [name, config] of Object.entries(servers)) {
73
67
  lines.push(`[mcp_servers.${name}]`);
74
- if (config.type === 'npm') {
68
+ if (config.kind === 'remote') {
69
+ throw new SyncError('Remote MCP servers are not supported in Codex sync.');
70
+ }
71
+ if (config.source === 'npm') {
75
72
  lines.push(`command = "npx"`);
76
73
  lines.push(`args = ["-y", ${tomlString(config.package)}]`);
77
74
  }
@@ -96,6 +93,26 @@ function buildMcpToml(servers) {
96
93
  function tomlString(value) {
97
94
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
98
95
  }
96
+ export function stripPluginSection(content, pluginKey) {
97
+ const target = `[plugins."${pluginKey}"]`;
98
+ const lines = content.split('\n');
99
+ const result = [];
100
+ let inTarget = false;
101
+ for (const line of lines) {
102
+ const trimmed = line.trim();
103
+ if (trimmed === target) {
104
+ inTarget = true;
105
+ continue;
106
+ }
107
+ if (inTarget && /^\[/.test(trimmed)) {
108
+ inTarget = false;
109
+ }
110
+ if (!inTarget) {
111
+ result.push(line);
112
+ }
113
+ }
114
+ return result.join('\n');
115
+ }
99
116
  function stripMcpSections(content) {
100
117
  const lines = content.split('\n');
101
118
  const result = [];
@@ -1,11 +1,13 @@
1
1
  import { copyFile, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { SyncError } from '../../errors.js';
4
5
  import { formatTimestamp } from './agent-writer.js';
5
6
  export function createGeminiWriter() {
6
7
  return {
7
8
  async write(options) {
8
- const geminiDir = path.join(options.cwd, '.gemini');
9
+ void options.cwd;
10
+ const geminiDir = path.join(homedir(), '.gemini');
9
11
  const configPath = path.join(geminiDir, 'settings.json');
10
12
  let existing = {};
11
13
  let backedUpTo = null;
@@ -28,11 +30,6 @@ export function createGeminiWriter() {
28
30
  for (const [name, config] of Object.entries(options.mcpServers)) {
29
31
  mcpServers[name] = toGeminiFormat(config);
30
32
  }
31
- // Always include brainctl itself
32
- mcpServers['brainctl'] = {
33
- command: 'npx',
34
- args: ['-y', 'brainctl', 'mcp'],
35
- };
36
33
  // Merge into existing config (preserve other settings)
37
34
  existing.mcpServers = mcpServers;
38
35
  // Atomic write
@@ -43,7 +40,8 @@ export function createGeminiWriter() {
43
40
  return { configPath, backedUpTo };
44
41
  },
45
42
  async restore(options) {
46
- const configPath = path.join(options.cwd, '.gemini', 'settings.json');
43
+ void options.cwd;
44
+ const configPath = path.join(homedir(), '.gemini', 'settings.json');
47
45
  const dir = path.dirname(configPath);
48
46
  const base = path.basename(configPath);
49
47
  let entries;
@@ -67,7 +65,10 @@ export function createGeminiWriter() {
67
65
  };
68
66
  }
69
67
  function toGeminiFormat(config) {
70
- if (config.type === 'npm') {
68
+ if (config.kind === 'remote') {
69
+ throw new SyncError('Remote MCP servers are not supported in Gemini sync.');
70
+ }
71
+ if (config.source === 'npm') {
71
72
  return {
72
73
  command: 'npx',
73
74
  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[];