mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
fix(skills): namespace ClawHub skill slugs (#263)
* fix(release): map ClawHub publish slugs * fix(release): share skill platform parsing
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { installAgentForSkill, PLATFORM_KEYS } from "./skill_platforms.mjs";
|
||||
|
||||
const PLATFORM_KEYS = ["openclaw", "nanoclaw", "hermes", "picoclaw"];
|
||||
const KNOWN_AGENT_TYPES = new Set(["codex", "hermes-agent", "openclaw", "universal"]);
|
||||
const PLATFORM_AGENT_ALIASES = new Map([["hermes", "hermes-agent"]]);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
@@ -98,50 +97,6 @@ function detectPlatform(skill) {
|
||||
return skill.platform || "agent-skills";
|
||||
}
|
||||
|
||||
function collectDeclaredPlatforms(skill) {
|
||||
const platforms = new Set();
|
||||
if (typeof skill.platform === "string" && skill.platform.trim()) {
|
||||
platforms.add(skill.platform.trim());
|
||||
}
|
||||
if (Array.isArray(skill.platforms)) {
|
||||
for (const platform of skill.platforms) {
|
||||
if (typeof platform === "string" && platform.trim()) {
|
||||
platforms.add(platform.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of PLATFORM_KEYS) {
|
||||
if (skill[key] && typeof skill[key] === "object") {
|
||||
platforms.add(key);
|
||||
}
|
||||
}
|
||||
return [...platforms];
|
||||
}
|
||||
|
||||
function installAgentForSkill(skill) {
|
||||
const platforms = collectDeclaredPlatforms(skill);
|
||||
if (platforms.length === 0) {
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
const matchedAgents = new Set();
|
||||
let allPlatformsMatched = true;
|
||||
for (const platform of platforms) {
|
||||
const candidate = PLATFORM_AGENT_ALIASES.get(platform) || platform;
|
||||
if (KNOWN_AGENT_TYPES.has(candidate)) {
|
||||
matchedAgents.add(candidate);
|
||||
} else {
|
||||
allPlatformsMatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allPlatformsMatched && matchedAgents.size === 1) {
|
||||
return [...matchedAgents][0];
|
||||
}
|
||||
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
function platformMetadata(skill, platform) {
|
||||
const direct = skill[platform];
|
||||
return direct && typeof direct === "object" ? direct : {};
|
||||
@@ -309,7 +264,7 @@ function buildInstallDoc({ skill, repository, tag, sourceRef }) {
|
||||
const refSuffix = sourceRef && sourceRef !== "main" ? `#${sourceRef}` : "";
|
||||
const source = `${repository}${refSuffix}`;
|
||||
const releaseUrl = tag ? `https://github.com/${repository}/releases/tag/${tag}` : `https://github.com/${repository}`;
|
||||
const agent = installAgentForSkill(skill);
|
||||
const agent = installAgentForSkill(skill, KNOWN_AGENT_TYPES);
|
||||
|
||||
return `# Install and Update ${skill.name}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { collectDeclaredPlatforms, PLATFORM_KEYS } from "./skill_platforms.mjs";
|
||||
|
||||
const EXPLICIT_SLUGS = new Map([
|
||||
["openclaw-traffic-guardian", "clawsec-openclaw-traffic-guardian"],
|
||||
["openclaw-audit-watchdog", "clawsec-openclaw-audit-watchdog"],
|
||||
["soul-guardian", "clawsec-openclaw-soul-guardian"],
|
||||
["hermes-attestation-guardian", "clawsec-hermes-attestation-guardian"],
|
||||
["hermes-traffic-guardian", "clawsec-hermes-traffic-guardian"],
|
||||
["nanoclaw-traffic-guardian", "clawsec-nanoclaw-traffic-guardian"],
|
||||
["picoclaw-security-guardian", "clawsec-picoclaw-security-guardian"],
|
||||
["picoclaw-self-pen-testing", "clawsec-picoclaw-self-pen-testing"],
|
||||
["picoclaw-traffic-guardian", "clawsec-picoclaw-traffic-guardian"],
|
||||
["clawtributor", "clawsec-clawtributor"],
|
||||
]);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage: node scripts/ci/resolve_clawhub_slug.mjs <skill-dir-or-name>",
|
||||
"",
|
||||
"Prints the ClawHub slug for a skill without changing the GitHub release tag or skill package name.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function loadSkill(input) {
|
||||
const skillJsonPath = existsSync(path.join(input, "skill.json")) ? path.join(input, "skill.json") : null;
|
||||
if (!skillJsonPath) {
|
||||
return { name: input, platforms: [] };
|
||||
}
|
||||
|
||||
const skill = JSON.parse(readFileSync(skillJsonPath, "utf8"));
|
||||
if (!skill.name || typeof skill.name !== "string") {
|
||||
throw new Error(`${skillJsonPath} missing string field: name`);
|
||||
}
|
||||
|
||||
return { name: skill.name, platforms: collectDeclaredPlatforms(skill) };
|
||||
}
|
||||
|
||||
export function resolveClawHubSlug({ name, platforms = [] }) {
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
throw new Error(`Invalid skill name for ClawHub slug mapping: ${name}`);
|
||||
}
|
||||
|
||||
if (name.startsWith("clawsec-")) {
|
||||
return name;
|
||||
}
|
||||
|
||||
if (EXPLICIT_SLUGS.has(name)) {
|
||||
return EXPLICIT_SLUGS.get(name);
|
||||
}
|
||||
|
||||
if (PLATFORM_KEYS.some((platform) => name.startsWith(`${platform}-`))) {
|
||||
return `clawsec-${name}`;
|
||||
}
|
||||
|
||||
const declaredPlatforms = collectDeclaredPlatforms({ platforms });
|
||||
if (declaredPlatforms.length === 1 && PLATFORM_KEYS.includes(declaredPlatforms[0])) {
|
||||
return `clawsec-${declaredPlatforms[0]}-${name}`;
|
||||
}
|
||||
|
||||
return `clawsec-${name}`;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const input = process.argv[2];
|
||||
if (!input || input === "--help" || input === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(input ? 0 : 1);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(resolveClawHubSlug(loadSkill(input)));
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export const PLATFORM_KEYS = Object.freeze(["openclaw", "nanoclaw", "hermes", "picoclaw"]);
|
||||
|
||||
const PLATFORM_AGENT_ALIASES = new Map([["hermes", "hermes-agent"]]);
|
||||
|
||||
function asStringArray(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim());
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function collectDeclaredPlatforms(skill) {
|
||||
const platforms = new Set([
|
||||
...asStringArray(skill.platform),
|
||||
...asStringArray(skill.platforms),
|
||||
]);
|
||||
|
||||
for (const key of PLATFORM_KEYS) {
|
||||
if (skill[key] && typeof skill[key] === "object") {
|
||||
platforms.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return [...platforms];
|
||||
}
|
||||
|
||||
export function installAgentForSkill(skill, agentTypes, fallback = "openclaw") {
|
||||
const platforms = collectDeclaredPlatforms(skill);
|
||||
if (platforms.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const matchedAgents = new Set();
|
||||
let allPlatformsMatched = true;
|
||||
for (const platform of platforms) {
|
||||
const candidate = PLATFORM_AGENT_ALIASES.get(platform) || platform;
|
||||
if (agentTypes.has(candidate)) {
|
||||
matchedAgents.add(candidate);
|
||||
} else {
|
||||
allPlatformsMatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allPlatformsMatched && matchedAgents.size === 1) {
|
||||
return [...matchedAgents][0];
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -4,12 +4,11 @@ import { existsSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import https from "node:https";
|
||||
import path from "node:path";
|
||||
import { installAgentForSkill } from "./skill_platforms.mjs";
|
||||
|
||||
const DEFAULT_REPOSITORY = "prompt-security/clawsec";
|
||||
const DEFAULT_AGENT_TYPES_URL = "https://raw.githubusercontent.com/vercel-labs/skills/main/src/types.ts";
|
||||
const DOC_FILENAMES = ["README.md", "SKILL.md"];
|
||||
const KNOWN_PLATFORM_KEYS = ["openclaw", "nanoclaw", "picoclaw", "hermes"];
|
||||
const PLATFORM_AGENT_ALIASES = new Map([["hermes", "hermes-agent"]]);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
@@ -170,55 +169,6 @@ async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function collectDeclaredPlatforms(skill) {
|
||||
const platforms = new Set();
|
||||
|
||||
if (typeof skill.platform === "string" && skill.platform.trim()) {
|
||||
platforms.add(skill.platform.trim());
|
||||
}
|
||||
|
||||
if (Array.isArray(skill.platforms)) {
|
||||
for (const platform of skill.platforms) {
|
||||
if (typeof platform === "string" && platform.trim()) {
|
||||
platforms.add(platform.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of KNOWN_PLATFORM_KEYS) {
|
||||
if (skill[key] && typeof skill[key] === "object") {
|
||||
platforms.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return [...platforms];
|
||||
}
|
||||
|
||||
function agentForSkill(skill, agentTypes) {
|
||||
const platforms = collectDeclaredPlatforms(skill);
|
||||
if (platforms.length === 0) {
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
const matchedAgents = new Set();
|
||||
let allPlatformsMatched = true;
|
||||
|
||||
for (const platform of platforms) {
|
||||
const aliasedPlatform = PLATFORM_AGENT_ALIASES.get(platform) || platform;
|
||||
if (agentTypes.has(aliasedPlatform)) {
|
||||
matchedAgents.add(aliasedPlatform);
|
||||
} else {
|
||||
allPlatformsMatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allPlatformsMatched && matchedAgents.size === 1) {
|
||||
return [...matchedAgents][0];
|
||||
}
|
||||
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
function hasRequiredCommand(markdown, { repository, skillName, agent }) {
|
||||
return markdown
|
||||
.split("\n")
|
||||
@@ -238,7 +188,7 @@ async function validateSkill({ root, skillDir, repository, agentTypes }) {
|
||||
const skillJsonPath = path.join(root, skillDir, "skill.json");
|
||||
const skill = await readJson(skillJsonPath);
|
||||
const skillName = skill.name || path.basename(skillDir);
|
||||
const agent = agentForSkill(skill, agentTypes);
|
||||
const agent = installAgentForSkill(skill, agentTypes);
|
||||
const command = `npx skills add ${repository} --skill ${skillName} -a ${agent} -y`;
|
||||
const failures = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveClawHubSlug } from "./ci/resolve_clawhub_slug.mjs";
|
||||
import { collectDeclaredPlatforms, installAgentForSkill } from "./ci/skill_platforms.mjs";
|
||||
|
||||
const cases = [
|
||||
["openclaw-traffic-guardian", ["openclaw"], "clawsec-openclaw-traffic-guardian"],
|
||||
["openclaw-audit-watchdog", ["openclaw"], "clawsec-openclaw-audit-watchdog"],
|
||||
["soul-guardian", ["openclaw"], "clawsec-openclaw-soul-guardian"],
|
||||
["hermes-attestation-guardian", ["hermes"], "clawsec-hermes-attestation-guardian"],
|
||||
["hermes-traffic-guardian", ["hermes"], "clawsec-hermes-traffic-guardian"],
|
||||
["nanoclaw-traffic-guardian", ["nanoclaw"], "clawsec-nanoclaw-traffic-guardian"],
|
||||
["picoclaw-security-guardian", ["picoclaw"], "clawsec-picoclaw-security-guardian"],
|
||||
["picoclaw-self-pen-testing", ["picoclaw"], "clawsec-picoclaw-self-pen-testing"],
|
||||
["picoclaw-traffic-guardian", ["picoclaw"], "clawsec-picoclaw-traffic-guardian"],
|
||||
["clawtributor", ["openclaw", "nanoclaw", "hermes", "picoclaw"], "clawsec-clawtributor"],
|
||||
["clawsec-feed", ["openclaw"], "clawsec-feed"],
|
||||
["clawsec-suite", ["openclaw"], "clawsec-suite"],
|
||||
];
|
||||
|
||||
for (const [name, platforms, expected] of cases) {
|
||||
assert.equal(resolveClawHubSlug({ name, platforms }), expected, `${name} should map to ${expected}`);
|
||||
assert.equal(resolveClawHubSlug({ name }), expected, `${name} should map to ${expected} without metadata`);
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => resolveClawHubSlug({ name: "../openclaw-traffic-guardian", platforms: ["openclaw"] }),
|
||||
/Invalid skill name/,
|
||||
"unsafe skill names must be rejected",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
collectDeclaredPlatforms({
|
||||
platform: "openclaw",
|
||||
platforms: ["hermes", "openclaw", ""],
|
||||
picoclaw: { requires: {} },
|
||||
}),
|
||||
["openclaw", "hermes", "picoclaw"],
|
||||
"declared platform parsing should combine legacy fields, arrays, and platform metadata keys",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
installAgentForSkill({ platform: "hermes" }, new Set(["codex", "hermes-agent", "openclaw"])),
|
||||
"hermes-agent",
|
||||
"install agent selection should reuse platform aliases",
|
||||
);
|
||||
@@ -155,3 +155,57 @@ assert.ok(
|
||||
workflow.includes('simulated_version | test("^[0-9]+\\\\.[0-9]+\\\\.[0-9]+(-[a-zA-Z0-9]+)?$")'),
|
||||
'Skill release workflow must accept every prerelease version format that release-skill.sh accepts',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/clawhub_slug: \$\{\{ steps\.publishable\.outputs\.clawhub_slug \}\}/,
|
||||
'Skill release workflow must expose the resolved ClawHub slug from release-tag outputs',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/CLAWHUB_SLUG=\$\(node scripts\/ci\/resolve_clawhub_slug\.mjs "\$SKILL_PATH"\)/,
|
||||
'Skill release workflow must resolve the ClawHub slug from the skill package path',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/cp scripts\/ci\/resolve_clawhub_slug\.mjs "\$RUNNER_TEMP\/resolve_clawhub_slug\.mjs"/,
|
||||
'Manual ClawHub republish must preserve the current slug helper before checking out an older release tag',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/CLAWHUB_SLUG=\$\(node "\$RUNNER_TEMP\/resolve_clawhub_slug\.mjs" "\$SKILL_PATH"\)/,
|
||||
'Manual ClawHub republish must resolve slugs with the preserved helper against the checked-out tag metadata',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/npx clawhub@latest install \$\{CLAWHUB_SLUG\}/,
|
||||
'GitHub release quick install instructions must use the resolved ClawHub slug',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/clawhub inspect "\$CLAWHUB_SLUG" --version "\$VERSION" --json/,
|
||||
'Duplicate ClawHub version guard must inspect the resolved ClawHub slug',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/--slug "\$CLAWHUB_SLUG"/,
|
||||
'ClawHub publish must use the resolved ClawHub slug',
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/clawhub inspect "\$SKILL_NAME" --version "\$VERSION" --json/,
|
||||
'Duplicate ClawHub version guard must not inspect the raw skill package name',
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/--slug "\$SKILL_NAME"/,
|
||||
'ClawHub publish must not use the raw skill package name as the ClawHub slug',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user