#!/usr/bin/env node /** * maestro CLI — 通过 REST API 操作 maestrod(零新依赖,node:util parseArgs)。 * * 子命令: * project add [--name N] [--branch B] [--verify CMD] [--concurrency N] * project list * task list * task add --complexity hard|medium|easy [--parent ID] [--priority 0|1|2(P0 最高,默认 P1)] * next <projectIdOrName> * approvals [projectIdOrName] * import-todo <repoPath> [--name N] (导入=首次同步,读 <repoPath>/todo/todo.json,幂等) * * 环境变量:MAESTRO_URL(默认 http://127.0.0.1:4517) */ import { parseArgs, type ParseArgsConfig } from 'node:util'; import { resolve, basename } from 'node:path'; import type { Project, Task } from '../model/types.js'; import { STATUS_LABEL } from '../model/status.js'; import { COMPLEXITY_LABEL, isComplexity } from '../model/complexity.js'; const BASE = process.env.MAESTRO_URL ?? 'http://127.0.0.1:4517'; const USAGE = `maestro — 多项目 TODO 管理与 Agent 执行系统 CLI 用法: maestro project add <repoPath> [--name 名称] [--branch 分支] [--verify 命令] [--concurrency 并发数] 注册项目(name 缺省取目录名) maestro project list 列出所有项目 maestro task list <项目id或名称> 树形列出项目任务(项目名支持模糊匹配) maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent 父任务id] [--priority 0|1|2,P0 最高,默认 P1] 新建任务 maestro next <项目id或名称> 取下一个可执行任务 maestro approvals [项目id或名称] 列出待审批任务(不带参数 = 全部项目) maestro import-todo <仓库路径> [--name 项目名] 导入/同步旧 todo skill 数据(读 <仓库路径>/todo/todo.json;项目不存在则建;幂等可重复) maestro --help | help 显示本说明 环境变量: MAESTRO_URL daemon 地址(默认 http://127.0.0.1:4517)`; // ---------- 基础工具 ---------- class ApiError extends Error {} function fail(msg: string): never { console.error(`错误:${msg}`); process.exit(1); } async function api<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> { let res: Response; try { res = await fetch(`${BASE}${path}`, { method, headers: body === undefined ? undefined : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); } catch { console.error(`无法连接 maestro daemon(${BASE})。`); console.error('请先启动 maestrod:在 maestro 仓库目录执行 `npm run dev`;'); console.error('若 daemon 跑在其他地址/端口,请设置 MAESTRO_URL 环境变量。'); process.exit(1); } const text = await res.text(); let data: unknown = null; try { data = text ? JSON.parse(text) : null; } catch { /* 非 JSON 响应 */ } if (!res.ok) { const msg = (data as { error?: string } | null)?.error ?? `HTTP ${res.status}`; throw new ApiError(msg); } return data as T; } /** 中日韩等全角字符按宽度 2 计算,保证表格对齐 */ function displayWidth(s: string): number { let w = 0; for (const ch of s) { w += /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1; } return w; } function pad(s: string, width: number): string { const gap = width - displayWidth(s); return gap > 0 ? s + ' '.repeat(gap) : s; } function printTable(headers: string[], rows: string[][]): void { const widths = headers.map((h, i) => { let w = displayWidth(h); for (const r of rows) w = Math.max(w, displayWidth(r[i] ?? '')); return w; }); const line = (cells: string[]): string => cells.map((c, i) => pad(c, widths[i])).join(' '); console.log(line(headers)); console.log(widths.map((w) => '-'.repeat(w)).join(' ')); for (const r of rows) console.log(line(r)); } function parseCmdArgs( rest: string[], options: NonNullable<ParseArgsConfig['options']>, ): { values: Record<string, string | boolean | undefined>; positionals: string[] } { try { const { values, positionals } = parseArgs({ args: rest, options, allowPositionals: true }); return { values: values as Record<string, string | boolean | undefined>, positionals }; } catch (e) { fail(`参数不合法:${(e as Error).message}\n\n${USAGE}`); } } async function resolveProject(idOrName: string): Promise<Project> { const projects = await api<Project[]>('GET', '/api/projects'); const byId = projects.find((p) => p.id === idOrName); if (byId) return byId; let matched = projects.filter((p) => p.name === idOrName); if (matched.length === 0) { const lower = idOrName.toLowerCase(); matched = projects.filter((p) => p.name.toLowerCase().includes(lower)); } if (matched.length === 1) return matched[0]; if (matched.length > 1) { fail( `项目「${idOrName}」匹配到 ${matched.length} 个,请用更精确的名称或直接用 id:\n` + matched.map((p) => ` - ${p.name} (${p.id})`).join('\n'), ); } fail(`找不到项目「${idOrName}」。可先用 \`maestro project list\` 查看全部项目。`); } // ---------- project ---------- async function cmdProjectAdd(rest: string[]): Promise<void> { const { values, positionals } = parseCmdArgs(rest, { name: { type: 'string' }, branch: { type: 'string' }, verify: { type: 'string' }, concurrency: { type: 'string' }, }); const repoArg = positionals[0]; if (!repoArg) fail('用法:maestro project add <repoPath> [--name N] [--branch B] [--verify CMD] [--concurrency N]'); const repoPath = resolve(repoArg); const name = (values.name as string | undefined) ?? basename(repoPath); const concurrency = values.concurrency === undefined ? undefined : Number(values.concurrency); if (concurrency !== undefined && (!Number.isInteger(concurrency) || concurrency < 1)) { fail('--concurrency 必须是 >=1 的整数'); } const p = await api<Project>('POST', '/api/projects', { name, repoPath, defaultBranch: values.branch as string | undefined, verifyCmd: values.verify as string | undefined, concurrency, }); console.log(`已注册项目「${p.name}」(${p.id})`); console.log(` repo: ${p.repoPath} · 分支: ${p.defaultBranch} · 并发: ${p.concurrency} · verify: ${p.verifyCmd ?? '无'}`); } async function cmdProjectList(): Promise<void> { const projects = await api<Project[]>('GET', '/api/projects'); if (projects.length === 0) { console.log('暂无项目。用 `maestro project add <repoPath>` 注册一个。'); return; } printTable( ['id', 'name', 'repoPath', 'status'], projects.map((p) => [p.id, p.name, p.repoPath, p.status]), ); } // ---------- task ---------- function taskLine(t: Task): string { const deps = t.deps.length ? ` deps:${t.deps.length}` : ''; return `[${COMPLEXITY_LABEL[t.complexity]}·${STATUS_LABEL[t.status]}] ${t.title}${deps} (${t.id})`; } function printTaskTree(tasks: Task[]): void { const byParent = new Map<string | null, Task[]>(); for (const t of tasks) { const key = t.parentId; const list = byParent.get(key) ?? []; list.push(t); byParent.set(key, list); } const sortFn = (a: Task, b: Task): number => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt); // P0 最高在前 const walk = (parentId: string | null, indent: string): void => { const list = (byParent.get(parentId) ?? []).sort(sortFn); for (const t of list) { console.log(`${indent}- ${taskLine(t)}`); walk(t.id, indent + ' '); } }; walk(null, ''); } async function cmdTaskList(rest: string[]): Promise<void> { const { positionals } = parseCmdArgs(rest, {}); if (!positionals[0]) fail('用法:maestro task list <项目id或名称>'); const project = await resolveProject(positionals[0]); const tasks = await api<Task[]>('GET', `/api/projects/${project.id}/tasks`); console.log(`项目「${project.name}」(${project.id}) · 共 ${tasks.length} 条任务\n`); if (tasks.length === 0) { console.log('(暂无任务)'); return; } printTaskTree(tasks); } async function cmdTaskAdd(rest: string[]): Promise<void> { const { values, positionals } = parseCmdArgs(rest, { complexity: { type: 'string' }, parent: { type: 'string' }, priority: { type: 'string' }, }); const projectArg = positionals[0]; const title = positionals.slice(1).join(' ').trim(); if (!projectArg || !title) { fail('用法:maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent ID] [--priority 0|1|2(P0 最高,默认 P1)]'); } if (!isComplexity(values.complexity)) fail('--complexity 必填,且只能是 hard | medium | easy'); const priority = values.priority === undefined ? undefined : Number(values.priority); if (priority !== undefined && ![0, 1, 2].includes(priority)) fail('--priority 必须是 0/1/2(P0 最高,P1 中,P2 最低)'); const project = await resolveProject(projectArg); const t = await api<Task>('POST', `/api/projects/${project.id}/tasks`, { title, complexity: values.complexity, parentId: values.parent as string | undefined, priority, }); console.log(`已创建任务 ${taskLine(t)}`); } // ---------- next / approvals ---------- async function cmdNext(rest: string[]): Promise<void> { const { positionals } = parseCmdArgs(rest, {}); if (!positionals[0]) fail('用法:maestro next <项目id或名称>'); const project = await resolveProject(positionals[0]); const r = await api<Task | { next: null }>('GET', `/api/projects/${project.id}/next`); if (!r || !('id' in r)) { console.log(`项目「${project.name}」当前没有可执行任务(叶子 + ready + 依赖满足)。`); return; } console.log(`下一个可执行任务:`); console.log(` ${taskLine(r)}`); } async function cmdApprovals(rest: string[]): Promise<void> { const { positionals } = parseCmdArgs(rest, {}); let query = ''; let scope = '全部项目'; if (positionals[0]) { const project = await resolveProject(positionals[0]); query = `?projectId=${encodeURIComponent(project.id)}`; scope = `项目「${project.name}」`; } const tasks = await api<Task[]>('GET', `/api/approvals${query}`); if (tasks.length === 0) { console.log(`${scope}当前没有待审批任务。`); return; } const projects = await api<Project[]>('GET', '/api/projects'); const nameOf = new Map(projects.map((p) => [p.id, p.name])); console.log(`${scope}待审批任务 ${tasks.length} 条:\n`); printTable( ['闸', '复杂度', '标题', '任务id', '项目'], tasks.map((t) => [ STATUS_LABEL[t.status], COMPLEXITY_LABEL[t.complexity], t.title, t.id, nameOf.get(t.projectId) ?? t.projectId, ]), ); } // ---------- import-todo(同步引擎在 daemon 侧:src/sync/todo-sync.ts,经 POST /api/projects/:id/sync 调用) ---------- interface SyncResult { created: number; doneAdvanced: number; skipped: number; warnings: string[]; lastSyncAt: string; } /** * 导入 = 首次同步:项目(按 repoPath 匹配)不存在则先创建,然后触发一次 sync。 * 数据固定读 <repoPath>/todo/todo.json;幂等,可重复执行做增量同步。 */ async function cmdImportTodo(rest: string[]): Promise<void> { const { values, positionals } = parseCmdArgs(rest, { repo: { type: 'string' }, name: { type: 'string' }, }); // 兼容旧用法 `import-todo <todo.json 路径> --repo <仓库路径>`:--repo 优先;否则把位置参数当 repoPath const repoArg = (values.repo as string | undefined) ?? positionals[0]; if (!repoArg) { fail('用法:maestro import-todo <仓库路径> [--name 项目名](同步引擎固定读 <仓库路径>/todo/todo.json)'); } const repoPath = resolve(repoArg); if (values.repo && positionals[0]) { const legacyFile = resolve(positionals[0]); const expected = resolve(repoPath, 'todo', 'todo.json'); if (legacyFile !== expected) { console.log(`提示:同步引擎固定读 ${expected},忽略指定的 ${legacyFile}`); } } // 项目不存在则建(按 repoPath 匹配),存在则直接增量同步 const projects = await api<Project[]>('GET', '/api/projects'); let project = projects.find((p) => p.repoPath === repoPath); if (project) { console.log(`项目「${project.name}」(${project.id}) 已存在,执行增量同步…\n`); } else { const name = (values.name as string | undefined) ?? basename(repoPath); project = await api<Project>('POST', '/api/projects', { name, repoPath }); console.log(`已创建项目「${project.name}」(${project.id}),开始首次同步…\n`); } const r = await api<SyncResult>('POST', `/api/projects/${project.id}/sync`); console.log('同步完成:'); console.log(` 新建 ${r.created} 条 · 推到 done ${r.doneAdvanced} 条 · 跳过(已映射)${r.skipped} 条 · 警告 ${r.warnings.length} 条`); for (const w of r.warnings) console.log(` ⚠ ${w}`); console.log(` 本次同步时间:${r.lastSyncAt}`); console.log(`\n查看结果:maestro task list ${project.name}`); } // ---------- 入口 ---------- async function main(): Promise<void> { const argv = process.argv.slice(2); const cmd = argv[0]; if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { console.log(USAGE); return; } if (cmd === 'project') { const sub = argv[1]; if (sub === 'add') return cmdProjectAdd(argv.slice(2)); if (sub === 'list') return cmdProjectList(); fail(`未知子命令:project ${sub ?? ''}\n\n${USAGE}`); } if (cmd === 'task') { const sub = argv[1]; if (sub === 'list') return cmdTaskList(argv.slice(2)); if (sub === 'add') return cmdTaskAdd(argv.slice(2)); fail(`未知子命令:task ${sub ?? ''}\n\n${USAGE}`); } if (cmd === 'next') return cmdNext(argv.slice(1)); if (cmd === 'approvals') return cmdApprovals(argv.slice(1)); if (cmd === 'import-todo') return cmdImportTodo(argv.slice(1)); fail(`未知命令:${cmd}\n\n${USAGE}`); } main().catch((err) => { if (err instanceof ApiError) fail(err.message); console.error('执行失败:', err); process.exit(1); });