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.
Files changed (59) hide show
  1. package/README.md +215 -136
  2. package/dist/cli.js +40 -0
  3. package/dist/commands/mcp.js +35 -0
  4. package/dist/commands/profile.js +35 -2
  5. package/dist/executor/resolver.js +1 -38
  6. package/dist/mcp/server.js +82 -2
  7. package/dist/services/agent-config-service.d.ts +20 -3
  8. package/dist/services/agent-config-service.js +84 -16
  9. package/dist/services/agent-converter-service.d.ts +21 -0
  10. package/dist/services/agent-converter-service.js +182 -0
  11. package/dist/services/credential-redaction-service.d.ts +13 -0
  12. package/dist/services/credential-redaction-service.js +89 -0
  13. package/dist/services/credential-resolution-service.d.ts +11 -0
  14. package/dist/services/credential-resolution-service.js +69 -0
  15. package/dist/services/mcp-preflight-service.d.ts +26 -0
  16. package/dist/services/mcp-preflight-service.js +238 -0
  17. package/dist/services/plugin-install-service.d.ts +135 -0
  18. package/dist/services/plugin-install-service.js +601 -0
  19. package/dist/services/portable-mcp-classifier.d.ts +12 -0
  20. package/dist/services/portable-mcp-classifier.js +116 -0
  21. package/dist/services/portable-profile-pack-service.d.ts +26 -0
  22. package/dist/services/portable-profile-pack-service.js +264 -0
  23. package/dist/services/profile-export-service.d.ts +15 -3
  24. package/dist/services/profile-export-service.js +10 -57
  25. package/dist/services/profile-import-service.d.ts +9 -1
  26. package/dist/services/profile-import-service.js +266 -11
  27. package/dist/services/profile-service.d.ts +1 -0
  28. package/dist/services/profile-service.js +128 -32
  29. package/dist/services/runtime-detector.d.ts +9 -0
  30. package/dist/services/runtime-detector.js +130 -0
  31. package/dist/services/skill-paths.d.ts +4 -0
  32. package/dist/services/skill-paths.js +26 -0
  33. package/dist/services/skill-preflight-service.d.ts +23 -0
  34. package/dist/services/skill-preflight-service.js +40 -0
  35. package/dist/services/sync/agent-reader.d.ts +14 -0
  36. package/dist/services/sync/agent-reader.js +198 -45
  37. package/dist/services/sync/claude-writer.js +4 -7
  38. package/dist/services/sync/codex-writer.d.ts +1 -0
  39. package/dist/services/sync/codex-writer.js +25 -8
  40. package/dist/services/sync/gemini-writer.js +9 -8
  41. package/dist/services/sync/managed-plugin-registry.d.ts +17 -0
  42. package/dist/services/sync/managed-plugin-registry.js +75 -0
  43. package/dist/services/sync/plugin-skill-reader.d.ts +7 -0
  44. package/dist/services/sync/plugin-skill-reader.js +174 -0
  45. package/dist/services/sync-service.js +6 -1
  46. package/dist/services/update-check-service.d.ts +33 -0
  47. package/dist/services/update-check-service.js +128 -0
  48. package/dist/system/executables.d.ts +1 -0
  49. package/dist/system/executables.js +38 -0
  50. package/dist/types.d.ts +62 -5
  51. package/dist/ui/routes.js +293 -8
  52. package/dist/web/assets/index-Cdb5hbxM.css +1 -0
  53. package/dist/web/assets/index-gN83hZYA.js +65 -0
  54. package/dist/web/favicon-light.svg +13 -0
  55. package/dist/web/favicon.svg +13 -0
  56. package/dist/web/index.html +7 -2
  57. package/package.json +9 -1
  58. package/dist/web/assets/index-364NYWPA.css +0 -1
  59. 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
- }
@@ -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
- name: args.name,
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: AgentMcpEntry;
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((servers) => {
30
- servers[key] = entry;
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((servers) => {
48
- delete servers[key];
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 servers = { ...current.mcpServers };
101
- mutate(servers);
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(servers);
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(servers) {
182
+ function buildCodexMcpToml(state) {
127
183
  const lines = [];
128
- for (const [name, entry] of Object.entries(servers)) {
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 {};