From 389b9a9f8b149664e07473871a73ce7dcc4a9263 Mon Sep 17 00:00:00 2001 From: sonwr Date: Sat, 28 Feb 2026 03:09:03 +0900 Subject: [PATCH] feat(sync): add dry-run summary with create update delete totals --- README.md | 30 +++++++++++ package.json | 3 ++ src/commands/sync/dry-run-summary.js | 48 +++++++++++++++++ src/commands/sync/index.js | 32 ++++++++++- src/tools/antigravity/index.js | 12 ++++- src/tools/claude-code/index.js | 12 ++++- src/tools/codex/index.js | 12 ++++- src/tools/copilot/index.js | 13 ++++- src/tools/cursor/index.js | 13 ++++- src/tools/skill-per-file.js | 46 ++++++++++++++++ src/tools/skill-per-folder.js | 46 ++++++++++++++++ tests/dry-run-summary.test.js | 79 ++++++++++++++++++++++++++++ 12 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 src/commands/sync/dry-run-summary.js create mode 100644 tests/dry-run-summary.test.js diff --git a/README.md b/README.md index 8a2df34..03c02bc 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,36 @@ npx heymark clean cursor claude-code # clean selected tool outputs npx heymark help ``` +### Dry-run Output Example + +```text +$ npx heymark sync cursor codex --dry-run +[Sync] + repo: https://github.com/MosslandOpenDevs/heymark.git + + mode: dry-run (no files will be written or removed) +[Clean] + Removed: .cursor/rules + Removed: .agents/skills + + Cursor -> .cursor/rules/*.mdc (7 skills) + Codex -> .agents/skills/*/SKILL.md (7 skills) + +[Dry-run Summary] + Cursor create: 1 update: 2 delete: 1 + + .cursor/rules/new-skill.mdc + ~ .cursor/rules/code-conventions.mdc + ~ .cursor/rules/readme-writing.mdc + - .cursor/rules/legacy-skill.mdc + Codex create: 2 update: 0 delete: 1 + + .agents/skills/new-skill/SKILL.md + + .agents/skills/ai-behavior/SKILL.md + - .agents/skills/legacy-skill/SKILL.md + TOTAL create: 3 update: 2 delete: 2 + +[Done] 2 tools synced (dry-run). +``` + ## How to Dev ### Tech Stack diff --git a/package.json b/package.json index 47a6777..eb28e61 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "commonjs", "main": "src/index.js", "bin": "src/index.js", + "scripts": { + "test": "node --test" + }, "keywords": [ "ai-coding-assistant", "ai-agent", diff --git a/src/commands/sync/dry-run-summary.js b/src/commands/sync/dry-run-summary.js new file mode 100644 index 0000000..b8af87d --- /dev/null +++ b/src/commands/sync/dry-run-summary.js @@ -0,0 +1,48 @@ +function buildDryRunSummary(tools, selectedTools, skills, cwd) { + const perTool = []; + const totals = { created: 0, updated: 0, deleted: 0 }; + + for (const toolKey of selectedTools) { + const tool = tools[toolKey]; + const preview = typeof tool.preview === "function" ? tool.preview(skills, cwd) : {}; + const created = Array.isArray(preview.created) ? preview.created.slice().sort() : []; + const updated = Array.isArray(preview.updated) ? preview.updated.slice().sort() : []; + const deleted = Array.isArray(preview.deleted) ? preview.deleted.slice().sort() : []; + + perTool.push({ + toolName: tool.name, + created, + updated, + deleted, + }); + + totals.created += created.length; + totals.updated += updated.length; + totals.deleted += deleted.length; + } + + return { perTool, totals }; +} + +function printDryRunSummary(summary) { + console.log(""); + console.log("[Dry-run Summary]"); + + for (const row of summary.perTool) { + console.log( + ` ${row.toolName.padEnd(16)} create:${String(row.created.length).padStart(3)} update:${String(row.updated.length).padStart(3)} delete:${String(row.deleted.length).padStart(3)}` + ); + row.created.forEach((p) => console.log(` + ${p}`)); + row.updated.forEach((p) => console.log(` ~ ${p}`)); + row.deleted.forEach((p) => console.log(` - ${p}`)); + } + + console.log( + ` ${"TOTAL".padEnd(16)} create:${String(summary.totals.created).padStart(3)} update:${String(summary.totals.updated).padStart(3)} delete:${String(summary.totals.deleted).padStart(3)}` + ); +} + +module.exports = { + buildDryRunSummary, + printDryRunSummary, +}; diff --git a/src/commands/sync/index.js b/src/commands/sync/index.js index b6d50c8..6616528 100644 --- a/src/commands/sync/index.js +++ b/src/commands/sync/index.js @@ -3,12 +3,26 @@ const { selectTools } = require("@/commands/select-tools"); const { readCache } = require("@/skill-repo/cache-folder"); const { readConfig } = require("@/skill-repo/config-file"); const { SKILL_REPO_DEFAULT_BRANCH } = require("@/skill-repo/constants"); +const { buildDryRunSummary, printDryRunSummary } = require("@/commands/sync/dry-run-summary"); + +function extractDryRun(flags) { + const dryRunFlags = new Set(["--dry-run", "-n"]); + const dryRun = flags.some((flag) => dryRunFlags.has(flag)); + const positional = flags.filter((flag) => !dryRunFlags.has(flag)); + return { dryRun, positional }; +} function runSync(flags, context) { - const selectedTools = selectTools(flags, context.tools); + const { dryRun, positional } = extractDryRun(flags); + const selectedTools = selectTools(positional, context.tools); const { skills } = readCache(context.cwd); const config = readConfig(context.cwd); + const previousDryRun = process.env.HEYMARK_DRY_RUN; + if (dryRun) { + process.env.HEYMARK_DRY_RUN = "1"; + } + console.log("[Sync]"); if (config) { console.log(` repo: ${config.repoUrl}`); @@ -17,7 +31,12 @@ function runSync(flags, context) { } console.log(""); + if (dryRun) { + console.log(" mode: dry-run (no files will be written or removed)"); + } + const skillNames = skills.map((s) => s.name); + const dryRunSummary = dryRun ? buildDryRunSummary(context.tools, selectedTools, skills, context.cwd) : null; cleaner(context.tools, selectedTools, skillNames, context.cwd); for (const toolKey of selectedTools) { @@ -26,8 +45,17 @@ function runSync(flags, context) { console.log(` ${tool.name.padEnd(16)} -> ${tool.output} (${count} skills)`); } + if (dryRun && dryRunSummary) { + printDryRunSummary(dryRunSummary); + } + + if (dryRun) { + if (previousDryRun === undefined) delete process.env.HEYMARK_DRY_RUN; + else process.env.HEYMARK_DRY_RUN = previousDryRun; + } + console.log(""); - console.log(`[Done] ${selectedTools.length} tools synced.`); + console.log(`[Done] ${selectedTools.length} tools synced${dryRun ? " (dry-run)" : ""}.`); } module.exports = { diff --git a/src/tools/antigravity/index.js b/src/tools/antigravity/index.js index aa7b1fc..47d3235 100644 --- a/src/tools/antigravity/index.js +++ b/src/tools/antigravity/index.js @@ -1,5 +1,5 @@ const { ANTIGRAVITY } = require("@/tools/constants"); -const { generate, clean } = require("@/tools/skill-per-folder"); +const { generate, preview, clean } = require("@/tools/skill-per-folder"); function createContent(skill) { const frontmatterLines = [ @@ -27,6 +27,16 @@ module.exports = { }); }, + preview(skills, cwd) { + return preview({ + cwd, + dir: ANTIGRAVITY.SKILLS_DIR, + fileName: ANTIGRAVITY.SKILL_FILE_NAME, + skills, + createContent, + }); + }, + clean(skillNames, cwd) { return clean(cwd, ANTIGRAVITY.SKILLS_DIR); }, diff --git a/src/tools/claude-code/index.js b/src/tools/claude-code/index.js index 7152444..8d3e4b9 100644 --- a/src/tools/claude-code/index.js +++ b/src/tools/claude-code/index.js @@ -1,5 +1,5 @@ const { CLAUDE_CODE } = require("@/tools/constants"); -const { generate, clean } = require("@/tools/skill-per-folder"); +const { generate, preview, clean } = require("@/tools/skill-per-folder"); function createContent(skill) { const frontmatterLines = [ @@ -27,6 +27,16 @@ module.exports = { }); }, + preview(skills, cwd) { + return preview({ + cwd, + dir: CLAUDE_CODE.SKILLS_DIR, + fileName: CLAUDE_CODE.SKILL_FILE_NAME, + skills, + createContent, + }); + }, + clean(skillNames, cwd) { return clean(cwd, CLAUDE_CODE.SKILLS_DIR); }, diff --git a/src/tools/codex/index.js b/src/tools/codex/index.js index 3f2ed73..63021e6 100644 --- a/src/tools/codex/index.js +++ b/src/tools/codex/index.js @@ -1,5 +1,5 @@ const { CODEX } = require("@/tools/constants"); -const { generate, clean } = require("@/tools/skill-per-folder"); +const { generate, preview, clean } = require("@/tools/skill-per-folder"); function createContent(skill) { const frontmatterLines = [ @@ -27,6 +27,16 @@ module.exports = { }); }, + preview(skills, cwd) { + return preview({ + cwd, + dir: CODEX.SKILLS_DIR, + fileName: CODEX.SKILL_FILE_NAME, + skills, + createContent, + }); + }, + clean(skillNames, cwd) { return clean(cwd, CODEX.SKILLS_DIR); }, diff --git a/src/tools/copilot/index.js b/src/tools/copilot/index.js index 20e0806..624ce18 100644 --- a/src/tools/copilot/index.js +++ b/src/tools/copilot/index.js @@ -1,5 +1,5 @@ const { COPILOT } = require("@/tools/constants"); -const { generate, clean } = require("@/tools/skill-per-file"); +const { generate, preview, clean } = require("@/tools/skill-per-file"); function getFileName(skill) { return `${skill.name}${COPILOT.FILE_SUFFIX}`; @@ -34,6 +34,17 @@ module.exports = { }); }, + preview(skills, cwd) { + return preview({ + cwd, + dir: COPILOT.INSTRUCTIONS_DIR, + skills, + getFileName, + createContent, + fileSuffix: COPILOT.FILE_SUFFIX, + }); + }, + clean(skillNames, cwd) { return clean(cwd, COPILOT.INSTRUCTIONS_DIR); }, diff --git a/src/tools/cursor/index.js b/src/tools/cursor/index.js index 2c89de4..a873fb8 100644 --- a/src/tools/cursor/index.js +++ b/src/tools/cursor/index.js @@ -1,5 +1,5 @@ const { CURSOR } = require("@/tools/constants"); -const { generate, clean } = require("@/tools/skill-per-file"); +const { generate, preview, clean } = require("@/tools/skill-per-file"); function getFileName(skill) { return `${skill.name}${CURSOR.FILE_SUFFIX}`; @@ -33,6 +33,17 @@ module.exports = { }); }, + preview(skills, cwd) { + return preview({ + cwd, + dir: CURSOR.SKILLS_DIR, + skills, + getFileName, + createContent, + fileSuffix: CURSOR.FILE_SUFFIX, + }); + }, + clean(skillNames, cwd) { return clean(cwd, CURSOR.SKILLS_DIR); }, diff --git a/src/tools/skill-per-file.js b/src/tools/skill-per-file.js index bac6d7e..fd17069 100644 --- a/src/tools/skill-per-file.js +++ b/src/tools/skill-per-file.js @@ -13,6 +13,51 @@ function generate({ cwd, dir, skills, getFileName, createContent }) { return skills.length; } +function preview({ cwd, dir, skills, getFileName, createContent, fileSuffix }) { + const destDir = path.join(cwd, dir); + const expected = new Map(); + const created = []; + const updated = []; + const deleted = []; + + for (const skill of skills) { + const relativePath = path.join(dir, getFileName(skill)); + expected.set(relativePath, createContent(skill)); + } + + for (const [relativePath, content] of expected.entries()) { + const absolutePath = path.join(cwd, relativePath); + if (!fs.existsSync(absolutePath)) { + created.push(relativePath); + continue; + } + + const current = fs.readFileSync(absolutePath, "utf8"); + if (current !== content) { + updated.push(relativePath); + } + } + + if (fs.existsSync(destDir)) { + const existing = fs + .readdirSync(destDir) + .filter((fileName) => (fileSuffix ? fileName.endsWith(fileSuffix) : true)) + .map((fileName) => path.join(dir, fileName)); + + for (const relativePath of existing) { + if (!expected.has(relativePath)) { + deleted.push(relativePath); + } + } + } + + return { + created: created.sort(), + updated: updated.sort(), + deleted: deleted.sort(), + }; +} + function clean(cwd, dir) { const targetPath = path.join(cwd, dir); if (!fs.existsSync(targetPath)) { @@ -25,5 +70,6 @@ function clean(cwd, dir) { module.exports = { generate, + preview, clean, }; diff --git a/src/tools/skill-per-folder.js b/src/tools/skill-per-folder.js index 0772f3c..af0c853 100644 --- a/src/tools/skill-per-folder.js +++ b/src/tools/skill-per-folder.js @@ -12,7 +12,53 @@ function generate({ cwd, dir, fileName, skills, createContent }) { return skills.length; } +function preview({ cwd, dir, fileName, skills, createContent }) { + const baseDir = path.join(cwd, dir); + const expected = new Map(); + const created = []; + const updated = []; + const deleted = []; + + for (const skill of skills) { + const relativePath = path.join(dir, skill.name, fileName); + expected.set(relativePath, createContent(skill)); + } + + for (const [relativePath, content] of expected.entries()) { + const absolutePath = path.join(cwd, relativePath); + if (!fs.existsSync(absolutePath)) { + created.push(relativePath); + continue; + } + + const current = fs.readFileSync(absolutePath, "utf8"); + if (current !== content) { + updated.push(relativePath); + } + } + + if (fs.existsSync(baseDir)) { + const existing = fs + .readdirSync(baseDir) + .filter((name) => fs.statSync(path.join(baseDir, name)).isDirectory()) + .map((name) => path.join(dir, name, fileName)); + + for (const relativePath of existing) { + if (!expected.has(relativePath)) { + deleted.push(relativePath); + } + } + } + + return { + created: created.sort(), + updated: updated.sort(), + deleted: deleted.sort(), + }; +} + module.exports = { generate, + preview, clean, }; diff --git a/tests/dry-run-summary.test.js b/tests/dry-run-summary.test.js new file mode 100644 index 0000000..9ceb465 --- /dev/null +++ b/tests/dry-run-summary.test.js @@ -0,0 +1,79 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); + +require("../src/alias"); + +const { preview } = require("@/tools/skill-per-file"); +const { buildDryRunSummary, printDryRunSummary } = require("@/commands/sync/dry-run-summary"); + +function withCapturedLogs(fn) { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + fn(); + } finally { + console.log = originalLog; + } + return lines.join("\n"); +} + +test("dry-run summary reports created/updated/deleted files with totals", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "heymark-dryrun-")); + const dir = path.join(".cursor", "rules"); + const targetDir = path.join(cwd, dir); + fs.mkdirSync(targetDir, { recursive: true }); + + fs.writeFileSync(path.join(targetDir, "alpha.mdc"), "old-alpha\n", "utf8"); + fs.writeFileSync(path.join(targetDir, "legacy.mdc"), "legacy\n", "utf8"); + + const skills = [ + { name: "alpha", body: "Alpha body" }, + { name: "beta", body: "Beta body" }, + ]; + + const diff = preview({ + cwd, + dir, + skills, + fileSuffix: ".mdc", + getFileName(skill) { + return `${skill.name}.mdc`; + }, + createContent(skill) { + return `${skill.body}\n`; + }, + }); + + assert.deepEqual(diff.created, [path.join(".cursor", "rules", "beta.mdc")]); + assert.deepEqual(diff.updated, [path.join(".cursor", "rules", "alpha.mdc")]); + assert.deepEqual(diff.deleted, [path.join(".cursor", "rules", "legacy.mdc")]); + + const summary = buildDryRunSummary( + { + cursor: { + name: "Cursor", + preview() { + return diff; + }, + }, + }, + ["cursor"], + skills, + cwd + ); + + assert.equal(summary.totals.created, 1); + assert.equal(summary.totals.updated, 1); + assert.equal(summary.totals.deleted, 1); + + const output = withCapturedLogs(() => printDryRunSummary(summary)); + assert.match(output, /\[Dry-run Summary\]/); + assert.match(output, /\+ \.cursor\/rules\/beta\.mdc/); + assert.match(output, /~ \.cursor\/rules\/alpha\.mdc/); + assert.match(output, /- \.cursor\/rules\/legacy\.mdc/); + assert.match(output, /TOTAL\s+create:\s*1 update:\s*1 delete:\s*1/); +});