Compare commits

...

9 Commits

15 changed files with 557 additions and 60 deletions

View File

@@ -22,3 +22,4 @@
- 单个文件或目录只分配给一个 subagent不重复分配subagent 输出文件路径、行号和问题摘要,不输出大段源码
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
- 优先使用提问工具对用户确认
- 禁止提交docs/superpowers目录和目录下的设计文档不需要留存

View File

@@ -13,6 +13,10 @@
```
src/
├── cli.ts # CLI 入口
├── cli/
│ ├── errors.ts # CLI 错误定义CliError 层级)
│ ├── output.ts # 格式化输出(错误/用法/提示)
│ └── help.ts # 帮助文本生成
├── types.ts # 类型定义
├── commands/
│ └── init.ts # init 命令
@@ -36,8 +40,29 @@ tests/ # 测试目录(镜像 src 结构)
bun test # 运行全部测试
bun test tests/core/ # 运行指定目录测试
bun src/cli.ts init opencode # 测试 init 命令
bun src/cli.ts help # 查看全局帮助
bun src/cli.ts help init # 查看 init 命令帮助
bun src/cli.ts version # 查看版本号
```
## CLI 交互架构
### 子命令
CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version` 标志:
- `rune help` — 显示全局帮助(可用命令列表)
- `rune help <command>` — 显示指定命令的详细用法
- `rune version` — 显示版本号
### 错误处理
错误消息采用结构化格式,相关代码位于:
- `src/cli/errors.ts``CliError` 错误层级(未知命令、缺少参数等)
- `src/cli/output.ts` — 格式化输出(`错误:``用法:``提示:` 三段式)
- `src/cli/help.ts` — 帮助文本生成
## 测试策略
### Level 1 — 纯单元/集成测试(当前)

View File

@@ -33,6 +33,14 @@ bunx rune init opencode
rune status
```
### 帮助与版本
```bash
rune help # 显示全局帮助
rune help <command> # 显示指定命令的详细帮助
rune version # 显示版本号
```
### 自定义配置
编辑 `.rune/config.yaml` 自定义提示词和文档模板。配置文件默认为空,使用内置默认策略;仅覆盖需要自定义的阶段,未配置的阶段使用内置默认配置。

View File

