Files
maestro/src/cli/index.ts
T
wangjia fa472a06a7 feat: 同步引擎+Agent配置+依赖自动落位+看板大改(预览/md渲染/筛选/徽章组)
后端:
- src/sync/todo-sync.ts: todo.json 单向同步引擎(导入=首次同步,幂等,source_ref 映射,
  subs 复杂度修正为 easy,旧侧 done 历史事实优先 forceDone)
- 依赖自动落位:ready 意图按 deps 落位 blocked,依赖全 done 自动放行,
  daemon 启动 reconcileDeps 对账,手动绕过会弹回
- 新 API: PATCH projects/:id(autonomy/concurrency)、POST :id/sync、GET /api/agents、
  PATCH tasks/:id(title/priority/complexity 重置)
- daemon 定时同步(MAESTRO_SYNC_INTERVAL 默认 300s) + project.synced 事件
- priority 语义翻转: P0 最高/P1 默认/P2 最低,取值限 0..2,排序/映射/MCP/CLI 全跟进
- 静态服务发 no-cache 头(修浏览器吃旧 CSS/JS)
- schema 迁移: tasks.source_ref / projects.last_sync_at(ensureColumn 平滑升级旧库)

看板:
- 全屏预览模式(94vh 读完整方案+就地裁决,Esc/遮罩/裁决自动关闭)
- 产出 markdown 渲染为 HTML(零依赖渲染器,转义优先)
- 任务树筛选(复杂度/状态分组/关键字)+ 顶栏徽章组(待审批/可执行/执行中,hover 展开)
- 依赖可视化:详情 DEPS 区块 + 行内⛓等依赖 + 锚点跳转定位
- 按钮收敛:提交评审/编辑产出移除(CC 经 MCP 操作),界面只留用户动作
- Agent 执行面板 + 项目配置(并发/工作模式)+ 同步按钮

测试:21 个全过(新增 sync 幂等/迁移/patch/依赖落位/对账幂等)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:58:28 +08:00

375 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* maestro CLI — 通过 REST API 操作 maestrod(零新依赖,node:util parseArgs)。
*
* 子命令:
* project add <repoPath> [--name N] [--branch B] [--verify CMD] [--concurrency N]
* project list
* task list <projectIdOrName>
* task add <projectIdOrName> <title> --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|2P0 最高,默认 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/2P0 最高,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);
});