brainctl 0.1.7 → 0.1.10

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 (51) hide show
  1. package/README.md +210 -157
  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/mcp/server.js +51 -5
  6. package/dist/services/agent-config-service.d.ts +4 -2
  7. package/dist/services/agent-config-service.js +50 -15
  8. package/dist/services/agent-converter-service.d.ts +21 -0
  9. package/dist/services/agent-converter-service.js +182 -0
  10. package/dist/services/credential-redaction-service.d.ts +13 -0
  11. package/dist/services/credential-redaction-service.js +89 -0
  12. package/dist/services/credential-resolution-service.d.ts +11 -0
  13. package/dist/services/credential-resolution-service.js +69 -0
  14. package/dist/services/mcp-preflight-service.d.ts +3 -2
  15. package/dist/services/mcp-preflight-service.js +159 -5
  16. package/dist/services/plugin-install-service.d.ts +43 -0
  17. package/dist/services/plugin-install-service.js +379 -21
  18. package/dist/services/portable-mcp-classifier.d.ts +12 -0
  19. package/dist/services/portable-mcp-classifier.js +116 -0
  20. package/dist/services/portable-profile-pack-service.d.ts +26 -0
  21. package/dist/services/portable-profile-pack-service.js +264 -0
  22. package/dist/services/profile-export-service.d.ts +15 -3
  23. package/dist/services/profile-export-service.js +10 -57
  24. package/dist/services/profile-import-service.d.ts +9 -1
  25. package/dist/services/profile-import-service.js +265 -10
  26. package/dist/services/profile-service.js +11 -0
  27. package/dist/services/runtime-detector.d.ts +9 -0
  28. package/dist/services/runtime-detector.js +130 -0
  29. package/dist/services/skill-paths.d.ts +2 -0
  30. package/dist/services/skill-paths.js +14 -0
  31. package/dist/services/sync/agent-reader.d.ts +9 -0
  32. package/dist/services/sync/agent-reader.js +177 -35
  33. package/dist/services/sync/claude-writer.js +0 -6
  34. package/dist/services/sync/codex-writer.d.ts +1 -0
  35. package/dist/services/sync/codex-writer.js +21 -8
  36. package/dist/services/sync/gemini-writer.js +5 -7
  37. package/dist/services/sync/plugin-skill-reader.d.ts +5 -0
  38. package/dist/services/sync/plugin-skill-reader.js +142 -1
  39. package/dist/services/sync-service.js +1 -1
  40. package/dist/services/update-check-service.d.ts +33 -0
  41. package/dist/services/update-check-service.js +128 -0
  42. package/dist/types.d.ts +47 -0
  43. package/dist/ui/routes.js +35 -8
  44. package/dist/web/assets/index-Cdb5hbxM.css +1 -0
  45. package/dist/web/assets/index-gN83hZYA.js +65 -0
  46. package/dist/web/favicon-light.svg +13 -0
  47. package/dist/web/favicon.svg +13 -0
  48. package/dist/web/index.html +7 -2
  49. package/package.json +5 -1
  50. package/dist/web/assets/index-BCkorugl.css +0 -1
  51. package/dist/web/assets/index-sGnTMhkX.js +0 -16
@@ -2,7 +2,7 @@ import { readFile, readdir } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { mergeManagedPluginsIntoSkills, readManagedPlugins, } from './managed-plugin-registry.js';
5
- import { readInstalledPlugins } from './plugin-skill-reader.js';
5
+ import { readCodexPlugins, readInstalledPlugins } from './plugin-skill-reader.js';
6
6
  export function createClaudeReader() {
7
7
  return {
8
8
  async read(options) {
@@ -10,11 +10,25 @@ export function createClaudeReader() {
10
10
  try {
11
11
  const source = await readFile(configPath, 'utf8');
12
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 ?? {});
13
15
  const projects = (data.projects ?? {});
14
16
  const projectConfig = projects[options.cwd] ?? {};
15
- const rawServers = (projectConfig.mcpServers ?? {});
17
+ const projectServers = (projectConfig.mcpServers ?? {});
18
+ const rawServers = { ...userServers, ...projectServers };
16
19
  const mcpServers = {};
20
+ const remoteMcpServers = {};
17
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
+ }
18
32
  mcpServers[name] = {
19
33
  command: String(entry.command ?? ''),
20
34
  args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
@@ -22,11 +36,26 @@ export function createClaudeReader() {
22
36
  };
23
37
  }
24
38
  const skills = await readClaudePlugins();
25
- 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
+ };
26
48
  }
27
49
  catch {
28
50
  const skills = await readClaudePlugins();
29
- 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
+ };
30
59
  }
