Files
Rune-Spec/src/cli.ts

312 lines
10 KiB
TypeScript
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 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);
}