feat(mosaic): merge @mosaic/cli into @mosaic/mosaic
@mosaic/mosaic is now the single package providing both: - 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.) - 'mosaic-wizard' binary (installation wizard) Changes: - Move packages/cli/src/* into packages/mosaic/src/ - Convert dynamic @mosaic/mosaic imports to static relative imports - Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic - Add jsx: react-jsx to mosaic's tsconfig - Exclude packages/cli from workspace (pnpm-workspace.yaml) - Update install.sh to install @mosaic/mosaic instead of @mosaic/cli - Bump version to 0.0.17 This eliminates the circular dependency between @mosaic/cli and @mosaic/mosaic that was blocking the build graph.
This commit is contained in:
385
packages/mosaic/src/commands/mission.ts
Normal file
385
packages/mosaic/src/commands/mission.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { selectItem } from './select-dialog.js';
|
||||
import {
|
||||
fetchMissions,
|
||||
fetchMission,
|
||||
createMission,
|
||||
updateMission,
|
||||
fetchMissionTasks,
|
||||
createMissionTask,
|
||||
updateMissionTask,
|
||||
fetchProjects,
|
||||
} from '../tui/gateway-api.js';
|
||||
import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js';
|
||||
|
||||
function formatMission(m: MissionInfo): string {
|
||||
return `${m.name} — ${m.status}${m.phase ? ` (${m.phase})` : ''}`;
|
||||
}
|
||||
|
||||
function showMissionDetail(m: MissionInfo) {
|
||||
console.log(` ID: ${m.id}`);
|
||||
console.log(` Name: ${m.name}`);
|
||||
console.log(` Status: ${m.status}`);
|
||||
console.log(` Phase: ${m.phase ?? '—'}`);
|
||||
console.log(` Project: ${m.projectId ?? '—'}`);
|
||||
console.log(` Description: ${m.description ?? '—'}`);
|
||||
console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
function showTaskDetail(t: MissionTaskInfo) {
|
||||
console.log(` ID: ${t.id}`);
|
||||
console.log(` Status: ${t.status}`);
|
||||
console.log(` Description: ${t.description ?? '—'}`);
|
||||
console.log(` Notes: ${t.notes ?? '—'}`);
|
||||
console.log(` PR: ${t.pr ?? '—'}`);
|
||||
console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
export function registerMissionCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('mission')
|
||||
.description('Manage missions')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--list', 'List all missions')
|
||||
.option('--init', 'Create a new mission')
|
||||
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
|
||||
.option('--update <idOrName>', 'Update a mission')
|
||||
.option('--project <idOrName>', 'Scope to project')
|
||||
.argument('[id]', 'Show mission detail by ID')
|
||||
.action(
|
||||
async (
|
||||
id: string | undefined,
|
||||
opts: {
|
||||
gateway: string;
|
||||
list?: boolean;
|
||||
init?: boolean;
|
||||
plan?: string;
|
||||
update?: string;
|
||||
project?: string;
|
||||
},
|
||||
) => {
|
||||
const auth = await withAuth(opts.gateway);
|
||||
|
||||
if (opts.list) {
|
||||
return listMissions(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.init) {
|
||||
return initMission(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.plan) {
|
||||
return planMission(auth.gateway, auth.cookie, opts.plan, opts.project);
|
||||
}
|
||||
if (opts.update) {
|
||||
return updateMissionWizard(auth.gateway, auth.cookie, opts.update);
|
||||
}
|
||||
if (id) {
|
||||
return showMission(auth.gateway, auth.cookie, id);
|
||||
}
|
||||
|
||||
// Default: interactive select
|
||||
return interactiveSelect(auth.gateway, auth.cookie);
|
||||
},
|
||||
);
|
||||
|
||||
// Task subcommand
|
||||
cmd
|
||||
.command('task')
|
||||
.description('Manage mission tasks')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--list', 'List tasks for a mission')
|
||||
.option('--new', 'Create a task')
|
||||
.option('--update <taskId>', 'Update a task')
|
||||
.option('--mission <idOrName>', 'Mission ID or name')
|
||||
.argument('[taskId]', 'Show task detail')
|
||||
.action(
|
||||
async (
|
||||
taskId: string | undefined,
|
||||
taskOpts: {
|
||||
gateway: string;
|
||||
list?: boolean;
|
||||
new?: boolean;
|
||||
update?: string;
|
||||
mission?: string;
|
||||
},
|
||||
) => {
|
||||
const auth = await withAuth(taskOpts.gateway);
|
||||
|
||||
const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission);
|
||||
if (!missionId) return;
|
||||
|
||||
if (taskOpts.list) {
|
||||
return listTasks(auth.gateway, auth.cookie, missionId);
|
||||
}
|
||||
if (taskOpts.new) {
|
||||
return createTaskWizard(auth.gateway, auth.cookie, missionId);
|
||||
}
|
||||
if (taskOpts.update) {
|
||||
return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update);
|
||||
}
|
||||
if (taskId) {
|
||||
return showTask(auth.gateway, auth.cookie, missionId, taskId);
|
||||
}
|
||||
|
||||
return listTasks(auth.gateway, auth.cookie, missionId);
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function resolveMissionByName(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName: string,
|
||||
): Promise<MissionInfo | undefined> {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
return missions.find((m) => m.id === idOrName || m.name === idOrName);
|
||||
}
|
||||
|
||||
async function resolveMissionId(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (idOrName) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
return undefined;
|
||||
}
|
||||
return mission.id;
|
||||
}
|
||||
|
||||
// Interactive select
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
const selected = await selectItem(missions, {
|
||||
message: 'Select a mission:',
|
||||
render: formatMission,
|
||||
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
||||
});
|
||||
return selected?.id;
|
||||
}
|
||||
|
||||
async function listMissions(gateway: string, cookie: string) {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
if (missions.length === 0) {
|
||||
console.log('No missions found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Missions (${missions.length}):\n`);
|
||||
for (const m of missions) {
|
||||
const phase = m.phase ? ` [${m.phase}]` : '';
|
||||
console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showMission(gateway: string, cookie: string, id: string) {
|
||||
try {
|
||||
const mission = await fetchMission(gateway, cookie, id);
|
||||
showMissionDetail(mission);
|
||||
} catch {
|
||||
// Try resolving by name
|
||||
const m = await resolveMissionByName(gateway, cookie, id);
|
||||
if (!m) {
|
||||
console.error(`Mission "${id}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
showMissionDetail(m);
|
||||
}
|
||||
}
|
||||
|
||||
async function interactiveSelect(gateway: string, cookie: string) {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
const selected = await selectItem(missions, {
|
||||
message: 'Select a mission:',
|
||||
render: formatMission,
|
||||
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
||||
});
|
||||
if (selected) {
|
||||
showMissionDetail(selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function initMission(gateway: string, cookie: string) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
const name = await ask('Mission name: ');
|
||||
if (!name.trim()) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Project selection
|
||||
const projects = await fetchProjects(gateway, cookie);
|
||||
let projectId: string | undefined;
|
||||
if (projects.length > 0) {
|
||||
const selected = await selectItem(projects, {
|
||||
message: 'Assign to project (required):',
|
||||
render: (p) => `${p.name} (${p.status})`,
|
||||
emptyMessage: 'No projects found.',
|
||||
});
|
||||
if (selected) projectId = selected.id;
|
||||
}
|
||||
|
||||
const description = await ask('Description (optional): ');
|
||||
|
||||
const mission = await createMission(gateway, cookie, {
|
||||
name: name.trim(),
|
||||
projectId,
|
||||
description: description.trim() || undefined,
|
||||
status: 'planning',
|
||||
});
|
||||
|
||||
console.log(`\nMission "${mission.name}" created (${mission.id}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function planMission(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName: string,
|
||||
_projectIdOrName?: string,
|
||||
) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Planning mission: ${mission.name}\n`);
|
||||
|
||||
try {
|
||||
const { runPrdWizard } = await import('@mosaic/prdy');
|
||||
await runPrdWizard({
|
||||
name: mission.name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
console.log(`Updating mission: ${mission.name}\n`);
|
||||
|
||||
const name = await ask(`Name [${mission.name}]: `);
|
||||
const description = await ask(`Description [${mission.description ?? 'none'}]: `);
|
||||
const status = await ask(`Status [${mission.status}]: `);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name.trim()) updates['name'] = name.trim();
|
||||
if (description.trim()) updates['description'] = description.trim();
|
||||
if (status.trim()) updates['status'] = status.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('No changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateMission(gateway, cookie, mission.id, updates);
|
||||
console.log(`\nMission "${updated.name}" updated.`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task operations ──
|
||||
|
||||
async function listTasks(gateway: string, cookie: string, missionId: string) {
|
||||
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
||||
if (tasks.length === 0) {
|
||||
console.log('No tasks found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Tasks (${tasks.length}):\n`);
|
||||
for (const t of tasks) {
|
||||
const desc = t.description ? ` — ${t.description.slice(0, 60)}` : '';
|
||||
console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) {
|
||||
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
console.error(`Task "${taskId}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
showTaskDetail(task);
|
||||
}
|
||||
|
||||
async function createTaskWizard(gateway: string, cookie: string, missionId: string) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
const description = await ask('Task description: ');
|
||||
if (!description.trim()) {
|
||||
console.error('Description is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await ask('Status [not-started]: ');
|
||||
|
||||
const task = await createMissionTask(gateway, cookie, missionId, {
|
||||
description: description.trim(),
|
||||
status: status.trim() || 'not-started',
|
||||
});
|
||||
|
||||
console.log(`\nTask created (${task.id}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskWizard(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
missionId: string,
|
||||
taskId: string,
|
||||
) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
const status = await ask('New status: ');
|
||||
const notes = await ask('Notes (optional): ');
|
||||
const pr = await ask('PR (optional): ');
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (status.trim()) updates['status'] = status.trim();
|
||||
if (notes.trim()) updates['notes'] = notes.trim();
|
||||
if (pr.trim()) updates['pr'] = pr.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('No changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates);
|
||||
console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user