31
60
  },
32
61
  };
@@ -37,13 +66,28 @@ export function createCodexReader() {
37
66
  const configPath = path.join(homedir(), '.codex', 'config.toml');
38
67
  try {
39
68
  const source = await readFile(configPath, 'utf8');
40
- const mcpServers = parseCodexToml(source);
69
+ const { mcpServers, remoteMcpServers } = parseCodexToml(source);
41
70
  const skills = await readCodexSkills();
42
- 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
+ };
43
80
  }
44
81
  catch {
45
82
  const skills = await readCodexSkills();
46
- 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
+ };
47
91
  }
48
92
  },
49
93
  };
@@ -52,28 +96,53 @@ export function createGeminiReader() {
52
96
  return {
53
97
  async read() {
54
98
  const configPath = path.join(homedir(), '.gemini', 'settings.json');
99
+ let rawServers = {};
100
+ let exists = false;
55
101
  try {
56
102
  const source = await readFile(configPath, 'utf8');
57
103
  const data = JSON.parse(source);
58
- const rawServers = (data.mcpServers ?? {});
59
- const mcpServers = {};
60
- for (const [name, entry] of Object.entries(rawServers)) {
61
- mcpServers[name] = {
62
- command: String(entry.command ?? ''),
63
- args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
64
- env: parseEnvObject(entry.env),
65
- };
66
- }
67
- const skills = await readGeminiSkills();
68
- return { agent: 'gemini', configPath, exists: true, mcpServers, skills };
104
+ rawServers = (data.mcpServers ?? {});
105
+ exists = true;
69
106
  }
70
107
  catch {
71
- const skills = await readGeminiSkills();
72
- return { agent: 'gemini', configPath, exists: false, mcpServers: {}, skills };
108
+ // no global config
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
+ };
73
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
+ };
74
134
  },
75
135
  };
