brainctl 0.1.2 → 0.1.4

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 (49) hide show
  1. package/dist/cli.d.ts +4 -0
  2. package/dist/cli.js +16 -2
  3. package/dist/commands/mcp.d.ts +2 -0
  4. package/dist/commands/mcp.js +9 -0
  5. package/dist/commands/profile.d.ts +3 -0
  6. package/dist/commands/profile.js +43 -0
  7. package/dist/commands/sync.d.ts +3 -0
  8. package/dist/commands/sync.js +31 -0
  9. package/dist/commands/ui.d.ts +2 -0
  10. package/dist/commands/ui.js +10 -0
  11. package/dist/config.d.ts +9 -1
  12. package/dist/config.js +24 -6
  13. package/dist/context/memory.js +7 -2
  14. package/dist/errors.d.ts +9 -0
  15. package/dist/errors.js +15 -0
  16. package/dist/executor/process.js +2 -0
  17. package/dist/executor/resolver.js +5 -2
  18. package/dist/executor/types.d.ts +1 -0
  19. package/dist/mcp/server.d.ts +7 -0
  20. package/dist/mcp/server.js +183 -0
  21. package/dist/services/config-write-service.d.ts +12 -0
  22. package/dist/services/config-write-service.js +70 -0
  23. package/dist/services/memory-write-service.d.ts +12 -0
  24. package/dist/services/memory-write-service.js +56 -0
  25. package/dist/services/profile-service.d.ts +30 -0
  26. package/dist/services/profile-service.js +190 -0
  27. package/dist/services/run-service.d.ts +5 -1
  28. package/dist/services/run-service.js +3 -2
  29. package/dist/services/sync/agent-writer.d.ts +18 -0
  30. package/dist/services/sync/agent-writer.js +5 -0
  31. package/dist/services/sync/claude-writer.d.ts +2 -0
  32. package/dist/services/sync/claude-writer.js +83 -0
  33. package/dist/services/sync/codex-writer.d.ts +2 -0
  34. package/dist/services/sync/codex-writer.js +116 -0
  35. package/dist/services/sync/gemini-writer.d.ts +2 -0
  36. package/dist/services/sync/gemini-writer.js +83 -0
  37. package/dist/services/sync-service.d.ts +15 -0
  38. package/dist/services/sync-service.js +64 -0
  39. package/dist/types.d.ts +39 -1
  40. package/dist/ui/routes.d.ts +10 -0
  41. package/dist/ui/routes.js +228 -0
  42. package/dist/ui/server.d.ts +14 -0
  43. package/dist/ui/server.js +47 -0
  44. package/dist/ui/streaming.d.ts +3 -0
  45. package/dist/ui/streaming.js +16 -0
  46. package/dist/web/assets/index-CRJ6cM0Q.css +1 -0
  47. package/dist/web/assets/index-Cr8gt3VF.js +9 -0
  48. package/dist/web/index.html +14 -0
  49. package/package.json +17 -3
package/dist/cli.d.ts CHANGED
@@ -2,13 +2,17 @@
2
2
  import { Command } from 'commander';
3
3
  import { type DoctorService } from './services/doctor-service.js';
4
4
  import { type InitService } from './services/init-service.js';
5
+ import { type ProfileService } from './services/profile-service.js';
5
6
  import { type RunService } from './services/run-service.js';
6
7
  import { type StatusService } from './services/status-service.js';
8
+ import { type SyncService } from './services/sync-service.js';
7
9
  export interface CliServices {
8
10
  initService: InitService;
9
11
  runService: RunService;
10
12
  statusService: StatusService;
11
13
  doctorService: DoctorService;
14
+ profileService: ProfileService;
15
+ syncService: SyncService;
12
16
  }
13
17
  export declare function createProgram(overrides?: Partial<CliServices>): Command;
14
18
  export declare function main(argv?: string[]): Promise<void>;
