import { readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import type { Command } from 'commander'; import { executeFleetAgentMutation, FleetAgentMutationError, type FleetAgentMutationAgent, type FleetAgentMutationOperation, type FleetAgentMutationOptions, type FleetAgentMutationRequest, type FleetAgentMutationResult, } from '../fleet/fleet-agent-crud.js'; import { parseRosterV2, ROSTER_V2_REASONING_LEVELS, ROSTER_V2_SUPPORTED_RUNTIMES, type FleetRosterV2, type RosterV2ReasoningLevel, type RosterV2RuntimeName, } from '../fleet/roster-v2.js'; export interface FleetAgentCrudCommandDeps { readonly mosaicHome?: string; /** Test seam for deterministic post-roster filesystem projection failures. */ readonly projectionApplier?: FleetAgentMutationOptions['projectionApplier']; } interface MutationOptions { readonly expectedGeneration: string; readonly agent?: string; readonly dryRun?: boolean; readonly persistedStart?: boolean; } const AGENT_REQUEST_KEYS = new Set([ 'name', 'alias', 'className', 'runtime', 'provider', 'model', 'reasoning', 'toolPolicy', 'workingDirectory', 'persistentPersona', 'resetBetweenTasks', 'launch', ]); const AGENT_LAUNCH_REQUEST_KEYS = new Set(['yolo']); /** Registers only roster-v2 desired-state mutations; it never invokes runtime actions. */ export function registerFleetAgentCrudCommands( agentCommand: Command, deps: FleetAgentCrudCommandDeps = {}, ): void { agentCommand .command('get ') .description('Read one local roster-v2 agent as JSON') .action(async (name: string): Promise => { await writeJsonOutcome(async (): Promise => { const roster = await loadRoster(agentCommand, deps); const agent = roster.agents.find((candidate): boolean => candidate.name === name); if (!agent) throw new FleetAgentMutationError('agent-not-found', `Agent "${name}" is not in roster.`); printJson({ generation: roster.generation, agent }); }); }); registerMutationCommand(agentCommand, deps, 'create'); registerMutationCommand(agentCommand, deps, 'update'); registerMutationCommand(agentCommand, deps, 'delete'); agentCommand .command('plan [name]') .description('Plan a local roster-v2 mutation without writing roster or projections') .requiredOption('--expected-generation ', 'Authoritative roster generation') .option('--agent ', 'JSON agent payload for create or update') .option( '--persisted-start', 'Plan create with desired_state: running without starting a runtime', ) .action( async (operation: string, name: string | undefined, opts: MutationOptions): Promise => { await writeJsonOutcome(async (): Promise => { const parsedOperation = parseOperation(operation); await executeCommand( agentCommand, deps, parsedOperation, opts, true, parsePlanTargetName(parsedOperation, name), ); }); }, ); } function registerMutationCommand( agentCommand: Command, deps: FleetAgentCrudCommandDeps, operation: FleetAgentMutationOperation, ): void { const command = agentCommand .command( operation === 'update' ? 'update ' : `${operation}${operation === 'delete' ? ' ' : ''}`, ) .description(`${operation} a local roster-v2 agent without runtime actions`) .requiredOption('--expected-generation ', 'Authoritative roster generation') .option('--agent ', 'JSON agent payload for create or update') .option('--dry-run', 'Validate and plan without writing roster or projections'); if (operation === 'create') { command.option( '--persisted-start', 'Persist desired_state: running without starting a runtime', ); command.action(async (opts: MutationOptions): Promise => { await writeJsonOutcome(async (): Promise => { await executeCommand(agentCommand, deps, operation, opts, false); }); }); return; } command.action(async (name: string, opts: MutationOptions): Promise => { await writeJsonOutcome(async (): Promise => { await executeCommand(agentCommand, deps, operation, opts, false, name); }); }); } async function executeCommand( agentCommand: Command, deps: FleetAgentCrudCommandDeps, operation: FleetAgentMutationOperation, opts: MutationOptions, forceDryRun: boolean, name?: string, ): Promise { const mosaicHome = resolveMosaicHome(agentCommand, deps); const rosterPath = resolveRosterPath(agentCommand, mosaicHome); const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'); const request = buildRequest(operation, opts, name); const result = await executeFleetAgentMutation({ roster, request, mosaicHome, rosterPath, agentEnvDir: join(mosaicHome, 'fleet', 'agents'), rolesDir: join(mosaicHome, 'fleet', 'roles'), overrideDir: join(mosaicHome, 'fleet', 'roles.local'), dryRun: forceDryRun || opts.dryRun === true, ...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }), }); printJson(result); process.exitCode = fleetAgentMutationExitCode(result); } /** A persisted roster with failed derived projections is a non-zero CLI outcome. */ export function fleetAgentMutationExitCode( result: Pick, ): 0 | 1 { return result.recovery === undefined ? 0 : 1; } function parsePlanTargetName( operation: FleetAgentMutationOperation, name: string | undefined, ): string | undefined { if (operation === 'create') { if (name !== undefined) { throw new FleetAgentMutationError( 'invalid-request', 'Create plan does not accept a target name.', ); } return undefined; } if (name === undefined) { throw new FleetAgentMutationError( 'invalid-request', `${operation} plan requires a target agent name.`, ); } return name; } function buildRequest( operation: FleetAgentMutationOperation, opts: MutationOptions, name?: string, ): FleetAgentMutationRequest { const expectedGeneration = parseExpectedGeneration(opts.expectedGeneration); const agent = opts.agent === undefined ? undefined : parseAgent(opts.agent); if ((operation === 'create' || operation === 'update') && !agent) { throw new FleetAgentMutationError('invalid-request', `${operation} requires --agent JSON.`); } return { operation, expectedGeneration, ...(name === undefined ? {} : { name }), ...(agent === undefined ? {} : { agent }), ...(operation === 'create' && opts.persistedStart === true ? { persistedStart: true } : {}), }; } function parseExpectedGeneration(value: string): number { const generation = Number(value); if (!Number.isSafeInteger(generation) || generation < 1) { throw new FleetAgentMutationError( 'invalid-request', '--expected-generation must be a positive safe integer.', ); } return generation; } function parseOperation(value: string): FleetAgentMutationOperation { if (value === 'create' || value === 'update' || value === 'delete') return value; throw new FleetAgentMutationError( 'invalid-request', 'plan operation must be create, update, or delete.', ); } function parseAgent(source: string): FleetAgentMutationAgent { let value: unknown; try { value = JSON.parse(source); } catch { throw new FleetAgentMutationError('invalid-request', '--agent must be valid JSON.'); } if (!isRecord(value)) { throw new FleetAgentMutationError('invalid-request', '--agent must be a JSON object.'); } rejectUnknownKeys(value, AGENT_REQUEST_KEYS, '--agent'); const runtime = requiredRuntime(value, 'runtime'); const reasoning = requiredReasoning(value, 'reasoning'); const launch = requiredRecord(value, 'launch'); rejectUnknownKeys(launch, AGENT_LAUNCH_REQUEST_KEYS, '--agent.launch'); return { name: requiredString(value, 'name'), alias: requiredString(value, 'alias'), className: requiredString(value, 'className'), runtime, provider: requiredString(value, 'provider'), model: requiredString(value, 'model'), reasoning, toolPolicy: requiredString(value, 'toolPolicy'), workingDirectory: requiredString(value, 'workingDirectory'), persistentPersona: requiredBoolean(value, 'persistentPersona'), resetBetweenTasks: requiredBoolean(value, 'resetBetweenTasks'), launch: { yolo: requiredBoolean(launch, 'yolo') }, }; } async function loadRoster( agentCommand: Command, deps: FleetAgentCrudCommandDeps, ): Promise { const mosaicHome = resolveMosaicHome(agentCommand, deps); const rosterPath = resolveRosterPath(agentCommand, mosaicHome); return parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'); } function resolveMosaicHome(agentCommand: Command, deps: FleetAgentCrudCommandDeps): string { const opts = agentCommand.optsWithGlobals<{ mosaicHome?: string }>(); return opts.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic'); } function resolveRosterPath(agentCommand: Command, mosaicHome: string): string { const opts = agentCommand.optsWithGlobals<{ roster?: string }>(); const canonical = join(mosaicHome, 'fleet', 'roster.yaml'); if (opts.roster !== undefined && resolve(opts.roster) !== resolve(canonical)) { throw new FleetAgentMutationError( 'invalid-request', 'Roster-v2 mutations require the canonical /fleet/roster.yaml path.', ); } return canonical; } function rejectUnknownKeys( value: Record, allowedKeys: ReadonlySet, field: string, ): void { const hasUnsupportedOwnProperty = Object.getOwnPropertyNames(value).some( (key: string): boolean => !allowedKeys.has(key), ); if (hasUnsupportedOwnProperty || Object.getOwnPropertySymbols(value).length > 0) { throw new FleetAgentMutationError('invalid-request', `${field} contains unsupported keys.`); } } function requiredString(value: Record, key: string): string { const candidate = requiredOwnValue(value, key); if (typeof candidate !== 'string' || candidate.length === 0) { throw new FleetAgentMutationError( 'invalid-request', `--agent.${key} must be a non-empty string.`, ); } return candidate; } function requiredBoolean(value: Record, key: string): boolean { const candidate = requiredOwnValue(value, key); if (typeof candidate !== 'boolean') { throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be a boolean.`); } return candidate; } function requiredRecord(value: Record, key: string): Record { const candidate = requiredOwnValue(value, key); if (!isRecord(candidate)) { throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be an object.`); } return candidate; } function requiredRuntime(value: Record, key: string): RosterV2RuntimeName { const candidate = requiredString(value, key); if (!isRuntime(candidate)) { throw new FleetAgentMutationError( 'invalid-request', `--agent.${key} is not a supported runtime.`, ); } return candidate; } function requiredReasoning(value: Record, key: string): RosterV2ReasoningLevel { const candidate = requiredString(value, key); if (!isReasoning(candidate)) { throw new FleetAgentMutationError( 'invalid-request', `--agent.${key} is not a supported reasoning level.`, ); } return candidate; } function isRuntime(value: string): value is RosterV2RuntimeName { return ROSTER_V2_SUPPORTED_RUNTIMES.some( (runtime: RosterV2RuntimeName): boolean => runtime === value, ); } function isReasoning(value: string): value is RosterV2ReasoningLevel { return ROSTER_V2_REASONING_LEVELS.some( (reasoning: RosterV2ReasoningLevel): boolean => reasoning === value, ); } function requiredOwnValue(value: Record, key: string): unknown { if (!Object.hasOwn(value, key)) { throw new FleetAgentMutationError('invalid-request', `--agent.${key} is required.`); } return value[key]; } function isRecord(value: unknown): value is Record { return ( typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype ); } async function writeJsonOutcome(action: () => Promise): Promise { try { await action(); } catch (error: unknown) { process.exitCode = 1; printJson({ error: { code: error instanceof FleetAgentMutationError ? error.code : 'mutation-failed', }, }); } } function printJson(value: object): void { console.log(JSON.stringify(value)); }