brainctl 0.1.22 → 0.1.23

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.
@@ -1,2 +1,14 @@
1
1
  import type { Command } from 'commander';
2
+ import { createUpdateCheckService } from '../services/platform/update-check-service.js';
2
3
  export declare function registerMcpCommand(program: Command): void;
4
+ export interface AutoUpdateDeps {
5
+ service?: ReturnType<typeof createUpdateCheckService>;
6
+ env?: NodeJS.ProcessEnv;
7
+ log?: (msg: string) => void;
8
+ relaunch?: (env: NodeJS.ProcessEnv) => number;
9
+ exit?: (code: number) => void;
10
+ }
11
+ export declare function autoUpdateAndRelaunch(deps?: AutoUpdateDeps): Promise<{
12
+ updated: boolean;
13
+ reason?: string;
14
+ }>;
@@ -7,16 +7,48 @@ export function registerMcpCommand(program) {
7
7
  .description('Start the brainctl MCP server (stdio transport)')
8
8
  .action(async () => {
9
9
  killPriorMcpServers();
10
+ await autoUpdateAndRelaunch();
10
11
  process.stdin.on('end', () => process.exit(0));
11
12
  process.stdin.on('close', () => process.exit(0));
12
13
  await startMcpServer({ cwd: process.cwd() });
13
14
  if (!process.env.BRAINCTL_NO_UPDATE_CHECK) {
14
- // Fire-and-forget after the server is up so cold-start isn't blocked
15
- // on a network round-trip (or `npm install brainctl@latest`).
16
15
  void notifyIfOutdated();
17
16
  }
18
17
  });
19
18
  }
19
+ export async function autoUpdateAndRelaunch(deps = {}) {
20
+ const env = deps.env ?? process.env;
21
+ if (env.BRAINCTL_NO_UPDATE_CHECK)
22
+ return { updated: false, reason: 'disabled' };
23
+ if (env.BRAINCTL_NO_AUTO_UPDATE)
24
+ return { updated: false, reason: 'disabled' };
25
+ // Guard against a re-launch loop if the new version still reports outdated.
26
+ if (env.BRAINCTL_SELF_UPDATED === '1')
27
+ return { updated: false, reason: 'already-updated' };
28
+ const log = deps.log ?? ((m) => process.stderr.write(m));
29
+ const relaunch = deps.relaunch ??
30
+ ((nextEnv) => {
31
+ const child = spawnSync(process.execPath, process.argv.slice(1), {
32
+ stdio: 'inherit',
33
+ env: nextEnv,
34
+ });
35
+ return child.status ?? 0;
36
+ });
37
+ const exit = deps.exit ?? ((code) => process.exit(code));
38
+ const service = deps.service ?? createUpdateCheckService();
39
+ const check = await service.check().catch(() => null);
40
+ if (!check || !check.isOutdated)
41
+ return { updated: false, reason: 'up-to-date' };
42
+ log(`brainctl: auto-updating ${check.current} -> ${check.latest}...\n`);
43
+ const result = await service.selfUpdate();
44
+ if (!result.success) {
45
+ log(`brainctl: self-update failed (${result.error ?? 'unknown'}); continuing on ${check.current}\n`);
46
+ return { updated: false, reason: 'install-failed' };
47
+ }
48
+ const status = relaunch({ ...env, BRAINCTL_SELF_UPDATED: '1' });
49
+ exit(status);
50
+ return { updated: true };
51
+ }
20
52
  function killPriorMcpServers() {
21
53
  const self = process.pid;
22
54
  const ppid = process.ppid;
@@ -14,11 +14,13 @@ export interface AgentConfigService {
14
14
  key: string;
15
15
  entry?: AgentMcpEntry;
16
16
  remoteEntry?: PortableRemoteMcpMetadata;
17
+ scope?: 'global' | 'project';
17
18
  }): Promise<void>;
