mirror of
https://github.com/parallel-web/parallel-agent-skills.git
synced 2026-09-14 20:00:25 +08:00
492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, cpSync } from "node:fs";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import archiver from "archiver";
|
|
|
|
import { renderIndexPage } from "./site-templates.ts";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
export const REPO_ROOT = resolve(__dirname, "..");
|
|
export const SKILLS_DIR = join(REPO_ROOT, "skills");
|
|
export const VERSION_FILE = join(REPO_ROOT, "VERSION");
|
|
export const SITE_URL = "https://skills.parallel.ai";
|
|
export const REPOSITORY = "https://github.com/parallel-web/parallel-agent-skills";
|
|
export const OWNER = "parallel-web";
|
|
export const REPO_NAME = "parallel-agent-skills";
|
|
export const REPO_SLUG = `${OWNER}/${REPO_NAME}`;
|
|
export const SCHEMA_VERSION = 1;
|
|
export const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
|
|
type Scalar = string | number | boolean | null;
|
|
export interface FrontmatterMap {
|
|
[key: string]: FrontmatterValue;
|
|
}
|
|
export type FrontmatterValue = Scalar | FrontmatterMap;
|
|
|
|
export type SkillFileInfo = {
|
|
path: string;
|
|
sha256: string;
|
|
size: number;
|
|
};
|
|
|
|
export type SkillData = {
|
|
name: string;
|
|
directory: string;
|
|
description: string;
|
|
metadata: FrontmatterMap;
|
|
body: string;
|
|
files: SkillFileInfo[];
|
|
};
|
|
|
|
export type ReleaseInfo = {
|
|
tag: string;
|
|
version: string;
|
|
published_at: string | null;
|
|
};
|
|
|
|
export class SkillError extends Error {}
|
|
|
|
function runGit(args: string[], check = true): string {
|
|
try {
|
|
return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" }).trim();
|
|
} catch (error) {
|
|
if (!check) {
|
|
return "";
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function currentCommitSha(): string {
|
|
return runGit(["rev-parse", "HEAD"]);
|
|
}
|
|
|
|
export function readRepositoryVersion(): string {
|
|
return readFileSync(VERSION_FILE, "utf8").trim();
|
|
}
|
|
|
|
function parseScalar(value: string): FrontmatterValue {
|
|
const lowered = value.toLowerCase();
|
|
if (lowered === "true") return true;
|
|
if (lowered === "false") return false;
|
|
if (lowered === "null" || lowered === "~") return null;
|
|
if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
|
|
if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value);
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
return value.slice(1, -1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parseFrontmatter(text: string): { metadata: FrontmatterMap; body: string } {
|
|
if (!text.startsWith("---\n")) {
|
|
throw new SkillError("SKILL.md is missing YAML frontmatter");
|
|
}
|
|
|
|
const secondFenceIndex = text.indexOf("\n---\n", 4);
|
|
if (secondFenceIndex === -1) {
|
|
throw new SkillError("SKILL.md frontmatter is malformed");
|
|
}
|
|
|
|
const frontmatterText = text.slice(4, secondFenceIndex);
|
|
const body = text.slice(secondFenceIndex + 5).replace(/^\n+/, "");
|
|
|
|
const data: FrontmatterMap = {};
|
|
const stack: Array<{ indent: number; target: FrontmatterMap }> = [{ indent: -1, target: data }];
|
|
|
|
for (const rawLine of frontmatterText.split(/\r?\n/)) {
|
|
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
continue;
|
|
}
|
|
|
|
const indent = rawLine.length - rawLine.trimStart().length;
|
|
const line = rawLine.trim();
|
|
const separatorIndex = line.indexOf(":");
|
|
if (separatorIndex === -1) {
|
|
throw new SkillError(`Unsupported frontmatter line: ${rawLine}`);
|
|
}
|
|
|
|
while (stack.length > 1 && indent <= stack.at(-1)!.indent) {
|
|
stack.pop();
|
|
}
|
|
|
|
const key = line.slice(0, separatorIndex).trim();
|
|
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
const parent = stack.at(-1)!.target;
|
|
|
|
if (rawValue === "") {
|
|
const child: FrontmatterMap = {};
|
|
parent[key] = child;
|
|
stack.push({ indent, target: child });
|
|
} else {
|
|
parent[key] = parseScalar(rawValue);
|
|
}
|
|
}
|
|
|
|
return { metadata: data, body };
|
|
}
|
|
|
|
function sha256File(path: string): string {
|
|
const digest = createHash("sha256");
|
|
digest.update(readFileSync(path));
|
|
return digest.digest("hex");
|
|
}
|
|
|
|
function walkFiles(directory: string): string[] {
|
|
const entries = readdirSync(directory, { withFileTypes: true });
|
|
const files: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...walkFiles(fullPath));
|
|
} else if (entry.isFile()) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
|
|
return files.sort();
|
|
}
|
|
|
|
function skillDirectories(): string[] {
|
|
return readdirSync(SKILLS_DIR)
|
|
.map((entry) => join(SKILLS_DIR, entry))
|
|
.filter((path) => statSync(path).isDirectory() && existsSync(join(path, "SKILL.md")))
|
|
.sort();
|
|
}
|
|
|
|
export function semverKey(version: string): [number, number, number] {
|
|
const match = SEMVER_RE.exec(version);
|
|
if (!match) {
|
|
throw new Error(`Invalid semver: ${version}`);
|
|
}
|
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
}
|
|
|
|
export function discoverReleases(): ReleaseInfo[] {
|
|
const raw = runGit(
|
|
[
|
|
"for-each-ref",
|
|
"--sort=version:refname",
|
|
"--format=%(refname:strip=2)\t%(creatordate:iso8601-strict)",
|
|
"refs/tags/v*",
|
|
],
|
|
false,
|
|
);
|
|
|
|
const releases: ReleaseInfo[] = [];
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
if (!line.trim()) continue;
|
|
const [tag, createdAt = ""] = line.split("\t");
|
|
const version = tag.startsWith("v") ? tag.slice(1) : tag;
|
|
if (!SEMVER_RE.test(version)) continue;
|
|
releases.push({
|
|
tag: `v${version}`,
|
|
version,
|
|
published_at: createdAt || null,
|
|
});
|
|
}
|
|
|
|
releases.sort((a, b) => {
|
|
const left = semverKey(a.version);
|
|
const right = semverKey(b.version);
|
|
return right[0] - left[0] || right[1] - left[1] || right[2] - left[2];
|
|
});
|
|
|
|
return releases;
|
|
}
|
|
|
|
export function collectSkills(): SkillData[] {
|
|
const skills: SkillData[] = [];
|
|
|
|
for (const skillDir of skillDirectories()) {
|
|
const skillText = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
|
const { metadata, body } = parseFrontmatter(skillText);
|
|
|
|
const directory = relative(SKILLS_DIR, skillDir);
|
|
const name = typeof metadata.name === "string" ? metadata.name : directory;
|
|
const description = metadata.description;
|
|
if (typeof description !== "string" || !description) {
|
|
throw new SkillError(`${join(skillDir, "SKILL.md")} is missing description`);
|
|
}
|
|
|
|
const files: SkillFileInfo[] = walkFiles(skillDir).map((filePath) => ({
|
|
path: relative(skillDir, filePath).replaceAll("\\", "/"),
|
|
sha256: sha256File(filePath),
|
|
size: statSync(filePath).size,
|
|
}));
|
|
|
|
skills.push({
|
|
name,
|
|
directory,
|
|
description,
|
|
metadata,
|
|
body,
|
|
files,
|
|
});
|
|
}
|
|
|
|
return skills;
|
|
}
|
|
|
|
function jsonDump(path: string, payload: unknown): void {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function buildHeadersFile(outputDir: string): void {
|
|
const headers = `/*.md
|
|
Content-Type: text/markdown; charset=utf-8
|
|
/*.txt
|
|
Content-Type: text/plain; charset=utf-8
|
|
/*.json
|
|
Content-Type: application/json; charset=utf-8
|
|
/*
|
|
Access-Control-Allow-Origin: *
|
|
`;
|
|
writeFileSync(join(outputDir, "_headers"), headers, "utf8");
|
|
}
|
|
|
|
function absoluteUrl(path: string): string {
|
|
return `${SITE_URL}${path}`;
|
|
}
|
|
|
|
export function installCommandForSkill(skillName: string): string {
|
|
return `npx skills add ${REPO_SLUG} --skill ${skillName}`;
|
|
}
|
|
|
|
function buildVersionsPayload(skillName: string, releases: ReleaseInfo[]) {
|
|
const latestReleaseVersion = releases[0]?.version ?? null;
|
|
return {
|
|
schema_version: SCHEMA_VERSION,
|
|
name: skillName,
|
|
latest_release_version: latestReleaseVersion,
|
|
versions: releases.map((release) => ({
|
|
version: release.version,
|
|
archive_url: absoluteUrl(`/archives/${skillName}/${release.version}.zip`),
|
|
published_at: release.published_at,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function buildManifestPayload(
|
|
skill: SkillData,
|
|
repositoryVersion: string,
|
|
latestReleaseVersion: string | null,
|
|
commitSha: string,
|
|
) {
|
|
return {
|
|
schema_version: SCHEMA_VERSION,
|
|
name: skill.name,
|
|
description: skill.description,
|
|
repository_version: repositoryVersion,
|
|
latest_release_version: latestReleaseVersion,
|
|
channel: "main",
|
|
commit_sha: commitSha,
|
|
repository: REPOSITORY,
|
|
files: skill.files.map((fileInfo) => ({
|
|
path: fileInfo.path,
|
|
url: absoluteUrl(`/${skill.name}/${fileInfo.path}`),
|
|
sha256: fileInfo.sha256,
|
|
size: fileInfo.size,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function buildIndexPayload(
|
|
skills: SkillData[],
|
|
repositoryVersion: string,
|
|
latestReleaseVersion: string | null,
|
|
commitSha: string,
|
|
) {
|
|
return {
|
|
schema_version: SCHEMA_VERSION,
|
|
generated_at: new Date().toISOString(),
|
|
site_url: SITE_URL,
|
|
repository: REPOSITORY,
|
|
repository_version: repositoryVersion,
|
|
latest_release_version: latestReleaseVersion,
|
|
channel: "main",
|
|
commit_sha: commitSha,
|
|
install: {
|
|
agent_skills: {
|
|
command: `npx skills add ${REPO_SLUG} --all --global`,
|
|
description: "Install all Parallel skills globally using the Agent Skills CLI.",
|
|
},
|
|
claude_code: {
|
|
commands: [
|
|
"/plugin marketplace add parallel-web/parallel-agent-skills",
|
|
"/plugin install parallel",
|
|
],
|
|
description: "Install the Parallel Claude Code plugin from its marketplace.",
|
|
},
|
|
codex: {
|
|
command: "$skill-installer parallel-web/parallel-agent-skills",
|
|
description: "Install the Parallel skills package in OpenAI Codex.",
|
|
},
|
|
},
|
|
skills: skills.map((skill) => ({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
skill_url: absoluteUrl(`/${skill.name}/SKILL.md`),
|
|
manifest_url: absoluteUrl(`/${skill.name}/manifest.json`),
|
|
versions_url: absoluteUrl(`/${skill.name}/versions.json`),
|
|
install_command: installCommandForSkill(skill.name),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function buildLlmsTxt(
|
|
skills: SkillData[],
|
|
repositoryVersion: string,
|
|
latestReleaseVersion: string | null,
|
|
): string {
|
|
const lines = [
|
|
"# Parallel Agent Skills",
|
|
"",
|
|
"> CDN-hosted Parallel Agent Skills catalog with install instructions and direct links to each skill.",
|
|
"",
|
|
`Repository version: ${repositoryVersion}`,
|
|
`Latest release version: ${latestReleaseVersion ?? "none"}`,
|
|
"",
|
|
"Install all skills with:",
|
|
"",
|
|
"```bash",
|
|
`npx skills add ${REPO_SLUG} --all --global`,
|
|
"```",
|
|
"",
|
|
"## Catalog",
|
|
];
|
|
|
|
for (const skill of skills) {
|
|
lines.push(`- [${skill.name}](${absoluteUrl(`/${skill.name}/SKILL.md`)}): ${skill.description}`);
|
|
}
|
|
|
|
lines.push(
|
|
"",
|
|
"## Metadata",
|
|
`- [index.json](${absoluteUrl("/index.json")}): machine-readable catalog for all available skills.`,
|
|
`- [GitHub repository](${REPOSITORY}): source for the skill definitions and release workflow.`,
|
|
);
|
|
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function ensureCleanDirectory(path: string): void {
|
|
if (existsSync(path)) {
|
|
rmSync(path, { recursive: true, force: true });
|
|
}
|
|
mkdirSync(path, { recursive: true });
|
|
}
|
|
|
|
export function buildSite(outputDirInput: string): void {
|
|
const outputDir = resolve(outputDirInput);
|
|
const repositoryVersion = readRepositoryVersion();
|
|
const commitSha = currentCommitSha();
|
|
const releases = discoverReleases();
|
|
const latestReleaseVersion = releases[0]?.version ?? null;
|
|
const skills = collectSkills();
|
|
|
|
ensureCleanDirectory(outputDir);
|
|
buildHeadersFile(outputDir);
|
|
|
|
for (const skill of skills) {
|
|
const destination = join(outputDir, skill.name);
|
|
cpSync(join(SKILLS_DIR, skill.directory), destination, { recursive: true });
|
|
jsonDump(
|
|
join(destination, "manifest.json"),
|
|
buildManifestPayload(skill, repositoryVersion, latestReleaseVersion, commitSha),
|
|
);
|
|
jsonDump(join(destination, "versions.json"), buildVersionsPayload(skill.name, releases));
|
|
}
|
|
|
|
jsonDump(join(outputDir, "index.json"), buildIndexPayload(skills, repositoryVersion, latestReleaseVersion, commitSha));
|
|
writeFileSync(
|
|
join(outputDir, "index.html"),
|
|
renderIndexPage(skills, repositoryVersion, latestReleaseVersion, REPOSITORY, REPO_SLUG),
|
|
"utf8",
|
|
);
|
|
writeFileSync(join(outputDir, "llms.txt"), buildLlmsTxt(skills, repositoryVersion, latestReleaseVersion), "utf8");
|
|
}
|
|
|
|
export async function buildArchives(outputDirInput: string, version: string): Promise<void> {
|
|
if (!SEMVER_RE.test(version)) {
|
|
throw new Error(`Version must be semver, got: ${version}`);
|
|
}
|
|
|
|
const outputDir = resolve(outputDirInput);
|
|
ensureCleanDirectory(outputDir);
|
|
|
|
for (const skill of collectSkills()) {
|
|
const skillDir = join(SKILLS_DIR, skill.directory);
|
|
const archivePath = join(outputDir, `${skill.name}-${version}.zip`);
|
|
|
|
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
const output = createWriteStream(archivePath);
|
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
|
|
output.on("close", () => resolvePromise());
|
|
output.on("error", rejectPromise);
|
|
archive.on("error", rejectPromise);
|
|
|
|
archive.pipe(output);
|
|
for (const filePath of walkFiles(skillDir)) {
|
|
const arcname = `${skill.name}/${relative(skillDir, filePath).replaceAll("\\", "/")}`;
|
|
archive.file(filePath, { name: arcname });
|
|
}
|
|
void archive.finalize();
|
|
});
|
|
}
|
|
}
|
|
|
|
function writeJsonFile(path: string, data: unknown): void {
|
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
export function bumpVersion(part: "major" | "minor" | "patch"): string {
|
|
const current = readRepositoryVersion();
|
|
const [major, minor, patch] = semverKey(current);
|
|
|
|
let newVersion: string;
|
|
if (part === "major") {
|
|
newVersion = `${major + 1}.0.0`;
|
|
} else if (part === "minor") {
|
|
newVersion = `${major}.${minor + 1}.0`;
|
|
} else {
|
|
newVersion = `${major}.${minor}.${patch + 1}`;
|
|
}
|
|
|
|
writeFileSync(VERSION_FILE, `${newVersion}\n`, "utf8");
|
|
|
|
const claudePluginPath = join(REPO_ROOT, ".claude-plugin", "plugin.json");
|
|
const claudePlugin = JSON.parse(readFileSync(claudePluginPath, "utf8")) as Record<string, unknown>;
|
|
claudePlugin.version = newVersion;
|
|
writeJsonFile(claudePluginPath, claudePlugin);
|
|
|
|
const marketplacePath = join(REPO_ROOT, ".claude-plugin", "marketplace.json");
|
|
const marketplace = JSON.parse(readFileSync(marketplacePath, "utf8")) as Record<string, unknown> & {
|
|
metadata?: Record<string, unknown>;
|
|
plugins?: Array<Record<string, unknown>>;
|
|
};
|
|
marketplace.metadata ??= {};
|
|
marketplace.metadata.version = newVersion;
|
|
if (marketplace.plugins?.[0]) {
|
|
marketplace.plugins[0].version = newVersion;
|
|
}
|
|
writeJsonFile(marketplacePath, marketplace);
|
|
|
|
const codexPluginPath = join(REPO_ROOT, ".codex-plugin", "plugin.json");
|
|
const codexPlugin = JSON.parse(readFileSync(codexPluginPath, "utf8")) as Record<string, unknown>;
|
|
codexPlugin.version = newVersion;
|
|
writeJsonFile(codexPluginPath, codexPlugin);
|
|
|
|
return newVersion;
|
|
}
|