aiblueprint-cli 1.4.60 → 1.4.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents-config/codex-config/config.toml +9 -0
- package/agents-config/codex-config/hooks/command-deny-list.ts +203 -0
- package/agents-config/skills/environments-manager/SKILL.md +1 -1
- package/agents-config/skills/rules-manager/SKILL.md +2 -2
- package/agents-config/skills/skill-manager/SKILL.md +20 -2
- package/agents-config/skills/skill-manager/references/description-recommandation.md +97 -0
- package/agents-config/skills/skill-manager/scripts/inspect-description.ts +743 -0
- package/dist/cli.js +581 -299
- package/package.json +1 -1
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const MIN_DESCRIPTION_CHARS = 50;
|
|
7
|
+
const MAX_DESCRIPTION_CHARS = 300;
|
|
8
|
+
const MAX_NAME_CHARS = 64;
|
|
9
|
+
const MIN_OPENAI_SHORT_DESCRIPTION_CHARS = 25;
|
|
10
|
+
const MAX_OPENAI_SHORT_DESCRIPTION_CHARS = 64;
|
|
11
|
+
|
|
12
|
+
type YamlValue = boolean | number | string | string[] | Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
type ParsedFrontmatter = {
|
|
15
|
+
duplicateKeys: string[];
|
|
16
|
+
keys: string[];
|
|
17
|
+
values: Map<string, YamlValue>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Finding = {
|
|
21
|
+
file: string;
|
|
22
|
+
message: string;
|
|
23
|
+
severity: "error" | "warning";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const allowedSkillFields = new Set([
|
|
27
|
+
"agent",
|
|
28
|
+
"allow_implicit_invocation",
|
|
29
|
+
"allowed-tools",
|
|
30
|
+
"argument-hint",
|
|
31
|
+
"arguments",
|
|
32
|
+
"category",
|
|
33
|
+
"context",
|
|
34
|
+
"description",
|
|
35
|
+
"disable-model-invocation",
|
|
36
|
+
"effort",
|
|
37
|
+
"hooks",
|
|
38
|
+
"license",
|
|
39
|
+
"metadata",
|
|
40
|
+
"model",
|
|
41
|
+
"name",
|
|
42
|
+
"paths",
|
|
43
|
+
"tags",
|
|
44
|
+
"user-invocable",
|
|
45
|
+
"version",
|
|
46
|
+
"author",
|
|
47
|
+
"color",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const allowedModelAliases = new Set(["haiku", "sonnet", "opus", "inherit"]);
|
|
51
|
+
const allowedEfforts = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
52
|
+
|
|
53
|
+
function expandHome(input: string): string {
|
|
54
|
+
if (input === "~") {
|
|
55
|
+
return os.homedir();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (input.startsWith("~/")) {
|
|
59
|
+
return path.join(os.homedir(), input.slice(2));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return input;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function pathExists(targetPath: string): Promise<boolean> {
|
|
66
|
+
try {
|
|
67
|
+
await fs.access(targetPath);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function charCount(value: string): number {
|
|
75
|
+
return Array.from(value).length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stripWrappingQuotes(value: string): string {
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
const first = trimmed.at(0);
|
|
81
|
+
const last = trimmed.at(-1);
|
|
82
|
+
|
|
83
|
+
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
|
|
84
|
+
return trimmed.slice(1, -1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return trimmed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseScalar(rawValue: string): YamlValue {
|
|
91
|
+
const trimmed = rawValue.trim();
|
|
92
|
+
|
|
93
|
+
if (trimmed === "true") {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (trimmed === "false") {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
102
|
+
return Number(trimmed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
106
|
+
const body = trimmed.slice(1, -1).trim();
|
|
107
|
+
|
|
108
|
+
if (!body) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return body.split(",").map((item) => stripWrappingQuotes(item.trim()));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return stripWrappingQuotes(trimmed);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readFrontmatter(markdown: string): string | null {
|
|
119
|
+
const frontmatterMatch = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
120
|
+
return frontmatterMatch?.[1] ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseTopLevelYaml(yaml: string): ParsedFrontmatter {
|
|
124
|
+
const lines = yaml.split(/\r?\n/);
|
|
125
|
+
const values = new Map<string, YamlValue>();
|
|
126
|
+
const keys: string[] = [];
|
|
127
|
+
const duplicateKeys: string[] = [];
|
|
128
|
+
|
|
129
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
130
|
+
const line = lines[index];
|
|
131
|
+
|
|
132
|
+
if (!line.trim() || line.trim().startsWith("#") || /^\s/.test(line)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
137
|
+
|
|
138
|
+
if (!match) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const key = match[1];
|
|
143
|
+
const rawValue = match[2].trim();
|
|
144
|
+
|
|
145
|
+
if (values.has(key)) {
|
|
146
|
+
duplicateKeys.push(key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
keys.push(key);
|
|
150
|
+
|
|
151
|
+
if (rawValue === "|" || rawValue === ">" || rawValue === "|-" || rawValue === ">-") {
|
|
152
|
+
const blockLines: string[] = [];
|
|
153
|
+
|
|
154
|
+
for (let blockIndex = index + 1; blockIndex < lines.length; blockIndex += 1) {
|
|
155
|
+
const blockLine = lines[blockIndex];
|
|
156
|
+
|
|
157
|
+
if (/^[A-Za-z0-9_-]+:\s*/.test(blockLine)) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
blockLines.push(blockLine.trim());
|
|
162
|
+
index = blockIndex;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
values.set(key, blockLines.join(rawValue.startsWith(">") ? " " : "\n").trim());
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (rawValue === "") {
|
|
170
|
+
const listValues: string[] = [];
|
|
171
|
+
let sawList = false;
|
|
172
|
+
|
|
173
|
+
for (let blockIndex = index + 1; blockIndex < lines.length; blockIndex += 1) {
|
|
174
|
+
const blockLine = lines[blockIndex];
|
|
175
|
+
|
|
176
|
+
if (/^[A-Za-z0-9_-]+:\s*/.test(blockLine)) {
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const listMatch = blockLine.match(/^\s*-\s*(.+)$/);
|
|
181
|
+
|
|
182
|
+
if (listMatch) {
|
|
183
|
+
sawList = true;
|
|
184
|
+
listValues.push(stripWrappingQuotes(listMatch[1]));
|
|
185
|
+
index = blockIndex;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
values.set(key, sawList ? listValues : {});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
values.set(key, parseScalar(rawValue));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { duplicateKeys, keys, values };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function statFollowSymlink(targetPath: string): Promise<{ isDirectory: boolean; isFile: boolean } | null> {
|
|
200
|
+
try {
|
|
201
|
+
const stat = await fs.stat(targetPath);
|
|
202
|
+
return {
|
|
203
|
+
isDirectory: stat.isDirectory(),
|
|
204
|
+
isFile: stat.isFile(),
|
|
205
|
+
};
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function findSkillFiles(root: string): Promise<string[]> {
|
|
212
|
+
const files: string[] = [];
|
|
213
|
+
const visitedDirectories = new Set<string>();
|
|
214
|
+
const visitedFiles = new Set<string>();
|
|
215
|
+
|
|
216
|
+
async function walk(currentPath: string): Promise<void> {
|
|
217
|
+
const realDirectory = await fs.realpath(currentPath).catch(() => currentPath);
|
|
218
|
+
|
|
219
|
+
if (visitedDirectories.has(realDirectory)) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
visitedDirectories.add(realDirectory);
|
|
224
|
+
|
|
225
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
226
|
+
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.name === "node_modules" || entry.name === ".git") {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const entryPath = path.join(currentPath, entry.name);
|
|
233
|
+
const stat = entry.isSymbolicLink()
|
|
234
|
+
? await statFollowSymlink(entryPath)
|
|
235
|
+
: { isDirectory: entry.isDirectory(), isFile: entry.isFile() };
|
|
236
|
+
|
|
237
|
+
if (!stat) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (stat.isDirectory) {
|
|
242
|
+
await walk(entryPath);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (stat.isFile && entry.name === "SKILL.md") {
|
|
247
|
+
const realFile = await fs.realpath(entryPath).catch(() => entryPath);
|
|
248
|
+
|
|
249
|
+
if (!visitedFiles.has(realFile)) {
|
|
250
|
+
visitedFiles.add(realFile);
|
|
251
|
+
files.push(entryPath);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await walk(root);
|
|
258
|
+
return files;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function findCursorRuleFiles(root: string): Promise<string[]> {
|
|
262
|
+
const files: string[] = [];
|
|
263
|
+
|
|
264
|
+
async function walk(currentPath: string): Promise<void> {
|
|
265
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
266
|
+
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
if (entry.name === "node_modules" || entry.name === ".git") {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const entryPath = path.join(currentPath, entry.name);
|
|
273
|
+
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
await walk(entryPath);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (entry.isFile() && /\.(md|mdc)$/.test(entry.name)) {
|
|
280
|
+
files.push(entryPath);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await walk(root);
|
|
286
|
+
return files;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function addFinding(findings: Finding[], severity: Finding["severity"], file: string, message: string): void {
|
|
290
|
+
findings.push({ file, message, severity });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getString(values: Map<string, YamlValue>, key: string): string | null {
|
|
294
|
+
const value = values.get(key);
|
|
295
|
+
return typeof value === "string" ? value : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function isStringList(value: YamlValue | undefined): value is string[] {
|
|
299
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function validateDescription(findings: Finding[], file: string, name: string, description: string | null): void {
|
|
303
|
+
if (!description) {
|
|
304
|
+
addFinding(findings, "error", file, `${name} has no description`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const length = charCount(description);
|
|
309
|
+
|
|
310
|
+
if (length < MIN_DESCRIPTION_CHARS) {
|
|
311
|
+
addFinding(findings, "error", file, `${name} description is too short (${length}/${MIN_DESCRIPTION_CHARS} chars min)`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (length > MAX_DESCRIPTION_CHARS) {
|
|
315
|
+
addFinding(findings, "error", file, `${name} description is too long (${length}/${MAX_DESCRIPTION_CHARS} chars max)`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (/<[A-Za-z][^>]*>/.test(description)) {
|
|
319
|
+
addFinding(findings, "error", file, `${name} description must not contain XML or HTML tags`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function validateSkillFrontmatter(findings: Finding[], file: string, frontmatter: ParsedFrontmatter): string {
|
|
324
|
+
const fallbackName = path.basename(path.dirname(file));
|
|
325
|
+
const name = getString(frontmatter.values, "name") ?? fallbackName;
|
|
326
|
+
|
|
327
|
+
for (const key of frontmatter.duplicateKeys) {
|
|
328
|
+
addFinding(findings, "error", file, `duplicate frontmatter key: ${key}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const key of frontmatter.keys) {
|
|
332
|
+
if (!allowedSkillFields.has(key)) {
|
|
333
|
+
addFinding(findings, "warning", file, `unknown SKILL.md frontmatter key: ${key}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const rawName = frontmatter.values.get("name");
|
|
338
|
+
|
|
339
|
+
if (typeof rawName !== "string" || rawName.trim() === "") {
|
|
340
|
+
addFinding(findings, "error", file, "name must be a non-empty string");
|
|
341
|
+
} else {
|
|
342
|
+
if (charCount(rawName) > MAX_NAME_CHARS) {
|
|
343
|
+
addFinding(findings, "error", file, `${rawName} name is too long (${charCount(rawName)}/${MAX_NAME_CHARS} chars max)`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(rawName)) {
|
|
347
|
+
addFinding(findings, "error", file, `${rawName} name must use lowercase letters, numbers, and hyphens only`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
validateDescription(findings, file, name, getString(frontmatter.values, "description"));
|
|
352
|
+
|
|
353
|
+
for (const key of ["disable-model-invocation", "user-invocable", "allow_implicit_invocation"]) {
|
|
354
|
+
const value = frontmatter.values.get(key);
|
|
355
|
+
|
|
356
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
357
|
+
addFinding(findings, "error", file, `${key} must be a boolean`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const allowedTools = frontmatter.values.get("allowed-tools");
|
|
362
|
+
|
|
363
|
+
if (allowedTools !== undefined) {
|
|
364
|
+
if (typeof allowedTools !== "string" && !isStringList(allowedTools)) {
|
|
365
|
+
addFinding(findings, "error", file, "allowed-tools must be a string or a YAML list of strings");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (isStringList(allowedTools) && allowedTools.some((tool) => tool.trim() === "")) {
|
|
369
|
+
addFinding(findings, "error", file, "allowed-tools contains an empty tool entry");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const argumentHint = frontmatter.values.get("argument-hint");
|
|
374
|
+
|
|
375
|
+
if (argumentHint !== undefined && typeof argumentHint !== "string") {
|
|
376
|
+
addFinding(findings, "error", file, "argument-hint must be a string; quote values that use [] syntax");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const key of ["arguments", "paths", "tags"]) {
|
|
380
|
+
const value = frontmatter.values.get(key);
|
|
381
|
+
|
|
382
|
+
if (value !== undefined && typeof value !== "string" && !isStringList(value)) {
|
|
383
|
+
addFinding(findings, "error", file, `${key} must be a string or a YAML list of strings`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const context = frontmatter.values.get("context");
|
|
388
|
+
|
|
389
|
+
if (context !== undefined && context !== "fork") {
|
|
390
|
+
addFinding(findings, "error", file, "context must be \"fork\" when present");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const agent = frontmatter.values.get("agent");
|
|
394
|
+
|
|
395
|
+
if (agent !== undefined && typeof agent !== "string") {
|
|
396
|
+
addFinding(findings, "error", file, "agent must be a string");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const model = frontmatter.values.get("model");
|
|
400
|
+
|
|
401
|
+
if (model !== undefined) {
|
|
402
|
+
if (typeof model !== "string") {
|
|
403
|
+
addFinding(findings, "error", file, "model must be a string");
|
|
404
|
+
} else if (!allowedModelAliases.has(model) && !/^claude-[a-z0-9.-]+$/.test(model)) {
|
|
405
|
+
addFinding(findings, "warning", file, `model value is not a common Claude alias or model id: ${model}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const effort = frontmatter.values.get("effort");
|
|
410
|
+
|
|
411
|
+
if (effort !== undefined && (typeof effort !== "string" || !allowedEfforts.has(effort))) {
|
|
412
|
+
addFinding(findings, "error", file, `effort must be one of: ${Array.from(allowedEfforts).join(", ")}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return name;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function validateSkillShape(findings: Finding[], skillFile: string): Promise<void> {
|
|
419
|
+
const skillDir = path.dirname(skillFile);
|
|
420
|
+
const entries = await fs.readdir(skillDir, { withFileTypes: true });
|
|
421
|
+
|
|
422
|
+
for (const entry of entries) {
|
|
423
|
+
if (entry.name === "SKILL.md") {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (entry.name === "agents" || entry.name === "assets" || entry.name === "references" || entry.name === "scripts") {
|
|
428
|
+
if (!entry.isDirectory()) {
|
|
429
|
+
addFinding(findings, "error", path.join(skillDir, entry.name), `${entry.name} must be a directory`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Skills may bundle extra runtime references or upstream repo files. Shape
|
|
436
|
+
// validation only enforces required files and known structured locations.
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const openaiYaml = path.join(skillDir, "agents", "openai.yaml");
|
|
440
|
+
|
|
441
|
+
if (await pathExists(openaiYaml)) {
|
|
442
|
+
await validateOpenAiYaml(findings, openaiYaml, path.basename(skillDir));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function getTopLevelKeys(yaml: string): string[] {
|
|
447
|
+
return yaml
|
|
448
|
+
.split(/\r?\n/)
|
|
449
|
+
.map((line) => line.match(/^([A-Za-z0-9_-]+):\s*/)?.[1])
|
|
450
|
+
.filter((key): key is string => Boolean(key));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getNestedScalar(yaml: string, section: string, key: string): string | boolean | null {
|
|
454
|
+
const lines = yaml.split(/\r?\n/);
|
|
455
|
+
let inSection = false;
|
|
456
|
+
|
|
457
|
+
for (const line of lines) {
|
|
458
|
+
if (/^[A-Za-z0-9_-]+:\s*/.test(line)) {
|
|
459
|
+
inSection = line.startsWith(`${section}:`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (!inSection) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const match = line.match(new RegExp(`^\\s{2}${key}:\\s*(.*)$`));
|
|
468
|
+
|
|
469
|
+
if (!match) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const value = parseScalar(match[1]);
|
|
474
|
+
return typeof value === "boolean" ? value : String(value);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function getOpenAiToolEntries(yaml: string): Array<Record<string, string>> {
|
|
481
|
+
const lines = yaml.split(/\r?\n/);
|
|
482
|
+
const tools: Array<Record<string, string>> = [];
|
|
483
|
+
let inTools = false;
|
|
484
|
+
let current: Record<string, string> | null = null;
|
|
485
|
+
|
|
486
|
+
for (const line of lines) {
|
|
487
|
+
if (/^[A-Za-z0-9_-]+:\s*/.test(line)) {
|
|
488
|
+
inTools = false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (/^\s{2}tools:\s*$/.test(line)) {
|
|
492
|
+
inTools = true;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!inTools) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const startMatch = line.match(/^\s{4}-\s*([A-Za-z0-9_-]+):\s*(.+)$/);
|
|
501
|
+
|
|
502
|
+
if (startMatch) {
|
|
503
|
+
current = { [startMatch[1]]: String(parseScalar(startMatch[2])) };
|
|
504
|
+
tools.push(current);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const fieldMatch = line.match(/^\s{6}([A-Za-z0-9_-]+):\s*(.+)$/);
|
|
509
|
+
|
|
510
|
+
if (fieldMatch && current) {
|
|
511
|
+
current[fieldMatch[1]] = String(parseScalar(fieldMatch[2]));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return tools;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function validateOpenAiYaml(findings: Finding[], file: string, skillName: string): Promise<void> {
|
|
519
|
+
const yaml = await fs.readFile(file, "utf8");
|
|
520
|
+
const allowedTopLevelKeys = new Set(["dependencies", "interface", "policy"]);
|
|
521
|
+
|
|
522
|
+
for (const key of getTopLevelKeys(yaml)) {
|
|
523
|
+
if (!allowedTopLevelKeys.has(key)) {
|
|
524
|
+
addFinding(findings, "error", file, `unknown agents/openai.yaml top-level key: ${key}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const displayName = getNestedScalar(yaml, "interface", "display_name");
|
|
529
|
+
|
|
530
|
+
if (displayName !== null && typeof displayName !== "string") {
|
|
531
|
+
addFinding(findings, "error", file, "interface.display_name must be a string");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const shortDescription = getNestedScalar(yaml, "interface", "short_description");
|
|
535
|
+
|
|
536
|
+
if (shortDescription !== null) {
|
|
537
|
+
if (typeof shortDescription !== "string") {
|
|
538
|
+
addFinding(findings, "error", file, "interface.short_description must be a string");
|
|
539
|
+
} else {
|
|
540
|
+
const length = charCount(shortDescription);
|
|
541
|
+
|
|
542
|
+
if (length < MIN_OPENAI_SHORT_DESCRIPTION_CHARS || length > MAX_OPENAI_SHORT_DESCRIPTION_CHARS) {
|
|
543
|
+
addFinding(
|
|
544
|
+
findings,
|
|
545
|
+
"error",
|
|
546
|
+
file,
|
|
547
|
+
`interface.short_description must be ${MIN_OPENAI_SHORT_DESCRIPTION_CHARS}-${MAX_OPENAI_SHORT_DESCRIPTION_CHARS} chars (${length} found)`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const defaultPrompt = getNestedScalar(yaml, "interface", "default_prompt");
|
|
554
|
+
|
|
555
|
+
if (defaultPrompt !== null) {
|
|
556
|
+
if (typeof defaultPrompt !== "string") {
|
|
557
|
+
addFinding(findings, "error", file, "interface.default_prompt must be a string");
|
|
558
|
+
} else if (!defaultPrompt.includes(`$${skillName}`)) {
|
|
559
|
+
addFinding(findings, "error", file, `interface.default_prompt must mention $${skillName}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const brandColor = getNestedScalar(yaml, "interface", "brand_color");
|
|
564
|
+
|
|
565
|
+
if (brandColor !== null && (typeof brandColor !== "string" || !/^#[0-9A-Fa-f]{6}$/.test(brandColor))) {
|
|
566
|
+
addFinding(findings, "error", file, "interface.brand_color must be a #RRGGBB hex color");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
for (const iconField of ["icon_small", "icon_large"]) {
|
|
570
|
+
const iconPath = getNestedScalar(yaml, "interface", iconField);
|
|
571
|
+
|
|
572
|
+
if (iconPath === null) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (typeof iconPath !== "string" || !iconPath.startsWith("./assets/")) {
|
|
577
|
+
addFinding(findings, "error", file, `interface.${iconField} must be a ./assets/... path`);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const absoluteIconPath = path.resolve(path.dirname(file), "..", iconPath);
|
|
582
|
+
|
|
583
|
+
if (!(await pathExists(absoluteIconPath))) {
|
|
584
|
+
addFinding(findings, "error", file, `interface.${iconField} does not exist: ${iconPath}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const allowImplicitInvocation = getNestedScalar(yaml, "policy", "allow_implicit_invocation");
|
|
589
|
+
|
|
590
|
+
if (allowImplicitInvocation !== null && typeof allowImplicitInvocation !== "boolean") {
|
|
591
|
+
addFinding(findings, "error", file, "policy.allow_implicit_invocation must be a boolean");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
for (const tool of getOpenAiToolEntries(yaml)) {
|
|
595
|
+
for (const requiredField of ["type", "value", "description", "transport", "url"]) {
|
|
596
|
+
if (!tool[requiredField]) {
|
|
597
|
+
addFinding(findings, "error", file, `dependencies.tools entry is missing ${requiredField}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (tool.type && tool.type !== "mcp") {
|
|
602
|
+
addFinding(findings, "error", file, "dependencies.tools[].type must be \"mcp\"");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (tool.transport && tool.transport !== "streamable_http") {
|
|
606
|
+
addFinding(findings, "error", file, "dependencies.tools[].transport must be \"streamable_http\"");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (tool.url && !tool.url.startsWith("https://")) {
|
|
610
|
+
addFinding(findings, "error", file, "dependencies.tools[].url must be an HTTPS URL");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function validateSkillFile(file: string): Promise<Finding[]> {
|
|
616
|
+
const findings: Finding[] = [];
|
|
617
|
+
const markdown = await fs.readFile(file, "utf8");
|
|
618
|
+
const frontmatter = readFrontmatter(markdown);
|
|
619
|
+
|
|
620
|
+
if (!frontmatter) {
|
|
621
|
+
addFinding(findings, "error", file, "SKILL.md must start with YAML frontmatter delimited by ---");
|
|
622
|
+
return findings;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const parsedFrontmatter = parseTopLevelYaml(frontmatter);
|
|
626
|
+
validateSkillFrontmatter(findings, file, parsedFrontmatter);
|
|
627
|
+
|
|
628
|
+
const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "").trim();
|
|
629
|
+
|
|
630
|
+
if (!body) {
|
|
631
|
+
addFinding(findings, "error", file, "SKILL.md body must not be empty");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
await validateSkillShape(findings, file);
|
|
635
|
+
return findings;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function validateCursorRuleFile(file: string): Promise<Finding[]> {
|
|
639
|
+
const findings: Finding[] = [];
|
|
640
|
+
const markdown = await fs.readFile(file, "utf8");
|
|
641
|
+
const frontmatter = readFrontmatter(markdown);
|
|
642
|
+
|
|
643
|
+
if (!frontmatter) {
|
|
644
|
+
return findings;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const parsedFrontmatter = parseTopLevelYaml(frontmatter);
|
|
648
|
+
const allowedCursorFields = new Set(["alwaysApply", "description", "globs"]);
|
|
649
|
+
|
|
650
|
+
for (const key of parsedFrontmatter.duplicateKeys) {
|
|
651
|
+
addFinding(findings, "error", file, `duplicate Cursor rule frontmatter key: ${key}`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
for (const key of parsedFrontmatter.keys) {
|
|
655
|
+
if (!allowedCursorFields.has(key)) {
|
|
656
|
+
addFinding(findings, "error", file, `unknown Cursor rule frontmatter key: ${key}`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const alwaysApply = parsedFrontmatter.values.get("alwaysApply");
|
|
661
|
+
|
|
662
|
+
if (alwaysApply !== undefined && typeof alwaysApply !== "boolean") {
|
|
663
|
+
addFinding(findings, "error", file, "alwaysApply must be a boolean");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const description = getString(parsedFrontmatter.values, "description");
|
|
667
|
+
|
|
668
|
+
if (description) {
|
|
669
|
+
validateDescription(findings, file, path.basename(file), description);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const globs = parsedFrontmatter.values.get("globs");
|
|
673
|
+
|
|
674
|
+
if (globs !== undefined && typeof globs !== "string" && !isStringList(globs)) {
|
|
675
|
+
addFinding(findings, "error", file, "globs must be a string or YAML list of strings");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return findings;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function collectSkillFiles(roots: string[]): Promise<string[]> {
|
|
682
|
+
const files = (await Promise.all(roots.map(findSkillFiles))).flat().sort();
|
|
683
|
+
return files;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function collectCursorRuleFiles(roots: string[]): Promise<string[]> {
|
|
687
|
+
const cursorRoots = roots.filter((root) => root.includes(`${path.sep}.cursor${path.sep}rules`) || root.endsWith(`${path.sep}.cursor${path.sep}rules`));
|
|
688
|
+
|
|
689
|
+
if (cursorRoots.length === 0) {
|
|
690
|
+
return [];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return (await Promise.all(cursorRoots.map(findCursorRuleFiles))).flat().sort();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function main(): Promise<void> {
|
|
697
|
+
const roots = process.argv.slice(2).map((arg) => path.resolve(expandHome(arg)));
|
|
698
|
+
const defaultRoots = [path.join(os.homedir(), ".agents", "skills")];
|
|
699
|
+
const rootsToInspect = roots.length > 0 ? roots : defaultRoots;
|
|
700
|
+
const existingRoots = [];
|
|
701
|
+
|
|
702
|
+
for (const root of rootsToInspect) {
|
|
703
|
+
if (await pathExists(root)) {
|
|
704
|
+
existingRoots.push(root);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (existingRoots.length === 0) {
|
|
709
|
+
console.error(`No roots found: ${rootsToInspect.join(", ")}`);
|
|
710
|
+
process.exitCode = 2;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const skillFiles = await collectSkillFiles(existingRoots);
|
|
715
|
+
const cursorRuleFiles = await collectCursorRuleFiles(existingRoots);
|
|
716
|
+
const allFindings = [
|
|
717
|
+
...(await Promise.all(skillFiles.map(validateSkillFile))).flat(),
|
|
718
|
+
...(await Promise.all(cursorRuleFiles.map(validateCursorRuleFile))).flat(),
|
|
719
|
+
];
|
|
720
|
+
|
|
721
|
+
const errors = allFindings.filter((finding) => finding.severity === "error");
|
|
722
|
+
const warnings = allFindings.filter((finding) => finding.severity === "warning");
|
|
723
|
+
|
|
724
|
+
for (const finding of allFindings) {
|
|
725
|
+
const prefix = finding.severity === "error" ? "error" : "warning";
|
|
726
|
+
console.warn(`${prefix}: ${finding.message} (${finding.file})`);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (errors.length === 0) {
|
|
730
|
+
const warningSuffix = warnings.length === 0 ? "" : ` with ${warnings.length} warning(s)`;
|
|
731
|
+
const cursorSuffix = cursorRuleFiles.length === 0 ? "" : ` and ${cursorRuleFiles.length} Cursor rule(s)`;
|
|
732
|
+
console.log(`OK: ${skillFiles.length} skill(s)${cursorSuffix} passed validation${warningSuffix}.`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
console.error(`Found ${errors.length} error(s) and ${warnings.length} warning(s) across ${skillFiles.length} skill(s).`);
|
|
737
|
+
process.exitCode = 1;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
main().catch((error: unknown) => {
|
|
741
|
+
console.error(error instanceof Error ? error.message : error);
|
|
742
|
+
process.exitCode = 2;
|
|
743
|
+
});
|