From 6f8dca2362ac53aacd703b52fe26a60740c84a31 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Thu, 15 Oct 2026 22:32:58 +0800 Subject: [PATCH 1/2] ci(skills): hide retired skills on ClawHub once the sync has run clawhub sync stops publishing a retired skill but leaves its last version listed. A new step inspects each RETIRED_SKILLS name under heygen-com and runs `clawhub hide --yes` with its retirement notice for the ones still listed. A hidden skill reads as not found, so later runs hide nothing; a dry run only names the skills still listed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/sync-skills-to-clawhub.yml | 12 +- package.json | 2 +- scripts/clawhub-unlist-retired.mjs | 81 +++++++++++ scripts/clawhub-unlist-retired.test.mjs | 135 +++++++++++++++++++ 4 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 scripts/clawhub-unlist-retired.mjs create mode 100644 scripts/clawhub-unlist-retired.test.mjs diff --git a/.github/workflows/sync-skills-to-clawhub.yml b/.github/workflows/sync-skills-to-clawhub.yml index 40be5d64b8b..7c6b76292a8 100644 --- a/.github/workflows/sync-skills-to-clawhub.yml +++ b/.github/workflows/sync-skills-to-clawhub.yml @@ -3,7 +3,8 @@ name: Sync skills to ClawHub # Publishes changed skills under skills/ to ClawHub (https://clawhub.ai/heygen-com) # whenever they land on main. `clawhub sync` only publishes skills whose content # changed vs. the registry, auto-bumping the patch version — unchanged skills are -# a no-op, so this is safe to run on every push. +# a no-op, so this is safe to run on every push. Skills listed in RETIRED_SKILLS +# (packages/cli/src/utils/skillsManifest.ts) are hidden once, then left alone. on: push: @@ -11,10 +12,12 @@ on: paths: - "skills/**" - "scripts/clawhub-sync.mjs" + - "scripts/clawhub-unlist-retired.mjs" + - "packages/cli/src/utils/skillsManifest.ts" workflow_dispatch: inputs: dry_run: - description: "Preview what would publish without publishing" + description: "Preview what would publish or be hidden, without changing ClawHub" type: boolean default: false @@ -52,3 +55,8 @@ jobs: env: DRY_RUN: ${{ github.event.inputs.dry_run }} run: node scripts/clawhub-sync.mjs + + - name: Hide retired skills + env: + DRY_RUN: ${{ github.event.inputs.dry_run }} + run: node scripts/clawhub-unlist-retired.mjs diff --git a/package.json b/package.json index c5b8d47da81..566099bb410 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/clawhub-unlist-retired.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/scripts/clawhub-unlist-retired.mjs b/scripts/clawhub-unlist-retired.mjs new file mode 100644 index 00000000000..de3a49f31b2 --- /dev/null +++ b/scripts/clawhub-unlist-retired.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// `clawhub sync` never republishes a retired skill, but its last version stays listed until +// the owner hides it. Hiding is ClawHub's owner soft delete (`clawhub unhide` restores it), and +// the registry answers "Skill not found" for a hidden skill, which is what makes a rerun a no-op. +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const OWNER_HANDLE = "heygen-com"; +const NOT_FOUND = /Skill not found/; + +function parsedOutput(result) { + try { + return JSON.parse(result.output); + } catch { + return null; + } +} + +/** "listed", "unlisted", or the inspect failure that settles neither. */ +function listing(run, slug) { + const inspected = run(["inspect", `${OWNER_HANDLE}/${slug}`, "--json"]); + if (!inspected.ok) return NOT_FOUND.test(inspected.message) ? "unlisted" : inspected.message; + const parsed = parsedOutput(inspected); + if (!parsed) return `inspect printed no JSON: ${inspected.message}`; + return parsed.skill ? "listed" : "unlisted"; +} + +/** Hides every retired skill ClawHub still lists; a dry run only names them. */ +export function unlistRetired({ run, names, reasonFor, dryRun }) { + const hidden = []; + const pending = []; + const errors = []; + for (const slug of names) { + const state = listing(run, slug); + if (state === "unlisted") continue; + if (state !== "listed") { + errors.push(`${slug}: ${state}`); + continue; + } + if (dryRun) { + pending.push(slug); + continue; + } + const hide = run(["hide", `${OWNER_HANDLE}/${slug}`, "--yes", "--reason", reasonFor(slug)]); + if (hide.ok) hidden.push(slug); + else errors.push(`${slug}: ${hide.message}`); + } + return { hidden, pending, errors }; +} + +function runClawhub(args) { + const result = spawnSync("clawhub", args, { encoding: "utf8" }); + if (args[0] !== "inspect") { + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + } + return { ok: result.status === 0, output: result.stdout, message: result.stdout + result.stderr }; +} + +async function main() { + const manifest = new URL("../packages/cli/src/utils/skillsManifest.ts", import.meta.url); + const { RETIRED_SKILLS, retiredSkillNotice } = await import(manifest.href); + const dryRun = process.env.DRY_RUN === "true"; + const { hidden, pending, errors } = unlistRetired({ + run: runClawhub, + names: RETIRED_SKILLS, + reasonFor: retiredSkillNotice, + dryRun, + }); + if (hidden.length > 0) console.log(`Hid retired skills on ClawHub:\n${hidden.join("\n")}`); + if (pending.length > 0) + console.log(`Retired skills still listed on ClawHub (dry run):\n${pending.join("\n")}`); + if (hidden.length + pending.length + errors.length === 0) + console.log(`No retired skill is listed on ClawHub (${RETIRED_SKILLS.length} checked).`); + for (const error of errors) console.error(`::error::${error}`); + if (errors.length > 0) process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/scripts/clawhub-unlist-retired.test.mjs b/scripts/clawhub-unlist-retired.test.mjs new file mode 100644 index 00000000000..bb09accb798 --- /dev/null +++ b/scripts/clawhub-unlist-retired.test.mjs @@ -0,0 +1,135 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { unlistRetired } from "./clawhub-unlist-retired.mjs"; +import { RETIRED_SKILLS } from "../packages/cli/src/utils/skillsManifest.ts"; + +const NOT_FOUND = "Error: Skill not found or unavailable to this account."; + +function fakeClawhub({ listed = [], moderationOnly = [], inspectError = null, refuse = null }) { + const calls = []; + const visible = new Set(listed); + const inspect = (args) => { + const slug = args[1].split("/")[1]; + if (inspectError) return { ok: false, output: "", message: inspectError }; + if (moderationOnly.includes(slug)) + return { ok: true, output: JSON.stringify({ skill: null, moderation: {} }), message: "" }; + if (!visible.has(slug)) return { ok: false, output: "", message: NOT_FOUND }; + const output = JSON.stringify({ skill: { slug, tags: { latest: "1.0.3" } } }); + return { ok: true, output, message: output }; + }; + const hide = (args) => { + if (refuse) return { ok: false, output: "", message: refuse }; + visible.delete(args[1].split("/")[1]); + return { ok: true, output: "", message: "" }; + }; + const commands = { inspect, hide }; + const run = (args) => { + calls.push(args); + return commands[args[0]](args); + }; + return { run, calls }; +} + +const reasonFor = (slug) => `${slug} was retired`; +const unlist = (clawhub, names, dryRun = false) => + unlistRetired({ run: clawhub.run, names, reasonFor, dryRun }); +const hideCalls = (clawhub) => clawhub.calls.filter((args) => args[0] === "hide"); + +describe("ClawHub retired-skill unlisting", () => { + it("hides each retired skill still listed under the owner, with its retirement note", () => { + const clawhub = fakeClawhub({ listed: ["general-video"] }); + + assert.deepEqual(unlist(clawhub, ["general-video", "pr-to-video"]), { + hidden: ["general-video"], + pending: [], + errors: [], + }); + assert.deepEqual(clawhub.calls, [ + ["inspect", "heygen-com/general-video", "--json"], + ["hide", "heygen-com/general-video", "--yes", "--reason", "general-video was retired"], + ["inspect", "heygen-com/pr-to-video", "--json"], + ]); + }); + + it("hides nothing on a second run", () => { + const clawhub = fakeClawhub({ listed: ["general-video", "pr-to-video"] }); + unlist(clawhub, ["general-video", "pr-to-video"]); + clawhub.calls.length = 0; + + assert.deepEqual(unlist(clawhub, ["general-video", "pr-to-video"]), { + hidden: [], + pending: [], + errors: [], + }); + assert.equal(hideCalls(clawhub).length, 0); + }); + + it("counts a skill the registry shows only by its moderation record as unlisted", () => { + const clawhub = fakeClawhub({ moderationOnly: ["hyperframes-core"] }); + + assert.deepEqual(unlist(clawhub, ["hyperframes-core"]).hidden, []); + assert.equal(hideCalls(clawhub).length, 0); + }); + + it("only names the listed skills in a dry run", () => { + const clawhub = fakeClawhub({ listed: ["general-video", "hyperframes-audio"] }); + + assert.deepEqual(unlist(clawhub, ["general-video", "pr-to-video", "hyperframes-audio"], true), { + hidden: [], + pending: ["general-video", "hyperframes-audio"], + errors: [], + }); + assert.equal(hideCalls(clawhub).length, 0); + }); + + it("reports an inspect failure other than not-found, without hiding", () => { + const clawhub = fakeClawhub({ listed: ["general-video"], inspectError: "fetch failed" }); + + assert.deepEqual(unlist(clawhub, ["general-video"]).errors, ["general-video: fetch failed"]); + assert.equal(hideCalls(clawhub).length, 0); + }); + + it("reports a hide the registry refuses", () => { + const clawhub = fakeClawhub({ listed: ["general-video"], refuse: "Forbidden" }); + + assert.deepEqual(unlist(clawhub, ["general-video"]), { + hidden: [], + pending: [], + errors: ["general-video: Forbidden"], + }); + }); + + it("reads the retired names from the CLI manifest and fails the workflow on a refusal", () => { + const bin = mkdtempSync(join(tmpdir(), "clawhub-fake-")); + try { + writeFileSync( + join(bin, "clawhub"), + [ + "#!/bin/sh", + 'if [ "$1" = inspect ]; then echo \'{"skill":{"slug":"x"}}\'; exit 0; fi', + "echo 'Forbidden' >&2", + "exit 1", + "", + ].join("\n"), + ); + chmodSync(join(bin, "clawhub"), 0o755); + const script = new URL("./clawhub-unlist-retired.mjs", import.meta.url).pathname; + const run = (env) => + spawnSync(process.execPath, [script], { encoding: "utf8", env: { PATH: bin, ...env } }); + + const preview = run({ DRY_RUN: "true" }); + assert.equal(preview.status, 0, preview.stderr); + for (const name of RETIRED_SKILLS) assert.match(preview.stdout, new RegExp(`^${name}$`, "m")); + + const refused = run({}); + assert.equal(refused.status, 1); + assert.match(refused.stderr, /::error::general-video: Forbidden/); + } finally { + rmSync(bin, { recursive: true, force: true }); + } + }); +}); From 2f867fc0a41126c8c78c1bf43a4b569842291749 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Thu, 22 Oct 2026 05:49:29 +0800 Subject: [PATCH 2/2] ci(skills): import the retirement manifest at the top of the unlist script Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/clawhub-unlist-retired.mjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/clawhub-unlist-retired.mjs b/scripts/clawhub-unlist-retired.mjs index de3a49f31b2..1deda5838eb 100644 --- a/scripts/clawhub-unlist-retired.mjs +++ b/scripts/clawhub-unlist-retired.mjs @@ -4,6 +4,7 @@ // the registry answers "Skill not found" for a hidden skill, which is what makes a rerun a no-op. import { spawnSync } from "node:child_process"; import { pathToFileURL } from "node:url"; +import { RETIRED_SKILLS, retiredSkillNotice } from "../packages/cli/src/utils/skillsManifest.ts"; const OWNER_HANDLE = "heygen-com"; const NOT_FOUND = /Skill not found/; @@ -57,9 +58,7 @@ function runClawhub(args) { return { ok: result.status === 0, output: result.stdout, message: result.stdout + result.stderr }; } -async function main() { - const manifest = new URL("../packages/cli/src/utils/skillsManifest.ts", import.meta.url); - const { RETIRED_SKILLS, retiredSkillNotice } = await import(manifest.href); +function main() { const dryRun = process.env.DRY_RUN === "true"; const { hidden, pending, errors } = unlistRetired({ run: runClawhub, @@ -77,5 +76,5 @@ async function main() { } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); + main(); }