feat: opencode/claude-code 统一 command 格式、智能识别引导、新增 intro/create、移除 status

This commit is contained in:
2026-06-10 13:16:02 +08:00
parent 49f523146f
commit 1f6e49e336
4 changed files with 199 additions and 120 deletions

View File

@@ -6,33 +6,40 @@ import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".claude/commands";
const STAGES_WITH_CHANGE_NAME = new Set(["create", "plan", "build", "archive"]);
function buildSmartGuide(command: string): string {
return `如果用户没有指定变更名称,请按以下步骤智能识别:
1. 运行 \`${command} status\` 查看当前所有变更
2. 如果只有一个变更,直接使用该变更名
3. 如果有多个变更,根据上下文推断最可能的变更
4. 如果无法确定,向用户确认`;
}
export async function injectClaudeCode(
projectRoot: string,
command: string = "rune",
): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const smartGuide = hasChangeName ? `\n${buildSmartGuide(command)}\n` : "";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
if (!existsSync(commandPath)) {
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const nameHint = hasChangeName ? "\n如果用户没有指定变更名称请向用户确认。" : "";
await writeFile(
commandPath,
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`,
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}\n`,
);
}
}
const commandDir = join(projectRoot, COMMANDS_DIR);
const statusPath = join(commandDir, "rune-status.md");
if (!existsSync(statusPath)) {
await writeFile(
statusPath,
`执行以下命令查看变更状态:\n\`\`\`bash\n${command} status\n\`\`\`\n`,
);
const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md");
if (!existsSync(introCommandPath)) {
await mkdir(join(projectRoot, COMMANDS_DIR), { recursive: true });
await writeFile(introCommandPath, generateIntroCommand(command));
}
}
@@ -41,20 +48,36 @@ export async function updateClaudeCode(
command: string = "rune",
): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const smartGuide = hasChangeName ? `\n${buildSmartGuide(command)}\n` : "";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const nameHint = hasChangeName ? "\n如果用户没有指定变更名称请向用户确认。" : "";
const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`;
const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}\n`;
await writeIfChanged(commandPath, newContent);
}
const statusPath = join(projectRoot, COMMANDS_DIR, "rune-status.md");
await writeIfChanged(
statusPath,
`执行以下命令查看变更状态:\n\`\`\`bash\n${command} status\n\`\`\`\n`,
);
const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md");
await writeIfChanged(introCommandPath, generateIntroCommand(command));
}
function generateIntroCommand(command: string): string {
return `Rune 是基于规格驱动开发SDD的 AI 开发辅助工具。SDD 工作流程:
discuss → create → plan → build → archive
可用命令:
- /rune-discuss — 自由讨论需求和方案
- /rune-create — 创建变更目录
- /rune-plan — 生成设计文档和任务清单
- /rune-build — 按任务清单逐步实现
- /rune-archive — 归档已完成的变更
查看当前状态:
\`\`\`bash
${command} status
\`\`\`
`;
}

View File

