feat(fleet): config-type presets + AI-free init wizard (F1) (#591)
This commit was merged in pull request #591.
This commit is contained in:
@@ -4,6 +4,7 @@ import { homedir, hostname, userInfo } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
|
||||
@@ -41,6 +42,11 @@ export interface FleetCommandDeps {
|
||||
sleepFn?: SleepFn;
|
||||
mosaicHome?: string;
|
||||
frameworkRoot?: string;
|
||||
/**
|
||||
* Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY.
|
||||
* Tests stub this to simulate interactive or non-interactive environments.
|
||||
*/
|
||||
isStdinTTY?: boolean;
|
||||
}
|
||||
|
||||
interface RawFleetRoster {
|
||||
@@ -799,19 +805,42 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
cmd
|
||||
.command('init')
|
||||
.description('Initialize a local fleet roster')
|
||||
.option('--profile <name>', 'Roster profile: minimal or local-canary', 'minimal')
|
||||
.option(
|
||||
'--profile <name>',
|
||||
`Roster profile: ${FLEET_PROFILES.join(', ')} (skips interactive wizard)`,
|
||||
)
|
||||
.option('--write', 'Write the roster to Mosaic home')
|
||||
.option('--force', 'Overwrite an existing roster when used with --write')
|
||||
.action(async (opts: { profile: string; write?: boolean; force?: boolean }) => {
|
||||
.action(async (opts: { profile?: string; write?: boolean; force?: boolean }) => {
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const profile = parseInitProfile(opts.profile);
|
||||
const source = join(frameworkRoot, 'fleet', 'examples', `${profile}.yaml`);
|
||||
|
||||
let profile: FleetProfile;
|
||||
if (opts.profile !== undefined) {
|
||||
// Explicit --profile flag: validate and use it (non-interactive path).
|
||||
profile = parseInitProfile(opts.profile);
|
||||
} else {
|
||||
// No --profile: use wizard when stdin is a TTY, else default to 'general'.
|
||||
const isTTY = deps.isStdinTTY ?? process.stdin.isTTY ?? false;
|
||||
if (isTTY) {
|
||||
profile = await promptFleetProfile();
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'Note: stdin is not a TTY; defaulting to fleet profile "general". ' +
|
||||
'Use --profile <name> to select a different preset.\n',
|
||||
);
|
||||
profile = 'general';
|
||||
}
|
||||
}
|
||||
|
||||
const source = join(frameworkRoot, 'fleet', 'examples', resolvePresetFilename(profile));
|
||||
const content = await readFile(source, 'utf8');
|
||||
|
||||
if (!opts.write) {
|
||||
console.log(content.trimEnd());
|
||||
return;
|
||||
}
|
||||
|
||||
const destination = commandOpts.roster ?? activePaths.rosterPath;
|
||||
if (!opts.force && (await canRead(destination))) {
|
||||
throw new Error(
|
||||
@@ -820,7 +849,23 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
console.log(`Wrote fleet roster: ${destination}`);
|
||||
|
||||
// Validate: exactly one orchestrator required (R5) — friendly summary on success.
|
||||
const written = await loadFleetRoster(destination);
|
||||
const orchCount = countOrchestrators(written);
|
||||
if (orchCount !== 1) {
|
||||
process.stderr.write(
|
||||
`Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`,
|
||||
);
|
||||
console.log(
|
||||
`Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`,
|
||||
);
|
||||
} else {
|
||||
const workerCount = written.agents.length - 1;
|
||||
console.log(
|
||||
`Initialized ${profile} fleet: 1 orchestrator + ${workerCount} agent(s). Next: mosaic fleet install`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
@@ -1668,11 +1713,96 @@ function splitCommand(command: string[]): [string, string[]] {
|
||||
return [bin, args];
|
||||
}
|
||||
|
||||
function parseInitProfile(profile: string): 'minimal' | 'local-canary' {
|
||||
if (profile === 'minimal' || profile === 'local-canary') {
|
||||
return profile;
|
||||
/** All supported fleet profile names. */
|
||||
export type FleetProfile =
|
||||
| 'general'
|
||||
| 'coding'
|
||||
| 'research'
|
||||
| 'hybrid'
|
||||
| 'minimal'
|
||||
| 'local-canary';
|
||||
|
||||
/** The list of all valid fleet profile names, for wizard menus and error messages. */
|
||||
export const FLEET_PROFILES: readonly FleetProfile[] = [
|
||||
'general',
|
||||
'coding',
|
||||
'research',
|
||||
'hybrid',
|
||||
'minimal',
|
||||
'local-canary',
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps a fleet profile name to its example YAML filename (without the path).
|
||||
* Pure function — testable without I/O.
|
||||
*/
|
||||
export function resolvePresetFilename(profile: FleetProfile): string {
|
||||
return `${profile}.yaml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalise a fleet profile name string.
|
||||
* Throws with a clear message on unknown values.
|
||||
*/
|
||||
export function parseInitProfile(profile: string): FleetProfile {
|
||||
if ((FLEET_PROFILES as readonly string[]).includes(profile)) {
|
||||
return profile as FleetProfile;
|
||||
}
|
||||
throw new Error(`Unsupported fleet profile "${profile}". Use: minimal, local-canary.`);
|
||||
throw new Error(`Unsupported fleet profile "${profile}". Use: ${FLEET_PROFILES.join(', ')}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count orchestrator agents in a parsed roster.
|
||||
* Returns the count; callers assert === 1.
|
||||
*/
|
||||
export function countOrchestrators(roster: FleetRoster): number {
|
||||
return roster.agents.filter((a) => a.className === 'orchestrator').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt interactively for a fleet profile via stdin readline.
|
||||
* AI-free: no LLM calls — pure readline menu.
|
||||
* Resolves with the chosen profile string, or rejects on I/O error.
|
||||
*/
|
||||
function promptFleetProfile(): Promise<FleetProfile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const menu = [
|
||||
'',
|
||||
'Choose a fleet configuration type:',
|
||||
' 1) general — orchestrator + generalist worker',
|
||||
' 2) coding — orchestrator + coder0 + coder1 + reviewer',
|
||||
' 3) research — orchestrator + researcher0 + researcher1 + analyst',
|
||||
' 4) hybrid — orchestrator + coder0 + researcher0 + reviewer',
|
||||
' 5) minimal — single canary-pi agent (no orchestrator)',
|
||||
' 6) local-canary — legacy canary preset with lead + coder + reviewer',
|
||||
'',
|
||||
].join('\n');
|
||||
process.stdout.write(menu);
|
||||
rl.question('Enter number or name [1]: ', (answer) => {
|
||||
rl.close();
|
||||
const trimmed = answer.trim();
|
||||
// Map numeric shortcut → name
|
||||
const byNumber: Record<string, FleetProfile> = {
|
||||
'1': 'general',
|
||||
'2': 'coding',
|
||||
'3': 'research',
|
||||
'4': 'hybrid',
|
||||
'5': 'minimal',
|
||||
'6': 'local-canary',
|
||||
'': 'general', // default on empty enter
|
||||
};
|
||||
if (trimmed in byNumber) {
|
||||
resolve(byNumber[trimmed]!);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(parseInitProfile(trimmed));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function writeCommandOutput(result: CommandResult): void {
|
||||
|
||||
Reference in New Issue
Block a user