brainctl 0.1.2 → 0.1.3

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/dist/cli.js CHANGED
@@ -1,29 +1,34 @@
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';
8
9
  import { registerRunCommand } from './commands/run.js';
9
10
  import { registerStatusCommand } from './commands/status.js';
11
+ import { registerUiCommand } from './commands/ui.js';
10
12
  import { printError } from './output.js';
11
13
  import { createDoctorService } from './services/doctor-service.js';
12
14
  import { createInitService } from './services/init-service.js';
13
15
  import { createRunService } from './services/run-service.js';
14
16
  import { createStatusService } from './services/status-service.js';
15
17
  import { createExecutorResolver } from './executor/resolver.js';
18
+ const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
16
19
  export function createProgram(overrides = {}) {
17
20
  const services = createDefaultServices(overrides);
18
21
  const program = new Command();
19
22
  program
20
23
  .name('brainctl')
21
24
  .description('Manage repeatable AI environments for local agent workflows')
22
- .version('0.1.0');
25
+ .version(packageVersion.version);
23
26
  registerInitCommand(program, services.initService);
24
27
  registerStatusCommand(program, services.statusService);
25
28
  registerRunCommand(program, services.runService);
26
29
  registerDoctorCommand(program, services.doctorService);
30
+ registerUiCommand(program);
31
+ registerMcpCommand(program);
27
32
  return program;
28
33
  }
29
34
  export async function main(argv = process.argv) {
@@ -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,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) {
@@ -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
  }
@@ -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,88 @@
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 { createDoctorService } from '../services/doctor-service.js';
7
+ import { createRunService } from '../services/run-service.js';
8
+ import { createStatusService } from '../services/status-service.js';
9
+ const packageVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
10
+ export function createMcpServer(options = {}) {
11
+ const cwd = options.cwd ?? process.cwd();
12
+ const server = new FastMCP({
13
+ name: 'brainctl',
14
+ version: packageVersion.version,
15
+ });
16
+ server.addTool({
17
+ name: 'brainctl_list_skills',
18
+ description: 'List available skills from the ai-stack.yaml config',
19
+ parameters: z.object({}),
20
+ execute: async () => {
21
+ const config = await loadConfig({ cwd });
22
+ const skills = Object.entries(config.skills).map(([name, skill]) => ({
23
+ name,
24
+ description: skill.description ?? null,
25
+ }));
26
+ return JSON.stringify(skills, null, 2);
27
+ },
28
+ });
29
+ server.addTool({
30
+ name: 'brainctl_run',
31
+ description: 'Execute a skill with input text. Runs the skill through the configured agent and returns the output.',
32
+ parameters: z.object({
33
+ skill: z.string().describe('Skill name as defined in ai-stack.yaml'),
34
+ input: z.string().describe('Input text to pass to the skill'),
35
+ agent: z.enum(['claude', 'codex']).default('claude').describe('Agent to use for execution'),
36
+ fallback_agent: z.enum(['claude', 'codex']).optional().describe('Fallback agent if primary is unavailable'),
37
+ }),
38
+ execute: async (args) => {
39
+ const inputPath = path.join(cwd, `.brainctl-mcp-input-${Date.now()}.tmp`);
40
+ const { writeFile: writeFileAsync, unlink } = await import('node:fs/promises');
41
+ try {
42
+ await writeFileAsync(inputPath, args.input, 'utf8');
43
+ const runService = createRunService();
44
+ const trace = await runService.execute({
45
+ cwd,
46
+ skill: args.skill,
47
+ inputFile: path.basename(inputPath),
48
+ primaryAgent: args.agent,
49
+ fallbackAgent: args.fallback_agent,
50
+ });
51
+ return trace.finalOutput;
52
+ }
53
+ finally {
54
+ try {
55
+ await unlink(inputPath);
56
+ }
57
+ catch {
58
+ // temp file cleanup is best-effort
59
+ }
60
+ }
61
+ },
62
+ });
63
+ server.addTool({
64
+ name: 'brainctl_status',
65
+ description: 'Show project status: config path, memory files, available skills, and agent availability',
66
+ parameters: z.object({}),
67
+ execute: async () => {
68
+ const statusService = createStatusService();
69
+ const result = await statusService.execute({ cwd });
70
+ return JSON.stringify(result, null, 2);
71
+ },
72
+ });
73
+ server.addTool({
74
+ name: 'brainctl_doctor',
75
+ description: 'Run health checks on the brainctl setup: config validity, memory paths, skill definitions, and agent availability',
76
+ parameters: z.object({}),
77
+ execute: async () => {
78
+ const doctorService = createDoctorService();
79
+ const result = await doctorService.execute({ cwd });
80
+ return JSON.stringify(result, null, 2);
81
+ },
82
+ });
83
+ return server;
84
+ }
85
+ export async function startMcpServer(options = {}) {
86
+ const server = createMcpServer(options);
87
+ await server.start({ transportType: 'stdio' });
88
+ }
@@ -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;
@@ -0,0 +1,56 @@
1
+ import { lstat, mkdir, realpath, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { MemoryPathError } from '../errors.js';
4
+ export function createMemoryWriteService() {
5
+ return {
6
+ async execute(request) {
7
+ const cwd = request.cwd ?? process.cwd();
8
+ const targetPath = path.resolve(cwd, request.filePath);
9
+ const workspaceRoot = await realpath(cwd);
10
+ const resolvedTargetPath = await resolvePathForWrite(targetPath);
11
+ if (!isWithinDirectory(workspaceRoot, resolvedTargetPath)) {
12
+ throw new MemoryPathError('Memory files must stay within the workspace root.');
13
+ }
14
+ await mkdir(path.dirname(targetPath), { recursive: true });
15
+ await writeFile(targetPath, request.content, 'utf8');
16
+ return { filePath: targetPath };
17
+ }
18
+ };
19
+ }
20
+ async function resolvePathForWrite(targetPath) {
21
+ const existingPath = await findNearestExistingPath(targetPath);
22
+ const resolvedExistingPath = await realpath(existingPath);
23
+ if (existingPath === targetPath) {
24
+ return resolvedExistingPath;
25
+ }
26
+ return path.resolve(resolvedExistingPath, path.relative(existingPath, targetPath));
27
+ }
28
+ async function findNearestExistingPath(targetPath) {
29
+ let currentPath = targetPath;
30
+ while (true) {
31
+ try {
32
+ await lstat(currentPath);
33
+ return currentPath;
34
+ }
35
+ catch (error) {
36
+ if (!isMissingPathError(error)) {
37
+ throw error;
38
+ }
39
+ }
40
+ const parentPath = path.dirname(currentPath);
41
+ if (parentPath === currentPath) {
42
+ throw new MemoryPathError(`Could not resolve filesystem path for ${targetPath}.`);
43
+ }
44
+ currentPath = parentPath;
45
+ }
46
+ }
47
+ function isWithinDirectory(parentDirectory, targetPath) {
48
+ const relativePath = path.relative(parentDirectory, targetPath);
49
+ if (relativePath === '') {
50
+ return true;
51
+ }
52
+ return !relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath);
53
+ }
54
+ function isMissingPathError(error) {
55
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
56
+ }
@@ -1,7 +1,11 @@
1
1
  import type { ExecutorResolver } from '../executor/resolver.js';
