diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index f38e64a7..9aa91ba3 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -11,6 +11,7 @@ import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; import { registerStorageCommand } from '@mosaicstack/storage'; import { registerTelemetryCommand } from './commands/telemetry.js'; +import { registerWatchCommand } from './commands/watch.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerInteractionCommand } from './commands/interaction.js'; import { registerConfigCommand } from './commands/config.js'; @@ -428,6 +429,7 @@ registerSkillCommand(program); // ─── telemetry ─────────────────────────────────────────────────────────────── registerTelemetryCommand(program); +registerWatchCommand(program); // ─── update ───────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/commands/watch.spec.ts b/packages/mosaic/src/commands/watch.spec.ts new file mode 100644 index 00000000..489ef8c2 --- /dev/null +++ b/packages/mosaic/src/commands/watch.spec.ts @@ -0,0 +1,72 @@ +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js'; + +// The dispatch command execs a real process with inherited stdio; the spec +// covers the pure resolution and exit-mapping surfaces plus the absent-tool +// path (which exits without spawning). Live pass-through is exercised by the +// fleet smoke test against the real brain tool. + +describe('resolveAgentWatchTool', () => { + const saved = process.env['MOSAIC_BRAIN_HOME']; + afterEach(() => { + if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME']; + else process.env['MOSAIC_BRAIN_HOME'] = saved; + }); + + it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => { + const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-')); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe( + join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'), + ); + }); + + it('resolves inside the brain tools tree', () => { + const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-')); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + const tool = resolveAgentWatchTool(tmp); + expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true); + }); +}); + +describe('exitStatusFor', () => { + it('maps absent tool to 127', () => { + expect(exitStatusFor({ status: 0 }, false)).toBe(127); + }); + + it('passes the tool exit status through', () => { + expect(exitStatusFor({ status: 2 }, true)).toBe(2); + expect(exitStatusFor({ status: 78 }, true)).toBe(78); + }); + + it('maps signal death / null status to 125', () => { + expect(exitStatusFor({ status: null }, true)).toBe(125); + }); +}); + +describe('registerWatchCommand absent-tool path', () => { + const saved = process.env['MOSAIC_BRAIN_HOME']; + afterEach(() => { + if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME']; + else process.env['MOSAIC_BRAIN_HOME'] = saved; + process.exitCode = undefined; + }); + + it('sets exitCode 127 with the resolved path when the tool is missing', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-')); + // The suite directory exists but the tool file does not. + mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true }); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + + const program = new Command(); + registerWatchCommand(program); + await program.parseAsync(['watch', 'list'], { from: 'user' }); + expect(process.exitCode).toBe(127); + }); +}); diff --git a/packages/mosaic/src/commands/watch.ts b/packages/mosaic/src/commands/watch.ts new file mode 100644 index 00000000..e3bde144 --- /dev/null +++ b/packages/mosaic/src/commands/watch.ts @@ -0,0 +1,87 @@ +import type { Command } from 'commander'; +import { spawnSync } from 'node:child_process'; +import { accessSync, constants } from 'node:fs'; +import { join } from 'node:path'; + +import { DEFAULT_MOSAIC_HOME } from '../constants.js'; +import { resolveBrainHome } from '../fleet/brain-home.js'; + +/** + * `mosaic watch` — dispatch to the brain's agent-watch suite. + * + * Architecture (fleet CLI integration, Jason ruling 2026-08-28): the npm + * package embeds the COMMAND SURFACE; operator-owned tool implementations + * stay in the brain (`~/.mosaic/tools/`). The dispatch resolves the brain + * home (MOSAIC_BRAIN_HOME wins, else canonical ~/.mosaic when adopted — + * see brain-home.ts) and execs the tool there. Nothing operator-specific + * ships inside the package, keeping the framework boundary clean. + * + * Pass-through contract: arguments, stdout/stderr, and the exit code belong + * to the tool. The CLI adds nothing on success. When the tool is absent the + * CLI fails loudly with the resolved path (exit 127) instead of guessing. + */ + +export function resolveAgentWatchTool(mosaicHome: string): string { + return join(resolveBrainHome(mosaicHome), 'tools', 'agent-watch', 'agent-watch.sh'); +} + +/** Map a spawnSync result + tool existence to the CLI exit status. */ +export function exitStatusFor( + result: { status: number | null; error?: NodeJS.ErrnoException }, + toolExists: boolean, +): number { + if (!toolExists) return 127; + if (result.status !== null) return result.status; + return 125; // killed by signal / could not run +} + +export function registerWatchCommand(program: Command): void { + const cmd: Command = program + .command('watch') + .description('Wake-me-when watchers (agent-watch): start, list, stop') + // allowUnknownOption + variadic = full ordered pass-through: unknown + // options (--name, --when, ...) and their values land in args verbatim + // (commander 13 measured behavior), so the tool owns its own flag + // surface without the CLI needing passThroughOptions (which would + // force enablePositionalOptions fleet-wide on the root program). + .allowUnknownOption() + .argument('[args...]', 'args passed through to agent-watch.sh') + .action(async (args: string[], _opts: unknown, command: Command) => { + // --mosaic-home is not global in this CLI; walk parents for it and + // fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export + // it) wins inside resolveBrainHome regardless. + let mosaicHome: string | undefined; + for (let anc: Command | null = command; anc; anc = anc.parent) { + const v = (anc.opts() as Record)['mosaicHome']; + if (v !== undefined) { + mosaicHome = v; + break; + } + } + const tool = resolveAgentWatchTool(mosaicHome ?? DEFAULT_MOSAIC_HOME); + + let toolExists = true; + try { + accessSync(tool, constants.X_OK); + } catch { + toolExists = false; + } + if (!toolExists) { + console.error( + `mosaic watch: agent-watch not found (expected ${tool}). ` + + 'The watcher suite lives in the brain tree under tools/agent-watch/; ' + + 'check MOSAIC_BRAIN_HOME or the brain checkout.', + ); + process.exitCode = 127; + return; + } + + const result = spawnSync(tool, args, { stdio: 'inherit', env: process.env }); + process.exitCode = exitStatusFor(result, true); + }); + + cmd.addHelpText( + 'after', + '\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).', + ); +}