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,232 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { mergeManagedPluginsIntoSkills, readManagedPlugins, } from './managed-plugin-registry.js';
5
+ import { readInstalledPlugins } from './plugin-skill-reader.js';
6
+ export function createClaudeReader() {
7
+ return {
8
+ async read(options) {
9
+ const configPath = path.join(homedir(), '.claude.json');
10
+ try {
11
+ const source = await readFile(configPath, 'utf8');
12
+ const data = JSON.parse(source);
13
+ const projects = (data.projects ?? {});
14
+ const projectConfig = projects[options.cwd] ?? {};
15
+ const rawServers = (projectConfig.mcpServers ?? {});
16
+ const mcpServers = {};
17
+ for (const [name, entry] of Object.entries(rawServers)) {
18
+ mcpServers[name] = {
19
+ command: String(entry.command ?? ''),
20
+ args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
21
+ env: parseEnvObject(entry.env),
22
+ };
23
+ }
24
+ const skills = await readClaudePlugins();
25
+ return { agent: 'claude', configPath, exists: true, mcpServers, skills };
26
+ }
27
+ catch {
28
+ const skills = await readClaudePlugins();
29
+ return { agent: 'claude', configPath, exists: false, mcpServers: {}, skills };
30
+ }
31
+ },
32
+ };
33
+ }
34
+ export function createCodexReader() {
35
+ return {
36
+ async read() {
37
+ const configPath = path.join(homedir(), '.codex', 'config.toml');
38
+ try {
39
+ const source = await readFile(configPath, 'utf8');
40
+ const mcpServers = parseCodexToml(source);
41
+ const skills = await readCodexSkills();
42
+ return { agent: 'codex', configPath, exists: true, mcpServers, skills };
43
+ }
44
+ catch {
45
+ const skills = await readCodexSkills();
46
+ return { agent: 'codex', configPath, exists: false, mcpServers: {}, skills };
47
+ }
48
+ },
49
+ };
50
+ }
51
+ export function createGeminiReader() {
52
+ return {
53
+ async read() {
54
+ const configPath = path.join(homedir(), '.gemini', 'settings.json');
55
+ try {
56
+ const source = await readFile(configPath, 'utf8');
57
+ 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 };
69
+ }
70
+ catch {
71
+ const skills = await readGeminiSkills();
72
+ return { agent: 'gemini', configPath, exists: false, mcpServers: {}, skills };
73
+ }
74
+ },
75
+ };
76
+ }
77
+ function parseEnvObject(value) {
78
+ if (!value || typeof value !== 'object' || Array.isArray(value))
79
+ return undefined;
80
+ const result = {};
81
+ for (const [k, v] of Object.entries(value)) {
82
+ result[k] = String(v);
83
+ }
84
+ return Object.keys(result).length > 0 ? result : undefined;
85
+ }
86
+ function parseCodexToml(source) {
87
+ const servers = {};
88
+ const lines = source.split('\n');
89
+ let currentServer = null;
90
+ let inEnv = false;
91
+ let currentEntry = { command: '' };
92
+ let currentEnv = {};
93
+ for (const line of lines) {
94
+ const trimmed = line.trim();
95
+ // Match [mcp_servers.name.env]
96
+ const envMatch = trimmed.match(/^\[mcp_servers\.([^.]+)\.env\]$/);
97
+ if (envMatch) {
98
+ inEnv = true;
99
+ continue;
100
+ }
101
+ // Match [mcp_servers.name]
102
+ const serverMatch = trimmed.match(/^\[mcp_servers\.([^\].]+)\]$/);
103
+ if (serverMatch) {
104
+ // Save previous server
105
+ if (currentServer) {
106
+ if (Object.keys(currentEnv).length > 0)
107
+ currentEntry.env = currentEnv;
108
+ servers[currentServer] = currentEntry;
109
+ }
110
+ currentServer = serverMatch[1];
111
+ currentEntry = { command: '' };
112
+ currentEnv = {};
113
+ inEnv = false;
114
+ continue;
115
+ }
116
+ // New non-mcp section — flush current server
117
+ if (/^\[/.test(trimmed) && !/^\[mcp_servers/.test(trimmed)) {
118
+ if (currentServer) {
119
+ if (Object.keys(currentEnv).length > 0)
120
+ currentEntry.env = currentEnv;
121
+ servers[currentServer] = currentEntry;
122
+ currentServer = null;
123
+ currentEntry = { command: '' };
124
+ currentEnv = {};
125
+ }
126
+ inEnv = false;
127
+ continue;
128
+ }
129
+ // Key-value pair
130
+ const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
131
+ if (!kvMatch || !currentServer)
132
+ continue;
133
+ const [, key, rawValue] = kvMatch;
134
+ if (inEnv) {
135
+ currentEnv[key] = parseTomlValue(rawValue);
136
+ }
137
+ else if (key === 'command') {
138
+ currentEntry.command = parseTomlValue(rawValue);
139
+ }
140
+ else if (key === 'args') {
141
+ currentEntry.args = parseTomlArray(rawValue);
142
+ }
143
+ }
144
+ // Flush last server
145
+ if (currentServer) {
146
+ if (Object.keys(currentEnv).length > 0)
147
+ currentEntry.env = currentEnv;
148
+ servers[currentServer] = currentEntry;
149
+ }
150
+ return servers;
151
+ }
152
+ function parseTomlValue(raw) {
153
+ const trimmed = raw.trim();
154
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
155
+ return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
156
+ }
157
+ return trimmed;
158
+ }
159
+ function parseTomlArray(raw) {
160
+ const trimmed = raw.trim();
161
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']'))
162
+ return [];
163
+ const inner = trimmed.slice(1, -1);
164
+ const result = [];
165
+ const parts = inner.split(',');
166
+ for (const part of parts) {
167
+ const val = parseTomlValue(part.trim());
168
+ if (val.length > 0)
169
+ result.push(val);
170
+ }
171
+ return result;
172
+ }
173
+ /* ---- Skill readers ---- */
174
+ async function readClaudePlugins() {
175
+ const results = [];
176
+ // Read marketplace plugins
177
+ try {
178
+ const pluginsPath = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
179
+ results.push(...await readInstalledPlugins(pluginsPath));
180
+ }
181
+ catch {
182
+ // no plugins file
183
+ }
184
+ // Read local skills from ~/.claude/skills/
185
+ try {
186
+ const skillsDir = path.join(homedir(), '.claude', 'skills');
187
+ const localSkills = await readSkillDirs(skillsDir);
188
+ results.push(...localSkills);
189
+ }
190
+ catch {
191
+ // no skills dir
192
+ }
193
+ return results;
194
+ }
195
+ async function readCodexSkills() {
196
+ try {
197
+ 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);
201
+ }
202
+ catch {
203
+ return await readManagedPlugins({ agent: 'codex' });
204
+ }
205
+ }
206
+ async function readGeminiSkills() {
207
+ try {
208
+ const skillsDir = path.join(homedir(), '.gemini', 'skills');
209
+ const localSkills = await readSkillDirs(skillsDir);
210
+ const managedPlugins = await readManagedPlugins({ agent: 'gemini' });
211
+ return mergeManagedPluginsIntoSkills(localSkills, managedPlugins);
212
+ }
213
+ catch {
214
+ return await readManagedPlugins({ agent: 'gemini' });
215
+ }
216
+ }
217
+ /** Shared: read skill directories (Codex and Gemini use the same SKILL.md convention) */
218
+ async function readSkillDirs(skillsDir) {
219
+ const entries = await readdir(skillsDir, { withFileTypes: true });
220
+ const skills = [];
221
+ for (const entry of entries) {
222
+ if (entry.name.startsWith('.'))
223
+ continue;
224
+ if (entry.isDirectory()) {
225
+ skills.push({ name: entry.name, source: 'local', kind: 'skill' });
226
+ }
227
+ else if (entry.isSymbolicLink()) {
228
+ skills.push({ name: entry.name, source: 'linked', kind: 'skill' });
229
+ }
230
+ }
231
+ return skills;
232
+ }
@@ -65,7 +65,10 @@ export function createClaudeWriter() {
65
65
  };
