feat: initialize OmniClaw skills registry
This commit is contained in:
157
tools/build-registry.mjs
Executable file
157
tools/build-registry.mjs
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { createReadStream } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const root = path.resolve(new URL("..", import.meta.url).pathname);
|
||||
const skillsDir = path.join(root, "skills");
|
||||
const apisDir = path.join(root, "apis");
|
||||
const packagesDir = path.join(root, "packages");
|
||||
const registryDir = path.join(root, "registry");
|
||||
|
||||
const repository = {
|
||||
name: "zsb/omniclaw-skills",
|
||||
git_url: "https://git.omniclaw.store/zsb/omniclaw-skills.git",
|
||||
raw_base_url: "https://git.omniclaw.store/zsb/omniclaw-skills/raw/branch/main",
|
||||
branch: "main",
|
||||
};
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await stat(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(file);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async function collectApiDocs() {
|
||||
const docs = [];
|
||||
if (!(await exists(apisDir))) {
|
||||
return docs;
|
||||
}
|
||||
const products = await readdir(apisDir, { withFileTypes: true });
|
||||
for (const productEntry of products) {
|
||||
if (!productEntry.isDirectory()) continue;
|
||||
const product = productEntry.name;
|
||||
const productDir = path.join(apisDir, product);
|
||||
const files = await readdir(productDir, { withFileTypes: true });
|
||||
for (const file of files) {
|
||||
if (!file.isFile() || !file.name.endsWith(".md")) continue;
|
||||
const fullPath = path.join(productDir, file.name);
|
||||
const relPath = path.relative(root, fullPath);
|
||||
const text = await readFile(fullPath, "utf8");
|
||||
const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim() || file.name;
|
||||
const language = file.name.endsWith(".zh.md") ? "zh" : file.name.endsWith(".en.md") ? "en" : "unknown";
|
||||
const id = `${product}/${file.name.replace(/\.md$/, "")}`;
|
||||
const sourceUrls = [...text.matchAll(/https:\/\/[^\s)]+/g)].map((m) =>
|
||||
m[0].replace(/[",)}>]+$/g, ""),
|
||||
);
|
||||
docs.push({
|
||||
id,
|
||||
title,
|
||||
product,
|
||||
language,
|
||||
path: relPath,
|
||||
url: `${repository.raw_base_url}/${relPath}`,
|
||||
tags: Array.from(new Set([product, ...id.split(/[/.]/).filter(Boolean)])),
|
||||
source_urls: Array.from(new Set(sourceUrls)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return docs.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
async function buildSkillPackage(skillName, version) {
|
||||
await mkdir(packagesDir, { recursive: true });
|
||||
const packageName = `${skillName}-${version}.tar.gz`;
|
||||
const packagePath = path.join(packagesDir, packageName);
|
||||
await rm(packagePath, { force: true });
|
||||
const result = spawnSync("tar", ["-czf", packagePath, "-C", skillsDir, skillName], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`tar failed for ${skillName}`);
|
||||
}
|
||||
const fileStat = await stat(packagePath);
|
||||
return {
|
||||
packageName,
|
||||
packagePath,
|
||||
sizeBytes: fileStat.size,
|
||||
digest: await sha256(packagePath),
|
||||
};
|
||||
}
|
||||
|
||||
async function collectSkills(apiDocs) {
|
||||
const skills = [];
|
||||
if (!(await exists(skillsDir))) {
|
||||
return skills;
|
||||
}
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillName = entry.name;
|
||||
const skillDir = path.join(skillsDir, skillName);
|
||||
const metadataPath = path.join(skillDir, "skill.registry.json");
|
||||
const skillPath = path.join(skillDir, "SKILL.md");
|
||||
if (!(await exists(metadataPath)) || !(await exists(skillPath))) {
|
||||
continue;
|
||||
}
|
||||
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
||||
const pkg = await buildSkillPackage(skillName, metadata.version);
|
||||
const relatedDocs = (metadata.api_docs || []).map((docId) => {
|
||||
const doc = apiDocs.find((item) => item.id === docId);
|
||||
return doc || { id: docId };
|
||||
});
|
||||
skills.push({
|
||||
name: skillName,
|
||||
version: metadata.version,
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
path: `skills/${skillName}`,
|
||||
raw_url: `${repository.raw_base_url}/skills/${skillName}/SKILL.md`,
|
||||
package: `packages/${pkg.packageName}`,
|
||||
package_url: `${repository.raw_base_url}/packages/${pkg.packageName}`,
|
||||
sha256: pkg.digest,
|
||||
size_bytes: pkg.sizeBytes,
|
||||
api_docs: relatedDocs,
|
||||
tags: metadata.tags || [],
|
||||
agents: metadata.agents || ["codex"],
|
||||
updated_at: metadata.updated_at,
|
||||
});
|
||||
}
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(registryDir, { recursive: true });
|
||||
const apis = await collectApiDocs();
|
||||
const skills = await collectSkills(apis);
|
||||
const manifest = {
|
||||
schema_version: "1.0.0",
|
||||
generated_at: new Date().toISOString(),
|
||||
repository,
|
||||
skills,
|
||||
apis,
|
||||
};
|
||||
await writeFile(path.join(registryDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Built registry: ${skills.length} skills, ${apis.length} API docs`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
99
tools/install.sh
Executable file
99
tools/install.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_NAME="${1:-}"
|
||||
VERSION="${2:-latest}"
|
||||
REPO_URL="${OMNICLAW_SKILLS_REPO:-https://git.omniclaw.store/zsb/omniclaw-skills.git}"
|
||||
RAW_BASE_URL="${OMNICLAW_SKILLS_RAW_BASE_URL:-https://git.omniclaw.store/zsb/omniclaw-skills/raw/branch/main}"
|
||||
DEST_DIR="${CODEX_HOME:-$HOME/.codex}/skills"
|
||||
|
||||
if [[ -z "$SKILL_NAME" ]]; then
|
||||
echo "Usage: install.sh <skill-name> [version]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing required command: $1" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd mktemp
|
||||
require_cmd node
|
||||
require_cmd tar
|
||||
require_cmd shasum
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
manifest="$tmp_dir/manifest.json"
|
||||
if [[ -d "$RAW_BASE_URL" && -f "$RAW_BASE_URL/registry/manifest.json" ]]; then
|
||||
cp "$RAW_BASE_URL/registry/manifest.json" "$manifest"
|
||||
elif command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$RAW_BASE_URL/registry/manifest.json" -o "$manifest" || true
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$manifest" "$RAW_BASE_URL/registry/manifest.json" || true
|
||||
else
|
||||
: > "$manifest"
|
||||
fi
|
||||
|
||||
if [[ ! -s "$manifest" ]]; then
|
||||
require_cmd git
|
||||
git clone --depth 1 "$REPO_URL" "$tmp_dir/repo"
|
||||
if [[ ! -f "$tmp_dir/repo/registry/manifest.json" ]]; then
|
||||
echo "Unable to find registry/manifest.json from $REPO_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$tmp_dir/repo/registry/manifest.json" "$manifest"
|
||||
RAW_BASE_URL="$tmp_dir/repo"
|
||||
fi
|
||||
|
||||
node_script='
|
||||
const fs = require("fs");
|
||||
const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const name = process.argv[2];
|
||||
const version = process.argv[3];
|
||||
const skill = manifest.skills.find((item) => item.name === name && (version === "latest" || item.version === version));
|
||||
if (!skill) {
|
||||
console.error(`Skill not found: ${name}@${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify(skill));
|
||||
'
|
||||
|
||||
skill_json="$(node -e "$node_script" "$manifest" "$SKILL_NAME" "$VERSION")"
|
||||
package_path="$(printf '%s' "$skill_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c);process.stdin.on("end",()=>console.log(JSON.parse(s).package));')"
|
||||
expected_sha="$(printf '%s' "$skill_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c);process.stdin.on("end",()=>console.log(JSON.parse(s).sha256));')"
|
||||
|
||||
archive="$tmp_dir/skill.tar.gz"
|
||||
if [[ -d "$RAW_BASE_URL" ]]; then
|
||||
cp "$RAW_BASE_URL/$package_path" "$archive"
|
||||
elif command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$RAW_BASE_URL/$package_path" -o "$archive"
|
||||
else
|
||||
wget -qO "$archive" "$RAW_BASE_URL/$package_path"
|
||||
fi
|
||||
|
||||
actual_sha="$(shasum -a 256 "$archive" | awk '{print $1}')"
|
||||
if [[ "$actual_sha" != "$expected_sha" ]]; then
|
||||
echo "Checksum mismatch for $package_path" >&2
|
||||
echo "expected: $expected_sha" >&2
|
||||
echo "actual: $actual_sha" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DEST_DIR"
|
||||
target="$DEST_DIR/$SKILL_NAME"
|
||||
if [[ -e "$target" ]]; then
|
||||
backup="$target.backup.$(date +%Y%m%d%H%M%S)"
|
||||
mv "$target" "$backup"
|
||||
echo "Existing skill moved to $backup"
|
||||
fi
|
||||
|
||||
tar -xzf "$archive" -C "$DEST_DIR"
|
||||
echo "Installed $SKILL_NAME to $DEST_DIR/$SKILL_NAME"
|
||||
echo "Restart Codex to pick up new skills."
|
||||
105
tools/validate.mjs
Executable file
105
tools/validate.mjs
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(new URL("..", import.meta.url).pathname);
|
||||
const skillsDir = path.join(root, "skills");
|
||||
const apisDir = path.join(root, "apis");
|
||||
|
||||
const errors = [];
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await stat(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) errors.push(message);
|
||||
}
|
||||
|
||||
function parseSkillFrontmatter(text, file) {
|
||||
const match = text.match(/^---\n([\s\S]*?)\n---/);
|
||||
check(Boolean(match), `${file}: missing YAML frontmatter`);
|
||||
if (!match) return {};
|
||||
const data = {};
|
||||
for (const line of match[1].split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 2) continue;
|
||||
const key = parts.shift().trim();
|
||||
const value = parts.join(":").trim().replace(/^"|"$/g, "");
|
||||
data[key] = value;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function validateSkills() {
|
||||
if (!(await exists(skillsDir))) return;
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const name = entry.name;
|
||||
const dir = path.join(skillsDir, name);
|
||||
const skillFile = path.join(dir, "SKILL.md");
|
||||
const registryFile = path.join(dir, "skill.registry.json");
|
||||
const agentFile = path.join(dir, "agents", "openai.yaml");
|
||||
check(await exists(skillFile), `${name}: missing SKILL.md`);
|
||||
check(await exists(registryFile), `${name}: missing skill.registry.json`);
|
||||
check(await exists(agentFile), `${name}: missing agents/openai.yaml`);
|
||||
if (!(await exists(skillFile)) || !(await exists(registryFile))) continue;
|
||||
|
||||
const skillText = await readFile(skillFile, "utf8");
|
||||
const frontmatter = parseSkillFrontmatter(skillText, `${name}/SKILL.md`);
|
||||
check(frontmatter.name === name, `${name}: frontmatter name must equal folder name`);
|
||||
check(Boolean(frontmatter.description), `${name}: missing frontmatter description`);
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = JSON.parse(await readFile(registryFile, "utf8"));
|
||||
} catch (error) {
|
||||
errors.push(`${name}: invalid skill.registry.json: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
check(metadata.name === name, `${name}: metadata name must equal folder name`);
|
||||
check(Boolean(metadata.version), `${name}: missing version`);
|
||||
check(Boolean(metadata.title), `${name}: missing title`);
|
||||
check(Boolean(metadata.description), `${name}: missing description`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateApiDocs() {
|
||||
if (!(await exists(apisDir))) return;
|
||||
const products = await readdir(apisDir, { withFileTypes: true });
|
||||
for (const productEntry of products) {
|
||||
if (!productEntry.isDirectory()) continue;
|
||||
const productDir = path.join(apisDir, productEntry.name);
|
||||
const files = (await readdir(productDir)).filter((name) => name.endsWith(".md"));
|
||||
const stems = new Map();
|
||||
for (const file of files) {
|
||||
const stem = file.replace(/\.(zh|en)\.md$/, "");
|
||||
if (!stems.has(stem)) stems.set(stem, new Set());
|
||||
if (file.endsWith(".zh.md")) stems.get(stem).add("zh");
|
||||
if (file.endsWith(".en.md")) stems.get(stem).add("en");
|
||||
const text = await readFile(path.join(productDir, file), "utf8");
|
||||
check(/^#\s+.+/m.test(text), `${productEntry.name}/${file}: missing H1 title`);
|
||||
}
|
||||
for (const [stem, languages] of stems) {
|
||||
check(languages.has("zh"), `${productEntry.name}/${stem}: missing zh doc`);
|
||||
check(languages.has("en"), `${productEntry.name}/${stem}: missing en doc`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await validateSkills();
|
||||
await validateApiDocs();
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.map((item) => `- ${item}`).join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Validation passed.");
|
||||
|
||||
Reference in New Issue
Block a user