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..f155a31 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 { skills } = readCache(context.cwd); + const { dryRun, positional } = extractDryRun(flags); + const selectedTools = selectTools(positional, context.tools); + const { skills } = readCache(context.cwd, { update: false }); + + 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..154d9a8 100644 --- a/src/commands/help/index.js +++ b/src/commands/help/index.js @@ -15,13 +15,19 @@ 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 --folder | -f +Dry-run (sync, clean): + --dry-run | -n + Supported tools: ${toolLines} 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..6b851a2 --- /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, { update: false }); + 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/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; 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;