brainctl 0.1.16 → 0.1.18

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 (74) hide show
  1. package/dist/cli.d.ts +4 -6
  2. package/dist/cli.js +11 -16
  3. package/dist/commands/profile.d.ts +4 -0
  4. package/dist/commands/profile.js +106 -16
  5. package/dist/commands/status.js +7 -7
  6. package/dist/mcp/server.d.ts +5 -0
  7. package/dist/mcp/server.js +85 -154
  8. package/dist/services/agent-asset-installer.d.ts +3 -0
  9. package/dist/services/agent-asset-installer.js +109 -0
  10. package/dist/services/agent-availability-service.d.ts +11 -0
  11. package/dist/services/agent-availability-service.js +32 -0
  12. package/dist/services/credential-redaction-service.d.ts +1 -0
  13. package/dist/services/credential-redaction-service.js +9 -3
  14. package/dist/services/doctor-service.d.ts +2 -2
  15. package/dist/services/doctor-service.js +7 -63
  16. package/dist/services/portable-profile-pack-service.d.ts +6 -0
  17. package/dist/services/portable-profile-pack-service.js +78 -4
  18. package/dist/services/profile-apply-service.d.ts +34 -0
  19. package/dist/services/profile-apply-service.js +102 -0
  20. package/dist/services/profile-export-service.d.ts +5 -1
  21. package/dist/services/profile-export-service.js +3 -1
  22. package/dist/services/profile-import-service.js +82 -127
  23. package/dist/services/profile-service.d.ts +3 -11
  24. package/dist/services/profile-service.js +57 -102
  25. package/dist/services/profile-snapshot-service.d.ts +12 -0
  26. package/dist/services/profile-snapshot-service.js +47 -0
  27. package/dist/services/status-service.d.ts +9 -7
  28. package/dist/services/status-service.js +14 -13
  29. package/dist/types.d.ts +2 -57
  30. package/dist/ui/routes.d.ts +0 -2
  31. package/dist/ui/routes.js +71 -120
  32. package/dist/web/assets/index-CGmTbSgk.js +63 -0
  33. package/dist/web/assets/index-EIVU5Woh.css +2 -0
  34. package/dist/web/brainctl-mark.svg +13 -0
  35. package/dist/web/index.html +2 -5
  36. package/package.json +2 -1
  37. package/dist/commands/init.d.ts +0 -3
  38. package/dist/commands/init.js +0 -27
  39. package/dist/commands/run.d.ts +0 -3
  40. package/dist/commands/run.js +0 -25
  41. package/dist/commands/sync.d.ts +0 -3
  42. package/dist/commands/sync.js +0 -31
  43. package/dist/config.d.ts +0 -14
  44. package/dist/config.js +0 -96
  45. package/dist/context/builder.d.ts +0 -6
  46. package/dist/context/builder.js +0 -13
  47. package/dist/context/memory.d.ts +0 -5
  48. package/dist/context/memory.js +0 -43
  49. package/dist/context/skills.d.ts +0 -2
  50. package/dist/context/skills.js +0 -8
  51. package/dist/executor/claude.d.ts +0 -12
  52. package/dist/executor/claude.js +0 -16
  53. package/dist/executor/codex.d.ts +0 -12
  54. package/dist/executor/codex.js +0 -16
  55. package/dist/executor/process.d.ts +0 -11
  56. package/dist/executor/process.js +0 -40
  57. package/dist/executor/resolver.d.ts +0 -13
  58. package/dist/executor/resolver.js +0 -60
  59. package/dist/executor/types.d.ts +0 -14
  60. package/dist/executor/types.js +0 -1
  61. package/dist/services/config-write-service.d.ts +0 -12
  62. package/dist/services/config-write-service.js +0 -70
  63. package/dist/services/init-service.d.ts +0 -14
  64. package/dist/services/init-service.js +0 -88
  65. package/dist/services/memory-write-service.d.ts +0 -12
  66. package/dist/services/memory-write-service.js +0 -56
  67. package/dist/services/run-service.d.ts +0 -15
  68. package/dist/services/run-service.js +0 -94
  69. package/dist/services/sync-service.d.ts +0 -15
  70. package/dist/services/sync-service.js +0 -69
  71. package/dist/ui/streaming.d.ts +0 -3
  72. package/dist/ui/streaming.js +0 -16
  73. package/dist/web/assets/index-CuNIAQ7N.js +0 -65
  74. package/dist/web/assets/index-Ow6x3bQk.css +0 -2
