brainctl 0.1.6 → 0.1.9
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/README.md +215 -136
- package/dist/cli.js +40 -0
- package/dist/commands/mcp.js +35 -0
- package/dist/commands/profile.js +35 -2
- package/dist/executor/resolver.js +1 -38
- package/dist/mcp/server.js +82 -2
- package/dist/services/agent-config-service.d.ts +20 -3
- package/dist/services/agent-config-service.js +84 -16
- package/dist/services/agent-converter-service.d.ts +21 -0
- package/dist/services/agent-converter-service.js +182 -0
- package/dist/services/credential-redaction-service.d.ts +13 -0
- package/dist/services/credential-redaction-service.js +89 -0
- package/dist/services/credential-resolution-service.d.ts +11 -0
- package/dist/services/credential-resolution-service.js +69 -0
- package/dist/services/mcp-preflight-service.d.ts +26 -0
- package/dist/services/mcp-preflight-service.js +238 -0
- package/dist/services/plugin-install-service.d.ts +135 -0
- package/dist/services/plugin-install-service.js +601 -0
- package/dist/services/portable-mcp-classifier.d.ts +12 -0
- package/dist/services/portable-mcp-classifier.js +116 -0
- package/dist/services/portable-profile-pack-service.d.ts +26 -0
- package/dist/services/portable-profile-pack-service.js +264 -0
- package/dist/services/profile-export-service.d.ts +15 -3
- package/dist/services/profile-export-service.js +10 -57
- package/dist/services/profile-import-service.d.ts +9 -1
- package/dist/services/profile-import-service.js +266 -11
- package/dist/services/profile-service.d.ts +1 -0
- package/dist/services/profile-service.js +128 -32
- package/dist/services/runtime-detector.d.ts +9 -0
- package/dist/services/runtime-detector.js +130 -0
- package/dist/services/skill-paths.d.ts +4 -0
- package/dist/services/skill-paths.js +26 -0
- package/dist/services/skill-preflight-service.d.ts +23 -0
- package/dist/services/skill-preflight-service.js +40 -0
- package/dist/services/sync/agent-reader.d.ts +14 -0
- package/dist/services/sync/agent-reader.js +198 -45
- package/dist/services/sync/claude-writer.js +4 -7
- package/dist/services/sync/codex-writer.d.ts +1 -0
- package/dist/services/sync/codex-writer.js +25 -8
- package/dist/services/sync/gemini-writer.js +9 -8
- package/dist/services/sync/managed-plugin-registry.d.ts +17 -0
- package/dist/services/sync/managed-plugin-registry.js +75 -0
- package/dist/services/sync/plugin-skill-reader.d.ts +7 -0
- package/dist/services/sync/plugin-skill-reader.js +174 -0
- package/dist/services/sync-service.js +6 -1
- package/dist/services/update-check-service.d.ts +33 -0
- package/dist/services/update-check-service.js +128 -0
- package/dist/system/executables.d.ts +1 -0
- package/dist/system/executables.js +38 -0
- package/dist/types.d.ts +62 -5
- package/dist/ui/routes.js +293 -8
- package/dist/web/assets/index-Cdb5hbxM.css +1 -0
- package/dist/web/assets/index-gN83hZYA.js +65 -0
- package/dist/web/favicon-light.svg +13 -0
- package/dist/web/favicon.svg +13 -0
- package/dist/web/index.html +7 -2
- package/package.json +9 -1
- package/dist/web/assets/index-364NYWPA.css +0 -1
- package/dist/web/assets/index-BmfE7rus.js +0 -16
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function getSkillDir(agent, skillName) {
|
|
4
|
+
const safeName = path.basename(skillName);
|
|
5
|
+
if (agent === 'claude')
|
|
6
|
+
return path.join(homedir(), '.claude', 'skills', safeName);
|
|
7
|
+
if (agent === 'codex')
|
|
8
|
+
return path.join(homedir(), '.codex', 'skills', safeName);
|
|
9
|
+
if (agent === 'gemini')
|
|
10
|
+
return path.join(homedir(), '.gemini', 'skills', safeName);
|
|
11
|
+
throw new Error(`Skill management is not supported for ${agent}`);
|
|
12
|
+
}
|
|
13
|
+
export function getAgentFilePath(agent, agentName) {
|
|
14
|
+
const safeName = path.basename(agentName);
|
|
15
|
+
if (agent === 'claude')
|
|
16
|
+
return path.join(homedir(), '.claude', 'agents', `${safeName}.md`);
|
|
17
|
+
if (agent === 'codex')
|
|
18
|
+
return path.join(homedir(), '.codex', 'agents', `${safeName}.toml`);
|
|
19
|
+
throw new Error(`Subagent management is not supported for ${agent}`);
|
|
20
|
+
}
|
|
21
|
+
export function getCommandFilePath(agent, commandName) {
|
|
22
|
+
const safeName = path.basename(commandName);
|
|
23
|
+
if (agent === 'claude')
|
|
24
|
+
return path.join(homedir(), '.claude', 'commands', `${safeName}.md`);
|
|
25
|
+
throw new Error(`Slash-command management is not supported for ${agent}`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentName } from '../types.js';
|
|
2
|
+
export interface SkillPreflightCheck {
|
|
3
|
+
label: string;
|
|
4
|
+
status: 'ok' | 'warn' | 'error';
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SkillPreflightResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
checks: SkillPreflightCheck[];
|
|
10
|
+
}
|
|
11
|
+
export interface SkillPreflightService {
|
|
12
|
+
execute(options: {
|
|
13
|
+
sourceAgent: AgentName;
|
|
14
|
+
targetAgent: AgentName;
|
|
15
|
+
skillName: string;
|
|
16
|
+
source?: string;
|
|
17
|
+
}): Promise<SkillPreflightResult>;
|
|
18
|
+
}
|
|
19
|
+
interface SkillPreflightDependencies {
|
|
20
|
+
pathExists?: (targetPath: string) => Promise<boolean>;
|
|
21
|
+
}
|
|
22
|
+
export declare function createSkillPreflightService(dependencies?: SkillPreflightDependencies): SkillPreflightService;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { getSkillDir } from './skill-paths.js';
|
|
3
|
+
export function createSkillPreflightService(dependencies = {}) {
|
|
4
|
+
const pathExists = dependencies.pathExists ?? defaultPathExists;
|
|
5
|
+
return {
|
|
6
|
+
async execute(options) {
|
|
7
|
+
const checks = [];
|
|
8
|
+
if (options.source && options.source !== 'local' && options.source !== 'linked') {
|
|
9
|
+
checks.push({
|
|
10
|
+
label: 'Source',
|
|
11
|
+
status: 'error',
|
|
12
|
+
message: `Only local skill folders can be copied today. "${options.skillName}" is a plugin/managed entry from ${options.source}.`,
|
|
13
|
+
});
|
|
14
|
+
return { ok: false, checks };
|
|
15
|
+
}
|
|
16
|
+
const sourceDir = getSkillDir(options.sourceAgent, options.skillName);
|
|
17
|
+
const exists = await pathExists(sourceDir);
|
|
18
|
+
checks.push({
|
|
19
|
+
label: 'Source',
|
|
20
|
+
status: exists ? 'ok' : 'error',
|
|
21
|
+
message: exists
|
|
22
|
+
? `Skill folder was found: ${sourceDir}`
|
|
23
|
+
: `Skill folder was not found: ${sourceDir}`,
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
ok: exists,
|
|
27
|
+
checks,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function defaultPathExists(targetPath) {
|
|
33
|
+
try {
|
|
34
|
+
await stat(targetPath);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -4,15 +4,29 @@ export interface AgentMcpEntry {
|
|
|
4
4
|
args?: string[];
|
|
5
5
|
env?: Record<string, string>;
|
|
6
6
|
}
|
|
7
|
+
export interface PortableRemoteMcpMetadata {
|
|
8
|
+
transport: 'http' | 'sse';
|
|
9
|
+
url: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
}
|
|
7
13
|
export interface AgentSkillEntry {
|
|
8
14
|
name: string;
|
|
9
15
|
source?: string;
|
|
16
|
+
kind?: 'skill' | 'plugin';
|
|
17
|
+
pluginSkills?: string[];
|
|
18
|
+
pluginMcps?: string[];
|
|
19
|
+
pluginAgents?: string[];
|
|
20
|
+
pluginCommands?: string[];
|
|
21
|
+
installPath?: string;
|
|
22
|
+
managed?: boolean;
|
|
10
23
|
}
|
|
11
24
|
export interface AgentLiveConfig {
|
|
12
25
|
agent: AgentName;
|
|
13
26
|
configPath: string;
|
|
14
27
|
exists: boolean;
|
|
15
28
|
mcpServers: Record<string, AgentMcpEntry>;
|
|
29
|
+
remoteMcpServers: Record<string, PortableRemoteMcpMetadata>;
|
|
16
30
|
skills: AgentSkillEntry[];
|
|
17
31
|
}
|
|
18
32
|
export interface AgentConfigReader {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFile, readdir } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { mergeManagedPluginsIntoSkills, readManagedPlugins, } from './managed-plugin-registry.js';
|
|
5
|
+
import { readCodexPlugins, readInstalledPlugins } from './plugin-skill-reader.js';
|
|
4
6
|
export function createClaudeReader() {
|
|
5
7
|
return {
|
|
6
8
|
async read(options) {
|
|
@@ -8,11 +10,25 @@ export function createClaudeReader() {
|
|
|
8
10
|
try {
|
|
9
11
|
const source = await readFile(configPath, 'utf8');
|
|
10
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 ?? {});
|
|
11
15
|
const projects = (data.projects ?? {});
|
|
12
16
|
const projectConfig = projects[options.cwd] ?? {};
|
|
13
|
-
const
|
|
17
|
+
const projectServers = (projectConfig.mcpServers ?? {});
|
|
18
|
+
const rawServers = { ...userServers, ...projectServers };
|
|
14
19
|
const mcpServers = {};
|
|
20
|
+
const remoteMcpServers = {};
|
|
15
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
|
+
}
|
|
16
32
|
mcpServers[name] = {
|
|
17
33
|
command: String(entry.command ?? ''),
|
|
18
34
|
args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
|
|
@@ -20,11 +36,26 @@ export function createClaudeReader() {
|
|
|
20
36
|
};
|
|
21
37
|
}
|
|
22
38
|
const skills = await readClaudePlugins();
|
|
23
|
-
|
|
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
|
+
};
|
|
24
48
|
}
|
|
25
49
|
catch {
|
|
26
50
|
const skills = await readClaudePlugins();
|
|
27
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
agent: 'claude',
|
|
53
|
+
configPath,
|
|
54
|
+
exists: false,
|
|
55
|
+
mcpServers: {},
|
|
56
|
+
remoteMcpServers: {},
|
|
57
|
+
skills,
|
|
58
|
+
};
|
|
28
59
|
}
|
|
29
60
|
},
|
|
30
61
|
};
|
|
@@ -35,13 +66,28 @@ export function createCodexReader() {
|
|
|
35
66
|
const configPath = path.join(homedir(), '.codex', 'config.toml');
|
|
36
67
|
try {
|
|
37
68
|
const source = await readFile(configPath, 'utf8');
|
|
38
|
-
const mcpServers = parseCodexToml(source);
|
|
69
|
+
const { mcpServers, remoteMcpServers } = parseCodexToml(source);
|
|
39
70
|
const skills = await readCodexSkills();
|
|
40
|
-
|
|
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
|
+
};
|
|
41
80
|
}
|
|
42
81
|
catch {
|
|
43
82
|
const skills = await readCodexSkills();
|
|
44
|
-
return {
|
|
83
|
+
return {
|
|
84
|
+
agent: 'codex',
|
|
85
|
+
configPath,
|
|
86
|
+
exists: false,
|
|
87
|
+
mcpServers: {},
|
|
88
|
+
remoteMcpServers: {},
|
|
89
|
+
skills,
|
|
90
|
+
};
|
|
45
91
|
}
|
|
46
92
|
},
|
|
47
93
|
};
|
|
@@ -50,28 +96,53 @@ export function createGeminiReader() {
|
|
|
50
96
|
return {
|
|
51
97
|
async read() {
|
|
52
98
|
const configPath = path.join(homedir(), '.gemini', 'settings.json');
|
|
99
|
+
let rawServers = {};
|
|
100
|
+
let exists = false;
|
|
53
101
|
try {
|
|
54
102
|
const source = await readFile(configPath, 'utf8');
|
|
55
103
|
const data = JSON.parse(source);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for (const [name, entry] of Object.entries(rawServers)) {
|
|
59
|
-
mcpServers[name] = {
|
|
60
|
-
command: String(entry.command ?? ''),
|
|
61
|
-
args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
|
|
62
|
-
env: parseEnvObject(entry.env),
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
const skills = await readGeminiSkills();
|
|
66
|
-
return { agent: 'gemini', configPath, exists: true, mcpServers, skills };
|
|
104
|
+
rawServers = (data.mcpServers ?? {});
|
|
105
|
+
exists = true;
|
|
67
106
|
}
|
|
68
107
|
catch {
|
|
69
|
-
|
|
70
|
-
return { agent: 'gemini', configPath, exists: false, mcpServers: {}, skills };
|
|
108
|
+
// no global config
|
|
71
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
|
+
};
|
|
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
|
+
};
|
|
72
134
|
},
|
|
73
135
|
};
|
|
74
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
|
+
}
|
|
75
146
|
function parseEnvObject(value) {
|
|
76
147
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
77
148
|
return undefined;
|
|
@@ -81,13 +152,40 @@ function parseEnvObject(value) {
|
|
|
81
152
|
}
|
|
82
153
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
83
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
|
+
}
|
|
84
180
|
function parseCodexToml(source) {
|
|
85
|
-
const
|
|
181
|
+
const mcpServers = {};
|
|
182
|
+
const remoteMcpServers = {};
|
|
86
183
|
const lines = source.split('\n');
|
|
87
184
|
let currentServer = null;
|
|
88
185
|
let inEnv = false;
|
|
89
186
|
let currentEntry = { command: '' };
|
|
90
187
|
let currentEnv = {};
|
|
188
|
+
let currentUrl = null;
|
|
91
189
|
for (const line of lines) {
|
|
92
190
|
const trimmed = line.trim();
|
|
93
191
|
// Match [mcp_servers.name.env]
|
|
@@ -101,25 +199,37 @@ function parseCodexToml(source) {
|
|
|
101
199
|
if (serverMatch) {
|
|
102
200
|
// Save previous server
|
|
103
201
|
if (currentServer) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
202
|
+
flushCodexServer({
|
|
203
|
+
currentServer,
|
|
204
|
+
currentEntry,
|
|
205
|
+
currentEnv,
|
|
206
|
+
currentUrl,
|
|
207
|
+
mcpServers,
|
|
208
|
+
remoteMcpServers,
|
|
209
|
+
});
|
|
107
210
|
}
|
|
108
211
|
currentServer = serverMatch[1];
|
|
109
212
|
currentEntry = { command: '' };
|
|
110
213
|
currentEnv = {};
|
|
214
|
+
currentUrl = null;
|
|
111
215
|
inEnv = false;
|
|
112
216
|
continue;
|
|
113
217
|
}
|
|
114
218
|
// New non-mcp section — flush current server
|
|
115
219
|
if (/^\[/.test(trimmed) && !/^\[mcp_servers/.test(trimmed)) {
|
|
116
220
|
if (currentServer) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
221
|
+
flushCodexServer({
|
|
222
|
+
currentServer,
|
|
223
|
+
currentEntry,
|
|
224
|
+
currentEnv,
|
|
225
|
+
currentUrl,
|
|
226
|
+
mcpServers,
|
|
227
|
+
remoteMcpServers,
|
|
228
|
+
});
|
|
120
229
|
currentServer = null;
|
|
121
230
|
currentEntry = { command: '' };
|
|
122
231
|
currentEnv = {};
|
|
232
|
+
currentUrl = null;
|
|
123
233
|
}
|
|
124
234
|
inEnv = false;
|
|
125
235
|
continue;
|
|
@@ -132,6 +242,9 @@ function parseCodexToml(source) {
|
|
|
132
242
|
if (inEnv) {
|
|
133
243
|
currentEnv[key] = parseTomlValue(rawValue);
|
|
134
244
|
}
|
|
245
|
+
else if (key === 'url') {
|
|
246
|
+
currentUrl = parseTomlValue(rawValue);
|
|
247
|
+
}
|
|
135
248
|
else if (key === 'command') {
|
|
136
249
|
currentEntry.command = parseTomlValue(rawValue);
|
|
137
250
|
}
|
|
@@ -141,11 +254,29 @@ function parseCodexToml(source) {
|
|
|
141
254
|
}
|
|
142
255
|
// Flush last server
|
|
143
256
|
if (currentServer) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
257
|
+
flushCodexServer({
|
|
258
|
+
currentServer,
|
|
259
|
+
currentEntry,
|
|
260
|
+
currentEnv,
|
|
261
|
+
currentUrl,
|
|
262
|
+
mcpServers,
|
|
263
|
+
remoteMcpServers,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
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;
|
|
147
276
|
}
|
|
148
|
-
|
|
277
|
+
if (Object.keys(currentEnv).length > 0)
|
|
278
|
+
currentEntry.env = currentEnv;
|
|
279
|
+
mcpServers[currentServer] = currentEntry;
|
|
149
280
|
}
|
|
150
281
|
function parseTomlValue(raw) {
|
|
151
282
|
const trimmed = raw.trim();
|
|
@@ -170,37 +301,59 @@ function parseTomlArray(raw) {
|
|
|
170
301
|
}
|
|
171
302
|
/* ---- Skill readers ---- */
|
|
172
303
|
async function readClaudePlugins() {
|
|
304
|
+
const results = [];
|
|
305
|
+
// Read marketplace plugins
|
|
173
306
|
try {
|
|
174
307
|
const pluginsPath = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
175
|
-
|
|
176
|
-
const data = JSON.parse(source);
|
|
177
|
-
if (!data.plugins)
|
|
178
|
-
return [];
|
|
179
|
-
return Object.keys(data.plugins).map((key) => {
|
|
180
|
-
const [name, source] = key.split('@');
|
|
181
|
-
return { name, source };
|
|
182
|
-
});
|
|
308
|
+
results.push(...await readInstalledPlugins(pluginsPath));
|
|
183
309
|
}
|
|
184
310
|
catch {
|
|
185
|
-
|
|
311
|
+
// no plugins file
|
|
186
312
|
}
|
|
313
|
+
// Read local skills from ~/.claude/skills/
|
|
314
|
+
try {
|
|
315
|
+
const skillsDir = path.join(homedir(), '.claude', 'skills');
|
|
316
|
+
const localSkills = await readSkillDirs(skillsDir);
|
|
317
|
+
results.push(...localSkills);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// no skills dir
|
|
321
|
+
}
|
|
322
|
+
return results;
|
|
187
323
|
}
|
|
188
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 = [];
|
|
189
330
|
try {
|
|
190
331
|
const skillsDir = path.join(homedir(), '.codex', 'skills');
|
|
191
|
-
|
|
332
|
+
localSkills = await readSkillDirs(skillsDir);
|
|
192
333
|
}
|
|
193
334
|
catch {
|
|
194
|
-
|
|
335
|
+
localSkills = [];
|
|
195
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);
|
|
345
|
+
}
|
|
346
|
+
return Array.from(seen.values());
|
|
196
347
|
}
|
|
197
348
|
async function readGeminiSkills() {
|
|
198
349
|
try {
|
|
199
350
|
const skillsDir = path.join(homedir(), '.gemini', 'skills');
|
|
200
|
-
|
|
351
|
+
const localSkills = await readSkillDirs(skillsDir);
|
|
352
|
+
const managedPlugins = await readManagedPlugins({ agent: 'gemini' });
|
|
353
|
+
return mergeManagedPluginsIntoSkills(localSkills, managedPlugins);
|
|
201
354
|
}
|
|
202
355
|
catch {
|
|
203
|
-
return
|
|
356
|
+
return await readManagedPlugins({ agent: 'gemini' });
|
|
204
357
|
}
|
|
205
358
|
}
|
|
206
359
|
/** Shared: read skill directories (Codex and Gemini use the same SKILL.md convention) */
|
|
@@ -211,10 +364,10 @@ async function readSkillDirs(skillsDir) {
|
|
|
211
364
|
if (entry.name.startsWith('.'))
|
|
212
365
|
continue;
|
|
213
366
|
if (entry.isDirectory()) {
|
|
214
|
-
skills.push({ name: entry.name, source: 'local' });
|
|
367
|
+
skills.push({ name: entry.name, source: 'local', kind: 'skill' });
|
|
215
368
|
}
|
|
216
369
|
else if (entry.isSymbolicLink()) {
|
|
217
|
-
skills.push({ name: entry.name, source: 'linked' });
|
|
370
|
+
skills.push({ name: entry.name, source: 'linked', kind: 'skill' });
|
|
218
371
|
}
|
|
219
372
|
}
|
|
220
373
|
return skills;
|
|
@@ -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] ?? {};
|
|
@@ -65,7 +59,10 @@ export function createClaudeWriter() {
|
|
|
65
59
|
};
|
|
66
60
|
}
|
|
67
61
|
function toClaudeFormat(config) {
|
|
68
|
-
if (config.
|
|
62
|
+
if (config.kind === 'remote') {
|
|
63
|
+
throw new SyncError('Remote MCP servers are not supported in Claude sync.');
|
|
64
|
+
}
|
|
65
|
+
if (config.source === 'npm') {
|
|
69
66
|
return {
|
|
70
67
|
type: 'stdio',
|
|
71
68
|
command: 'npx',
|
|
@@ -24,13 +24,7 @@ export function createCodexWriter() {
|
|
|
24
24
|
backedUpTo = backupPath;
|
|
25
25
|
}
|
|
26
26
|
// Build MCP servers section
|
|
27
|
-
const
|
|
28
|
-
// Always include brainctl itself
|
|
29
|
-
allServers['brainctl'] = {
|
|
30
|
-
type: 'npm',
|
|
31
|
-
package: 'brainctl',
|
|
32
|
-
};
|
|
33
|
-
const mcpToml = buildMcpToml(allServers);
|
|
27
|
+
const mcpToml = buildMcpToml(options.mcpServers);
|
|
34
28
|
// Preserve non-mcp_servers content from existing config
|
|
35
29
|
const existingNonMcp = stripMcpSections(existingContent);
|
|
36
30
|
const finalContent = existingNonMcp.trim().length > 0
|
|
@@ -71,7 +65,10 @@ function buildMcpToml(servers) {
|
|
|
71
65
|
const lines = [];
|
|
72
66
|
for (const [name, config] of Object.entries(servers)) {
|
|
73
67
|
lines.push(`[mcp_servers.${name}]`);
|
|
74
|
-
if (config.
|
|
68
|
+
if (config.kind === 'remote') {
|
|
69
|
+
throw new SyncError('Remote MCP servers are not supported in Codex sync.');
|
|
70
|
+
}
|
|
71
|
+
if (config.source === 'npm') {
|
|
75
72
|
lines.push(`command = "npx"`);
|
|
76
73
|
lines.push(`args = ["-y", ${tomlString(config.package)}]`);
|
|
77
74
|
}
|
|
@@ -96,6 +93,26 @@ function buildMcpToml(servers) {
|
|
|
96
93
|
function tomlString(value) {
|
|
97
94
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
98
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
|
+
}
|
|
99
116
|
function stripMcpSections(content) {
|
|
100
117
|
const lines = content.split('\n');
|
|
101
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -67,7 +65,10 @@ export function createGeminiWriter() {
|
|
|
67
65
|
};
|
|
68
66
|
}
|
|
69
67
|
function toGeminiFormat(config) {
|
|
70
|
-
if (config.
|
|
68
|
+
if (config.kind === 'remote') {
|
|
69
|
+
throw new SyncError('Remote MCP servers are not supported in Gemini sync.');
|
|
70
|
+
}
|
|
71
|
+
if (config.source === 'npm') {
|
|
71
72
|
return {
|
|
72
73
|
command: 'npx',
|
|
73
74
|
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[];
|