From f257ccbe4a7703b9e1b3a0749472e8d91ddf498d Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 9 Jun 2026 12:39:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20rune=20update=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=94=A8=E4=BA=8E=E6=9B=B4=E6=96=B0=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/claude-code.ts | 35 ++++++++++++++++++++- src/adapters/opencode.ts | 41 +++++++++++++++++++++++- src/cli.ts | 29 +++++++++++++++++ src/cli/help.ts | 13 ++++++++ tests/adapters/opencode.test.ts | 56 ++++++++++++++++++++++++++++++++- 5 files changed, 171 insertions(+), 3 deletions(-) diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index 5a31dcf..b8146e2 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; import { join } from "node:path"; import { STAGES } from "../types.ts"; @@ -35,3 +35,36 @@ export async function injectClaudeCode(projectRoot: string): Promise { ); } } + +export async function updateClaudeCode(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, `rune-${stage}.md`); + const cmd = hasChangeName + ? `rune ${stage} <变更名>` + : `rune ${stage}`; + const nameHint = hasChangeName + ? "\n如果用户没有指定变更名称,请向用户确认。" + : ""; + const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`; + await writeIfChangedClaude(commandPath, newContent); + } + + const statusPath = join(projectRoot, COMMANDS_DIR, "rune-status.md"); + await writeIfChangedClaude(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`); +} + +async function writeIfChangedClaude(filePath: string, newContent: string): Promise { + try { + const existing = await readFile(filePath, "utf-8"); + if (existing === newContent) { + return; + } + } catch { + // 文件不存在,创建 + } + await writeFile(filePath, newContent); +} diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index fc4b99d..e0fe22c 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; import { join } from "node:path"; import { STAGES } from "../types.ts"; @@ -39,6 +39,45 @@ export async function injectOpenCode(projectRoot: string): Promise { } } +export async function updateOpenCode(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, `rune-${stage}.md`); + const newCommand = generateCommand(stage, hasChangeName); + await writeIfChanged(commandPath, newCommand); + + const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`); + await mkdir(skillStageDir, { recursive: true }); + const skillPath = join(skillStageDir, "SKILL.md"); + const newSkill = generateSkill(stage, hasChangeName); + await writeIfChanged(skillPath, newSkill); + } + + 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()); +} + +async function writeIfChanged(filePath: string, newContent: string): Promise { + try { + const existing = await readFile(filePath, "utf-8"); + if (existing === newContent) { + return; + } + } catch { + // 文件不存在,创建 + } + await writeFile(filePath, newContent); +} + function generateCommand(stage: string, hasChangeName: boolean): string { if (hasChangeName) { return `请调用 rune-${stage} skill 执行 ${stage} 阶段。 diff --git a/src/cli.ts b/src/cli.ts index 750bf39..bfb3f34 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -129,6 +129,35 @@ cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action( }, ); +cli.command("update [...tools]", "更新已注入的工具配置").action( + async (tools: string[]) => { + if (!tools || tools.length === 0) { + throw new UsageError("请指定至少一个工具", { + usage: "rune update <工具...>", + hint: "如:rune update opencode", + }); + } + const root = requireProjectRoot(); + const { updateOpenCode } = await import("./adapters/opencode.ts"); + const { updateClaudeCode } = await import("./adapters/claude-code.ts"); + const validators: Record Promise> = { + opencode: updateOpenCode, + "claude-code": updateClaudeCode, + }; + for (const tool of tools) { + if (!validators[tool]) { + throw new CommandError(`不支持的工具: ${tool}`, { + hint: `支持的工具: ${Object.keys(validators).join(", ")}`, + }); + } + } + for (const tool of tools) { + await validators[tool](root); + } + console.log(`工具配置已更新:${tools.join(", ")}`); + }, +); + cli.command("discuss", "讨论阶段").action(async () => { const root = requireProjectRoot(); const config = await loadConfig(root); diff --git a/src/cli/help.ts b/src/cli/help.ts index ea090a4..59492ea 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -69,6 +69,18 @@ const COMMANDS: Record = { "rune archive fix-memory-leak", ], }, + update: { + name: "update", + alias: "update <工具...>", + description: "更新:更新已注入的编辑器配置", + usage: "rune update <工具...>", + args: [{ name: "<工具...>", desc: "要更新的 AI 工具,如 opencode、claude-code" }], + detail: "对比已注入的命令和 skill 文件,与内置版本不一致时覆盖,不存在时新建。用于升级 Rune 后同步编辑器配置。", + examples: [ + "rune update opencode", + "rune update opencode claude-code", + ], + }, status: { name: "status", alias: "status [变更]", @@ -105,6 +117,7 @@ export function showGlobalHelp(): string { lines.push(""); lines.push("示例:"); lines.push(" rune init opencode 初始化并注入 OpenCode 配置"); + lines.push(" rune update opencode 更新 OpenCode 配置"); lines.push(" rune plan add-login design 规划 \"add-login\" 的设计文档"); lines.push(" rune status 查看当前变更状态"); diff --git a/tests/adapters/opencode.test.ts b/tests/adapters/opencode.test.ts index 206bd27..ede621e 100644 --- a/tests/adapters/opencode.test.ts +++ b/tests/adapters/opencode.test.ts @@ -2,7 +2,8 @@ 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"; +import { writeFile } from "node:fs/promises"; +import { injectOpenCode, updateOpenCode } from "../../src/adapters/opencode.ts"; const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__"); @@ -108,3 +109,56 @@ describe("injectOpenCode", () => { expect(content).toBe(originalContent); }); }); + +describe("updateOpenCode", () => { + it("文件不存在时创建", async () => { + await updateOpenCode(TMP_DIR); + expect( + existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md")), + ).toBe(true); + expect( + existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md")), + ).toBe(true); + }); + + it("文件存在且内容一致时不覆盖", async () => { + await injectOpenCode(TMP_DIR); + const originalContent = await readFile( + join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"), + "utf-8", + ); + + await updateOpenCode(TMP_DIR); + const content = await readFile( + join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"), + "utf-8", + ); + expect(content).toBe(originalContent); + }); + + it("文件存在但内容不一致时覆盖", async () => { + await injectOpenCode(TMP_DIR); + await writeFile( + join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"), + "旧内容", + ); + + await updateOpenCode(TMP_DIR); + const content = await readFile( + join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"), + "utf-8", + ); + expect(content).not.toBe("旧内容"); + expect(content).toContain("rune-discuss"); + }); + + it("更新 status 命令和 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); + }); +});