66
66
  }
67
67
  function toClaudeFormat(config) {
68
- if (config.type === 'npm') {
68
+ if (config.kind === 'remote') {
69
+ throw new SyncError('Remote MCP servers are not supported in Claude sync.');
70
+ }
71
+ if (config.source === 'npm') {
69
72
  return {
70
73
  type: 'stdio',
71
74
  command: 'npx',
@@ -27,7 +27,8 @@ export function createCodexWriter() {
27
27
  const allServers = { ...options.mcpServers };
28
28
  // Always include brainctl itself
29
29
  allServers['brainctl'] = {
30
- type: 'npm',
30
+ kind: 'local',
31
+ source: 'npm',
31
32
  package: 'brainctl',
32
33
  };
33
34
  const mcpToml = buildMcpToml(allServers);
@@ -71,7 +72,10 @@ function buildMcpToml(servers) {
71
72
  const lines = [];
72
73
  for (const [name, config] of Object.entries(servers)) {
73
74
  lines.push(`[mcp_servers.${name}]`);
74
- if (config.type === 'npm') {
75
+ if (config.kind === 'remote') {
76
+ throw new SyncError('Remote MCP servers are not supported in Codex sync.');
77
+ }
78
+ if (config.source === 'npm') {
75
79
  lines.push(`command = "npx"`);
76
80
  lines.push(`args = ["-y", ${tomlString(config.package)}]`);
77
81
  }
@@ -67,7 +67,10 @@ export function createGeminiWriter() {
67
67
  };
68
68
  }
69
69
  function toGeminiFormat(config) {
70
- if (config.type === 'npm') {
70
+ if (config.kind === 'remote') {
71
+ throw new SyncError('Remote MCP servers are not supported in Gemini sync.');
72
+ }
73
+ if (config.source === 'npm') {
71
74
  return {
72
75
  command: 'npx',
73
76
  args: ['-y', config.package],
@@ -0,0 +1,17 @@
1
+ import type { AgentName } from '../../types.js';
2
+ import type { AgentSkillEntry } from './agent-reader.js';
3
+ export declare function readManagedPlugins(options: {
4
+ homeDir?: string;
5
+ agent: AgentName;
6
+ }): Promise<AgentSkillEntry[]>;
7
+ export declare function writeManagedPluginInstall(options: {
8
+ homeDir?: string;
9
+ agent: AgentName;
10
+ plugin: AgentSkillEntry;
11
+ }): Promise<void>;
12
+ export declare function removeManagedPluginInstall(options: {
13
+ homeDir?: string;
14
+ agent: AgentName;
15
+ pluginName: string;
16
+ }): Promise<void>;
17
+ export declare function mergeManagedPluginsIntoSkills(localSkills: AgentSkillEntry[], managedPlugins: AgentSkillEntry[]): AgentSkillEntry[];
@@ -0,0 +1,75 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ function getRegistryPath(homeDir) {
5
+ return path.join(homeDir, '.brainctl', 'managed-plugins.json');
6
+ }
7
+ export async function readManagedPlugins(options) {
8
+ const homeDir = options.homeDir ?? homedir();
9
+ const registryPath = getRegistryPath(homeDir);
10
+ try {
11
+ const source = await readFile(registryPath, 'utf8');
12
+ const parsed = JSON.parse(source);
13
+ return (parsed.agents?.[options.agent] ?? []).map((entry) => ({
14
+ ...entry,
15
+ kind: 'plugin',
16
+ managed: true,
17
+ }));
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ export async function writeManagedPluginInstall(options) {
24
+ const homeDir = options.homeDir ?? homedir();
25
+ const registryPath = getRegistryPath(homeDir);
26
+ const existing = await readRegistryFile(homeDir);
27
+ const currentEntries = existing.agents?.[options.agent] ?? [];
28
+ const nextEntries = [
29
+ ...currentEntries.filter((entry) => entry.name !== options.plugin.name),
30
+ {
31
+ ...options.plugin,
32
+ kind: 'plugin',
33
+ managed: true,
34
+ },
35
+ ].sort((left, right) => left.name.localeCompare(right.name));
36
+ const next = {
37
+ version: 1,
38
+ agents: {
39
+ ...existing.agents,
40
+ [options.agent]: nextEntries,
41
+ },
42
+ };
43
+ await mkdir(path.dirname(registryPath), { recursive: true });
44
+ await writeFile(registryPath, JSON.stringify(next, null, 2) + '\n', 'utf8');
45
+ }
46
+ export async function removeManagedPluginInstall(options) {
47
+ const homeDir = options.homeDir ?? homedir();
48
+ const registryPath = getRegistryPath(homeDir);
49
+ const existing = await readRegistryFile(homeDir);
50
+ const currentEntries = existing.agents?.[options.agent] ?? [];
51
+ const nextEntries = currentEntries.filter((entry) => entry.name !== options.pluginName);
52
+ const next = {
53
+ version: 1,
54
+ agents: {
55
+ ...existing.agents,
56
+ [options.agent]: nextEntries,
57
+ },
58
+ };
59
+ await mkdir(path.dirname(registryPath), { recursive: true });
60
+ await writeFile(registryPath, JSON.stringify(next, null, 2) + '\n', 'utf8');
61
+ }
62
+ export function mergeManagedPluginsIntoSkills(localSkills, managedPlugins) {
63
+ const pluginOwnedSkills = new Set(managedPlugins.flatMap((plugin) => plugin.pluginSkills ?? []));
64
+ const filteredLocalSkills = localSkills.filter((skill) => !pluginOwnedSkills.has(skill.name));
65
+ return [...managedPlugins, ...filteredLocalSkills];
66
+ }
67
+ async function readRegistryFile(homeDir) {
68
+ try {
69
+ const source = await readFile(getRegistryPath(homeDir), 'utf8');
70
+ return JSON.parse(source);
71
+ }
72
+ catch {
73
+ return { version: 1, agents: {} };
74
+ }
75
+ }
@@ -0,0 +1,2 @@
1
+ import type { AgentSkillEntry } from './agent-reader.js';
2
+ export declare function readInstalledPlugins(installedPluginsPath: string): Promise<AgentSkillEntry[]>;
@@ -0,0 +1,33 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function readInstalledPlugins(installedPluginsPath) {
4
+ const source = await readFile(installedPluginsPath, 'utf8');
5
+ const data = JSON.parse(source);
6
+ const results = [];
7
+ for (const [key, records] of Object.entries(data.plugins ?? {})) {
8
+ const [name, pluginSource] = key.split('@');
9
+ const installPath = records[0]?.installPath;
10
+ const pluginSkills = installPath ? await readPluginSkills(installPath) : [];
11
+ results.push({
12
+ name,
13
+ source: pluginSource,
14
+ kind: 'plugin',
15
+ installPath,
16
+ pluginSkills,
17
+ });
18
+ }
19
+ return results;
20
+ }
21
+ async function readPluginSkills(installPath) {
22
+ const skillsDir = path.join(installPath, 'skills');
23
+ try {
24
+ const entries = await readdir(skillsDir, { withFileTypes: true });
25
+ return entries
26
+ .filter((entry) => !entry.name.startsWith('.') && entry.isDirectory())
27
+ .map((entry) => entry.name)
28
+ .sort((left, right) => left.localeCompare(right));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
@@ -1,3 +1,4 @@
1
+ import { ProfileError } from '../errors.js';
1
2
  import { createClaudeWriter } from './sync/claude-writer.js';
2
3
  import { createCodexWriter } from './sync/codex-writer.js';
3
4
  import { createGeminiWriter } from './sync/gemini-writer.js';
@@ -21,6 +22,10 @@ export function createSyncService(dependencies = {}) {
21
22
  throw new Error('No active profile set. Run "brainctl profile use <name>" first.');
22
23
  }
23
24
  const profile = await profileService.get({ cwd, name: meta.active_profile });
25
+ const remoteMcpName = Object.entries(profile.mcps).find(([, config]) => config.kind === 'remote')?.[0];
26
+ if (remoteMcpName) {
27
+ throw new ProfileError(`Profile "${profile.name}" includes remote MCP "${remoteMcpName}". Remote MCP sync is not supported yet.`);
28
+ }
24
29
  const results = [];
25
30
  for (const agent of meta.agents) {
26
31
  const writer = writers[agent];
@@ -0,0 +1 @@
1
+ export declare function findExecutable(command: string): Promise<string | null>;
@@ -0,0 +1,38 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ export async function findExecutable(command) {
5
+ if (command.includes(path.sep)) {
6
+ return (await isExecutable(command)) ? command : null;
7
+ }
8
+ const pathEntries = (process.env.PATH ?? '')
9
+ .split(path.delimiter)
10
+ .filter((entry) => entry.length > 0);
11
+ const extensions = process.platform === 'win32'
12
+ ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM')
13
+ .split(';')
14
+ .filter((entry) => entry.length > 0)
15
+ : [''];
16
+ for (const pathEntry of pathEntries) {
17
+ for (const extension of extensions) {
18
+ const candidate = process.platform === 'win32' &&
19
+ extension.length > 0 &&
20
+ !command.toLowerCase().endsWith(extension.toLowerCase())
21
+ ? path.join(pathEntry, `${command}${extension}`)
22
+ : path.join(pathEntry, command);
23
+ if (await isExecutable(candidate)) {
24
+ return candidate;
25
+ }
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+ async function isExecutable(filePath) {
31
+ try {
32
+ await access(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
package/dist/types.d.ts CHANGED
@@ -55,20 +55,30 @@ export interface DiagnosticCheck {
55
55
  status: DiagnosticStatus;
56
56
  message: string;
57
57
  }
58
- export interface NpmMcpServerConfig {
59
- type: 'npm';
58
+ export interface LocalNpmMcpServerConfig {
59
+ kind: 'local';
60
+ source: 'npm';
60
61
  package: string;
61
62
  env?: Record<string, string>;
62
63
  }
63
- export interface BundledMcpServerConfig {
64
- type: 'bundled';
64
+ export interface LocalBundledMcpServerConfig {
65
+ kind: 'local';
66
+ source: 'bundled';
65
67
  path: string;
66
68
  install?: string;
67
69
  command: string;
68
70
  args?: string[];
69
71
  env?: Record<string, string>;
70
72
  }
71
- export type McpServerConfig = NpmMcpServerConfig | BundledMcpServerConfig;
73
+ export interface RemoteMcpServerConfig {
74
+ kind: 'remote';
75
+ transport: 'http' | 'sse';
76
+ url: string;
77
+ headers?: Record<string, string>;
78
+ env?: Record<string, string>;
79
+ }
80
+ export type LocalMcpServerConfig = LocalNpmMcpServerConfig | LocalBundledMcpServerConfig;
81
+ export type McpServerConfig = LocalMcpServerConfig | RemoteMcpServerConfig;
72
82
  export interface ProfileConfig {
73
83
  name: string;
74
84
  description?: string;