diff --git a/packages/brain/package.json b/packages/brain/package.json index bb26908..4005827 100644 --- a/packages/brain/package.json +++ b/packages/brain/package.json @@ -22,7 +22,8 @@ }, "dependencies": { "@mosaicstack/db": "workspace:^", - "@mosaicstack/types": "workspace:*" + "@mosaicstack/types": "workspace:*", + "commander": "^13.0.0" }, "devDependencies": { "typescript": "^5.8.0", diff --git a/packages/brain/src/cli.spec.ts b/packages/brain/src/cli.spec.ts new file mode 100644 index 0000000..a05238e --- /dev/null +++ b/packages/brain/src/cli.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { registerBrainCommand } from './cli.js'; + +/** + * Smoke test: verifies the command tree is correctly registered. + * No database connection is opened — we only inspect Commander metadata. + */ +describe('registerBrainCommand', () => { + function buildProgram(): Command { + const program = new Command('mosaic'); + // Prevent Commander from calling process.exit on parse errors during tests. + program.exitOverride(); + registerBrainCommand(program); + return program; + } + + it('registers a top-level "brain" command', () => { + const program = buildProgram(); + const brainCmd = program.commands.find((c) => c.name() === 'brain'); + expect(brainCmd).toBeDefined(); + }); + + it('registers "brain projects" with "list" and "create" subcommands', () => { + const program = buildProgram(); + const brainCmd = program.commands.find((c) => c.name() === 'brain')!; + const projectsCmd = brainCmd.commands.find((c) => c.name() === 'projects'); + expect(projectsCmd).toBeDefined(); + + const subNames = projectsCmd!.commands.map((c) => c.name()); + expect(subNames).toContain('list'); + expect(subNames).toContain('create'); + }); + + it('registers "brain missions" with "list" subcommand', () => { + const program = buildProgram(); + const brainCmd = program.commands.find((c) => c.name() === 'brain')!; + const missionsCmd = brainCmd.commands.find((c) => c.name() === 'missions'); + expect(missionsCmd).toBeDefined(); + + const subNames = missionsCmd!.commands.map((c) => c.name()); + expect(subNames).toContain('list'); + }); + + it('registers "brain tasks" with "list" subcommand', () => { + const program = buildProgram(); + const brainCmd = program.commands.find((c) => c.name() === 'brain')!; + const tasksCmd = brainCmd.commands.find((c) => c.name() === 'tasks'); + expect(tasksCmd).toBeDefined(); + + const subNames = tasksCmd!.commands.map((c) => c.name()); + expect(subNames).toContain('list'); + }); + + it('registers "brain conversations" with "list" subcommand', () => { + const program = buildProgram(); + const brainCmd = program.commands.find((c) => c.name() === 'brain')!; + const conversationsCmd = brainCmd.commands.find((c) => c.name() === 'conversations'); + expect(conversationsCmd).toBeDefined(); + + const subNames = conversationsCmd!.commands.map((c) => c.name()); + expect(subNames).toContain('list'); + }); + + it('"brain projects list" accepts --db and --limit options', () => { + const program = buildProgram(); + const brainCmd = program.commands.find((c) => c.name() === 'brain')!; + const projectsCmd = brainCmd.commands.find((c) => c.name() === 'projects')!; + const listCmd = projectsCmd.commands.find((c) => c.name() === 'list')!; + + const optionNames = listCmd.options.map((o) => o.long); + expect(optionNames).toContain('--db'); + expect(optionNames).toContain('--limit'); + }); + + it('"brain missions list" accepts --project option', () => { + const program = buildProgram(); + const brainCmd = program.commands.find((c) => c.name() === 'brain')!; + const missionsCmd = brainCmd.commands.find((c) => c.name() === 'missions')!; + const listCmd = missionsCmd.commands.find((c) => c.name() === 'list')!; + + const optionNames = listCmd.options.map((o) => o.long); + expect(optionNames).toContain('--project'); + }); + + it('"brain tasks list" accepts --project option', () => { + const program = buildProgram(); + const brainCmd = program.commands.find((c) => c.name() === 'brain')!; + const tasksCmd = brainCmd.commands.find((c) => c.name() === 'tasks')!; + const listCmd = tasksCmd.commands.find((c) => c.name() === 'list')!; + + const optionNames = listCmd.options.map((o) => o.long); + expect(optionNames).toContain('--project'); + }); +}); diff --git a/packages/brain/src/cli.ts b/packages/brain/src/cli.ts new file mode 100644 index 0000000..908e371 --- /dev/null +++ b/packages/brain/src/cli.ts @@ -0,0 +1,142 @@ +import type { Command } from 'commander'; +import { createDb, type DbHandle } from '@mosaicstack/db'; +import { createBrain } from './brain.js'; + +/** + * Build and attach the `brain` subcommand tree onto an existing Commander program. + * Uses the caller's Command instance to avoid cross-package Commander version mismatches. + */ +export function registerBrainCommand(parent: Command): void { + const brain = parent.command('brain').description('Inspect and manage brain data stores'); + + // ─── shared DB option helper ───────────────────────────────────────────── + + function addDbOption(cmd: Command): Command { + return cmd.option( + '--db ', + 'PostgreSQL connection string (overrides MOSAIC_DB_URL)', + ); + } + + function resolveDb(opts: { db?: string }): ReturnType { + const connectionString = opts.db ?? process.env['MOSAIC_DB_URL']; + if (!connectionString) { + console.error('No DB connection string provided. Pass --db or set MOSAIC_DB_URL.'); + process.exit(1); + } + const handle: DbHandle = createDb(connectionString); + return createBrain(handle.db); + } + + // ─── projects ──────────────────────────────────────────────────────────── + + const projects = brain.command('projects').description('Manage projects'); + + addDbOption( + projects + .command('list') + .description('List all projects') + .option('--limit ', 'Maximum number of results', '50'), + ).action(async (opts: { db?: string; limit: string }) => { + const b = resolveDb(opts); + const limit = parseInt(opts.limit, 10); + const rows = await b.projects.findAll(); + const sliced = rows.slice(0, limit); + if (sliced.length === 0) { + console.log('No projects found.'); + return; + } + for (const p of sliced) { + console.log(`${p.id} ${p.name}`); + } + }); + + addDbOption( + projects + .command('create ') + .description('Create a new project') + .requiredOption('--owner-id ', 'Owner user ID'), + ).action(async (name: string, opts: { db?: string; ownerId: string }) => { + const b = resolveDb(opts); + const created = await b.projects.create({ + name, + ownerId: opts.ownerId, + ownerType: 'user', + }); + console.log(`Created project: ${created.id} ${created.name}`); + }); + + // ─── missions ──────────────────────────────────────────────────────────── + + const missions = brain.command('missions').description('Manage missions'); + + addDbOption( + missions + .command('list') + .description('List all missions') + .option('--limit ', 'Maximum number of results', '50') + .option('--project ', 'Filter by project ID'), + ).action(async (opts: { db?: string; limit: string; project?: string }) => { + const b = resolveDb(opts); + const limit = parseInt(opts.limit, 10); + const rows = opts.project + ? await b.missions.findByProject(opts.project) + : await b.missions.findAll(); + const sliced = rows.slice(0, limit); + if (sliced.length === 0) { + console.log('No missions found.'); + return; + } + for (const m of sliced) { + console.log(`${m.id} ${m.name}`); + } + }); + + // ─── tasks ──────────────────────────────────────────────────────────────── + + const tasks = brain.command('tasks').description('Manage generic tasks'); + + addDbOption( + tasks + .command('list') + .description('List all tasks') + .option('--limit ', 'Maximum number of results', '50') + .option('--project ', 'Filter by project ID'), + ).action(async (opts: { db?: string; limit: string; project?: string }) => { + const b = resolveDb(opts); + const limit = parseInt(opts.limit, 10); + const rows = opts.project ? await b.tasks.findByProject(opts.project) : await b.tasks.findAll(); + const sliced = rows.slice(0, limit); + if (sliced.length === 0) { + console.log('No tasks found.'); + return; + } + for (const t of sliced) { + console.log(`${t.id} ${t.title} [${t.status}]`); + } + }); + + // ─── conversations ──────────────────────────────────────────────────────── + + const conversations = brain.command('conversations').description('Manage conversations'); + + addDbOption( + conversations + .command('list') + .description('List conversations for a user') + .option('--limit ', 'Maximum number of results', '50') + .requiredOption('--user-id ', 'User ID to scope the query'), + ).action(async (opts: { db?: string; limit: string; userId: string }) => { + const b = resolveDb(opts); + const limit = parseInt(opts.limit, 10); + const rows = await b.conversations.findAll(opts.userId); + const sliced = rows.slice(0, limit); + if (sliced.length === 0) { + console.log('No conversations found.'); + return; + } + for (const c of sliced) { + console.log(`${c.id} ${c.title ?? '(untitled)'}`); + } + }); +} diff --git a/packages/brain/src/index.ts b/packages/brain/src/index.ts index 8bf5516..a024854 100644 --- a/packages/brain/src/index.ts +++ b/packages/brain/src/index.ts @@ -1,4 +1,5 @@ export { createBrain, type Brain } from './brain.js'; +export { registerBrainCommand } from './cli.js'; export { createProjectsRepo, type ProjectsRepo, diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 466ee96..f5a3def 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -27,6 +27,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@mosaicstack/brain": "workspace:*", "@mosaicstack/config": "workspace:*", "@mosaicstack/forge": "workspace:*", "@mosaicstack/macp": "workspace:*", diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 03444d9..4b7e0d7 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -2,6 +2,7 @@ import { createRequire } from 'module'; import { Command } from 'commander'; +import { registerBrainCommand } from '@mosaicstack/brain'; import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerAgentCommand } from './commands/agent.js'; import { registerMissionCommand } from './commands/mission.js'; @@ -333,6 +334,10 @@ registerAgentCommand(program); registerMissionCommand(program); +// ─── brain ────────────────────────────────────────────────────────────── + +registerBrainCommand(program); + // ─── quality-rails ────────────────────────────────────────────────────── registerQualityRails(program); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df1291d..5fbbfb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,9 @@ importers: '@mosaicstack/types': specifier: workspace:* version: link:../types + commander: + specifier: ^13.0.0 + version: 13.1.0 devDependencies: typescript: specifier: ^5.8.0 @@ -454,6 +457,9 @@ importers: '@clack/prompts': specifier: ^0.9.1 version: 0.9.1 + '@mosaicstack/brain': + specifier: workspace:* + version: link:../brain '@mosaicstack/config': specifier: workspace:* version: link:../config