mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
4c2ba2d161
The Channels onboarding workflow is served at https://copilotkit.ai/channels-guide.md, and every other entry point now copies one line that points there (#6357 for the docs surfaces). Coding agents invoked through a skill had no such pointer, so they fell back to setup-slack-channel, which is scoped to Slack, to the provider half, and to an OpenTag checkout. channels-setup is a pointer, not a copy. The workflow stays in one place and is corrected there instead of drifting against the CLI on its own schedule. Verifying the fetch is the substance of the file. The site answers unknown paths with a "Page not found" HTML page under HTTP 200, so a status check proves nothing: an agent that keys on the status code gets a marketing 404 and improvises the workflow from memory. The skill checks the body instead -- markdown, the guide's H1, five Phase headings -- and stops if any fail. Standalone skills are not generated from packages/*/skills, so the slug is registered in RESERVED_LIFECYCLE_SLUGS. Without that entry the sync treats the directory as an orphan and deletes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
274 lines
9.7 KiB
TypeScript
274 lines
9.7 KiB
TypeScript
#!/usr/bin/env tsx
|
|
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { dirname, join, relative } from "node:path";
|
|
|
|
// Report paths with forward slashes for cross-platform consistency.
|
|
const toPosix = (p: string) => p.split("\\").join("/");
|
|
|
|
export const RESERVED_LIFECYCLE_SLUGS: ReadonlySet<string> = new Set([
|
|
// Standalone skills — not generated from packages/*/skills, exempt from orphan detection
|
|
"copilotkit-setup",
|
|
"copilotkit-channels",
|
|
"copilotkit-develop",
|
|
"copilotkit-agui",
|
|
"copilotkit-integrations",
|
|
"copilotkit-debug",
|
|
"copilotkit-upgrade",
|
|
"copilotkit-contribute",
|
|
"copilotkit-self-update",
|
|
"setup-slack-channel",
|
|
"channels-setup",
|
|
]);
|
|
|
|
// Version sync — plugin version tracks this package's version.
|
|
const VERSION_SOURCE_PACKAGE_JSON = "packages/runtime/package.json";
|
|
const PLUGIN_JSON = ".claude-plugin/plugin.json";
|
|
const MARKETPLACE_JSON = ".claude-plugin/marketplace.json";
|
|
|
|
export type SyncMode = "write" | "check";
|
|
|
|
export interface SyncOptions {
|
|
cwd: string;
|
|
mode: SyncMode;
|
|
}
|
|
|
|
export interface SyncResult {
|
|
exitCode: 0 | 1 | 2;
|
|
message: string;
|
|
changed: string[];
|
|
orphans: string[];
|
|
}
|
|
|
|
// ─── Source discovery ────────────────────────────────────────────────────────
|
|
|
|
interface PackageSkill {
|
|
slug: string; // e.g. "runtime"
|
|
sourceDir: string; // absolute path of packages/<pkg>/skills/<slug>
|
|
mirrorDir: string; // absolute path of skills/<slug>
|
|
}
|
|
|
|
async function findPackageSkills(cwd: string): Promise<PackageSkill[]> {
|
|
const packagesDir = join(cwd, "packages");
|
|
if (!existsSync(packagesDir)) return [];
|
|
|
|
const pkgs = await readdir(packagesDir, { withFileTypes: true });
|
|
const out: PackageSkill[] = [];
|
|
|
|
for (const pkg of pkgs) {
|
|
if (!pkg.isDirectory()) continue;
|
|
const skillsDir = join(packagesDir, pkg.name, "skills");
|
|
if (!existsSync(skillsDir)) continue;
|
|
const slugs = await readdir(skillsDir, { withFileTypes: true });
|
|
for (const slug of slugs) {
|
|
if (!slug.isDirectory()) continue;
|
|
const sourceDir = join(skillsDir, slug.name);
|
|
if (!existsSync(join(sourceDir, "SKILL.md"))) continue;
|
|
out.push({
|
|
slug: slug.name,
|
|
sourceDir,
|
|
mirrorDir: join(cwd, "skills", slug.name),
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function listFilesRec(dir: string, base = dir): Promise<string[]> {
|
|
const out: string[] = [];
|
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
for (const e of entries) {
|
|
const full = join(dir, e.name);
|
|
if (e.isDirectory()) out.push(...(await listFilesRec(full, base)));
|
|
else if (e.isFile()) out.push(relative(base, full));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
|
|
export async function syncPluginSkills(opts: SyncOptions): Promise<SyncResult> {
|
|
const skills = await findPackageSkills(opts.cwd);
|
|
|
|
// Collision check.
|
|
for (const s of skills) {
|
|
if (RESERVED_LIFECYCLE_SLUGS.has(s.slug)) {
|
|
return {
|
|
exitCode: 2,
|
|
message: `package skill slug "${s.slug}" collides with reserved lifecycle slug. Rename the package skill.`,
|
|
changed: [],
|
|
orphans: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
const changed: string[] = [];
|
|
const orphans: string[] = [];
|
|
|
|
for (const s of skills) {
|
|
const files = await listFilesRec(s.sourceDir);
|
|
for (const relPath of files) {
|
|
const srcPath = join(s.sourceDir, relPath);
|
|
const dstPath = join(s.mirrorDir, relPath);
|
|
const src = await readFile(srcPath);
|
|
|
|
if (opts.mode === "check") {
|
|
if (!existsSync(dstPath)) {
|
|
changed.push(toPosix(join("skills", s.slug, relPath)));
|
|
continue;
|
|
}
|
|
const dst = await readFile(dstPath);
|
|
if (!src.equals(dst))
|
|
changed.push(toPosix(join("skills", s.slug, relPath)));
|
|
} else {
|
|
await mkdir(dirname(dstPath), { recursive: true });
|
|
await writeFile(dstPath, src);
|
|
}
|
|
}
|
|
|
|
// Detect orphan files — files in mirror that are not in source.
|
|
if (existsSync(s.mirrorDir)) {
|
|
const mirrorFiles = await listFilesRec(s.mirrorDir);
|
|
const sourceSet = new Set(files);
|
|
for (const mf of mirrorFiles) {
|
|
if (!sourceSet.has(mf))
|
|
orphans.push(toPosix(join("skills", s.slug, mf)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Full-dir orphan scan — detect mirror skills directories whose source package
|
|
// was removed entirely. The main loop cannot catch these because it only
|
|
// iterates over currently discovered source skills.
|
|
const mirrorRoot = join(opts.cwd, "skills");
|
|
if (existsSync(mirrorRoot)) {
|
|
const sourceSlugs = new Set(skills.map((s) => s.slug));
|
|
const mirrorEntries = await readdir(mirrorRoot, { withFileTypes: true });
|
|
for (const entry of mirrorEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (RESERVED_LIFECYCLE_SLUGS.has(entry.name)) continue;
|
|
if (sourceSlugs.has(entry.name)) continue;
|
|
// Orphan directory — source package was removed but mirror still has it.
|
|
orphans.push(toPosix(join("skills", entry.name)));
|
|
}
|
|
}
|
|
|
|
// Version sync — read runtime package version, write/check plugin + marketplace.
|
|
const versionDrift = await handleVersionSync(opts);
|
|
|
|
if (opts.mode === "check") {
|
|
if (changed.length === 0 && orphans.length === 0 && !versionDrift) {
|
|
return {
|
|
exitCode: 0,
|
|
message: "plugin skill mirror in sync",
|
|
changed,
|
|
orphans,
|
|
};
|
|
}
|
|
const lines: string[] = [];
|
|
if (changed.length) {
|
|
lines.push(`drift detected in ${changed.length} file(s):`);
|
|
lines.push(...changed.map((p) => ` ${p}`));
|
|
}
|
|
if (orphans.length) {
|
|
lines.push(`orphan file(s) in mirror (source removed):`);
|
|
lines.push(...orphans.map((p) => ` ${p}`));
|
|
}
|
|
if (versionDrift) {
|
|
lines.push(`version drift: ${versionDrift}`);
|
|
}
|
|
lines.push("run: pnpm sync:plugin-skills");
|
|
return { exitCode: 1, message: lines.join("\n"), changed, orphans };
|
|
}
|
|
|
|
// Write mode — also prune orphans so mirror is exactly the source.
|
|
if (orphans.length) {
|
|
const { rm } = await import("node:fs/promises");
|
|
for (const o of orphans) {
|
|
await rm(join(opts.cwd, o), { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
return {
|
|
exitCode: 0,
|
|
message: `synced ${skills.length} package skill(s)`,
|
|
changed: [],
|
|
orphans: [],
|
|
};
|
|
}
|
|
|
|
// ─── Version sync helper ─────────────────────────────────────────────────────
|
|
|
|
// Returns a drift description string (for check mode), or empty string if in sync.
|
|
// In write mode, mutates the files and always returns empty string.
|
|
async function handleVersionSync(opts: SyncOptions): Promise<string> {
|
|
const srcPath = join(opts.cwd, VERSION_SOURCE_PACKAGE_JSON);
|
|
if (!existsSync(srcPath)) return "";
|
|
const srcVersion: string = JSON.parse(
|
|
await readFile(srcPath, "utf8"),
|
|
).version;
|
|
|
|
const pluginPath = join(opts.cwd, PLUGIN_JSON);
|
|
const marketPath = join(opts.cwd, MARKETPLACE_JSON);
|
|
|
|
if (existsSync(pluginPath)) {
|
|
const plugin = JSON.parse(await readFile(pluginPath, "utf8"));
|
|
if (plugin.version !== srcVersion) {
|
|
if (opts.mode === "check") {
|
|
return `plugin.json version is "${plugin.version}", expected "${srcVersion}" (from ${VERSION_SOURCE_PACKAGE_JSON})`;
|
|
}
|
|
plugin.version = srcVersion;
|
|
await writeFile(pluginPath, JSON.stringify(plugin, null, 2) + "\n");
|
|
}
|
|
}
|
|
|
|
if (existsSync(marketPath)) {
|
|
const market = JSON.parse(await readFile(marketPath, "utf8"));
|
|
const marketVersion = market.plugins?.[0]?.version;
|
|
const metadataVersion = market.metadata?.version;
|
|
// Both version fields in marketplace.json track the runtime package. They
|
|
// are checked together so neither rots independently — metadata.version was
|
|
// historically unmanaged and drifted behind plugins[0].version.
|
|
if (marketVersion !== srcVersion) {
|
|
if (opts.mode === "check") {
|
|
return `marketplace.json plugins[0].version is "${marketVersion}", expected "${srcVersion}" (from ${VERSION_SOURCE_PACKAGE_JSON})`;
|
|
}
|
|
}
|
|
if (metadataVersion !== undefined && metadataVersion !== srcVersion) {
|
|
if (opts.mode === "check") {
|
|
return `marketplace.json metadata.version is "${metadataVersion}", expected "${srcVersion}" (from ${VERSION_SOURCE_PACKAGE_JSON})`;
|
|
}
|
|
}
|
|
if (opts.mode === "write") {
|
|
let mutated = false;
|
|
if (market.plugins?.[0] && marketVersion !== srcVersion) {
|
|
market.plugins[0].version = srcVersion;
|
|
mutated = true;
|
|
}
|
|
if (market.metadata && metadataVersion !== srcVersion) {
|
|
market.metadata.version = srcVersion;
|
|
mutated = true;
|
|
}
|
|
if (mutated) {
|
|
await writeFile(marketPath, JSON.stringify(market, null, 2) + "\n");
|
|
}
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
const mode: SyncMode = process.argv.includes("--check") ? "check" : "write";
|
|
const result = await syncPluginSkills({ cwd: process.cwd(), mode });
|
|
if (result.message) console.log(result.message);
|
|
process.exit(result.exitCode);
|
|
}
|
|
|
|
// Use import.meta detection so the file is testable without triggering the CLI path.
|
|
if (process.argv[1] && process.argv[1].endsWith("sync-plugin-skills.ts")) {
|
|
void main();
|
|
}
|