Repository navigation
Expand file tree
/
Copy pathvalidate-skills.ts
More file actions
160 lines (148 loc) · 4.75 KB
/
Copy pathvalidate-skills.ts
File metadata and controls
160 lines (148 loc) · 4.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { readdir, readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const pluginRoot = packageRoot;
const skillsDirectory = resolve(pluginRoot, "skills");
const pluginManifestPaths = [
resolve(pluginRoot, ".codex-plugin", "plugin.json"),
resolve(pluginRoot, ".claude-plugin", "plugin.json"),
] as const;
const pluginName = "sim";
const expectedSkillNames = [
"build-workflow",
"deploy-workflow",
"files",
"knowledge-base",
"run-tool",
"run-workflow",
"sync-workspaces",
"table",
] as const;
const expectedPackageFiles = [
".claude-plugin",
".codex-plugin",
"dist",
"skills",
] as const;
const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MAX_SKILL_NAME_LENGTH = 64;
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
function frontmatterValue(
frontmatter: string,
key: string,
): string | undefined {
const prefix = `${key}:`;
return frontmatter
.split("\n")
.find((line) => line.startsWith(prefix))
?.slice(prefix.length)
.trim();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readJsonObject(path: string): Promise<Record<string, unknown>> {
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRecord(parsed)) throw new Error(`${path}: expected a JSON object`);
return parsed;
}
async function validatePluginManifest(
path: string,
version: string,
): Promise<void> {
const manifest = await readJsonObject(path);
if (manifest.name !== pluginName)
throw new Error(`${path}: plugin name must be ${pluginName}`);
if (manifest.version !== version) {
throw new Error(`${path}: version must match package version ${version}`);
}
if (
typeof manifest.description !== "string" ||
!manifest.description.trim()
) {
throw new Error(`${path}: description is required`);
}
if (manifest.skills !== "./skills/") {
throw new Error(`${path}: skills must point to ./skills/`);
}
}
async function validateSkill(directoryName: string): Promise<void> {
if (!skillNamePattern.test(directoryName)) {
throw new Error(
`${directoryName}: skill directory must be lowercase kebab-case`,
);
}
if (directoryName.length > MAX_SKILL_NAME_LENGTH) {
throw new Error(
`${directoryName}: skill name must be ${MAX_SKILL_NAME_LENGTH} characters or fewer`,
);
}
const raw = await readFile(
resolve(skillsDirectory, directoryName, "SKILL.md"),
"utf8",
);
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]+)$/);
if (!match)
throw new Error(
`${directoryName}: SKILL.md must contain YAML frontmatter and a body`,
);
const [, frontmatter, body] = match;
const name = frontmatterValue(frontmatter, "name");
const description = frontmatterValue(frontmatter, "description");
if (name !== directoryName) {
throw new Error(
`${directoryName}: frontmatter name must match the directory`,
);
}
if (!description)
throw new Error(`${directoryName}: description is required`);
if (description.length > MAX_SKILL_DESCRIPTION_LENGTH) {
throw new Error(
`${directoryName}: description must be ${MAX_SKILL_DESCRIPTION_LENGTH} characters or fewer`,
);
}
if (!body.trim())
throw new Error(`${directoryName}: instruction body is required`);
if (/\b(?:TODO|PLACEHOLDER)\b/.test(raw)) {
throw new Error(`${directoryName}: unfinished scaffold marker found`);
}
}
const entries = await readdir(skillsDirectory, { withFileTypes: true });
const skillDirectories = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
if (skillDirectories.length === 0)
throw new Error("sim-skills must contain at least one skill");
if (skillDirectories.join("\n") !== expectedSkillNames.join("\n")) {
throw new Error(`Expected Sim skills: ${expectedSkillNames.join(", ")}`);
}
const packageManifest = await readJsonObject(
resolve(packageRoot, "package.json"),
);
const packageVersion = packageManifest.version;
if (typeof packageVersion !== "string" || !packageVersion) {
throw new Error("package.json: version is required");
}
const packageFiles = packageManifest.files;
if (
!Array.isArray(packageFiles) ||
packageFiles.some((entry) => typeof entry !== "string")
) {
throw new Error("package.json: files must be an array of strings");
}
if ([...packageFiles].sort().join("\n") !== expectedPackageFiles.join("\n")) {
throw new Error(
`package.json: files must be exactly ${expectedPackageFiles.join(", ")}`,
);
}
await Promise.all([
...skillDirectories.map(validateSkill),
...pluginManifestPaths.map((path) =>
validatePluginManifest(path, packageVersion),
),
]);
process.stdout.write(
`Validated the ${pluginName} plugin and ${skillDirectories.length} skills.\n`,
);