76
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
+ }
77
146
  function parseEnvObject(value) {
78
147
  if (!value || typeof value !== 'object' || Array.isArray(value))
79
148
  return undefined;
@@ -83,13 +152,40 @@ function parseEnvObject(value) {
83
152
  }
84
153
  return Object.keys(result).length > 0 ? result : undefined;
85
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
+ }
86
180
  function parseCodexToml(source) {
87
- const servers = {};
181
+ const mcpServers = {};
182
+ const remoteMcpServers = {};
88
183
  const lines = source.split('\n');
89
184
  let currentServer = null;
90
185
  let inEnv = false;
91
186
  let currentEntry = { command: '' };
92
187
  let currentEnv = {};
188
+ let currentUrl = null;
93
189
  for (const line of lines) {
94
190
  const trimmed = line.trim();
95
191
  // Match [mcp_servers.name.env]
@@ -103,25 +199,37 @@ function parseCodexToml(source) {
103
199
  if (serverMatch) {
104
200
  // Save previous server
105
201
  if (currentServer) {
106
- if (Object.keys(currentEnv).length > 0)
107
- currentEntry.env = currentEnv;
108
- servers[currentServer] = currentEntry;
202
+ flushCodexServer({
203
+ currentServer,
204
+ currentEntry,
205
+ currentEnv,
206
+ currentUrl,
207
+ mcpServers,
208
+ remoteMcpServers,
209
+ });
109
210
  }
110
211
  currentServer = serverMatch[1];
111
212
  currentEntry = { command: '' };
112
213
  currentEnv = {};
214
+ currentUrl = null;
113
215
  inEnv = false;
114
216
  continue;
115
217
  }
116
218
  // New non-mcp section — flush current server
117
219
  if (/^\[/.test(trimmed) && !/^\[mcp_servers/.test(trimmed)) {
118
220
  if (currentServer) {
119
- if (Object.keys(currentEnv).length > 0)
120
- currentEntry.env = currentEnv;
121
- servers[currentServer] = currentEntry;
221
+ flushCodexServer({
222
+ currentServer,
223
+ currentEntry,
224
+ currentEnv,
225
+ currentUrl,
226
+ mcpServers,
227
+ remoteMcpServers,
228
+ });
122
229
  currentServer = null;
123
230
  currentEntry = { command: '' };
124
231
  currentEnv = {};
232
+ currentUrl = null;
125
233
  }
126
234
  inEnv = false;
127
235
  continue;
@@ -134,6 +242,9 @@ function parseCodexToml(source) {
134
242
  if (inEnv) {
135
243
  currentEnv[key] = parseTomlValue(rawValue);
136
244
  }
245
+ else if (key === 'url') {
246
+ currentUrl = parseTomlValue(rawValue);
247
+ }
137
248
  else if (key === 'command') {
138
249
  currentEntry.command = parseTomlValue(rawValue);
139
250
  }
@@ -143,11 +254,29 @@ function parseCodexToml(source) {
143
254
  }
144
255
  // Flush last server
145
256
  if (currentServer) {
146
- if (Object.keys(currentEnv).length > 0)
147
- currentEntry.env = currentEnv;
148
- servers[currentServer] = currentEntry;
257
+ flushCodexServer({
258
+ currentServer,
259
+ currentEntry,
260
+ currentEnv,
261
+ currentUrl,
262
+ mcpServers,
263
+ remoteMcpServers,
264
+ });
149
265
  }
150
- return servers;
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;
276
+ }
277
+ if (Object.keys(currentEnv).length > 0)
278
+ currentEntry.env = currentEnv;
279
+ mcpServers[currentServer] = currentEntry;
151
280
  }
152
281
  function parseTomlValue(raw) {
153
282
  const trimmed = raw.trim();
@@ -193,15 +322,28 @@ async function readClaudePlugins() {
193
322
  return results;
194
323
  }
195
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 = [];
196
330
  try {
197
331
  const skillsDir = path.join(homedir(), '.codex', 'skills');
198
- const localSkills = await readSkillDirs(skillsDir);
199
- const managedPlugins = await readManagedPlugins({ agent: 'codex' });
200
- return mergeManagedPluginsIntoSkills(localSkills, managedPlugins);
332
+ localSkills = await readSkillDirs(skillsDir);
201
333
  }
202
334
  catch {
203
- return await readManagedPlugins({ agent: 'codex' });
335
+ localSkills = [];
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);
204
345
  }
346
+ return Array.from(seen.values());
205
347
  }
206
348
  async function readGeminiSkills() {
207
349
  try {
@@ -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] ?? {};
@@ -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,14 +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
- kind: 'local',
31
- source: 'npm',
32
- package: 'brainctl',
33
- };
34
- const mcpToml = buildMcpToml(allServers);
27
+ const mcpToml = buildMcpToml(options.mcpServers);
35
28
  // Preserve non-mcp_servers content from existing config
36
29
  const existingNonMcp = stripMcpSections(existingContent);
37
30
  const finalContent = existingNonMcp.trim().length > 0
@@ -100,6 +93,26 @@ function buildMcpToml(servers) {
100
93
  function tomlString(value) {
101
94
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
102
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
+ }
103
116
  function stripMcpSections(content) {
104
117
  const lines = content.split('\n');
105
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;
@@ -1,2 +1,7 @@
1
1
  import type { AgentSkillEntry } from './agent-reader.js';
2
2
  export declare function readInstalledPlugins(installedPluginsPath: string): Promise<AgentSkillEntry[]>;
3
+ export declare function readCodexPlugins(options: {
4
+ configTomlPath: string;
5
+ pluginsCacheDir: string;
6
+ }): Promise<AgentSkillEntry[]>;
7
+ export declare function readPluginMcpKeys(installPath: string): Promise<string[]>;
@@ -1,4 +1,4 @@
1
- import { readFile, readdir } from 'node:fs/promises';
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  export async function readInstalledPlugins(installedPluginsPath) {
4
4
  const source = await readFile(installedPluginsPath, 'utf8');
@@ -8,12 +8,18 @@ export async function readInstalledPlugins(installedPluginsPath) {
8
8
  const [name, pluginSource] = key.split('@');
9
9
  const installPath = records[0]?.installPath;
10
10
  const pluginSkills = installPath ? await readPluginSkills(installPath) : [];
11
+ const pluginMcps = installPath ? await readPluginMcpKeys(installPath) : [];
12
+ const pluginAgents = installPath ? await readPluginAgentNames(installPath) : [];
13
+ const pluginCommands = installPath ? await readPluginCommandNames(installPath) : [];
11
14
  results.push({
12
15
  name,
13
16
  source: pluginSource,
14
17
  kind: 'plugin',
15
18
  installPath,
16
19
  pluginSkills,
20
+ ...(pluginMcps.length > 0 ? { pluginMcps } : {}),
21
+ ...(pluginAgents.length > 0 ? { pluginAgents } : {}),
22
+ ...(pluginCommands.length > 0 ? { pluginCommands } : {}),
17
23
  });
18
24
  }
19
25
  return results;
@@ -31,3 +37,138 @@ async function readPluginSkills(installPath) {
31
37
  return [];
32
38
  }
33
39
  }
40
+ export async function readCodexPlugins(options) {
41
+ let tomlSource;
42
+ try {
43
+ tomlSource = await readFile(options.configTomlPath, 'utf8');
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ const entries = [];
49
+ for (const { name, marketplace, enabled } of parseCodexPluginDeclarations(tomlSource)) {
50
+ if (!enabled)
51
+ continue;
52
+ const installPath = await resolveCodexPluginInstallPath({
53
+ pluginsCacheDir: options.pluginsCacheDir,
54
+ marketplace,
55
+ name,
56
+ });
57
+ if (!installPath)
58
+ continue;
59
+ const pluginSkills = await readPluginSkills(installPath);
60
+ const pluginMcps = await readPluginMcpKeys(installPath);
61
+ const pluginAgents = await readPluginAgentNames(installPath);
62
+ const pluginCommands = await readPluginCommandNames(installPath);
63
+ entries.push({
64
+ name,
65
+ source: marketplace,
66
+ kind: 'plugin',
67
+ installPath,
68
+ pluginSkills,
69
+ ...(pluginMcps.length > 0 ? { pluginMcps } : {}),
70
+ ...(pluginAgents.length > 0 ? { pluginAgents } : {}),
71
+ ...(pluginCommands.length > 0 ? { pluginCommands } : {}),
72
+ });
73
+ }
74
+ return entries.sort((left, right) => left.name.localeCompare(right.name));
75
+ }
76
+ function parseCodexPluginDeclarations(source) {
77
+ const results = [];
78
+ const lines = source.split('\n');
79
+ let current = null;
80
+ const flush = () => {
81
+ if (current) {
82
+ results.push(current);
83
+ current = null;
84
+ }
85
+ };
86
+ for (const line of lines) {
87
+ const trimmed = line.trim();
88
+ const sectionMatch = trimmed.match(/^\[plugins\."([^"@]+)@([^"]+)"\]$/);
89
+ if (sectionMatch) {
90
+ flush();
91
+ current = {
92
+ name: sectionMatch[1],
93
+ marketplace: sectionMatch[2],
94
+ enabled: false,
95
+ };
96
+ continue;
97
+ }
98
+ if (/^\[/.test(trimmed)) {
99
+ flush();
100
+ continue;
101
+ }
102
+ if (!current)
103
+ continue;
104
+ const kv = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
105
+ if (kv && kv[1] === 'enabled') {
106
+ current.enabled = kv[2].trim() === 'true';
107
+ }
108
+ }
109
+ flush();
110
+ return results;
111
+ }
112
+ async function resolveCodexPluginInstallPath(options) {
113
+ const pluginRoot = path.join(options.pluginsCacheDir, options.marketplace, options.name);
114
+ let versionDirs;
115
+ try {
116
+ const entries = await readdir(pluginRoot, { withFileTypes: true });
117
+ versionDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ if (versionDirs.length === 0)
123
+ return null;
124
+ if (versionDirs.length === 1)
125
+ return path.join(pluginRoot, versionDirs[0]);
126
+ const stats = await Promise.all(versionDirs.map(async (dir) => {
127
+ try {
128
+ const info = await stat(path.join(pluginRoot, dir));
129
+ return { dir, mtime: info.mtimeMs };
130
+ }
131
+ catch {
132
+ return { dir, mtime: 0 };
133
+ }
134
+ }));
135
+ stats.sort((left, right) => right.mtime - left.mtime);
136
+ return path.join(pluginRoot, stats[0].dir);
137
+ }
138
+ async function readPluginAgentNames(installPath) {
139
+ try {
140
+ const entries = await readdir(path.join(installPath, 'agents'), { withFileTypes: true });
141
+ return entries
142
+ .filter((entry) => entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.toml')))
143
+ .map((entry) => entry.name.replace(/\.(md|toml)$/, ''))
144
+ .sort((left, right) => left.localeCompare(right));
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ }
150
+ async function readPluginCommandNames(installPath) {
151
+ try {
152
+ const entries = await readdir(path.join(installPath, 'commands'), { withFileTypes: true });
153
+ return entries
154
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
155
+ .map((entry) => entry.name.replace(/\.md$/, ''))
156
+ .sort((left, right) => left.localeCompare(right));
157
+ }
158
+ catch {
159
+ return [];
160
+ }
161
+ }
162
+ export async function readPluginMcpKeys(installPath) {
163
+ try {
164
+ const source = await readFile(path.join(installPath, '.mcp.json'), 'utf8');
165
+ const parsed = JSON.parse(source);
166
+ const servers = parsed.mcpServers && typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers)
167
+ ? parsed.mcpServers
168
+ : parsed;
169
+ return Object.keys(servers).sort((left, right) => left.localeCompare(right));
170
+ }
171
+ catch {
172
+ return [];
173
+ }
174
+ }
@@ -40,7 +40,7 @@ export function createSyncService(dependencies = {}) {
40
40
  agent,
41
41
  configPath: result.configPath,
42
42
  backedUpTo: result.backedUpTo,
43
- mcpCount: Object.keys(profile.mcps).length + 1, // +1 for brainctl itself
43
+ mcpCount: Object.keys(profile.mcps).length,
44
44
  });
45
45
  }
46
46
  return results;
@@ -0,0 +1,33 @@
1
+ export interface UpdateCheckResult {
2
+ current: string;
3
+ latest: string;
4
+ isOutdated: boolean;
5
+ fromCache: boolean;
6
+ }
7
+ export interface SelfUpdateResult {
8
+ success: boolean;
9
+ fromVersion: string;
10
+ toVersion: string;
11
+ error?: string;
12
+ }
13
+ export interface UpdateCheckService {
14
+ check(): Promise<UpdateCheckResult>;
15
+ selfUpdate(): Promise<SelfUpdateResult>;
16
+ }
17
+ interface UpdateCheckCache {
18
+ lastCheck: string;
19
+ latestVersion: string;
20
+ }
21
+ interface UpdateCheckDependencies {
22
+ currentVersion?: string;
23
+ fetchLatestVersion?: () => Promise<string>;
24
+ readCache?: () => Promise<UpdateCheckCache | null>;
25
+ writeCache?: (cache: UpdateCheckCache) => Promise<void>;
26
+ runInstall?: () => Promise<{
27
+ success: boolean;
28
+ error?: string;
29
+ }>;
30
+ }
31
+ export declare function createUpdateCheckService(dependencies?: UpdateCheckDependencies): UpdateCheckService;
32
+ export declare function isNewer(candidate: string, baseline: string): boolean;
33
+ export {};