refactor(scripts): replace clawhub CLI with local sync-clawhub.mjs
This commit is contained in:
parent
7b8247544d
commit
6363bd83e2
|
|
@ -0,0 +1,528 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||
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",
|
||||
]);
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const config = await readClawhubConfig();
|
||||
const registry = (
|
||||
process.env.CLAWHUB_REGISTRY ||
|
||||
process.env.CLAWDHUB_REGISTRY ||
|
||||
config.registry ||
|
||||
DEFAULT_REGISTRY
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
if (!config.token) {
|
||||
throw new Error("Not logged in. Run: clawhub login");
|
||||
}
|
||||
|
||||
await apiJson(registry, config.token, "/api/v1/whoami");
|
||||
|
||||
const roots = options.roots.length > 0 ? options.roots : [path.resolve("skills")];
|
||||
const skills = await findSkills(roots);
|
||||
|
||||
if (skills.length === 0) {
|
||||
throw new Error("No skills found.");
|
||||
}
|
||||
|
||||
console.log("ClawHub sync");
|
||||
console.log(`Roots with skills: ${roots.join(", ")}`);
|
||||
|
||||
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
|
||||
const files = await listTextFiles(skill.folder);
|
||||
const fingerprint = buildFingerprint(files);
|
||||
return {
|
||||
...skill,
|
||||
fileCount: files.length,
|
||||
fingerprint,
|
||||
};
|
||||
});
|
||||
|
||||
const candidates = await mapWithConcurrency(locals, options.concurrency, async (skill) => {
|
||||
const query = new URLSearchParams({
|
||||
slug: skill.slug,
|
||||
hash: skill.fingerprint,
|
||||
});
|
||||
const { status, body } = await apiJsonWithStatus(
|
||||
registry,
|
||||
config.token,
|
||||
`/api/v1/resolve?${query.toString()}`
|
||||
);
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
...skill,
|
||||
status: "new",
|
||||
latestVersion: null,
|
||||
matchVersion: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error(body?.message || `Resolve failed for ${skill.slug} (HTTP ${status})`);
|
||||
}
|
||||
|
||||
const latestVersion = body?.latestVersion?.version ?? null;
|
||||
const matchVersion = body?.match?.version ?? null;
|
||||
|
||||
if (!latestVersion) {
|
||||
return {
|
||||
...skill,
|
||||
status: "new",
|
||||
latestVersion: null,
|
||||
matchVersion: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...skill,
|
||||
status: matchVersion ? "synced" : "update",
|
||||
latestVersion,
|
||||
matchVersion,
|
||||
};
|
||||
});
|
||||
|
||||
const actionable = candidates.filter((candidate) => candidate.status !== "synced");
|
||||
if (actionable.length === 0) {
|
||||
console.log("Nothing to sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("To sync");
|
||||
for (const candidate of actionable) {
|
||||
console.log(`- ${formatCandidate(candidate, options.bump)}`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log("");
|
||||
console.log(`Dry run: would upload ${actionable.length} skill(s).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = options.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of actionable) {
|
||||
const version =
|
||||
candidate.status === "new"
|
||||
? "1.0.0"
|
||||
: bumpSemver(candidate.latestVersion, options.bump);
|
||||
|
||||
console.log(`Publishing ${candidate.slug}@${version}`);
|
||||
const files = await listTextFiles(candidate.folder);
|
||||
await publishSkill({
|
||||
registry,
|
||||
token: config.token,
|
||||
skill: candidate,
|
||||
files,
|
||||
version,
|
||||
changelog: options.changelog,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Uploaded ${actionable.length} skill(s).`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
roots: [],
|
||||
dryRun: false,
|
||||
bump: "patch",
|
||||
changelog: "",
|
||||
tags: "latest",
|
||||
concurrency: 4,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--dry-run") {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--all") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--root") {
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error("--root requires a directory");
|
||||
options.roots.push(path.resolve(value));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--bump") {
|
||||
const value = argv[index + 1];
|
||||
if (!["patch", "minor", "major"].includes(value)) {
|
||||
throw new Error("--bump must be patch, minor, or major");
|
||||
}
|
||||
options.bump = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--changelog") {
|
||||
const value = argv[index + 1];
|
||||
if (value == null) throw new Error("--changelog requires text");
|
||||
options.changelog = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tags") {
|
||||
const value = argv[index + 1];
|
||||
if (value == null) throw new Error("--tags requires a value");
|
||||
options.tags = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--concurrency") {
|
||||
const value = Number(argv[index + 1]);
|
||||
if (!Number.isInteger(value) || value < 1 || value > 32) {
|
||||
throw new Error("--concurrency must be an integer between 1 and 32");
|
||||
}
|
||||
options.concurrency = value;
|
||||
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: sync-clawhub.mjs [options]
|
||||
|
||||
Options:
|
||||
--root <dir> Extra skill root (repeatable)
|
||||
--all Accepted for compatibility
|
||||
--dry-run Show what would be uploaded
|
||||
--bump <type> patch | minor | major
|
||||
--changelog <text> Changelog for updates
|
||||
--tags <tags> Comma-separated tags
|
||||
--concurrency <n> Registry check concurrency (1-32)
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
async function readClawhubConfig() {
|
||||
const configPath = getConfigPath();
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigPath() {
|
||||
const override =
|
||||
process.env.CLAWHUB_CONFIG_PATH?.trim() || process.env.CLAWDHUB_CONFIG_PATH?.trim();
|
||||
if (override) {
|
||||
return path.resolve(override);
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
if (process.platform === "darwin") {
|
||||
const clawhub = path.join(home, "Library", "Application Support", "clawhub", "config.json");
|
||||
const clawdhub = path.join(home, "Library", "Application Support", "clawdhub", "config.json");
|
||||
return pathForExistingConfig(clawhub, clawdhub);
|
||||
}
|
||||
|
||||
const xdg = process.env.XDG_CONFIG_HOME;
|
||||
if (xdg) {
|
||||
const clawhub = path.join(xdg, "clawhub", "config.json");
|
||||
const clawdhub = path.join(xdg, "clawdhub", "config.json");
|
||||
return pathForExistingConfig(clawhub, clawdhub);
|
||||
}
|
||||
|
||||
if (process.platform === "win32" && process.env.APPDATA) {
|
||||
const clawhub = path.join(process.env.APPDATA, "clawhub", "config.json");
|
||||
const clawdhub = path.join(process.env.APPDATA, "clawdhub", "config.json");
|
||||
return pathForExistingConfig(clawhub, clawdhub);
|
||||
}
|
||||
|
||||
const clawhub = path.join(home, ".config", "clawhub", "config.json");
|
||||
const clawdhub = path.join(home, ".config", "clawdhub", "config.json");
|
||||
return pathForExistingConfig(clawhub, clawdhub);
|
||||
}
|
||||
|
||||
function pathForExistingConfig(primary, legacy) {
|
||||
if (existsSync(primary)) return path.resolve(primary);
|
||||
if (existsSync(legacy)) return path.resolve(legacy);
|
||||
return path.resolve(primary);
|
||||
}
|
||||
|
||||
async function findSkills(roots) {
|
||||
const deduped = new Map();
|
||||
for (const root of roots) {
|
||||
const folders = await findSkillFolders(root);
|
||||
for (const folder of folders) {
|
||||
deduped.set(folder.slug, folder);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()].sort((left, right) => left.slug.localeCompare(right.slug));
|
||||
}
|
||||
|
||||
async function findSkillFolders(root) {
|
||||
const stat = await safeStat(root);
|
||||
if (!stat?.isDirectory()) return [];
|
||||
|
||||
if (await hasSkillMarker(root)) {
|
||||
return [buildSkillEntry(root)];
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
const found = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const folder = path.join(root, entry.name);
|
||||
if (await hasSkillMarker(folder)) {
|
||||
found.push(buildSkillEntry(folder));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function buildSkillEntry(folder) {
|
||||
const base = path.basename(folder);
|
||||
return {
|
||||
folder,
|
||||
slug: sanitizeSlug(base),
|
||||
displayName: titleCase(base),
|
||||
};
|
||||
}
|
||||
|
||||
async function hasSkillMarker(folder) {
|
||||
return Boolean(
|
||||
(await safeStat(path.join(folder, "SKILL.md")))?.isFile() ||
|
||||
(await safeStat(path.join(folder, "skill.md")))?.isFile()
|
||||
);
|
||||
}
|
||||
|
||||
async function listTextFiles(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;
|
||||
|
||||
const fullPath = path.join(folder, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
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 bytes = await fs.readFile(fullPath);
|
||||
files.push({ relPath, bytes });
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root);
|
||||
files.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
||||
return files;
|
||||
}
|
||||
|
||||
function buildFingerprint(files) {
|
||||
const payload = files
|
||||
.map((file) => `${file.relPath}:${sha256(file.bytes)}`)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.join("\n");
|
||||
return crypto.createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function publishSkill({ registry, token, skill, files, version, changelog, tags }) {
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"payload",
|
||||
JSON.stringify({
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
version,
|
||||
changelog,
|
||||
tags,
|
||||
acceptLicenseTerms: true,
|
||||
})
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
form.append("files", new Blob([file.bytes], { type: "text/plain" }), file.relPath);
|
||||
}
|
||||
|
||||
const response = await fetch(`${registry}/api/v1/skills`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `Publish failed for ${skill.slug} (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const result = text ? JSON.parse(text) : {};
|
||||
console.log(`OK. Published ${skill.slug}@${version}${result.versionId ? ` (${result.versionId})` : ""}`);
|
||||
}
|
||||
|
||||
async function apiJson(registry, token, requestPath) {
|
||||
const { status, body } = await apiJsonWithStatus(registry, token, requestPath);
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(body?.message || `HTTP ${status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function apiJsonWithStatus(registry, token, requestPath) {
|
||||
const response = await fetch(`${registry}${requestPath}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = { message: text };
|
||||
}
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, fn) {
|
||||
const results = new Array(items.length);
|
||||
let cursor = 0;
|
||||
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
results[index] = await fn(items[index], index);
|
||||
}
|
||||
}
|
||||
|
||||
const count = Math.min(Math.max(limit, 1), Math.max(items.length, 1));
|
||||
await Promise.all(Array.from({ length: count }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
function formatCandidate(candidate, bump) {
|
||||
if (candidate.status === "new") {
|
||||
return `${candidate.slug} NEW (${candidate.fileCount} files)`;
|
||||
}
|
||||
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver(
|
||||
candidate.latestVersion,
|
||||
bump
|
||||
)} (${candidate.fileCount} files)`;
|
||||
}
|
||||
|
||||
function bumpSemver(version, bump) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semver: ${version}`);
|
||||
}
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
const patch = Number(match[3]);
|
||||
|
||||
if (bump === "major") return `${major + 1}.0.0`;
|
||||
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
function sanitizeSlug(value) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.replace(/--+/g, "-");
|
||||
}
|
||||
|
||||
function titleCase(value) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
async function safeStat(filePath) {
|
||||
try {
|
||||
return await fs.stat(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -4,13 +4,8 @@ set -euo pipefail
|
|||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SKILLS_DIR="${ROOT_DIR}/skills"
|
||||
|
||||
if command -v clawhub >/dev/null 2>&1; then
|
||||
CLAWHUB_CMD=(clawhub)
|
||||
elif command -v npx >/dev/null 2>&1; then
|
||||
CLAWHUB_CMD=(npx -y clawhub)
|
||||
else
|
||||
echo "Error: neither clawhub nor npx is available."
|
||||
echo "See https://docs.openclaw.ai/zh-CN/tools/clawhub"
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "Error: node is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -18,4 +13,4 @@ if [ "$#" -eq 0 ]; then
|
|||
set -- --all
|
||||
fi
|
||||
|
||||
exec "${CLAWHUB_CMD[@]}" sync --root "${SKILLS_DIR}" "$@"
|
||||
exec node "${ROOT_DIR}/scripts/sync-clawhub.mjs" --root "${SKILLS_DIR}" "$@"
|
||||
|
|
|
|||
Loading…
Reference in New Issue