18
19
  removeMcp(options: {
19
20
  cwd: string;
20
21
  agent: AgentName;
21
22
  key: string;
23
+ scope?: 'global' | 'project';
22
24
  }): Promise<void>;
23
25
  copySkill(options: {
24
26
  sourceAgent: AgentName;
@@ -25,52 +25,83 @@ export function createAgentConfigService(dependencies = {}) {
25
25
  return results;
26
26
  },
27
27
  async addMcp(options) {
28
- const { cwd, agent, key, entry, remoteEntry } = options;
28
+ const { cwd, agent, key, entry, remoteEntry, scope = 'global' } = options;
29
29
  const preflight = await mcpPreflightService.execute({ cwd, agent, key, entry, remoteEntry });
30
30
  const firstError = preflight.checks.find((check) => check.status === 'error');
31
31
  if (firstError) {
32
32
  throw new ValidationError(`MCP "${key}" cannot be added to ${agent}: ${firstError.message}`);
33
33
  }
34
34
  if (agent === 'claude') {
35
- await mutateClaudeConfig(cwd, (servers) => {
35
+ await mutateClaudeConfig(cwd, scope, (servers) => {
36
36
  servers[key] = remoteEntry ? toClaudeRemoteEntry(remoteEntry) : toClaudeEntry(entry);
37
37
  });
38
38
  }
39
39
  else if (agent === 'codex') {
40
- await mutateCodexConfig(cwd, (state) => {
41
- delete state.mcpServers[key];
42
- delete state.remoteMcpServers[key];
43
- if (remoteEntry) {
44
- state.remoteMcpServers[key] = remoteEntry;
45
- }
46
- else {
47
- state.mcpServers[key] = entry;
48
- }
49
- });
40
+ if (scope === 'project') {
41
+ await mutateBrainctlProjectMcps(cwd, 'codex', (state) => {
42
+ delete state.mcpServers[key];
43
+ delete state.remoteMcpServers[key];
44
+ if (remoteEntry) {
45
+ state.remoteMcpServers[key] = remoteEntry;
46
+ }
47
+ else {
48
+ state.mcpServers[key] = entry;
49
+ }
50
+ });
51
+ }
52
+ else {
53
+ await mutateCodexConfig(cwd, (state) => {
54
+ delete state.mcpServers[key];
55
+ delete state.remoteMcpServers[key];
56
+ if (remoteEntry) {
57
+ state.remoteMcpServers[key] = remoteEntry;
58
+ }
59
+ else {
60
+ state.mcpServers[key] = entry;
61
+ }
62
+ });
63
+ }
50
64
  }
51
65
  else if (agent === 'gemini') {
52
- await mutateGeminiConfig(cwd, (servers) => {
53
- servers[key] = remoteEntry ? toGeminiRemoteEntry(remoteEntry) : toGeminiEntry(entry);
54
- });
66
+ if (scope === 'project') {
67
+ await mutateBrainctlProjectMcps(cwd, 'gemini', (state) => {
68
+ delete state.mcpServers[key];
69
+ delete state.remoteMcpServers[key];
70
+ if (remoteEntry) {
71
+ state.remoteMcpServers[key] = remoteEntry;
72
+ }
73
+ else {
74
+ state.mcpServers[key] = entry;
75
+ }
76
+ });
77
+ }
78
+ else {
79
+ await mutateGeminiConfig(cwd, (servers) => {
80
+ servers[key] = remoteEntry ? toGeminiRemoteEntry(remoteEntry) : toGeminiEntry(entry);
81
+ });
82
+ }
55
83
  }
56
84
  },
57
85
  async removeMcp(options) {
58
- const { cwd, agent, key } = options;
86
+ const { cwd, agent, key, scope = 'global' } = options;
59
87
  if (agent === 'claude') {
60
- await mutateClaudeConfig(cwd, (servers) => {
61
- delete servers[key];
62
- });
88
+ await mutateClaudeConfig(cwd, scope, (servers) => { delete servers[key]; });
63
89
  }
64
90
  else if (agent === 'codex') {
65
- await mutateCodexConfig(cwd, (state) => {
66
- delete state.mcpServers[key];
67
- delete state.remoteMcpServers[key];
68
- });
91
+ if (scope === 'project') {
92
+ await mutateBrainctlProjectMcps(cwd, 'codex', (state) => { delete state.mcpServers[key]; delete state.remoteMcpServers[key]; });
93
+ }
94
+ else {
95
+ await mutateCodexConfig(cwd, (state) => { delete state.mcpServers[key]; delete state.remoteMcpServers[key]; });
96
+ }
69
97
  }
70
98
  else if (agent === 'gemini') {
71
- await mutateGeminiConfig(cwd, (servers) => {
72
- delete servers[key];
73
- });
99
+ if (scope === 'project') {
100
+ await mutateBrainctlProjectMcps(cwd, 'gemini', (state) => { delete state.mcpServers[key]; delete state.remoteMcpServers[key]; });
101
+ }
102
+ else {
103
+ await mutateGeminiConfig(cwd, (servers) => { delete servers[key]; });
104
+ }
74
105
  }
75
106
  },
76
107
  async copySkill(options) {
@@ -98,7 +129,7 @@ export function createAgentConfigService(dependencies = {}) {
98
129
  };
99
130
  }
100
131
  /* ---- Claude: JSON with projects[cwd].mcpServers ---- */
101
- async function mutateClaudeConfig(cwd, mutate) {
132
+ async function mutateClaudeConfig(cwd, scope, mutate) {
102
133
  const configPath = path.join(homedir(), '.claude.json');
103
134
  let existing = {};
104
135
  try {
@@ -108,18 +139,20 @@ async function mutateClaudeConfig(cwd, mutate) {
108
139
  catch {
109
140
  // fresh config
110
141
  }
111
- // Apply mutation to user-scoped (top-level) MCPs
112
- const userServers = (existing.mcpServers ?? {});
113
- mutate(userServers);
114
- existing.mcpServers = userServers;
115
- // Apply mutation to project-scoped MCPs
116
- const projects = (existing.projects ?? {});
117
- const projectConfig = projects[cwd] ?? {};
118
- const servers = (projectConfig.mcpServers ?? {});
119
- mutate(servers);
120
- projectConfig.mcpServers = servers;
121
- projects[cwd] = projectConfig;
122
- existing.projects = projects;
142
+ if (scope === 'global') {
143
+ const servers = (existing.mcpServers ?? {});
144
+ mutate(servers);
145
+ existing.mcpServers = servers;
146
+ }
147
+ else {
148
+ const projects = (existing.projects ?? {});
149
+ const projectConfig = (projects[cwd] ?? {});
150
+ const servers = (projectConfig.mcpServers ?? {});
151
+ mutate(servers);
152
+ projectConfig.mcpServers = servers;
153
+ projects[cwd] = projectConfig;
154
+ existing.projects = projects;
155
+ }
123
156
  await atomicWriteJson(configPath, existing);
124
157
  }
125
158
  function toClaudeEntry(entry) {
@@ -237,6 +270,40 @@ function toGeminiRemoteEntry(entry) {
237
270
  ...(entry.env ? { env: entry.env } : {}),
238
271
  };
239
272
  }
273
+ /* ---- Brainctl project MCPs: .brainctl/project-mcps.json ---- */
274
+ async function mutateBrainctlProjectMcps(cwd, agent, mutate) {
275
+ const filePath = path.join(cwd, '.brainctl', 'project-mcps.json');
276
+ let data = {};
277
+ try {
278
+ data = JSON.parse(await readFile(filePath, 'utf8'));
279
+ }
280
+ catch {
281
+ // fresh file
282
+ }
283
+ const agentData = (data[agent] ?? {});
284
+ const rawServers = (agentData.mcpServers ?? {});
285
+ const mcpServers = {};
286
+ const remoteMcpServers = {};
287
+ for (const [name, entry] of Object.entries(rawServers)) {
288
+ if (typeof entry.url === 'string' && entry.url) {
289
+ remoteMcpServers[name] = { transport: entry.transport ?? 'http', url: entry.url };
290
+ }
291
+ else {
292
+ mcpServers[name] = { command: String(entry.command ?? ''), args: Array.isArray(entry.args) ? entry.args.map(String) : undefined };
293
+ }
294
+ }
295
+ mutate({ mcpServers, remoteMcpServers });
296
+ const newServers = {};
297
+ for (const [name, entry] of Object.entries(mcpServers)) {
298
+ newServers[name] = { command: entry.command, ...(entry.args ? { args: entry.args } : {}), ...(entry.env ? { env: entry.env } : {}) };
299
+ }
300
+ for (const [name, entry] of Object.entries(remoteMcpServers)) {
301
+ newServers[name] = { transport: entry.transport, url: entry.url };
302
+ }
303
+ data[agent] = { mcpServers: newServers };
304
+ await mkdir(path.join(cwd, '.brainctl'), { recursive: true });
305
+ await atomicWriteJson(filePath, data);
306
+ }
240
307
  /* ---- Shared helpers ---- */
241
308
  async function backupFile(filePath) {
242
309
  const backupPath = `${filePath}.bak.${formatTimestamp()}`;
@@ -27,6 +27,8 @@ export interface AgentLiveConfig {
27
27
  exists: boolean;
28
28
  mcpServers: Record<string, AgentMcpEntry>;
29
29
  remoteMcpServers: Record<string, PortableRemoteMcpMetadata>;
30
+ projectMcpServers: Record<string, AgentMcpEntry>;
31
+ projectRemoteMcpServers: Record<string, PortableRemoteMcpMetadata>;
30
32
  skills: AgentSkillEntry[];
31
33
  }
32
34
  export interface AgentConfigReader {
@@ -10,39 +10,40 @@ 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
13
  const userServers = (data.mcpServers ?? {});
15
14
  const projects = (data.projects ?? {});
16
- const projectConfig = projects[options.cwd] ?? {};
15
+ const projectConfig = (projects[options.cwd] ?? {});
17
16
  const projectServers = (projectConfig.mcpServers ?? {});
18
- const rawServers = { ...userServers, ...projectServers };
19
17
  const mcpServers = {};
20
18
  const remoteMcpServers = {};
21
- for (const [name, entry] of Object.entries(rawServers)) {
19
+ for (const [name, entry] of Object.entries(userServers)) {
22
20
  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;
21
+ remoteMcpServers[name] = { transport: entry.type === 'sse' ? 'sse' : 'http', url: String(entry.url ?? ''), headers: parseEnvObject(entry.headers), env: parseEnvObject(entry.env) };
22
+ }
23
+ else {
24
+ mcpServers[name] = { command: String(entry.command ?? ''), args: Array.isArray(entry.args) ? entry.args.map(String) : undefined, env: parseEnvObject(entry.env) };
25
+ }
26
+ }
27
+ const projectMcpServers = {};
28
+ const projectRemoteMcpServers = {};
29
+ for (const [name, entry] of Object.entries(projectServers)) {
30
+ if (isClaudeRemoteEntry(entry)) {
31
+ projectRemoteMcpServers[name] = { transport: entry.type === 'sse' ? 'sse' : 'http', url: String(entry.url ?? ''), headers: parseEnvObject(entry.headers), env: parseEnvObject(entry.env) };
32
+ }
33
+ else {
34
+ projectMcpServers[name] = { command: String(entry.command ?? ''), args: Array.isArray(entry.args) ? entry.args.map(String) : undefined, env: parseEnvObject(entry.env) };
31
35
  }
32
- mcpServers[name] = {
33
- command: String(entry.command ?? ''),
34
- args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
35
- env: parseEnvObject(entry.env),
36
- };
37
36
  }
38
37
  const skills = await readClaudePlugins();
39
- const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, skills });
38
+ const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, projectMcpServers, projectRemoteMcpServers, skills });
40
39
  return {
41
40
  agent: 'claude',
42
41
  configPath,
43
42
  exists: true,
44
43
  mcpServers: filtered.mcpServers,
45
44
  remoteMcpServers: filtered.remoteMcpServers,
45
+ projectMcpServers: filtered.projectMcpServers,
46
+ projectRemoteMcpServers: filtered.projectRemoteMcpServers,
46
47
  skills,
47
48
  };
48
49
  }
@@ -54,6 +55,8 @@ export function createClaudeReader() {
54
55
  exists: false,
55
56
  mcpServers: {},
56
57
  remoteMcpServers: {},
58
+ projectMcpServers: {},
59
+ projectRemoteMcpServers: {},
57
60
  skills,
58
61
  };
59
62
  }
@@ -62,19 +65,22 @@ export function createClaudeReader() {
62
65
  }
63
66
  export function createCodexReader() {
64
67
  return {
65
- async read() {
68
+ async read(options) {
66
69
  const configPath = path.join(homedir(), '.codex', 'config.toml');
70
+ const { projectMcpServers, projectRemoteMcpServers } = await readBrainctlProjectMcps(options.cwd, 'codex');
67
71
  try {
68
72
  const source = await readFile(configPath, 'utf8');
69
73
  const { mcpServers, remoteMcpServers } = parseCodexToml(source);
70
74
  const skills = await readCodexSkills();
71
- const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, skills });
75
+ const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, projectMcpServers, projectRemoteMcpServers, skills });
72
76
  return {
73
77
  agent: 'codex',
74
78
  configPath,
75
79
  exists: true,
76
80
  mcpServers: filtered.mcpServers,
77
81
  remoteMcpServers: filtered.remoteMcpServers,
82
+ projectMcpServers: filtered.projectMcpServers,
83
+ projectRemoteMcpServers: filtered.projectRemoteMcpServers,
78
84
  skills,
79
85
  };
80
86
  }
@@ -86,6 +92,8 @@ export function createCodexReader() {
86
92
  exists: false,
87
93
  mcpServers: {},
88
94
  remoteMcpServers: {},
95
+ projectMcpServers,
96
+ projectRemoteMcpServers,
89
97
  skills,
90
98
  };
91
99
  }
@@ -94,8 +102,9 @@ export function createCodexReader() {
94
102
  }
95
103
  export function createGeminiReader() {
96
104
  return {
97
- async read() {
105
+ async read(options) {
98
106
  const configPath = path.join(homedir(), '.gemini', 'settings.json');
107
+ const { projectMcpServers, projectRemoteMcpServers } = await readBrainctlProjectMcps(options.cwd, 'gemini');
99
108
  let rawServers = {};
100
109
  let exists = false;
101
110
  try {
@@ -115,33 +124,57 @@ export function createGeminiReader() {
115
124
  remoteMcpServers[name] = remoteEntry;
116
125
  continue;
117
126
  }
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
- };
127
+ mcpServers[name] = { command: String(entry.command ?? ''), args: Array.isArray(entry.args) ? entry.args.map(String) : undefined, env: parseEnvObject(entry.env) };
123
128
  }
124
129
  const skills = await readGeminiSkills();
125
- const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, skills });
130
+ const filtered = filterPluginOwnedMcps({ mcpServers, remoteMcpServers, projectMcpServers, projectRemoteMcpServers, skills });
126
131
  return {
127
132
  agent: 'gemini',
128
133
  configPath,
129
134
  exists,
130
135
  mcpServers: filtered.mcpServers,
131
136
  remoteMcpServers: filtered.remoteMcpServers,
137
+ projectMcpServers: filtered.projectMcpServers,
138
+ projectRemoteMcpServers: filtered.projectRemoteMcpServers,
132
139
  skills,
133
140
  };
134
141
  },
135
142
  };
136
143
  }