2
2
  import type { ExecutionStep, ExecutionTrace, RunRequest } from '../types.js';
3
3
  export interface RunService {
4
- execute(request: RunRequest): Promise<ExecutionTrace>;
4
+ execute(request: RunRequest, options?: RunServiceExecuteOptions): Promise<ExecutionTrace>;
5
+ }
6
+ export interface RunServiceExecuteOptions {
7
+ onOutputChunk?: (chunk: string) => void;
8
+ streamOutput?: boolean;
5
9
  }
6
10
  interface RunServiceDependencies {
7
11
  resolver?: ExecutorResolver;
@@ -9,7 +9,7 @@ import { createExecutorResolver } from '../executor/resolver.js';
9
9
  export function createRunService(dependencies = {}) {
10
10
  const resolver = dependencies.resolver ?? createExecutorResolver();
11
11
  return {
12
- async execute(request) {
12
+ async execute(request, options = {}) {
13
13
  const cwd = request.cwd ?? process.cwd();
14
14
  const config = await loadConfig({ cwd });
15
15
  const memory = await loadMemory({ paths: config.memory.paths });
@@ -30,7 +30,8 @@ export function createRunService(dependencies = {}) {
30
30
  fallbackUsed = true;
31
31
  }
32
32
  const result = await executor.instance.run(context, {
33
- streamOutput: true
33
+ streamOutput: options.streamOutput ?? true,
34
+ onOutputChunk: options.onOutputChunk
34
35
  });
35
36
  previousOutput = result.output;
36
37
  results.push({
package/dist/types.d.ts CHANGED
@@ -18,6 +18,10 @@ export interface MemoryLoadResult {
18
18
  content: string;
19
19
  files: string[];
20
20
  count: number;
21
+ entries: Array<{
22
+ path: string;
23
+ content: string;
24
+ }>;
21
25
  }
22
26
  export interface RunRequest {
23
27
  cwd?: string;
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { RunService } from '../services/run-service.js';
3
+ import type { StatusService } from '../services/status-service.js';
4
+ export interface UiRouteDependencies {
5
+ cwd: string;
6
+ statusService?: StatusService;
7
+ runService?: RunService;
8
+ }
9
+ export type UiRouteHandler = (request: IncomingMessage, response: ServerResponse) => Promise<void>;
10
+ export declare function createUiRouteHandler(dependencies: UiRouteDependencies): UiRouteHandler;