mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
refactor: unify skill cdp and release artifacts
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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",
|
||||
"peerDependencies",
|
||||
"devDependencies",
|
||||
];
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export async function collectReleaseFiles(root) {
|
||||
const baseFiles = await listTextFiles(root);
|
||||
const fileMap = new Map(baseFiles.map((file) => [file.relPath, file.bytes]));
|
||||
const vendoredPackages = new Set();
|
||||
|
||||
for (const file of baseFiles.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
|
||||
const packageDirRel = normalizeDirRel(path.posix.dirname(file.relPath));
|
||||
const rewritten = await rewritePackageJsonForRelease({
|
||||
root,
|
||||
packageDirRel,
|
||||
bytes: file.bytes,
|
||||
fileMap,
|
||||
vendoredPackages,
|
||||
});
|
||||
if (rewritten) {
|
||||
fileMap.set(file.relPath, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
return [...fileMap.entries()]
|
||||
.map(([relPath, bytes]) => ({ relPath, bytes }))
|
||||
.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function rewritePackageJsonForRelease({ root, packageDirRel, bytes, fileMap, vendoredPackages }) {
|
||||
const packageJson = JSON.parse(bytes.toString("utf8"));
|
||||
let changed = false;
|
||||
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
const dependencies = packageJson[section];
|
||||
if (!dependencies || typeof dependencies !== "object") continue;
|
||||
|
||||
for (const [name, spec] of Object.entries(dependencies)) {
|
||||
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
|
||||
|
||||
const sourceDir = path.resolve(root, fromPosixRel(packageDirRel), spec.slice(5));
|
||||
const vendorDirRel = normalizeDirRel(path.posix.join(packageDirRel, "vendor", name));
|
||||
await vendorPackageTree({
|
||||
sourceDir,
|
||||
targetDirRel: vendorDirRel,
|
||||
fileMap,
|
||||
vendoredPackages,
|
||||
});
|
||||
dependencies[name] = toFileDependencySpec(packageDirRel, vendorDirRel);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return null;
|
||||
return Buffer.from(`${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function vendorPackageTree({ sourceDir, targetDirRel, fileMap, vendoredPackages }) {
|
||||
const dedupeKey = `${path.resolve(sourceDir)}=>${targetDirRel}`;
|
||||
if (vendoredPackages.has(dedupeKey)) return;
|
||||
vendoredPackages.add(dedupeKey);
|
||||
|
||||
const files = await listTextFiles(sourceDir);
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Local package has no text files: ${sourceDir}`);
|
||||
}
|
||||
|
||||
const packageJson = files.find((file) => file.relPath === "package.json");
|
||||
if (!packageJson) {
|
||||
throw new Error(`Local package is missing package.json: ${sourceDir}`);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.relPath === "package.json") continue;
|
||||
fileMap.set(joinReleasePath(targetDirRel, file.relPath), file.bytes);
|
||||
}
|
||||
|
||||
const rewrittenPackageJson = await rewritePackageJsonForRelease({
|
||||
root: sourceDir,
|
||||
packageDirRel: ".",
|
||||
bytes: packageJson.bytes,
|
||||
fileMap: {
|
||||
set(relPath, outputBytes) {
|
||||
fileMap.set(joinReleasePath(targetDirRel, relPath), outputBytes);
|
||||
},
|
||||
},
|
||||
vendoredPackages,
|
||||
});
|
||||
|
||||
fileMap.set(
|
||||
joinReleasePath(targetDirRel, "package.json"),
|
||||
rewrittenPackageJson ?? packageJson.bytes,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDirRel(relPath) {
|
||||
return relPath === "." ? "." : relPath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function fromPosixRel(relPath) {
|
||||
return relPath === "." ? "." : relPath.split("/").join(path.sep);
|
||||
}
|
||||
|
||||
function joinReleasePath(base, relPath) {
|
||||
const joined = normalizeDirRel(path.posix.join(base === "." ? "" : base, relPath));
|
||||
return joined.replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function toFileDependencySpec(fromDirRel, targetDirRel) {
|
||||
const fromDir = fromDirRel === "." ? "" : fromDirRel;
|
||||
const relative = path.posix.relative(fromDir || ".", targetDirRel);
|
||||
const normalized = relative === "" ? "." : relative;
|
||||
return `file:${normalized.startsWith(".") ? normalized : `./${normalized}`}`;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/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 <dir> --out-dir <dir>
|
||||
|
||||
Options:
|
||||
--skill-dir <dir> Source skill directory
|
||||
--out-dir <dir> Artifact output directory
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { listTextFiles } from "./lib/skill-artifact.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");
|
||||
}
|
||||
|
||||
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);
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Artifact directory is empty: ${artifactDir}`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(`Dry run: would publish ${skill.slug}@${options.version}`);
|
||||
console.log(`Artifact: ${artifactDir}`);
|
||||
console.log(`Files: ${files.length}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await readClawhubConfig();
|
||||
const registry = (
|
||||
options.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 tags = options.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
await publishSkill({
|
||||
registry,
|
||||
token: config.token,
|
||||
skill,
|
||||
files,
|
||||
version: options.version,
|
||||
changelog,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
skillDir: "",
|
||||
artifactDir: "",
|
||||
version: "",
|
||||
changelogFile: "",
|
||||
registry: "",
|
||||
tags: "latest",
|
||||
dryRun: false,
|
||||
slug: "",
|
||||
displayName: "",
|
||||
};
|
||||
|
||||
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 === "--artifact-dir") {
|
||||
options.artifactDir = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--version") {
|
||||
options.version = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--changelog-file") {
|
||||
options.changelogFile = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--registry") {
|
||||
options.registry = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tags") {
|
||||
options.tags = argv[index + 1] ?? "latest";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--slug") {
|
||||
options.slug = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--display-name") {
|
||||
options.displayName = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--dry-run") {
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("-")) {
|
||||
options.dryRun = parseBoolean(next);
|
||||
index += 1;
|
||||
} else {
|
||||
options.dryRun = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: publish-skill-artifact.mjs --skill-dir <dir> --artifact-dir <dir> --version <semver> [options]
|
||||
|
||||
Options:
|
||||
--skill-dir <dir> Source skill directory (used for slug/display name)
|
||||
--artifact-dir <dir> Prepared artifact directory
|
||||
--version <semver> Version to publish
|
||||
--changelog-file <file> Release notes file
|
||||
--registry <url> Override registry base URL
|
||||
--tags <tags> Comma-separated tags (default: latest)
|
||||
--slug <value> Override slug
|
||||
--display-name <value> Override display name
|
||||
--dry-run Print publish plan without network calls
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
function buildSkillEntry(folder, slugOverride, displayNameOverride) {
|
||||
const base = path.basename(folder);
|
||||
return {
|
||||
folder,
|
||||
slug: slugOverride || sanitizeSlug(base),
|
||||
displayName: displayNameOverride || titleCase(base),
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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 };
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(body?.message || `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
function parseBoolean(value) {
|
||||
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user