144
+ async function readBrainctlProjectMcps(cwd, agent) {
145
+ try {
146
+ const filePath = path.join(cwd, '.brainctl', 'project-mcps.json');
147
+ const data = JSON.parse(await readFile(filePath, 'utf8'));
148
+ const agentData = (data[agent] ?? {});
149
+ const rawServers = (agentData.mcpServers ?? {});
150
+ const projectMcpServers = {};
151
+ const projectRemoteMcpServers = {};
152
+ for (const [name, entry] of Object.entries(rawServers)) {
153
+ if (typeof entry.url === 'string' && entry.url) {
154
+ projectRemoteMcpServers[name] = { transport: entry.transport ?? 'http', url: entry.url, headers: parseEnvObject(entry.headers), env: parseEnvObject(entry.env) };
155
+ }
156
+ else {
157
+ projectMcpServers[name] = { command: String(entry.command ?? ''), args: Array.isArray(entry.args) ? entry.args.map(String) : undefined, env: parseEnvObject(entry.env) };
158
+ }
159
+ }
160
+ return { projectMcpServers, projectRemoteMcpServers };
161
+ }
162
+ catch {
163
+ return { projectMcpServers: {}, projectRemoteMcpServers: {} };
164
+ }
165
+ }
137
166
  function filterPluginOwnedMcps(options) {
138
167
  const pluginOwned = new Set(options.skills.flatMap((skill) => skill.pluginMcps ?? []));
139
168
  if (pluginOwned.size === 0) {
140
- return { mcpServers: options.mcpServers, remoteMcpServers: options.remoteMcpServers };
169
+ return { mcpServers: options.mcpServers, remoteMcpServers: options.remoteMcpServers, projectMcpServers: options.projectMcpServers, projectRemoteMcpServers: options.projectRemoteMcpServers };
141
170
  }
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 };
171
+ const notOwned = ([key]) => !pluginOwned.has(key);
172
+ return {
173
+ mcpServers: Object.fromEntries(Object.entries(options.mcpServers).filter(notOwned)),
174
+ remoteMcpServers: Object.fromEntries(Object.entries(options.remoteMcpServers).filter(notOwned)),
175
+ projectMcpServers: Object.fromEntries(Object.entries(options.projectMcpServers).filter(notOwned)),
176
+ projectRemoteMcpServers: Object.fromEntries(Object.entries(options.projectRemoteMcpServers).filter(notOwned)),
177
+ };
145
178
  }
146
179
  function parseEnvObject(value) {
147
180
  if (!value || typeof value !== 'object' || Array.isArray(value))
package/dist/ui/routes.js CHANGED
@@ -289,6 +289,7 @@ export function createUiRouteHandler(dependencies) {
289
289
  key: data.key,
290
290
  entry: data.entry,
291
291
  remoteEntry: data.remoteEntry,
292
+ scope: data.scope === 'project' ? 'project' : 'global',
292
293
  });
293
294
  return sendJson(response, 200, { ok: true });
294
295
  }
@@ -297,11 +298,13 @@ export function createUiRouteHandler(dependencies) {
297
298
  }
298
299
  }
299
300
  if (request.method === 'DELETE' && mcpKey) {
301
+ const scope = url.searchParams.get('scope') === 'project' ? 'project' : 'global';
300
302
  try {
301
303
  await agentConfigService.removeMcp({
302
304
  cwd: dependencies.cwd,
303
305
  agent: agentName,
304
306
  key: mcpKey,
307
+ scope,
305
308
  });
306
309
  return sendJson(response, 200, { ok: true });
307
310
  }