chore: publish from main

This commit is contained in:
github-actions[bot]
2026-08-26 00:32:35 +00:00
parent dc25d2bc3e
commit 8b22c37b88
3 changed files with 191 additions and 37 deletions
+32 -13
View File
@@ -14,6 +14,24 @@ function safeFileOperation(operation, filePath, defaultValue = null) {
}
}
/**
* Decide whether a symlinked entry should be left out of a bundled asset list.
*
* Directory symlinks are skipped because following one can walk outside the
* folder or form a cycle. Broken links are skipped too. File symlinks are kept:
* plugin materialization dereferences them, so they really are bundled content.
*
* @param {string} filePath - Path to the symlinked entry
* @returns {boolean} True when the entry should be skipped
*/
function skipsAsBundledAsset(filePath) {
try {
return fs.statSync(filePath).isDirectory();
} catch {
return true;
}
}
/**
* Parse frontmatter from a markdown file using vfile-matter
* Works with any markdown file that has YAML frontmatter (agents, prompts, instructions)
@@ -142,16 +160,16 @@ function parseSkillMetadata(skillPath) {
return null;
}
// List bundled assets (all files except SKILL.md), recursing through subdirectories
// List bundled assets (all files except SKILL.md), recursing through subdirectories.
// Directory symlinks are skipped: following one can escape the skill folder.
const getAllFiles = (dirPath, arrayOfFiles = []) => {
const files = fs.readdirSync(dirPath);
const assetPaths = ['references', 'assets', 'scripts'];
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
files.forEach((file) => {
const filePath = path.join(dirPath, file);
if (fs.statSync(filePath).isDirectory() && assetPaths.includes(file)) {
entries.forEach((entry) => {
const filePath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
arrayOfFiles = getAllFiles(filePath, arrayOfFiles);
} else {
} else if (!entry.isSymbolicLink() || !skipsAsBundledAsset(filePath)) {
const relativePath = path.relative(skillPath, filePath);
if (relativePath !== "SKILL.md") {
// Normalize path separators to forward slashes for cross-platform consistency
@@ -218,15 +236,16 @@ function parseHookMetadata(hookPath) {
}
}
// List bundled assets (all files except README.md), recursing through subdirectories
// List bundled assets (all files except README.md), recursing through subdirectories.
// Directory symlinks are skipped: following one can escape the hook folder.
const getAllFiles = (dirPath, arrayOfFiles = []) => {
const files = fs.readdirSync(dirPath);
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
files.forEach((file) => {
const filePath = path.join(dirPath, file);
if (fs.statSync(filePath).isDirectory()) {
entries.forEach((entry) => {
const filePath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
arrayOfFiles = getAllFiles(filePath, arrayOfFiles);
} else {
} else if (!entry.isSymbolicLink() || !skipsAsBundledAsset(filePath)) {
const relativePath = path.relative(hookPath, filePath);
if (relativePath !== "README.md") {
// Normalize path separators to forward slashes for cross-platform consistency
+135
View File
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, test } from "node:test";
import { parseSkillMetadata } from "./yaml-parser.mjs";
const tempDirs = [];
after(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function createSkill(files) {
const skillPath = fs.mkdtempSync(path.join(os.tmpdir(), "skill-"));
tempDirs.push(skillPath);
for (const [relativePath, contents] of Object.entries(files)) {
const target = path.join(skillPath, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
}
return skillPath;
}
const SKILL_MD = `---
name: sample-skill
description: A sample skill used to exercise bundled asset discovery.
---
# Sample skill
`;
test("lists bundled assets from the spec-defined subdirectories", () => {
const skillPath = createSkill({
"SKILL.md": SKILL_MD,
"references/guide.md": "guide",
"scripts/run.py": "run",
"assets/logo.png": "logo",
});
const metadata = parseSkillMetadata(skillPath);
assert.deepEqual(metadata.assets, [
"assets/logo.png",
"references/guide.md",
"scripts/run.py",
]);
});
test("recurses into nested and non-spec subdirectories instead of listing the directory", () => {
const skillPath = createSkill({
"SKILL.md": SKILL_MD,
"templates/report.md": "report",
"references/skeletons/threat-model.md": "skeleton",
"nested/deep/tool.sh": "tool",
});
const metadata = parseSkillMetadata(skillPath);
assert.deepEqual(metadata.assets, [
"nested/deep/tool.sh",
"references/skeletons/threat-model.md",
"templates/report.md",
]);
for (const asset of metadata.assets) {
assert.ok(
fs.statSync(path.join(skillPath, asset)).isFile(),
`expected "${asset}" to be a file, not a directory`
);
}
});
test("excludes only the root SKILL.md, keeping nested ones as bundled assets", () => {
const skillPath = createSkill({
"SKILL.md": SKILL_MD,
"scaling-qps/SKILL.md": SKILL_MD,
"scaling-data-volume/tenant-scaling/SKILL.md": SKILL_MD,
});
const metadata = parseSkillMetadata(skillPath);
assert.deepEqual(metadata.assets, [
"scaling-data-volume/tenant-scaling/SKILL.md",
"scaling-qps/SKILL.md",
]);
assert.ok(
!metadata.assets.includes("SKILL.md"),
"the skill's own SKILL.md must not be listed as a bundled asset"
);
});
test("skips directory symlinks instead of escaping the skill folder or cycling", () => {
const outside = createSkill({ "secret.md": "not part of the skill" });
const skillPath = createSkill({
"SKILL.md": SKILL_MD,
"references/guide.md": "guide",
});
// A link that leaves the skill root, and one that points back at it.
fs.symlinkSync(outside, path.join(skillPath, "templates"));
fs.symlinkSync(skillPath, path.join(skillPath, "references", "loop"));
const metadata = parseSkillMetadata(skillPath);
assert.deepEqual(metadata.assets, ["references/guide.md"]);
for (const asset of metadata.assets) {
assert.ok(
!asset.startsWith("templates/") && !asset.includes("loop/"),
`asset "${asset}" was resolved through a symlink`
);
}
});
test("keeps file symlinks, which materialization dereferences into the bundle", () => {
const outside = createSkill({ "shared.md": "shared content" });
const skillPath = createSkill({ "SKILL.md": SKILL_MD });
fs.symlinkSync(path.join(outside, "shared.md"), path.join(skillPath, "shared.md"));
const metadata = parseSkillMetadata(skillPath);
// copyDirRecursive in materialize-plugins.mjs copies this via copyFileSync,
// so the file ships with the plugin and must appear in the asset list.
assert.deepEqual(metadata.assets, ["shared.md"]);
});
test("skips broken symlinks", () => {
const skillPath = createSkill({ "SKILL.md": SKILL_MD });
fs.symlinkSync(path.join(skillPath, "does-not-exist.md"), path.join(skillPath, "dangling.md"));
const metadata = parseSkillMetadata(skillPath);
assert.deepEqual(metadata.assets, []);
});