skanvil 1.0.0 → 1.1.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.
@@ -200,18 +200,192 @@ ${this.body}`;
200
200
  }
201
201
  };
202
202
 
203
+ // src/core/params.ts
204
+ import fs from "fs/promises";
205
+ import path2 from "path";
206
+ import { z } from "zod";
207
+ var PARAMS_FILE = "params.json";
208
+ var ParamSchema = z.union([
209
+ z.object({
210
+ type: z.literal("enum"),
211
+ values: z.array(z.string()).min(1),
212
+ default: z.string(),
213
+ description: z.string().optional()
214
+ }).strict(),
215
+ z.object({ type: z.literal("string"), default: z.string(), description: z.string().optional() }).strict(),
216
+ z.object({ type: z.literal("boolean"), default: z.boolean(), description: z.string().optional() }).strict()
217
+ ]);
218
+ var ParamsFileSchema = z.record(
219
+ z.string().regex(/^[a-zA-Z][a-zA-Z0-9]*$/, "parameter names must be camelCase letters and digits"),
220
+ ParamSchema
221
+ );
222
+ function parseParams(text) {
223
+ let json;
224
+ try {
225
+ json = JSON.parse(text);
226
+ } catch (error) {
227
+ throw new Error(`${PARAMS_FILE} is not valid JSON: ${error.message}`);
228
+ }
229
+ if (json && typeof json === "object" && !Array.isArray(json)) {
230
+ const invalid = Object.keys(json).filter((key) => !/^[a-zA-Z][a-zA-Z0-9]*$/.test(key));
231
+ if (invalid.length > 0) {
232
+ throw new Error(
233
+ `invalid ${PARAMS_FILE}: parameter names must be camelCase letters and digits (${invalid.join(", ")})`
234
+ );
235
+ }
236
+ }
237
+ const parsed = ParamsFileSchema.safeParse(json);
238
+ if (!parsed.success) {
239
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
240
+ throw new Error(`invalid ${PARAMS_FILE}: ${issues.slice(0, 3).join("; ")}`);
241
+ }
242
+ for (const [name, definition] of Object.entries(parsed.data)) {
243
+ if (definition.type === "enum" && !definition.values.includes(definition.default)) {
244
+ throw new Error(`invalid ${PARAMS_FILE}: ${name}.default must be one of ${definition.values.join(", ")}`);
245
+ }
246
+ }
247
+ return parsed.data;
248
+ }
249
+ async function readParams(skillDir) {
250
+ let text;
251
+ try {
252
+ text = await fs.readFile(path2.join(skillDir, PARAMS_FILE), "utf8");
253
+ } catch {
254
+ return null;
255
+ }
256
+ return parseParams(text);
257
+ }
258
+ function resolveParamValues(definitions, given = {}, skill = "skill", ignoreUnknown = false) {
259
+ const values = {};
260
+ for (const key of Object.keys(given)) {
261
+ if (!Object.prototype.hasOwnProperty.call(definitions, key) && !ignoreUnknown) {
262
+ const known = Object.keys(definitions);
263
+ throw new Error(
264
+ `${skill} has no parameter '${key}'` + (known.length ? ` (parameters: ${known.join(", ")})` : " (it has no parameters)")
265
+ );
266
+ }
267
+ }
268
+ for (const [name, definition] of Object.entries(definitions)) {
269
+ const raw = Object.prototype.hasOwnProperty.call(given, name) && given[name] !== void 0 ? given[name] : definition.default;
270
+ if (definition.type === "boolean") {
271
+ if (raw === true || raw === "true") values[name] = true;
272
+ else if (raw === false || raw === "false") values[name] = false;
273
+ else throw new Error(`${skill}: parameter '${name}' must be true or false, got ${JSON.stringify(raw)}`);
274
+ } else if (typeof raw !== "string") {
275
+ throw new Error(`${skill}: parameter '${name}' must be a string, got ${JSON.stringify(raw)}`);
276
+ } else if (definition.type === "enum" && !definition.values.includes(raw)) {
277
+ throw new Error(`${skill}: parameter '${name}' must be one of ${definition.values.join(", ")}, got "${raw}"`);
278
+ } else {
279
+ values[name] = raw;
280
+ }
281
+ }
282
+ return values;
283
+ }
284
+ function renderTemplate(text, values) {
285
+ const tagPattern = /\{\{\s*(#if\s+[^}]*?|else|\/if|[A-Za-z][A-Za-z0-9]*)\s*\}\}/g;
286
+ const tokens = [];
287
+ let cursor = 0;
288
+ let match;
289
+ while ((match = tagPattern.exec(text)) !== null) {
290
+ const body = match[1];
291
+ const isBlock = body.startsWith("#if") || body === "else" || body === "/if";
292
+ let start = match.index;
293
+ let end = match.index + match[0].length;
294
+ if (isBlock) {
295
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
296
+ const newline = text.indexOf("\n", end);
297
+ const lineEnd = newline === -1 ? text.length : newline;
298
+ const before = text.slice(lineStart, start);
299
+ const after = text.slice(end, lineEnd);
300
+ if (before.trim() === "" && after.trim() === "" && lineStart >= cursor) {
301
+ start = lineStart;
302
+ end = newline === -1 ? text.length : newline + 1;
303
+ }
304
+ }
305
+ if (start > cursor) tokens.push({ kind: "text", value: text.slice(cursor, start) });
306
+ if (body.startsWith("#if")) tokens.push({ kind: "if", expr: body.slice(3).trim() });
307
+ else if (body === "else") tokens.push({ kind: "else" });
308
+ else if (body === "/if") tokens.push({ kind: "end" });
309
+ else tokens.push({ kind: "var", name: body, raw: match[0] });
310
+ cursor = end;
311
+ tagPattern.lastIndex = end;
312
+ }
313
+ if (cursor < text.length) tokens.push({ kind: "text", value: text.slice(cursor) });
314
+ const has = (name) => Object.prototype.hasOwnProperty.call(values, name);
315
+ const evaluate = (expr) => {
316
+ const comparison = expr.match(/^([A-Za-z][A-Za-z0-9]*)\s*(==|!=)\s*(?:"([^"]*)"|'([^']*)')$/);
317
+ if (comparison) {
318
+ const [, name, operator, double, single] = comparison;
319
+ if (!has(name)) throw new Error(`{{#if ${expr}}} uses unknown parameter '${name}'`);
320
+ const equal = String(values[name]) === (double !== void 0 ? double : single);
321
+ return operator === "==" ? equal : !equal;
322
+ }
323
+ const truthy = expr.match(/^(!?)([A-Za-z][A-Za-z0-9]*)$/);
324
+ if (truthy) {
325
+ const [, negate, name] = truthy;
326
+ if (!has(name)) throw new Error(`{{#if ${expr}}} uses unknown parameter '${name}'`);
327
+ const value = values[name];
328
+ const result = value !== false && value !== "" && value !== "false";
329
+ return negate ? !result : result;
330
+ }
331
+ throw new Error(`invalid condition {{#if ${expr}}}: use name, !name, name == "value" or name != "value"`);
332
+ };
333
+ const stack = [];
334
+ let output = "";
335
+ let active = true;
336
+ for (const token of tokens) {
337
+ if (token.kind === "if") {
338
+ const condition = active ? evaluate(token.expr) : false;
339
+ stack.push({ active: condition, parentActive: active, sawElse: false });
340
+ active = active && condition;
341
+ } else if (token.kind === "else") {
342
+ const frame = stack[stack.length - 1];
343
+ if (!frame || frame.sawElse) throw new Error("{{else}} without a matching {{#if}}");
344
+ frame.sawElse = true;
345
+ active = frame.parentActive && !frame.active;
346
+ } else if (token.kind === "end") {
347
+ const frame = stack.pop();
348
+ if (!frame) throw new Error("{{/if}} without a matching {{#if}}");
349
+ active = frame.parentActive;
350
+ } else if (active) {
351
+ output += token.kind === "text" ? token.value : has(token.name) ? String(values[token.name]) : token.raw;
352
+ }
353
+ }
354
+ if (stack.length > 0) throw new Error(`${stack.length} {{#if}} block(s) are not closed with {{/if}}`);
355
+ return output;
356
+ }
357
+ function findUndeclaredParams(text, definitions) {
358
+ const names = /* @__PURE__ */ new Set();
359
+ for (const match of text.matchAll(/\{\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\}/g)) {
360
+ if (match[1] !== "else" && !Object.prototype.hasOwnProperty.call(definitions, match[1])) names.add(match[1]);
361
+ }
362
+ return [...names];
363
+ }
364
+ function parseParamPairs(pairs) {
365
+ const values = {};
366
+ for (const pair of pairs) {
367
+ const index = pair.indexOf("=");
368
+ const key = index === -1 ? "" : pair.slice(0, index).trim();
369
+ if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key)) {
370
+ throw new Error(`Invalid --param '${pair}'. Use name=value, e.g. --param ticketTracker=jira`);
371
+ }
372
+ values[key] = pair.slice(index + 1);
373
+ }
374
+ return values;
375
+ }
376
+
203
377
  // src/core/templates.ts