@@ -1,74 +1,25 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
- import path from 'node:path';
4
3
  import { FastMCP } from 'fastmcp';
5
4
  import { z } from 'zod';
6
- import { loadConfig } from '../config.js';
7
- import { loadMemory } from '../context/memory.js';
8
5
  import { createAgentConfigService } from '../services/agent-config-service.js';
9
6
  import { createDoctorService } from '../services/doctor-service.js';
10
7
  import { startUiServer } from '../ui/server.js';
11
- import { createMemoryWriteService } from '../services/memory-write-service.js';
12
8
  import { createProfileExportService } from '../services/profile-export-service.js';
13
9
  import { createProfileImportService } from '../services/profile-import-service.js';
10
+ import { createProfileApplyService } from '../services/profile-apply-service.js';
14
11
  import { createProfileService } from '../services/profile-service.js';
15
- import { createRunService } from '../services/run-service.js';
12
+ import { createProfileSnapshotService, defaultBackupProfileName, } from '../services/profile-snapshot-service.js';
16
13
  import { createStatusService } from '../services/status-service.js';
17
- import { createSyncService } from '../services/sync-service.js';
14
+ const ALL_AGENTS = ['claude', 'codex', 'gemini'];
18
15
  const packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
19
16
  export function createMcpServer(options = {}) {
20
17
  const cwd = options.cwd ?? process.cwd();
18
+ const uiState = options.uiServerState ?? { current: null };
21
19
  const server = new FastMCP({
22
20
  name: 'brainctl',
23
21
  version: packageVersion.version,
24
22
  });
25
- server.addTool({
26
- name: 'brainctl_list_skills',
27
- description: 'List available skills from the ai-stack.yaml config',
28
- parameters: z.object({}),
29
- execute: async () => {
30
- const config = await loadConfig({ cwd });
31
- const skills = Object.entries(config.skills).map(([name, skill]) => ({
32
- name,
33
- description: skill.description ?? null,
34
- }));
35
- return JSON.stringify(skills, null, 2);
36
- },
37
- });
38
- server.addTool({
39
- name: 'brainctl_run',
40
- description: 'Execute a skill with input text. Runs the skill through the configured agent and returns the output.',
41
- parameters: z.object({
42
- skill: z.string().describe('Skill name as defined in ai-stack.yaml'),
43
- input: z.string().describe('Input text to pass to the skill'),
44
- agent: z.enum(['claude', 'codex']).default('claude').describe('Agent to use for execution'),
45
- fallback_agent: z.enum(['claude', 'codex']).optional().describe('Fallback agent if primary is unavailable'),
46
- }),
47
- execute: async (args) => {
48
- const inputPath = path.join(cwd, `.brainctl-mcp-input-${Date.now()}.tmp`);
49
- const { writeFile: writeFileAsync, unlink } = await import('node:fs/promises');
50
- try {
51
- await writeFileAsync(inputPath, args.input, 'utf8');
52
- const runService = createRunService();
53
- const trace = await runService.execute({
54
- cwd,
55
- skill: args.skill,
56
- inputFile: path.basename(inputPath),
57
- primaryAgent: args.agent,
58
- fallbackAgent: args.fallback_agent,
59
- });
60
- return trace.finalOutput;
61
- }
62
- finally {
63
- try {
64
- await unlink(inputPath);
65
- }
66
- catch {
67
- // temp file cleanup is best-effort
68
- }
69
- }
70
- },
71
- });
72
23
  server.addTool({
73
24
  name: 'brainctl_status',
74
25
  description: 'Show project status: config path, memory files, available skills, and agent availability',
@@ -90,94 +41,67 @@ export function createMcpServer(options = {}) {
90
41
  },
91
42
  });
92
43
  server.addTool({
93
- name: 'brainctl_read_memory',
94
- description: 'Read all shared memory files. Returns every markdown file from configured memory paths with file names and content. Use this to understand context left by other agents.',
44
+ name: 'brainctl_list_profiles',
45
+ description: 'List available profiles and show which one is active.',
95
46
  parameters: z.object({}),
96
47
  execute: async () => {
97
- const config = await loadConfig({ cwd });
98
- const memory = await loadMemory({ paths: config.memory.paths });
99
- const result = {
100
- count: memory.count,
101
- files: memory.entries.map((entry) => ({
102
- path: entry.path,
103
- content: entry.content,
104
- })),
105
- };
48
+ const profileService = createProfileService();
49
+ const result = await profileService.list({ cwd });
106
50
  return JSON.stringify(result, null, 2);
107
51
  },
108
52
  });
109
53
  server.addTool({
110
- name: 'brainctl_write_memory',
111
- description: 'Write or update a shared memory file. Use this to leave notes, decisions, or context for other agents. The file must be within a configured memory path.',
54
+ name: 'brainctl_apply_profile',
55
+ description: 'Apply a profile (MCPs, plugins, user skills) to the specified agents. Selective by --agents and --items. Auto-backs up live agent state before a full apply unless backup=false.',
112
56
  parameters: z.object({
113
- file_path: z.string().describe('Relative path for the memory file (e.g., "memory/notes.md")'),
114
- content: z.string().describe('Markdown content to write'),
57
+ name: z.string().describe('Profile name to apply'),
58
+ agents: z
59
+ .array(z.enum(['claude', 'codex', 'gemini']))
60
+ .optional()
61
+ .describe('Agents to target (default: all three)'),
62
+ items: z
63
+ .array(z.object({
64
+ type: z.enum(['mcp', 'plugin', 'skill']),
65
+ name: z.string(),
66
+ }))
67
+ .optional()
68
+ .describe('Specific items to apply (default: everything matching)'),
69
+ backup: z
70
+ .boolean()
71
+ .optional()
72
+ .describe('Force backup on/off (default: on for full apply, off for partial)'),
115
73
  }),
116
74
  execute: async (args) => {
117
- const memoryWriteService = createMemoryWriteService();
118
- const result = await memoryWriteService.execute({
75
+ const applyService = createProfileApplyService();
76
+ const result = await applyService.execute({
119
77
  cwd,
120
- filePath: args.file_path,
121
- content: args.content,
78
+ profileName: args.name,
79
+ agents: args.agents ?? ALL_AGENTS,
80
+ items: args.items,
81
+ backup: args.backup,
122
82
  });
123
- return JSON.stringify({ written: result.filePath });
124
- },
125
- });
126
- server.addTool({
127
- name: 'brainctl_get_skill',
128
- description: 'Get the full details of a specific skill including its prompt text and description. Use this to understand what a skill does before running it.',
129
- parameters: z.object({
130
- skill: z.string().describe('Skill name as defined in ai-stack.yaml'),
131
- }),
132
- execute: async (args) => {
133
- const config = await loadConfig({ cwd });
134
- const skillConfig = config.skills[args.skill];
135
- if (!skillConfig) {
136
- throw new Error(`Skill "${args.skill}" is not defined in ai-stack.yaml.`);
137
- }
138
- return JSON.stringify({
139
- name: args.skill,
140
- description: skillConfig.description ?? null,
141
- prompt: skillConfig.prompt,
142
- }, null, 2);
143
- },
144
- });
145
- server.addTool({
146
- name: 'brainctl_list_profiles',
147
- description: 'List available profiles and show which one is active.',
148
- parameters: z.object({}),
149
- execute: async () => {
150
- const profileService = createProfileService();
151
- const result = await profileService.list({ cwd });
152
83
  return JSON.stringify(result, null, 2);
153
84
  },
154
85
  });
155
86
  server.addTool({
156
- name: 'brainctl_switch_profile',
157
- description: 'Switch the active profile and sync it to all configured agents. Combines profile switch + sync in one step.',
87
+ name: 'brainctl_snapshot_agent',
88
+ description: "Snapshot a live agent's MCPs+plugins+skills into a new profile folder. Useful for backups or capturing your current setup as a shareable profile.",
158
89
  parameters: z.object({
159
- name: z.string().describe('Profile name to activate'),
90
+ agent: z.enum(['claude', 'codex', 'gemini']),
91
+ as: z
92
+ .string()
93
+ .optional()
94
+ .describe('Profile name to write into (default: backup-<agent>-<timestamp>)'),
160
95
  }),
161
96
  execute: async (args) => {
162
- const profileService = createProfileService();
163
- const switchResult = await profileService.use({ cwd, name: args.name });
164
- const syncService = createSyncService({ profileService });
165
- const syncResult = await syncService.execute({ cwd });
166
- return JSON.stringify({
167
- previousProfile: switchResult.previousProfile,
168
- activeProfile: args.name,
169
- synced: syncResult,
170
- }, null, 2);
171
- },
172
- });
173
- server.addTool({
174
- name: 'brainctl_sync',
175
- description: 'Sync the active profile to all configured agent configs (Claude, Codex). Creates backups before overwriting.',
176
- parameters: z.object({}),
177
- execute: async () => {
178
- const syncService = createSyncService();
179
- const result = await syncService.execute({ cwd });
180
- return JSON.stringify(result, null, 2);
97
+ const snapshotService = createProfileSnapshotService();
98
+ const profileName = args.as ?? defaultBackupProfileName(args.agent);
99
+ const result = await snapshotService.execute({
100
+ cwd,
101
+ agent: args.agent,
102
+ profileName,
103
+ });
104
+ return JSON.stringify({ profileName, ...result }, null, 2);
181
105
  },
182
106
  });
183
107
  server.addTool({
@@ -211,20 +135,13 @@ export function createMcpServer(options = {}) {
211
135
  });
212
136
  server.addTool({
213
137
  name: 'brainctl_update_profile',
214
- description: 'Update a profile config. Pass the full profile object with skills, mcps, and memory fields. Use this to add, remove, or modify skills and MCPs within a profile.',
138
+ description: 'Update a profile config. Pass the full profile object with name, optional description, and mcps map.',
215
139
  parameters: z.object({
216
140
  name: z.string().describe('Profile name to update'),
217
141
  config: z.object({
218
142
  name: z.string(),
219
143
  description: z.string().optional(),
220
- skills: z.record(z.string(), z.object({
221
- description: z.string().optional(),
222
- prompt: z.string(),
223
- })),
224
144
  mcps: z.record(z.string(), z.unknown()),
225
- memory: z.object({
226
- paths: z.array(z.string()),
227
- }),
228
145
  }).describe('Full profile config object'),
229
146
  }),
230
147
  execute: async (args) => {
@@ -251,25 +168,17 @@ export function createMcpServer(options = {}) {
251
168
  });
252
169
  server.addTool({
253
170
  name: 'brainctl_copy_profile_items',
254
- description: 'Copy skills and/or MCPs from one profile to another. Specify which skill and MCP keys to copy. Existing items with the same key in the target are overwritten.',
171
+ description: 'Copy MCPs from one profile to another. Existing MCPs with the same key in the target are overwritten.',
255
172
  parameters: z.object({
256
173
  source: z.string().describe('Source profile name'),
257
174
  target: z.string().describe('Target profile name'),
258
- skills: z.array(z.string()).default([]).describe('Skill keys to copy'),
259
175
  mcps: z.array(z.string()).default([]).describe('MCP keys to copy'),
260
176
  }),
261
177
  execute: async (args) => {
262
178
  const profileService = createProfileService();
263
179
  const sourceProfile = await profileService.get({ cwd, name: args.source });
264
180
  const targetProfile = await profileService.get({ cwd, name: args.target });
265
- const copiedSkills = [];
266
181
  const copiedMcps = [];
267
- for (const key of args.skills) {
268
- if (sourceProfile.skills[key]) {
269
- targetProfile.skills[key] = sourceProfile.skills[key];
270
- copiedSkills.push(key);
271
- }
272
- }
273
182
  for (const key of args.mcps) {
274
183
  if (sourceProfile.mcps[key]) {
275
184
  targetProfile.mcps[key] = sourceProfile.mcps[key];
@@ -280,7 +189,6 @@ export function createMcpServer(options = {}) {
280
189
  return JSON.stringify({
281
190
  source: args.source,
282
191
  target: args.target,
283
- copiedSkills,
284
192
  copiedMcps,
285
193
  }, null, 2);
286
194
  },
@@ -327,33 +235,32 @@ export function createMcpServer(options = {}) {
327
235
  return JSON.stringify(result, null, 2);
328
236
  },
329
237
  });
330
- let uiServerInstance = null;
331
238
  server.addTool({
332
239
  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.',
240
+ description: 'Open the brainctl web dashboard in the default browser. The dashboard auto-starts with the MCP server; this tool just opens the URL. Starts the server on demand if auto-start was skipped or failed.',
334
241
  parameters: z.object({
335
- port: z.number().default(3333).describe('Port number for the UI server'),
242
+ port: z.number().default(3333).describe('Port to use if the dashboard needs to be started'),
336
243
  openBrowser: z
337
244
  .boolean()
338
245
  .default(true)
339
246
  .describe('Whether to launch the default browser at the UI URL'),
340
247
  }),
341
248
  execute: async (args) => {
342
- if (uiServerInstance) {
249
+ if (uiState.current) {
343
250
  if (args.openBrowser)
344
- openInBrowser(uiServerInstance.url);
251
+ openInBrowser(uiState.current.url);
345
252
  return JSON.stringify({
346
- url: uiServerInstance.url,
253
+ url: uiState.current.url,
347
254
  status: 'already_running',
348
255
  browserOpened: args.openBrowser,
349
256
  });
350
257
  }
351
258
  try {
352
- uiServerInstance = await startUiServer({ cwd, port: args.port });
259
+ uiState.current = await startUiServer({ cwd, port: args.port });
353
260
  if (args.openBrowser)
354
- openInBrowser(uiServerInstance.url);
261
+ openInBrowser(uiState.current.url);
355
262
  return JSON.stringify({
356
- url: uiServerInstance.url,
263
+ url: uiState.current.url,
357
264
  status: 'started',
358
265
  browserOpened: args.openBrowser,
359
266
  });
@@ -368,11 +275,11 @@ export function createMcpServer(options = {}) {
368
275
  description: 'Stop the brainctl web dashboard if it is running.',
369
276
  parameters: z.object({}),
370
277
  execute: async () => {
371
- if (!uiServerInstance) {
278
+ if (!uiState.current) {
372
279
  return JSON.stringify({ status: 'not_running' });
373
280
  }
374
- await uiServerInstance.close();
375
- uiServerInstance = null;
281
+ await uiState.current.close();
282
+ uiState.current = null;
376
283
  return JSON.stringify({ status: 'stopped' });
377
284
  },
378
285
  });
@@ -422,9 +329,33 @@ export function createMcpServer(options = {}) {
422
329
  return server;
423
330
  }
424
331
  export async function startMcpServer(options = {}) {
425
- const server = createMcpServer(options);
332
+ const cwd = options.cwd ?? process.cwd();
333
+ const uiServerState = { current: null };
334
+ if (process.env.BRAINCTL_AUTO_UI !== '0') {
335
+ uiServerState.current = await tryAutoStartUi(cwd, 3333);
336
+ }
337
+ const server = createMcpServer({ cwd, uiServerState });
426
338
  await server.start({ transportType: 'stdio' });
427
339
  }
340
+ async function tryAutoStartUi(cwd, port) {
341
+ try {
342
+ return await startUiServer({ cwd, port });
343
+ }
344
+ catch (err) {
345
+ const code = err.code;
346
+ if (code === 'EADDRINUSE') {
347
+ const url = `http://127.0.0.1:${port}`;
348
+ process.stderr.write(`brainctl: UI port ${port} already in use; assuming another brainctl instance owns ${url}\n`);
349
+ return {
350
+ server: null,
351
+ url,
352
+ close: async () => { },
353
+ };
354
+ }
355
+ process.stderr.write(`brainctl: UI auto-start failed: ${err.message}\n`);
356
+ return null;
357
+ }
358
+ }
428
359
  function openInBrowser(url) {
429
360
  const platform = process.platform;
430
361
  const { command, args } = platform === 'darwin'
@@ -0,0 +1,3 @@
1
+ import type { PortablePluginSnapshot, PortableUserSkillSnapshot } from '../types.js';
2
+ export declare function installPlugin(sourceDir: string, plugin: PortablePluginSnapshot): Promise<void>;
3
+ export declare function installUserSkill(sourceDir: string, skill: PortableUserSkillSnapshot): Promise<void>;
@@ -0,0 +1,109 @@
1
+ import { copyFile, cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { ProfileError } from '../errors.js';
5
+ import { formatTimestamp } from './sync/agent-writer.js';
6
+ export async function installPlugin(sourceDir, plugin) {
7
+ try {
8
+ await stat(sourceDir);
9
+ }
10
+ catch {
11
+ throw new ProfileError(`Bundled plugin "${plugin.name}" source missing at ${sourceDir}.`);
12
+ }
13
+ if (plugin.agent === 'gemini') {
14
+ return;
15
+ }
16
+ const marketplace = plugin.marketplace ?? plugin.source;
17
+ const version = plugin.version ?? 'unknown';
18
+ const cacheRoot = path.join(homedir(), `.${plugin.agent}`, 'plugins', 'cache');
19
+ const targetDir = path.join(cacheRoot, marketplace, plugin.name, version);
20
+ await rm(targetDir, { recursive: true, force: true });
21
+ await mkdir(path.dirname(targetDir), { recursive: true });
22
+ await cp(sourceDir, targetDir, { recursive: true });
23
+ if (plugin.agent === 'claude') {
24
+ await registerClaudePlugin({
25
+ pluginKey: `${plugin.name}@${marketplace}`,
26
+ installPath: targetDir,
27
+ version,
28
+ });
29
+ return;
30
+ }
31
+ if (plugin.agent === 'codex') {
32
+ await registerCodexPlugin({
33
+ pluginKey: `${plugin.name}@${marketplace}`,
34
+ });
35
+ }
36
+ }
37
+ export async function installUserSkill(sourceDir, skill) {
38
+ try {
39
+ await stat(sourceDir);
40
+ }
41
+ catch {
42
+ throw new ProfileError(`Bundled user skill "${skill.name}" source missing at ${sourceDir}.`);
43
+ }
44
+ const targetDir = path.join(homedir(), `.${skill.agent}`, 'skills', skill.name);
45
+ await rm(targetDir, { recursive: true, force: true });
46
+ await mkdir(path.dirname(targetDir), { recursive: true });
47
+ await cp(sourceDir, targetDir, { recursive: true });
48
+ }
49
+ async function registerClaudePlugin(options) {
50
+ const filePath = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
51
+ let existing = { version: 2, plugins: {} };
52
+ try {
53
+ const source = await readFile(filePath, 'utf8');
54
+ existing = JSON.parse(source);
55
+ await backupFile(filePath);
56
+ }
57
+ catch {
58
+ // fresh file
59
+ }
60
+ const plugins = (existing.plugins ?? {});
61
+ const now = new Date().toISOString();
62
+ const entry = {
63
+ scope: 'user',
64
+ installPath: options.installPath,
65
+ version: options.version,
66
+ installedAt: now,
67
+ lastUpdated: now,
68
+ };
69
+ plugins[options.pluginKey] = [entry];
70
+ existing.plugins = plugins;
71
+ if (typeof existing.version !== 'number')
72
+ existing.version = 2;
73
+ await mkdir(path.dirname(filePath), { recursive: true });
74
+ await atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
75
+ }
76
+ async function registerCodexPlugin(options) {
77
+ const filePath = path.join(homedir(), '.codex', 'config.toml');
78
+ let existing = '';
79
+ try {
80
+ existing = await readFile(filePath, 'utf8');
81
+ await backupFile(filePath);
82
+ }
83
+ catch {
84
+ existing = '';
85
+ }
86
+ const header = `[plugins."${options.pluginKey}"]`;
87
+ if (existing.includes(header))
88
+ return;
89
+ const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
90
+ const separator = existing.length > 0 ? '\n' : '';
91
+ const block = `${header}\nenabled = true\n`;
92
+ const next = existing + prefix + separator + block;
93
+ await mkdir(path.dirname(filePath), { recursive: true });
94
+ await atomicWrite(filePath, next);
95
+ }
96
+ async function backupFile(filePath) {
97
+ const backupPath = `${filePath}.bak.${formatTimestamp()}`;
98
+ try {
99
+ await copyFile(filePath, backupPath);
100
+ }
101
+ catch {
102
+ // file may not exist
103
+ }
104
+ }
105
+ async function atomicWrite(filePath, content) {
106
+ const tmpPath = `${filePath}.tmp.${Date.now()}`;
107
+ await writeFile(tmpPath, content, 'utf8');
108
+ await rename(tmpPath, filePath);
109
+ }
@@ -0,0 +1,11 @@
1
+ import type { AgentName } from '../types.js';
2
+ export interface AgentAvailability {
3
+ agent: AgentName;
4
+ available: boolean;
5
+ command: string;
6
+ resolvedPath?: string;
7
+ }
8
+ export interface AgentAvailabilityService {
9
+ getAll(): Promise<Record<AgentName, AgentAvailability>>;
10
+ }
11
+ export declare function createAgentAvailabilityService(): AgentAvailabilityService;
@@ -0,0 +1,32 @@
1
+ import { findExecutable } from '../system/executables.js';
2
+ const SUPPORTED_AGENTS = ['claude', 'codex', 'gemini'];
3
+ const AGENT_COMMANDS = {
4
+ claude: 'claude',
5
+ codex: 'codex',
6
+ gemini: 'gemini',
7
+ };
8
+ export function createAgentAvailabilityService() {
9
+ const cache = new Map();
10
+ const check = (agent) => {
11
+ if (!cache.has(agent)) {
12
+ cache.set(agent, checkAvailability(agent));
13
+ }
14
+ return cache.get(agent);
15
+ };
16
+ return {
17
+ async getAll() {
18
+ const entries = await Promise.all(SUPPORTED_AGENTS.map(async (agent) => [agent, await check(agent)]));
19
+ return Object.fromEntries(entries);
20
+ },
21
+ };
22
+ }
23
+ async function checkAvailability(agent) {
24
+ const command = AGENT_COMMANDS[agent];
25
+ const resolvedPath = await findExecutable(command);
26
+ return {
27
+ agent,
28
+ command,
29
+ available: resolvedPath !== null,
30
+ resolvedPath: resolvedPath ?? undefined,
31
+ };
32
+ }
@@ -2,6 +2,7 @@ import type { McpServerConfig, PortableCredentialSpec } from '../types.js';
2
2
  export interface CredentialRedactionResult<T extends McpServerConfig> {
3
3
  redacted: T;
4
4
  credentials: PortableCredentialSpec[];
5
+ rawValues: Record<string, string>;
5
6
  }
6
7
  export declare function redactPortableMcpCredentials<T extends McpServerConfig>(config: T): CredentialRedactionResult<T>;
7
8
  interface CredentialAccumulator {
@@ -1,8 +1,9 @@
1
1
  export function redactPortableMcpCredentials(config) {
2
2
  const credentialsByKey = new Map();
3
- const redactedEnv = redactStringMap(config.env, 'env', credentialsByKey);
3
+ const rawValues = {};
4
+ const redactedEnv = redactStringMap(config.env, 'env', credentialsByKey, rawValues);
4
5
  if (config.kind === 'remote') {
5
- const redactedHeaders = redactStringMap(config.headers, 'header', credentialsByKey);
6
+ const redactedHeaders = redactStringMap(config.headers, 'header', credentialsByKey, rawValues);
6
7
  return {
7
8
  redacted: {
8
9
  ...config,
@@ -10,6 +11,7 @@ export function redactPortableMcpCredentials(config) {
10
11
  ...(redactedHeaders ? { headers: redactedHeaders } : {}),
11
12
  },
12
13
  credentials: finalizePortableCredentialSpecs(credentialsByKey),
14
+ rawValues,
13
15
  };
14
16
  }
15
17
  return {
@@ -18,9 +20,10 @@ export function redactPortableMcpCredentials(config) {
18
20
  ...(redactedEnv ? { env: redactedEnv } : {}),
19
21
  },
20
22
  credentials: finalizePortableCredentialSpecs(credentialsByKey),
23
+ rawValues,
21
24
  };
22
25
  }
23
- function redactStringMap(values, source, credentialsByKey) {
26
+ function redactStringMap(values, source, credentialsByKey, rawValues) {
24
27
  if (!values) {
25
28
  return undefined;
26
29
  }
@@ -32,6 +35,9 @@ function redactStringMap(values, source, credentialsByKey) {
32
35
  }
33
36
  const credentialKey = normalizeCredentialKey(key);
34
37
  addCredentialSpec(credentialsByKey, credentialKey, source, key);
38
+ if (!isCredentialPlaceholder(value)) {
39
+ rawValues[credentialKey] = value;
40
+ }
35
41
  redacted[key] = isCredentialPlaceholder(value)
36
42
  ? value
37
43
  : `\${credentials.${credentialKey}}`;
@@ -1,4 +1,4 @@
1
- import type { ExecutorResolver } from '../executor/resolver.js';
1
+ import { type AgentAvailabilityService } from './agent-availability-service.js';
2
2
  import type { DiagnosticCheck } from '../types.js';
3
3
  export interface DoctorResult {
4
4
  checks: DiagnosticCheck[];
@@ -10,5 +10,5 @@ export interface DoctorService {
10
10
  }): Promise<DoctorResult>;
11
11
  }
12
12
  export declare function createDoctorService(dependencies?: {
13
- resolver?: ExecutorResolver;
13
+ availabilityService?: AgentAvailabilityService;
14
14
  }): DoctorService;