diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index e4a118d..a174f84 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -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 { 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 { 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 +\`\`\` +`; } diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index df93dfb..df23713 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -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 { 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 { 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 = { 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 归档已完成的变更 `; } diff --git a/tests/adapters/claude-code.test.ts b/tests/adapters/claude-code.test.ts index 61dc78a..035c981 100644 --- a/tests/adapters/claude-code.test.ts +++ b/tests/adapters/claude-code.test.ts @@ -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"); }); }); diff --git a/tests/adapters/opencode.test.ts b/tests/adapters/opencode.test.ts index 1b7464a..8baa1b9 100644 --- a/tests/adapters/opencode.test.ts +++ b/tests/adapters/opencode.test.ts @@ -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 }); }