204
- import fs2 from "fs/promises";
205
- import path3 from "path";
378
+ import fs3 from "fs/promises";
379
+ import path4 from "path";
206
380
  import { stringify } from "yaml";
207
381
 
208
382
  // src/util/fs.ts
209
- import fs from "fs/promises";
383
+ import fs2 from "fs/promises";
210
384
  import { createHash } from "crypto";
211
- import path2 from "path";
385
+ import path3 from "path";
212
386
  async function exists(target) {
213
387
  try {
214
- await fs.access(target);
388
+ await fs2.access(target);
215
389
  return true;
216
390
  } catch {
217
391
  return false;
@@ -219,14 +393,14 @@ async function exists(target) {
219
393
  }
220
394
  async function isEmptyOrMissing(dir) {
221
395
  try {
222
- return (await fs.readdir(dir)).length === 0;
396
+ return (await fs2.readdir(dir)).length === 0;
223
397
  } catch {
224
398
  return true;
225
399
  }
226
400
  }
227
401
  async function listFiles(dir, relative = "") {
228
402
  const out = [];
229
- for (const entry of await fs.readdir(path2.join(dir, relative), { withFileTypes: true })) {
403
+ for (const entry of await fs2.readdir(path3.join(dir, relative), { withFileTypes: true })) {
230
404
  const rel = relative ? `${relative}/${entry.name}` : entry.name;
231
405
  if (entry.isDirectory()) {
232
406
  out.push(...await listFiles(dir, rel));
@@ -241,7 +415,7 @@ async function hashDirectory(dir) {
241
415
  for (const file of await listFiles(dir)) {
242
416
  hash.update(file);
243
417
  hash.update("\0");
244
- hash.update(await fs.readFile(path2.join(dir, file)));
418
+ hash.update(await fs2.readFile(path3.join(dir, file)));
245
419
  hash.update("\0");
246
420
  }
247
421
  return hash.digest("hex").slice(0, 16);
@@ -286,8 +460,8 @@ function renderSkillMarkdown(text, variables) {
286
460
  }
287
461
  async function copyTemplateDir(source, destination, options = {}, relative = "") {
288
462
  const { variables = {}, skip = () => false, render: shouldRender = true } = options;
289
- await fs2.mkdir(path3.join(destination, relative), { recursive: true });
290
- for (const entry of await fs2.readdir(path3.join(source, relative), { withFileTypes: true })) {
463
+ await fs3.mkdir(path4.join(destination, relative), { recursive: true });
464
+ for (const entry of await fs3.readdir(path4.join(source, relative), { withFileTypes: true })) {
291
465
  const relativePath = relative ? `${relative}/${entry.name}` : entry.name;
292
466
  if (skip(relativePath)) continue;
293
467
  if (entry.isDirectory()) {
@@ -295,45 +469,45 @@ async function copyTemplateDir(source, destination, options = {}, relative = "")
295
469
  continue;
296
470
  }
297
471
  const targetName = shouldRender ? FILE_ALIASES[entry.name] || entry.name : entry.name;
298
- const from = path3.join(source, relative, entry.name);
299
- const target = path3.join(destination, relative, targetName);
300
- if (shouldRender && TEXT_EXTENSIONS.has(path3.extname(entry.name))) {
301
- const text = await fs2.readFile(from, "utf8");
472
+ const from = path4.join(source, relative, entry.name);
473
+ const target = path4.join(destination, relative, targetName);
474
+ if (shouldRender && TEXT_EXTENSIONS.has(path4.extname(entry.name))) {
475
+ const text = await fs3.readFile(from, "utf8");
302
476
  const rendered = entry.name === "SKILL.md" ? renderSkillMarkdown(text, variables) : render(text, variables);
303
- await fs2.writeFile(target, rendered, "utf8");
477
+ await fs3.writeFile(target, rendered, "utf8");
304
478
  } else {
305
- await fs2.copyFile(from, target);
479
+ await fs3.copyFile(from, target);
306
480
  }
307
481
  }
308
482
  }
309
483
  function builtinTemplate(name) {
310
- return path3.join(TEMPLATES_ROOT, name);
484
+ return path4.join(TEMPLATES_ROOT, name);
311
485
  }
312
486
  async function resolveSkillTemplate(library, categoryId, explicit) {
313
- const localRoot = path3.join(library.root, library.config.templates.dir);
487
+ const localRoot = path4.join(library.root, library.config.templates.dir);
314
488
  if (explicit) {
315
- const candidate = path3.join(localRoot, explicit);
489
+ const candidate = path4.join(localRoot, explicit);
316
490
  if (await exists(candidate)) return candidate;
317
491
  throw new Error(`Template '${explicit}' not found in ${localRoot}.`);
318
492
  }
319
- for (const candidate of [path3.join(localRoot, `skill-${categoryId}`), path3.join(localRoot, "skill")]) {
493
+ for (const candidate of [path4.join(localRoot, `skill-${categoryId}`), path4.join(localRoot, "skill")]) {
320
494
  if (await exists(candidate)) return candidate;
321
495
  }
322
496
  return builtinTemplate("skill");
323
497
  }
324
498
  async function ejectSkillTemplate(library, categoryId, force = false) {
325
499
  const folder = categoryId ? `skill-${library.categories.resolve(categoryId).id}` : "skill";
326
- const target = path3.join(library.root, library.config.templates.dir, folder);
500
+ const target = path4.join(library.root, library.config.templates.dir, folder);
327
501
  if (await exists(target) && !force) {
328
502
  throw new Error(`Template folder already exists: ${target} (use --force to overwrite).`);
329
503
  }
330
- await fs2.rm(target, { recursive: true, force: true });
504
+ await fs3.rm(target, { recursive: true, force: true });
331
505
  await copyTemplateDir(builtinTemplate("skill"), target, { render: false });
332
506
  return target;
333
507
  }
334
508
 
335
509
  // src/core/validate.ts
336
- import path4 from "path";
510
+ import path5 from "path";
337
511
  import { isMap as isMap2, isScalar as isScalar2 } from "yaml";
338
512
 
339
513
  // src/core/naming.ts
@@ -400,6 +574,14 @@ var RULES = {
400
574
  severity: "warn",
401
575
  description: "The body no longer contains the generic text of the built-in skill template."
402
576
  },
577
+ "params-file": {
578
+ severity: "error",
579
+ description: "params.json, when present, is valid: camelCase names, a type, and a default (in its values for enums)."
580
+ },
581
+ "params-template": {
582
+ severity: "error",
583
+ description: "{{placeholders}} and {{#if}} blocks use declared parameters and are balanced."
584
+ },
403
585
  "readme-missing": { severity: "error", description: "Every indexed skill is listed in the README.", fixable: true },
404
586
  "readme-orphan": { severity: "error", description: "README does not reference missing skills.", fixable: true },
405
587
  "readme-section": {
@@ -557,7 +739,7 @@ function findBrokenReferences(body, files) {
557
739
  continue;
558
740
  const clean = safeDecode(raw.split(/[?#]/)[0]);
559
741
  if (!clean) continue;
560
- const resolved = path4.posix.normalize(clean).replace(/\/+$/, "");
742
+ const resolved = path5.posix.normalize(clean).replace(/\/+$/, "");
561
743
  if (resolved === "." || resolved === "") continue;
562
744
  if (resolved === ".." || resolved.startsWith("../")) {
563
745
  problems.push(`reference '${raw}' points outside the skill folder`);
@@ -567,6 +749,31 @@ function findBrokenReferences(body, files) {
567
749
  }
568
750
  return problems;
569
751
  }
752
+ async function renderForValidation(library, folder, raw, collector) {
753
+ let params;
754
+ try {
755
+ params = await library.readSkillParams(folder);
756
+ } catch (error) {
757
+ collector.add("params-file", error.message, folder);
758
+ return null;
759
+ }
760
+ if (!params) return null;
761
+ const source = raw.toLfString();
762
+ const undeclared = findUndeclaredParams(source, params);
763
+ if (undeclared.length > 0) {
764
+ collector.add(
765
+ "params-template",
766
+ `uses undeclared parameter(s) ${undeclared.map((name) => `{{${name}}}`).join(", ")}; declare them in ${PARAMS_FILE}`,
767
+ folder
768
+ );
769
+ }
770
+ try {
771
+ return SkillFile.parse(renderTemplate(source, resolveParamValues(params, {}, folder)));
772
+ } catch (error) {
773
+ collector.add("params-template", error.message, folder);
774
+ return null;
775
+ }
776
+ }
570
777
  async function validateLibrary(library, options = {}) {
571
778
  const collector = new Collector(library.config.validation.rules);
572
779
  const folders = await library.listSkillFolders();
@@ -581,6 +788,8 @@ async function validateLibrary(library, options = {}) {
581
788
  collector.add("skill-file", "missing SKILL.md", folder);
582
789
  continue;
583
790
  }
791
+ const rendered = await renderForValidation(library, folder, skill, collector);
792
+ if (rendered) skill = rendered;
584
793
  const files = new Set(await listFiles(library.skillPath(folder)));
585
794
  const allowUnderscore = library.categories.fromFolder(folder).prefix === "_";
586
795
  const name = validateSkillFile(collector, folder, skill, ruleOptions, allowUnderscore, files);
@@ -636,9 +845,10 @@ function formatDiagnostic(diagnostic) {
636
845
  }
637
846
 
638
847
  // src/core/sync.ts
639
- import fs3 from "fs/promises";
848
+ import fs4 from "fs/promises";
640
849
  import os from "os";
641
- import path5 from "path";
850
+ import path6 from "path";
851
+ import { createHash as createHash2 } from "crypto";
642
852
  var TARGET_PRESETS = {
643
853
  claude: ".claude/skills",
644
854
  // Claude Code
@@ -663,51 +873,72 @@ function resolveTarget(dest, target, options = {}) {
663
873
  `Global installs only accept presets (${Object.keys(USER_TARGET_PRESETS).join(", ")}), got '${target}'.`
664
874
  );
665
875
  }
666
- return path5.join(options.homeDir ?? os.homedir(), preset);
876
+ return path6.join(options.homeDir ?? os.homedir(), preset);
667
877
  }
668
- const root = path5.resolve(dest);
669
- if (TARGET_PRESETS[target]) return path5.resolve(root, TARGET_PRESETS[target]);
670
- const resolved = path5.resolve(root, target);
671
- const relative = path5.relative(root, resolved);
672
- if (!relative || relative.startsWith("..") || path5.isAbsolute(relative)) {
878
+ const root = path6.resolve(dest);
879
+ if (TARGET_PRESETS[target]) return path6.resolve(root, TARGET_PRESETS[target]);
880
+ const resolved = path6.resolve(root, target);
881
+ const relative = path6.relative(root, resolved);
882
+ if (!relative || relative.startsWith("..") || path6.isAbsolute(relative)) {
673
883
  throw new Error(`Target '${target}' must be a folder inside ${root}.`);
674
884
  }
675
885
  return resolved;
676
886
  }
677
887
  async function readManifest(dir) {
678
888
  try {
679
- const parsed = JSON.parse(await fs3.readFile(path5.join(dir, MANIFEST_FILE), "utf8"));
889
+ const parsed = JSON.parse(await fs4.readFile(path6.join(dir, MANIFEST_FILE), "utf8"));
680
890
  return parsed && parsed.tool === "skanvil" && parsed.skills ? parsed : null;
681
891
  } catch {
682
892
  return null;
683
893
  }
684
894
  }
685
895
  async function resolveSource(from, dest) {
686
- const direct = path5.resolve(dest, from);
896
+ const direct = path6.resolve(dest, from);
687
897
  if (await exists(direct)) return direct;
688
- let dir = path5.resolve(dest);
898
+ let dir = path6.resolve(dest);
689
899
  for (; ; ) {
690
- const candidate = path5.join(dir, "node_modules", from);
900
+ const candidate = path6.join(dir, "node_modules", from);
691
901
  if (await exists(candidate)) return candidate;
692
- const parent = path5.dirname(dir);
902
+ const parent = path6.dirname(dir);
693
903
  if (parent === dir) break;
694
904
  dir = parent;
695
905
  }
696
906
  throw new Error(`Cannot find skills source '${from}' (not a folder, nor an installed npm package).`);
697
907
  }
698
- async function place(source, target, mode, warnings) {
699
- await fs3.rm(target, { recursive: true, force: true });
700
- if (mode === "link") {
908
+ async function place(source, target, mode, warnings, rendered) {
909
+ await fs4.rm(target, { recursive: true, force: true });
910
+ if (mode === "link" && rendered !== null) {
911
+ warnings.push(`${path6.basename(target)} has parameters, so it was copied rendered instead of linked`);
912
+ } else if (mode === "link") {
701
913
  try {
702
- await fs3.symlink(source, target, process.platform === "win32" ? "junction" : "dir");
914
+ await fs4.symlink(source, target, process.platform === "win32" ? "junction" : "dir");
703
915
  return "link";
704
916
  } catch (error) {
705
- warnings.push(`could not link ${path5.basename(target)} (${error.message}); copied instead`);
917
+ warnings.push(`could not link ${path6.basename(target)} (${error.message}); copied instead`);
706
918
  }
707
919
  }
708
- await fs3.cp(source, target, { recursive: true });
920
+ await fs4.cp(source, target, { recursive: true });
921
+ if (rendered !== null) {
922
+ await fs4.writeFile(path6.join(target, "SKILL.md"), rendered, "utf8");
923
+ await fs4.rm(path6.join(target, PARAMS_FILE), { force: true });
924
+ }
709
925
  return "copy";
710
926
  }
927
+ async function materialize(library, name, definitions, values) {
928
+ const dir = library.skillPath(name);
929
+ if (!definitions) return { hash: await hashDirectory(dir), rendered: null };
930
+ const raw = await fs4.readFile(path6.join(dir, "SKILL.md"), "utf8");
931
+ const rendered = renderTemplate(raw, resolveParamValues(definitions, values, name, true));
932
+ const hash = createHash2("sha256");
933
+ for (const file of await listFiles(dir)) {
934
+ if (file === PARAMS_FILE) continue;
935
+ hash.update(file);
936
+ hash.update("\0");
937
+ hash.update(file === "SKILL.md" ? Buffer.from(rendered, "utf8") : await fs4.readFile(path6.join(dir, file)));
938
+ hash.update("\0");
939
+ }
940
+ return { hash: hash.digest("hex").slice(0, 16), rendered };
941
+ }
711
942
  async function syncSkills(library, options) {
712
943
  const mode = options.mode ?? library.config.sync.mode;
713
944
  const filtered = Boolean(options.categories?.length || options.skills?.length);
@@ -739,15 +970,34 @@ async function syncSkills(library, options) {
739
970
  }
740
971
  }
741
972
  }
742
- const hashes = /* @__PURE__ */ new Map();
743
- for (const name of candidates) hashes.set(name, await hashDirectory(library.skillPath(name)));
744
- const dirs = options.targets.map(
745
- (target) => resolveTarget(options.dest, target, { scope: options.scope, homeDir: options.homeDir })
746
- );
747
- const results = [];
748
- for (const [index, target] of options.targets.entries()) {
749
- const dir = dirs[index];
973
+ const definitions = /* @__PURE__ */ new Map();
974
+ for (const name of candidates) definitions.set(name, await library.readSkillParams(name));
975
+ const declared = new Set([...definitions.values()].flatMap((defs) => Object.keys(defs ?? {})));
976
+ if (options.params) {
977
+ const unknown = Object.keys(options.params).filter((key) => !declared.has(key));
978
+ if (unknown.length > 0) {
979
+ throw new Error(
980
+ `Unknown parameter(s) ${unknown.join(", ")}: no synced skill declares them` + (declared.size ? ` (declared: ${[...declared].join(", ")})` : " (no synced skill has parameters)")
981
+ );
982
+ }
983
+ }
984
+ const cache = /* @__PURE__ */ new Map();
985
+ const plans = [];
986
+ for (const target of options.targets) {
987
+ const dir = resolveTarget(options.dest, target, { scope: options.scope, homeDir: options.homeDir });
750
988
  const manifest = await readManifest(dir) ?? { tool: "skanvil", version: "", source: "", skills: {} };
989
+ const remembered = Object.fromEntries(Object.entries(manifest.params ?? {}).filter(([key]) => declared.has(key)));
990
+ const values = options.params ?? remembered;
991
+ const items = /* @__PURE__ */ new Map();
992
+ for (const name of candidates) {
993
+ const key = `${name}\0${JSON.stringify(values)}`;
994
+ if (!cache.has(key)) cache.set(key, await materialize(library, name, definitions.get(name) ?? null, values));
995
+ items.set(name, cache.get(key));
996
+ }
997
+ plans.push({ target, dir, manifest, values, items });
998
+ }
999
+ const results = [];
1000
+ for (const { target, dir, manifest, values, items } of plans) {
751
1001
  const result = {
752
1002
  target,
753
1003
  path: dir,
@@ -756,21 +1006,23 @@ async function syncSkills(library, options) {
756
1006
  unchanged: [],
757
1007
  removed: [],
758
1008
  skipped: [...skipped],
759
- warnings: []
1009
+ warnings: [],
1010
+ params: declared.size ? values : {}
760
1011
  };
761
1012
  const next = {
762
1013
  tool: "skanvil",
763
1014
  version: packageVersion(),
764
1015
  // Relative, so a committed manifest does not change from machine to machine.
765
- source: path5.relative(dir, library.root).split(path5.sep).join("/"),
766
- skills: filtered ? { ...manifest.skills } : {}
1016
+ source: path6.relative(dir, library.root).split(path6.sep).join("/"),
1017
+ skills: filtered ? { ...manifest.skills } : {},
1018
+ ...declared.size && Object.keys(values).length ? { params: values } : {}
767
1019
  };
768
- if (!options.dryRun) await fs3.mkdir(dir, { recursive: true });
1020
+ if (!options.dryRun) await fs4.mkdir(dir, { recursive: true });
769
1021
  for (const name of candidates) {
770
- const destination = path5.join(dir, name);
1022
+ const destination = path6.join(dir, name);
771
1023
  const previous = manifest.skills[name];
772
1024
  const present = await exists(destination);
773
- const hash = hashes.get(name);
1025
+ const { hash, rendered } = items.get(name);
774
1026
  if (present && !previous) {
775
1027
  result.skipped.push({ skill: name, reason: `${destination} exists and is not managed by skanvil` });
776
1028
  continue;
@@ -781,7 +1033,7 @@ async function syncSkills(library, options) {
781
1033
  continue;
782
1034
  }
783
1035
  (present ? result.updated : result.added).push(name);
784
- const actualMode = options.dryRun ? mode : await place(library.skillPath(name), destination, mode, result.warnings);
1036
+ const actualMode = options.dryRun ? mode : await place(library.skillPath(name), destination, mode, result.warnings, rendered);
785
1037
  next.skills[name] = { hash, mode: actualMode };
786
1038
  }
787
1039
  if (!filtered) {
@@ -792,11 +1044,11 @@ async function syncSkills(library, options) {
792
1044
  continue;
793
1045
  }
794
1046
  result.removed.push(name);
795
- if (!options.dryRun) await fs3.rm(path5.join(dir, name), { recursive: true, force: true });
1047
+ if (!options.dryRun) await fs4.rm(path6.join(dir, name), { recursive: true, force: true });
796
1048
  }
797
1049
  }
798
1050
  if (!options.dryRun) {
799
- await fs3.writeFile(path5.join(dir, MANIFEST_FILE), `${JSON.stringify(next, null, 2)}
1051
+ await fs4.writeFile(path6.join(dir, MANIFEST_FILE), `${JSON.stringify(next, null, 2)}
800
1052
  `, "utf8");
801
1053
  }
802
1054
  results.push(result);
@@ -805,16 +1057,16 @@ async function syncSkills(library, options) {
805
1057
  }
806
1058
 
807
1059
  // src/core/agent-skill.ts
808
- import fs4 from "fs/promises";
809
- import path6 from "path";
1060
+ import fs5 from "fs/promises";
1061
+ import path7 from "path";
810
1062
  var AGENT_SKILL_NAME = "skanvil";
811
1063
  var AGENT_SKILL_VERSION_KEY = "skanvil-version";
812
1064
  function agentSkillSource() {
813
- return path6.join(PACKAGE_ROOT, "agent-skill", AGENT_SKILL_NAME);
1065
+ return path7.join(PACKAGE_ROOT, "agent-skill", AGENT_SKILL_NAME);
814
1066
  }
815
1067
  async function installedVersion(dir) {
816
1068
  try {
817
- const skill = SkillFile.parse(await fs4.readFile(path6.join(dir, "SKILL.md"), "utf8"));
1069
+ const skill = SkillFile.parse(await fs5.readFile(path7.join(dir, "SKILL.md"), "utf8"));
818
1070
  return skill.getString("metadata", AGENT_SKILL_VERSION_KEY) || null;
819
1071
  } catch {
820
1072
  return null;
@@ -824,7 +1076,7 @@ async function installAgentSkill(options) {
824
1076
  const version = packageVersion();
825
1077
  const results = [];
826
1078
  const destinations = options.targets.map(
827
- (target) => path6.join(resolveTarget(options.dest, target, { scope: options.scope, homeDir: options.homeDir }), AGENT_SKILL_NAME)
1079
+ (target) => path7.join(resolveTarget(options.dest, target, { scope: options.scope, homeDir: options.homeDir }), AGENT_SKILL_NAME)
828
1080
  );
829
1081
  for (const [index, target] of options.targets.entries()) {
830
1082
  const destination = destinations[index];
@@ -843,18 +1095,255 @@ async function installAgentSkill(options) {
843
1095
  results.push({ target, path: destination, status: "unchanged" });
844
1096
  continue;
845
1097
  }
846
- await fs4.rm(destination, { recursive: true, force: true });
847
- await fs4.mkdir(path6.dirname(destination), { recursive: true });
848
- await fs4.cp(agentSkillSource(), destination, { recursive: true });
849
- const skillPath = path6.join(destination, "SKILL.md");
850
- const skill = SkillFile.parse(await fs4.readFile(skillPath, "utf8"));
1098
+ await fs5.rm(destination, { recursive: true, force: true });
1099
+ await fs5.mkdir(path7.dirname(destination), { recursive: true });
1100
+ await fs5.cp(agentSkillSource(), destination, { recursive: true });
1101
+ const skillPath = path7.join(destination, "SKILL.md");
1102
+ const skill = SkillFile.parse(await fs5.readFile(skillPath, "utf8"));
851
1103
  skill.set(["metadata", AGENT_SKILL_VERSION_KEY], version);
852
- await fs4.writeFile(skillPath, skill.toString(), "utf8");
1104
+ await fs5.writeFile(skillPath, skill.toString(), "utf8");
853
1105
  results.push({ target, path: destination, status: present ? "updated" : "installed" });
854
1106
  }
855
1107
  return results;
856
1108
  }
857
1109
 
1110
+ // src/core/build.ts
1111
+ import fs6 from "fs/promises";
1112
+ import path8 from "path";
1113
+ var RESERVED = new Set(
1114
+ "break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await arguments eval skills getSkill renderTemplate resolveParamValues".split(" ")
1115
+ );
1116
+ function camelCase(value) {
1117
+ return value.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part, index) => index === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1)).join("");
1118
+ }
1119
+ function capitalize(identifier) {
1120
+ return identifier.charAt(0).toUpperCase() + identifier.slice(1);
1121
+ }
1122
+ function exportNames(library, names) {
1123
+ const fix = (name) => /^[a-z]/.test(name) && !RESERVED.has(name) ? name : `skill${capitalize(name)}`;
1124
+ const short = new Map(names.map((name) => [name, fix(camelCase(library.categories.stripPrefix(name)))]));
1125
+ const counts = /* @__PURE__ */ new Map();
1126
+ for (const value of short.values()) counts.set(value, (counts.get(value) ?? 0) + 1);
1127
+ const result = /* @__PURE__ */ new Map();
1128
+ for (const name of names) {
1129
+ const candidate = short.get(name);
1130
+ result.set(name, counts.get(candidate) > 1 ? fix(camelCase(name)) : candidate);
1131
+ }
1132
+ return result;
1133
+ }
1134
+ function isText(buffer) {
1135
+ return !buffer.includes(0) && Buffer.from(buffer.toString("utf8"), "utf8").equals(buffer);
1136
+ }
1137
+ function docComment(lines, indent = "") {
1138
+ const body = lines.flatMap((line) => line.split("\n")).map((line) => `${indent} * ${line.replace(/\*\//g, "*\\/")}`.trimEnd());
1139
+ return `${indent}/**
1140
+ ${body.join("\n")}
1141
+ ${indent} */
1142
+ `;
1143
+ }
1144
+ function paramsType(definitions) {
1145
+ const entries = Object.entries(definitions).map(([name, definition]) => {
1146
+ const type = definition.type === "enum" ? definition.values.map((value) => JSON.stringify(value)).join(" | ") : definition.type === "boolean" ? "boolean" : "string";
1147
+ const doc = docComment(
1148
+ [
1149
+ ...definition.description ? [definition.description] : [],
1150
+ `@defaultValue ${JSON.stringify(definition.default)}`
1151
+ ],
1152
+ " "
1153
+ );
1154
+ return `${doc} ${name}?: ${type};`;
1155
+ });
1156
+ return entries.length ? `{
1157
+ ${entries.join("\n")}
1158
+ }` : "Record<string, never>";
1159
+ }
1160
+ function runtimeSource(skills) {
1161
+ const data = skills.map((skill) => ({
1162
+ name: skill.name,
1163
+ category: skill.category,
1164
+ title: skill.title,
1165
+ description: skill.description,
1166
+ params: skill.params,
1167
+ template: skill.template,
1168
+ files: skill.files
1169
+ }));
1170
+ return `const renderTemplate = ${renderTemplate.toString()};
1171
+
1172
+ const resolveParamValues = ${resolveParamValues.toString()};
1173
+
1174
+ const definitions = ${JSON.stringify(data, null, 2)};
1175
+
1176
+ function createSkill(definition) {
1177
+ const render = (params) =>
1178
+ renderTemplate(definition.template, resolveParamValues(definition.params, params || {}, definition.name));
1179
+ return Object.freeze({
1180
+ name: definition.name,
1181
+ category: definition.category,
1182
+ title: definition.title,
1183
+ description: definition.description,
1184
+ params: definition.params,
1185
+ render,
1186
+ files: (params) => Object.assign({ "SKILL.md": { encoding: "utf8", content: render(params) } }, definition.files)
1187
+ });
1188
+ }
1189
+
1190
+ const skills = Object.freeze(definitions.map(createSkill));
1191
+
1192
+ function getSkill(name) {
1193
+ const skill = skills.find((candidate) => candidate.name === name);
1194
+ if (!skill) throw new Error("Unknown skill '" + name + "'. Available: " + skills.map((s) => s.name).join(", "));
1195
+ return skill;
1196
+ }
1197
+ `;
1198
+ }
1199
+ function declarationSource(skills, packageName) {
1200
+ const header = `// Generated by skanvil build. Do not edit.
1201
+
1202
+ /** Definition of a skill parameter (from its params.json). */
1203
+ export interface SkillParamDefinition {
1204
+ type: "enum" | "string" | "boolean";
1205
+ /** Allowed values of an enum parameter. */
1206
+ values?: string[];
1207
+ default: string | boolean;
1208
+ description?: string;
1209
+ }
1210
+
1211
+ /** A file of a skill. Text files are UTF-8, binary files base64. */
1212
+ export interface SkillFileContent {
1213
+ encoding: "utf8" | "base64";
1214
+ content: string;
1215
+ }
1216
+
1217
+ /** A skill of ${packageName}. */
1218
+ export interface Skill<P = Record<string, unknown>> {
1219
+ /** Folder and frontmatter name, e.g. "front-api-review". */
1220
+ readonly name: string;
1221
+ /** Category id. */
1222
+ readonly category: string;
1223
+ /** Display title. */
1224
+ readonly title: string;
1225
+ /** Frontmatter description (rendered with default parameters). */
1226
+ readonly description: string;
1227
+ /** Declared parameters. */
1228
+ readonly params: Readonly<Record<string, SkillParamDefinition>>;
1229
+ /** Returns SKILL.md rendered with the given parameters (defaults for the rest). Throws on invalid values. */
1230
+ render(params?: P): string;
1231
+ /** Every file of the skill folder, keyed by relative path, with SKILL.md rendered. Write them to e.g. .claude/skills/<name>/. */
1232
+ files(params?: P): Record<string, SkillFileContent>;
1233
+ }
1234
+
1235
+ /** Every skill of the package, in folder order. */
1236
+ export declare const skills: readonly Skill[];
1237
+
1238
+ /** Finds a skill by folder name. Throws when it does not exist. */
1239
+ export declare function getSkill(name: SkillName): Skill;
1240
+
1241
+ /** Folder names of the skills in this package. */
1242
+ export type SkillName = ${skills.map((skill) => JSON.stringify(skill.name)).join(" | ") || "never"};
1243
+ `;
1244
+ const perSkill = skills.map((skill) => {
1245
+ const typeName = `${capitalize(skill.exportName)}Params`;
1246
+ return `
1247
+ ${docComment([`Parameters of the \`${skill.name}\` skill.`])}export type ${typeName} = ${paramsType(skill.params)};
1248
+
1249
+ ` + docComment([
1250
+ `${skill.title}: ${skill.description}`,
1251
+ "",
1252
+ `@returns The \`${skill.name}\` SKILL.md${Object.keys(skill.params).length ? ", rendered with the given parameters" : ""}.`
1253
+ ]) + `export declare function ${skill.exportName}(params?: ${typeName}): string;
1254
+ `;
1255
+ });
1256
+ return header + perSkill.join("");
1257
+ }
1258
+ async function buildLibrary(library, options = {}) {
1259
+ if (!options.force) {
1260
+ const validation = await validateLibrary(library);
1261
+ if (validation.errors > 0) {
1262
+ throw new Error(
1263
+ `Validation found ${validation.errors} error(s); run 'skanvil validate' (or build with --force).`
1264
+ );
1265
+ }
1266
+ }
1267
+ const folders = [];
1268
+ for (const folder of await library.listSkillFolders()) {
1269
+ if (library.categories.fromFolder(folder).validated && await library.hasSkill(folder)) folders.push(folder);
1270
+ }
1271
+ const names = exportNames(library, folders);
1272
+ const skills = [];
1273
+ for (const folder of folders) {
1274
+ const dir = library.skillPath(folder);
1275
+ const params = await library.readSkillParams(folder) ?? {};
1276
+ const template = (await fs6.readFile(path8.join(dir, "SKILL.md"), "utf8")).replace(/\r\n/g, "\n");
1277
+ const rendered = await library.readSkillRendered(folder);
1278
+ const files = {};
1279
+ for (const file of await listFiles(dir)) {
1280
+ if (file === "SKILL.md" || file === PARAMS_FILE) continue;
1281
+ const buffer = await fs6.readFile(path8.join(dir, file));
1282
+ files[file] = isText(buffer) ? { encoding: "utf8", content: buffer.toString("utf8") } : { encoding: "base64", content: buffer.toString("base64") };
1283
+ }
1284
+ skills.push({
1285
+ name: folder,
1286
+ exportName: names.get(folder),
1287
+ category: library.categories.fromFolder(folder).id,
1288
+ title: rendered.title || folder,
1289
+ description: rendered.getString("description"),
1290
+ params,
1291
+ template,
1292
+ files
1293
+ });
1294
+ }
1295
+ let packageName = library.config.name ?? path8.basename(library.root);
1296
+ try {
1297
+ packageName = JSON.parse(await fs6.readFile(path8.join(library.root, "package.json"), "utf8")).name ?? packageName;
1298
+ } catch {
1299
+ }
1300
+ const outDir = path8.resolve(library.root, options.outDir ?? "dist");
1301
+ await fs6.mkdir(outDir, { recursive: true });
1302
+ const runtime = runtimeSource(skills);
1303
+ const exportsList = skills.map((skill) => ` ${skill.exportName}`);
1304
+ const functions = skills.map((skill, index) => `const ${skill.exportName} = (params) => skills[${index}].render(params);`).join("\n");
1305
+ const banner = "// Generated by skanvil build. Do not edit.\n";
1306
+ const esm = `${banner}${runtime}
1307
+ ${functions}
1308
+
1309
+ export {
1310
+ skills,
1311
+ getSkill${exportsList.length ? ",\n" + exportsList.join(",\n") : ""}
1312
+ };
1313
+ `;
1314
+ const cjs = `${banner}"use strict";
1315
+ ${runtime}
1316
+ ${functions}
1317
+
1318
+ module.exports = {
1319
+ skills,
1320
+ getSkill${exportsList.length ? ",\n" + exportsList.join(",\n") : ""}
1321
+ };
1322
+ `;
1323
+ const dts = declarationSource(skills, packageName);
1324
+ const written = {
1325
+ "index.mjs": esm,
1326
+ "index.cjs": cjs,
1327
+ "index.d.mts": dts,
1328
+ "index.d.cts": dts
1329
+ };
1330
+ for (const [file, content] of Object.entries(written)) {
1331
+ await fs6.writeFile(path8.join(outDir, file), content, "utf8");
1332
+ }
1333
+ return {
1334
+ outDir,
1335
+ files: Object.keys(written).map(
1336
+ (file) => path8.relative(library.root, path8.join(outDir, file)).split(path8.sep).join("/")
1337
+ ),
1338
+ skills: skills.map((skill) => ({
1339
+ name: skill.name,
1340
+ exportName: skill.exportName,
1341
+ category: skill.category,
1342
+ params: Object.keys(skill.params)
1343
+ }))
1344
+ };
1345
+ }
1346
+
858
1347
  // src/core/readme-index.ts
859
1348
  var SUMMARY_PATTERN = /<summary><strong>(.+?)<\/strong><\/summary>/;
860
1349
  function folderPattern(skillsDir) {
@@ -1260,46 +1749,46 @@ var Categories = class {
1260
1749
  };
1261
1750
 
1262
1751
  // src/core/config.ts
1263
- import fs5 from "fs/promises";
1264
- import path7 from "path";
1265
- import { z } from "zod";
1752
+ import fs7 from "fs/promises";
1753
+ import path9 from "path";
1754
+ import { z as z2 } from "zod";
1266
1755
  var CONFIG_FILE = ".skanvilrc.json";
1267
1756
  var SEVERITIES = ["error", "warn", "off"];
1268
1757
  var PREFIX_PATTERN = /^(|_|[a-z0-9]+(?:-[a-z0-9]+)*-)$/;
1269
- var CategorySchema = z.object({
1270
- id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "category id must be kebab-case").describe("Category identifier used by `--type` (kebab-case)."),
1271
- prefix: z.string().regex(PREFIX_PATTERN, "prefix must be empty, '_' or kebab-case ending in '-'").optional().describe(
1758
+ var CategorySchema = z2.object({
1759
+ id: z2.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "category id must be kebab-case").describe("Category identifier used by `--type` (kebab-case)."),
1760
+ prefix: z2.string().regex(PREFIX_PATTERN, "prefix must be empty, '_' or kebab-case ending in '-'").optional().describe(
1272
1761
  "Folder-name prefix of the category's skills, e.g. `front-` or `_`. Only the default category may be empty."
1273
1762
  ),
1274
- title: z.string().min(1).optional().describe("README section title. Defaults to the title-cased id."),
1275
- aliases: z.array(z.string()).optional().describe("Alternative names accepted by `--type` (case-insensitive)."),
1276
- default: z.boolean().optional().describe(
1763
+ title: z2.string().min(1).optional().describe("README section title. Defaults to the title-cased id."),
1764
+ aliases: z2.array(z2.string()).optional().describe("Alternative names accepted by `--type` (case-insensitive)."),
1765
+ default: z2.boolean().optional().describe(
1277
1766
  "Marks the fallback category for folders without a known prefix. Exactly one category must be the default."
1278
1767
  ),
1279
- indexed: z.boolean().optional().describe("List the category's skills in the README index. Default: true."),
1280
- validated: z.boolean().optional().describe("Validate the category's skills against the spec and allow syncing them. Default: true."),
1281
- metadata: z.record(z.string(), z.string()).optional().describe("Key/value pairs written to the `metadata` frontmatter map of new or retyped skills."),
1282
- compatibility: z.string().max(500).optional().describe("Value written to the `compatibility` frontmatter field of new or retyped skills (max 500 chars).")
1768
+ indexed: z2.boolean().optional().describe("List the category's skills in the README index. Default: true."),
1769
+ validated: z2.boolean().optional().describe("Validate the category's skills against the spec and allow syncing them. Default: true."),
1770
+ metadata: z2.record(z2.string(), z2.string()).optional().describe("Key/value pairs written to the `metadata` frontmatter map of new or retyped skills."),
1771
+ compatibility: z2.string().max(500).optional().describe("Value written to the `compatibility` frontmatter field of new or retyped skills (max 500 chars).")
1283
1772
  }).strict().describe("A skill category: folder prefix, README section and frontmatter defaults.");
1284
- var ConfigSchema = z.object({
1285
- $schema: z.string().optional().describe("JSON Schema reference for editor support."),
1286
- name: z.string().optional().describe("Library name (informational)."),
1287
- scope: z.string().optional().describe("npm scope without '@' (informational)."),
1288
- skillsDir: z.string().min(1).optional().describe("Folder holding the skills, relative to the library root. Default: skills."),
1289
- readme: z.string().min(1).optional().describe("README file holding the skills index. Default: README.md."),
1290
- categories: z.array(CategorySchema).min(1).optional().describe("Skill categories. Replaces the default preset (front, back, transversal, internal) when set."),
1291
- validation: z.object({
1292
- requiredSections: z.array(z.string()).optional().describe("Level-2 headings (`## Name`) every SKILL.md must contain, compared case-insensitively."),
1293
- placeholders: z.array(z.string()).optional().describe("Forbidden placeholder strings. ALL-CAPS entries (TODO) match case-sensitively as words."),
1294
- maxLines: z.number().int().positive().optional().describe("Line count reported by the `max-lines` rule. Default: 500."),
1295
- rules: z.record(z.string(), z.enum(SEVERITIES)).optional().describe('Per-rule severity overrides, e.g. { "max-lines": "off" }. See `skanvil validate --help`.')
1773
+ var ConfigSchema = z2.object({
1774
+ $schema: z2.string().optional().describe("JSON Schema reference for editor support."),
1775
+ name: z2.string().optional().describe("Library name (informational)."),
1776
+ scope: z2.string().optional().describe("npm scope without '@' (informational)."),
1777
+ skillsDir: z2.string().min(1).optional().describe("Folder holding the skills, relative to the library root. Default: skills."),
1778
+ readme: z2.string().min(1).optional().describe("README file holding the skills index. Default: README.md."),
1779
+ categories: z2.array(CategorySchema).min(1).optional().describe("Skill categories. Replaces the default preset (front, back, transversal, internal) when set."),
1780
+ validation: z2.object({
1781
+ requiredSections: z2.array(z2.string()).optional().describe("Level-2 headings (`## Name`) every SKILL.md must contain, compared case-insensitively."),
1782
+ placeholders: z2.array(z2.string()).optional().describe("Forbidden placeholder strings. ALL-CAPS entries (TODO) match case-sensitively as words."),
1783
+ maxLines: z2.number().int().positive().optional().describe("Line count reported by the `max-lines` rule. Default: 500."),
1784
+ rules: z2.record(z2.string(), z2.enum(SEVERITIES)).optional().describe('Per-rule severity overrides, e.g. { "max-lines": "off" }. See `skanvil validate --help`.')
1296
1785
  }).strict().optional().describe("Validation settings."),
1297
- templates: z.object({
1298
- dir: z.string().min(1).optional().describe("Folder with custom templates, relative to the library root. Default: .skanvil/templates.")
1786
+ templates: z2.object({
1787
+ dir: z2.string().min(1).optional().describe("Folder with custom templates, relative to the library root. Default: .skanvil/templates.")
1299
1788
  }).strict().optional().describe("Custom template settings."),
1300
- sync: z.object({
1301
- targets: z.array(z.string()).optional().describe("Default `skanvil sync` targets: presets (claude, copilot, agents, codex) or paths."),
1302
- mode: z.enum(["copy", "link"]).optional().describe("Default sync mode. Default: copy.")
1789
+ sync: z2.object({
1790
+ targets: z2.array(z2.string()).optional().describe("Default `skanvil sync` targets: presets (claude, copilot, agents, codex) or paths."),
1791
+ mode: z2.enum(["copy", "link"]).optional().describe("Default sync mode. Default: copy.")
1303
1792
  }).strict().optional().describe("Defaults for `skanvil sync`.")
1304
1793
  }).strict().describe("skanvil library configuration.");
1305
1794
  var DEFAULT_CATEGORIES = [
@@ -1434,7 +1923,7 @@ function resolveConfig(raw = {}) {
1434
1923
  async function readRawConfig(root) {
1435
1924
  let text;
1436
1925
  try {
1437
- text = await fs5.readFile(path7.join(root, CONFIG_FILE), "utf8");
1926
+ text = await fs7.readFile(path9.join(root, CONFIG_FILE), "utf8");
1438
1927
  } catch {
1439
1928
  return {};
1440
1929
  }
@@ -1448,12 +1937,12 @@ async function loadConfig(root) {
1448
1937
  return resolveConfig(await readRawConfig(root));
1449
1938
  }
1450
1939
  function configJsonSchema() {
1451
- return z.toJSONSchema(ConfigSchema, { target: "draft-7" });
1940
+ return z2.toJSONSchema(ConfigSchema, { target: "draft-7" });
1452
1941
  }
1453
1942
 
1454
1943
  // src/core/library.ts
1455
- import fs6 from "fs/promises";
1456
- import path8 from "path";
1944
+ import fs8 from "fs/promises";
1945
+ import path10 from "path";
1457
1946
  var Library = class _Library {
1458
1947
  /** Absolute library root. */
1459
1948
  root;
@@ -1468,7 +1957,7 @@ var Library = class _Library {
1468
1957
  * @param config - Resolved configuration. @defaultValue the default preset
1469
1958
  */
1470
1959
  constructor(root, config = resolveConfig()) {
1471
- this.root = path8.resolve(root);
1960
+ this.root = path10.resolve(root);
1472
1961
  this.config = config;
1473
1962
  this.categories = new Categories(config.categories);
1474
1963
  }
@@ -1480,7 +1969,7 @@ var Library = class _Library {
1480
1969
  * @throws Error when the config is invalid or the skills folder is missing.
1481
1970
  */
1482
1971
  static async open(root) {
1483
- const library = new _Library(root, await loadConfig(path8.resolve(root)));
1972
+ const library = new _Library(root, await loadConfig(path10.resolve(root)));
1484
1973
  if (!await exists(library.skillsDir)) {
1485
1974
  throw new Error(
1486
1975
  `No ${library.config.skillsDir}/ folder found in ${library.root}. Run 'skanvil init' first or use --cwd.`
@@ -1490,29 +1979,29 @@ var Library = class _Library {
1490
1979
  }
1491
1980
  /** Absolute path of the skills folder. */
1492
1981
  get skillsDir() {
1493
- return path8.join(this.root, this.config.skillsDir);
1982
+ return path10.join(this.root, this.config.skillsDir);
1494
1983
  }
1495
1984
  /** Skills folder as written in README entries (posix separators), e.g. `skills`. */
1496
1985
  get skillsDirRef() {
1497
- return this.config.skillsDir.split(path8.sep).join("/");
1986
+ return this.config.skillsDir.split(path10.sep).join("/");
1498
1987
  }
1499
1988
  /** Absolute path of the README holding the index. */
1500
1989
  get readmePath() {
1501
- return path8.join(this.root, this.config.readme);
1990
+ return path10.join(this.root, this.config.readme);
1502
1991
  }
1503
1992
  /**
1504
1993
  * @param name - Skill folder name.
1505
1994
  * @returns Absolute path of the skill folder (it may not exist).
1506
1995
  */
1507
1996
  skillPath(name) {
1508
- return path8.join(this.skillsDir, name);
1997
+ return path10.join(this.skillsDir, name);
1509
1998
  }
1510
1999
  /**
1511
2000
  * @param name - Skill folder name.
1512
2001
  * @returns Absolute path of the skill's `SKILL.md` (it may not exist).
1513
2002
  */
1514
2003
  skillFilePath(name) {
1515
- return path8.join(this.skillPath(name), "SKILL.md");
2004
+ return path10.join(this.skillPath(name), "SKILL.md");
1516
2005
  }
1517
2006
  /**
1518
2007
  * Lists the skill folders. Hidden folders (starting with `.`) are ignored.
@@ -1520,7 +2009,7 @@ var Library = class _Library {
1520
2009
  * @returns Folder names sorted with `localeCompare`.
1521
2010
  */
1522
2011
  async listSkillFolders() {
1523
- const entries = await fs6.readdir(this.skillsDir, { withFileTypes: true });
2012
+ const entries = await fs8.readdir(this.skillsDir, { withFileTypes: true });
1524
2013
  return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).sort((a, b) => a.localeCompare(b));
1525
2014
  }
1526
2015
  /**
@@ -1549,7 +2038,33 @@ var Library = class _Library {
1549
2038
  * @throws The file system error when the file cannot be read.
1550
2039
  */
1551
2040
  async readSkill(name) {
1552
- return SkillFile.parse(await fs6.readFile(this.skillFilePath(name), "utf8"));
2041
+ return SkillFile.parse(await fs8.readFile(this.skillFilePath(name), "utf8"));
2042
+ }
2043
+ /**
2044
+ * Reads a skill's parameter definitions (`params.json`).
2045
+ *
2046
+ * @param name - Skill folder name.
2047
+ * @returns The definitions, or `null` when the skill has none.
2048
+ * @throws Error when `params.json` exists but is invalid.
2049
+ */
2050
+ async readSkillParams(name) {
2051
+ return readParams(this.skillPath(name));
2052
+ }
2053
+ /**
2054
+ * Reads a skill's `SKILL.md` rendered with parameter values. Skills without `params.json` are
2055
+ * returned as they are.
2056
+ *
2057
+ * @param name - Skill folder name.
2058
+ * @param values - Parameter values; missing ones take their defaults. @defaultValue `{}`
2059
+ * @returns The rendered file.
2060
+ * @throws Error when the file cannot be read, `params.json` is invalid, a value is invalid, or
2061
+ * the template is malformed.
2062
+ */
2063
+ async readSkillRendered(name, values = {}) {
2064
+ const text = await fs8.readFile(this.skillFilePath(name), "utf8");
2065
+ const params = await this.readSkillParams(name);
2066
+ if (!params) return SkillFile.parse(text);
2067
+ return SkillFile.parse(renderTemplate(text, resolveParamValues(params, values, name)));
1553
2068
  }
1554
2069
  /**
1555
2070
  * Writes a skill's `SKILL.md`, keeping the line ending it was read with.
@@ -1558,7 +2073,7 @@ var Library = class _Library {
1558
2073
  * @param skill - File content to write.
1559
2074
  */
1560
2075
  async writeSkill(name, skill) {
1561
- await fs6.writeFile(this.skillFilePath(name), skill.toString(), "utf8");
2076
+ await fs8.writeFile(this.skillFilePath(name), skill.toString(), "utf8");
1562
2077
  }
1563
2078
  /**
1564
2079
  * Reads the README.
@@ -1568,7 +2083,7 @@ var Library = class _Library {
1568
2083
  */
1569
2084
  async readReadme() {
1570
2085
  try {
1571
- return splitEol(await fs6.readFile(this.readmePath, "utf8"));
2086
+ return splitEol(await fs8.readFile(this.readmePath, "utf8"));
1572
2087
  } catch {
1573
2088
  throw new Error(`Cannot read ${this.config.readme} in ${this.root}.`);
1574
2089
  }
@@ -1591,7 +2106,7 @@ var Library = class _Library {
1591
2106
  * @param readme - Content and line ending, usually from {@link Library.readReadme}.
1592
2107
  */
1593
2108
  async writeReadme(readme) {
1594
- await fs6.writeFile(this.readmePath, fromLf(readme.text, readme.eol), "utf8");
2109
+ await fs8.writeFile(this.readmePath, fromLf(readme.text, readme.eol), "utf8");
1595
2110
  }
1596
2111
  /**
1597
2112
  * Lists the skills documented in each README section of the indexed categories.
@@ -1629,7 +2144,7 @@ var Library = class _Library {
1629
2144
  let title = "";
1630
2145
  let description = "";
1631
2146
  try {
1632
- const skill = await this.readSkill(name);
2147
+ const skill = await this.readSkillRendered(name).catch(() => this.readSkill(name));
1633
2148
  title = skill.title;
1634
2149
  description = skill.getString("description");
1635
2150
  } catch {
@@ -1646,8 +2161,8 @@ var Library = class _Library {
1646
2161
 
1647
2162
  // src/core/init.ts
1648
2163
  import { spawnSync } from "child_process";
1649
- import fs7 from "fs/promises";
1650
- import path9 from "path";
2164
+ import fs9 from "fs/promises";
2165
+ import path11 from "path";
1651
2166
  var defaultCategories = new Categories(resolveConfig().categories);
1652
2167
  function parseProfile(value) {
1653
2168
  const indexed = defaultCategories.indexed.map((category) => category.id);
@@ -1668,11 +2183,11 @@ function parseProfile(value) {
1668
2183
  return [...new Set(ids)];
1669
2184
  }
1670
2185
  async function createLibrary(target, options = {}) {
1671
- target = path9.resolve(target);
2186
+ target = path11.resolve(target);
1672
2187
  if (!await isEmptyOrMissing(target)) {
1673
2188
  throw new Error(`Target folder is not empty: ${target}`);
1674
2189
  }
1675
- const name = toKebabCase(options.name || path9.basename(target));
2190
+ const name = toKebabCase(options.name || path11.basename(target));
1676
2191
  if (!name) {
1677
2192
  throw new Error("Library name is required.");
1678
2193
  }
@@ -1700,7 +2215,8 @@ async function createLibrary(target, options = {}) {
1700
2215
  packageName: scope ? `@${scope}/${name}` : name,
1701
2216
  title,
1702
2217
  description,
1703
- sections: buildSections(sectionTitles)
2218
+ sections: buildSections(sectionTitles),
2219
+ skanvilRange: `^${packageVersion()}`
1704
2220
  }
1705
2221
  });
1706
2222
  const rc = {
@@ -1718,7 +2234,7 @@ async function createLibrary(target, options = {}) {
1718
2234
  sync: { targets: syncTargets, mode: "copy" }
1719
2235
  };
1720
2236
  resolveConfig(rc);
1721
- await fs7.writeFile(path9.join(target, CONFIG_FILE), `${JSON.stringify(rc, null, 2)}
2237
+ await fs9.writeFile(path11.join(target, CONFIG_FILE), `${JSON.stringify(rc, null, 2)}
1722
2238
  `, "utf8");
1723
2239
  if (options.ejectTemplates) {
1724
2240
  await ejectSkillTemplate(new Library(target, resolveConfig(rc)));
@@ -1733,7 +2249,7 @@ async function createLibrary(target, options = {}) {
1733
2249
  }
1734
2250
 
1735
2251
  // src/commands/shared.ts
1736
- import path10 from "path";
2252
+ import path12 from "path";
1737
2253
 
1738
2254
  // src/util/output.ts
1739
2255
  import pc from "picocolors";
@@ -1776,7 +2292,7 @@ var Output = class {
1776
2292
 
1777
2293
  // src/commands/shared.ts
1778
2294
  function resolveRoot(options) {
1779
- return path10.resolve(options.cwd || process.cwd());
2295
+ return path12.resolve(options.cwd || process.cwd());
1780
2296
  }
1781
2297
  function openLibrary(options) {
1782
2298
  return Library.open(resolveRoot(options));
@@ -1805,7 +2321,7 @@ async function validateAfter(library, label, skip) {
1805
2321
  }
1806
2322
 
1807
2323
  // src/core/skills.ts
1808
- import fs8 from "fs/promises";
2324
+ import fs10 from "fs/promises";
1809
2325
  async function toLibrary(ref) {
1810
2326
  return typeof ref === "string" ? Library.open(ref) : ref;
1811
2327
  }
@@ -1875,7 +2391,7 @@ async function scaffoldSkill(ref, options) {
1875
2391
  await library.writeReadme(readme);
1876
2392
  }
1877
2393
  } catch (error) {
1878
- await fs8.rm(target, { recursive: true, force: true });
2394
+ await fs10.rm(target, { recursive: true, force: true });
1879
2395
  throw error;
1880
2396
  }
1881
2397
  return { skillName, title, type: category.id, target, template };
@@ -1895,7 +2411,7 @@ async function editSkill(ref, skillName, options) {
1895
2411
  throw new Error(`Skill folder already exists: ${library.skillPath(newName)}`);
1896
2412
  }
1897
2413
  const skillFile = library.skillFilePath(skillName);
1898
- const originalSkill = await fs8.readFile(skillFile, "utf8");
2414
+ const originalSkill = await fs10.readFile(skillFile, "utf8");
1899
2415
  const skill = await library.readSkill(skillName);
1900
2416
  skill.set(["name"], newName);
1901
2417
  if (description) skill.set(["description"], description);
@@ -1921,13 +2437,13 @@ async function editSkill(ref, skillName, options) {
1921
2437
  readme.text = insertEntry(removeEntry(readme.text, skillName, dir), newCategory.title, entry);
1922
2438
  }
1923
2439
  try {
1924
- await fs8.writeFile(skillFile, skill.toString(), "utf8");
2440
+ await fs10.writeFile(skillFile, skill.toString(), "utf8");
1925
2441
  await library.writeReadme(readme);
1926
2442
  if (newName !== skillName) {
1927
- await fs8.rename(library.skillPath(skillName), library.skillPath(newName));
2443
+ await fs10.rename(library.skillPath(skillName), library.skillPath(newName));
1928
2444
  }
1929
2445
  } catch (error) {
1930
- await fs8.writeFile(skillFile, originalSkill, "utf8").catch(() => void 0);
2446
+ await fs10.writeFile(skillFile, originalSkill, "utf8").catch(() => void 0);
1931
2447
  await library.writeReadme(originalReadme).catch(() => void 0);
1932
2448
  throw new Error(`Edit failed and was rolled back: ${error.message}`);
1933
2449
  }
@@ -1937,7 +2453,7 @@ async function removeSkill(ref, skillName) {
1937
2453
  const library = await toLibrary(ref);
1938
2454
  await library.requireSkill(skillName);
1939
2455
  const readme = await library.readReadmeOrEmpty();
1940
- await fs8.rm(library.skillPath(skillName), { recursive: true, force: true });
2456
+ await fs10.rm(library.skillPath(skillName), { recursive: true, force: true });
1941
2457
  if (readme.text) {
1942
2458
  readme.text = removeEntry(readme.text, skillName, library.skillsDirRef);
1943
2459
  await library.writeReadme(readme);
@@ -2068,6 +2584,13 @@ export {
2068
2584
  packageVersion,
2069
2585
  displayPath,
2070
2586
  SkillFile,
2587
+ PARAMS_FILE,
2588
+ parseParams,
2589
+ readParams,
2590
+ resolveParamValues,
2591
+ renderTemplate,
2592
+ findUndeclaredParams,
2593
+ parseParamPairs,
2071
2594
  toKebabCase,
2072
2595
  toTitleCase,
2073
2596
  resolveSkillTemplate,
@@ -2084,6 +2607,8 @@ export {
2084
2607
  AGENT_SKILL_NAME,
2085
2608
  agentSkillSource,
2086
2609
  installAgentSkill,
2610
+ exportNames,
2611
+ buildLibrary,
2087
2612
  rebuildIndex,
2088
2613
  reconcileIndex,
2089
2614
  doctor,