From 8e126a95c63fbb7c3a1a8ba38504590c80dd0c2a Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 8 Jun 2026 17:28:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CLI=20=E5=85=A5=E5=8F=A3=E5=92=8C=20ini?= =?UTF-8?q?t=20=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 103 ++++++++++++++++++++++++++++++++++++ src/commands/init.ts | 39 ++++++++++++++ tests/commands/init.test.ts | 68 ++++++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 src/cli.ts create mode 100644 src/commands/init.ts create mode 100644 tests/commands/init.test.ts diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..a2ee160 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +import { cac } from "cac"; +import { join } from "node:path"; +import { mkdir, rename } from "node:fs/promises"; +import { runInit } from "./commands/init.ts"; +import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts"; +import { + assembleDiscussPrompt, + assemblePlanPrompt, + assembleBuildPrompt, + assembleArchivePrompt, +} from "./core/assembler.ts"; +import { scanChanges } from "./core/scanner.ts"; + +const cli = cac("rune"); + +cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action( + async (tools: string[]) => { + if (!tools || tools.length === 0) { + console.error("请指定至少一个工具,如:rune init opencode"); + process.exit(1); + } + await runInit(process.cwd(), tools); + console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`); + }, +); + +cli.command("discuss", "讨论阶段").action(async () => { + const root = findProjectRoot(); + if (!root) { + console.error("未找到 .rune 目录,请先运行 rune init"); + process.exit(1); + } + const config = await loadConfig(root); + const prompt = assembleDiscussPrompt(config); + console.log(prompt); +}); + +cli + .command("plan ", "规划阶段") + .action(async (changeName: string) => { + const root = findProjectRoot(); + if (!root) { + console.error("未找到 .rune 目录,请先运行 rune init"); + process.exit(1); + } + 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 ", "构建阶段") + .action(async (changeName: string) => { + const root = findProjectRoot(); + if (!root) { + console.error("未找到 .rune 目录,请先运行 rune init"); + process.exit(1); + } + const config = await loadConfig(root); + const prompt = await assembleBuildPrompt(config, root, changeName); + console.log(prompt); + }); + +cli + .command("archive ", "归档阶段") + .action(async (changeName: string) => { + const root = findProjectRoot(); + if (!root) { + console.error("未找到 .rune 目录,请先运行 rune init"); + process.exit(1); + } + const config = await loadConfig(root); + const prompt = assembleArchivePrompt(config, changeName); + const today = new Date().toISOString().slice(0, 10); + const src = getChangeDir(root, changeName); + 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 changes = await scanChanges(root); + if (changes.length === 0) { + console.log("当前无进行中的变更。"); + return; + } + for (const change of changes) { + const progress = change.taskProgress + ? ` (${change.taskProgress.completed}/${change.taskProgress.total} 任务)` + : ""; + console.log(`- ${change.name}${progress} [${change.documents.join(", ")}]`); + } +}); + +cli.help(); +cli.parse(); diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..2aba298 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,39 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { stringify as stringifyYaml } from "yaml"; +import { defaultConfig } from "../defaults/config.ts"; +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"; + +const SUPPORTED_TOOLS: Record Promise> = { + opencode: injectOpenCode, + "claude-code": injectClaudeCode, +}; + +export async function runInit( + projectRoot: string, + tools: string[], +): Promise { + for (const tool of tools) { + if (!SUPPORTED_TOOLS[tool]) { + throw new Error(`不支持的工具: ${tool}`); + } + } + + const runeDir = join(projectRoot, RUNE_DIR); + await mkdir(runeDir, { recursive: true }); + await mkdir(join(runeDir, CHANGES_DIR), { recursive: true }); + await mkdir(join(runeDir, ARCHIVE_DIR), { recursive: true }); + + const configPath = join(runeDir, CONFIG_FILE); + if (!existsSync(configPath)) { + const yaml = stringifyYaml(defaultConfig); + await writeFile(configPath, yaml, "utf-8"); + } + + for (const tool of tools) { + await SUPPORTED_TOOLS[tool](projectRoot); + } +} diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts new file mode 100644 index 0000000..887a947 --- /dev/null +++ b/tests/commands/init.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +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"; + +const TMP_DIR = join(import.meta.dir, "__tmp_init_test__"); + +beforeEach(async () => { + await mkdir(TMP_DIR, { recursive: true }); +}); + +afterEach(async () => { + await rm(TMP_DIR, { recursive: true, force: true }); +}); + +describe("runInit", () => { + it("创建 .rune 目录和默认 config.yaml", async () => { + await runInit(TMP_DIR, ["opencode"]); + + expect(existsSync(join(TMP_DIR, ".rune"))).toBe(true); + expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true); + + const content = await readFile( + join(TMP_DIR, ".rune", "config.yaml"), + "utf-8", + ); + expect(content).toContain("discuss"); + expect(content).toContain("plan"); + expect(content).toContain("build"); + expect(content).toContain("archive"); + }); + + it("创建 .rune/changes 和 .rune/archive 目录", async () => { + await runInit(TMP_DIR, ["opencode"]); + + expect(existsSync(join(TMP_DIR, ".rune", "changes"))).toBe(true); + expect(existsSync(join(TMP_DIR, ".rune", "archive"))).toBe(true); + }); + + it("注入 OpenCode command 和 skill", async () => { + await runInit(TMP_DIR, ["opencode"]); + + expect(existsSync(join(TMP_DIR, ".opencode", "commands", "discuss.md"))).toBe(true); + expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"))).toBe(true); + }); + + it("重复 init 不覆盖 config.yaml", async () => { + await runInit(TMP_DIR, ["opencode"]); + await writeFile( + join(TMP_DIR, ".rune", "config.yaml"), + "自定义内容", + ); + + await runInit(TMP_DIR, ["opencode"]); + const content = await readFile( + join(TMP_DIR, ".rune", "config.yaml"), + "utf-8", + ); + expect(content).toBe("自定义内容"); + }); + + it("不支持的工具名抛出错误", async () => { + await expect(runInit(TMP_DIR, ["unknown-tool"])).rejects.toThrow( + "不支持的工具", + ); + }); +});