feat(installer): Add selective install filters and new skill
Add installer filters for risk, category, and tags so maintainers and users can ship smaller skill surfaces to context-sensitive runtimes. Document the reduced-install flow for OpenCode-style hosts, add the humanize-chinese community skill, and sync the generated catalog and plugin-safe artifacts that now reflect the release batch. Refs #437 Refs #440 Refs #443
This commit is contained in:
@@ -5,6 +5,7 @@ const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const { resolveSafeRealPath } = require("../lib/symlink-safety");
|
||||
const { listSkillIdsRecursive, readSkill } = require("../lib/skill-utils");
|
||||
|
||||
const REPO = "https://github.com/sickn33/antigravity-awesome-skills.git";
|
||||
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
||||
@@ -21,6 +22,9 @@ function parseArgs() {
|
||||
let pathArg = null;
|
||||
let versionArg = null;
|
||||
let tagArg = null;
|
||||
let riskArg = null;
|
||||
let categoryArg = null;
|
||||
let tagsArg = null;
|
||||
let cursor = false,
|
||||
claude = false,
|
||||
gemini = false,
|
||||
@@ -42,6 +46,18 @@ function parseArgs() {
|
||||
tagArg = a[++i];
|
||||
continue;
|
||||
}
|
||||
if (a[i] === "--risk" && a[i + 1]) {
|
||||
riskArg = a[++i];
|
||||
continue;
|
||||
}
|
||||
if (a[i] === "--category" && a[i + 1]) {
|
||||
categoryArg = a[++i];
|
||||
continue;
|
||||
}
|
||||
if (a[i] === "--tags" && a[i + 1]) {
|
||||
tagsArg = a[++i];
|
||||
continue;
|
||||
}
|
||||
if (a[i] === "--cursor") {
|
||||
cursor = true;
|
||||
continue;
|
||||
@@ -73,6 +89,9 @@ function parseArgs() {
|
||||
pathArg,
|
||||
versionArg,
|
||||
tagArg,
|
||||
riskArg,
|
||||
categoryArg,
|
||||
tagsArg,
|
||||
cursor,
|
||||
claude,
|
||||
gemini,
|
||||
@@ -131,6 +150,9 @@ Options:
|
||||
--kiro Install to ~/.kiro/skills (Kiro CLI)
|
||||
--antigravity Install to ~/.gemini/antigravity/skills (Antigravity)
|
||||
--path <dir> Install to <dir> (default: ~/.gemini/antigravity/skills)
|
||||
--risk <csv> Install only skills matching these risk labels
|
||||
--category <csv> Install only skills matching these categories
|
||||
--tags <csv> Install only skills matching these tags
|
||||
--version <ver> Clone tag v<ver> (e.g. 4.6.0 -> v4.6.0)
|
||||
--tag <tag> Clone this tag or branch (e.g. v4.6.0)
|
||||
|
||||
@@ -139,12 +161,99 @@ Examples:
|
||||
npx antigravity-awesome-skills --cursor
|
||||
npx antigravity-awesome-skills --kiro
|
||||
npx antigravity-awesome-skills --antigravity
|
||||
npx antigravity-awesome-skills --path .agents/skills --category development,backend --risk safe,none
|
||||
npx antigravity-awesome-skills --path .agents/skills --tags debugging,typescript-legacy-
|
||||
npx antigravity-awesome-skills --version 4.6.0
|
||||
npx antigravity-awesome-skills --path ./my-skills
|
||||
npx antigravity-awesome-skills --claude --codex Install to multiple targets
|
||||
`);
|
||||
}
|
||||
|
||||
function normalizeFilterValue(value) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function uniqueValues(values) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function parseSelectorArg(raw) {
|
||||
const include = [];
|
||||
const exclude = [];
|
||||
|
||||
if (typeof raw !== "string" || !raw.trim()) {
|
||||
return { include, exclude };
|
||||
}
|
||||
|
||||
for (const token of raw.split(",")) {
|
||||
const normalized = normalizeFilterValue(token);
|
||||
if (!normalized) continue;
|
||||
if (normalized.endsWith("-") && normalized.length > 1) {
|
||||
exclude.push(normalized.slice(0, -1));
|
||||
continue;
|
||||
}
|
||||
include.push(normalized);
|
||||
}
|
||||
|
||||
const excludeValues = uniqueValues(exclude);
|
||||
return {
|
||||
include: uniqueValues(include).filter((value) => !excludeValues.includes(value)),
|
||||
exclude: excludeValues,
|
||||
};
|
||||
}
|
||||
|
||||
function hasActiveSelector(selector) {
|
||||
return selector.include.length > 0 || selector.exclude.length > 0;
|
||||
}
|
||||
|
||||
function buildInstallSelectors(opts) {
|
||||
return {
|
||||
risk: parseSelectorArg(opts.riskArg),
|
||||
category: parseSelectorArg(opts.categoryArg),
|
||||
tags: parseSelectorArg(opts.tagsArg),
|
||||
};
|
||||
}
|
||||
|
||||
function hasInstallSelectors(selectors) {
|
||||
return Object.values(selectors).some(hasActiveSelector);
|
||||
}
|
||||
|
||||
function matchesScalarSelector(value, selector) {
|
||||
const normalized = normalizeFilterValue(value);
|
||||
if (normalized && selector.exclude.includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (selector.include.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return selector.include.includes(normalized);
|
||||
}
|
||||
|
||||
function matchesArraySelector(values, selector) {
|
||||
const normalizedValues = uniqueValues(
|
||||
(Array.isArray(values) ? values : []).map((value) => normalizeFilterValue(value)),
|
||||
);
|
||||
|
||||
if (normalizedValues.some((value) => selector.exclude.includes(value))) {
|
||||
return false;
|
||||
}
|
||||
if (selector.include.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return normalizedValues.some((value) => selector.include.includes(value));
|
||||
}
|
||||
|
||||
function matchesInstallSelectors(skill, selectors) {
|
||||
return (
|
||||
matchesScalarSelector(skill.risk, selectors.risk) &&
|
||||
matchesScalarSelector(skill.category, selectors.category) &&
|
||||
matchesArraySelector(skill.tags, selectors.tags)
|
||||
);
|
||||
}
|
||||
|
||||
function copyRecursiveSync(src, dest, rootDir = src, skipGit = true) {
|
||||
const stats = fs.lstatSync(src);
|
||||
const resolvedSource = stats.isSymbolicLink()
|
||||
@@ -171,13 +280,24 @@ function copyRecursiveSync(src, dest, rootDir = src, skipGit = true) {
|
||||
}
|
||||
|
||||
/** Copy contents of repo's skills/ into target so each skill is target/skill-name/ (for Claude Code etc.). */
|
||||
function getInstallEntries(tempDir) {
|
||||
function getInstallEntries(tempDir, selectors = buildInstallSelectors({})) {
|
||||
const repoSkills = path.join(tempDir, "skills");
|
||||
if (!fs.existsSync(repoSkills)) {
|
||||
console.error("Cloned repo has no skills/ directory.");
|
||||
process.exit(1);
|
||||
}
|
||||
const entries = fs.readdirSync(repoSkills);
|
||||
|
||||
const skillEntries = listSkillIdsRecursive(repoSkills);
|
||||
const filteredEntries = hasInstallSelectors(selectors)
|
||||
? skillEntries.filter((skillId) => matchesInstallSelectors(readSkill(repoSkills, skillId), selectors))
|
||||
: skillEntries;
|
||||
|
||||
if (hasInstallSelectors(selectors) && filteredEntries.length === 0) {
|
||||
console.error("No skills matched the requested --risk/--category/--tags filters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entries = [...filteredEntries];
|
||||
if (fs.existsSync(path.join(tempDir, "docs"))) {
|
||||
entries.push("docs");
|
||||
}
|
||||
@@ -295,7 +415,7 @@ function buildCloneArgs(repo, tempDir, ref = null) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function installForTarget(tempDir, target) {
|
||||
function installForTarget(tempDir, target, selectors = buildInstallSelectors({})) {
|
||||
if (fs.existsSync(target.path)) {
|
||||
ensureTargetIsDirectory(target.path);
|
||||
const gitDir = path.join(target.path, ".git");
|
||||
@@ -334,7 +454,7 @@ function installForTarget(tempDir, target) {
|
||||
fs.mkdirSync(target.path, { recursive: true });
|
||||
}
|
||||
|
||||
const installEntries = getInstallEntries(tempDir);
|
||||
const installEntries = getInstallEntries(tempDir, selectors);
|
||||
const previousEntries = readInstallManifest(target.path);
|
||||
pruneRemovedEntries(target.path, previousEntries, installEntries);
|
||||
installSkillsIntoTarget(tempDir, target.path, installEntries);
|
||||
@@ -342,7 +462,12 @@ function installForTarget(tempDir, target) {
|
||||
console.log(` ✓ Installed to ${target.path}`);
|
||||
}
|
||||
|
||||
function getPostInstallMessages(targets) {
|
||||
function isOpenCodeStylePath(targetPath) {
|
||||
const normalizedPath = path.normalize(targetPath);
|
||||
return normalizedPath.endsWith(path.join(".agents", "skills"));
|
||||
}
|
||||
|
||||
function getPostInstallMessages(targets, selectors = buildInstallSelectors({})) {
|
||||
const messages = [
|
||||
"Pick a bundle in docs/users/bundles.md and use @skill-name in your AI assistant.",
|
||||
];
|
||||
@@ -356,12 +481,24 @@ function getPostInstallMessages(targets) {
|
||||
);
|
||||
}
|
||||
|
||||
if (targets.some((target) => isOpenCodeStylePath(target.path))) {
|
||||
const baseMessage =
|
||||
"For OpenCode or other .agents/skills installs, prefer a reduced install with --risk, --category, or --tags to avoid context overload.";
|
||||
messages.push(baseMessage);
|
||||
if (!hasInstallSelectors(selectors)) {
|
||||
messages.push(
|
||||
"Example: npx antigravity-awesome-skills --path .agents/skills --category development,backend --risk safe,none",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs();
|
||||
const { tagArg, versionArg } = opts;
|
||||
const selectors = buildInstallSelectors(opts);
|
||||
const ref =
|
||||
tagArg ||
|
||||
(versionArg
|
||||
@@ -396,10 +533,10 @@ function main() {
|
||||
console.log(`\nInstalling for ${targets.length} target(s):`);
|
||||
for (const target of targets) {
|
||||
console.log(`\n${target.name}:`);
|
||||
installForTarget(tempDir, target);
|
||||
installForTarget(tempDir, target, selectors);
|
||||
}
|
||||
|
||||
for (const message of getPostInstallMessages(targets)) {
|
||||
for (const message of getPostInstallMessages(targets, selectors)) {
|
||||
console.log(`\n${message}`);
|
||||
}
|
||||
} finally {
|
||||
@@ -425,10 +562,14 @@ module.exports = {
|
||||
copyRecursiveSync,
|
||||
getPostInstallMessages,
|
||||
buildCloneArgs,
|
||||
buildInstallSelectors,
|
||||
getInstallEntries,
|
||||
installSkillsIntoTarget,
|
||||
installForTarget,
|
||||
isOpenCodeStylePath,
|
||||
main,
|
||||
matchesInstallSelectors,
|
||||
parseSelectorArg,
|
||||
pruneRemovedEntries,
|
||||
readInstallManifest,
|
||||
writeInstallManifest,
|
||||
|
||||
@@ -137,28 +137,34 @@ function readSkill(skillDir, skillId) {
|
||||
if (Array.isArray(data.tags)) {
|
||||
tags = data.tags.map(tag => String(tag).trim());
|
||||
} else if (typeof data.tags === 'string' && data.tags.trim()) {
|
||||
const parts = data.tags.includes(',')
|
||||
? data.tags.split(',')
|
||||
: data.tags.split(/\s+/);
|
||||
const inlineTags = parseInlineList(data.tags);
|
||||
const parts = inlineTags.length > 0
|
||||
? inlineTags
|
||||
: (data.tags.includes(',') ? data.tags.split(',') : data.tags.split(/\s+/));
|
||||
tags = parts.map(tag => tag.trim());
|
||||
} else if (isPlainObject(data.metadata) && data.metadata.tags) {
|
||||
const rawTags = data.metadata.tags;
|
||||
if (Array.isArray(rawTags)) {
|
||||
tags = rawTags.map(tag => String(tag).trim());
|
||||
} else if (typeof rawTags === 'string' && rawTags.trim()) {
|
||||
const parts = rawTags.includes(',')
|
||||
? rawTags.split(',')
|
||||
: rawTags.split(/\s+/);
|
||||
const inlineTags = parseInlineList(rawTags);
|
||||
const parts = inlineTags.length > 0
|
||||
? inlineTags
|
||||
: (rawTags.includes(',') ? rawTags.split(',') : rawTags.split(/\s+/));
|
||||
tags = parts.map(tag => tag.trim());
|
||||
}
|
||||
}
|
||||
|
||||
tags = tags.filter(Boolean);
|
||||
const category = typeof data.category === 'string' ? data.category.trim() : '';
|
||||
const risk = typeof data.risk === 'string' ? data.risk.trim() : '';
|
||||
|
||||
return {
|
||||
id: skillId,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
risk,
|
||||
tags,
|
||||
path: skillPath,
|
||||
content,
|
||||
|
||||
146
tools/scripts/tests/installer_filters.test.js
Normal file
146
tools/scripts/tests/installer_filters.test.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const installer = require(path.resolve(__dirname, "..", "..", "bin", "install.js"));
|
||||
|
||||
function withTempDir(fn) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "installer-filters-"));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeSkill(repoRoot, skillPath, frontmatter) {
|
||||
const skillDir = path.join(repoRoot, "skills", skillPath);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---\n${frontmatter}\n---\n\n# ${skillPath}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
installer.parseSelectorArg("safe,critical,offensive-"),
|
||||
{ include: ["safe", "critical"], exclude: ["offensive"] },
|
||||
"parseSelectorArg should split CSV values and treat suffix - as exclude",
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
installer.parseSelectorArg("safe,safe-,none"),
|
||||
{ include: ["none"], exclude: ["safe"] },
|
||||
"parseSelectorArg should let excludes win over duplicate includes",
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
installer.isOpenCodeStylePath(path.join("/tmp", ".agents", "skills")),
|
||||
true,
|
||||
"OpenCode-style paths should be detected from .agents/skills",
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
installer.isOpenCodeStylePath(path.join("/tmp", ".codex", "skills")),
|
||||
false,
|
||||
"non-OpenCode paths should not trigger the .agents/skills guidance",
|
||||
);
|
||||
|
||||
withTempDir((root) => {
|
||||
const repoRoot = path.join(root, "repo");
|
||||
fs.mkdirSync(path.join(repoRoot, "skills"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "docs"), { recursive: true });
|
||||
|
||||
writeSkill(
|
||||
repoRoot,
|
||||
"safe-debugger",
|
||||
'name: safe-debugger\ncategory: development\nrisk: safe\ntags: [debugging, typescript]',
|
||||
);
|
||||
writeSkill(
|
||||
repoRoot,
|
||||
"offensive-tool",
|
||||
'name: offensive-tool\ncategory: security\nrisk: offensive\ntags: [pentest, red-team]',
|
||||
);
|
||||
writeSkill(
|
||||
repoRoot,
|
||||
path.join("nested", "metadata-tags"),
|
||||
'name: metadata-tags\ncategory: backend\nrisk: none\nmetadata:\n tags: "api,saas"',
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
installer.getInstallEntries(repoRoot, installer.buildInstallSelectors({})),
|
||||
["nested/metadata-tags", "offensive-tool", "safe-debugger", "docs"],
|
||||
"full installs should return recursive skill paths plus docs",
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
installer.getInstallEntries(
|
||||
repoRoot,
|
||||
installer.buildInstallSelectors({
|
||||
riskArg: "safe,none",
|
||||
categoryArg: "development,backend",
|
||||
tagsArg: "typescript,saas",
|
||||
}),
|
||||
),
|
||||
["nested/metadata-tags", "safe-debugger", "docs"],
|
||||
"filters should AND across flags and keep docs when skills match",
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
installer.getInstallEntries(
|
||||
repoRoot,
|
||||
installer.buildInstallSelectors({
|
||||
tagsArg: "pentest-",
|
||||
}),
|
||||
),
|
||||
["nested/metadata-tags", "safe-debugger", "docs"],
|
||||
"exclude-only tag filters should remove matching skills",
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
installer.matchesInstallSelectors(
|
||||
{ risk: "safe", category: "development", tags: ["debugging", "typescript"] },
|
||||
installer.buildInstallSelectors({
|
||||
riskArg: "safe",
|
||||
categoryArg: "development",
|
||||
tagsArg: "typescript",
|
||||
}),
|
||||
),
|
||||
true,
|
||||
"skills should match when all selector dimensions pass",
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
installer.matchesInstallSelectors(
|
||||
{ risk: "", category: "development", tags: ["debugging"] },
|
||||
installer.buildInstallSelectors({
|
||||
riskArg: "safe",
|
||||
}),
|
||||
),
|
||||
false,
|
||||
"missing scalar metadata should not satisfy positive selectors",
|
||||
);
|
||||
|
||||
const openCodeMessages = installer.getPostInstallMessages(
|
||||
[{ name: "Custom", path: path.join(root, ".agents", "skills") }],
|
||||
installer.buildInstallSelectors({}),
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
openCodeMessages.some((message) => message.includes("reduced install")),
|
||||
"OpenCode-style paths should get reduced-install guidance",
|
||||
);
|
||||
|
||||
const filteredOpenCodeMessages = installer.getPostInstallMessages(
|
||||
[{ name: "Custom", path: path.join(root, ".agents", "skills") }],
|
||||
installer.buildInstallSelectors({ categoryArg: "development" }),
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
filteredOpenCodeMessages.some((message) => message.includes("Example:")),
|
||||
false,
|
||||
"OpenCode guidance should skip the example once selectors are already active",
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ function writeSkill(repoRoot, skillName, content = "# Skill\n") {
|
||||
|
||||
function createFakeRepo(rootDir, skills) {
|
||||
fs.mkdirSync(path.join(rootDir, "skills"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootDir, "docs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, "docs", "README.md"), "# Docs\n", "utf8");
|
||||
for (const skillName of skills) {
|
||||
writeSkill(rootDir, skillName, `# ${skillName}\n`);
|
||||
}
|
||||
@@ -35,22 +37,47 @@ try {
|
||||
|
||||
createFakeRepo(repoV1, ["skill-a", "skill-b"]);
|
||||
createFakeRepo(repoV2, ["skill-a"]);
|
||||
writeSkill(
|
||||
repoV1,
|
||||
path.join("nested", "skill-c"),
|
||||
"---\nname: nested-skill-c\ncategory: backend\nrisk: safe\ntags: [api]\n---\n",
|
||||
);
|
||||
writeSkill(
|
||||
repoV2,
|
||||
"skill-a",
|
||||
"---\nname: skill-a\ncategory: development\nrisk: safe\ntags: [debugging]\n---\n",
|
||||
);
|
||||
writeSkill(
|
||||
repoV2,
|
||||
path.join("nested", "skill-c"),
|
||||
"---\nname: nested-skill-c\ncategory: backend\nrisk: safe\ntags: [api]\n---\n",
|
||||
);
|
||||
|
||||
installer.installForTarget(repoV1, { name: "Test", path: targetDir });
|
||||
assert.ok(fs.existsSync(path.join(targetDir, "skill-a", "SKILL.md")));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, "skill-b", "SKILL.md")));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, "nested", "skill-c", "SKILL.md")));
|
||||
|
||||
installer.installForTarget(repoV2, { name: "Test", path: targetDir });
|
||||
assert.ok(fs.existsSync(path.join(targetDir, "skill-a", "SKILL.md")));
|
||||
installer.installForTarget(
|
||||
repoV2,
|
||||
{ name: "Test", path: targetDir },
|
||||
installer.buildInstallSelectors({ categoryArg: "backend" }),
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(targetDir, "skill-a")),
|
||||
false,
|
||||
"non-matching top-level skills should be pruned during filtered updates",
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(targetDir, "skill-b")),
|
||||
false,
|
||||
"stale managed skill should be pruned during updates",
|
||||
"stale managed top-level skills should be pruned during updates",
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(targetDir, "nested", "skill-c", "SKILL.md")));
|
||||
assert.deepStrictEqual(
|
||||
readManifestEntries(targetDir),
|
||||
["skill-a"],
|
||||
"install manifest should mirror the latest installed entries",
|
||||
["docs", "nested/skill-c"],
|
||||
"install manifest should mirror the latest filtered install entries",
|
||||
);
|
||||
|
||||
const badTargetPath = path.join(tmpRoot, "bad-target");
|
||||
|
||||
@@ -19,6 +19,7 @@ const LOCAL_TEST_COMMANDS = [
|
||||
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_editorial_bundles.py")],
|
||||
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_plugin_compatibility.py")],
|
||||
[path.join(TOOL_TESTS, "installer_antigravity_guidance.test.js")],
|
||||
[path.join(TOOL_TESTS, "installer_filters.test.js")],
|
||||
[path.join(TOOL_TESTS, "installer_update_sync.test.js")],
|
||||
[path.join(TOOL_TESTS, "jetski_gemini_loader.test.cjs")],
|
||||
[path.join(TOOL_TESTS, "npm_package_contents.test.js")],
|
||||
|
||||
@@ -3,7 +3,7 @@ const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const { listSkillIds, listSkillIdsRecursive } = require("../../lib/skill-utils");
|
||||
const { listSkillIds, listSkillIdsRecursive, readSkill } = require("../../lib/skill-utils");
|
||||
|
||||
function withTempDir(fn) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-utils-security-"));
|
||||
@@ -110,3 +110,33 @@ withTempDir((root) => {
|
||||
fs.lstatSync = originalLstatSync;
|
||||
}
|
||||
});
|
||||
|
||||
withTempDir((root) => {
|
||||
const skillsDir = path.join(root, "skills");
|
||||
const skillDir = path.join(skillsDir, "metadata-skill");
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: metadata-skill
|
||||
category: backend
|
||||
risk: safe
|
||||
metadata:
|
||||
tags: "[api, saas]"
|
||||
---
|
||||
|
||||
# metadata-skill
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const skill = readSkill(skillsDir, "metadata-skill");
|
||||
|
||||
assert.strictEqual(skill.category, "backend", "readSkill should expose category metadata");
|
||||
assert.strictEqual(skill.risk, "safe", "readSkill should expose risk metadata");
|
||||
assert.deepStrictEqual(
|
||||
skill.tags,
|
||||
["api", "saas"],
|
||||
"readSkill should normalize inline tag lists from metadata.tags",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user