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
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { access } from 'node:fs/promises';
|
|
2
|
-
import { constants } from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
1
|
import { AgentNotAvailableError } from '../errors.js';
|
|
5
2
|
import { ClaudeExecutor } from './claude.js';
|
|
6
3
|
import { CodexExecutor } from './codex.js';
|
|
4
|
+
import { findExecutable } from '../system/executables.js';
|
|
7
5
|
const SUPPORTED_AGENTS = ['claude', 'codex', 'gemini'];
|
|
8
6
|
const AGENT_COMMANDS = {
|
|
9
7
|
claude: 'claude',
|
|
@@ -60,38 +58,3 @@ async function checkAvailability(agentName) {
|
|
|
60
58
|
resolvedPath: resolvedPath ?? undefined
|
|
61
59
|
};
|
|
62
60
|
}
|
|
63
|
-
async function findExecutable(command) {
|
|
64
|
-
if (command.includes(path.sep)) {
|
|
65
|
-
return (await isExecutable(command)) ? command : null;
|
|
66
|
-
}
|
|
67
|
-
const pathEntries = (process.env.PATH ?? '')
|
|
68
|
-
.split(path.delimiter)
|
|
69
|
-
.filter((entry) => entry.length > 0);
|
|
70
|
-
const extensions = process.platform === 'win32'
|
|
71
|
-
? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM')
|
|
72
|
-
.split(';')
|
|
73
|
-
.filter((entry) => entry.length > 0)
|
|
74
|
-
: [''];
|
|
75
|
-
for (const pathEntry of pathEntries) {
|
|
76
|
-
for (const extension of extensions) {
|
|
77
|
-
const candidate = process.platform === 'win32' &&
|
|
78
|
-
extension.length > 0 &&
|
|
79
|
-
!command.toLowerCase().endsWith(extension.toLowerCase())
|
|
80
|
-
? path.join(pathEntry, `${command}${extension}`)
|
|
81
|
-
: path.join(pathEntry, command);
|
|
82
|
-
if (await isExecutable(candidate)) {
|
|
83
|
-
return candidate;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
async function isExecutable(filePath) {
|
|
90
|
-
try {
|
|
91
|
-
await access(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { FastMCP } from 'fastmcp';
|
|
@@ -6,6 +7,7 @@ import { loadConfig } from '../config.js';
|
|
|
6
7
|
import { loadMemory } from '../context/memory.js';
|
|
7
8
|
import { createAgentConfigService } from '../services/agent-config-service.js';
|
|
8
9
|
import { createDoctorService } from '../services/doctor-service.js';
|
|
10
|
+
import { startUiServer } from '../ui/server.js';
|
|
9
11
|
import { createMemoryWriteService } from '../services/memory-write-service.js';
|
|
10
12
|
import { createProfileExportService } from '../services/profile-export-service.js';
|
|
11
13
|
import { createProfileImportService } from '../services/profile-import-service.js';
|
|
@@ -287,14 +289,20 @@ export function createMcpServer(options = {}) {
|
|
|
287
289
|
name: 'brainctl_export_profile',
|
|
288
290
|
description: 'Export a profile as a portable tarball. Packages the profile config and bundled MCP source code for sharing.',
|
|
289
291
|
parameters: z.object({
|
|
290
|
-
name: z.string().describe('Profile name to export'),
|
|
292
|
+
name: z.string().optional().describe('Profile name to export'),
|
|
293
|
+
agent: z.enum(['claude', 'codex', 'gemini']).optional().describe('Pack a live agent config instead of a saved profile'),
|
|
291
294
|
output_path: z.string().optional().describe('Output file path (defaults to <name>.tar.gz in cwd)'),
|
|
292
295
|
}),
|
|
293
296
|
execute: async (args) => {
|
|
297
|
+
if (!args.name && !args.agent) {
|
|
298
|
+
return JSON.stringify({ error: 'Provide name or agent.' }, null, 2);
|
|
299
|
+
}
|
|
294
300
|
const exportService = createProfileExportService();
|
|
295
301
|
const result = await exportService.execute({
|
|
296
302
|
cwd,
|
|
297
|
-
|
|
303
|
+
source: args.agent
|
|
304
|
+
? { source: 'agent', agent: args.agent, cwd }
|
|
305
|
+
: { source: 'profile', name: args.name },
|
|
298
306
|
outputPath: args.output_path,
|
|
299
307
|
});
|
|
300
308
|
return JSON.stringify(result, null, 2);
|
|
@@ -306,6 +314,7 @@ export function createMcpServer(options = {}) {
|
|
|
306
314
|
parameters: z.object({
|
|
307
315
|
archive_path: z.string().describe('Path to the profile tarball'),
|
|
308
316
|
force: z.boolean().default(false).describe('Overwrite existing profile if it exists'),
|
|
317
|
+
credentials: z.record(z.string(), z.string()).optional().describe('Credential values keyed by placeholder name'),
|
|
309
318
|
}),
|
|
310
319
|
execute: async (args) => {
|
|
311
320
|
const importService = createProfileImportService();
|
|
@@ -313,10 +322,60 @@ export function createMcpServer(options = {}) {
|
|
|
313
322
|
cwd,
|
|
314
323
|
archivePath: args.archive_path,
|
|
315
324
|
force: args.force,
|
|
325
|
+
credentials: args.credentials,
|
|
316
326
|
});
|
|
317
327
|
return JSON.stringify(result, null, 2);
|
|
318
328
|
},
|
|
319
329
|
});
|
|
330
|
+
let uiServerInstance = null;
|
|
331
|
+
server.addTool({
|
|
332
|
+
name: 'brainctl_open_ui',
|
|
333
|
+
description: 'Start the brainctl web dashboard and open it in the default browser. Returns the URL. If already running, reopens the existing URL in the browser.',
|
|
334
|
+
parameters: z.object({
|
|
335
|
+
port: z.number().default(3333).describe('Port number for the UI server'),
|
|
336
|
+
openBrowser: z
|
|
337
|
+
.boolean()
|
|
338
|
+
.default(true)
|
|
339
|
+
.describe('Whether to launch the default browser at the UI URL'),
|
|
340
|
+
}),
|
|
341
|
+
execute: async (args) => {
|
|
342
|
+
if (uiServerInstance) {
|
|
343
|
+
if (args.openBrowser)
|
|
344
|
+
openInBrowser(uiServerInstance.url);
|
|
345
|
+
return JSON.stringify({
|
|
346
|
+
url: uiServerInstance.url,
|
|
347
|
+
status: 'already_running',
|
|
348
|
+
browserOpened: args.openBrowser,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
uiServerInstance = await startUiServer({ cwd, port: args.port });
|
|
353
|
+
if (args.openBrowser)
|
|
354
|
+
openInBrowser(uiServerInstance.url);
|
|
355
|
+
return JSON.stringify({
|
|
356
|
+
url: uiServerInstance.url,
|
|
357
|
+
status: 'started',
|
|
358
|
+
browserOpened: args.openBrowser,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
return JSON.stringify({ error: err.message, status: 'failed' });
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
server.addTool({
|
|
367
|
+
name: 'brainctl_close_ui',
|
|
368
|
+
description: 'Stop the brainctl web dashboard if it is running.',
|
|
369
|
+
parameters: z.object({}),
|
|
370
|
+
execute: async () => {
|
|
371
|
+
if (!uiServerInstance) {
|
|
372
|
+
return JSON.stringify({ status: 'not_running' });
|
|
373
|
+
}
|
|
374
|
+
await uiServerInstance.close();
|
|
375
|
+
uiServerInstance = null;
|
|
376
|
+
return JSON.stringify({ status: 'stopped' });
|
|
377
|
+
},
|
|
378
|
+
});
|
|
320
379
|
server.addTool({
|
|
321
380
|
name: 'brainctl_read_agent_configs',
|
|
322
381
|
description: 'Read the live MCP configs from all agents (Claude, Codex, Gemini). Shows what is actually configured in each agent right now, by reading their real config files.',
|
|
@@ -366,3 +425,24 @@ export async function startMcpServer(options = {}) {
|
|
|
366
425
|
const server = createMcpServer(options);
|
|
367
426
|
await server.start({ transportType: 'stdio' });
|
|
368
427
|
}
|
|
428
|
+
function openInBrowser(url) {
|
|
429
|
+
const platform = process.platform;
|
|
430
|
+
const { command, args } = platform === 'darwin'
|
|
431
|
+
? { command: 'open', args: [url] }
|
|
432
|
+
: platform === 'win32'
|
|
433
|
+
? { command: 'cmd', args: ['/c', 'start', '""', url] }
|
|
434
|
+
: { command: 'xdg-open', args: [url] };
|
|
435
|
+
try {
|
|
436
|
+
const child = spawn(command, args, {
|
|
437
|
+
detached: true,
|
|
438
|
+
stdio: 'ignore',
|
|
439
|
+
});
|
|
440
|
+
child.on('error', () => {
|
|
441
|
+
// Browser open is best-effort; swallow errors so the MCP call still succeeds.
|
|
442
|
+
});
|
|
443
|
+
child.unref();
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// ignore
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { AgentName } from '../types.js';
|
|
2
|
-
import { type AgentLiveConfig, type AgentMcpEntry } from './sync/agent-reader.js';
|
|
2
|
+
import { type AgentLiveConfig, type AgentMcpEntry, type PortableRemoteMcpMetadata } from './sync/agent-reader.js';
|
|
3
|
+
import { type McpPreflightService } from './mcp-preflight-service.js';
|
|
4
|
+
import { type SkillPreflightService } from './skill-preflight-service.js';
|
|
3
5
|
export type { AgentLiveConfig, AgentMcpEntry, AgentSkillEntry } from './sync/agent-reader.js';
|
|
6
|
+
export type { PortableRemoteMcpMetadata } from './sync/agent-reader.js';
|
|
4
7
|
export interface AgentConfigService {
|
|
5
8
|
readAll(options: {
|
|
6
9
|
cwd: string;
|
|
@@ -9,12 +12,26 @@ export interface AgentConfigService {
|
|
|
9
12
|
cwd: string;
|
|
10
13
|
agent: AgentName;
|
|
11
14
|
key: string;
|
|
12
|
-
entry
|
|
15
|
+
entry?: AgentMcpEntry;
|
|
16
|
+
remoteEntry?: PortableRemoteMcpMetadata;
|
|
13
17
|
}): Promise<void>;
|
|
14
18
|
removeMcp(options: {
|
|
15
19
|
cwd: string;
|
|
16
20
|
agent: AgentName;
|
|
17
21
|
key: string;
|
|
18
22
|
}): Promise<void>;
|
|
23
|
+
copySkill(options: {
|
|
24
|
+
sourceAgent: AgentName;
|
|
25
|
+
targetAgent: AgentName;
|
|
26
|
+
skillName: string;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
removeSkill(options: {
|
|
29
|
+
agent: AgentName;
|
|
30
|
+
skillName: string;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
interface AgentConfigServiceDependencies {
|
|
34
|
+
mcpPreflightService?: McpPreflightService;
|
|
35
|
+
skillPreflightService?: SkillPreflightService;
|
|
19
36
|
}
|
|
20
|
-
export declare function createAgentConfigService(): AgentConfigService;
|
|
37
|
+
export declare function createAgentConfigService(dependencies?: AgentConfigServiceDependencies): AgentConfigService;
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { copyFile, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { ValidationError } from '../errors.js';
|
|
4
5
|
import { createClaudeReader, createCodexReader, createGeminiReader, } from './sync/agent-reader.js';
|
|
5
6
|
import { formatTimestamp } from './sync/agent-writer.js';
|
|
7
|
+
import { createMcpPreflightService } from './mcp-preflight-service.js';
|
|
8
|
+
import { createSkillPreflightService } from './skill-preflight-service.js';
|
|
9
|
+
import { getSkillDir } from './skill-paths.js';
|
|
6
10
|
const readers = {
|
|
7
11
|
claude: createClaudeReader(),
|
|
8
12
|
codex: createCodexReader(),
|
|
9
13
|
gemini: createGeminiReader(),
|
|
10
14
|
};
|
|
11
|
-
export function createAgentConfigService() {
|
|
15
|
+
export function createAgentConfigService(dependencies = {}) {
|
|
16
|
+
const mcpPreflightService = dependencies.mcpPreflightService ?? createMcpPreflightService();
|
|
17
|
+
const skillPreflightService = dependencies.skillPreflightService ?? createSkillPreflightService();
|
|
12
18
|
return {
|
|
13
19
|
async readAll(options) {
|
|
14
20
|
const results = await Promise.all([
|
|
@@ -19,20 +25,32 @@ export function createAgentConfigService() {
|
|
|
19
25
|
return results;
|
|
20
26
|
},
|
|
21
27
|
async addMcp(options) {
|
|
22
|
-
const { cwd, agent, key, entry } = options;
|
|
28
|
+
const { cwd, agent, key, entry, remoteEntry } = options;
|
|
29
|
+
const preflight = await mcpPreflightService.execute({ cwd, agent, key, entry, remoteEntry });
|
|
30
|
+
const firstError = preflight.checks.find((check) => check.status === 'error');
|
|
31
|
+
if (firstError) {
|
|
32
|
+
throw new ValidationError(`MCP "${key}" cannot be added to ${agent}: ${firstError.message}`);
|
|
33
|
+
}
|
|
23
34
|
if (agent === 'claude') {
|
|
24
35
|
await mutateClaudeConfig(cwd, (servers) => {
|
|
25
|
-
servers[key] = toClaudeEntry(entry);
|
|
36
|
+
servers[key] = remoteEntry ? toClaudeRemoteEntry(remoteEntry) : toClaudeEntry(entry);
|
|
26
37
|
});
|
|
27
38
|
}
|
|
28
39
|
else if (agent === 'codex') {
|
|
29
|
-
await mutateCodexConfig((
|
|
30
|
-
|
|
40
|
+
await mutateCodexConfig(cwd, (state) => {
|
|
41
|
+
delete state.mcpServers[key];
|
|
42
|
+
delete state.remoteMcpServers[key];
|
|
43
|
+
if (remoteEntry) {
|
|
44
|
+
state.remoteMcpServers[key] = remoteEntry;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
state.mcpServers[key] = entry;
|
|
48
|
+
}
|
|
31
49
|
});
|
|
32
50
|
}
|
|
33
51
|
else if (agent === 'gemini') {
|
|
34
52
|
await mutateGeminiConfig(cwd, (servers) => {
|
|
35
|
-
servers[key] = toGeminiEntry(entry);
|
|
53
|
+
servers[key] = remoteEntry ? toGeminiRemoteEntry(remoteEntry) : toGeminiEntry(entry);
|
|
36
54
|
});
|
|
37
55
|
}
|
|
38
56
|
},
|
|
@@ -44,8 +62,9 @@ export function createAgentConfigService() {
|
|
|
44
62
|
});
|
|
45
63
|
}
|
|
46
64
|
else if (agent === 'codex') {
|
|
47
|
-
await mutateCodexConfig((
|
|
48
|
-
delete
|
|
65
|
+
await mutateCodexConfig(cwd, (state) => {
|
|
66
|
+
delete state.mcpServers[key];
|
|
67
|
+
delete state.remoteMcpServers[key];
|
|
49
68
|
});
|
|
50
69
|
}
|
|
51
70
|
else if (agent === 'gemini') {
|
|
@@ -54,6 +73,28 @@ export function createAgentConfigService() {
|
|
|
54
73
|
});
|
|
55
74
|
}
|
|
56
75
|
},
|
|
76
|
+
async copySkill(options) {
|
|
77
|
+
const { sourceAgent, targetAgent, skillName } = options;
|
|
78
|
+
const preflight = await skillPreflightService.execute({
|
|
79
|
+
sourceAgent,
|
|
80
|
+
targetAgent,
|
|
81
|
+
skillName,
|
|
82
|
+
source: 'local',
|
|
83
|
+
});
|
|
84
|
+
const firstError = preflight.checks.find((check) => check.status === 'error');
|
|
85
|
+
if (firstError) {
|
|
86
|
+
throw new ValidationError(`Skill "${skillName}" cannot be copied from ${sourceAgent} to ${targetAgent}: ${firstError.message}`);
|
|
87
|
+
}
|
|
88
|
+
const sourceDir = getSkillDir(sourceAgent, skillName);
|
|
89
|
+
const targetDir = getSkillDir(targetAgent, skillName);
|
|
90
|
+
await mkdir(path.dirname(targetDir), { recursive: true });
|
|
91
|
+
await cp(sourceDir, targetDir, { recursive: true });
|
|
92
|
+
},
|
|
93
|
+
async removeSkill(options) {
|
|
94
|
+
const { agent, skillName } = options;
|
|
95
|
+
const skillDir = getSkillDir(agent, skillName);
|
|
96
|
+
await rm(skillDir, { recursive: true, force: true });
|
|
97
|
+
},
|
|
57
98
|
};
|
|
58
99
|
}
|
|
59
100
|
/* ---- Claude: JSON with projects[cwd].mcpServers ---- */
|
|
@@ -67,6 +108,11 @@ async function mutateClaudeConfig(cwd, mutate) {
|
|
|
67
108
|
catch {
|
|
68
109
|
// fresh config
|
|
69
110
|
}
|
|
111
|
+
// Apply mutation to user-scoped (top-level) MCPs
|
|
112
|
+
const userServers = (existing.mcpServers ?? {});
|
|
113
|
+
mutate(userServers);
|
|
114
|
+
existing.mcpServers = userServers;
|
|
115
|
+
// Apply mutation to project-scoped MCPs
|
|
70
116
|
const projects = (existing.projects ?? {});
|
|
71
117
|
const projectConfig = projects[cwd] ?? {};
|
|
72
118
|
const servers = (projectConfig.mcpServers ?? {});
|
|
@@ -84,8 +130,15 @@ function toClaudeEntry(entry) {
|
|
|
84
130
|
...(entry.env ? { env: entry.env } : {}),
|
|
85
131
|
};
|
|
86
132
|
}
|
|
133
|
+
function toClaudeRemoteEntry(entry) {
|
|
134
|
+
return {
|
|
135
|
+
type: entry.transport === 'sse' ? 'sse' : 'http',
|
|
136
|
+
url: entry.url,
|
|
137
|
+
...(entry.headers ? { headers: entry.headers } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
87
140
|
/* ---- Codex: TOML with [mcp_servers.*] ---- */
|
|
88
|
-
async function mutateCodexConfig(mutate) {
|
|
141
|
+
async function mutateCodexConfig(cwd, mutate) {
|
|
89
142
|
const configPath = path.join(homedir(), '.codex', 'config.toml');
|
|
90
143
|
let existingContent = '';
|
|
91
144
|
try {
|
|
@@ -96,12 +149,15 @@ async function mutateCodexConfig(mutate) {
|
|
|
96
149
|
// fresh config
|
|
97
150
|
}
|
|
98
151
|
// Read current servers via reader
|
|
99
|
-
const current = await readers.codex.read({ cwd
|
|
100
|
-
const
|
|
101
|
-
|
|
152
|
+
const current = await readers.codex.read({ cwd });
|
|
153
|
+
const state = {
|
|
154
|
+
mcpServers: { ...current.mcpServers },
|
|
155
|
+
remoteMcpServers: { ...current.remoteMcpServers },
|
|
156
|
+
};
|
|
157
|
+
mutate(state);
|
|
102
158
|
// Rebuild: preserve non-mcp content + new mcp sections
|
|
103
159
|
const nonMcp = stripCodexMcpSections(existingContent).trim();
|
|
104
|
-
const mcpToml = buildCodexMcpToml(
|
|
160
|
+
const mcpToml = buildCodexMcpToml(state);
|
|
105
161
|
const final = nonMcp.length > 0 ? `${nonMcp}\n\n${mcpToml}` : mcpToml;
|
|
106
162
|
await mkdir(path.dirname(configPath), { recursive: true });
|
|
107
163
|
await atomicWrite(configPath, final + '\n');
|
|
@@ -123,9 +179,9 @@ function stripCodexMcpSections(content) {
|
|
|
123
179
|
}
|
|
124
180
|
return result.join('\n');
|
|
125
181
|
}
|
|
126
|
-
function buildCodexMcpToml(
|
|
182
|
+
function buildCodexMcpToml(state) {
|
|
127
183
|
const lines = [];
|
|
128
|
-
for (const [name, entry] of Object.entries(
|
|
184
|
+
for (const [name, entry] of Object.entries(state.mcpServers)) {
|
|
129
185
|
lines.push(`[mcp_servers.${name}]`);
|
|
130
186
|
lines.push(`command = ${tomlStr(entry.command)}`);
|
|
131
187
|
if (entry.args && entry.args.length > 0) {
|
|
@@ -140,6 +196,11 @@ function buildCodexMcpToml(servers) {
|
|
|
140
196
|
}
|
|
141
197
|
lines.push('');
|
|
142
198
|
}
|
|
199
|
+
for (const [name, entry] of Object.entries(state.remoteMcpServers)) {
|
|
200
|
+
lines.push(`[mcp_servers.${name}]`);
|
|
201
|
+
lines.push(`url = ${tomlStr(entry.url)}`);
|
|
202
|
+
lines.push('');
|
|
203
|
+
}
|
|
143
204
|
return lines.join('\n').trim();
|
|
144
205
|
}
|
|
145
206
|
function tomlStr(value) {
|
|
@@ -169,6 +230,13 @@ function toGeminiEntry(entry) {
|
|
|
169
230
|
...(entry.env ? { env: entry.env } : {}),
|
|
170
231
|
};
|
|
171
232
|
}
|
|
233
|
+
function toGeminiRemoteEntry(entry) {
|
|
234
|
+
return {
|
|
235
|
+
...(entry.transport === 'http' ? { httpUrl: entry.url } : { url: entry.url }),
|
|
236
|
+
...(entry.headers ? { headers: entry.headers } : {}),
|
|
237
|
+
...(entry.env ? { env: entry.env } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
172
240
|
/* ---- Shared helpers ---- */
|
|
173
241
|
async function backupFile(filePath) {
|
|
174
242
|
const backupPath = `${filePath}.bak.${formatTimestamp()}`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ParsedClaudeAgent {
|
|
2
|
+
frontmatter: Record<string, unknown>;
|
|
3
|
+
body: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ParsedCodexAgent {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
developerInstructions: string;
|
|
9
|
+
sandboxMode?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
extra: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseMarkdownWithFrontmatter(source: string): ParsedClaudeAgent;
|
|
14
|
+
export declare function serializeMarkdownWithFrontmatter(frontmatter: Record<string, unknown>, body: string): string;
|
|
15
|
+
export declare function claudeAgentMdToCodexToml(source: string): string;
|
|
16
|
+
export declare function codexAgentTomlToClaudeMd(source: string): string;
|
|
17
|
+
export declare function claudeCommandMdToCodexSkill(source: string): {
|
|
18
|
+
frontmatter: Record<string, unknown>;
|
|
19
|
+
skillMarkdown: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function parseCodexAgentToml(source: string): ParsedCodexAgent;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
3
|
+
export function parseMarkdownWithFrontmatter(source) {
|
|
4
|
+
const match = source.match(FRONTMATTER_RE);
|
|
5
|
+
if (!match) {
|
|
6
|
+
return { frontmatter: {}, body: source };
|
|
7
|
+
}
|
|
8
|
+
const parsed = YAML.parse(match[1]);
|
|
9
|
+
return {
|
|
10
|
+
frontmatter: parsed ?? {},
|
|
11
|
+
body: match[2],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function serializeMarkdownWithFrontmatter(frontmatter, body) {
|
|
15
|
+
const keys = Object.keys(frontmatter);
|
|
16
|
+
if (keys.length === 0)
|
|
17
|
+
return body;
|
|
18
|
+
const yaml = YAML.stringify(frontmatter).trimEnd();
|
|
19
|
+
const normalizedBody = body.startsWith('\n') ? body : `\n${body}`;
|
|
20
|
+
return `---\n${yaml}\n---${normalizedBody}`;
|
|
21
|
+
}
|
|
22
|
+
export function claudeAgentMdToCodexToml(source) {
|
|
23
|
+
const { frontmatter, body } = parseMarkdownWithFrontmatter(source);
|
|
24
|
+
const name = String(frontmatter.name ?? '').trim();
|
|
25
|
+
const description = String(frontmatter.description ?? '').trim();
|
|
26
|
+
if (!name)
|
|
27
|
+
throw new Error('Claude agent is missing required "name" frontmatter field.');
|
|
28
|
+
if (!description)
|
|
29
|
+
throw new Error('Claude agent is missing required "description" frontmatter field.');
|
|
30
|
+
const sandboxMode = inferSandboxModeFromClaudeTools(frontmatter.tools);
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push(`name = ${tomlString(name)}`);
|
|
33
|
+
lines.push(`description = ${tomlString(description)}`);
|
|
34
|
+
lines.push(`developer_instructions = ${tomlMultiline(body.trim())}`);
|
|
35
|
+
if (sandboxMode)
|
|
36
|
+
lines.push(`sandbox_mode = ${tomlString(sandboxMode)}`);
|
|
37
|
+
return lines.join('\n') + '\n';
|
|
38
|
+
}
|
|
39
|
+
export function codexAgentTomlToClaudeMd(source) {
|
|
40
|
+
const parsed = parseCodexAgentToml(source);
|
|
41
|
+
const frontmatter = {
|
|
42
|
+
name: parsed.name,
|
|
43
|
+
description: parsed.description,
|
|
44
|
+
};
|
|
45
|
+
if (parsed.sandboxMode) {
|
|
46
|
+
frontmatter.tools = claudeToolsFromSandboxMode(parsed.sandboxMode);
|
|
47
|
+
}
|
|
48
|
+
return serializeMarkdownWithFrontmatter(frontmatter, `\n${parsed.developerInstructions.trim()}\n`);
|
|
49
|
+
}
|
|
50
|
+
export function claudeCommandMdToCodexSkill(source) {
|
|
51
|
+
const { frontmatter, body } = parseMarkdownWithFrontmatter(source);
|
|
52
|
+
const description = String(frontmatter.description ?? '').trim();
|
|
53
|
+
if (!description) {
|
|
54
|
+
throw new Error('Claude command is missing required "description" frontmatter field.');
|
|
55
|
+
}
|
|
56
|
+
const skillFrontmatter = {
|
|
57
|
+
description,
|
|
58
|
+
};
|
|
59
|
+
if (frontmatter['argument-hint']) {
|
|
60
|
+
skillFrontmatter['argument-hint'] = frontmatter['argument-hint'];
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
frontmatter: skillFrontmatter,
|
|
64
|
+
skillMarkdown: serializeMarkdownWithFrontmatter(skillFrontmatter, body.startsWith('\n') ? body : `\n${body}`),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function inferSandboxModeFromClaudeTools(rawTools) {
|
|
68
|
+
const tools = normalizeClaudeToolList(rawTools);
|
|
69
|
+
if (tools.length === 0)
|
|
70
|
+
return undefined;
|
|
71
|
+
const mutating = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
72
|
+
return tools.some((tool) => mutating.has(tool)) ? 'workspace-write' : 'read-only';
|
|
73
|
+
}
|
|
74
|
+
function normalizeClaudeToolList(raw) {
|
|
75
|
+
if (Array.isArray(raw))
|
|
76
|
+
return raw.map((item) => String(item).trim()).filter(Boolean);
|
|
77
|
+
if (typeof raw === 'string') {
|
|
78
|
+
return raw.split(',').map((item) => item.trim()).filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
function claudeToolsFromSandboxMode(sandboxMode) {
|
|
83
|
+
const readOnlyTools = 'Glob, Grep, LS, Read, NotebookRead, WebFetch, WebSearch';
|
|
84
|
+
if (sandboxMode === 'read-only')
|
|
85
|
+
return readOnlyTools;
|
|
86
|
+
return `${readOnlyTools}, Edit, Write, Bash`;
|
|
87
|
+
}
|
|
88
|
+
export function parseCodexAgentToml(source) {
|
|
89
|
+
const lines = source.split('\n');
|
|
90
|
+
const simple = new Map();
|
|
91
|
+
const extra = {};
|
|
92
|
+
let i = 0;
|
|
93
|
+
while (i < lines.length) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
const trimmed = line.trim();
|
|
96
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (/^\[/.test(trimmed)) {
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
|
|
105
|
+
if (!kvMatch) {
|
|
106
|
+
i++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const [, key, rawValue] = kvMatch;
|
|
110
|
+
if (rawValue.startsWith('"""')) {
|
|
111
|
+
const { value, nextIndex } = readTomlMultiline(lines, i, rawValue);
|
|
112
|
+
simple.set(key, value);
|
|
113
|
+
i = nextIndex;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
simple.set(key, parseTomlScalar(rawValue));
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
const name = simple.get('name');
|
|
120
|
+
const description = simple.get('description');
|
|
121
|
+
const developerInstructions = simple.get('developer_instructions');
|
|
122
|
+
if (!name)
|
|
123
|
+
throw new Error('Codex agent is missing required "name" field.');
|
|
124
|
+
if (!description)
|
|
125
|
+
throw new Error('Codex agent is missing required "description" field.');
|
|
126
|
+
if (!developerInstructions) {
|
|
127
|
+
throw new Error('Codex agent is missing required "developer_instructions" field.');
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
description,
|
|
132
|
+
developerInstructions,
|
|
133
|
+
sandboxMode: simple.get('sandbox_mode'),
|
|
134
|
+
model: simple.get('model'),
|
|
135
|
+
extra,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function readTomlMultiline(lines, startIndex, firstLine) {
|
|
139
|
+
const afterOpen = firstLine.slice(3);
|
|
140
|
+
if (afterOpen.endsWith('"""') && afterOpen.length >= 3) {
|
|
141
|
+
return { value: unescapeTomlString(afterOpen.slice(0, -3)), nextIndex: startIndex + 1 };
|
|
142
|
+
}
|
|
143
|
+
const collected = [];
|
|
144
|
+
if (afterOpen.length > 0)
|
|
145
|
+
collected.push(afterOpen);
|
|
146
|
+
let i = startIndex + 1;
|
|
147
|
+
while (i < lines.length) {
|
|
148
|
+
const line = lines[i];
|
|
149
|
+
const closeIdx = line.indexOf('"""');
|
|
150
|
+
if (closeIdx === -1) {
|
|
151
|
+
collected.push(line);
|
|
152
|
+
i++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (closeIdx > 0)
|
|
156
|
+
collected.push(line.slice(0, closeIdx));
|
|
157
|
+
return { value: unescapeTomlString(collected.join('\n').replace(/^\n/, '')), nextIndex: i + 1 };
|
|
158
|
+
}
|
|
159
|
+
throw new Error('Unterminated TOML multiline string.');
|
|
160
|
+
}
|
|
161
|
+
function parseTomlScalar(raw) {
|
|
162
|
+
const trimmed = raw.trim();
|
|
163
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
164
|
+
return unescapeTomlString(trimmed.slice(1, -1));
|
|
165
|
+
}
|
|
166
|
+
return trimmed;
|
|
167
|
+
}
|
|
168
|
+
function unescapeTomlString(value) {
|
|
169
|
+
return value
|
|
170
|
+
.replace(/\\\\/g, '\\')
|
|
171
|
+
.replace(/\\"/g, '"')
|
|
172
|
+
.replace(/\\n/g, '\n')
|
|
173
|
+
.replace(/\\r/g, '\r')
|
|
174
|
+
.replace(/\\t/g, '\t');
|
|
175
|
+
}
|
|
176
|
+
function tomlString(value) {
|
|
177
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
178
|
+
}
|
|
179
|
+
function tomlMultiline(value) {
|
|
180
|
+
const escaped = value.replace(/"""/g, '\\"\\"\\"');
|
|
181
|
+
return `"""\n${escaped}\n"""`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { McpServerConfig, PortableCredentialSpec } from '../types.js';
|
|
2
|
+
export interface CredentialRedactionResult<T extends McpServerConfig> {
|
|
3
|
+
redacted: T;
|
|
4
|
+
credentials: PortableCredentialSpec[];
|
|
5
|
+
}
|
|
6
|
+
export declare function redactPortableMcpCredentials<T extends McpServerConfig>(config: T): CredentialRedactionResult<T>;
|
|
7
|
+
interface CredentialAccumulator {
|
|
8
|
+
key: string;
|
|
9
|
+
required: true;
|
|
10
|
+
descriptions: Set<string>;
|
|
11
|
+
}
|
|
12
|
+
export declare function finalizePortableCredentialSpecs(credentialsByKey: Map<string, CredentialAccumulator>): PortableCredentialSpec[];
|
|
13
|
+
export {};
|