From 7530a5a7430fb4b09be5f6fe9476f72d5c04a67a Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 8 Jun 2026 17:24:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20OpenCode=20=E5=92=8C=20Claude=20Code=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E5=8F=8A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/claude-code.ts | 37 +++++++++++++ src/adapters/opencode.ts | 94 +++++++++++++++++++++++++++++++++ tests/adapters/opencode.test.ts | 85 +++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 src/adapters/claude-code.ts create mode 100644 src/adapters/opencode.ts create mode 100644 tests/adapters/opencode.test.ts diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts new file mode 100644 index 0000000..82187e0 --- /dev/null +++ b/src/adapters/claude-code.ts @@ -0,0 +1,37 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { STAGES } from "../types.ts"; + +const COMMANDS_DIR = ".claude/commands"; + +export async function injectClaudeCode(projectRoot: string): 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, `${stage}.md`); + if (!existsSync(commandPath)) { + const cmd = hasChangeName + ? `rune ${stage} <变更名>` + : `rune ${stage}`; + const nameHint = hasChangeName + ? "\n如果用户没有指定变更名称,请向用户确认。" + : ""; + await writeFile( + commandPath, + `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`, + ); + } + } + + const commandDir = join(projectRoot, COMMANDS_DIR); + const statusPath = join(commandDir, "rune-status.md"); + if (!existsSync(statusPath)) { + await writeFile( + statusPath, + `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`, + ); + } +} diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts new file mode 100644 index 0000000..8fba259 --- /dev/null +++ b/src/adapters/opencode.ts @@ -0,0 +1,94 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { STAGES } from "../types.ts"; + +const COMMANDS_DIR = ".opencode/commands"; +const SKILLS_DIR = ".opencode/skills"; + +export async function injectOpenCode(projectRoot: string): 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, `${stage}.md`); + if (!existsSync(commandPath)) { + await writeFile(commandPath, generateCommand(stage, hasChangeName)); + } + + const skillDir = join(projectRoot, SKILLS_DIR); + await mkdir(skillDir, { recursive: true }); + const skillPath = join(skillDir, `rune-${stage}.md`); + if (!existsSync(skillPath)) { + await writeFile(skillPath, generateSkill(stage, hasChangeName)); + } + } + + const commandDir = join(projectRoot, COMMANDS_DIR); + const statusCommandPath = join(commandDir, "rune-status.md"); + if (!existsSync(statusCommandPath)) { + await writeFile(statusCommandPath, generateStatusCommand()); + } + + const skillDir = join(projectRoot, SKILLS_DIR); + const statusSkillPath = join(skillDir, "rune-status.md"); + if (!existsSync(statusSkillPath)) { + await writeFile(statusSkillPath, generateStatusSkill()); + } +} + +function generateCommand(stage: string, hasChangeName: boolean): string { + if (hasChangeName) { + return `请调用 rune-${stage} skill 执行 ${stage} 阶段。 + +如果用户没有指定变更名称,请向用户确认要操作的变更名称。 +`; + } + return `请调用 rune-${stage} skill 执行 ${stage} 阶段。 +`; +} + +function generateSkill(stage: string, hasChangeName: boolean): string { + const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`; + const nameHint = hasChangeName + ? `将 <变更名> 替换为实际的变更名称。\n` + : ""; + + return `--- +description: Rune SDD ${stage} 阶段 +--- + +# ${stage} 阶段 + +执行以下命令获取工作指引: + +\`\`\`bash +${cmd} +\`\`\` + +${nameHint}将命令输出作为工作指引,执行当前阶段的工作。 +`; +} + +function generateStatusCommand(): string { + return `请调用 rune-status skill 查看当前所有变更状态。 +`; +} + +function generateStatusSkill(): string { + return `--- +description: 查看所有 Rune 变更状态 +--- + +# 状态查看 + +执行以下命令: + +\`\`\`bash +rune status +\`\`\` + +将命令输出展示给用户。 +`; +} diff --git a/tests/adapters/opencode.test.ts b/tests/adapters/opencode.test.ts new file mode 100644 index 0000000..69d2cbe --- /dev/null +++ b/tests/adapters/opencode.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, rm, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { injectOpenCode } from "../../src/adapters/opencode.ts"; + +const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__"); + +beforeEach(async () => { + await mkdir(TMP_DIR, { recursive: true }); +}); + +afterEach(async () => { + await rm(TMP_DIR, { recursive: true, force: true }); +}); + +describe("injectOpenCode", () => { + it("生成 discuss、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"]) { + expect(commands).toContain(`${stage}.md`); + expect(skills).toContain(`rune-${stage}.md`); + } + }); + + 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.md"); + }); + + it("command 文件包含 skill 调用指令", async () => { + await injectOpenCode(TMP_DIR); + const content = await readFile( + join(TMP_DIR, ".opencode", "commands", "discuss.md"), + "utf-8", + ); + expect(content).toContain("rune-discuss"); + }); + + it("skill 文件包含 bash 命令", async () => { + await injectOpenCode(TMP_DIR); + const content = await readFile( + join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"), + "utf-8", + ); + expect(content).toContain("rune discuss"); + expect(content).toContain("description"); + }); + + 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}.md`), + "utf-8", + ); + expect(content).toContain("变更名"); + } + }); + + it("重复注入时不覆盖已存在的文件", async () => { + await injectOpenCode(TMP_DIR); + const originalContent = await readFile( + join(TMP_DIR, ".opencode", "commands", "discuss.md"), + "utf-8", + ); + + await injectOpenCode(TMP_DIR); + const content = await readFile( + join(TMP_DIR, ".opencode", "commands", "discuss.md"), + "utf-8", + ); + expect(content).toBe(originalContent); + }); +});