Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"type": "commonjs",
"main": "src/index.js",
"bin": "src/index.js",
"scripts": {
"test": "node --test"
},
"keywords": [
"ai-coding-assistant",
"ai-agent",
Expand Down
4 changes: 4 additions & 0 deletions src/commands/help/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Usage:
heymark clean <tool1> <tool2> ...
heymark clean . --dry-run
heymark validate
heymark validate --json

Link flags:
--branch | -b
Expand All @@ -28,6 +29,9 @@ Link flags:
Dry-run (sync, clean):
--dry-run | -n

Validate flags:
--json

Supported tools:
${toolLines}

Expand Down
51 changes: 40 additions & 11 deletions src/commands/validate/index.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,80 @@
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 (
skill.metadata
&& 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 (
skill.metadata
&& 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);
}

Expand Down
87 changes: 87 additions & 0 deletions tests/validate-json.test.js
Original file line number Diff line number Diff line change
@@ -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, []);
});