package/dist/cli.js CHANGED
@@ -1,29 +1,40 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from 'node:fs';
2
+ import { realpathSync, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { Command } from 'commander';
6
6
  import { registerDoctorCommand } from './commands/doctor.js';
7
7
  import { registerInitCommand } from './commands/init.js';
8
+ import { registerMcpCommand } from './commands/mcp.js';
9
+ import { registerProfileCommand } from './commands/profile.js';
8
10
  import { registerRunCommand } from './commands/run.js';
9
11
  import { registerStatusCommand } from './commands/status.js';
12
+ import { registerSyncCommand } from './commands/sync.js';
13
+ import { registerUiCommand } from './commands/ui.js';
10
14
  import { printError } from './output.js';
11
15
  import { createDoctorService } from './services/doctor-service.js';
12
16
  import { createInitService } from './services/init-service.js';
17
+ import { createProfileService } from './services/profile-service.js';
13
18
  import { createRunService } from './services/run-service.js';
14
19
  import { createStatusService } from './services/status-service.js';
20
+ import { createSyncService } from './services/sync-service.js';
15
21
  import { createExecutorResolver } from './executor/resolver.js';
22
+ const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
16
23
  export function createProgram(overrides = {}) {
17
24
  const services = createDefaultServices(overrides);
18
25
  const program = new Command();
19
26
  program
20
27
  .name('brainctl')
21
28
  .description('Manage repeatable AI environments for local agent workflows')
22
- .version('0.1.0');
29
+ .version(packageVersion.version);
23
30
  registerInitCommand(program, services.initService);
24
31
  registerStatusCommand(program, services.statusService);
25
32
  registerRunCommand(program, services.runService);
26
33
  registerDoctorCommand(program, services.doctorService);
34
+ registerProfileCommand(program, services.profileService);
35
+ registerSyncCommand(program, services.syncService);
36
+ registerUiCommand(program);
37
+ registerMcpCommand(program);
27
38
  return program;
28
39
  }
29
40
  export async function main(argv = process.argv) {
@@ -44,11 +55,14 @@ export function shouldRunMain(entryPointPath, moduleUrl) {
44
55
  }
45
56
  function createDefaultServices(overrides) {
46
57
  const resolver = createExecutorResolver();
58
+ const profileService = createProfileService();
47
59
  return {
48
60
  initService: createInitService(),
49
61
  runService: createRunService({ resolver }),
50
62
  statusService: createStatusService({ resolver }),
51
63
  doctorService: createDoctorService({ resolver }),
64
+ profileService,
65
+ syncService: createSyncService({ profileService }),
52
66
  ...overrides
53
67
  };
54
68
  }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerMcpCommand(program: Command): void;
@@ -0,0 +1,9 @@
1
+ import { startMcpServer } from '../mcp/server.js';
2
+ export function registerMcpCommand(program) {
3
+ program
4
+ .command('mcp')
5
+ .description('Start the brainctl MCP server (stdio transport)')
6
+ .action(async () => {
7
+ await startMcpServer({ cwd: process.cwd() });
8
+ });
9
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import type { ProfileService } from '../services/profile-service.js';
3
+ export declare function registerProfileCommand(program: Command, profileService: ProfileService): void;
@@ -0,0 +1,43 @@
1
+ import pc from 'picocolors';
2
+ export function registerProfileCommand(program, profileService) {
3
+ const profileCmd = program
4
+ .command('profile')
5
+ .description('Manage brainctl profiles');
6
+ profileCmd
7
+ .command('list')
8
+ .description('List available profiles')
9
+ .action(async () => {
10
+ const { profiles, activeProfile } = await profileService.list({ cwd: process.cwd() });
11
+ if (profiles.length === 0) {
12
+ console.log('No profiles found. Run "brainctl profile create <name>" to create one.');
13
+ return;
14
+ }
15
+ console.log(pc.bold('Profiles:'));
16
+ for (const name of profiles) {
17
+ const marker = name === activeProfile ? pc.green(' (active)') : '';
18
+ console.log(` ${name}${marker}`);
19
+ }
20
+ });
21
+ profileCmd
22
+ .command('create')
23
+ .argument('<name>', 'Profile name')
24
+ .option('-d, --description <text>', 'Profile description')
25
+ .description('Create a new profile')
26
+ .action(async (name, options) => {
27
+ const result = await profileService.create({
28
+ cwd: process.cwd(),
29
+ name,
30
+ description: options.description,
31
+ });
32
+ console.log(`Created profile at ${result.profilePath}`);
33
+ });
34
+ profileCmd
35
+ .command('use')
36
+ .argument('<name>', 'Profile name to activate')
37
+ .description('Switch the active profile')
38
+ .action(async (name) => {
39
+ const result = await profileService.use({ cwd: process.cwd(), name });
40
+ const prev = result.previousProfile ? ` (was "${result.previousProfile}")` : '';
41
+ console.log(`Switched to profile "${name}"${prev}`);
42
+ });
43
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import type { SyncService } from '../services/sync-service.js';
3
+ export declare function registerSyncCommand(program: Command, syncService: SyncService): void;
@@ -0,0 +1,31 @@
1
+ import pc from 'picocolors';
2
+ export function registerSyncCommand(program, syncService) {
3
+ program
4
+ .command('sync')
5
+ .description('Sync active profile to agent configs')
6
+ .option('--restore', 'Restore agent configs from most recent backup')
7
+ .action(async (options) => {
8
+ const results = await syncService.execute({
9
+ cwd: process.cwd(),
10
+ restore: options.restore,
11
+ });
12
+ if (results.length === 0) {
13
+ console.log('No agents to sync.');
14
+ return;
15
+ }
16
+ if (options.restore) {
17
+ console.log(pc.bold('Restored agent configs:'));
18
+ for (const result of results) {
19
+ console.log(` ${result.agent}: restored from ${result.configPath}`);
20
+ }
21
+ return;
22
+ }
23
+ console.log(pc.bold('Synced profile to agents:'));
24
+ for (const result of results) {
25
+ console.log(` ${result.agent}: ${result.configPath} (${result.mcpCount} MCPs)`);
26
+ if (result.backedUpTo) {
27
+ console.log(` backed up to ${result.backedUpTo}`);
28
+ }
29
+ }
30
+ });
31
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerUiCommand(program: Command): void;
@@ -0,0 +1,10 @@
1
+ import { startUiServer } from '../ui/server.js';
2
+ export function registerUiCommand(program) {
3
+ program
4
+ .command('ui')
5
+ .description('Start the local brainctl dashboard')
6
+ .action(async () => {
7
+ const server = await startUiServer({ cwd: process.cwd() });
8
+ console.log(`brainctl UI listening at ${server.url}`);
9
+ });
10
+ }
package/dist/config.d.ts CHANGED
@@ -1,6 +1,14 @@
1
- import type { BrainctlConfig } from './types.js';
1
+ import type { BrainctlConfig, SkillConfig } from './types.js';
2
2
  interface LoadConfigOptions {
3
3
  cwd?: string;
4
4
  }
5
+ export interface ConfigPayload {
6
+ memory: {
7
+ paths: string[];
8
+ };
9
+ skills: Record<string, SkillConfig>;
10
+ mcps: Record<string, unknown>;
11
+ }
5
12
  export declare function loadConfig(options?: LoadConfigOptions): Promise<BrainctlConfig>;
13
+ export declare function parseConfigPayload(value: unknown): ConfigPayload;
6
14
  export {};
package/dist/config.js CHANGED
@@ -14,30 +14,48 @@ export async function loadConfig(options = {}) {
14
14
  }
15
15
  let parsed;
16
16
  try {
17
- parsed = (YAML.parse(source) ?? {});
17
+ parsed = YAML.parse(source) ?? {};
18
18
  }
19
19
  catch (error) {
20
20
  throw new ConfigError('ai-stack.yaml could not be parsed.');
21
21
  }
22
+ const config = parseConfigPayload(parsed);
23
+ return {
24
+ configPath,
25
+ rootDir: cwd,
26
+ memory: {
27
+ paths: config.memory.paths.map((memoryPath) => {
28
+ if (typeof memoryPath !== 'string' || memoryPath.trim().length === 0) {
29
+ throw new ConfigError('ai-stack.yaml contains an invalid memory path.');
30
+ }
31
+ return path.resolve(cwd, memoryPath);
32
+ })
33
+ },
34
+ skills: config.skills,
35
+ mcps: config.mcps
36
+ };
37
+ }
38
+ export function parseConfigPayload(value) {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
40
+ throw new ConfigError('ai-stack.yaml is missing the required "memory.paths" section.');
41
+ }
42
+ const parsed = value;
22
43
  if (!parsed.memory || !Array.isArray(parsed.memory.paths)) {
23
44
  throw new ConfigError('ai-stack.yaml is missing the required "memory.paths" section.');
24
45
  }
25
46
  if (!parsed.skills || typeof parsed.skills !== 'object' || Array.isArray(parsed.skills)) {
26
47
  throw new ConfigError('ai-stack.yaml is missing the required "skills" section.');
27
48
  }
28
- const skills = normalizeSkills(parsed.skills);
29
49
  return {
30
- configPath,
31
- rootDir: cwd,
32
50
  memory: {
33
51
  paths: parsed.memory.paths.map((memoryPath) => {
34
52
  if (typeof memoryPath !== 'string' || memoryPath.trim().length === 0) {
35
53
  throw new ConfigError('ai-stack.yaml contains an invalid memory path.');
36
54
  }
37
- return path.resolve(cwd, memoryPath);
55
+ return memoryPath;
38
56
  })
39
57
  },
40
- skills,
58
+ skills: normalizeSkills(parsed.skills),
41
59
  mcps: normalizeMcps(parsed.mcps)
42
60
  };
43
61
  }
@@ -5,11 +5,16 @@ export async function loadMemory(options) {
5
5
  const markdownFiles = (await Promise.all(options.paths.map(async (memoryPath) => collectMarkdownFiles(memoryPath))))
6
6
  .flat()
7
7
  .sort((left, right) => left.localeCompare(right));
8
- const contents = await Promise.all(markdownFiles.map(async (filePath) => (await readFile(filePath, 'utf8')).trim()));
8
+ const entries = await Promise.all(markdownFiles.map(async (filePath) => ({
9
+ path: filePath,
10
+ content: await readFile(filePath, 'utf8')
11
+ })));
12
+ const contents = entries.map((entry) => entry.content.trim());
9
13
  return {
10
14
  files: markdownFiles,
11
15
  count: markdownFiles.length,
12
- content: contents.filter((entry) => entry.length > 0).join('\n\n')
16
+ content: contents.filter((entry) => entry.length > 0).join('\n\n'),
17
+ entries
13
18
  };
14
19
  }
15
20
  async function collectMarkdownFiles(targetPath) {
package/dist/errors.d.ts CHANGED
@@ -25,3 +25,12 @@ export declare class AgentNotAvailableError extends BrainctlError {
25
25
  export declare class ExecutionError extends BrainctlError {
26
26
  constructor(message: string);
27
27
  }
28
+ export declare class ProfileError extends BrainctlError {
29
+ constructor(message: string);
30
+ }
31
+ export declare class ProfileNotFoundError extends BrainctlError {
32
+ constructor(message: string);
33
+ }
34
+ export declare class SyncError extends BrainctlError {
35
+ constructor(message: string);
36
+ }
package/dist/errors.js CHANGED
@@ -43,3 +43,18 @@ export class ExecutionError extends BrainctlError {
43
43
  super(message, 'system', 'EXECUTION_ERROR');
44
44
  }
45
45
  }
46
+ export class ProfileError extends BrainctlError {
47
+ constructor(message) {
48
+ super(message, 'user', 'PROFILE_ERROR');
49
+ }
50
+ }
51
+ export class ProfileNotFoundError extends BrainctlError {
52
+ constructor(message) {
53
+ super(message, 'user', 'PROFILE_NOT_FOUND');
54
+ }
55
+ }
56
+ export class SyncError extends BrainctlError {
57
+ constructor(message) {
58
+ super(message, 'system', 'SYNC_ERROR');
59
+ }
60
+ }
@@ -9,6 +9,7 @@ export async function runAgentProcess(options) {
9
9
  child.stdout.on('data', (chunk) => {
10
10
  const text = chunk.toString();
11
11
  output += text;
12
+ options.runOptions?.onOutputChunk?.(text);
12
13
  if (options.runOptions?.streamOutput !== false) {
13
14
  process.stdout.write(chunk);
14
15
  }
@@ -16,6 +17,7 @@ export async function runAgentProcess(options) {
16
17
  child.stderr.on('data', (chunk) => {
17
18
  const text = chunk.toString();
18
19
  output += text;
20
+ options.runOptions?.onOutputChunk?.(text);
19
21
  if (options.runOptions?.streamOutput !== false) {
20
22
  process.stderr.write(chunk);
21
23
  }
@@ -4,10 +4,11 @@ import path from 'node:path';
4
4
  import { AgentNotAvailableError } from '../errors.js';
5
5
  import { ClaudeExecutor } from './claude.js';
6
6
  import { CodexExecutor } from './codex.js';
7
- const SUPPORTED_AGENTS = ['claude', 'codex'];
7
+ const SUPPORTED_AGENTS = ['claude', 'codex', 'gemini'];
8
8
  const AGENT_COMMANDS = {
9
9
  claude: 'claude',
10
- codex: 'codex'
10
+ codex: 'codex',
11
+ gemini: 'gemini'
11
12
  };
12
13
  class DefaultExecutorResolver {
13
14
  availabilityCache = new Map();
@@ -45,6 +46,8 @@ function createExecutor(agentName) {
45
46
  return new ClaudeExecutor();
46
47
  case 'codex':
47
48
  return new CodexExecutor();
49
+ default:
50
+ throw new AgentNotAvailableError(`Agent "${agentName}" does not have an executor implementation.`);
48
51
  }
49
52
  }
50
53
  async function checkAvailability(agentName) {
@@ -1,6 +1,7 @@
1
1
  import type { AgentName } from '../types.js';
2
2
  export interface ExecutorRunOptions {
3
3
  streamOutput?: boolean;
4
+ onOutputChunk?: (chunk: string) => void;
4
5
  }
5
6
  export interface ExecutorResult {
6
7
  output: string;
@@ -0,0 +1,7 @@
1
+ import { FastMCP } from 'fastmcp';
2
+ export declare function createMcpServer(options?: {
3
+ cwd?: string;
4
+ }): FastMCP;
5
+ export declare function startMcpServer(options?: {
6
+ cwd?: string;
7
+ }): Promise<void>;
@@ -0,0 +1,183 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { FastMCP } from 'fastmcp';
4
+ import { z } from 'zod';
5
+ import { loadConfig } from '../config.js';
6
+ import { loadMemory } from '../context/memory.js';
7
+ import { createDoctorService } from '../services/doctor-service.js';
8
+ import { createMemoryWriteService } from '../services/memory-write-service.js';
9
+ import { createProfileService } from '../services/profile-service.js';
10
+ import { createRunService } from '../services/run-service.js';
11
+ import { createStatusService } from '../services/status-service.js';
12
+ import { createSyncService } from '../services/sync-service.js';
13
+ const packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
14
+ export function createMcpServer(options = {}) {
15
+ const cwd = options.cwd ?? process.cwd();
16
+ const server = new FastMCP({
17
+ name: 'brainctl',
18
+ version: packageVersion.version,
19
+ });
20
+ server.addTool({
21
+ name: 'brainctl_list_skills',
22
+ description: 'List available skills from the ai-stack.yaml config',
23
+ parameters: z.object({}),
24
+ execute: async () => {
25
+ const config = await loadConfig({ cwd });
26
+ const skills = Object.entries(config.skills).map(([name, skill]) => ({
27
+ name,
28
+ description: skill.description ?? null,
29
+ }));
30
+ return JSON.stringify(skills, null, 2);
31
+ },
32
+ });
33
+ server.addTool({
34
+ name: 'brainctl_run',
35
+ description: 'Execute a skill with input text. Runs the skill through the configured agent and returns the output.',
36
+ parameters: z.object({
37
+ skill: z.string().describe('Skill name as defined in ai-stack.yaml'),
38
+ input: z.string().describe('Input text to pass to the skill'),
39
+ agent: z.enum(['claude', 'codex']).default('claude').describe('Agent to use for execution'),
40
+ fallback_agent: z.enum(['claude', 'codex']).optional().describe('Fallback agent if primary is unavailable'),
41
+ }),
42
+ execute: async (args) => {
43
+ const inputPath = path.join(cwd, `.brainctl-mcp-input-${Date.now()}.tmp`);
44
+ const { writeFile: writeFileAsync, unlink } = await import('node:fs/promises');
45
+ try {
46
+ await writeFileAsync(inputPath, args.input, 'utf8');
47
+ const runService = createRunService();
48
+ const trace = await runService.execute({
49
+ cwd,
50
+ skill: args.skill,
51
+ inputFile: path.basename(inputPath),
52
+ primaryAgent: args.agent,
53
+ fallbackAgent: args.fallback_agent,
54
+ });
55
+ return trace.finalOutput;
56
+ }
57
+ finally {
58
+ try {
59
+ await unlink(inputPath);
60
+ }
61
+ catch {
62
+ // temp file cleanup is best-effort
63
+ }
64
+ }
65
+ },
66
+ });
67
+ server.addTool({
68
+ name: 'brainctl_status',
69
+ description: 'Show project status: config path, memory files, available skills, and agent availability',
70
+ parameters: z.object({}),
71
+ execute: async () => {
72
+ const statusService = createStatusService();
73
+ const result = await statusService.execute({ cwd });
74
+ return JSON.stringify(result, null, 2);
75
+ },
76
+ });
77
+ server.addTool({
78
+ name: 'brainctl_doctor',
79
+ description: 'Run health checks on the brainctl setup: config validity, memory paths, skill definitions, and agent availability',
80
+ parameters: z.object({}),
81
+ execute: async () => {
82
+ const doctorService = createDoctorService();
83
+ const result = await doctorService.execute({ cwd });
84
+ return JSON.stringify(result, null, 2);
85
+ },
86
+ });
87
+ server.addTool({
88
+ name: 'brainctl_read_memory',
89
+ 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.',
90
+ parameters: z.object({}),
91
+ execute: async () => {
92
+ const config = await loadConfig({ cwd });
93
+ const memory = await loadMemory({ paths: config.memory.paths });
94
+ const result = {
95
+ count: memory.count,
96
+ files: memory.entries.map((entry) => ({
97
+ path: entry.path,
98
+ content: entry.content,
99
+ })),
100
+ };
101
+ return JSON.stringify(result, null, 2);
102
+ },
103
+ });
104
+ server.addTool({
105
+ name: 'brainctl_write_memory',
106
+ 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.',
107
+ parameters: z.object({
108
+ file_path: z.string().describe('Relative path for the memory file (e.g., "memory/notes.md")'),
109
+ content: z.string().describe('Markdown content to write'),
110
+ }),
111
+ execute: async (args) => {
112
+ const memoryWriteService = createMemoryWriteService();
113
+ const result = await memoryWriteService.execute({
114
+ cwd,
115
+ filePath: args.file_path,
116
+ content: args.content,
117
+ });
118
+ return JSON.stringify({ written: result.filePath });
119
+ },
120
+ });
121
+ server.addTool({
122
+ name: 'brainctl_get_skill',
123
+ 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.',
124
+ parameters: z.object({
125
+ skill: z.string().describe('Skill name as defined in ai-stack.yaml'),
126
+ }),
127
+ execute: async (args) => {
128
+ const config = await loadConfig({ cwd });
129
+ const skillConfig = config.skills[args.skill];
130
+ if (!skillConfig) {
131
+ throw new Error(`Skill "${args.skill}" is not defined in ai-stack.yaml.`);
132
+ }
133
+ return JSON.stringify({
134
+ name: args.skill,
135
+ description: skillConfig.description ?? null,
136
+ prompt: skillConfig.prompt,
137
+ }, null, 2);
138
+ },
139
+ });
140
+ server.addTool({
141
+ name: 'brainctl_list_profiles',
142
+ description: 'List available profiles and show which one is active.',
143
+ parameters: z.object({}),
144
+ execute: async () => {
145
+ const profileService = createProfileService();
146
+ const result = await profileService.list({ cwd });
147
+ return JSON.stringify(result, null, 2);
148
+ },
149
+ });
150
+ server.addTool({
151
+ name: 'brainctl_switch_profile',
152
+ description: 'Switch the active profile and sync it to all configured agents. Combines profile switch + sync in one step.',
153
+ parameters: z.object({
154
+ name: z.string().describe('Profile name to activate'),
155
+ }),
156
+ execute: async (args) => {
157
+ const profileService = createProfileService();
158
+ const switchResult = await profileService.use({ cwd, name: args.name });
159
+ const syncService = createSyncService({ profileService });
160
+ const syncResult = await syncService.execute({ cwd });
161
+ return JSON.stringify({
162
+ previousProfile: switchResult.previousProfile,
163
+ activeProfile: args.name,
164
+ synced: syncResult,
165
+ }, null, 2);
166
+ },
167
+ });
168
+ server.addTool({
169
+ name: 'brainctl_sync',
170
+ description: 'Sync the active profile to all configured agent configs (Claude, Codex). Creates backups before overwriting.',
171
+ parameters: z.object({}),
172
+ execute: async () => {
173
+ const syncService = createSyncService();
174
+ const result = await syncService.execute({ cwd });
175
+ return JSON.stringify(result, null, 2);
176
+ },
177
+ });
178
+ return server;
179
+ }
180
+ export async function startMcpServer(options = {}) {
181
+ const server = createMcpServer(options);
182
+ await server.start({ transportType: 'stdio' });
183
+ }
@@ -0,0 +1,12 @@
1
+ import type { ConfigPayload } from '../config.js';
2
+ export interface ConfigWriteRequest {
3
+ cwd?: string;
4
+ config: ConfigPayload;
5
+ }
6
+ export interface ConfigWriteResult {
7
+ configPath: string;
8
+ }
9
+ export interface ConfigWriteService {
10
+ execute(request: ConfigWriteRequest): Promise<ConfigWriteResult>;
11
+ }
12
+ export declare function createConfigWriteService(): ConfigWriteService;
@@ -0,0 +1,70 @@
1
+ import { lstat, mkdir, realpath, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { ConfigError } from '../errors.js';
5
+ export function createConfigWriteService() {
6
+ return {
7
+ async execute(request) {
8
+ const cwd = request.cwd ?? process.cwd();
9
+ const configPath = path.join(cwd, 'ai-stack.yaml');
10
+ const memoryPaths = await Promise.all(request.config.memory.paths.map((memoryPath) => normalizeMemoryPath(cwd, memoryPath)));
11
+ const payload = {
12
+ memory: {
13
+ paths: memoryPaths
14
+ },
15
+ skills: request.config.skills,
16
+ mcps: request.config.mcps
17
+ };
18
+ await mkdir(path.dirname(configPath), { recursive: true });
19
+ await writeFile(configPath, `${YAML.stringify(payload)}`, 'utf8');
20
+ return { configPath };
21
+ }
22
+ };
23
+ }
24
+ async function normalizeMemoryPath(cwd, filePath) {
25
+ const workspaceRoot = await realpath(cwd);
26
+ const resolvedPath = path.resolve(cwd, filePath);
27
+ const realTargetPath = await resolvePathForWrite(resolvedPath);
28
+ if (!isWithinDirectory(workspaceRoot, realTargetPath)) {
29
+ throw new ConfigError('Memory paths must stay within the workspace root.');
30
+ }
31
+ const relativePath = path.relative(cwd, resolvedPath);
32
+ return relativePath.length > 0 ? relativePath : '.';
33
+ }
34
+ async function resolvePathForWrite(targetPath) {
35
+ const existingPath = await findNearestExistingPath(targetPath);
36
+ const resolvedExistingPath = await realpath(existingPath);
37
+ if (existingPath === targetPath) {
38
+ return resolvedExistingPath;
39
+ }
40
+ return path.resolve(resolvedExistingPath, path.relative(existingPath, targetPath));
41
+ }
42
+ async function findNearestExistingPath(targetPath) {
43
+ let currentPath = targetPath;
44
+ while (true) {
45
+ try {
46
+ await lstat(currentPath);
47
+ return currentPath;
48
+ }
49
+ catch (error) {
50
+ if (!isMissingPathError(error)) {
51
+ throw error;
52
+ }
53
+ }
54
+ const parentPath = path.dirname(currentPath);
55
+ if (parentPath === currentPath) {
56
+ throw new ConfigError(`Could not resolve filesystem path for ${targetPath}.`);
57
+ }
58
+ currentPath = parentPath;
59
+ }
60
+ }
61
+ function isWithinDirectory(parentDirectory, targetPath) {
62
+ const relativePath = path.relative(parentDirectory, targetPath);
63
+ if (relativePath === '') {
64
+ return true;
65
+ }
66
+ return !relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath);
67
+ }
68
+ function isMissingPathError(error) {
69
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
70
+ }
@@ -0,0 +1,12 @@
1
+ export interface MemoryWriteRequest {
2
+ cwd?: string;
3
+ filePath: string;
4
+ content: string;
5
+ }
6
+ export interface MemoryWriteResult {
7
+ filePath: string;
8
+ }
9
+ export interface MemoryWriteService {
10
+ execute(request: MemoryWriteRequest): Promise<MemoryWriteResult>;
11
+ }
12
+ export declare function createMemoryWriteService(): MemoryWriteService;