Compare commits
9 Commits
bafbbaa01e
...
03b0c60fb6
| Author | SHA1 | Date | |
|---|---|---|---|
| 03b0c60fb6 | |||
| c924e17253 | |||
| 92dbffbec0 | |||
| 11819a270b | |||
| c4511ca825 | |||
| c63912dc0d | |||
| 1fbec93d55 | |||
| 50456188a0 | |||
| dab63975f5 |
@@ -22,3 +22,4 @@
|
||||
- 单个文件或目录只分配给一个 subagent,不重复分配;subagent 输出文件路径、行号和问题摘要,不输出大段源码
|
||||
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
|
||||
- 优先使用提问工具对用户确认
|
||||
- 禁止提交docs/superpowers目录和目录下的设计文档,不需要留存
|
||||
|
||||
@@ -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 — 纯单元/集成测试(当前)
|
||||
|
||||
@@ -33,6 +33,14 @@ bunx rune init opencode
|
||||
rune status
|
||||
```
|
||||
|
||||
### 帮助与版本
|
||||
|
||||
```bash
|
||||
rune help # 显示全局帮助
|
||||
rune help <command> # 显示指定命令的详细帮助
|
||||
rune version # 显示版本号
|
||||
```
|
||||
|
||||
### 自定义配置
|
||||
|
||||
编辑 `.rune/config.yaml` 自定义提示词和文档模板。配置文件默认为空,使用内置默认策略;仅覆盖需要自定义的阶段,未配置的阶段使用内置默认配置。
|
||||
|
||||
@@ -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 变更的状态和任务进度
|
||||
---
|
||||
|
||||
# 状态查看
|
||||
|
||||
149
src/cli.ts
149
src/cli.ts
@@ -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
26
src/cli/errors.ts
Normal 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
138
src/cli/help.ts
Normal 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
20
src/cli/output.ts
Normal 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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
55
tests/cli/errors.test.ts
Normal 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
50
tests/cli/help.test.ts
Normal 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
49
tests/cli/output.test.ts
Normal 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("错误: 发生了未预期的错误");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,6 @@ describe("loadConfig", () => {
|
||||
|
||||
describe("getRuneDir", () => {
|
||||
it("返回 .rune 目录路径", () => {
|
||||
expect(getRuneDir("/project")).toBe("/project/.rune");
|
||||
expect(getRuneDir("/project")).toBe(join("/project", ".rune"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user