Read against the public marketplaces as they are: a marketplace entry that is not strict names its skills outright, so the skills marketplace's plugins each bring their own skills and not every sibling folder; a plugin skill's long description is shortened for the listing rather than refused; and a skill whose folder is larger than one version holds (1.5 MB, within the database's row) keeps its text and leaves its biggest binaries out, named in the install's problems, since a runner fetches the plugin whole.
6 files+87−190/6 viewed
| 261 | 261 | const entry = parseMarketplaceManifest(SKILLS); | |
| 262 | 262 | assert.ok(entry.ok); | |
| 263 | 263 | const docs = pluginLayout(["skills/xlsx/SKILL.md", "skills/docx/SKILL.md", "skills/pdf/SKILL.md", "skills/pptx/SKILL.md", "skills/other/SKILL.md", "README.md"], null, entry.value.plugins[0]!); | |
| 264 | + | // The entry is not strict: it names its skills, so a sibling folder under skills/ is not its. | |
| 264 | 265 | assert.deepEqual( | |
| 265 | 266 | docs.skills.map((s) => s.name), | |
| 266 | − | ["docx", "other", "pdf", "pptx", "xlsx"], | |
| 267 | + | ["docx", "pdf", "pptx", "xlsx"], | |
| 267 | 268 | ); | |
| 269 | + | // An entry that names its skills gets exactly those, not every sibling folder under skills/. | |
| 270 | + | assert.deepEqual(pluginLayout(["skills/xlsx/SKILL.md", "skills/claude-api/SKILL.md", "skills/other/SKILL.md"], null, entry.value.plugins[1]!).skills, [{ path: "skills/claude-api", name: "claude-api" }]); | |
| 268 | 271 | // A plugin that is one skill at its root. | |
| 269 | 272 | assert.deepEqual(pluginLayout(["SKILL.md", "reference.md"], null, { ...entry.value.plugins[1]!, skills: ["."] }).skills, [{ path: ".", name: "claude-api" }]); | |
| 270 | 273 | }); |
| 599 | 599 | const mdName = (path: string): string => path.replace(/^.*\//, "").replace(/\.md$/i, ""); | |
| 600 | 600 | ||
| 601 | 601 | /** | |
| 602 | − | * The plugin's components from its files: the standard folders, then the | |
| 603 | − | * manifest's and the marketplace entry's paths (the entry's added when it | |
| 604 | − | * is strict, as Claude Code does). `files` are paths inside the plugin. | |
| 602 | + | * The plugin's components from its files: the standard folders | |
| 603 | + | * (`skills/`, `commands/`, `agents/`) plus whatever the manifest names, | |
| 604 | + | * plus the marketplace entry's paths. An entry that is not strict names | |
| 605 | + | * its components outright instead of the standard folders, as the public | |
| 606 | + | * skills marketplace does (one repository, one plugin per group of | |
| 607 | + | * skills). `files` are paths inside the plugin. | |
| 605 | 608 | */ | |
| 606 | 609 | export function pluginLayout(files: string[], manifest: PluginManifest | null, entry: MarketplacePluginEntry | null): PluginLayout { | |
| 607 | 610 | const set = new Set(files.map((f) => f.replace(/^\.\//, ""))); | |
| ⋯ | |||
| 613 | 616 | if (has(clean ? `${clean}/SKILL.md` : "SKILL.md")) skillDirs.add(clean || "."); | |
| 614 | 617 | else for (const f of under(clean || ".")) if (/(^|\/)SKILL\.md$/.test(f) && f.slice(clean.length + (clean ? 1 : 0)).split("/").length === 2) skillDirs.add(f.replace(/\/SKILL\.md$/, "")); | |
| 615 | 618 | }; | |
| 616 | − | addSkillDir("skills"); | |
| 617 | − | const strictExtras = entry && entry.strict ? entry : null; | |
| 618 | − | for (const dir of [...(manifest?.skills ?? []), ...(strictExtras?.skills ?? []), ...(!manifest && entry ? entry.skills : [])]) addSkillDir(dir); | |
| 619 | + | const standard = !(entry && !entry.strict); | |
| 620 | + | if (standard) addSkillDir("skills"); | |
| 621 | + | for (const dir of [...(manifest?.skills ?? []), ...(entry?.skills ?? [])]) addSkillDir(dir); | |
| 619 | 622 | const commandFiles = new Set<string>(); | |
| 620 | 623 | const addCommands = (path: string) => { | |
| 621 | 624 | if (/\.md$/i.test(path) && has(path)) commandFiles.add(path); | |
| 622 | 625 | else for (const f of under(path)) if (/\.md$/i.test(f)) commandFiles.add(f); | |
| 623 | 626 | }; | |
| 624 | − | addCommands("commands"); | |
| 625 | − | for (const p of [...(manifest?.commands ?? []), ...(strictExtras?.commands ?? []), ...(!manifest && entry ? entry.commands : [])]) addCommands(p); | |
| 627 | + | if (standard) addCommands("commands"); | |
| 628 | + | for (const p of [...(manifest?.commands ?? []), ...(entry?.commands ?? [])]) addCommands(p); | |
| 626 | 629 | const agentFiles = new Set<string>(); | |
| 627 | 630 | const addAgents = (path: string) => { | |
| 628 | 631 | if (/\.md$/i.test(path) && has(path)) agentFiles.add(path); | |
| 629 | 632 | else for (const f of under(path)) if (/\.md$/i.test(f)) agentFiles.add(f); | |
| 630 | 633 | }; | |
| 631 | − | addAgents("agents"); | |
| 632 | − | for (const p of [...(manifest?.agents ?? []), ...(strictExtras?.agents ?? []), ...(!manifest && entry ? entry.agents : [])]) addAgents(p); | |
| 634 | + | if (standard) addAgents("agents"); | |
| 635 | + | for (const p of [...(manifest?.agents ?? []), ...(entry?.agents ?? [])]) addAgents(p); | |
| 633 | 636 | const hooks = [...(has("hooks/hooks.json") ? ["hooks/hooks.json"] : []), ...(manifest?.hooks ?? []).filter((h) => h === "(inline)" || has(h))]; | |
| 634 | 637 | return { | |
| 635 | 638 | skills: [...skillDirs].sort().map((path) => ({ path, name: path === "." ? (manifest?.name ?? entry?.name ?? "skill") : path.replace(/^.*\//, "") })), | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | + | import { PLUGIN_SKILL_LIMITS, checkSkillFolder as checkFolder } from "./skill-format.ts"; | |
| 5 | + | ||
| 6 | + | test("a plugin's skill is checked leniently: its name keeps its case, a long description is shortened, Claude Code's tools are kept aside, and it may be larger", () => { | |
| 7 | + | const long = "Use when ".padEnd(1500, "x"); | |
| 8 | + | const checked = checkFolder([{ path: "SKILL.md", content: `---\nname: Claude-API\ndescription: ${long}\ntools: Read, read_file\n---\n\nBody.\n` }], { lenient: true, limits: PLUGIN_SKILL_LIMITS }); | |
| 9 | + | assert.ok(checked.ok, !checked.ok ? checked.message : ""); | |
| 10 | + | assert.equal(checked.skill.name, "Claude-API"); | |
| 11 | + | assert.equal(checked.skill.description.length, 1024); | |
| 12 | + | assert.ok(checked.skill.description.endsWith("…")); | |
| 13 | + | assert.deepEqual(checked.skill.tools, ["read_file"]); | |
| 14 | + | assert.deepEqual(checked.skill.extra.tools, ["Read"]); | |
| 15 | + | const strict = checkFolder([{ path: "SKILL.md", content: `---\nname: claude-api\ndescription: ${long}\n---\n\nBody.\n` }]); | |
| 16 | + | assert.equal(strict.ok, false); | |
| 17 | + | }); | |
| 18 | + | ||
| 4 | 19 | import { | |
| 5 | 20 | AGENT_TOOL_NAMES, | |
| 6 | 21 | RESERVED_SKILL_NAMES, |
| 359 | 359 | limits?: { files: number; bytes: number }; | |
| 360 | 360 | }; | |
| 361 | 361 | ||
| 362 | − | /** How large a plugin's skill may be: public skills carry references and schemas. */ | |
| 363 | − | export const PLUGIN_SKILL_LIMITS = { files: 1000, bytes: 4 * 1024 * 1024 }; | |
| 362 | + | /** | |
| 363 | + | * How large a plugin's skill kept here may be: public skills carry | |
| 364 | + | * references and schemas, and a version is one database row (2 MB at | |
| 365 | + | * most, with its JSON). Larger folders keep their text and leave their | |
| 366 | + | * biggest binaries out (the runner fetches the plugin whole anyway). | |
| 367 | + | */ | |
| 368 | + | export const PLUGIN_SKILL_LIMITS = { files: 1000, bytes: 1_500_000 }; | |
| 364 | 369 | ||
| 365 | 370 | export function checkSkillFolder(input: SkillFile[], options: SkillCheckOptions = {}): SkillCheck { | |
| 366 | 371 | const bad = (message: string): SkillCheck => ({ ok: false, message }); | |
| ⋯ | |||
| 403 | 408 | const nameProblem = options.lenient ? lenientSkillNameProblem(name) : skillNameProblem(name); | |
| 404 | 409 | if (nameProblem) return bad(name ? nameProblem : "SKILL.md's front-matter needs a name: the skill's name, like release-notes."); | |
| 405 | 410 | if (options.expectName && options.expectName !== name) return bad(`The folder is ${options.expectName}, but its SKILL.md is named ${name}. They must match.`); | |
| 406 | − | const description = typeof front.description === "string" ? front.description.replace(/\s+/g, " ").trim() : ""; | |
| 407 | − | if (!description) return bad("SKILL.md's front-matter needs a description: when an agent should use the skill."); | |
| 408 | − | if (description.length > SKILL_DESCRIPTION_MAX) return bad(`A skill's description is at most ${SKILL_DESCRIPTION_MAX} characters.`); | |
| 411 | + | const full = typeof front.description === "string" ? front.description.replace(/\s+/g, " ").trim() : ""; | |
| 412 | + | if (!full) return bad("SKILL.md's front-matter needs a description: when an agent should use the skill."); | |
| 413 | + | if (full.length > SKILL_DESCRIPTION_MAX && !options.lenient) return bad(`A skill's description is at most ${SKILL_DESCRIPTION_MAX} characters.`); | |
| 414 | + | // A plugin's skill keeps its text as written; the listing shows the first part. | |
| 415 | + | const description = full.length > SKILL_DESCRIPTION_MAX ? `${full.slice(0, SKILL_DESCRIPTION_MAX - 1).trimEnd()}…` : full; | |
| 409 | 416 | const listed = listOf(front.tools); | |
| 410 | 417 | if (!listed) return bad("tools: is a list of tool names, like [read_file, make_file]."); | |
| 411 | 418 | const named = [...new Set(listed.map((t) => t.trim()))].filter(Boolean); | |
| 229 | 229 | assert.deepEqual(plugins.installsOut([row], [listing])[0]!.update, { version: "1.2.0", sha: null }); | |
| 230 | 230 | assert.equal(plugins.installsOut([row], [{ ...listing, version: "1.0.0" }])[0]!.update, null); | |
| 231 | 231 | }); | |
| 232 | + | ||
| 233 | + | test("a plugin skill larger than a version holds keeps its text and leaves its biggest binaries out", () => { | |
| 234 | + | const big = "A".repeat(1_200_000); | |
| 235 | + | const files = [ | |
| 236 | + | { path: "SKILL.md", content: "---\nname: canvas\ndescription: Use when drawing.\n---\n\nDraw.\n", encoding: "utf8" as const }, | |
| 237 | + | { path: "reference.md", content: "# Reference\n", encoding: "utf8" as const }, | |
| 238 | + | { path: "fonts/a.ttf", content: big, encoding: "base64" as const }, | |
| 239 | + | { path: "fonts/b.ttf", content: big, encoding: "base64" as const }, | |
| 240 | + | { path: "scripts/draw.py", content: "print(1)\n", encoding: "utf8" as const }, | |
| 241 | + | ]; | |
| 242 | + | const fitted = plugins.fitSkillFolder(files); | |
| 243 | + | assert.deepEqual(fitted.omitted, ["fonts/a.ttf"], "one font out brings it under the limit; text stays"); | |
| 244 | + | assert.deepEqual(fitted.files.map((f) => f.path).sort(), ["SKILL.md", "fonts/b.ttf", "reference.md", "scripts/draw.py"]); | |
| 245 | + | assert.deepEqual(plugins.fitSkillFolder(files.slice(0, 2)).omitted, []); | |
| 246 | + | }); |
| 35 | 35 | } from "../../../packages/contracts/src/claude-plugin.ts"; | |
| 36 | 36 | import { MAX_PLUGIN_INSTALLS, type PluginAgentTemplate, type PluginComponents, type PluginDetail, type PluginInstall, type PluginListing, componentsOf } from "../../../packages/contracts/src/customize.ts"; | |
| 37 | 37 | import { newId } from "../../../packages/contracts/src/ids.ts"; | |
| 38 | − | import { PLUGIN_SKILL_LIMITS, type SkillFile, checkSkillFolder, parseFrontMatter, splitFrontMatter } from "../../../packages/contracts/src/skill-format.ts"; | |
| 38 | + | import { PLUGIN_SKILL_LIMITS, type SkillFile, checkSkillFolder, parseFrontMatter, skillFileBytes, splitFrontMatter } from "../../../packages/contracts/src/skill-format.ts"; | |
| 39 | 39 | import { type FetchDeps, fetchPlugin } from "./customize-fetch.ts"; | |
| 40 | 40 | import { type SkillRow, skillByName, writeVersion } from "./skill-library.ts"; | |
| 41 | 41 | ||
| ⋯ | |||
| 216 | 216 | }; | |
| 217 | 217 | } | |
| 218 | 218 | ||
| 219 | + | /** | |
| 220 | + | * A skill's folder cut to what one version holds: its biggest binary files | |
| 221 | + | * go first, then its biggest other files besides SKILL.md, until it fits | |
| 222 | + | * `PLUGIN_SKILL_LIMITS`. What was left out is named, for the install's | |
| 223 | + | * problems and the version's note. | |
| 224 | + | */ | |
| 225 | + | export function fitSkillFolder(files: SkillFile[]): { files: SkillFile[]; omitted: string[] } { | |
| 226 | + | let total = files.reduce((n, f) => n + skillFileBytes(f), 0); | |
| 227 | + | if (files.length <= PLUGIN_SKILL_LIMITS.files && total <= PLUGIN_SKILL_LIMITS.bytes) return { files, omitted: [] }; | |
| 228 | + | const keep = new Map(files.map((f) => [f.path, f])); | |
| 229 | + | const order = [...files] | |
| 230 | + | .filter((f) => f.path.toLowerCase() !== "skill.md") | |
| 231 | + | .sort((a, b) => Number(b.encoding === "base64") - Number(a.encoding === "base64") || skillFileBytes(b) - skillFileBytes(a)); | |
| 232 | + | const omitted: string[] = []; | |
| 233 | + | for (const file of order) { | |
| 234 | + | if (keep.size <= PLUGIN_SKILL_LIMITS.files && total <= PLUGIN_SKILL_LIMITS.bytes) break; | |
| 235 | + | keep.delete(file.path); | |
| 236 | + | total -= skillFileBytes(file); | |
| 237 | + | omitted.push(file.path); | |
| 238 | + | } | |
| 239 | + | return { files: [...keep.values()], omitted: omitted.sort() }; | |
| 240 | + | } | |
| 241 | + | ||
| 219 | 242 | /** A command file (`commands/<name>.md`) as a one-file skill: its front-matter's description, or its first line. */ | |
| 220 | 243 | function commandAsSkill(name: string, text: string): SkillFile[] { | |
| 221 | 244 | const split = splitFrontMatter(text); | |
| ⋯ | |||
| 312 | 335 | ]; | |
| 313 | 336 | const seen = new Set<string>(); | |
| 314 | 337 | for (const one of folders) { | |
| 315 | − | const checked = checkSkillFolder(one.files, { expectName: one.name, lenient: true, limits: PLUGIN_SKILL_LIMITS }); | |
| 338 | + | const fitted = fitSkillFolder(one.files); | |
| 339 | + | if (fitted.omitted.length) problems.push(`${one.path}: ${fitted.omitted.length} ${fitted.omitted.length === 1 ? "file" : "files"} left out to fit (${fitted.omitted.slice(0, 5).join(", ")}${fitted.omitted.length > 5 ? "…" : ""}); Claude Code on a runner gets the whole plugin.`); | |
| 340 | + | const checked = checkSkillFolder(fitted.files, { expectName: one.name, lenient: true, limits: PLUGIN_SKILL_LIMITS }); | |
| 316 | 341 | if (!checked.ok) { | |
| 317 | 342 | problems.push(`${one.path}: ${checked.message}`); | |
| 318 | 343 | skills.push({ name: one.name, id: "", problem: checked.message }); | |
| ⋯ | |||
| 329 | 354 | existing, | |
| 330 | 355 | checked: checked.skill, | |
| 331 | 356 | origin: { kind: "plugin", plugin: listing.ref, marketplace: listing.marketplace, path: one.path, sha: plugin.sha }, | |
| 332 | − | note: `From ${listing.ref}${plugin.sha ? ` at ${plugin.sha.slice(0, 8)}` : ""}`, | |
| 357 | + | note: `From ${listing.ref}${plugin.sha ? ` at ${plugin.sha.slice(0, 8)}` : ""}${fitted.omitted.length ? ` (${fitted.omitted.length} large files left out)` : ""}`, | |
| 333 | 358 | by: input.by, | |
| 334 | 359 | now, | |
| 335 | 360 | status: "published", | |
Comments
Sign in to comment.