skyl.dev 0.1.2 → 0.2.0

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.
@@ -112,7 +112,7 @@ function list(fm, key) {
112
112
  if (Array.isArray(v)) return v;
113
113
  throw new SkillFormatError(`\`${key}\` must be a list`);
114
114
  }
115
- function parseSkill(text) {
115
+ function parseSkill(text, references = []) {
116
116
  const fm = parseFrontmatter(text);
117
117
  const md = body(text);
118
118
  const name = requireString(fm, "name");
@@ -152,6 +152,7 @@ ${found}`;
152
152
  family: name.slice(0, slash),
153
153
  skill: name.slice(slash + 1),
154
154
  axis,
155
+ description: typeof fm["description"] === "string" ? fm["description"] : "",
155
156
  version,
156
157
  requires: list(fm, "requires"),
157
158
  agentSections,
@@ -161,7 +162,8 @@ ${found}`;
161
162
  rules: rules(md),
162
163
  installable,
163
164
  installableWords: installable.split(/\s+/).filter(Boolean).length,
164
- raw: text
165
+ raw: text,
166
+ references
165
167
  };
166
168
  }
167
169
 
@@ -303,13 +305,24 @@ function directorySource(root, label = "directory") {
303
305
  } catch (cause) {
304
306
  throw new SourceError(`cannot read ${root}`, String(cause));
305
307
  }
