Files
Rune-Spec/src/cli.ts

360 lines
12 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 { getPmPrefix, getFallbackNote, DEFAULT_PREFIX, detectCommandPrefix } from "./core/pm.ts";
import type { ChangeStatus, RuneConfig } from "./types.ts";
function requireProjectRoot(): string {
const root = findProjectRoot();
if (!root) {
const prefix = getPmPrefix();
throw new ConfigError("当前项目未初始化", { hint: `请先运行 ${prefix} 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, config)}`);
return lines.join("\n");
}
export function suggestNextStep(change: ChangeStatus, config?: RuneConfig): string {
const prefix = getPmPrefix(config);
if (!change.planCompleted) {
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
if (nextDoc) {
return `${prefix} plan ${change.name} ${nextDoc.name}`;
}
return `完成前置依赖后再规划文档`;
}
if (change.taskProgress && change.taskProgress.completed < change.taskProgress.total) {
return `${prefix} build ${change.name}`;
}
if (change.taskProgress && change.taskProgress.completed === change.taskProgress.total) {
return `${prefix} archive ${change.name}`;
}
return `${prefix} build ${change.name}`;
}
const cli = cac("rune");
cli.command("", "").action(async () => {
const root = findProjectRoot();
let prefix = getPmPrefix();
if (root) {
try {
const config = await loadConfig(root);
prefix = getPmPrefix(config);
} catch {}
}
console.log(showGlobalHelp(prefix));
});
cli.command("help [command]", "显示帮助信息").action(async (command?: string) => {
const root = findProjectRoot();
let prefix = getPmPrefix();
if (root) {
try {
const config = await loadConfig(root);
prefix = getPmPrefix(config);
} catch {}
}
if (command) {
const output = showCommandHelp(command, prefix);
if (!output) {
throw new UsageError(`未知命令: ${command}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
}
console.log(output);
} else {
console.log(showGlobalHelp(prefix));
}
});
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[]) => {
const prefix = getPmPrefix();
const fallbackNote = getFallbackNote();
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: `${prefix} init <工具...>`,
hint: `如:${prefix} init opencode\n\n${fallbackNote}`,
});
}
await runInit(process.cwd(), tools);
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
});
cli.command("update [...tools]", "更新已注入的工具配置").action(async (tools: string[]) => {
const prefix = getPmPrefix();
const fallbackNote = getFallbackNote();
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: `${prefix} update <工具...>`,
hint: `如:${prefix} update opencode\n\n${fallbackNote}`,
});
}
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, command?: 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(", ")}`,
});
}
}
let config: RuneConfig | undefined;
try {
config = await loadConfig(root);
} catch {}
const command = getPmPrefix(config);
if (!config?.metadata?.command) {
const detected = await detectCommandPrefix();
if (detected) {
const { readFile, appendFile } = await import("node:fs/promises");
const yaml = await import("yaml");
const { join: joinPath } = await import("node:path");
const configPath = joinPath(root, ".rune", "config.yaml");
try {
const content = await readFile(configPath, "utf-8");
const parsed = yaml.parse(content) as { metadata?: { command?: string } } | null;
if (!parsed?.metadata?.command) {
await appendFile(configPath, `\nmetadata:\n command: "${detected}"\n`);
}
} catch {}
}
}
for (const tool of tools) {
await updaters[tool](root, command === DEFAULT_PREFIX ? undefined : command);
}
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: `请先完成依赖文档:${getPmPrefix(config)} 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)) {
const prefix = getPmPrefix();
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 ${prefix} 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)) {
const prefix = getPmPrefix();
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 ${prefix} 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 prefix = getPmPrefix(config);
const changes = await scanChanges(root, config);
if (changeName) {
const change = changes.find((c) => c.name === changeName);
if (!change) {
throw new CommandError(`变更 "${changeName}" 不存在`, {
hint: `运行 ${prefix} 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 {
const prefix = getPmPrefix();
if (e.message.includes("Unknown option")) {
const match = e.message.match(/Unknown option `([^`]+)`/);
const flag = match ? match[1] : "未知选项";
return new UsageError(`未知选项: ${flag}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
}
if (e.message.includes("Unknown command")) {
const match = e.message.match(/Unknown command `([^`]+)`/);
const cmd = match ? match[1] : "未知命令";
return new UsageError(`未知命令: ${cmd}`, {
hint: `运行 ${prefix} 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: `运行 ${prefix} 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: `${prefix} ${cmd} <change-name>`,
hint: `运行 ${prefix} 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);
}