brainctl 0.1.22 → 0.1.25
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.
- package/dist/commands/mcp.d.ts +12 -0
- package/dist/commands/mcp.js +34 -2
- package/dist/services/agent/agent-config-service.d.ts +2 -0
- package/dist/services/agent/agent-config-service.js +106 -39
- package/dist/services/platform/recent-projects-service.d.ts +16 -0
- package/dist/services/platform/recent-projects-service.js +51 -0
- package/dist/services/sync/agent-reader.d.ts +2 -0
- package/dist/services/sync/agent-reader.js +64 -31
- package/dist/ui/routes.d.ts +2 -0
- package/dist/ui/routes.js +67 -10
- package/dist/web/assets/index-CQbRl-ip.css +2 -0
- package/dist/web/assets/index-Dpa_8Gkh.js +63 -0
- package/dist/web/assets/index-DpcVh6Yo.js +63 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-C3IdnvPZ.js +0 -63
- package/dist/web/assets/index-DeNgTy7u.css +0 -2
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -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
|
+
}>;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
66
|
-
delete state.mcpServers[key];
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
delete
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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()}`;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface FsLike {
|
|
2
|
+
readFile(p: string): Promise<string>;
|
|
3
|
+
writeFile(p: string, content: string): Promise<void>;
|
|
4
|
+
mkdir(p: string, opts?: {
|
|
5
|
+
recursive: boolean;
|
|
6
|
+
}): Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface RecentProjectsService {
|
|
9
|
+
read(): Promise<string[]>;
|
|
10
|
+
addRecent(cwd: string): Promise<string[]>;
|
|
11
|
+
}
|
|
12
|
+
export declare function createRecentProjectsService(deps: {
|
|
13
|
+
filePath: string;
|
|
14
|
+
fs?: FsLike;
|
|
15
|
+
}): RecentProjectsService;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_RECENTS = 20;
|
|
4
|
+
const FILE_VERSION = 1;
|
|
5
|
+
const defaultFs = {
|
|
6
|
+
readFile: (p) => readFile(p, 'utf8'),
|
|
7
|
+
writeFile: (p, content) => writeFile(p, content, 'utf8'),
|
|
8
|
+
mkdir: (p, opts) => mkdir(p, opts),
|
|
9
|
+
};
|
|
10
|
+
export function createRecentProjectsService(deps) {
|
|
11
|
+
const { filePath, fs: fsImpl = defaultFs } = deps;
|
|
12
|
+
async function readFile_() {
|
|
13
|
+
try {
|
|
14
|
+
const content = await fsImpl.readFile(filePath);
|
|
15
|
+
const parsed = JSON.parse(content);
|
|
16
|
+
if (typeof parsed === 'object' && parsed !== null && 'recents' in parsed) {
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === 'ENOENT') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function persist(recents) {
|
|
29
|
+
const dir = path.dirname(filePath);
|
|
30
|
+
await fsImpl.mkdir(dir, { recursive: true });
|
|
31
|
+
const data = { version: FILE_VERSION, recents };
|
|
32
|
+
await fsImpl.writeFile(filePath, JSON.stringify(data, null, 2));
|
|
33
|
+
}
|
|
34
|
+
async function read() {
|
|
35
|
+
const data = await readFile_();
|
|
36
|
+
if (!data)
|
|
37
|
+
return [];
|
|
38
|
+
return (data.recents ?? []).filter((e) => typeof e === 'string');
|
|
39
|
+
}
|
|
40
|
+
async function addRecent(cwd) {
|
|
41
|
+
if (!path.isAbsolute(cwd)) {
|
|
42
|
+
throw new Error(`cwd must be an absolute path, got: ${cwd}`);
|
|
43
|
+
}
|
|
44
|
+
const current = await read();
|
|
45
|
+
const deduplicated = current.filter((p) => p !== cwd);
|
|
46
|
+
const updated = [cwd, ...deduplicated].slice(0, MAX_RECENTS);
|
|
47
|
+
await persist(updated);
|
|
48
|
+
return updated;
|
|
49
|
+
}
|
|
50
|
+
return { read, addRecent };
|
|
51
|
+
}
|
|
@@ -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(
|
|
19
|
+
for (const [name, entry] of Object.entries(userServers)) {
|
|
22
20
|
if (isClaudeRemoteEntry(entry)) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
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.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { StatusService } from '../services/platform/status-service.js';
|
|
|
3
3
|
export interface UiRouteDependencies {
|
|
4
4
|
cwd: string;
|
|
5
5
|
statusService?: StatusService;
|
|
6
|
+
recentsFilePath?: string;
|
|
7
|
+
claudeJsonPath?: string;
|
|
6
8
|
}
|
|
7
9
|
export type UiRouteHandler = (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
|
8
10
|
export declare function createUiRouteHandler(dependencies: UiRouteDependencies): UiRouteHandler;
|