312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
#!/usr/bin/env bun
|
||
import { cac } from "cac";
|
||
import { join } from "node:path";
|
||
import { mkdir, rename } from "node:fs/promises";
|
||
import { readFileSync, existsSync } from "node:fs";
|
||
import { runInit } from "./commands/init.ts";
|
||
import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
|
||
import {
|
||
assembleDiscussPrompt,
|
||
assemblePlanPrompt,
|
||
assembleBuildPrompt,
|
||
assembleArchivePrompt,
|
||
} from "./core/assembler.ts";
|
||
import { scanChanges } from "./core/scanner.ts";
|
||
import { UsageError, ConfigError, CommandError, InternalError, CliError } from "./cli/errors.ts";
|
||
import { printError } from "./cli/output.ts";
|
||
import { showGlobalHelp, showCommandHelp } from "./cli/help.ts";
|
||
import type { ChangeStatus, RuneConfig } from "./types.ts";
|
||
|
||
function requireProjectRoot(): string {
|
||
const root = findProjectRoot();
|
||
if (!root) {
|
||
throw new ConfigError("当前项目未初始化", { hint: "请先运行 rune init" });
|
||
}
|
||
return root;
|
||
}
|
||
|
||
export function validateChangeName(name: string): void {
|
||
if (!/^[\u4e00-\u9fa5a-zA-Z-]+$/.test(name)) {
|
||
throw new CommandError(`变更名 "${name}" 包含不支持的字符`, {
|
||
hint: "变更名仅支持中文、英文和短横线(-)",
|
||
});
|
||
}
|
||
}
|
||
|
||
export function formatChangeStatus(change: ChangeStatus, config?: RuneConfig): string {
|
||
const lines: string[] = [];
|
||
lines.push(`变更:${change.name}`);
|
||
|
||
lines.push(" 规划阶段:");
|
||
const planDocs = config?.stages.plan?.documents;
|
||
for (const doc of change.documents) {
|
||
if (doc.completed) {
|
||
lines.push(` ${doc.name}.md ✓ 已完成`);
|
||
} else {
|
||
const docConfig = planDocs?.find((d) => d.name === doc.name);
|
||
const depInfo =
|
||
!doc.dependMet && docConfig?.depend?.length
|
||
? `(依赖 ${docConfig.depend.map((d) => `${d}.md`).join("、")})`
|
||
: "";
|
||
lines.push(` ${doc.name}.md ○ 待完成${depInfo}`);
|
||
}
|
||
}
|
||
|
||
const completedCount = change.documents.filter((d) => d.completed).length;
|
||
lines.push(` 规划进度:${completedCount}/${change.documents.length} 文档已完成`);
|
||
|
||
if (change.buildUnlocked) {
|
||
lines.push(" 构建阶段:已解锁");
|
||
} else {
|
||
lines.push(" 构建阶段:未解锁(需完成规划)");
|
||
}
|
||
|
||
if (change.taskProgress) {
|
||
lines.push(` 任务进度:${change.taskProgress.completed}/${change.taskProgress.total} 已完成`);
|
||
}
|
||
|
||
lines.push("");
|
||
lines.push(` 建议下一步:${suggestNextStep(change)}`);
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
export function suggestNextStep(change: ChangeStatus): string {
|
||
if (!change.planCompleted) {
|
||
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
|
||
if (nextDoc) {
|
||
return `rune plan ${change.name} ${nextDoc.name}`;
|
||
}
|
||
return `完成前置依赖后再规划文档`;
|
||
}
|
||
|
||
if (change.taskProgress && change.taskProgress.completed < change.taskProgress.total) {
|
||
return `rune build ${change.name}`;
|
||
}
|
||
|
||
if (change.taskProgress && change.taskProgress.completed === change.taskProgress.total) {
|
||
return `rune archive ${change.name}`;
|
||
}
|
||
|
||
return `rune build ${change.name}`;
|
||
}
|
||
|
||
const cli = cac("rune");
|
||
|
||
cli.command("", "").action(() => {
|
||
console.log(showGlobalHelp());
|
||
});
|
||
|
||
cli.command("help [command]", "显示帮助信息").action((command?: string) => {
|
||
if (command) {
|
||
const output = showCommandHelp(command);
|
||
if (!output) {
|
||
throw new UsageError(`未知命令: ${command}`, {
|
||
hint: "运行 rune help 查看所有命令",
|
||
});
|
||
}
|
||
console.log(output);
|
||
} else {
|
||
console.log(showGlobalHelp());
|
||
}
|
||
});
|
||
|
||
cli.command("version", "显示版本号").action(() => {
|
||
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "../package.json"), "utf-8"));
|
||
console.log(`rune v${pkg.version}`);
|
||
});
|
||
|
||
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(async (tools: string[]) => {
|
||
if (!tools || tools.length === 0) {
|
||
throw new UsageError("请指定至少一个工具", {
|
||
usage: "rune init <工具...>",
|
||
hint: "如:rune init opencode",
|
||
});
|
||
}
|
||
await runInit(process.cwd(), tools);
|
||
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
|
||
});
|
||
|
||
cli.command("update [...tools]", "更新已注入的工具配置").action(async (tools: string[]) => {
|
||
if (!tools || tools.length === 0) {
|
||
throw new UsageError("请指定至少一个工具", {
|
||
usage: "rune update <工具...>",
|
||
hint: "如:rune update opencode",
|
||
});
|
||
}
|
||
const root = requireProjectRoot();
|
||
const { updateOpenCode } = await import("./adapters/opencode.ts");
|
||
const { updateClaudeCode } = await import("./adapters/claude-code.ts");
|
||
const { SUPPORTED_TOOLS } = await import("./commands/init.ts");
|
||
const updaters: Record<string, (root: string) => Promise<void>> = {
|
||
opencode: updateOpenCode,
|
||
"claude-code": updateClaudeCode,
|
||
};
|
||
for (const tool of tools) {
|
||
if (!SUPPORTED_TOOLS[tool]) {
|
||
throw new CommandError(`不支持的工具: ${tool}`, {
|
||
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
|
||
});
|
||
}
|
||
}
|
||
for (const tool of tools) {
|
||
await updaters[tool](root);
|
||
}
|
||
console.log(`工具配置已更新:${tools.join(", ")}`);
|
||
});
|
||
|
||
cli.command("discuss", "讨论阶段").action(async () => {
|
||
const root = requireProjectRoot();
|
||
const config = await loadConfig(root);
|
||
const prompt = assembleDiscussPrompt(config);
|
||
console.log(prompt);
|
||
});
|
||
|
||
cli
|
||
.command("plan <change-name> <document-name>", "规划阶段")
|
||
.action(async (changeName: string, documentName: string) => {
|
||
validateChangeName(changeName);
|
||
const root = requireProjectRoot();
|
||
const config = await loadConfig(root);
|
||
const planDocs = config.stages.plan?.documents;
|
||
if (!planDocs || !planDocs.find((d) => d.name === documentName)) {
|
||
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents 中`, {
|
||
hint: `可用文档:${planDocs?.map((d) => d.name).join(", ") ?? "无"}`,
|
||
});
|
||
}
|
||
|
||
const changeDir = getChangeDir(root, changeName);
|
||
await mkdir(changeDir, { recursive: true });
|
||
|
||
const doc = planDocs.find((d) => d.name === documentName)!;
|
||
if (doc.depend && doc.depend.length > 0) {
|
||
const missing = doc.depend.filter((dep) => !existsSync(join(changeDir, `${dep}.md`)));
|
||
if (missing.length > 0) {
|
||
throw new CommandError(
|
||
`文档 "${documentName}" 的前置依赖未满足:${missing.map((d) => `${d}.md`).join("、")} 尚未完成`,
|
||
{
|
||
hint: `请先完成依赖文档:rune plan ${changeName} ${missing[0]}`,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
const prompt = await assemblePlanPrompt(config, root, changeName, documentName);
|
||
console.log(prompt);
|
||
});
|
||
|
||
cli.command("build <change-name>", "构建阶段").action(async (changeName: string) => {
|
||
validateChangeName(changeName);
|
||
const root = requireProjectRoot();
|
||
const changeDir = getChangeDir(root, changeName);
|
||
if (!existsSync(changeDir)) {
|
||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
||
hint: `请先运行 rune plan ${changeName} 创建变更`,
|
||
});
|
||
}
|
||
const config = await loadConfig(root);
|
||
const prompt = await assembleBuildPrompt(config, root, changeName);
|
||
console.log(prompt);
|
||
});
|
||
|
||
cli.command("archive <change-name>", "归档阶段").action(async (changeName: string) => {
|
||
validateChangeName(changeName);
|
||
const root = requireProjectRoot();
|
||
const changeDir = getChangeDir(root, changeName);
|
||
if (!existsSync(changeDir)) {
|
||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
||
hint: `请先运行 rune plan ${changeName} 创建变更`,
|
||
});
|
||
}
|
||
const config = await loadConfig(root);
|
||
const prompt = await assembleArchivePrompt(config, root, changeName);
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const src = changeDir;
|
||
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
|
||
await rename(src, dest);
|
||
console.log(prompt);
|
||
});
|
||
|
||
cli.command("status [change-name]", "查看变更状态").action(async (changeName?: string) => {
|
||
const root = requireProjectRoot();
|
||
const config = await loadConfig(root);
|
||
const changes = await scanChanges(root, config);
|
||
|
||
if (changeName) {
|
||
const change = changes.find((c) => c.name === changeName);
|
||
if (!change) {
|
||
throw new CommandError(`变更 "${changeName}" 不存在`, {
|
||
hint: "运行 rune status 查看所有变更",
|
||
});
|
||
}
|
||
console.log(formatChangeStatus(change, config));
|
||
} else {
|
||
if (changes.length === 0) {
|
||
console.log("当前无进行中的变更。");
|
||
return;
|
||
}
|
||
for (const change of changes) {
|
||
console.log(formatChangeStatus(change, config));
|
||
console.log("---\n");
|
||
}
|
||
}
|
||
});
|
||
|
||
export function mapError(e: unknown): CliError {
|
||
if (e instanceof CliError) {
|
||
return e;
|
||
}
|
||
if (e instanceof Error) {
|
||
const err = mapCacError(e);
|
||
if (err) return err;
|
||
}
|
||
return new InternalError();
|
||
}
|
||
|
||
function mapCacError(e: Error): CliError | null {
|
||
if (e.message.includes("Unknown option")) {
|
||
const match = e.message.match(/Unknown option `([^`]+)`/);
|
||
const flag = match ? match[1] : "未知选项";
|
||
return new UsageError(`未知选项: ${flag}`, {
|
||
hint: "运行 rune help 查看所有命令",
|
||
});
|
||
}
|
||
if (e.message.includes("Unknown command")) {
|
||
const match = e.message.match(/Unknown command `([^`]+)`/);
|
||
const cmd = match ? match[1] : "未知命令";
|
||
return new UsageError(`未知命令: ${cmd}`, {
|
||
hint: "运行 rune help 查看所有命令",
|
||
});
|
||
}
|
||
if (e.message.includes("Unused args")) {
|
||
const match = e.message.match(/Unused args: (.+)/);
|
||
const args = match ? match[1] : "未知参数";
|
||
return new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
|
||
hint: "运行 rune help 查看所有命令",
|
||
});
|
||
}
|
||
if (e.message.includes("missing required args")) {
|
||
const match = e.message.match(/command `(\w+)/);
|
||
const cmd = match ? match[1] : "未知命令";
|
||
return new UsageError(`命令 '${cmd}' 缺少必填参数`, {
|
||
usage: `rune ${cmd} <change-name>`,
|
||
hint: `运行 rune help ${cmd} 查看用法`,
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function handleError(e: unknown): never {
|
||
printError(mapError(e));
|
||
}
|
||
|
||
process.on("unhandledRejection", (e) => {
|
||
handleError(e);
|
||
});
|
||
|
||
try {
|
||
cli.parse();
|
||
} catch (e) {
|
||
handleError(e);
|
||
}
|