brainctl 0.1.7 → 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 +210 -157
- package/dist/cli.js +40 -0
- package/dist/commands/mcp.js +35 -0
- package/dist/commands/profile.js +35 -2
- package/dist/mcp/server.js +51 -5
- package/dist/services/agent-config-service.d.ts +4 -2
- package/dist/services/agent-config-service.js +50 -15
- 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 +3 -2
- package/dist/services/mcp-preflight-service.js +159 -5
- package/dist/services/plugin-install-service.d.ts +43 -0
- package/dist/services/plugin-install-service.js +379 -21
- 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 +265 -10
- package/dist/services/profile-service.js +11 -0
- package/dist/services/runtime-detector.d.ts +9 -0
- package/dist/services/runtime-detector.js +130 -0
- package/dist/services/skill-paths.d.ts +2 -0
- package/dist/services/skill-paths.js +14 -0
- package/dist/services/sync/agent-reader.d.ts +9 -0
- package/dist/services/sync/agent-reader.js +177 -35
- package/dist/services/sync/claude-writer.js +0 -6
- package/dist/services/sync/codex-writer.d.ts +1 -0
- package/dist/services/sync/codex-writer.js +21 -8
- package/dist/services/sync/gemini-writer.js +5 -7
- package/dist/services/sync/plugin-skill-reader.d.ts +5 -0
- package/dist/services/sync/plugin-skill-reader.js +142 -1
- package/dist/services/sync-service.js +1 -1
- package/dist/services/update-check-service.d.ts +33 -0
- package/dist/services/update-check-service.js +128 -0
- package/dist/types.d.ts +47 -0
- package/dist/ui/routes.js +35 -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 +5 -1
- package/dist/web/assets/index-BCkorugl.css +0 -1
- package/dist/web/assets/index-sGnTMhkX.js +0 -16
|
@@ -2,7 +2,7 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { mergeManagedPluginsIntoSkills, readManagedPlugins, } from './managed-plugin-registry.js';
|
|
5
|
-
import { readInstalledPlugins } from './plugin-skill-reader.js';
|
|
5
|
+
import { readCodexPlugins, readInstalledPlugins } from './plugin-skill-reader.js';
|
|
6
6
|
export function createClaudeReader() {
|
|
7
7
|
return {
|
|
8
8
|
async read(options) {
|
|
@@ -10,11 +10,25 @@ 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
|
+
const userServers = (data.mcpServers ?? {});
|
|
13
15
|
const projects = (data.projects ?? {});
|
|
14
16
|
const projectConfig = projects[options.cwd] ?? {};
|
|
15
|
-
const
|
|
17
|
+
const projectServers = (projectConfig.mcpServers ?? {});
|
|
18
|
+
const rawServers = { ...userServers, ...projectServers };
|
|
16
19
|
const mcpServers = {};
|
|
20
|
+
const remoteMcpServers = {};
|
|
17
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
|
+
}
|
|
18
32
|
mcpServers[name] = {
|
|
19
33
|
command: String(entry.command ?? ''),
|
|
20
34
|
args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
|
|
@@ -22,11 +36,26 @@ export function createClaudeReader() {
|
|
|
22
36
|
};
|
|
23
37
|
}
|
|
24
38
|
const skills = await readClaudePlugins();
|
|
25
|
-
|
|
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
|
+
};
|
|
26
48
|
}
|
|
27
49
|
catch {
|
|
28
50
|
const skills = await readClaudePlugins();
|
|
29
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
agent: 'claude',
|
|
53
|
+
configPath,
|
|
54
|
+
exists: false,
|
|
55
|
+
mcpServers: {},
|
|
56
|
+
remoteMcpServers: {},
|
|
57
|
+
skills,
|
|
58
|
+
};
|
|
30
59
|
}
|
|
31
60
|
},
|
|
32
61
|
};
|
|
@@ -37,13 +66,28 @@ export function createCodexReader() {
|
|
|
37
66
|
const configPath = path.join(homedir(), '.codex', 'config.toml');
|
|
38
67
|
try {
|
|
39
68
|
const source = await readFile(configPath, 'utf8');
|
|
40
|
-
const mcpServers = parseCodexToml(source);
|
|
69
|
+
const { mcpServers, remoteMcpServers } = parseCodexToml(source);
|
|
41
70
|
const skills = await readCodexSkills();
|
|
42
|
-
|
|
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
|
+
};
|
|
43
80
|
}
|
|
44
81
|
catch {
|
|
45
82
|
const skills = await readCodexSkills();
|
|
46
|
-
return {
|
|
83
|
+
return {
|
|
84
|
+
agent: 'codex',
|
|
85
|
+
configPath,
|
|
86
|
+
exists: false,
|
|
87
|
+
mcpServers: {},
|
|
88
|
+
remoteMcpServers: {},
|
|
89
|
+
skills,
|
|
90
|
+
};
|
|
47
91
|
}
|
|
48
92
|
},
|
|
49
93
|
};
|
|
@@ -52,28 +96,53 @@ export function createGeminiReader() {
|
|
|
52
96
|
return {
|
|
53
97
|
async read() {
|
|
54
98
|
const configPath = path.join(homedir(), '.gemini', 'settings.json');
|
|
99
|
+
let rawServers = {};
|
|
100
|
+
let exists = false;
|
|
55
101
|
try {
|
|
56
102
|
const source = await readFile(configPath, 'utf8');
|
|
57
103
|
const data = JSON.parse(source);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
for (const [name, entry] of Object.entries(rawServers)) {
|
|
61
|
-
mcpServers[name] = {
|
|
62
|
-
command: String(entry.command ?? ''),
|
|
63
|
-
args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
|
|
64
|
-
env: parseEnvObject(entry.env),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
const skills = await readGeminiSkills();
|
|
68
|
-
return { agent: 'gemini', configPath, exists: true, mcpServers, skills };
|
|
104
|
+
rawServers = (data.mcpServers ?? {});
|
|
105
|
+
exists = true;
|
|
69
106
|
}
|
|
70
107
|
catch {
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
// no global config
|
|
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
|
+
};
|
|
73
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
|
+
};
|
|
74
134
|
},
|
|
75
135
|
};
|
|
76
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
|
+
}
|
|
77
146
|
function parseEnvObject(value) {
|
|
78
147
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
79
148
|
return undefined;
|
|
@@ -83,13 +152,40 @@ function parseEnvObject(value) {
|
|
|
83
152
|
}
|
|
84
153
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
85
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
|
+
}
|
|
86
180
|
function parseCodexToml(source) {
|
|
87
|
-
const
|
|
181
|
+
const mcpServers = {};
|
|
182
|
+
const remoteMcpServers = {};
|
|
88
183
|
const lines = source.split('\n');
|
|
89
184
|
let currentServer = null;
|
|
90
185
|
let inEnv = false;
|
|
91
186
|
let currentEntry = { command: '' };
|
|
92
187
|
let currentEnv = {};
|
|
188
|
+
let currentUrl = null;
|
|
93
189
|
for (const line of lines) {
|
|
94
190
|
const trimmed = line.trim();
|
|
95
191
|
// Match [mcp_servers.name.env]
|
|
@@ -103,25 +199,37 @@ function parseCodexToml(source) {
|
|
|
103
199
|
if (serverMatch) {
|
|
104
200
|
// Save previous server
|
|
105
201
|
if (currentServer) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
202
|
+
flushCodexServer({
|
|
203
|
+
currentServer,
|
|
204
|
+
currentEntry,
|
|
205
|
+
currentEnv,
|
|
206
|
+
currentUrl,
|
|
207
|
+
mcpServers,
|
|
208
|
+
remoteMcpServers,
|
|
209
|
+
});
|
|
109
210
|
}
|
|
110
211
|
currentServer = serverMatch[1];
|
|
111
212
|
currentEntry = { command: '' };
|
|
112
213
|
currentEnv = {};
|
|
214
|
+
currentUrl = null;
|
|
113
215
|
inEnv = false;
|
|
114
216
|
continue;
|
|
115
217
|
}
|
|
116
218
|
// New non-mcp section — flush current server
|
|
117
219
|
if (/^\[/.test(trimmed) && !/^\[mcp_servers/.test(trimmed)) {
|
|
118
220
|
if (currentServer) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
221
|
+
flushCodexServer({
|
|
222
|
+
currentServer,
|
|
223
|
+
currentEntry,
|
|
224
|
+
currentEnv,
|
|
225
|
+
currentUrl,
|
|
226
|
+
mcpServers,
|
|
227
|
+
remoteMcpServers,
|
|
228
|
+
});
|
|
122
229
|
currentServer = null;
|
|
123
230
|
currentEntry = { command: '' };
|
|
124
231
|
currentEnv = {};
|
|
232
|
+
currentUrl = null;
|
|
125
233
|
}
|
|
126
234
|
inEnv = false;
|
|
127
235
|
continue;
|
|
@@ -134,6 +242,9 @@ function parseCodexToml(source) {
|
|
|
134
242
|
if (inEnv) {
|
|
135
243
|
currentEnv[key] = parseTomlValue(rawValue);
|
|
136
244
|
}
|
|
245
|
+
else if (key === 'url') {
|
|
246
|
+
currentUrl = parseTomlValue(rawValue);
|
|
247
|
+
}
|
|
137
248
|
else if (key === 'command') {
|
|
138
249
|
currentEntry.command = parseTomlValue(rawValue);
|
|
139
250
|
}
|
|
@@ -143,11 +254,29 @@ function parseCodexToml(source) {
|
|
|
143
254
|
}
|
|
144
255
|
// Flush last server
|
|
145
256
|
if (currentServer) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
257
|
+
flushCodexServer({
|
|
258
|
+
currentServer,
|
|
259
|
+
currentEntry,
|
|
260
|
+
currentEnv,
|
|
261
|
+
currentUrl,
|
|
262
|
+
mcpServers,
|
|
263
|
+
remoteMcpServers,
|
|
264
|
+
});
|
|
149
265
|
}
|
|
150
|
-
return
|
|
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;
|
|
276
|
+
}
|
|
277
|
+
if (Object.keys(currentEnv).length > 0)
|
|
278
|
+
currentEntry.env = currentEnv;
|
|
279
|
+
mcpServers[currentServer] = currentEntry;
|
|
151
280
|
}
|
|
152
281
|
function parseTomlValue(raw) {
|
|
153
282
|
const trimmed = raw.trim();
|
|
@@ -193,15 +322,28 @@ async function readClaudePlugins() {
|
|
|
193
322
|
return results;
|
|
194
323
|
}
|
|
195
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 = [];
|
|
196
330
|
try {
|
|
197
331
|
const skillsDir = path.join(homedir(), '.codex', 'skills');
|
|
198
|
-
|
|
199
|
-
const managedPlugins = await readManagedPlugins({ agent: 'codex' });
|
|
200
|
-
return mergeManagedPluginsIntoSkills(localSkills, managedPlugins);
|
|
332
|
+
localSkills = await readSkillDirs(skillsDir);
|
|
201
333
|
}
|
|
202
334
|
catch {
|
|
203
|
-
|
|
335
|
+
localSkills = [];
|
|
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);
|
|
204
345
|
}
|
|
346
|
+
return Array.from(seen.values());
|
|
205
347
|
}
|
|
206
348
|
async function readGeminiSkills() {
|
|
207
349
|
try {
|
|
@@ -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] ?? {};
|
|
@@ -24,14 +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
|
-
kind: 'local',
|
|
31
|
-
source: 'npm',
|
|
32
|
-
package: 'brainctl',
|
|
33
|
-
};
|
|
34
|
-
const mcpToml = buildMcpToml(allServers);
|
|
27
|
+
const mcpToml = buildMcpToml(options.mcpServers);
|
|
35
28
|
// Preserve non-mcp_servers content from existing config
|
|
36
29
|
const existingNonMcp = stripMcpSections(existingContent);
|
|
37
30
|
const finalContent = existingNonMcp.trim().length > 0
|
|
@@ -100,6 +93,26 @@ function buildMcpToml(servers) {
|
|
|
100
93
|
function tomlString(value) {
|
|
101
94
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
102
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
|
+
}
|
|
103
116
|
function stripMcpSections(content) {
|
|
104
117
|
const lines = content.split('\n');
|
|
105
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;
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import type { AgentSkillEntry } from './agent-reader.js';
|
|
2
2
|
export declare function readInstalledPlugins(installedPluginsPath: string): Promise<AgentSkillEntry[]>;
|
|
3
|
+
export declare function readCodexPlugins(options: {
|
|
4
|
+
configTomlPath: string;
|
|
5
|
+
pluginsCacheDir: string;
|
|
6
|
+
}): Promise<AgentSkillEntry[]>;
|
|
7
|
+
export declare function readPluginMcpKeys(installPath: string): Promise<string[]>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
export async function readInstalledPlugins(installedPluginsPath) {
|
|
4
4
|
const source = await readFile(installedPluginsPath, 'utf8');
|
|
@@ -8,12 +8,18 @@ export async function readInstalledPlugins(installedPluginsPath) {
|
|
|
8
8
|
const [name, pluginSource] = key.split('@');
|
|
9
9
|
const installPath = records[0]?.installPath;
|
|
10
10
|
const pluginSkills = installPath ? await readPluginSkills(installPath) : [];
|
|
11
|
+
const pluginMcps = installPath ? await readPluginMcpKeys(installPath) : [];
|
|
12
|
+
const pluginAgents = installPath ? await readPluginAgentNames(installPath) : [];
|
|
13
|
+
const pluginCommands = installPath ? await readPluginCommandNames(installPath) : [];
|
|
11
14
|
results.push({
|
|
12
15
|
name,
|
|
13
16
|
source: pluginSource,
|
|
14
17
|
kind: 'plugin',
|
|
15
18
|
installPath,
|
|
16
19
|
pluginSkills,
|
|
20
|
+
...(pluginMcps.length > 0 ? { pluginMcps } : {}),
|
|
21
|
+
...(pluginAgents.length > 0 ? { pluginAgents } : {}),
|
|
22
|
+
...(pluginCommands.length > 0 ? { pluginCommands } : {}),
|
|
17
23
|
});
|
|
18
24
|
}
|
|
19
25
|
return results;
|
|
@@ -31,3 +37,138 @@ async function readPluginSkills(installPath) {
|
|
|
31
37
|
return [];
|
|
32
38
|
}
|
|
33
39
|
}
|
|
40
|
+
export async function readCodexPlugins(options) {
|
|
41
|
+
let tomlSource;
|
|
42
|
+
try {
|
|
43
|
+
tomlSource = await readFile(options.configTomlPath, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const entries = [];
|
|
49
|
+
for (const { name, marketplace, enabled } of parseCodexPluginDeclarations(tomlSource)) {
|
|
50
|
+
if (!enabled)
|
|
51
|
+
continue;
|
|
52
|
+
const installPath = await resolveCodexPluginInstallPath({
|
|
53
|
+
pluginsCacheDir: options.pluginsCacheDir,
|
|
54
|
+
marketplace,
|
|
55
|
+
name,
|
|
56
|
+
});
|
|
57
|
+
if (!installPath)
|
|
58
|
+
continue;
|
|
59
|
+
const pluginSkills = await readPluginSkills(installPath);
|
|
60
|
+
const pluginMcps = await readPluginMcpKeys(installPath);
|
|
61
|
+
const pluginAgents = await readPluginAgentNames(installPath);
|
|
62
|
+
const pluginCommands = await readPluginCommandNames(installPath);
|
|
63
|
+
entries.push({
|
|
64
|
+
name,
|
|
65
|
+
source: marketplace,
|
|
66
|
+
kind: 'plugin',
|
|
67
|
+
installPath,
|
|
68
|
+
pluginSkills,
|
|
69
|
+
...(pluginMcps.length > 0 ? { pluginMcps } : {}),
|
|
70
|
+
...(pluginAgents.length > 0 ? { pluginAgents } : {}),
|
|
71
|
+
...(pluginCommands.length > 0 ? { pluginCommands } : {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
75
|
+
}
|
|
76
|
+
function parseCodexPluginDeclarations(source) {
|
|
77
|
+
const results = [];
|
|
78
|
+
const lines = source.split('\n');
|
|
79
|
+
let current = null;
|
|
80
|
+
const flush = () => {
|
|
81
|
+
if (current) {
|
|
82
|
+
results.push(current);
|
|
83
|
+
current = null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
const sectionMatch = trimmed.match(/^\[plugins\."([^"@]+)@([^"]+)"\]$/);
|
|
89
|
+
if (sectionMatch) {
|
|
90
|
+
flush();
|
|
91
|
+
current = {
|
|
92
|
+
name: sectionMatch[1],
|
|
93
|
+
marketplace: sectionMatch[2],
|
|
94
|
+
enabled: false,
|
|
95
|
+
};
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (/^\[/.test(trimmed)) {
|
|
99
|
+
flush();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!current)
|
|
103
|
+
continue;
|
|
104
|
+
const kv = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
|
|
105
|
+
if (kv && kv[1] === 'enabled') {
|
|
106
|
+
current.enabled = kv[2].trim() === 'true';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
flush();
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
async function resolveCodexPluginInstallPath(options) {
|
|
113
|
+
const pluginRoot = path.join(options.pluginsCacheDir, options.marketplace, options.name);
|
|
114
|
+
let versionDirs;
|
|
115
|
+
try {
|
|
116
|
+
const entries = await readdir(pluginRoot, { withFileTypes: true });
|
|
117
|
+
versionDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
if (versionDirs.length === 0)
|
|
123
|
+
return null;
|
|
124
|
+
if (versionDirs.length === 1)
|
|
125
|
+
return path.join(pluginRoot, versionDirs[0]);
|
|
126
|
+
const stats = await Promise.all(versionDirs.map(async (dir) => {
|
|
127
|
+
try {
|
|
128
|
+
const info = await stat(path.join(pluginRoot, dir));
|
|
129
|
+
return { dir, mtime: info.mtimeMs };
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { dir, mtime: 0 };
|
|
133
|
+
}
|
|
134
|
+
}));
|
|
135
|
+
stats.sort((left, right) => right.mtime - left.mtime);
|
|
136
|
+
return path.join(pluginRoot, stats[0].dir);
|
|
137
|
+
}
|
|
138
|
+
async function readPluginAgentNames(installPath) {
|
|
139
|
+
try {
|
|
140
|
+
const entries = await readdir(path.join(installPath, 'agents'), { withFileTypes: true });
|
|
141
|
+
return entries
|
|
142
|
+
.filter((entry) => entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.toml')))
|
|
143
|
+
.map((entry) => entry.name.replace(/\.(md|toml)$/, ''))
|
|
144
|
+
.sort((left, right) => left.localeCompare(right));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function readPluginCommandNames(installPath) {
|
|
151
|
+
try {
|
|
152
|
+
const entries = await readdir(path.join(installPath, 'commands'), { withFileTypes: true });
|
|
153
|
+
return entries
|
|
154
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
155
|
+
.map((entry) => entry.name.replace(/\.md$/, ''))
|
|
156
|
+
.sort((left, right) => left.localeCompare(right));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function readPluginMcpKeys(installPath) {
|
|
163
|
+
try {
|
|
164
|
+
const source = await readFile(path.join(installPath, '.mcp.json'), 'utf8');
|
|
165
|
+
const parsed = JSON.parse(source);
|
|
166
|
+
const servers = parsed.mcpServers && typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers)
|
|
167
|
+
? parsed.mcpServers
|
|
168
|
+
: parsed;
|
|
169
|
+
return Object.keys(servers).sort((left, right) => left.localeCompare(right));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -40,7 +40,7 @@ export function createSyncService(dependencies = {}) {
|
|
|
40
40
|
agent,
|
|
41
41
|
configPath: result.configPath,
|
|
42
42
|
backedUpTo: result.backedUpTo,
|
|
43
|
-
mcpCount: Object.keys(profile.mcps).length
|
|
43
|
+
mcpCount: Object.keys(profile.mcps).length,
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
return results;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface UpdateCheckResult {
|
|
2
|
+
current: string;
|
|
3
|
+
latest: string;
|
|
4
|
+
isOutdated: boolean;
|
|
5
|
+
fromCache: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface SelfUpdateResult {
|
|
8
|
+
success: boolean;
|
|
9
|
+
fromVersion: string;
|
|
10
|
+
toVersion: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UpdateCheckService {
|
|
14
|
+
check(): Promise<UpdateCheckResult>;
|
|
15
|
+
selfUpdate(): Promise<SelfUpdateResult>;
|
|
16
|
+
}
|
|
17
|
+
interface UpdateCheckCache {
|
|
18
|
+
lastCheck: string;
|
|
19
|
+
latestVersion: string;
|
|
20
|
+
}
|
|
21
|
+
interface UpdateCheckDependencies {
|
|
22
|
+
currentVersion?: string;
|
|
23
|
+
fetchLatestVersion?: () => Promise<string>;
|
|
24
|
+
readCache?: () => Promise<UpdateCheckCache | null>;
|
|
25
|
+
writeCache?: (cache: UpdateCheckCache) => Promise<void>;
|
|
26
|
+
runInstall?: () => Promise<{
|
|
27
|
+
success: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export declare function createUpdateCheckService(dependencies?: UpdateCheckDependencies): UpdateCheckService;
|
|
32
|
+
export declare function isNewer(candidate: string, baseline: string): boolean;
|
|
33
|
+
export {};
|