Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/sync-skills-to-clawhub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ 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:
branches: [main]
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

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 80 additions & 0 deletions scripts/clawhub-unlist-retired.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/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";
import { RETIRED_SKILLS, retiredSkillNotice } from "../packages/cli/src/utils/skillsManifest.ts";

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 };
}

function main() {
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) {
main();
}
135 changes: 135 additions & 0 deletions scripts/clawhub-unlist-retired.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading