diff --git a/.github/workflows/skill-release.yml b/.github/workflows/skill-release.yml index 263ad5e..521790c 100644 --- a/.github/workflows/skill-release.yml +++ b/.github/workflows/skill-release.yml @@ -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" \ diff --git a/scripts/ci/generate_skill_release_trust_packet.mjs b/scripts/ci/generate_skill_release_trust_packet.mjs index a8a547b..9817897 100644 --- a/scripts/ci/generate_skill_release_trust_packet.mjs +++ b/scripts/ci/generate_skill_release_trust_packet.mjs @@ -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} diff --git a/scripts/ci/resolve_clawhub_slug.mjs b/scripts/ci/resolve_clawhub_slug.mjs new file mode 100644 index 0000000..3be1c43 --- /dev/null +++ b/scripts/ci/resolve_clawhub_slug.mjs @@ -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 ", + "", + "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); + } +} diff --git a/scripts/ci/skill_platforms.mjs b/scripts/ci/skill_platforms.mjs new file mode 100644 index 0000000..bf1ea67 --- /dev/null +++ b/scripts/ci/skill_platforms.mjs @@ -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; +} diff --git a/scripts/ci/validate_skill_install_docs.mjs b/scripts/ci/validate_skill_install_docs.mjs index 0ea5ecc..5fcb27d 100644 --- a/scripts/ci/validate_skill_install_docs.mjs +++ b/scripts/ci/validate_skill_install_docs.mjs @@ -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 = []; diff --git a/scripts/test-skill-clawhub-slug.mjs b/scripts/test-skill-clawhub-slug.mjs new file mode 100644 index 0000000..cfc6b69 --- /dev/null +++ b/scripts/test-skill-clawhub-slug.mjs @@ -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", +); diff --git a/scripts/test-skill-release-workflow.mjs b/scripts/test-skill-release-workflow.mjs index f1af09b..5670374 100644 --- a/scripts/test-skill-release-workflow.mjs +++ b/scripts/test-skill-release-workflow.mjs @@ -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', +);