@@ -17,9 +17,9 @@ export async function injectOpenCode(projectRoot: string): Promise<void> {
await writeFile(commandPath, generateCommand(stage, hasChangeName));
}
const skillDir = join(projectRoot, SKILLS_DIR);
await mkdir(skillDir, { recursive: true });
const skillPath = join(skillDir, `rune-${stage}.md`);
const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
await mkdir(skillStageDir, { recursive: true });
const skillPath = join(skillStageDir, "SKILL.md");
if (!existsSync(skillPath)) {
await writeFile(skillPath, generateSkill(stage, hasChangeName));
}
@@ -31,8 +31,9 @@ export async function injectOpenCode(projectRoot: string): Promise<void> {
await writeFile(statusCommandPath, generateStatusCommand());
}
const skillDir = join(projectRoot, SKILLS_DIR);
const statusSkillPath = join(skillDir, "rune-status.md");
const statusSkillDir = join(projectRoot, SKILLS_DIR, "rune-status");
await mkdir(statusSkillDir, { recursive: true });
const statusSkillPath = join(statusSkillDir, "SKILL.md");
if (!existsSync(statusSkillPath)) {
await writeFile(statusSkillPath, generateStatusSkill());
}
@@ -54,9 +55,16 @@ function generateSkill(stage: string, hasChangeName: boolean): string {
const nameHint = hasChangeName
? `将 <变更名> 替换为实际的变更名称。\n`
: "";
const descriptionMap: Record<string, string> = {
discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案",
plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单",
build: "Use when 需要进入 SDD 构建阶段,按任务清单逐步实现变更",
archive: "Use when 需要进入 SDD 归档阶段,确认变更完成并归档",
};
return `---
description: Rune SDD ${stage} 阶段
name: rune-${stage}
description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶段`}
---
# ${stage} 阶段
@@ -78,7 +86,8 @@ function generateStatusCommand(): string {
function generateStatusSkill(): string {
return `---
description: 查看所有 Rune 变更状态
name: rune-status
description: Use when 需要查看当前所有 Rune 变更的状态和任务进度
---
# 状态查看

View File

@@ -2,6 +2,7 @@
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 {
@@ -11,14 +12,50 @@ import {
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";
function requireProjectRoot(): string {
const root = findProjectRoot();
if (!root) {
throw new ConfigError("当前项目未初始化", { hint: "请先运行 rune init" });
}
return root;
}
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) {
console.error("请指定至少一个工具rune init opencode");
process.exit(1);
throw new UsageError("请指定至少一个工具", {
usage: "rune init <工具...>",
hint: "如rune init opencode",
});
}
await runInit(process.cwd(), tools);
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
@@ -26,66 +63,58 @@ cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(
);
cli.command("discuss", "讨论阶段").action(async () => {
const root = findProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
}
const root = requireProjectRoot();
const config = await loadConfig(root);
const prompt = assembleDiscussPrompt(config);
console.log(prompt);
});
cli
.command("plan <change-name>", "规划阶段")
.action(async (changeName: string) => {
const root = findProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
}
cli.command("plan <change-name>", "规划阶段").action(
async (changeName: string) => {
const root = requireProjectRoot();
await mkdir(getChangeDir(root, changeName), { recursive: true });
const config = await loadConfig(root);
const prompt = await assemblePlanPrompt(config, root, changeName);
console.log(prompt);
});
},
);
cli
.command("build <change-name>", "构建阶段")
.action(async (changeName: string) => {
const root = findProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
cli.command("build <change-name>", "构建阶段").action(
async (changeName: string) => {
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) => {
const root = findProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
cli.command("archive <change-name>", "归档阶段").action(
async (changeName: string) => {
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 = assembleArchivePrompt(config, changeName);
const today = new Date().toISOString().slice(0, 10);
const src = getChangeDir(root, changeName);
const src = changeDir;
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
await rename(src, dest);
console.log(prompt);
});
},
);
cli.command("status", "查看变更状态").action(async () => {
const root = findProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
}
const root = requireProjectRoot();
const changes = await scanChanges(root);
if (changes.length === 0) {
console.log("当前无进行中的变更。");
@@ -99,5 +128,45 @@ cli.command("status", "查看变更状态").action(async () => {
}
});
cli.help();
cli.parse();
function handleError(e: unknown): never {
if (e instanceof CliError) {
printError(e);
} else if (e instanceof Error && e.message.includes("Unknown option")) {
const match = e.message.match(/Unknown option `([^`]+)`/);
const flag = match ? match[1] : "未知选项";
printError(new UsageError(`未知选项: ${flag}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("Unknown command")) {
const match = e.message.match(/Unknown command `([^`]+)`/);
const cmd = match ? match[1] : "未知命令";
printError(new UsageError(`未知命令: ${cmd}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("Unused args")) {
const match = e.message.match(/Unused args: (.+)/);
const args = match ? match[1] : "未知参数";
printError(new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("missing required args")) {
const match = e.message.match(/command `(\w+)/);
const cmd = match ? match[1] : "未知命令";
printError(new UsageError(`命令 '${cmd}' 缺少必填参数`, {
usage: `rune ${cmd} <change-name>`,
hint: `运行 rune help ${cmd} 查看用法`,
}));
} else {
printError(new InternalError());
}
}
process.on("unhandledRejection", (e) => {
handleError(e);
});
try {
cli.parse();
} catch (e) {
handleError(e);
}

26
src/cli/errors.ts Normal file
View File

@@ -0,0 +1,26 @@
export class CliError extends Error {
readonly hint?: string;
readonly usage?: string;
constructor(
message: string,
opts?: { hint?: string; usage?: string },
) {
super(message);
this.name = this.constructor.name;
this.hint = opts?.hint;
this.usage = opts?.usage;
}
}
export class UsageError extends CliError {}
export class ConfigError extends CliError {}
export class CommandError extends CliError {}
export class InternalError extends CliError {
constructor() {
super("发生了未预期的错误");
}
}

138
src/cli/help.ts Normal file
View File

@@ -0,0 +1,138 @@
interface CommandHelpDef {
name: string;
alias: string;
description: string;
usage: string;
args: { name: string; desc: string }[];
detail: string;
examples: string[];
}
const COMMANDS: Record<string, CommandHelpDef> = {
init: {
name: "init",
alias: "init <工具...>",
description: "初始化 Rune 并注入工具配置",
usage: "rune init <工具...>",
args: [{ name: "<工具...>", desc: "要注入的 AI 工具,如 opencode、claude-code" }],
detail: "在当前项目中创建 .rune 目录结构,并注入指定 AI 工具的 command 和 skill 配置文件。",
examples: [
"rune init opencode",
"rune init opencode claude-code",
],
},
discuss: {
name: "discuss",
alias: "discuss",
description: "讨论:生成讨论阶段提示词",
usage: "rune discuss",
args: [],
detail: "生成 SDD 流程中讨论阶段的提示词,输出到标准输出。需要先运行 rune init 初始化项目。",
examples: ["rune discuss"],
},
plan: {
name: "plan",
alias: "plan <名称>",
description: "规划:生成规划阶段提示词",
usage: "rune plan <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
detail: "生成规划阶段的提示词。变更目录将创建在 .rune/changes/<change-name>/ 下。",
examples: [
"rune plan add-user-auth",
"rune plan fix-memory-leak",
],
},
build: {
name: "build",
alias: "build <名称>",
description: "构建:生成构建阶段提示词",
usage: "rune build <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
detail: "生成构建阶段的提示词。变更目录需已存在(通过 rune plan 创建)。",
examples: [
"rune build add-user-auth",
"rune build fix-memory-leak",
],
},
archive: {
name: "archive",
alias: "archive <名称>",
description: "归档:归档变更并生成提示词",
usage: "rune archive <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
detail: "将变更目录从 .rune/changes/ 移动到 .rune/archive/,并生成归档阶段提示词。",
examples: [
"rune archive add-user-auth",
"rune archive fix-memory-leak",
],
},
status: {
name: "status",
alias: "status",
description: "查看:列出当前进行中的变更",
usage: "rune status",
args: [],
detail: "扫描 .rune/changes/ 目录,列出所有进行中的变更及其任务进度。",
examples: ["rune status"],
},
};
export function showGlobalHelp(): string {
const lines: string[] = [
"Rune — 基于规格驱动开发SDD的 AI 开发辅助工具",
"",
"用法:",
" rune <命令> [参数]",
"",
"命令:",
];
const entries = Object.values(COMMANDS);
const maxAliasLen = Math.max(...entries.map((c) => c.alias.length));
for (const cmd of entries) {
lines.push(` ${cmd.alias.padEnd(maxAliasLen)} ${cmd.description}`);
}
lines.push(` ${"help".padEnd(maxAliasLen)} 显示帮助信息`);
lines.push(` ${"version".padEnd(maxAliasLen)} 显示版本号`);
lines.push("");
lines.push("示例:");
lines.push(" rune init opencode 初始化并注入 OpenCode 配置");
lines.push(" rune plan add-login 开始规划 \"add-login\" 变更");
lines.push(" rune status 查看当前变更状态");
return lines.join("\n");
}
export function showCommandHelp(name: string): string | null {
const cmd = COMMANDS[name];
if (!cmd) return null;
const lines: string[] = [
`rune ${cmd.name}${cmd.description}`,
"",
"用法:",
` ${cmd.usage}`,
];
if (cmd.args.length > 0) {
lines.push("");
lines.push("参数:");
for (const arg of cmd.args) {
lines.push(` ${arg.name} ${arg.desc}`);
}
}
lines.push("");
lines.push("描述:");
lines.push(` ${cmd.detail}`);
lines.push("");
lines.push("示例:");
for (const ex of cmd.examples) {
lines.push(` ${ex}`);
}
return lines.join("\n");
}

20
src/cli/output.ts Normal file
View File

@@ -0,0 +1,20 @@
import { CliError } from "./errors.ts";
export function formatError(error: CliError): string {
const parts: string[] = [`错误: ${error.message}`];
if (error.usage) {
parts.push(`用法: ${error.usage}`);
}
if (error.hint) {
parts.push(`提示: ${error.hint}`);
}
return parts.join("\n\n");
}
export function printError(error: CliError): never {
process.stderr.write(formatError(error) + "\n");
process.exit(1);
}

View File

@@ -4,6 +4,38 @@ import { join } from "node:path";
import { CHANGES_DIR, ARCHIVE_DIR, RUNE_DIR, CONFIG_FILE } from "../types.ts";
import { injectOpenCode } from "../adapters/opencode.ts";
import { injectClaudeCode } from "../adapters/claude-code.ts";
import { CommandError } from "../cli/errors.ts";
const CONFIG_TEMPLATE = `# Rune 配置文件
#
# 未配置的阶段将使用内置默认配置。
# 阶段顺序discuss -> plan -> build -> archive
#
# 可配置阶段:
# discuss - 讨论阶段:自由讨论需求和架构
# plan - 规划阶段:生成设计文档和任务清单
# build - 构建阶段:按任务清单逐步实现
# archive - 归档阶段:确认完成并归档变更
#
# 示例 - 自定义讨论阶段提示词:
# stages:
# discuss:
# prompt: |
# 你是一位资深软件架构师...
#
# 示例 - 自定义规划阶段的文档模板:
# stages:
# plan:
# documents:
# - name: design
# prompt: 生成设计文档
# template: |
# # {{change-name}} 设计文档
# - name: task
# prompt: 生成任务清单
# template: |
# # {{change-name}} 任务清单
`;
const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
opencode: injectOpenCode,
@@ -16,7 +48,9 @@ export async function runInit(
): Promise<void> {
for (const tool of tools) {
if (!SUPPORTED_TOOLS[tool]) {
throw new Error(`不支持的工具: ${tool}`);
throw new CommandError(`不支持的工具: ${tool}`, {
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
});
}
}
@@ -27,7 +61,7 @@ export async function runInit(
const configPath = join(runeDir, CONFIG_FILE);
if (!existsSync(configPath)) {
await writeFile(configPath, "stages: {}\n", "utf-8");
await writeFile(configPath, CONFIG_TEMPLATE, "utf-8");
}
for (const tool of tools) {

View File

@@ -23,7 +23,10 @@ describe("injectOpenCode", () => {
for (const stage of ["discuss", "plan", "build", "archive"]) {
expect(commands).toContain(`rune-${stage}.md`);
expect(skills).toContain(`rune-${stage}.md`);
expect(skills).toContain(`rune-${stage}`);
expect(
existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md")),
).toBe(true);
}
});
@@ -34,7 +37,10 @@ describe("injectOpenCode", () => {
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
expect(commands).toContain("rune-status.md");
expect(skills).toContain("rune-status.md");
expect(skills).toContain("rune-status");
expect(
existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md")),
).toBe(true);
});
it("command 文件包含 skill 调用指令", async () => {
@@ -49,11 +55,12 @@ describe("injectOpenCode", () => {
it("skill 文件包含 bash 命令", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"),
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
"utf-8",
);
expect(content).toContain("rune discuss");
expect(content).toContain("description");
expect(content).toContain("name: rune-discuss");
});
it("plan/build/archive skill 包含变更名称参数提示", async () => {
@@ -61,7 +68,7 @@ describe("injectOpenCode", () => {
for (const stage of ["plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", `rune-${stage}.md`),
join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"),
"utf-8",
);
expect(content).toContain("变更名");

55
tests/cli/errors.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "bun:test";
import {
CliError,
UsageError,
ConfigError,
CommandError,
InternalError,
} from "../../src/cli/errors.ts";
describe("CliError 类层次", () => {
it("UsageError 携带 message 和可选 hint", () => {
const err = new UsageError("缺少参数", { hint: "请提供参数" });
expect(err).toBeInstanceOf(CliError);
expect(err).toBeInstanceOf(UsageError);
expect(err.message).toBe("缺少参数");
expect(err.hint).toBe("请提供参数");
expect(err.usage).toBeUndefined();
});
it("UsageError 携带 message、hint、usage", () => {
const err = new UsageError("缺少参数", {
hint: "请提供参数",
usage: "rune plan <change-name>",
});
expect(err.usage).toBe("rune plan <change-name>");
});
it("ConfigError 只带 message", () => {
const err = new ConfigError("未初始化");
expect(err).toBeInstanceOf(CliError);
expect(err).toBeInstanceOf(ConfigError);
expect(err.message).toBe("未初始化");
expect(err.hint).toBeUndefined();
});
it("CommandError 带 message 和 hint", () => {
const err = new CommandError("变更不存在", { hint: "请先运行 rune plan" });
expect(err).toBeInstanceOf(CliError);
expect(err.message).toBe("变更不存在");
expect(err.hint).toBe("请先运行 rune plan");
});
it("InternalError 用默认 message", () => {
const err = new InternalError();
expect(err).toBeInstanceOf(CliError);
expect(err.message).toBe("发生了未预期的错误");
});
it("可以用 name 区分错误类型", () => {
expect(new UsageError("a").name).toBe("UsageError");
expect(new ConfigError("b").name).toBe("ConfigError");
expect(new CommandError("c").name).toBe("CommandError");
expect(new InternalError().name).toBe("InternalError");
});
});

50
tests/cli/help.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "bun:test";
import { showGlobalHelp, showCommandHelp } from "../../src/cli/help.ts";
describe("showGlobalHelp", () => {
it("包含所有命令行", () => {
const output = showGlobalHelp();
expect(output).toContain("rune <命令> [参数]");
expect(output).toContain("init <工具...>");
expect(output).toContain("discuss");
expect(output).toContain("plan <名称>");
expect(output).toContain("build <名称>");
expect(output).toContain("archive <名称>");
expect(output).toContain("status");
expect(output).toContain("help");
expect(output).toContain("version");
});
it("包含示例", () => {
const output = showGlobalHelp();
expect(output).toContain("rune init opencode");
expect(output).toContain("rune plan add-login");
expect(output).toContain("rune status");
});
it("以标题行开头", () => {
const output = showGlobalHelp();
expect(output.startsWith("Rune")).toBe(true);
});
});
describe("showCommandHelp", () => {
it("plan 命令包含用法、参数、描述、示例", () => {
const output = showCommandHelp("plan");
expect(output).toContain("rune plan <change-name>");
expect(output).toContain("<change-name>");
expect(output).toContain("规划阶段");
expect(output).toContain("rune plan add-user-auth");
});
it("init 命令包含工具参数说明", () => {
const output = showCommandHelp("init");
expect(output).toContain("rune init <工具...>");
expect(output).toContain("opencode");
});
it("不存在的命令返回 null", () => {
const output = showCommandHelp("nonexistent");
expect(output).toBeNull();
});
});

49
tests/cli/output.test.ts Normal file
View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "bun:test";
import { formatError } from "../../src/cli/output.ts";
import {
UsageError,
ConfigError,
CommandError,
InternalError,
} from "../../src/cli/errors.ts";
describe("formatError", () => {
it("只输出错误行(无 hint/usage", () => {
const err = new ConfigError("未初始化");
const output = formatError(err);
expect(output).toBe("错误: 未初始化");
});
it("输出错误行 + 提示行", () => {
const err = new ConfigError("未初始化", { hint: "请先运行 rune init" });
const output = formatError(err);
expect(output).toBe("错误: 未初始化\n\n提示: 请先运行 rune init");
});
it("输出错误行 + 用法行", () => {
const err = new UsageError("缺少参数", {
usage: "rune plan <change-name>",
});
const output = formatError(err);
expect(output).toBe(
"错误: 缺少参数\n\n用法: rune plan <change-name>",
);
});
it("输出完整格式(错误 + 用法 + 提示)", () => {
const err = new UsageError("缺少必填参数 <change-name>", {
usage: "rune plan <change-name>",
hint: "请指定变更名称,如 \"add-login\"",
});
const output = formatError(err);
expect(output).toBe(
'错误: 缺少必填参数 <change-name>\n\n用法: rune plan <change-name>\n\n提示: 请指定变更名称,如 "add-login"',
);
});
it("InternalError 输出固定消息", () => {
const err = new InternalError();
const output = formatError(err);
expect(output).toBe("错误: 发生了未预期的错误");
});
});

View File

@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { mkdir, rm, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { runInit } from "../../src/commands/init.ts";
import { CommandError } from "../../src/cli/errors.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_init_test__");
@@ -25,7 +26,8 @@ describe("runInit", () => {
join(TMP_DIR, ".rune", "config.yaml"),
"utf-8",
);
expect(content.trim()).toBe("stages: {}");
expect(content).toContain("# Rune 配置文件");
expect(content).toContain("stages:");
});
it("创建 .rune/changes 和 .rune/archive 目录", async () => {
@@ -39,7 +41,7 @@ describe("runInit", () => {
await runInit(TMP_DIR, ["opencode"]);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"))).toBe(true);
});
it("重复 init 不覆盖 config.yaml", async () => {
@@ -57,9 +59,13 @@ describe("runInit", () => {
expect(content).toBe("自定义内容");
});
it("不支持的工具名抛出错误", async () => {
await expect(runInit(TMP_DIR, ["unknown-tool"])).rejects.toThrow(
"不支持的工具",
);
it("不支持的工具名抛出 CommandError", async () => {
try {
await runInit(TMP_DIR, ["unknown-tool"]);
expect.unreachable("应抛出错误");
} catch (e) {
expect(e).toBeInstanceOf(CommandError);
expect((e as CommandError).message).toContain("不支持的工具: unknown-tool");
}
});
});

View File

@@ -100,6 +100,6 @@ describe("loadConfig", () => {
describe("getRuneDir", () => {
it("返回 .rune 目录路径", () => {
expect(getRuneDir("/project")).toBe("/project/.rune");
expect(getRuneDir("/project")).toBe(join("/project", ".rune"));
});
});