308
+ const referencesIn = async (skillDir) => {
309
+ const dir = join9(skillDir, "references");
310
+ const names = await readdir4(dir).catch(() => []);
311
+ const out3 = [];
312
+ for (const file of names.filter((f) => f.endsWith(".md")).sort()) {
313
+ out3.push({ file, body: await readFile9(join9(dir, file), "utf8") });
314
+ }
315
+ return out3;
316
+ };
306
317
  for (const family of families) {
307
318
  const dir = join9(root, family);
308
319
  const entries = (await readdir4(dir, { withFileTypes: true })).filter((e) => e.isDirectory());
309
320
  for (const entry of entries) {
310
- const file = join9(dir, entry.name, "SKILL.md");
321
+ const skillDir = join9(dir, entry.name);
322
+ const file = join9(skillDir, "SKILL.md");
311
323
  try {
312
- out2.push(parseSkill(await readFile9(file, "utf8")));
324
+ const text = await readFile9(file, "utf8");
325
+ out2.push(parseSkill(text, await referencesIn(skillDir)));
313
326
  } catch (cause) {
314
327
  if (cause.code === "ENOENT") continue;
315
328
  throw new SourceError(`cannot parse ${file}`, cause.message);
@@ -326,8 +339,9 @@ function bundleSource(load, label) {
326
339
  async load() {
327
340
  const bundle = await load();
328
341
  return Object.entries(bundle.skills).map(([name, raw]) => {
342
+ const refs = Object.entries(bundle.references?.[name] ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([file, body2]) => ({ file, body: body2 }));
329
343
  try {
330
- return parseSkill(raw);
344
+ return parseSkill(raw, refs);
331
345
  } catch (cause) {
332
346
  throw new SourceError(`cannot parse \`${name}\` from ${label}`, cause.message);
333
347
  }
@@ -358,28 +372,83 @@ async function firstAvailable(sources2) {
358
372
 
359
373
  // ../core/src/targets.ts
360
374
  var flat = (name) => name.replace("/", "-");
375
+ var quoted = (text) => JSON.stringify(text);
376
+ var takesReferences = (target) => target.fileFor("x/y").includes("/");
361
377
  var TARGETS = [
362
378
  {
379
+ // Claude Code finds a skill by reading `name` and `description` out of the
380
+ // frontmatter. Without them the directory is passed over silently at startup.
363
381
  id: "claude",
364
382
  dir: ".claude/skills",
365
- fileFor: (name) => `${flat(name)}/SKILL.md`
383
+ fileFor: (name) => `${flat(name)}/SKILL.md`,
384
+ header: (s) => `---
385
+ name: ${flat(s.name)}
386
+ description: ${quoted(s.description)}
387
+ ---
388
+
389
+ `
366
390
  },
367
391
  {
392
+ // Cursor decides when to apply a rule from its own frontmatter, so a skill that
393
+ // arrives without one is either always on or never on, depending on the version.
394
+ // The description is what it matches against, which is why this is the description
395
+ // and not the name: the name says `android/core` and answers no question.
368
396
  id: "cursor",
369
397
  dir: ".cursor/rules",
370
398
  fileFor: (name) => `${flat(name)}.mdc`,
371
- // Cursor decides when to apply a rule from its own frontmatter, so a skill that
372
- // arrives without one is either always on or never on, depending on the version.
373
- header: (name) => `---
374
- description: ${name}
399
+ header: (s) => `---
400
+ description: ${quoted(s.description)}
375
401
  alwaysApply: false
376
402
  ---
377
403
 
378
404
  `
379
405
  },
380
- { id: "windsurf", dir: ".windsurf/rules", fileFor: (name) => `${flat(name)}.md` },
381
- { id: "continue", dir: ".continue/rules", fileFor: (name) => `${flat(name)}.md` },
382
- { id: "agents", dir: ".agents/skills", fileFor: (name) => `${flat(name)}.md` }
406
+ {
407
+ // Windsurf reads one `trigger` field, and `model_decision` is the mode that means
408
+ // "read the description and decide", which is what a skill is for. It also caps a
409
+ // rule at 6,000 characters and the whole active set at 12,000, and enforces both by
410
+ // truncating rather than by saying anything.
411
+ id: "windsurf",
412
+ dir: ".windsurf/rules",
413
+ fileFor: (name) => `${flat(name)}.md`,
414
+ header: (s) => `---
415
+ trigger: model_decision
416
+ description: ${quoted(s.description)}
417
+ ---
418
+
419
+ `,
420
+ limit: {
421
+ perFile: 6e3,
422
+ total: 12e3,
423
+ says: "Windsurf reads 6,000 characters of a rule and 12,000 across all of them,\nand truncates the rest without saying so."
424
+ }
425
+ },
426
+ {
427
+ id: "continue",
428
+ dir: ".continue/rules",
429
+ fileFor: (name) => `${flat(name)}.md`,
430
+ header: (s) => `---
431
+ name: ${s.name}
432
+ description: ${quoted(s.description)}
433
+ alwaysApply: false
434
+ ---
435
+
436
+ `
437
+ },
438
+ {
439
+ // The vendor-neutral Agent Skills layout: a directory per skill holding SKILL.md,
440
+ // with the same two fields Claude Code reads. This was a flat file until the
441
+ // frontmatter went in, and a flat file has nowhere to put a skill's references.
442
+ id: "agents",
443
+ dir: ".agents/skills",
444
+ fileFor: (name) => `${flat(name)}/SKILL.md`,
445
+ header: (s) => `---
446
+ name: ${flat(s.name)}
447
+ description: ${quoted(s.description)}
448
+ ---
449
+
450
+ `
451
+ }
383
452
  ];
384
453
  var DEFAULT_TARGET = "claude";
385
454
  function targetById(id) {
@@ -577,6 +646,19 @@ function lintSkill(text, path) {
577
646
  });
578
647
  }
579
648
  }
649
+ if (skill.description === "") {
650
+ found.push({
651
+ level: "warn",
652
+ message: "no `description`",
653
+ detail: "nothing that lists this skill can say what it is for"
654
+ });
655
+ } else if (skill.description.length > 300) {
656
+ found.push({
657
+ level: "warn",
658
+ message: `description is ${String(skill.description.length)} characters`,
659
+ detail: "one line, read in a list next to a dozen others"
660
+ });
661
+ }
580
662
  const md = body(text);
581
663
  const all = sections(md);
582
664
  const offset = text.slice(0, text.length - md.length).split("\n").length - 1;
@@ -870,10 +952,24 @@ async function writeLockfile(root, lock) {
870
952
  await writeFile(join2(root, LOCKFILE_NAME), formatLockfile(lock), "utf8");
871
953
  }
872
954
  function renderFor(target, skill) {
873
- const header = target.header?.(skill.name, skill.version) ?? "";
874
- return `${header}${skill.installable}
955
+ return `${target.header(skill)}${skill.installable}
875
956
  `;
876
957
  }
958
+ function referencePathFor(root, target, skill, file) {
959
+ return join2(dirname2(pathFor(root, target, skill)), "references", file);
960
+ }
961
+ function referencesDropped(target, ordered) {
962
+ if (takesReferences(target)) return [];
963
+ return ordered.filter((s) => s.references.length > 0);
964
+ }
965
+ function overLimit(target, ordered) {
966
+ if (!target.limit) return { over: [], total: 0 };
967
+ const size = (s) => renderFor(target, s).length;
968
+ return {
969
+ over: ordered.filter((s) => size(s) > target.limit.perFile),
970
+ total: ordered.reduce((n, s) => n + size(s), 0)
971
+ };
972
+ }
877
973
  function pathFor(root, target, skill) {
878
974
  return join2(root, target.dir, target.fileFor(skill.name));
879
975
  }
@@ -890,11 +986,19 @@ async function install(root, target, ordered, requested, existing) {
890
986
  target: target.id,
891
987
  skills: { ...existing?.target === target.id ? existing.skills : {} }
892
988
  };
989
+ const carriesReferences = takesReferences(target);
893
990
  for (const skill of ordered) {
894
991
  const file = pathFor(root, target, skill);
895
992
  const text = renderFor(target, skill);
896
993
  await mkdir(dirname2(file), { recursive: true });
897
994
  await writeFile(file, text, "utf8");
995
+ if (carriesReferences) {
996
+ for (const reference of skill.references) {
997
+ const at = referencePathFor(root, target, skill, reference.file);
998
+ await mkdir(dirname2(at), { recursive: true });
999
+ await writeFile(at, reference.body, "utf8");
1000
+ }
1001
+ }
898
1002
  lock.skills[skill.name] = entryFor(
899
1003
  skill,
900
1004
  text,
@@ -930,7 +1034,8 @@ function err(line) {
930
1034
  `);
931
1035
  }
932
1036
  function tokens(words2) {
933
- return `~${(Math.round(words2 * 4 / 300) * 100).toLocaleString()} tokens`;
1037
+ const n = Math.round(words2 * 4 / 300) * 100;
1038
+ return `~${(words2 > 0 ? Math.max(n, 100) : 0).toLocaleString()} tokens`;
934
1039
  }
935
1040
  async function confirm(question, assumeYes) {
936
1041
  if (assumeYes) return true;
@@ -965,9 +1070,36 @@ async function add(opts) {
965
1070
  out(` ${bold(skill.name.padEnd(22))} ${skill.version.padEnd(8)} ${state.padEnd(18)} ${why}`);
966
1071
  }
967
1072
  const words2 = ordered.reduce((n, s) => n + s.installableWords, 0);
1073
+ const files = ordered.reduce((n, s) => n + s.references.length, 0);
1074
+ const alsoRefs = files > 0 && referencesDropped(target, ordered).length === 0 ? dim(`, plus ${files} reference ${files === 1 ? "file" : "files"}`) : "";
968
1075
  out();
969
- out(` ${ordered.length} skills, ${tokens(words2)} ${dim(`into ${target.dir}`)}`);
1076
+ const count = `${ordered.length} ${ordered.length === 1 ? "skill" : "skills"}`;
1077
+ out(` ${count}${alsoRefs}, ${tokens(words2)} ${dim(`into ${target.dir}`)}`);
970
1078
  out();
1079
+ const dropped = referencesDropped(target, ordered);
1080
+ if (dropped.length > 0) {
1081
+ out(` ${yellow("References stay behind.")} ${dim(`${target.id} keeps one file per skill, so the`)}`);
1082
+ out(` ${dim("files these rules point at cannot sit beside them:")}`);
1083
+ for (const skill of dropped) {
1084
+ out(` ${dim(`${skill.name} ${skill.references.map((r) => r.file).join(", ")}`)}`);
1085
+ }
1086
+ out(` ${dim("Read them on https://skyl.dev/skills, under References.")}`);
1087
+ out();
1088
+ }
1089
+ const { over, total } = overLimit(target, ordered);
1090
+ if (target.limit && (over.length > 0 || total > target.limit.total)) {
1091
+ out(` ${yellow("Past what this tool reads.")}`);
1092
+ for (const line of target.limit.says.split("\n")) out(` ${dim(line)}`);
1093
+ for (const skill of over) {
1094
+ const size = skill.installable.length;
1095
+ out(` ${dim(`${skill.name.padEnd(22)} ${size.toLocaleString()} characters`)}`);
1096
+ }
1097
+ if (total > target.limit.total) {
1098
+ out(` ${dim(`all of them together ${total.toLocaleString()} characters`)}`);
1099
+ }
1100
+ out(` ${dim("Install fewer of them, or use a tool with no ceiling.")}`);
1101
+ out();
1102
+ }
971
1103
  if (!await confirm(" Write them?", opts.yes === true)) {
972
1104
  out(` ${dim("Nothing written.")}`);
973
1105
  return 1;
@@ -1778,6 +1910,13 @@ async function lint(opts) {
1778
1910
  err(`${yellow("No SKILL.md found in")} ${targets.join(", ")}`);
1779
1911
  return 1;
1780
1912
  }
1913
+ const written = await Promise.all(files.map((f) => readFile8(f, "utf8")));
1914
+ if (written.length > 0 && written.every((text) => /^---\nname: [^/\n]+\n/.test(text))) {
1915
+ err(`${yellow("That is an install directory, not a registry.")}`);
1916
+ err(dim(" Installed skills carry a flattened name and only the sections an agent is given."));
1917
+ err(dim(" Lint the registry they came from: skyl lint <checkout>/skills"));
1918
+ return 1;
1919
+ }
1781
1920
  let errors = 0;
1782
1921
  let warnings = 0;
1783
1922
  const parsed = [];
@@ -2118,7 +2257,7 @@ async function recordVersions(root, lock, pairs) {
2118
2257
  }
2119
2258
 
2120
2259
  // src/cli.ts
2121
- var VERSION = "0.1.2";
2260
+ var VERSION = "0.2.0";
2122
2261
  var HELP = `
2123
2262
  ${bold("skyl")} ${dim("curated agent skills for what your project actually uses")}
2124
2263
 
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "skyl.dev",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Install curated AI agent skills for what your project actually uses",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
8
- "skyl": "dist/bin.js"
8
+ "skyl": "dist/skyl.js"
9
9
  },
10
10
  "files": [
11
- "dist/bin.js",
11
+ "dist/skyl.js",
12
12
  "bundle.json",
13
13
  "README.md"
14
14
  ],