From c11e4b3f858059925504f54f23e07d312dfd745e Mon Sep 17 00:00:00 2001 From: sonwr Date: Sat, 28 Feb 2026 10:24:36 +0900 Subject: [PATCH] feat(validate): add --json output for automation --- README.md | 25 ++++++++++ package.json | 3 ++ src/commands/help/index.js | 4 ++ src/commands/validate/index.js | 51 +++++++++++++++----- tests/validate-json.test.js | 87 ++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 tests/validate-json.test.js diff --git a/README.md b/README.md index 915cb7f..77cfce5 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,35 @@ 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 validate --json # machine-readable validation output npx heymark help ``` +### Validate JSON output + +Use `validate --json` when you need structured output for CI or automation: + +```bash +npx heymark validate --json +``` + +Example output: + +```json +{ + "valid": false, + "skillCount": 2, + "errors": [ + { + "tool": "skill-repo", + "path": "my-skill.md", + "error": "Missing required frontmatter key: description" + } + ] +} +``` + ## 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/help/index.js b/src/commands/help/index.js index 154d9a8..efcd837 100644 --- a/src/commands/help/index.js +++ b/src/commands/help/index.js @@ -20,6 +20,7 @@ Usage: heymark clean ... heymark clean . --dry-run heymark validate + heymark validate --json Link flags: --branch | -b @@ -28,6 +29,9 @@ Link flags: Dry-run (sync, clean): --dry-run | -n +Validate flags: + --json + Supported tools: ${toolLines} diff --git a/src/commands/validate/index.js b/src/commands/validate/index.js index 6b851a2..8776588 100644 --- a/src/commands/validate/index.js +++ b/src/commands/validate/index.js @@ -1,20 +1,36 @@ 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.`); +function parseValidateFlags(flags) { + const jsonFlags = new Set(["--json"]); + const unknownFlags = flags.filter((flag) => !jsonFlags.has(flag)); + + if (unknownFlags.length > 0) { + console.error(`[Error] Unknown: ${unknownFlags.join(", ")}. validate supports only --json.`); process.exit(1); } + return { + json: flags.includes("--json"), + }; +} + +function toValidationError(skill, message) { + return { + tool: "skill-repo", + path: skill.fileName, + error: message, + }; +} + +function runValidate(flags, context) { + const { json } = parseValidateFlags(flags); 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`); + errors.push(toValidationError(skill, "Missing required frontmatter key: description")); } if ( @@ -22,7 +38,7 @@ function runValidate(flags, context) { && Object.prototype.hasOwnProperty.call(skill.metadata, "alwaysApply") && typeof skill.metadata.alwaysApply !== "boolean" ) { - errors.push(`[${label}] alwaysApply must be boolean (true/false)`); + errors.push(toValidationError(skill, "alwaysApply must be boolean (true/false)")); } if ( @@ -30,22 +46,35 @@ function runValidate(flags, context) { && Object.prototype.hasOwnProperty.call(skill.metadata, "globs") && typeof skill.metadata.globs !== "string" ) { - errors.push(`[${label}] globs must be a string`); + errors.push(toValidationError(skill, "globs must be a string")); } const normalizedName = (skill.name || "").trim().toLowerCase(); if (!normalizedName) { - errors.push(`[${label}] Skill name is empty`); + errors.push(toValidationError(skill, "Skill name is empty")); } else if (seenNames.has(normalizedName)) { - errors.push(`[${label}] Duplicate skill name detected: ${skill.name}`); + errors.push(toValidationError(skill, `Duplicate skill name detected: ${skill.name}`)); } else { seenNames.add(normalizedName); } } + if (json) { + const payload = { + valid: errors.length === 0, + skillCount: skills.length, + errors, + }; + console.log(JSON.stringify(payload, null, 2)); + if (!payload.valid) { + process.exit(1); + } + return; + } + if (errors.length > 0) { console.error("[Validate] Failed"); - errors.forEach((error) => console.error(` - ${error}`)); + errors.forEach((item) => console.error(` - [${item.path}] ${item.error}`)); process.exit(1); } diff --git a/tests/validate-json.test.js b/tests/validate-json.test.js new file mode 100644 index 0000000..4ef62d6 --- /dev/null +++ b/tests/validate-json.test.js @@ -0,0 +1,87 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { spawnSync } = require("child_process"); + +const CLI_PATH = path.resolve(__dirname, "../src/index.js"); + +function createLinkedCacheWorkspace(skillContent) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "heymark-validate-json-")); + + const repoUrl = "https://example.com/demo-skills.git"; + const cacheDir = path.join(cwd, ".heymark", "cache", "demo-skills"); + + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync( + path.join(cwd, ".heymark", "config.json"), + JSON.stringify({ repoUrl, branch: "main" }, null, 2), + "utf8" + ); + fs.writeFileSync(path.join(cacheDir, "skill.md"), skillContent, "utf8"); + + return cwd; +} + +function runValidateJson(cwd) { + const result = spawnSync(process.execPath, [CLI_PATH, "validate", "--json"], { + cwd, + encoding: "utf8", + }); + + let parsed; + try { + parsed = JSON.parse(result.stdout || "{}"); + } catch { + parsed = null; + } + + return { + code: result.status, + stdout: result.stdout, + stderr: result.stderr, + json: parsed, + }; +} + +test("validate --json returns structured errors and exit code 1 when invalid", () => { + const cwd = createLinkedCacheWorkspace("# Missing frontmatter description\n\nBody"); + const result = runValidateJson(cwd); + + assert.equal(result.code, 1); + assert.ok(result.json, "stdout should be valid JSON"); + assert.equal(result.json.valid, false); + assert.equal(result.json.skillCount, 1); + assert.ok(Array.isArray(result.json.errors)); + assert.ok(result.json.errors.length > 0); + + const firstError = result.json.errors[0]; + assert.equal(firstError.tool, "skill-repo"); + assert.equal(firstError.path, "skill.md"); + assert.match(firstError.error, /description/i); +}); + +test("validate --json returns valid=true and exit code 0 when valid", () => { + const cwd = createLinkedCacheWorkspace( + [ + "---", + 'description: "A valid skill"', + 'globs: "**/*.ts"', + "alwaysApply: true", + "---", + "", + "# Skill", + "", + "Body", + ].join("\n") + ); + + const result = runValidateJson(cwd); + + assert.equal(result.code, 0); + assert.ok(result.json, "stdout should be valid JSON"); + assert.equal(result.json.valid, true); + assert.equal(result.json.skillCount, 1); + assert.deepEqual(result.json.errors, []); +});