Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
ad3f6ae042 feat(brain): add registerBrainCommand for mosaic brain CLI surface
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Implements task CU-05-05. Adds a registerBrainCommand(parent) export to
@mosaicstack/brain that wires brain projects/missions/tasks/conversations
subcommands onto the mosaic CLI. All DB-backed commands accept --db or
MOSAIC_DB_URL. Smoke tests assert command tree without opening a real DB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:12:52 -05:00
11 changed files with 253 additions and 357 deletions

View File

@@ -22,7 +22,8 @@
},
"dependencies": {
"@mosaicstack/db": "workspace:^",
"@mosaicstack/types": "workspace:*"
"@mosaicstack/types": "workspace:*",
"commander": "^13.0.0"
},
"devDependencies": {
"typescript": "^5.8.0",

View File

@@ -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');
});
});

142
packages/brain/src/cli.ts Normal file
View File

@@ -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 <connection-string>',
'PostgreSQL connection string (overrides MOSAIC_DB_URL)',
);
}
function resolveDb(opts: { db?: string }): ReturnType<typeof createBrain> {
const connectionString = opts.db ?? process.env['MOSAIC_DB_URL'];
if (!connectionString) {
console.error('No DB connection string provided. Pass --db <url> 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 <n>', '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 <name>')
.description('Create a new project')
.requiredOption('--owner-id <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 <n>', 'Maximum number of results', '50')
.option('--project <id>', '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 <n>', 'Maximum number of results', '50')
.option('--project <id>', '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 <n>', 'Maximum number of results', '50')
.requiredOption('--user-id <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)'}`);
}
});
}

View File

@@ -1,4 +1,5 @@
export { createBrain, type Brain } from './brain.js';
export { registerBrainCommand } from './cli.js';
export {
createProjectsRepo,
type ProjectsRepo,

View File

@@ -27,12 +27,12 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
"@mosaicstack/config": "workspace:*",
"@mosaicstack/forge": "workspace:*",
"@mosaicstack/macp": "workspace:*",
"@mosaicstack/prdy": "workspace:*",
"@mosaicstack/quality-rails": "workspace:*",
"@mosaicstack/storage": "workspace:*",
"@mosaicstack/types": "workspace:*",
"@clack/prompts": "^0.9.1",
"commander": "^13.0.0",

View File

@@ -2,8 +2,8 @@
import { createRequire } from 'module';
import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain';
import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerStorageCommand } from '@mosaicstack/storage';
import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
// prdy is registered via launch.ts
@@ -315,14 +315,14 @@ registerAgentCommand(program);
registerMissionCommand(program);
// ─── brain ──────────────────────────────────────────────────────────────
registerBrainCommand(program);
// ─── quality-rails ──────────────────────────────────────────────────────
registerQualityRails(program);
// ─── storage ─────────────────────────────────────────────────────────────
registerStorageCommand(program);
// ─── update ─────────────────────────────────────────────────────────────
program

View File

@@ -23,8 +23,7 @@
"dependencies": {
"@electric-sql/pglite": "^0.2.17",
"@mosaicstack/db": "workspace:^",
"@mosaicstack/types": "workspace:*",
"commander": "^13.0.0"
"@mosaicstack/types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.8.0",

View File

@@ -1,85 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerStorageCommand } from './cli.js';
describe('registerStorageCommand', () => {
function buildProgram(): Command {
const program = new Command();
program.exitOverride(); // prevent process.exit in tests
registerStorageCommand(program);
return program;
}
it('registers a "storage" command on the parent', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage');
expect(storageCmd).toBeDefined();
});
it('registers "storage status" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const statusCmd = storageCmd.commands.find((c) => c.name() === 'status');
expect(statusCmd).toBeDefined();
});
it('registers "storage tier" subcommand group', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier');
expect(tierCmd).toBeDefined();
});
it('registers "storage tier show" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const showCmd = tierCmd.commands.find((c) => c.name() === 'show');
expect(showCmd).toBeDefined();
});
it('registers "storage tier switch" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const switchCmd = tierCmd.commands.find((c) => c.name() === 'switch');
expect(switchCmd).toBeDefined();
});
it('registers "storage export" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const exportCmd = storageCmd.commands.find((c) => c.name() === 'export');
expect(exportCmd).toBeDefined();
});
it('registers "storage import" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const importCmd = storageCmd.commands.find((c) => c.name() === 'import');
expect(importCmd).toBeDefined();
});
it('registers "storage migrate" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const migrateCmd = storageCmd.commands.find((c) => c.name() === 'migrate');
expect(migrateCmd).toBeDefined();
});
it('has all required subcommands in a single assertion', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const topLevel = storageCmd.commands.map((c) => c.name());
expect(topLevel).toContain('status');
expect(topLevel).toContain('tier');
expect(topLevel).toContain('export');
expect(topLevel).toContain('import');
expect(topLevel).toContain('migrate');
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const tierSubcmds = tierCmd.commands.map((c) => c.name());
expect(tierSubcmds).toContain('show');
expect(tierSubcmds).toContain('switch');
});
});

View File

@@ -1,256 +0,0 @@
import type { Command } from 'commander';
/**
* Reads the DATABASE_URL environment variable and redacts the password portion.
*/
function redactedConnectionString(): string | null {
const url = process.env['DATABASE_URL'];
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.password) {
parsed.password = '***';
}
return parsed.toString();
} catch {
// Not a valid URL — redact anything that looks like :password@
return url.replace(/:([^@/]+)@/, ':***@');
}
}
/**
* Determine the active storage tier from the environment.
* Looks at DATABASE_URL; if absent or set to a pglite path, treats tier as pglite.
*/
function activeTier(): 'postgres' | 'pglite' {
const url = process.env['DATABASE_URL'];
if (url && url.startsWith('postgres')) return 'postgres';
return 'pglite';
}
/**
* Return a human-readable config source description.
*/
function configSource(): string {
if (process.env['DATABASE_URL']) return 'env:DATABASE_URL';
const pgliteDir = process.env['PGLITE_DATA_DIR'];
if (pgliteDir) return `env:PGLITE_DATA_DIR (${pgliteDir})`;
return 'default (no DATABASE_URL set)';
}
/**
* Register storage subcommands on an existing Commander program.
* Follows the registerQualityRails pattern — uses the caller's Command
* instance to avoid cross-package Commander version mismatches.
*/
export function registerStorageCommand(parent: Command): void {
const storage = parent
.command('storage')
.description('Inspect and manage Mosaic storage configuration');
// ── storage status ───────────────────────────────────────────────────────
storage
.command('status')
.description('Show the configured storage tier and whether the adapter is reachable')
.action(async () => {
const tier = activeTier();
const source = configSource();
const connStr = tier === 'postgres' ? redactedConnectionString() : null;
console.log(`[storage] tier: ${tier}`);
console.log(`[storage] config source: ${source}`);
if (tier === 'postgres' && connStr) {
console.log(`[storage] connection: ${connStr}`);
try {
const { createDb, sql } = await import('@mosaicstack/db');
const url = process.env['DATABASE_URL'] ?? '';
const handle = createDb(url);
await handle.db.execute(sql`SELECT 1`);
await handle.close();
console.log('[storage] reachable: yes');
} catch (err) {
console.log(
`[storage] reachable: no (${err instanceof Error ? err.message : String(err)})`,
);
}
} else {
const dataDir = process.env['PGLITE_DATA_DIR'] ?? ':memory:';
console.log(`[storage] data dir: ${dataDir}`);
console.log('[storage] reachable: pglite is always local — no network check needed');
}
});
// ── storage tier ─────────────────────────────────────────────────────────
const tier = storage.command('tier').description('Inspect or switch the storage tier');
tier
.command('show')
.description('Print the active storage tier and its config source')
.action(() => {
const activeTierValue = activeTier();
const source = configSource();
console.log(`[storage] active tier: ${activeTierValue}`);
console.log(`[storage] config source: ${source}`);
});
tier
.command('switch <tier>')
.description('Switch storage tier between pglite and postgres')
.action((newTier: string) => {
const validTiers = ['pglite', 'postgres'];
if (!validTiers.includes(newTier)) {
console.error(
`[storage] unknown tier: ${newTier}. Valid options: ${validTiers.join(', ')}`,
);
process.exitCode = 1;
return;
}
console.log(`[storage] tier switch requested: ${newTier}`);
console.log('');
console.log('Mosaic storage tier is controlled by environment variables.');
console.log('Automatic config-file mutation is not supported — set the variable manually.');
console.log('');
if (newTier === 'postgres') {
console.log('To switch to postgres:');
console.log(' 1. Set DATABASE_URL in your environment or .env file:');
console.log(' export DATABASE_URL="postgresql://user:pass@localhost:5432/mosaic"');
console.log(' 2. Run migrations:');
console.log(' pnpm --filter @mosaicstack/db db:migrate');
console.log(' 3. Restart the gateway.');
} else {
console.log('To switch to pglite:');
console.log(' 1. Unset DATABASE_URL (or set it to a pglite path):');
console.log(' unset DATABASE_URL');
console.log(' # optionally: export PGLITE_DATA_DIR=/path/to/pglite/data');
console.log(' 2. Restart the gateway.');
console.log(' Note: pglite uses an in-process database — no migrations needed.');
}
});
// ── storage export ───────────────────────────────────────────────────────
storage
.command('export <path>')
.description('Dump the active storage contents to a file')
.action((outputPath: string) => {
const currentTier = activeTier();
if (currentTier === 'postgres') {
const redacted = redactedConnectionString() ?? '<DATABASE_URL>';
console.log('[storage] export for postgres tier');
console.log('');
console.log('postgres export is not yet wired in the CLI — use pg_dump directly:');
console.log('');
console.log(` pg_dump "${redacted}" > ${outputPath}`);
console.log('');
console.log('Or with Docker:');
console.log(
` docker exec <postgres-container> pg_dump -U <user> <dbname> > ${outputPath}`,
);
process.exitCode = 0;
} else {
const dataDir = process.env['PGLITE_DATA_DIR'];
console.log('[storage] export for pglite tier');
console.log('');
console.log(
'pglite export is not yet wired in the CLI — copy the data directory directly:',
);
console.log('');
if (dataDir) {
console.log(` cp -r ${dataDir} ${outputPath}`);
} else {
console.log(
' PGLITE_DATA_DIR is not set; the database is in-memory and cannot be exported.',
);
console.log(' Set PGLITE_DATA_DIR to a persistent path before running export.');
}
process.exitCode = 0;
}
});
// ── storage import ───────────────────────────────────────────────────────
storage
.command('import <path>')
.description('Restore storage contents from a previously exported file')
.action((inputPath: string) => {
const currentTier = activeTier();
if (currentTier === 'postgres') {
const redacted = redactedConnectionString() ?? '<DATABASE_URL>';
console.log('[storage] import for postgres tier');
console.log('');
console.log('postgres import is not yet wired in the CLI — use psql directly:');
console.log('');
console.log(` psql "${redacted}" < ${inputPath}`);
process.exitCode = 0;
} else {
const dataDir = process.env['PGLITE_DATA_DIR'];
console.log('[storage] import for pglite tier');
console.log('');
console.log(
'pglite import is not yet wired in the CLI — restore the data directory directly:',
);
console.log('');
if (dataDir) {
console.log(` rm -rf ${dataDir} && cp -r ${inputPath} ${dataDir}`);
console.log(' Then restart the gateway.');
} else {
console.log(
' PGLITE_DATA_DIR is not set; set it to a persistent path before running import.',
);
}
process.exitCode = 0;
}
});
// ── storage migrate ──────────────────────────────────────────────────────
storage
.command('migrate')
.description(
'Run database migrations (thin wrapper — delegates to pnpm db:migrate or prints the command)',
)
.option('--run', 'Actually execute the migration command via shell')
.action(async (opts: { run?: boolean }) => {
const currentTier = activeTier();
if (currentTier === 'pglite') {
console.log('[storage] pglite tier detected');
console.log(
'pglite runs schema setup automatically on first connection via adapter.migrate().',
);
console.log('No separate migration step is required.');
return;
}
const migrateCmd = 'pnpm --filter @mosaicstack/db db:migrate';
console.log('[storage] postgres tier detected');
console.log(`Migration command: ${migrateCmd}`);
console.log('');
if (opts.run) {
console.log('Running migrations...');
const { execSync } = await import('node:child_process');
try {
execSync(migrateCmd, { stdio: 'inherit' });
console.log('[storage] migrations complete.');
} catch (err) {
console.error(
`[storage] migration failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
} else {
console.log('To run migrations, execute:');
console.log(` ${migrateCmd}`);
console.log('');
console.log('Or pass --run to have this command execute it for you.');
}
});
}

View File

@@ -2,7 +2,6 @@ export type { StorageAdapter, StorageConfig } from './types.js';
export { createStorageAdapter, registerStorageAdapter } from './factory.js';
export { PostgresAdapter } from './adapters/postgres.js';
export { PgliteAdapter } from './adapters/pglite.js';
export { registerStorageCommand } from './cli.js';
import { registerStorageAdapter } from './factory.js';
import { PostgresAdapter } from './adapters/postgres.js';

12
pnpm-lock.yaml generated
View File

@@ -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
@@ -469,9 +475,6 @@ importers:
'@mosaicstack/quality-rails':
specifier: workspace:*
version: link:../quality-rails
'@mosaicstack/storage':
specifier: workspace:*
version: link:../storage
'@mosaicstack/types':
specifier: workspace:*
version: link:../types
@@ -590,9 +593,6 @@ importers:
'@mosaicstack/types':
specifier: workspace:*
version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
devDependencies:
typescript:
specifier: ^5.8.0