From 4da87449ac068c452944c71b5044bd80f022a099 Mon Sep 17 00:00:00 2001 From: sonwr Date: Wed, 25 Feb 2026 12:02:19 +0900 Subject: [PATCH 1/3] feat: add validate command and dry-run support for sync/clean --- README.md | 4 +++ src/commands/clean/index.js | 25 +++++++++++++-- src/commands/constants.js | 2 ++ src/commands/help/index.js | 3 ++ src/commands/sync/index.js | 26 ++++++++++++++-- src/commands/validate/index.js | 57 ++++++++++++++++++++++++++++++++++ src/index.js | 8 ++++- src/tools/skill-per-file.js | 17 ++++++++-- src/tools/skill-per-folder.js | 10 ++++-- 9 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 src/commands/validate/index.js diff --git a/README.md b/README.md index 8a2df34..915cb7f 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,13 @@ npx heymark link --branch npx heymark sync . # sync all tools npx heymark sync cursor claude-code # sync selected tools +npx heymark sync . --dry-run # preview sync without writing files npx heymark clean . # clean all generated outputs npx heymark clean cursor claude-code # clean selected tool outputs +npx heymark clean . --dry-run # preview clean without removing files + +npx heymark validate # validate skill frontmatter and naming npx heymark help ``` diff --git a/src/commands/clean/index.js b/src/commands/clean/index.js index cafdaef..b225c70 100644 --- a/src/commands/clean/index.js +++ b/src/commands/clean/index.js @@ -2,14 +2,35 @@ const { cleaner } = require("@/commands/cleaner"); const { selectTools } = require("@/commands/select-tools"); const { readCache } = require("@/skill-repo/cache-folder"); +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 runClean(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 previousDryRun = process.env.HEYMARK_DRY_RUN; + if (dryRun) { + process.env.HEYMARK_DRY_RUN = "1"; + console.log("[Clean]"); + console.log(" mode: dry-run (no files will be removed)"); + console.log(""); + } + const skillNames = skills.map((s) => s.name); const cleanedCount = cleaner(context.tools, selectedTools, skillNames, context.cwd); - console.log(`[Done] ${cleanedCount} tools cleaned.`); + if (dryRun) { + if (previousDryRun === undefined) delete process.env.HEYMARK_DRY_RUN; + else process.env.HEYMARK_DRY_RUN = previousDryRun; + } + + console.log(`[Done] ${cleanedCount} tools cleaned${dryRun ? " (dry-run)" : ""}.`); } module.exports = { diff --git a/src/commands/constants.js b/src/commands/constants.js index 56f4539..af9737b 100644 --- a/src/commands/constants.js +++ b/src/commands/constants.js @@ -2,6 +2,7 @@ const COMMAND_LINK = "link"; const COMMAND_SYNC = "sync"; const COMMAND_CLEAN = "clean"; const COMMAND_HELP = "help"; +const COMMAND_VALIDATE = "validate"; const LATEST_VERSION_COMMAND = "npx heymark@latest"; @@ -10,5 +11,6 @@ module.exports = { COMMAND_SYNC, COMMAND_CLEAN, COMMAND_HELP, + COMMAND_VALIDATE, LATEST_VERSION_COMMAND, }; diff --git a/src/commands/help/index.js b/src/commands/help/index.js index db739eb..0edbe63 100644 --- a/src/commands/help/index.js +++ b/src/commands/help/index.js @@ -15,8 +15,11 @@ Usage: heymark link heymark sync . heymark sync ... + heymark sync . --dry-run heymark clean . heymark clean ... + heymark clean . --dry-run + heymark validate Link flags: --branch | -b diff --git a/src/commands/sync/index.js b/src/commands/sync/index.js index b6d50c8..79f69fb 100644 --- a/src/commands/sync/index.js +++ b/src/commands/sync/index.js @@ -4,11 +4,24 @@ const { readCache } = require("@/skill-repo/cache-folder"); const { readConfig } = require("@/skill-repo/config-file"); const { SKILL_REPO_DEFAULT_BRANCH } = require("@/skill-repo/constants"); +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,6 +30,10 @@ 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); cleaner(context.tools, selectedTools, skillNames, context.cwd); @@ -26,8 +43,13 @@ function runSync(flags, context) { console.log(` ${tool.name.padEnd(16)} -> ${tool.output} (${count} skills)`); } + 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/commands/validate/index.js b/src/commands/validate/index.js new file mode 100644 index 0000000..1971cee --- /dev/null +++ b/src/commands/validate/index.js @@ -0,0 +1,57 @@ +const { readCache } = require("@/skill-repo/cache-folder"); + +function runValidate(flags, context) { + if (flags.length > 0) { + console.error(`[Error] Unknown: ${flags.join(", ")}. validate takes no arguments.`); + process.exit(1); + } + + const { skills } = readCache(context.cwd); + const errors = []; + const seenNames = new Set(); + + for (const skill of skills) { + const label = `${skill.fileName}`; + + if (!skill.metadata || typeof skill.metadata.description !== "string" || !skill.metadata.description.trim()) { + errors.push(`[${label}] Missing required frontmatter key: description`); + } + + if ( + skill.metadata + && Object.prototype.hasOwnProperty.call(skill.metadata, "alwaysApply") + && typeof skill.metadata.alwaysApply !== "boolean" + ) { + errors.push(`[${label}] alwaysApply must be boolean (true/false)`); + } + + if ( + skill.metadata + && Object.prototype.hasOwnProperty.call(skill.metadata, "globs") + && typeof skill.metadata.globs !== "string" + ) { + errors.push(`[${label}] globs must be a string`); + } + + const normalizedName = (skill.name || "").trim().toLowerCase(); + if (!normalizedName) { + errors.push(`[${label}] Skill name is empty`); + } else if (seenNames.has(normalizedName)) { + errors.push(`[${label}] Duplicate skill name detected: ${skill.name}`); + } else { + seenNames.add(normalizedName); + } + } + + if (errors.length > 0) { + console.error("[Validate] Failed"); + errors.forEach((error) => console.error(` - ${error}`)); + process.exit(1); + } + + console.log(`[Validate] OK (${skills.length} skills)`); +} + +module.exports = { + runValidate, +}; diff --git a/src/index.js b/src/index.js index 7108be2..424c266 100755 --- a/src/index.js +++ b/src/index.js @@ -2,11 +2,12 @@ require("./alias.js"); -const { COMMAND_LINK, COMMAND_SYNC, COMMAND_CLEAN, COMMAND_HELP } = require("@/commands/constants"); +const { COMMAND_LINK, COMMAND_SYNC, COMMAND_CLEAN, COMMAND_HELP, COMMAND_VALIDATE } = require("@/commands/constants"); const { runLink } = require("@/commands/link"); const { runSync } = require("@/commands/sync"); const { runClean } = require("@/commands/clean"); const { runHelp } = require("@/commands/help"); +const { runValidate } = require("@/commands/validate"); const { loadTools } = require("@/tools/loader"); function main() { @@ -45,6 +46,11 @@ function main() { return; } + if (command === COMMAND_VALIDATE) { + runValidate(flags, context); + return; + } + console.error(`[Error] Unknown command: ${command}. Run: heymark help`); process.exit(1); } diff --git a/src/tools/skill-per-file.js b/src/tools/skill-per-file.js index bac6d7e..8b820ff 100644 --- a/src/tools/skill-per-file.js +++ b/src/tools/skill-per-file.js @@ -1,13 +1,22 @@ const fs = require("fs"); const path = require("path"); +function isDryRun() { + return process.env.HEYMARK_DRY_RUN === "1"; +} + function generate({ cwd, dir, skills, getFileName, createContent }) { const destDir = path.join(cwd, dir); - fs.mkdirSync(destDir, { recursive: true }); + + if (!isDryRun()) { + fs.mkdirSync(destDir, { recursive: true }); + } for (const skill of skills) { const filePath = path.join(destDir, getFileName(skill)); - fs.writeFileSync(filePath, createContent(skill), "utf8"); + if (!isDryRun()) { + fs.writeFileSync(filePath, createContent(skill), "utf8"); + } } return skills.length; @@ -19,7 +28,9 @@ function clean(cwd, dir) { return []; } - fs.rmSync(targetPath, { recursive: true, force: true }); + if (!isDryRun()) { + fs.rmSync(targetPath, { recursive: true, force: true }); + } return [dir]; } diff --git a/src/tools/skill-per-folder.js b/src/tools/skill-per-folder.js index 0772f3c..ae39f32 100644 --- a/src/tools/skill-per-folder.js +++ b/src/tools/skill-per-folder.js @@ -2,11 +2,17 @@ const fs = require("fs"); const path = require("path"); const { clean } = require("@/tools/skill-per-file"); +function isDryRun() { + return process.env.HEYMARK_DRY_RUN === "1"; +} + function generate({ cwd, dir, fileName, skills, createContent }) { for (const skill of skills) { const skillDir = path.join(cwd, dir, skill.name); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, fileName), createContent(skill), "utf8"); + if (!isDryRun()) { + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, fileName), createContent(skill), "utf8"); + } } return skills.length; From 438b4a65bf2f93c09c880d8313c2503deca59b28 Mon Sep 17 00:00:00 2001 From: yeyoung kim Date: Wed, 25 Feb 2026 18:01:34 +0900 Subject: [PATCH 2/3] chore(help): add dry-run option description --- src/commands/help/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/commands/help/index.js b/src/commands/help/index.js index 0edbe63..154d9a8 100644 --- a/src/commands/help/index.js +++ b/src/commands/help/index.js @@ -25,6 +25,9 @@ Link flags: --branch | -b --folder | -f +Dry-run (sync, clean): + --dry-run | -n + Supported tools: ${toolLines} From d426a709c93738f1ff91b2ec01659859829645d3 Mon Sep 17 00:00:00 2001 From: yeyoung kim Date: Wed, 25 Feb 2026 18:15:01 +0900 Subject: [PATCH 3/3] fix: do not pull on validate/clean - readCache(cwd, { update: false }) skips writeCache/gitPull - validate and clean use local cache; sync remains the only command that updates from remote - preserves local cache changes when running validate or clean --- src/commands/clean/index.js | 2 +- src/commands/validate/index.js | 2 +- src/skill-repo/cache-folder.js | 24 ++++++++++++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/commands/clean/index.js b/src/commands/clean/index.js index b225c70..f155a31 100644 --- a/src/commands/clean/index.js +++ b/src/commands/clean/index.js @@ -12,7 +12,7 @@ function extractDryRun(flags) { function runClean(flags, context) { const { dryRun, positional } = extractDryRun(flags); const selectedTools = selectTools(positional, context.tools); - const { skills } = readCache(context.cwd); + const { skills } = readCache(context.cwd, { update: false }); const previousDryRun = process.env.HEYMARK_DRY_RUN; if (dryRun) { diff --git a/src/commands/validate/index.js b/src/commands/validate/index.js index 1971cee..6b851a2 100644 --- a/src/commands/validate/index.js +++ b/src/commands/validate/index.js @@ -6,7 +6,7 @@ function runValidate(flags, context) { process.exit(1); } - const { skills } = readCache(context.cwd); + const { skills } = readCache(context.cwd, { update: false }); const errors = []; const seenNames = new Set(); diff --git a/src/skill-repo/cache-folder.js b/src/skill-repo/cache-folder.js index 9d7df2a..9342e03 100644 --- a/src/skill-repo/cache-folder.js +++ b/src/skill-repo/cache-folder.js @@ -57,8 +57,28 @@ function writeCache(cwd) { return { config, cloneFolderPath }; } -function readCache(cwd) { - const { config, cloneFolderPath } = writeCache(cwd); +function readCache(cwd, options = {}) { + const update = options.update !== false; + let config, cloneFolderPath; + + if (update) { + const result = writeCache(cwd); + config = result.config; + cloneFolderPath = result.cloneFolderPath; + } else { + config = readConfig(cwd); + if (!config) { + console.error( + `[Error] Not linked. Run: heymark link (config: ${HEYMARK.DIR}/${HEYMARK.CONFIG_FILE})` + ); + process.exit(1); + } + cloneFolderPath = getCloneFolderPath(cwd, config.repoUrl); + if (!fs.existsSync(cloneFolderPath) || !fs.statSync(cloneFolderPath).isDirectory()) { + console.error("[Error] Cache not found. Run: heymark sync"); + process.exit(1); + } + } const folder = config.folder || ""; const skillsFolderPath = folder ? path.join(cloneFolderPath, folder) : cloneFolderPath;