@@ -7,80 +7,60 @@ import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".opencode/commands";
const SKILLS_DIR = ".opencode/skills";
const STAGES_WITH_CHANGE_NAME = new Set(["create", "plan", "build", "archive"]);
export async function injectOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
if (!existsSync(commandPath)) {
await writeFile(commandPath, generateCommand(stage, hasChangeName));
await writeFile(commandPath, generateCommand(stage));
}
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, command));
await writeFile(skillPath, generateSkill(stage, command));
}
}
const commandDir = join(projectRoot, COMMANDS_DIR);
const statusCommandPath = join(commandDir, "rune-status.md");
if (!existsSync(statusCommandPath)) {
await writeFile(statusCommandPath, generateStatusCommand());
}
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(command));
const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro");
await mkdir(introSkillDir, { recursive: true });
const introSkillPath = join(introSkillDir, "SKILL.md");
if (!existsSync(introSkillPath)) {
await writeFile(introSkillPath, generateIntroSkill(command));
}
}
export async function updateOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
const newCommand = generateCommand(stage, hasChangeName);
await writeIfChanged(commandPath, newCommand);
await writeIfChanged(commandPath, generateCommand(stage));
const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
await mkdir(skillStageDir, { recursive: true });
const skillPath = join(skillStageDir, "SKILL.md");
const newSkill = generateSkill(stage, hasChangeName, command);
await writeIfChanged(skillPath, newSkill);
await writeIfChanged(skillPath, generateSkill(stage, command));
}
const commandDir = join(projectRoot, COMMANDS_DIR);
const statusCommandPath = join(commandDir, "rune-status.md");
await writeIfChanged(statusCommandPath, generateStatusCommand());
const statusSkillDir = join(projectRoot, SKILLS_DIR, "rune-status");
await mkdir(statusSkillDir, { recursive: true });
const statusSkillPath = join(statusSkillDir, "SKILL.md");
await writeIfChanged(statusSkillPath, generateStatusSkill(command));
const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro");
await mkdir(introSkillDir, { recursive: true });
const introSkillPath = join(introSkillDir, "SKILL.md");
await writeIfChanged(introSkillPath, generateIntroSkill(command));
}
function generateCommand(stage: string, hasChangeName: boolean): string {
if (hasChangeName) {
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
如果用户没有指定变更名称,请向用户确认要操作的变更名称。
`;
}
function generateCommand(stage: string): string {
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
`;
}
function generateSkill(stage: string, hasChangeName: boolean, command: string): string {
function generateSkill(stage: string, command: string): string {
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const nameHint = hasChangeName ? `将 <变更名> 替换为实际的变更名称。\n` : "";
let extraGuide = "";
if (stage === "plan") {
@@ -89,11 +69,22 @@ function generateSkill(stage: string, hasChangeName: boolean, command: string):
const descriptionMap: Record<string, string> = {
discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案",
create: "Use when 需要创建变更目录,为 SDD 流程准备变更工作区",
plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单",
build: "Use when 需要进入 SDD 构建阶段,按任务清单逐步实现变更",
archive: "Use when 需要进入 SDD 归档阶段,确认变更完成并归档",
};
let smartGuide = "";
if (hasChangeName) {
smartGuide = `如果用户没有指定变更名称,请按以下步骤智能识别:
1. 运行 \`${command} status\` 查看当前所有变更
2. 如果只有一个变更,直接使用该变更名
3. 如果有多个变更,根据上下文推断最可能的变更
4. 如果无法确定,向用户确认
`;
}
return `---
name: rune-${stage}
description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶段`}
@@ -107,29 +98,49 @@ description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶
${cmd}
\`\`\`
${nameHint}${extraGuide}将命令输出作为工作指引,执行当前阶段的工作。
${smartGuide}${extraGuide}将命令输出作为工作指引,执行当前阶段的工作。
`;
}
function generateStatusCommand(): string {
return `请调用 rune-status skill 查看当前所有变更状态。
`;
}
function generateStatusSkill(command: string): string {
function generateIntroSkill(command: string): string {
return `---
name: rune-status
description: Use when 需要查看当前所有 Rune 变更的状态和任务进度
name: rune-intro
description: Use when 用户首次接触 Rune需要了解 SDD 工作流程和使用方式
---
# 状态查看
# Rune 简介
执行以下命令:
Rune 是基于规格驱动开发SDD的 AI 开发辅助工具。它通过结构化的阶段流程,帮助 AI 编辑器和开发者高效协作。
## SDD 工作流程
\`\`\`
discuss → create → plan → build → archive
探索 创建 规划 构建 归档
\`\`\`
## 可用命令
| 阶段 | 编辑器命令 | 说明 |
|------|-----------|------|
| discuss | /rune-discuss | 自由讨论需求和方案 |
| create | /rune-create | 创建变更目录 |
| plan | /rune-plan | 生成设计文档和任务清单 |
| build | /rune-build | 按任务清单逐步实现 |
| archive | /rune-archive | 归档已完成的变更 |
查看当前状态:
\`\`\`bash
${command} status
\`\`\`
将命令输出展示给用户。
## 快速开始
1. 使用 /rune-discuss 进入讨论,自由探索需求
2. 使用 /rune-create 创建变更目录
3. 使用 /rune-plan 生成设计文档和任务清单
4. 使用 /rune-build 按任务顺序实现功能
5. 使用 /rune-archive 归档已完成的变更
`;
}

View File

@@ -15,20 +15,27 @@ afterEach(async () => {
});
describe("injectClaudeCode", () => {
it("生成 discuss、plan、build、archive 的 command 文件", async () => {
it("生成 discuss、create、plan、build、archive 的 command 文件", async () => {
await injectClaudeCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
for (const stage of ["discuss", "plan", "build", "archive"]) {
for (const stage of ["discuss", "create", "plan", "build", "archive"]) {
expect(commands).toContain(`rune-${stage}.md`);
}
});
it("生成 rune-status command 文件", async () => {
it("生成 rune-status command", async () => {
await injectClaudeCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
expect(commands).toContain("rune-status.md");
expect(commands).not.toContain("rune-status.md");
});
it("生成 rune-intro command", async () => {
await injectClaudeCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
expect(commands).toContain("rune-intro.md");
});
it("command 文件包含 bash 命令", async () => {
@@ -41,26 +48,26 @@ describe("injectClaudeCode", () => {
expect(content).toContain("```bash");
});
it("plan/build/archive command 包含变更名称提示", async () => {
it("create/plan/build/archive command 包含变更名智能识别引导", async () => {
await injectClaudeCode(TMP_DIR);
for (const stage of ["plan", "build", "archive"]) {
for (const stage of ["create", "plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".claude", "commands", `rune-${stage}.md`),
"utf-8",
);
expect(content).toContain("变更名");
expect(content).toContain("如果用户没有指定变更名称");
expect(content).toContain("智能识别");
expect(content).toContain("rune status");
}
});
it("discuss command 不包含变更名称提示", async () => {
it("discuss command 不包含智能识别引导", async () => {
await injectClaudeCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).not.toContain("如果用户没有指定变更名称");
expect(content).not.toContain("智能识别");
});
it("重复注入时不覆盖已存在的文件", async () => {
@@ -113,9 +120,14 @@ describe("updateClaudeCode", () => {
expect(content).toContain("rune discuss");
});
it("更新 status 命令", async () => {
it("更新时生成 rune-intro 命令", async () => {
await updateClaudeCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-status.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-intro.md"))).toBe(true);
});
it("不生成 rune-status 命令", async () => {
await updateClaudeCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-status.md"))).toBe(false);
});
});
@@ -138,9 +150,9 @@ describe("injectClaudeCode with command prefix", () => {
expect(content).toContain("rune discuss");
});
it("status command 使用自定义前缀", async () => {
it("rune-intro command 使用自定义前缀", async () => {
await injectClaudeCode(TMP_DIR, "npx @lanyuanxiaoyao/rune");
const content = await readFile(join(TMP_DIR, ".claude", "commands", "rune-status.md"), "utf-8");
const content = await readFile(join(TMP_DIR, ".claude", "commands", "rune-intro.md"), "utf-8");
expect(content).toContain("npx @lanyuanxiaoyao/rune status");
});
});

View File

@@ -15,13 +15,13 @@ afterEach(async () => {
});
describe("injectOpenCode", () => {
it("生成 discuss、plan、build、archive 的 command 和 skill 文件", async () => {
it("生成 discuss、create、plan、build、archive 的 command 和 skill 文件", async () => {
await injectOpenCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
for (const stage of ["discuss", "plan", "build", "archive"]) {
for (const stage of ["discuss", "create", "plan", "build", "archive"]) {
expect(commands).toContain(`rune-${stage}.md`);
expect(skills).toContain(`rune-${stage}`);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"))).toBe(
@@ -30,47 +30,60 @@ describe("injectOpenCode", () => {
}
});
it("生成 rune-status command 和 skill", async () => {
it("生成 rune-status command 和 skill", async () => {
await injectOpenCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
expect(commands).toContain("rune-status.md");
expect(skills).toContain("rune-status");
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(true);
expect(commands).not.toContain("rune-status.md");
expect(skills).not.toContain("rune-status");
});
it("command 文件包含 skill 调用指令", async () => {
it("生成 rune-intro skill无对应 command", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toContain("rune-discuss");
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
expect(commands).not.toContain("rune-intro.md");
expect(skills).toContain("rune-intro");
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"))).toBe(true);
});
it("skill 文件包含 bash 命令", async () => {
it("command 文件统一格式:只包含 skill 调用指令", async () => {
await injectOpenCode(TMP_DIR);
for (const stage of ["discuss", "create", "plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", `rune-${stage}.md`),
"utf-8",
);
expect(content).toContain(`rune-${stage} skill`);
expect(content).not.toContain("变更名");
}
});
it("create/plan/build/archive skill 包含变更名智能识别引导", async () => {
await injectOpenCode(TMP_DIR);
for (const stage of ["create", "plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"),
"utf-8",
);
expect(content).toContain("智能识别");
expect(content).toContain("rune status");
}
});
it("discuss skill 不包含智能识别引导", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
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 () => {
await injectOpenCode(TMP_DIR);
for (const stage of ["plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"),
"utf-8",
);
expect(content).toContain("变更名");
}
expect(content).not.toContain("智能识别");
});
it("plan skill 包含运行 status 的引导", async () => {
@@ -88,7 +101,7 @@ describe("injectOpenCode", () => {
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
"utf-8",
);
expect(content).not.toContain("rune status");
expect(content).not.toContain("规划阶段应先运行");
});
it("重复注入时不覆盖已存在的文件", async () => {
@@ -142,10 +155,15 @@ describe("updateOpenCode", () => {
expect(content).toContain("rune-discuss");
});
it("更新 status 命令和 skill", async () => {
it("更新时生成 rune-intro skill", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-status.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"))).toBe(true);
});
it("不生成 rune-status", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-status.md"))).toBe(false);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(false);
});
});
@@ -180,16 +198,31 @@ describe("injectOpenCode with command prefix", () => {
}
});
it("status skill 使用自定义前缀", async () => {
const tmpDir = join(import.meta.dir, "__tmp_opencode_status_test__");
it("create skill 使用自定义前缀", async () => {
const tmpDir = join(import.meta.dir, "__tmp_opencode_create_test__");
await mkdir(tmpDir, { recursive: true });
try {
await injectOpenCode(tmpDir, "pnpx @lanyuanxiaoyao/rune");
const content = await readFile(
join(tmpDir, ".opencode", "skills", "rune-status", "SKILL.md"),
join(tmpDir, ".opencode", "skills", "rune-create", "SKILL.md"),
"utf-8",
);
expect(content).toContain("pnpx @lanyuanxiaoyao/rune status");
expect(content).toContain("pnpx @lanyuanxiaoyao/rune create");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("rune-intro skill 使用自定义前缀", async () => {
const tmpDir = join(import.meta.dir, "__tmp_opencode_intro_test__");
await mkdir(tmpDir, { recursive: true });
try {
await injectOpenCode(tmpDir, "bunx @lanyuanxiaoyao/rune");
const content = await readFile(
join(tmpDir, ".opencode", "skills", "rune-intro", "SKILL.md"),
"utf-8",
);
expect(content).toContain("bunx @lanyuanxiaoyao/rune status");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}