fix(skills): namespace ClawHub skill slugs (#263)

* fix(release): map ClawHub publish slugs

* fix(release): share skill platform parsing
This commit is contained in:
davida-ps
2026-06-10 16:39:19 +03:00
committed by GitHub
parent d99f324f72
commit 59d54ed778
7 changed files with 258 additions and 107 deletions
+24 -8
View File
@@ -845,6 +845,7 @@ jobs:
publishable: ${{ steps.publishable.outputs.publishable }}
openclaw_skill: ${{ steps.publishable.outputs.openclaw_skill }}
publish_clawhub: ${{ steps.publishable.outputs.publish_clawhub }}
clawhub_slug: ${{ steps.publishable.outputs.clawhub_slug }}
steps:
- name: Parse tag
id: parse
@@ -942,10 +943,13 @@ jobs:
PUBLISH_CLAWHUB=true
fi
CLAWHUB_SLUG=$(node scripts/ci/resolve_clawhub_slug.mjs "$SKILL_PATH")
echo "internal=${INTERNAL}" >> $GITHUB_OUTPUT
echo "openclaw_skill=${OPENCLAW_SKILL}" >> $GITHUB_OUTPUT
echo "publish_clawhub=${PUBLISH_CLAWHUB}" >> $GITHUB_OUTPUT
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
echo "clawhub_slug=${CLAWHUB_SLUG}" >> $GITHUB_OUTPUT
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -1318,6 +1322,7 @@ jobs:
run: |
set -euo pipefail
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
CLAWHUB_SLUG="${{ steps.publishable.outputs.clawhub_slug }}"
VERSION="${{ steps.parse.outputs.version }}"
REPO="${{ github.repository }}"
TAG="${{ github.ref_name }}"
@@ -1351,7 +1356,7 @@ jobs:
**Via ClawHub (recommended):**
\`\`\`bash
npx clawhub@latest install ${SKILL_NAME}
npx clawhub@latest install ${CLAWHUB_SLUG}
\`\`\`
**If you already have \`clawsec-suite\` installed:**
@@ -1567,23 +1572,24 @@ jobs:
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
REGISTRY=${CLAWHUB_REGISTRY:-$SITE}
SKILL_NAME="${{ needs.release-tag.outputs.skill_name }}"
CLAWHUB_SLUG="${{ needs.release-tag.outputs.clawhub_slug }}"
VERSION="${{ needs.release-tag.outputs.version }}"
export CLAWHUB_CONFIG_PATH="$HOME/.clawhub-ci/config.json"
set +e
CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
clawhub inspect "$SKILL_NAME" --version "$VERSION" --json \
clawhub inspect "$CLAWHUB_SLUG" --version "$VERSION" --json \
> /tmp/clawhub-existing-version.json 2> /tmp/clawhub-existing-version.err
STATUS=$?
set -e
if [ "$STATUS" -eq 0 ]; then
echo "::error::ClawHub already contains ${SKILL_NAME}@${VERSION}. Bump the version before tagging."
echo "::error::ClawHub already contains ${CLAWHUB_SLUG}@${VERSION}. Bump the version before tagging."
exit 1
fi
if grep -Eqi "Version not found|Skill not found" /tmp/clawhub-existing-version.err; then
echo "No existing ${SKILL_NAME}@${VERSION} detected in ClawHub. Proceeding."
echo "No existing ${CLAWHUB_SLUG}@${VERSION} detected in ClawHub. Proceeding."
else
echo "::error::Failed to verify ClawHub version precondition."
cat /tmp/clawhub-existing-version.err
@@ -1598,6 +1604,7 @@ jobs:
REGISTRY=${CLAWHUB_REGISTRY:-$SITE}
SKILL_PATH="${{ needs.release-tag.outputs.skill_path }}"
SKILL_NAME="${{ needs.release-tag.outputs.skill_name }}"
CLAWHUB_SLUG="${{ needs.release-tag.outputs.clawhub_slug }}"
VERSION="${{ needs.release-tag.outputs.version }}"
NAME=$(jq -r '.name' "$SKILL_PATH/skill.json")
CHANGELOG="Release ${VERSION} via CI"
@@ -1606,7 +1613,7 @@ jobs:
if ! CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
clawhub publish "$SKILL_PATH" \
--slug "$SKILL_NAME" \
--slug "$CLAWHUB_SLUG" \
--name "$NAME" \
--version "$VERSION" \
--changelog "$CHANGELOG" \
@@ -1616,7 +1623,7 @@ jobs:
exit 1
fi
echo "✓ Successfully published $SKILL_NAME@$VERSION to ClawHub"
echo "✓ Successfully published $SKILL_NAME@$VERSION to ClawHub as $CLAWHUB_SLUG"
republish-clawhub:
# Manual workflow to republish a specific tag to ClawHub
@@ -1643,6 +1650,12 @@ jobs:
echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}"
- name: Checkout workflow helpers
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Prepare ClawHub slug helper
run: cp scripts/ci/resolve_clawhub_slug.mjs "$RUNNER_TEMP/resolve_clawhub_slug.mjs"
- name: Checkout tag
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -1672,6 +1685,8 @@ jobs:
exit 1
fi
CLAWHUB_SLUG=$(node "$RUNNER_TEMP/resolve_clawhub_slug.mjs" "$SKILL_PATH")
echo "clawhub_slug=${CLAWHUB_SLUG}" >> $GITHUB_OUTPUT
echo "Skill is publishable to ClawHub"
- name: Setup Node
@@ -1750,18 +1765,19 @@ jobs:
REGISTRY=${CLAWHUB_REGISTRY:-$SITE}
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
CLAWHUB_SLUG="${{ steps.publishable.outputs.clawhub_slug }}"
VERSION="${{ steps.parse.outputs.version }}"
NAME=$(jq -r '.name' "$SKILL_PATH/skill.json")
CHANGELOG="Manual republish of ${VERSION} via workflow_dispatch"
export CLAWHUB_CONFIG_PATH="$HOME/.clawhub-ci/config.json"
echo "Publishing $SKILL_NAME@$VERSION to ClawHub..."
echo "Publishing $SKILL_NAME@$VERSION to ClawHub as $CLAWHUB_SLUG..."
# Publish with idempotent retry handling
if ! CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
clawhub publish "$SKILL_PATH" \
--slug "$SKILL_NAME" \
--slug "$CLAWHUB_SLUG" \
--name "$NAME" \
--version "$VERSION" \
--changelog "$CHANGELOG" \
@@ -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}
+79
View File
@@ -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);
}
}
+52
View File
@@ -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;
}
+2 -52
View File
@@ -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 = [];
+45
View File
@@ -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",
);
+54
View File
@@ -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',
);