From 05dba5c320febfb8b6d024ff3302eb20f2a69151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Wed, 11 Mar 2026 21:36:19 -0500 Subject: [PATCH] refactor: publish skills directly from synced vendor --- .claude/skills/release-skills/SKILL.md | 8 +- .releaserc.yml | 5 +- CLAUDE.md | 2 +- docs/publishing.md | 2 +- .../{skill-artifact.mjs => release-files.mjs} | 77 +++---------------- scripts/lib/shared-skill-packages.mjs | 25 +++++- scripts/prepare-skill-artifact.mjs | 64 --------------- ...h-skill-artifact.mjs => publish-skill.mjs} | 27 +++---- scripts/sync-shared-skill-packages.mjs | 11 ++- 9 files changed, 62 insertions(+), 159 deletions(-) rename scripts/lib/{skill-artifact.mjs => release-files.mjs} (59%) delete mode 100644 scripts/prepare-skill-artifact.mjs rename scripts/{publish-skill-artifact.mjs => publish-skill.mjs} (88%) diff --git a/.claude/skills/release-skills/SKILL.md b/.claude/skills/release-skills/SKILL.md index 372ac6c..b11a47c 100644 --- a/.claude/skills/release-skills/SKILL.md +++ b/.claude/skills/release-skills/SKILL.md @@ -57,8 +57,8 @@ Supported hooks: | Hook | Purpose | Expected Responsibility | |------|---------|-------------------------| -| `prepare_artifact` | Build a releasable artifact for one target | Validate the target is self-contained, stage files, apply any project-specific packaging | -| `publish_artifact` | Publish one prepared artifact | Upload artifact, attach version/changelog/tags | +| `prepare_artifact` | Make one target releasable | Validate the target is self-contained, sync/embed local dependencies, optionally stage extra files | +| `publish_artifact` | Publish one releasable target | Upload the prepared target (or a staged directory if the project uses one), attach version/changelog/tags | Supported placeholders: @@ -66,14 +66,14 @@ Supported placeholders: |-------------|---------| | `{project_root}` | Absolute path to repository root | | `{target}` | Absolute path to the module/skill being released | -| `{artifact_dir}` | Absolute path to a temporary artifact directory for this target | +| `{artifact_dir}` | Absolute path to a temporary staging directory for this target, when the project uses one | | `{version}` | Version selected by the release workflow | | `{dry_run}` | `true` or `false` | | `{release_notes_file}` | Absolute path to a UTF-8 file containing release notes/changelog text | Execution rules: - Keep the skill generic: do not hardcode registry/package-manager/project layout details into this SKILL. -- If `prepare_artifact` exists, run it once per target before publish-related checks that need the final artifact. +- If `prepare_artifact` exists, run it once per target before publish-related checks that need the final releasable target state. - Write release notes to a temp file and pass that file path to `publish_artifact`; do not inline multiline changelog text into shell commands. - If hooks are absent, fall back to the default project-agnostic release workflow. diff --git a/.releaserc.yml b/.releaserc.yml index e6dbccc..438655c 100644 --- a/.releaserc.yml +++ b/.releaserc.yml @@ -1,7 +1,6 @@ release: target_globs: - skills/* - artifact_root: .release-artifacts hooks: - prepare_artifact: bun scripts/prepare-skill-artifact.mjs --skill-dir "{target}" --out-dir "{artifact_dir}" - publish_artifact: node scripts/publish-skill-artifact.mjs --skill-dir "{target}" --artifact-dir "{artifact_dir}" --version "{version}" --changelog-file "{release_notes_file}" --dry-run "{dry_run}" + prepare_artifact: node scripts/sync-shared-skill-packages.mjs --repo-root "{project_root}" --target "{target}" + publish_artifact: node scripts/publish-skill.mjs --skill-dir "{target}" --version "{version}" --changelog-file "{release_notes_file}" --dry-run "{dry_run}" diff --git a/CLAUDE.md b/CLAUDE.md index 7862e1c..b397eb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Skills organized into three categories in `.claude-plugin/marketplace.json` (def Each skill contains `SKILL.md` (YAML front matter + docs), optional `scripts/`, `references/`, `prompts/`. -Top-level `scripts/` contains repo maintenance utilities (sync, hooks, artifact build/publish). +Top-level `scripts/` contains repo maintenance utilities (sync, hooks, publish). ## Running Skills diff --git a/docs/publishing.md b/docs/publishing.md index 651dcdd..32a9bda 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -21,7 +21,7 @@ bash scripts/sync-clawhub.sh # sync all skills bash scripts/sync-clawhub.sh # sync one skill ``` -Release-time artifact preparation is configured via `.releaserc.yml`. Keep registry/project-specific packaging in hook scripts. +Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: release prep only syncs `packages/` into each skill's committed `scripts/vendor/`, and publish reads the skill directory directly. ## Shared Workspace Packages diff --git a/scripts/lib/skill-artifact.mjs b/scripts/lib/release-files.mjs similarity index 59% rename from scripts/lib/skill-artifact.mjs rename to scripts/lib/release-files.mjs index 578e06f..3e576f8 100644 --- a/scripts/lib/skill-artifact.mjs +++ b/scripts/lib/release-files.mjs @@ -1,47 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -const TEXT_EXTENSIONS = new Set([ - "md", - "mdx", - "txt", - "json", - "json5", - "yaml", - "yml", - "toml", - "js", - "cjs", - "mjs", - "ts", - "tsx", - "jsx", - "py", - "sh", - "rb", - "go", - "rs", - "swift", - "kt", - "java", - "cs", - "cpp", - "c", - "h", - "hpp", - "sql", - "csv", - "ini", - "cfg", - "env", - "xml", - "html", - "css", - "scss", - "sass", - "svg", -]); - const PACKAGE_DEPENDENCY_SECTIONS = [ "dependencies", "optionalDependencies", @@ -49,15 +8,18 @@ const PACKAGE_DEPENDENCY_SECTIONS = [ "devDependencies", ]; -export async function listTextFiles(root) { +const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules"]); +const SKIPPED_FILES = new Set([".DS_Store"]); + +export async function listReleaseFiles(root) { + const resolvedRoot = path.resolve(root); const files = []; async function walk(folder) { const entries = await fs.readdir(folder, { withFileTypes: true }); for (const entry of entries) { - if (entry.name.startsWith(".")) continue; - if (entry.name === "node_modules") continue; - if (entry.name === ".clawhub" || entry.name === ".clawdhub") continue; + if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) continue; + if (entry.isFile() && SKIPPED_FILES.has(entry.name)) continue; const fullPath = path.join(folder, entry.name); if (entry.isDirectory()) { @@ -66,36 +28,19 @@ export async function listTextFiles(root) { } if (!entry.isFile()) continue; - const relPath = path.relative(root, fullPath).split(path.sep).join("/"); - const ext = relPath.split(".").pop()?.toLowerCase() ?? ""; - if (!TEXT_EXTENSIONS.has(ext)) continue; - + const relPath = path.relative(resolvedRoot, fullPath).split(path.sep).join("/"); const bytes = await fs.readFile(fullPath); files.push({ relPath, bytes }); } } - await walk(root); + await walk(resolvedRoot); files.sort((left, right) => left.relPath.localeCompare(right.relPath)); return files; } -export async function collectReleaseFiles(root) { - await validateSelfContainedRelease(root); - return listTextFiles(root); -} - -export async function materializeReleaseFiles(files, outDir) { - await fs.mkdir(outDir, { recursive: true }); - for (const file of files) { - const outputPath = path.join(outDir, fromPosixRel(file.relPath)); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, file.bytes); - } -} - export async function validateSelfContainedRelease(root) { - const files = await listTextFiles(root); + const files = await listReleaseFiles(root); for (const file of files.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) { const packageDir = path.resolve(root, fromPosixRel(path.posix.dirname(file.relPath))); const packageJson = JSON.parse(file.bytes.toString("utf8")); @@ -108,7 +53,7 @@ export async function validateSelfContainedRelease(root) { const targetDir = path.resolve(packageDir, spec.slice(5)); if (!isWithinRoot(root, targetDir)) { throw new Error( - `Release artifact is not self-contained: ${file.relPath} depends on ${name} via ${spec}`, + `Release target is not self-contained: ${file.relPath} depends on ${name} via ${spec}`, ); } await fs.access(targetDir).catch(() => { diff --git a/scripts/lib/shared-skill-packages.mjs b/scripts/lib/shared-skill-packages.mjs index 81f259a..f283cb8 100644 --- a/scripts/lib/shared-skill-packages.mjs +++ b/scripts/lib/shared-skill-packages.mjs @@ -16,7 +16,8 @@ const SKIPPED_FILES = new Set([".DS_Store"]); export async function syncSharedSkillPackages(repoRoot, options = {}) { const root = path.resolve(repoRoot); const workspacePackages = await discoverWorkspacePackages(root); - const consumers = await discoverSkillScriptPackages(root); + const targetConsumerDirs = normalizeTargetConsumerDirs(root, options.targets ?? []); + const consumers = await discoverSkillScriptPackages(root, targetConsumerDirs); const runtime = options.install === false ? null : resolveBunRuntime(); const managedPaths = new Set(); const packageDirs = []; @@ -42,6 +43,25 @@ export async function syncSharedSkillPackages(repoRoot, options = {}) { }; } +function normalizeTargetConsumerDirs(repoRoot, targets) { + if (!targets || targets.length === 0) return null; + + const consumerDirs = new Set(); + for (const target of targets) { + if (!target) continue; + + const resolvedTarget = path.resolve(repoRoot, target); + if (path.basename(resolvedTarget) === "scripts") { + consumerDirs.add(resolvedTarget); + continue; + } + + consumerDirs.add(path.join(resolvedTarget, "scripts")); + } + + return consumerDirs; +} + export function ensureManagedPathsClean(repoRoot, managedPaths) { if (managedPaths.length === 0) return; @@ -182,13 +202,14 @@ async function discoverWorkspacePackages(repoRoot) { return map; } -async function discoverSkillScriptPackages(repoRoot) { +async function discoverSkillScriptPackages(repoRoot, targetConsumerDirs = null) { const skillsRoot = path.join(repoRoot, "skills"); const consumers = []; const skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true }); for (const entry of skillEntries) { if (!entry.isDirectory()) continue; const scriptsDir = path.join(skillsRoot, entry.name, "scripts"); + if (targetConsumerDirs && !targetConsumerDirs.has(path.resolve(scriptsDir))) continue; const packageJsonPath = path.join(scriptsDir, "package.json"); if (!existsSync(packageJsonPath)) continue; consumers.push({ dir: scriptsDir, packageJsonPath }); diff --git a/scripts/prepare-skill-artifact.mjs b/scripts/prepare-skill-artifact.mjs deleted file mode 100644 index e519758..0000000 --- a/scripts/prepare-skill-artifact.mjs +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node - -import path from "node:path"; - -import { collectReleaseFiles, materializeReleaseFiles } from "./lib/skill-artifact.mjs"; - -async function main() { - const options = parseArgs(process.argv.slice(2)); - if (!options.skillDir || !options.outDir) { - throw new Error("--skill-dir and --out-dir are required"); - } - - const skillDir = path.resolve(options.skillDir); - const outDir = path.resolve(options.outDir); - const files = await collectReleaseFiles(skillDir); - await materializeReleaseFiles(files, outDir); - - console.log(`Prepared artifact for ${path.basename(skillDir)}`); - console.log(`Source: ${skillDir}`); - console.log(`Output: ${outDir}`); - console.log(`Files: ${files.length}`); -} - -function parseArgs(argv) { - const options = { - skillDir: "", - outDir: "", - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--skill-dir") { - options.skillDir = argv[index + 1] ?? ""; - index += 1; - continue; - } - if (arg === "--out-dir") { - options.outDir = argv[index + 1] ?? ""; - index += 1; - continue; - } - if (arg === "-h" || arg === "--help") { - printUsage(); - process.exit(0); - } - throw new Error(`Unknown argument: ${arg}`); - } - - return options; -} - -function printUsage() { - console.log(`Usage: prepare-skill-artifact.mjs --skill-dir --out-dir - -Options: - --skill-dir Source skill directory - --out-dir Artifact output directory - -h, --help Show help`); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/publish-skill-artifact.mjs b/scripts/publish-skill.mjs similarity index 88% rename from scripts/publish-skill-artifact.mjs rename to scripts/publish-skill.mjs index 2d5f987..e22bad7 100644 --- a/scripts/publish-skill-artifact.mjs +++ b/scripts/publish-skill.mjs @@ -5,31 +5,31 @@ import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { listTextFiles } from "./lib/skill-artifact.mjs"; +import { listReleaseFiles, validateSelfContainedRelease } from "./lib/release-files.mjs"; const DEFAULT_REGISTRY = "https://clawhub.ai"; async function main() { const options = parseArgs(process.argv.slice(2)); - if (!options.skillDir || !options.artifactDir || !options.version) { - throw new Error("--skill-dir, --artifact-dir, and --version are required"); + if (!options.skillDir || !options.version) { + throw new Error("--skill-dir and --version are required"); } const skillDir = path.resolve(options.skillDir); - const artifactDir = path.resolve(options.artifactDir); const skill = buildSkillEntry(skillDir, options.slug, options.displayName); const changelog = options.changelogFile ? await fs.readFile(path.resolve(options.changelogFile), "utf8") : ""; - const files = await listTextFiles(artifactDir); + await validateSelfContainedRelease(skillDir); + const files = await listReleaseFiles(skillDir); if (files.length === 0) { - throw new Error(`Artifact directory is empty: ${artifactDir}`); + throw new Error(`Skill directory is empty: ${skillDir}`); } if (options.dryRun) { console.log(`Dry run: would publish ${skill.slug}@${options.version}`); - console.log(`Artifact: ${artifactDir}`); + console.log(`Skill: ${skillDir}`); console.log(`Files: ${files.length}`); return; } @@ -68,7 +68,6 @@ async function main() { function parseArgs(argv) { const options = { skillDir: "", - artifactDir: "", version: "", changelogFile: "", registry: "", @@ -85,11 +84,6 @@ function parseArgs(argv) { index += 1; continue; } - if (arg === "--artifact-dir") { - options.artifactDir = argv[index + 1] ?? ""; - index += 1; - continue; - } if (arg === "--version") { options.version = argv[index + 1] ?? ""; index += 1; @@ -141,11 +135,10 @@ function parseArgs(argv) { } function printUsage() { - console.log(`Usage: publish-skill-artifact.mjs --skill-dir --artifact-dir --version [options] + console.log(`Usage: publish-skill.mjs --skill-dir --version [options] Options: - --skill-dir Source skill directory (used for slug/display name) - --artifact-dir Prepared artifact directory + --skill-dir Skill directory to publish --version Version to publish --changelog-file Release notes file --registry Override registry base URL @@ -227,7 +220,7 @@ async function publishSkill({ registry, token, skill, files, version, changelog, ); for (const file of files) { - form.append("files", new Blob([file.bytes], { type: "text/plain" }), file.relPath); + form.append("files", new Blob([file.bytes], { type: "application/octet-stream" }), file.relPath); } const response = await fetch(`${registry}/api/v1/skills`, { diff --git a/scripts/sync-shared-skill-packages.mjs b/scripts/sync-shared-skill-packages.mjs index b5d9a87..ad8de57 100755 --- a/scripts/sync-shared-skill-packages.mjs +++ b/scripts/sync-shared-skill-packages.mjs @@ -10,7 +10,9 @@ import { async function main() { const options = parseArgs(process.argv.slice(2)); const repoRoot = path.resolve(options.repoRoot); - const result = await syncSharedSkillPackages(repoRoot); + const result = await syncSharedSkillPackages(repoRoot, { + targets: options.targets, + }); if (options.enforceClean) { ensureManagedPathsClean(repoRoot, result.managedPaths); @@ -23,6 +25,7 @@ function parseArgs(argv) { const options = { repoRoot: process.cwd(), enforceClean: false, + targets: [], }; for (let index = 0; index < argv.length; index += 1) { @@ -36,6 +39,11 @@ function parseArgs(argv) { options.enforceClean = true; continue; } + if (arg === "--target") { + options.targets.push(argv[index + 1] ?? ""); + index += 1; + continue; + } if (arg === "-h" || arg === "--help") { printUsage(); process.exit(0); @@ -51,6 +59,7 @@ function printUsage() { Options: --repo-root Repository root (default: current directory) + --target Sync only one skill directory (can be repeated) --enforce-clean Fail if managed files change after sync -h, --help Show help`); }