brainctl 0.1.21 → 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.
- 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/sync/agent-reader.d.ts +2 -0
- package/dist/services/sync/agent-reader.js +67 -33
- package/dist/ui/routes.js +33 -0
- package/dist/web/assets/index-DwjP_DgF.js +63 -0
- package/dist/web/assets/index-WpJOVUJR.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BWIFiVAz.js +0 -63
- package/dist/web/assets/index-CdsjWk34.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()}`;
|
|
@@ -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))
|
|
@@ -363,11 +396,12 @@ async function readSkillDirs(skillsDir) {
|
|
|
363
396
|
for (const entry of entries) {
|
|
364
397
|
if (entry.name.startsWith('.'))
|
|
365
398
|
continue;
|
|
399
|
+
const installPath = path.join(skillsDir, entry.name);
|
|
366
400
|
if (entry.isDirectory()) {
|
|
367
|
-
skills.push({ name: entry.name, source: 'local', kind: 'skill' });
|
|
401
|
+
skills.push({ name: entry.name, source: 'local', kind: 'skill', installPath });
|
|
368
402
|
}
|
|
369
403
|
else if (entry.isSymbolicLink()) {
|
|
370
|
-
skills.push({ name: entry.name, source: 'linked', kind: 'skill' });
|
|
404
|
+
skills.push({ name: entry.name, source: 'linked', kind: 'skill', installPath });
|
|
371
405
|
}
|
|
372
406
|
}
|
|
373
407
|
return skills;
|
package/dist/ui/routes.js
CHANGED
|
@@ -50,6 +50,36 @@ export function createUiRouteHandler(dependencies) {
|
|
|
50
50
|
const configs = await agentConfigService.readAll({ cwd: dependencies.cwd });
|
|
51
51
|
return sendJson(response, 200, configs);
|
|
52
52
|
}
|
|
53
|
+
case '/api/open-folder': {
|
|
54
|
+
if (request.method !== 'POST') {
|
|
55
|
+
return sendJson(response, 405, { error: 'Method not allowed' });
|
|
56
|
+
}
|
|
57
|
+
const body = await readJsonBody(request);
|
|
58
|
+
if (!body.ok) {
|
|
59
|
+
return sendJson(response, 400, { error: 'Invalid JSON body' });
|
|
60
|
+
}
|
|
61
|
+
const { path: folderPath } = (body.value ?? {});
|
|
62
|
+
if (!folderPath || typeof folderPath !== 'string') {
|
|
63
|
+
return sendJson(response, 400, { error: 'path is required' });
|
|
64
|
+
}
|
|
65
|
+
if (!existsSync(folderPath)) {
|
|
66
|
+
return sendJson(response, 404, { error: `Path not found: ${folderPath}` });
|
|
67
|
+
}
|
|
68
|
+
const opener = process.platform === 'darwin'
|
|
69
|
+
? 'open'
|
|
70
|
+
: process.platform === 'win32'
|
|
71
|
+
? 'explorer'
|
|
72
|
+
: 'xdg-open';
|
|
73
|
+
try {
|
|
74
|
+
spawn(opener, [folderPath], { detached: true, stdio: 'ignore' }).unref();
|
|
75
|
+
return sendJson(response, 200, { ok: true, path: folderPath });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return sendJson(response, 500, {
|
|
79
|
+
error: error instanceof Error ? error.message : 'Failed to open folder',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
53
83
|
case '/api/profiles': {
|
|
54
84
|
if (request.method === 'GET') {
|
|
55
85
|
const result = await profileService.list({ cwd: dependencies.cwd });
|
|
@@ -259,6 +289,7 @@ export function createUiRouteHandler(dependencies) {
|
|
|
259
289
|
key: data.key,
|
|
260
290
|
entry: data.entry,
|
|
261
291
|
remoteEntry: data.remoteEntry,
|
|
292
|
+
scope: data.scope === 'project' ? 'project' : 'global',
|
|
262
293
|
});
|
|
263
294
|
return sendJson(response, 200, { ok: true });
|
|
264
295
|
}
|
|
@@ -267,11 +298,13 @@ export function createUiRouteHandler(dependencies) {
|
|
|
267
298
|
}
|
|
268
299
|
}
|
|
269
300
|
if (request.method === 'DELETE' && mcpKey) {
|
|
301
|
+
const scope = url.searchParams.get('scope') === 'project' ? 'project' : 'global';
|
|
270
302
|
try {
|
|
271
303
|
await agentConfigService.removeMcp({
|
|
272
304
|
cwd: dependencies.cwd,
|
|
273
305
|
agent: agentName,
|
|
274
306
|
key: mcpKey,
|
|
307
|
+
scope,
|
|
275
308
|
});
|
|
276
309
|
return sendJson(response, 200, { ok: true });
|
|
277
310
|
}
|