zidane 6.4.4 → 6.5.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.
Files changed (99) hide show
  1. package/README.md +1 -0
  2. package/dist/{acp-BLO58K3F.js → acp-B-AOovHz.js} +2 -2
  3. package/dist/{acp-BLO58K3F.js.map → acp-B-AOovHz.js.map} +1 -1
  4. package/dist/acp-cli.js +3 -2
  5. package/dist/acp-cli.js.map +1 -1
  6. package/dist/acp.d.ts +1 -1
  7. package/dist/acp.js +1 -1
  8. package/dist/{agent-8sUadp5M.js → agent-SQ6rxfpT.js} +231 -106
  9. package/dist/agent-SQ6rxfpT.js.map +1 -0
  10. package/dist/agent.d.ts +1 -1
  11. package/dist/agent.js +1 -1
  12. package/dist/chat/pure.d.ts +3 -3
  13. package/dist/chat.d.ts +13 -44
  14. package/dist/chat.d.ts.map +1 -1
  15. package/dist/chat.js +5 -4
  16. package/dist/chat.js.map +1 -1
  17. package/dist/{context-transport-D29WvHcm.js → context-transport-CDBatvQc.js} +3 -3
  18. package/dist/{context-transport-D29WvHcm.js.map → context-transport-CDBatvQc.js.map} +1 -1
  19. package/dist/contexts/daytona.d.ts +1 -1
  20. package/dist/contexts/e2b.d.ts +1 -1
  21. package/dist/eval.d.ts +1 -1
  22. package/dist/extensions.d.ts +2 -2
  23. package/dist/extensions.js +2 -2
  24. package/dist/headless.d.ts +1 -1
  25. package/dist/headless.js +1 -1
  26. package/dist/{index-C-Nd2ZbV.d.ts → index-CWBSrqI6.d.ts} +676 -3
  27. package/dist/index-CWBSrqI6.d.ts.map +1 -0
  28. package/dist/index.d.ts +2 -2
  29. package/dist/index.js +7 -6
  30. package/dist/index.js.map +1 -1
  31. package/dist/{login-CMVVnAHS.js → login-XZDS4K2o.js} +3 -3
  32. package/dist/{login-CMVVnAHS.js.map → login-XZDS4K2o.js.map} +1 -1
  33. package/dist/{mcp-3GXUBwur.js → mcp-D27rP47I.js} +87 -6
  34. package/dist/mcp-D27rP47I.js.map +1 -0
  35. package/dist/mcp.d.ts +2 -2
  36. package/dist/mcp.js +3 -2
  37. package/dist/origin-pinned-fetch-CqG8VZIq.js +159 -0
  38. package/dist/origin-pinned-fetch-CqG8VZIq.js.map +1 -0
  39. package/dist/output/stream-json.d.ts +1 -1
  40. package/dist/output/terminal.d.ts +1 -1
  41. package/dist/plugins-2PMTVcp-.js +1174 -0
  42. package/dist/plugins-2PMTVcp-.js.map +1 -0
  43. package/dist/plugins.d.ts +2 -0
  44. package/dist/plugins.js +2 -0
  45. package/dist/presets.d.ts +1 -1
  46. package/dist/presets.js +1 -1
  47. package/dist/providers/anthropic.d.ts +1 -1
  48. package/dist/providers/arcee.d.ts +1 -1
  49. package/dist/providers/baseten.d.ts +1 -1
  50. package/dist/providers/cerebras.d.ts +1 -1
  51. package/dist/providers/local.d.ts +1 -1
  52. package/dist/providers/openai-compat.d.ts +1 -1
  53. package/dist/providers/openai.d.ts +1 -1
  54. package/dist/providers/openrouter.d.ts +1 -1
  55. package/dist/providers/xai.d.ts +1 -1
  56. package/dist/providers.d.ts +1 -1
  57. package/dist/restate.d.ts +1 -1
  58. package/dist/restate.js +5 -3
  59. package/dist/restate.js.map +1 -1
  60. package/dist/session/sqlite.d.ts +1 -1
  61. package/dist/session.d.ts +1 -1
  62. package/dist/{resolve-CZJxds85.js → skills-Cp9vnBwd.js} +1430 -1006
  63. package/dist/skills-Cp9vnBwd.js.map +1 -0
  64. package/dist/skills.d.ts +1 -1
  65. package/dist/skills.js +1 -2
  66. package/dist/{stdio-BvL8HGYW.js → stdio-O-m9WVkE.js} +2 -2
  67. package/dist/{stdio-BvL8HGYW.js.map → stdio-O-m9WVkE.js.map} +1 -1
  68. package/dist/{stdio-loader-CjfoqBle.js → stdio-loader-BSGQmG4L.js} +2 -2
  69. package/dist/{stdio-loader-CjfoqBle.js.map → stdio-loader-BSGQmG4L.js.map} +1 -1
  70. package/dist/{tool-formatters-DPQjso_C.d.ts → tool-formatters-gFqQbzPY.d.ts} +2 -2
  71. package/dist/{tool-formatters-DPQjso_C.d.ts.map → tool-formatters-gFqQbzPY.d.ts.map} +1 -1
  72. package/dist/tools/fetch-url.d.ts +1 -1
  73. package/dist/tools/web-search.d.ts +1 -1
  74. package/dist/tools.d.ts +1 -1
  75. package/dist/tools.js +1 -1
  76. package/dist/{transcript-anchors-CgDzYin1.js → transcript-anchors-D4IxJifV.js} +42 -5
  77. package/dist/transcript-anchors-D4IxJifV.js.map +1 -0
  78. package/dist/{transcript-anchors-BCUpbNxc.d.ts → transcript-anchors-qCs3wZpu.d.ts} +98 -92
  79. package/dist/transcript-anchors-qCs3wZpu.d.ts.map +1 -0
  80. package/dist/tui.d.ts +38 -4
  81. package/dist/tui.d.ts.map +1 -1
  82. package/dist/tui.js +839 -105
  83. package/dist/tui.js.map +1 -1
  84. package/dist/{turn-operations-Bu2Hkinw.d.ts → turn-operations-M9-jUyDd.d.ts} +3 -3
  85. package/dist/{turn-operations-Bu2Hkinw.d.ts.map → turn-operations-M9-jUyDd.d.ts.map} +1 -1
  86. package/dist/types-BIarq1qE.js.map +1 -1
  87. package/dist/types.d.ts +1 -1
  88. package/docs/PLUGINS.md +340 -0
  89. package/docs/RESTATE.md +69 -3
  90. package/docs/SKILL.md +24 -8
  91. package/package.json +6 -1
  92. package/dist/agent-8sUadp5M.js.map +0 -1
  93. package/dist/index-C-Nd2ZbV.d.ts.map +0 -1
  94. package/dist/mcp-3GXUBwur.js.map +0 -1
  95. package/dist/resolve-CZJxds85.js.map +0 -1
  96. package/dist/skills-Dd6XCi4s.js +0 -420
  97. package/dist/skills-Dd6XCi4s.js.map +0 -1
  98. package/dist/transcript-anchors-BCUpbNxc.d.ts.map +0 -1
  99. package/dist/transcript-anchors-CgDzYin1.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { o as AgentToolNotAllowedError, u as errorMessage } from "./errors-BwBxu3zD.js";
2
2
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
3
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { realpath } from "node:fs/promises";
5
5
  import { execFile } from "node:child_process";
6
6
  import { homedir, tmpdir } from "node:os";
@@ -326,90 +326,105 @@ function isToolAllowedByUnion(displayName, input, union) {
326
326
  return union.some((pattern) => matchesAllowedTool(displayName, input, pattern));
327
327
  }
328
328
  //#endregion
329
- //#region src/skills/interpolate.ts
330
- /** Regex to match !`command` patterns in skill instructions */
331
- const SHELL_INTERPOLATION_RE = /!`([^`]+)`/g;
332
- /**
333
- * Replace every !\`command\` pattern with a static placeholder, without
334
- * executing anything. Used when shell interpolation is disabled
335
- * (`SkillsConfig.allowShellInterpolation: false`): skill content can be
336
- * third-party input, and running its embedded commands on activation
337
- * bypasses the allowed-tools gate.
338
- */
339
- function stripShellInterpolations(instructions) {
340
- return instructions.replace(SHELL_INTERPOLATION_RE, "[shell interpolation disabled]");
329
+ //#region src/skills/activation.ts
330
+ function createSkillActivationState(options = {}) {
331
+ const byName = /* @__PURE__ */ new Map();
332
+ const maxActive = typeof options.maxActive === "number" && options.maxActive > 0 ? options.maxActive : void 0;
333
+ return {
334
+ active() {
335
+ return [...byName.values()];
336
+ },
337
+ isActive(name) {
338
+ return byName.has(name);
339
+ },
340
+ get(name) {
341
+ return byName.get(name);
342
+ },
343
+ activate(skill, via) {
344
+ if (byName.has(skill.name)) return "already-active";
345
+ if (maxActive !== void 0 && byName.size >= maxActive) return "cap-reached";
346
+ byName.set(skill.name, {
347
+ skill,
348
+ activatedAt: Date.now(),
349
+ activatedVia: via
350
+ });
351
+ return "ok";
352
+ },
353
+ deactivate(name) {
354
+ const existing = byName.get(name);
355
+ if (!existing) return void 0;
356
+ byName.delete(name);
357
+ return existing;
358
+ },
359
+ clear() {
360
+ const snapshot = [...byName.values()];
361
+ byName.clear();
362
+ return snapshot;
363
+ }
364
+ };
341
365
  }
366
+ //#endregion
367
+ //#region src/skills/allowed-tools.ts
368
+ /** Tools that are always allowed regardless of the active skills' allow-list. */
369
+ const IMPLICITLY_ALLOWED_SKILL_TOOLS = [
370
+ "skills_use",
371
+ "skills_read",
372
+ "skills_run_script"
373
+ ];
342
374
  /**
343
- * Interpolate shell commands in skill instructions.
375
+ * Register `tool:gate` / `mcp:tool:gate` handlers that enforce the union of
376
+ * `allowedTools` across active skills.
344
377
  *
345
- * Runs each !\`command\` through the execution context and replaces
346
- * the placeholder with the command's stdout. If a command fails,
347
- * the placeholder is replaced with an error message (and
348
- * `options.onFailure` is notified when provided).
378
+ * No-op when no active skill declares an allow-list (permissive default —
379
+ * matches the spec's "experimental" note for `allowed-tools`).
349
380
  *
350
- * @param instructions - Raw skill instructions with potential !\`command\` patterns
351
- * @param execution - The execution context to run commands in
352
- * @param handle - The active execution handle
353
- * @returns Instructions with all !\`command\` patterns replaced by output
381
+ * Returns an `uninstall` fn. The agent calls this at run end to detach the
382
+ * handlers and prevent cross-run hook leaks.
354
383
  */
355
- async function interpolateShellCommands(instructions, execution, handle, options = {}) {
356
- const matches = [...instructions.matchAll(SHELL_INTERPOLATION_RE)];
357
- if (matches.length === 0) return instructions;
358
- const replacements = [];
359
- for (const match of matches) {
360
- const command = match[1];
361
- const index = match.index;
362
- const length = match[0].length;
363
- if (options.approve) {
364
- let approved = false;
365
- try {
366
- approved = await options.approve(command);
367
- } catch {
368
- approved = false;
369
- }
370
- if (!approved) {
371
- const placeholder = "[shell interpolation not approved]";
372
- options.onFailure?.(command, placeholder);
373
- replacements.push({
374
- index,
375
- length,
376
- output: placeholder
377
- });
378
- continue;
379
- }
380
- }
381
- try {
382
- const result = await execution.exec(handle, command, { timeout: 30 });
383
- if (result.exitCode === 0) replacements.push({
384
- index,
385
- length,
386
- output: result.stdout.trim()
387
- });
388
- else {
389
- const placeholder = `[command failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}]`;
390
- options.onFailure?.(command, placeholder);
391
- replacements.push({
392
- index,
393
- length,
394
- output: placeholder
395
- });
396
- }
397
- } catch (err) {
398
- const placeholder = `[command error: ${errorMessage(err)}]`;
399
- options.onFailure?.(command, placeholder);
400
- replacements.push({
401
- index,
402
- length,
403
- output: placeholder
404
- });
405
- }
384
+ function installAllowedToolsGate(hooks, state) {
385
+ function effectiveUnion() {
386
+ const active = state.active();
387
+ const declared = [];
388
+ for (const record of active) if (record.skill.allowedTools?.length) declared.push(...record.skill.allowedTools);
389
+ return {
390
+ union: declared.length > 0 ? [...declared, ...IMPLICITLY_ALLOWED_SKILL_TOOLS] : [],
391
+ active: active.map((a) => a.skill.name)
392
+ };
406
393
  }
407
- let result = instructions;
408
- for (let i = replacements.length - 1; i >= 0; i--) {
409
- const { index, length, output } = replacements[i];
410
- result = result.slice(0, index) + output + result.slice(index + length);
394
+ function gateHandler(ctx) {
395
+ if (ctx.block) return;
396
+ const { union, active } = effectiveUnion();
397
+ if (union.length === 0) return;
398
+ if (isToolAllowedByUnion(ctx.displayName, ctx.input, union)) return;
399
+ const err = new AgentToolNotAllowedError({
400
+ toolName: ctx.name,
401
+ displayName: ctx.displayName,
402
+ allowedUnion: union,
403
+ activeSkills: active
404
+ });
405
+ ctx.block = true;
406
+ ctx.reason = err.message;
411
407
  }
412
- return result;
408
+ function mcpGateHandler(ctx) {
409
+ if (ctx.block) return;
410
+ const { union, active } = effectiveUnion();
411
+ if (union.length === 0) return;
412
+ if (isToolAllowedByUnion(ctx.displayName, ctx.input, union)) return;
413
+ const err = new AgentToolNotAllowedError({
414
+ toolName: `mcp_${ctx.server}_${ctx.tool}`,
415
+ displayName: ctx.displayName,
416
+ allowedUnion: union,
417
+ activeSkills: active
418
+ });
419
+ ctx.block = true;
420
+ ctx.reason = err.message;
421
+ }
422
+ const unregisterTool = hooks.hook("tool:gate", gateHandler);
423
+ const unregisterMcp = hooks.hook("mcp:tool:gate", mcpGateHandler);
424
+ return function uninstall() {
425
+ unregisterTool();
426
+ unregisterMcp();
427
+ };
413
428
  }
414
429
  //#endregion
415
430
  //#region src/xml.ts
@@ -431,1028 +446,1405 @@ function escapeXml(str) {
431
446
  return str.replace(RE_AMP, "&").replace(RE_LT, "<").replace(RE_GT, ">").replace(RE_QUOT, """);
432
447
  }
433
448
  //#endregion
434
- //#region src/install/source.ts
449
+ //#region src/skills/catalog.ts
435
450
  /**
436
- * Shared install-source layer — the source-agnostic half of "install a
437
- * thing from github/npm". Extracted from the extensions installer so
438
- * SKILL installs (and any future installable artifact) reuse the exact
439
- * same spec grammar, fetch pipeline, and hardening instead of forking
440
- * them:
441
- *
442
- * - Spec grammar: `owner/repo[#ref][:subdir]`, `github:owner/repo`,
443
- * pasted GitHub URLs (including `/tree/<ref>/<path>`),
444
- * `pkg[@version]`, `@scope/pkg[@version]`.
445
- * - Fetch: `git clone --depth 1` (records the exact commit) with a
446
- * codeload-tarball fallback, npm registry resolve (exact versions +
447
- * dist-tags) + tarball download. Downloads are double-capped at
448
- * {@link MAX_DOWNLOAD_BYTES} (content-length header AND post-hoc for
449
- * chunked responses). Extraction requires `tar` on PATH.
450
- * - All side effects go through the injectable {@link InstallIo} seams
451
- * so tests stay offline and hosts can instrument.
452
- *
453
- * Flavor-specific concerns stay OUT of this module: candidate
454
- * enumeration, validation, target roots, provenance files, dependency
455
- * installs. Flavors subclass {@link InstallSourceError} to widen the
456
- * error-code union while keeping `.code`-based handling uniform.
451
+ * Build the skill catalog XML and behavioral instructions for the system prompt.
457
452
  */
453
+ function buildCatalog(skills, options = {}) {
454
+ if (skills.length === 0) return "";
455
+ const skillsToolRegistered = options.skillsToolRegistered ?? true;
456
+ const readToolName = options.readToolName ?? "read_file";
457
+ const entries = skills.map((skill) => {
458
+ const locationLine = skill.location ? `\n <location>${escapeXml(skill.location)}</location>` : "";
459
+ return ` <skill>
460
+ <name>${escapeXml(skill.name)}</name>
461
+ <description>${escapeXml(skill.description)}</description>${locationLine}
462
+ </skill>`;
463
+ }).join("\n");
464
+ const hasFsSkills = skills.some((s) => s.location);
465
+ const hasInlineSkills = skills.some((s) => !s.location);
466
+ const behavioralParts = [];
467
+ behavioralParts.push("The following skills provide specialized instructions for specific tasks.", "When a task matches a skill's description, activate the skill to load its full instructions before proceeding.");
468
+ if (skillsToolRegistered) behavioralParts.push("To activate a skill, call the `skills_use` tool with the skill's name. The response contains the full instructions and any bundled resources you can then load via `skills_read` (reference files) or execute via `skills_run_script` (scripts/ directory).", "Relative paths referenced in a skill's instructions resolve against the skill directory noted in the `skills_use` response.");
469
+ else if (hasFsSkills) behavioralParts.push(`For skills with a <location>, use the ${readToolName} tool to read the SKILL.md file at that path.`, "When a skill references relative paths, resolve them against the skill's directory (the parent of SKILL.md) and use absolute paths in tool calls.");
470
+ if (hasInlineSkills && !skillsToolRegistered) behavioralParts.push("Skills without a <location> have their instructions included directly in <instructions> tags below.");
471
+ const parts = [
472
+ behavioralParts.join("\n"),
473
+ "",
474
+ "<available_skills>",
475
+ entries,
476
+ "</available_skills>"
477
+ ];
478
+ if (hasInlineSkills && !skillsToolRegistered) {
479
+ parts.push("");
480
+ for (const skill of skills) if (!skill.location && skill.instructions) {
481
+ parts.push(`<skill_instructions name="${escapeXml(skill.name)}">`);
482
+ parts.push(skill.instructions);
483
+ if (skill.resources && skill.resources.length > 0) {
484
+ parts.push("");
485
+ parts.push("<skill_resources>");
486
+ for (const res of skill.resources) parts.push(` <file type="${res.type}">${escapeXml(res.path)}</file>`);
487
+ parts.push("</skill_resources>");
488
+ }
489
+ parts.push("</skill_instructions>");
490
+ }
491
+ }
492
+ return parts.join("\n");
493
+ }
494
+ //#endregion
495
+ //#region src/skills/discovery.ts
496
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
497
+ const INDENT_RE = /^[ \t]{2,}/;
498
+ const KV_RE = /^([^:]+):(.*)$/;
499
+ const DOUBLE_QUOTED_RE = /^"((?:\\.|[^"\\])*)"$/;
500
+ const SINGLE_QUOTED_RE = /^'((?:''|[^'])*)'$/;
501
+ const DQ_ESCAPE_RE = /\\(["\\/bfnrt])/g;
502
+ const WHITESPACE_SPLIT_RE = /\s+/;
503
+ const PARAGRAPH_SPLIT_RE = /\n\n/;
504
+ const COMMA_OR_SPACE_RE = /[,\s]+/;
458
505
  /**
459
- * Base error for install pipelines. `code` is a plain string here so
460
- * flavors (extensions, skills) can widen it with their own codes while
461
- * callers keep a single `err.code` switch. The base layer itself only
462
- * throws `'invalid-source'` and `'fetch-failed'`.
506
+ * Block-list bullet (`- value`) under an indented frontmatter key. A
507
+ * single dash followed by either a space or end-of-line; bare `-` is a
508
+ * list item with an empty value (preserved by callers as `""` because
509
+ * the spec doesn't disallow it).
463
510
  */
464
- var InstallSourceError = class extends Error {
465
- code;
466
- /** Set for candidate-selection errors — the names the caller can pick from. */
467
- candidates;
468
- constructor(code, message, candidates) {
469
- super(message);
470
- this.name = "InstallSourceError";
471
- this.code = code;
472
- if (candidates) this.candidates = candidates;
473
- }
474
- };
475
- const GITHUB_TREE_URL_RE = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#@:]+?)(?:\.git)?\/tree\/([^/\s:]+)(?:\/([^\s:]+?))?\/?$/;
476
- const GITHUB_URL_RE = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#@:]+?)(?:\.git)?\/?$/;
477
- const OWNER_REPO_RE = /^([\w.-]+)\/([\w.-]+)$/;
478
- const GITHUB_REF_RE = /^[\w./-]+$/;
479
- const NPM_NAME_RE = /^(@[\w.-]+\/[\w.-]+|[\w.-]+)(?:@([\w.^~><=*-]+))?$/;
480
- /** Structural validation for a `:subdir` suffix — repo-relative, no escapes. */
481
- function parseSubdir(rawSubdir, raw) {
482
- const subdir = rawSubdir.replace(/^\.\//, "").replace(/\/+$/, "");
483
- const segments = subdir.split("/");
484
- if (subdir.length === 0 || subdir.startsWith("/") || subdir.includes("\\") || segments.some((s) => s.length === 0 || s === "." || s === "..")) throw new InstallSourceError("invalid-source", `"${raw}" has an invalid subdirectory — expected a repo-relative path (no leading /, no ..).`);
485
- return subdir;
486
- }
511
+ const LIST_ITEM_RE = /^-(?:\s+|$)/;
487
512
  /**
488
- * Classify a raw source string:
513
+ * Flow-style list scalar (`[a, b, c]`). Anchored, non-nested, single
514
+ * pass — we trim the outer brackets and let the caller split on
515
+ * commas. Anything more complex (nested brackets, quoted commas)
516
+ * falls through to the whitespace-split branch, where the bracket
517
+ * characters become visible authoring errors instead of silent garbage.
489
518
  *
490
- * - `github:owner/repo`, `owner/repo`, GitHub URLs (including
491
- * `/tree/<ref>/<path>` links) → GitHub. Optional `#ref` (or the
492
- * legacy `@ref`) pins a branch/tag; optional `:path/to/dir` roots the
493
- * candidate search in a subdirectory: `owner/repo#branch:packages/x`.
494
- * - `@scope/pkg[@version]`, `pkg[@version]` → npm (no subdir support —
495
- * flavors expose their own selector for multi-candidate packages).
519
+ * Carefully written without overlapping `\s*` runs so it's not
520
+ * ReDoS-prone: the body class `[^[\]]*` already swallows whitespace,
521
+ * so wrapping it in `\s*…\s*` would let the regex engine
522
+ * non-deterministically split a run of spaces between the body and
523
+ * the boundary quantifiers.
524
+ */
525
+ const FLOW_LIST_RE = /^\[([^[\]]*)\]$/;
526
+ /**
527
+ * Common kebab/camel/snake misspellings of multi-word spec frontmatter
528
+ * keys. The Agent Skills spec mandates kebab-case (`allowed-tools`), but
529
+ * authors raised on JS / Python idioms naturally reach for camelCase
530
+ * (`allowedTools`) or snake_case (`allowed_tools`) — and the consequences
531
+ * of a silent mismatch on `allowed-tools` specifically are nasty: the
532
+ * skill loads fine, looks active in the UI, the model reads the body
533
+ * and tells the user "tools are restricted", but the `tool:gate`
534
+ * middleware finds nothing in the union and runs permissively. So we
535
+ * look for these alts at parse time, migrate the value to the canonical
536
+ * key, and emit a diagnostic so the next person sees the issue clearly.
496
537
  *
497
- * The one ambiguity — `a/b` — is resolved as GitHub: npm's slash names
498
- * are always scoped (`@scope/…`), so a bare `owner/repo` can only be a
499
- * repo. Throws `invalid-source` on anything else.
538
+ * Keep the value space tight — accepting too many shapes erodes the
539
+ * "lenient load, strict authoring" principle. Add entries only for keys
540
+ * that are (a) multi-word (single-word keys have no ambiguity), (b) in
541
+ * active use, and (c) cause silent failures when missing (gate keys,
542
+ * security gates, …).
500
543
  */
501
- function parseInstallSource(raw) {
502
- const spec = raw.trim();
503
- if (spec.length === 0) throw new InstallSourceError("invalid-source", "Empty source. Expected `owner/repo`, `github:owner/repo`, a GitHub URL, or an npm package name.");
504
- const explicitGithub = spec.startsWith("github:");
505
- const body = explicitGithub ? spec.slice(7) : spec;
506
- const tree = GITHUB_TREE_URL_RE.exec(body);
507
- if (tree) return {
508
- kind: "github",
509
- owner: tree[1],
510
- repo: tree[2],
511
- ref: tree[3],
512
- ...tree[4] ? { subdir: parseSubdir(tree[4], raw) } : {}
513
- };
514
- const url = GITHUB_URL_RE.exec(body);
515
- if (url) return {
516
- kind: "github",
517
- owner: url[1],
518
- repo: url[2]
519
- };
520
- if (body.startsWith("@")) {
521
- if (explicitGithub) throw new InstallSourceError("invalid-source", `"${raw}" mixes the github: prefix with an npm scoped name.`);
522
- if (body.includes(":")) throw new InstallSourceError("invalid-source", `"${raw}": subdirectories (\`:path\`) are only supported for GitHub sources.`);
523
- const m = NPM_NAME_RE.exec(body);
524
- if (!m) throw new InstallSourceError("invalid-source", `"${raw}" is not a valid npm package spec.`);
525
- return {
526
- kind: "npm",
527
- pkg: m[1],
528
- ...m[2] ? { version: m[2] } : {}
529
- };
530
- }
531
- if (body.includes("/")) {
532
- const colonIdx = body.indexOf(":");
533
- const specPart = colonIdx === -1 ? body : body.slice(0, colonIdx);
534
- const subdir = colonIdx === -1 ? void 0 : parseSubdir(body.slice(colonIdx + 1), raw);
535
- const hashIdx = specPart.indexOf("#");
536
- const refIdx = hashIdx >= 0 ? hashIdx : specPart.indexOf("@");
537
- const repoPart = refIdx === -1 ? specPart : specPart.slice(0, refIdx);
538
- const ref = refIdx === -1 ? void 0 : specPart.slice(refIdx + 1);
539
- const m = OWNER_REPO_RE.exec(repoPart);
540
- if (!m || ref !== void 0 && !GITHUB_REF_RE.test(ref)) throw new InstallSourceError("invalid-source", `"${raw}" is not a valid GitHub repo spec (expected owner/repo, optionally #ref and :subdir).`);
541
- return {
542
- kind: "github",
543
- owner: m[1],
544
- repo: m[2],
545
- ...ref ? { ref } : {},
546
- ...subdir ? { subdir } : {}
547
- };
548
- }
549
- if (explicitGithub) throw new InstallSourceError("invalid-source", `"${raw}" is missing the repo part (expected github:owner/repo).`);
550
- if (body.includes(":")) throw new InstallSourceError("invalid-source", `"${raw}": subdirectories (\`:path\`) are only supported for GitHub sources.`);
551
- const m = NPM_NAME_RE.exec(body);
552
- if (!m) throw new InstallSourceError("invalid-source", `"${raw}" is not a valid npm package spec.`);
553
- return {
554
- kind: "npm",
555
- pkg: m[1],
556
- ...m[2] ? { version: m[2] } : {}
557
- };
558
- }
544
+ const FRONTMATTER_KEY_ALIASES = /* @__PURE__ */ new Map([["allowed-tools", ["allowedTools", "allowed_tools"]]]);
559
545
  /**
560
- * Materialize the source into `destDir` (created by the caller, assumed
561
- * empty). Returns the provenance string plus, for git clones, the exact
562
- * commit SHA (the tarball fallback can't know it — codeload doesn't
563
- * expose the resolved commit).
546
+ * Mutate `frontmatter` so any well-known alt-spelling of a canonical
547
+ * spec key gets folded into the canonical slot. Idempotent — when the
548
+ * canonical key is already set the function is a no-op even if an
549
+ * alt-spelling is also present (canonical wins; alt is ignored).
550
+ *
551
+ * Pushes a `frontmatter-key-alt-spelling` diagnostic per migration so
552
+ * the SkillConfig's `.diagnostics` carries an actionable hint into any
553
+ * host UI surfacing skill load issues.
564
554
  */
565
- async function fetchInstallSource(source, destDir, io = {}, onProgress) {
566
- const exec = io.exec ?? defaultExec;
567
- const which = io.which ?? defaultWhich;
568
- const fetchImpl = io.fetch ?? fetch;
569
- if (source.kind === "github") {
570
- const label = `${source.owner}/${source.repo}${source.ref ? `@${source.ref}` : ""}${source.subdir ? `:${source.subdir}` : ""}`;
571
- if (which("git")) {
572
- onProgress?.(`cloning github.com/${label}…`);
573
- const cloneArgs = (url) => {
574
- const args = [
575
- "clone",
576
- "--depth",
577
- "1",
578
- "--single-branch"
579
- ];
580
- if (source.ref) args.push("--branch", source.ref);
581
- args.push(url, destDir);
582
- return args;
583
- };
584
- let res = await exec("git", cloneArgs(`https://github.com/${source.owner}/${source.repo}.git`), { env: { GIT_TERMINAL_PROMPT: "0" } });
585
- if (!res.ok && /could not read username|terminal prompts disabled|authentication failed|repository not found/i.test(res.stderr)) {
586
- rmSync(destDir, {
587
- recursive: true,
588
- force: true
589
- });
590
- mkdirSync(destDir, { recursive: true });
591
- onProgress?.(`anonymous clone rejected — retrying via SSH (git@github.com:${source.owner}/${source.repo})…`);
592
- const ssh = await exec("git", cloneArgs(`git@github.com:${source.owner}/${source.repo}.git`), { env: {
593
- GIT_TERMINAL_PROMPT: "0",
594
- GIT_SSH_COMMAND: sshCommandWithBatchMode(process.env.GIT_SSH_COMMAND)
595
- } });
596
- if (ssh.ok) res = ssh;
597
- else throw new InstallSourceError("fetch-failed", `git clone failed for ${label}: ${lastLine(res.stderr)}; SSH retry also failed: ${lastLine(ssh.stderr)} (private repo? check your GitHub SSH keys / repo access, or configure git credentials for HTTPS)`);
598
- }
599
- if (!res.ok) throw new InstallSourceError("fetch-failed", `git clone failed for ${label}: ${lastLine(res.stderr)}`);
600
- const head = await exec("git", ["rev-parse", "HEAD"], { cwd: destDir });
601
- const commit = head.ok && /^[0-9a-f]{40}$/.test(head.stdout.trim()) ? head.stdout.trim() : void 0;
602
- rmSync(join(destDir, ".git"), {
603
- recursive: true,
604
- force: true
555
+ function normalizeKeyAliases(frontmatter, diagnostics) {
556
+ for (const [canonical, aliases] of FRONTMATTER_KEY_ALIASES) {
557
+ if (frontmatter[canonical] !== void 0) continue;
558
+ for (const alias of aliases) {
559
+ if (frontmatter[alias] === void 0) continue;
560
+ frontmatter[canonical] = frontmatter[alias];
561
+ diagnostics.push({
562
+ severity: "warning",
563
+ code: "frontmatter-key-alt-spelling",
564
+ message: `Frontmatter field "${alias}" is not a spec key — the spec uses "${canonical}" (kebab-case). Reading the value from "${alias}" as a courtesy; rename it to "${canonical}" to silence this warning.`,
565
+ field: alias
605
566
  });
606
- return {
607
- provenance: `github:${label}`,
608
- ...commit ? { commit } : {}
609
- };
567
+ break;
610
568
  }
611
- onProgress?.(`downloading github.com/${label} tarball…`);
612
- const ref = source.ref ?? "HEAD";
613
- await downloadAndExtract(`https://codeload.github.com/${source.owner}/${source.repo}/tar.gz/${ref}`, destDir, {
614
- exec,
615
- fetchImpl,
616
- which,
617
- what: label
618
- });
619
- return { provenance: `github:${label}` };
620
569
  }
621
- onProgress?.(`resolving npm:${source.pkg}…`);
622
- const { version, tarball } = await resolveNpmVersion(source, fetchImpl);
623
- onProgress?.(`downloading npm:${source.pkg}@${version}…`);
624
- await downloadAndExtract(tarball, destDir, {
625
- exec,
626
- fetchImpl,
627
- which,
628
- what: `${source.pkg}@${version}`
629
- });
630
- return { provenance: `npm:${source.pkg}@${version}` };
631
570
  }
632
571
  /**
633
- * Registry lookup shared by the fetch path and update no-op checks.
634
- * Exact versions and dist-tags only — range resolution is out of scope.
572
+ * Parse a SKILL.md file into frontmatter + body.
573
+ *
574
+ * Uses a simple regex-based YAML extractor that handles:
575
+ * - Flat key: value pairs
576
+ * - Quoted values
577
+ * - One-level nested maps (for `metadata:`)
578
+ * - One-level nested block lists, `key:` followed by indented `- item`
579
+ * lines (for `allowed-tools` and any future array fields). Items are
580
+ * unquoted with the same rules as scalar values.
581
+ * - Flow-style scalars are preserved verbatim — interpretation (e.g.
582
+ * `[a, b]` as a list) is left to per-field consumers via
583
+ * {@link takeStringOrArray}.
584
+ * - Lenient recovery from unquoted-colon values (e.g.
585
+ * `description: Use when: the user asks …`) via a quote-wrap retry.
635
586
  */
636
- async function resolveNpmVersion(source, fetchImpl) {
637
- const registryUrl = `https://registry.npmjs.org/${source.pkg.replace("/", "%2F")}`;
638
- let meta;
639
- try {
640
- const res = await fetchImpl(registryUrl);
641
- if (!res.ok) throw new Error(`registry returned ${res.status}`);
642
- meta = await res.json();
643
- } catch (err) {
644
- throw new InstallSourceError("fetch-failed", `Could not resolve npm package "${source.pkg}": ${errorText(err)}`);
645
- }
646
- const version = source.version && meta.versions?.[source.version] ? source.version : meta["dist-tags"]?.[source.version ?? "latest"];
647
- const tarball = version ? meta.versions?.[version]?.dist?.tarball : void 0;
648
- if (!version || !tarball) throw new InstallSourceError("fetch-failed", `npm package "${source.pkg}" has no ${source.version ? `version/tag "${source.version}"` : "latest version"} (exact versions and dist-tags only — ranges are not resolved).`);
649
- return {
650
- version,
651
- tarball
587
+ function parseFrontmatter(content) {
588
+ const diagnostics = [];
589
+ const match = content.match(FRONTMATTER_RE);
590
+ if (!match) return {
591
+ frontmatter: {},
592
+ body: content.trim(),
593
+ diagnostics
652
594
  };
595
+ const yamlBlock = match[1];
596
+ const body = match[2].trim();
597
+ const frontmatter = {};
598
+ let currentKey = null;
599
+ let currentMap = null;
600
+ let currentList = null;
601
+ const flushPending = () => {
602
+ if (currentKey === null) return;
603
+ if (currentList !== null) frontmatter[currentKey] = currentList;
604
+ else if (currentMap !== null) frontmatter[currentKey] = currentMap;
605
+ currentKey = null;
606
+ currentMap = null;
607
+ currentList = null;
608
+ };
609
+ for (const line of yamlBlock.split("\n")) {
610
+ if (!line.trim() || line.trim().startsWith("#")) continue;
611
+ if (currentKey !== null && INDENT_RE.test(line)) {
612
+ const stripped = line.trim();
613
+ if (LIST_ITEM_RE.test(stripped)) {
614
+ if (currentMap !== null && Object.keys(currentMap).length > 0) {
615
+ diagnostics.push({
616
+ severity: "warning",
617
+ code: "mixed-list-and-map",
618
+ message: `Frontmatter field "${currentKey}" mixes list items and map entries — keeping the map and dropping the list items.`,
619
+ field: currentKey
620
+ });
621
+ continue;
622
+ }
623
+ currentList = currentList ?? [];
624
+ currentList.push(unquoteYaml(stripped.slice(1).trim()));
625
+ continue;
626
+ }
627
+ const nestedMatch = stripped.match(KV_RE);
628
+ if (nestedMatch) {
629
+ if (currentList !== null) {
630
+ diagnostics.push({
631
+ severity: "warning",
632
+ code: "mixed-list-and-map",
633
+ message: `Frontmatter field "${currentKey}" mixes list items and map entries — keeping the list and dropping the map entries.`,
634
+ field: currentKey
635
+ });
636
+ continue;
637
+ }
638
+ currentMap = currentMap ?? {};
639
+ const val = nestedMatch[2].trim();
640
+ currentMap[nestedMatch[1].trim()] = unquoteYaml(val);
641
+ }
642
+ continue;
643
+ }
644
+ flushPending();
645
+ const kvMatch = matchFirstColon(line);
646
+ if (!kvMatch) continue;
647
+ const key = kvMatch.key.trim();
648
+ const rawValue = kvMatch.value.trim();
649
+ if (!rawValue) currentKey = key;
650
+ else frontmatter[key] = unquoteYaml(rawValue);
651
+ }
652
+ flushPending();
653
+ return {
654
+ frontmatter,
655
+ body,
656
+ diagnostics
657
+ };
658
+ }
659
+ function matchFirstColon(line) {
660
+ const idx = line.indexOf(":");
661
+ if (idx < 0) return null;
662
+ const key = line.slice(0, idx);
663
+ const value = line.slice(idx + 1);
664
+ if (!KV_RE.test(`${key}:`)) return null;
665
+ return {
666
+ key,
667
+ value
668
+ };
669
+ }
670
+ /**
671
+ * Strip outer quotes and decode YAML-style escapes.
672
+ *
673
+ * - Double-quoted values honor `\"`, `\\`, `\n`, `\r`, `\t`, `\b`, `\f`, `\/`.
674
+ * - Single-quoted values honor `''` (the YAML single-quote escape for a literal `'`).
675
+ * - Trailing inline comments on unquoted values (` # comment`) are dropped.
676
+ * - Unquoted values are returned as-is (trimmed by caller).
677
+ *
678
+ * Skill frontmatter in the wild uses a narrow YAML subset — this is
679
+ * sufficient for every field in the current spec and the common authoring
680
+ * tools. Block scalars (`|`, `>`) and flow sequences are intentionally left
681
+ * unsupported: they'd only appear in a bespoke workflow, and silently
682
+ * half-supporting them is worse than rejecting them obviously.
683
+ */
684
+ function unquoteYaml(val) {
685
+ const dq = val.match(DOUBLE_QUOTED_RE);
686
+ if (dq) return dq[1].replace(DQ_ESCAPE_RE, (_, ch) => {
687
+ switch (ch) {
688
+ case "\"": return "\"";
689
+ case "\\": return "\\";
690
+ case "/": return "/";
691
+ case "b": return "\b";
692
+ case "f": return "\f";
693
+ case "n": return "\n";
694
+ case "r": return "\r";
695
+ case "t": return " ";
696
+ default: return ch;
697
+ }
698
+ });
699
+ const sq = val.match(SINGLE_QUOTED_RE);
700
+ if (sq) return sq[1].replace(/''/g, "'");
701
+ const hashIdx = val.indexOf(" #");
702
+ if (hashIdx >= 0) return val.slice(0, hashIdx).trimEnd();
703
+ return val;
704
+ }
705
+ /**
706
+ * Narrow a frontmatter field to a string. Produces a diagnostic when present
707
+ * but not a string, so malformed YAML (e.g. `name: 123`) surfaces as a
708
+ * warning instead of silently coerced downstream via `as string`.
709
+ */
710
+ function takeString(frontmatter, key, diagnostics) {
711
+ const raw = frontmatter[key];
712
+ if (raw === void 0 || raw === null) return void 0;
713
+ if (typeof raw === "string") return raw;
714
+ diagnostics.push({
715
+ severity: "warning",
716
+ code: "invalid-field-type",
717
+ message: `Frontmatter field "${key}" expected string, got ${Array.isArray(raw) ? "array" : typeof raw}. Skipping.`,
718
+ field: key
719
+ });
720
+ }
721
+ /**
722
+ * Accept either a YAML scalar (whitespace-separated tokens, the
723
+ * spec-canonical form) OR a YAML block / flow list (`- item` or
724
+ * `[a, b]`). Returns the field as a normalized `string[]`, or
725
+ * `undefined` when the field is absent or empty.
726
+ *
727
+ * Used for list-shaped fields like `allowed-tools` whose authoring
728
+ * surface has always been "either a space-separated string OR a
729
+ * list" in skill files in the wild (Claude Code's docs use both
730
+ * interchangeably). Pre-fix, the parser silently turned the list
731
+ * forms into `[object Object]` / `["[a", "b]"]` and the
732
+ * allowed-tools gate would never match anything — opening it wide.
733
+ *
734
+ * Diagnostics are pushed for unexpected shapes (number, plain
735
+ * object, etc.) and the field is dropped to avoid silent corruption.
736
+ */
737
+ function takeStringOrArray(frontmatter, key, diagnostics) {
738
+ const raw = frontmatter[key];
739
+ if (raw === void 0 || raw === null) return void 0;
740
+ if (Array.isArray(raw)) {
741
+ const items = raw.filter((v) => v !== null && v !== void 0).map((v) => typeof v === "string" ? v : String(v)).map((v) => v.trim()).filter((v) => v.length > 0);
742
+ return items.length > 0 ? items : void 0;
743
+ }
744
+ if (typeof raw === "string") {
745
+ const trimmed = raw.trim();
746
+ if (trimmed.length === 0) return void 0;
747
+ const flowMatch = trimmed.match(FLOW_LIST_RE);
748
+ if (flowMatch) return flowMatch[1].split(",").map((s) => s.trim()).filter(Boolean);
749
+ return trimmed.split(WHITESPACE_SPLIT_RE).filter(Boolean);
750
+ }
751
+ diagnostics.push({
752
+ severity: "warning",
753
+ code: "invalid-field-type",
754
+ message: `Frontmatter field "${key}" expected string or list, got ${typeof raw}. Skipping.`,
755
+ field: key
756
+ });
757
+ }
758
+ const RESOURCE_DIRS = {
759
+ scripts: "script",
760
+ references: "reference",
761
+ assets: "asset"
762
+ };
763
+ function enumerateResources(baseDir) {
764
+ const resources = [];
765
+ for (const [dir, type] of Object.entries(RESOURCE_DIRS)) {
766
+ const dirPath = join(baseDir, dir);
767
+ if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) continue;
768
+ try {
769
+ const files = readdirSync(dirPath, { recursive: true });
770
+ for (const file of files) {
771
+ const rel = typeof file === "string" ? file : file.toString("utf-8");
772
+ if (statSync(join(dirPath, rel)).isFile()) resources.push({
773
+ path: join(dir, rel),
774
+ type
775
+ });
776
+ }
777
+ } catch {}
778
+ }
779
+ try {
780
+ for (const entry of readdirSync(baseDir)) {
781
+ if (entry === "SKILL.md") continue;
782
+ if (statSync(join(baseDir, entry)).isFile()) resources.push({
783
+ path: entry,
784
+ type: "other"
785
+ });
786
+ }
787
+ } catch {}
788
+ return resources;
789
+ }
790
+ /**
791
+ * Parse a SKILL.md file into a SkillConfig (lenient).
792
+ *
793
+ * Returns `null` only when the skill is fundamentally unusable:
794
+ * - The file is missing
795
+ * - The description is absent (required by spec for disclosure)
796
+ *
797
+ * All other issues are surfaced as `SkillConfig.diagnostics` with severity
798
+ * `warning`. Deprecated top-level fields (`paths`, `model`, `thinking`) are
799
+ * auto-migrated into `metadata['zidane.*']`.
800
+ */
801
+ async function parseSkillFile(filePath, options = {}) {
802
+ const absPath = resolve(filePath);
803
+ if (!existsSync(absPath)) return null;
804
+ const { frontmatter, body, diagnostics } = parseFrontmatter(readFileSync(absPath, "utf-8"));
805
+ normalizeKeyAliases(frontmatter, diagnostics);
806
+ let description = takeString(frontmatter, "description", diagnostics);
807
+ if (!description && body) {
808
+ const firstParagraph = body.split(PARAGRAPH_SPLIT_RE)[0]?.trim();
809
+ if (firstParagraph) description = firstParagraph;
810
+ }
811
+ if (!description) return null;
812
+ if (description.length > 1024) diagnostics.push({
813
+ severity: "warning",
814
+ code: "description-too-long",
815
+ message: `Description exceeds spec limit of 1024 characters (got ${description.length}). Loading anyway.`,
816
+ field: "description"
817
+ });
818
+ const baseDir = dirname(absPath);
819
+ const dirName = basename(baseDir);
820
+ const frontmatterName = takeString(frontmatter, "name", diagnostics);
821
+ const name = frontmatterName || dirName;
822
+ if (frontmatterName && frontmatterName !== dirName) diagnostics.push({
823
+ severity: "warning",
824
+ code: "name-mismatch-directory",
825
+ message: `Skill name "${frontmatterName}" does not match parent directory "${dirName}". Loading anyway.`,
826
+ field: "name"
827
+ });
828
+ if (name.length > 64) diagnostics.push({
829
+ severity: "warning",
830
+ code: "name-too-long",
831
+ message: `Skill name "${name}" exceeds spec limit of 64 characters. Loading anyway.`,
832
+ field: "name"
833
+ });
834
+ if (!validateSkillName(name)) diagnostics.push({
835
+ severity: "warning",
836
+ code: "invalid-name-format",
837
+ message: `Skill name "${name}" does not match spec format (lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens). Loading anyway.`,
838
+ field: "name"
839
+ });
840
+ const config = {
841
+ name,
842
+ description,
843
+ instructions: body,
844
+ source: options.source ?? "project",
845
+ location: absPath,
846
+ baseDir,
847
+ resources: enumerateResources(baseDir)
848
+ };
849
+ const license = takeString(frontmatter, "license", diagnostics);
850
+ if (license) config.license = license;
851
+ const compatibility = takeString(frontmatter, "compatibility", diagnostics);
852
+ if (compatibility) {
853
+ if (compatibility.length > 500) diagnostics.push({
854
+ severity: "warning",
855
+ code: "compatibility-too-long",
856
+ message: `Compatibility exceeds spec limit of 500 characters (got ${compatibility.length}). Loading anyway.`,
857
+ field: "compatibility"
858
+ });
859
+ config.compatibility = compatibility;
860
+ }
861
+ const metadata = {};
862
+ const rawMetadata = frontmatter.metadata;
863
+ if (rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)) for (const [k, v] of Object.entries(rawMetadata)) {
864
+ if (typeof v !== "string") {
865
+ diagnostics.push({
866
+ severity: "warning",
867
+ code: "invalid-metadata-value",
868
+ message: `Metadata value for "${k}" is not a string; coerced. (Spec requires string values.)`,
869
+ field: "metadata"
870
+ });
871
+ metadata[k] = String(v);
872
+ continue;
873
+ }
874
+ metadata[k] = v;
875
+ }
876
+ else if (rawMetadata !== void 0) diagnostics.push({
877
+ severity: "warning",
878
+ code: "invalid-metadata-shape",
879
+ message: `Frontmatter "metadata" expected a map, got ${Array.isArray(rawMetadata) ? "array" : typeof rawMetadata}. Ignored.`,
880
+ field: "metadata"
881
+ });
882
+ const pathsField = takeString(frontmatter, "paths", diagnostics);
883
+ if (pathsField) {
884
+ metadata["zidane.paths"] = pathsField.split(COMMA_OR_SPACE_RE).filter(Boolean).join(",");
885
+ diagnostics.push({
886
+ severity: "warning",
887
+ code: "deprecated-top-level-field",
888
+ message: "`paths` is not a spec field and is deprecated — moved to `metadata[\"zidane.paths\"]`.",
889
+ field: "paths"
890
+ });
891
+ }
892
+ const modelField = takeString(frontmatter, "model", diagnostics);
893
+ if (modelField) {
894
+ metadata["zidane.model"] = modelField;
895
+ diagnostics.push({
896
+ severity: "warning",
897
+ code: "deprecated-top-level-field",
898
+ message: "`model` is not a spec field and is deprecated — moved to `metadata[\"zidane.model\"]`.",
899
+ field: "model"
900
+ });
901
+ }
902
+ const thinkingField = takeString(frontmatter, "thinking", diagnostics);
903
+ const effortField = thinkingField ? void 0 : takeString(frontmatter, "effort", diagnostics);
904
+ const legacyThinking = thinkingField ?? effortField;
905
+ if (legacyThinking) {
906
+ metadata["zidane.thinking"] = legacyThinking;
907
+ diagnostics.push({
908
+ severity: "warning",
909
+ code: "deprecated-top-level-field",
910
+ message: `\`${thinkingField ? "thinking" : "effort"}\` is not a spec field and is deprecated — moved to \`metadata["zidane.thinking"]\`.`,
911
+ field: thinkingField ? "thinking" : "effort"
912
+ });
913
+ }
914
+ if (Object.keys(metadata).length > 0) config.metadata = metadata;
915
+ const allowedTools = takeStringOrArray(frontmatter, "allowed-tools", diagnostics);
916
+ if (allowedTools && allowedTools.length > 0) config.allowedTools = allowedTools;
917
+ if (diagnostics.length > 0) config.diagnostics = diagnostics;
918
+ return config;
919
+ }
920
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
921
+ ".git",
922
+ "node_modules",
923
+ ".DS_Store",
924
+ "dist",
925
+ "build"
926
+ ]);
927
+ function findSkillDirs(root, maxDepth = 4, _depth = 0) {
928
+ if (_depth > maxDepth) return [];
929
+ if (!existsSync(root) || !statSync(root).isDirectory()) return [];
930
+ const results = [];
931
+ try {
932
+ for (const entry of readdirSync(root)) {
933
+ if (SKIP_DIRS.has(entry)) continue;
934
+ const entryPath = join(root, entry);
935
+ if (!statSync(entryPath).isDirectory()) continue;
936
+ const skillFile = join(entryPath, "SKILL.md");
937
+ if (existsSync(skillFile) && statSync(skillFile).isFile()) results.push(skillFile);
938
+ else results.push(...findSkillDirs(entryPath, maxDepth, _depth + 1));
939
+ }
940
+ } catch {}
941
+ return results;
653
942
  }
654
943
  /**
655
- * OOM guard: tarballs are buffered in memory before extraction, so an
656
- * unexpectedly huge (or hostile) URL must fail fast instead of ballooning
657
- * RSS. Install sources are source trees — 64MB is already generous.
944
+ * Return the default scan paths tagged by source. Project-scope paths come
945
+ * first; their skills therefore win on name collisions against user-scope
946
+ * skills (first-found wins in discovery).
658
947
  */
659
- const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
660
- async function downloadAndExtract(url, destDir, opts) {
661
- let bytes;
662
- try {
663
- const res = await opts.fetchImpl(url);
664
- if (!res.ok) throw new Error(`download returned ${res.status}`);
665
- const declared = Number(res.headers.get("content-length") ?? "0");
666
- if (declared > 67108864) throw new Error(`archive is ${Math.round(declared / 1024 / 1024)}MB — larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB limit`);
667
- bytes = await res.arrayBuffer();
668
- if (bytes.byteLength > 67108864) throw new Error(`archive is ${Math.round(bytes.byteLength / 1024 / 1024)}MB — larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB limit`);
669
- } catch (err) {
670
- throw new InstallSourceError("fetch-failed", `Download failed for ${opts.what}: ${errorText(err)}`);
671
- }
672
- if (!opts.which("tar")) throw new InstallSourceError("fetch-failed", `Extracting ${opts.what} requires \`tar\` on PATH (or install git for the clone path).`);
673
- const tarPath = join(destDir, ".zidane-install.tgz");
674
- mkdirSync(destDir, { recursive: true });
675
- writeFileSync(tarPath, new Uint8Array(bytes));
676
- const res = await opts.exec("tar", [
677
- "-xzf",
678
- tarPath,
679
- "-C",
680
- destDir,
681
- "--strip-components=1"
682
- ], {});
683
- rmSync(tarPath, { force: true });
684
- if (!res.ok) throw new InstallSourceError("fetch-failed", `Extracting ${opts.what} failed: ${lastLine(res.stderr)}`);
948
+ function getDefaultScanPaths() {
949
+ const home = homedir();
950
+ const cwd = process.cwd();
951
+ return [
952
+ {
953
+ path: join(cwd, ".agents", "skills"),
954
+ source: "project"
955
+ },
956
+ {
957
+ path: join(cwd, ".zidane", "skills"),
958
+ source: "project"
959
+ },
960
+ {
961
+ path: join(home, ".agents", "skills"),
962
+ source: "user"
963
+ },
964
+ {
965
+ path: join(home, ".zidane", "skills"),
966
+ source: "user"
967
+ }
968
+ ];
685
969
  }
686
970
  /**
687
- * Resolve a `:subdir` inside a fetched tree with PHYSICAL containment:
688
- * string-level prefix check first, then realpath both sides and re-check
689
- * so a malicious repo shipping a symlink (`evil -> /`) can't walk the
690
- * candidate search (and any follow-up validation) onto the local
691
- * filesystem. Returns the physical directory, or a typed refusal the
692
- * flavor maps onto its own error vocabulary.
971
+ * Infer a source tag for a user-provided scan path.
972
+ * Paths under `$HOME` are treated as 'user'; everything else as 'project'.
693
973
  */
694
- function resolveSubdirRoot(fetchedInto, subdir) {
695
- const resolved = resolve(fetchedInto, subdir);
696
- if (!resolved.startsWith(fetchedInto + sep)) return {
697
- ok: false,
698
- reason: "escape"
699
- };
700
- let physical = null;
701
- try {
702
- physical = realpathSync(resolved);
703
- } catch {}
704
- if (physical === null || !isDirectory(physical)) return {
705
- ok: false,
706
- reason: "missing"
707
- };
708
- const physicalRoot = realpathSync(fetchedInto);
709
- if (physical !== physicalRoot && !physical.startsWith(physicalRoot + sep)) return {
710
- ok: false,
711
- reason: "escape-symlink"
712
- };
713
- return {
714
- ok: true,
715
- dir: physical
716
- };
717
- }
718
- function isDirectory(path) {
719
- try {
720
- return lstatSync(path).isDirectory();
721
- } catch {
722
- return false;
723
- }
724
- }
725
- function defaultExec(cmd, args, opts) {
726
- return new Promise((resolvePromise) => {
727
- const env = opts.env ? {
728
- ...process.env,
729
- ...opts.env
730
- } : void 0;
731
- execFile(cmd, [...args], {
732
- cwd: opts.cwd,
733
- env,
734
- maxBuffer: 16 * 1024 * 1024
735
- }, (err, stdout, stderr) => {
736
- resolvePromise({
737
- ok: !err,
738
- stdout: String(stdout),
739
- stderr: String(stderr)
740
- });
741
- });
742
- });
974
+ function inferSource(path) {
975
+ return path.startsWith(homedir()) ? "user" : "project";
743
976
  }
744
- function defaultWhich(cmd) {
745
- const exts = process.platform === "win32" ? [
746
- ".exe",
747
- ".cmd",
748
- ".bat",
749
- ""
750
- ] : [""];
751
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
752
- if (!dir) continue;
753
- for (const ext of exts) {
754
- const full = resolve(dir, cmd + ext);
755
- try {
756
- if (lstatSync(full).isFile()) return full;
757
- } catch {
977
+ /**
978
+ * Discover skills from sourced filesystem paths.
979
+ * Each path is scanned for subdirectories containing SKILL.md.
980
+ * Earlier paths have higher priority (first-found wins on name collision).
981
+ *
982
+ * `signal` is checked between each `parseSkillFile` so a rapid `cwd` switch
983
+ * in the host (TUI / GUI) can stop a long scan without leaking I/O. Aborts
984
+ * reject with the signal's `reason` (or a default `AbortError`) — same shape
985
+ * `AbortSignal.throwIfAborted()` produces in modern runtimes.
986
+ */
987
+ async function discoverSkills(paths, signal) {
988
+ const skillsByName = /* @__PURE__ */ new Map();
989
+ for (const { path: scanPath, source } of paths) {
990
+ signal?.throwIfAborted?.();
991
+ const skillFiles = findSkillDirs(resolve(scanPath));
992
+ for (const file of skillFiles) {
993
+ signal?.throwIfAborted?.();
994
+ const skill = await parseSkillFile(file, { source });
995
+ if (!skill) continue;
996
+ if (skillsByName.has(skill.name)) {
997
+ const existing = skillsByName.get(skill.name);
998
+ const diag = {
999
+ severity: "warning",
1000
+ code: "name-collision-shadowed",
1001
+ message: `A skill with name "${skill.name}" was also found at ${file} (source: ${source}); shadowed by ${existing.location} (source: ${existing.source}).`
1002
+ };
1003
+ existing.diagnostics = [...existing.diagnostics ?? [], diag];
758
1004
  continue;
759
1005
  }
1006
+ skillsByName.set(skill.name, skill);
760
1007
  }
761
1008
  }
762
- return null;
1009
+ return [...skillsByName.values()];
763
1010
  }
764
- /** Last non-empty line of a command's output — the useful bit of tool noise. */
1011
+ //#endregion
1012
+ //#region src/install/source.ts
765
1013
  /**
766
- * Insert `-oBatchMode=yes` into a `GIT_SSH_COMMAND` right after the command
767
- * token. OpenSSH resolves every option to the FIRST obtained value
768
- * (repeated `-o` included), so insertion-first beats a user-set
769
- * `-oBatchMode=no` — deliberate: the install flow cannot answer a prompt,
770
- * and a hang is strictly worse than a fast auth failure. A leading quote
771
- * (single or double) marks a command path containing spaces
772
- * (`"/my tools/ssh" -i key`) — insert after the CLOSING quote so the path
773
- * isn't split mid-token; an unterminated quote falls back to appending at
774
- * the end (mangled input, best-effort semantics).
1014
+ * Shared install-source layer — the source-agnostic half of "install a
1015
+ * thing from github/npm". Extracted from the extensions installer so
1016
+ * SKILL installs (and any future installable artifact) reuse the exact
1017
+ * same spec grammar, fetch pipeline, and hardening instead of forking
1018
+ * them:
1019
+ *
1020
+ * - Spec grammar: `owner/repo[#ref][:subdir]`, `github:owner/repo`,
1021
+ * pasted GitHub URLs (including `/tree/<ref>/<path>`),
1022
+ * `pkg[@version]`, `@scope/pkg[@version]`.
1023
+ * - Fetch: `git clone --depth 1` (records the exact commit) with a
1024
+ * codeload-tarball fallback, npm registry resolve (exact versions +
1025
+ * dist-tags) + tarball download. Downloads are double-capped at
1026
+ * {@link MAX_DOWNLOAD_BYTES} (content-length header AND post-hoc for
1027
+ * chunked responses). Extraction requires `tar` on PATH.
1028
+ * - All side effects go through the injectable {@link InstallIo} seams
1029
+ * so tests stay offline and hosts can instrument.
1030
+ *
1031
+ * Flavor-specific concerns stay OUT of this module: candidate
1032
+ * enumeration, validation, target roots, provenance files, dependency
1033
+ * installs. Flavors subclass {@link InstallSourceError} to widen the
1034
+ * error-code union while keeping `.code`-based handling uniform.
775
1035
  */
776
- function sshCommandWithBatchMode(configured) {
777
- const base = configured?.trim() || "ssh";
778
- const quoteChar = base[0] === "\"" || base[0] === "'" ? base[0] : null;
779
- if (quoteChar) {
780
- const closing = base.indexOf(quoteChar, 1);
781
- if (closing === -1) return `${base} -oBatchMode=yes`;
782
- return `${base.slice(0, closing + 1)} -oBatchMode=yes${base.slice(closing + 1)}`;
783
- }
784
- return base.replace(/^(\S+)/, "$1 -oBatchMode=yes");
785
- }
786
- function lastLine(text) {
787
- const lines = text.trim().split("\n").filter((l) => l.trim().length > 0);
788
- return lines[lines.length - 1]?.trim() ?? "";
789
- }
790
- function errorText(err) {
791
- return err instanceof Error ? err.message : String(err);
792
- }
793
- //#endregion
794
- //#region src/skills/activation.ts
795
- function createSkillActivationState(options = {}) {
796
- const byName = /* @__PURE__ */ new Map();
797
- const maxActive = typeof options.maxActive === "number" && options.maxActive > 0 ? options.maxActive : void 0;
798
- return {
799
- active() {
800
- return [...byName.values()];
801
- },
802
- isActive(name) {
803
- return byName.has(name);
804
- },
805
- get(name) {
806
- return byName.get(name);
807
- },
808
- activate(skill, via) {
809
- if (byName.has(skill.name)) return "already-active";
810
- if (maxActive !== void 0 && byName.size >= maxActive) return "cap-reached";
811
- byName.set(skill.name, {
812
- skill,
813
- activatedAt: Date.now(),
814
- activatedVia: via
815
- });
816
- return "ok";
817
- },
818
- deactivate(name) {
819
- const existing = byName.get(name);
820
- if (!existing) return void 0;
821
- byName.delete(name);
822
- return existing;
823
- },
824
- clear() {
825
- const snapshot = [...byName.values()];
826
- byName.clear();
827
- return snapshot;
828
- }
829
- };
1036
+ /**
1037
+ * Base error for install pipelines. `code` is a plain string here so
1038
+ * flavors (extensions, skills) can widen it with their own codes while
1039
+ * callers keep a single `err.code` switch. The base layer itself only
1040
+ * throws `'invalid-source'` and `'fetch-failed'`.
1041
+ */
1042
+ var InstallSourceError = class extends Error {
1043
+ code;
1044
+ /** Set for candidate-selection errors — the names the caller can pick from. */
1045
+ candidates;
1046
+ constructor(code, message, candidates) {
1047
+ super(message);
1048
+ this.name = "InstallSourceError";
1049
+ this.code = code;
1050
+ if (candidates) this.candidates = candidates;
1051
+ }
1052
+ };
1053
+ const GITHUB_TREE_URL_RE = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#@:]+?)(?:\.git)?\/tree\/([^/\s:]+)(?:\/([^\s:]+?))?\/?$/;
1054
+ const GITHUB_URL_RE = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#@:]+?)(?:\.git)?\/?$/;
1055
+ const OWNER_REPO_RE = /^([\w.-]+)\/([\w.-]+)$/;
1056
+ const GITHUB_REF_RE = /^[\w./-]+$/;
1057
+ const NPM_NAME_RE = /^(@[\w.-]+\/[\w.-]+|[\w.-]+)(?:@([\w.^~><=*-]+))?$/;
1058
+ /** Structural validation for a `:subdir` suffix — repo-relative, no escapes. */
1059
+ function parseSubdir(rawSubdir, raw) {
1060
+ const subdir = rawSubdir.replace(/^\.\//, "").replace(/\/+$/, "");
1061
+ const segments = subdir.split("/");
1062
+ if (subdir.length === 0 || subdir.startsWith("/") || subdir.includes("\\") || segments.some((s) => s.length === 0 || s === "." || s === "..")) throw new InstallSourceError("invalid-source", `"${raw}" has an invalid subdirectory — expected a repo-relative path (no leading /, no ..).`);
1063
+ return subdir;
830
1064
  }
831
- //#endregion
832
- //#region src/skills/allowed-tools.ts
833
- /** Tools that are always allowed regardless of the active skills' allow-list. */
834
- const IMPLICITLY_ALLOWED_SKILL_TOOLS = [
835
- "skills_use",
836
- "skills_read",
837
- "skills_run_script"
838
- ];
839
1065
  /**
840
- * Register `tool:gate` / `mcp:tool:gate` handlers that enforce the union of
841
- * `allowedTools` across active skills.
1066
+ * Classify a raw source string:
842
1067
  *
843
- * No-op when no active skill declares an allow-list (permissive default —
844
- * matches the spec's "experimental" note for `allowed-tools`).
1068
+ * - `github:owner/repo`, `owner/repo`, GitHub URLs (including
1069
+ * `/tree/<ref>/<path>` links) → GitHub. Optional `#ref` (or the
1070
+ * legacy `@ref`) pins a branch/tag; optional `:path/to/dir` roots the
1071
+ * candidate search in a subdirectory: `owner/repo#branch:packages/x`.
1072
+ * - `@scope/pkg[@version]`, `pkg[@version]` → npm (no subdir support —
1073
+ * flavors expose their own selector for multi-candidate packages).
845
1074
  *
846
- * Returns an `uninstall` fn. The agent calls this at run end to detach the
847
- * handlers and prevent cross-run hook leaks.
1075
+ * The one ambiguity — `a/b` — is resolved as GitHub: npm's slash names
1076
+ * are always scoped (`@scope/…`), so a bare `owner/repo` can only be a
1077
+ * repo. Throws `invalid-source` on anything else.
848
1078
  */
849
- function installAllowedToolsGate(hooks, state) {
850
- function effectiveUnion() {
851
- const active = state.active();
852
- const declared = [];
853
- for (const record of active) if (record.skill.allowedTools?.length) declared.push(...record.skill.allowedTools);
1079
+ function parseInstallSource(raw) {
1080
+ const spec = raw.trim();
1081
+ if (spec.length === 0) throw new InstallSourceError("invalid-source", "Empty source. Expected `owner/repo`, `github:owner/repo`, a GitHub URL, or an npm package name.");
1082
+ const explicitGithub = spec.startsWith("github:");
1083
+ const body = explicitGithub ? spec.slice(7) : spec;
1084
+ const tree = GITHUB_TREE_URL_RE.exec(body);
1085
+ if (tree) return {
1086
+ kind: "github",
1087
+ owner: tree[1],
1088
+ repo: tree[2],
1089
+ ref: tree[3],
1090
+ ...tree[4] ? { subdir: parseSubdir(tree[4], raw) } : {}
1091
+ };
1092
+ const url = GITHUB_URL_RE.exec(body);
1093
+ if (url) return {
1094
+ kind: "github",
1095
+ owner: url[1],
1096
+ repo: url[2]
1097
+ };
1098
+ if (body.startsWith("@")) {
1099
+ if (explicitGithub) throw new InstallSourceError("invalid-source", `"${raw}" mixes the github: prefix with an npm scoped name.`);
1100
+ if (body.includes(":")) throw new InstallSourceError("invalid-source", `"${raw}": subdirectories (\`:path\`) are only supported for GitHub sources.`);
1101
+ const m = NPM_NAME_RE.exec(body);
1102
+ if (!m) throw new InstallSourceError("invalid-source", `"${raw}" is not a valid npm package spec.`);
854
1103
  return {
855
- union: declared.length > 0 ? [...declared, ...IMPLICITLY_ALLOWED_SKILL_TOOLS] : [],
856
- active: active.map((a) => a.skill.name)
1104
+ kind: "npm",
1105
+ pkg: m[1],
1106
+ ...m[2] ? { version: m[2] } : {}
857
1107
  };
858
1108
  }
859
- function gateHandler(ctx) {
860
- if (ctx.block) return;
861
- const { union, active } = effectiveUnion();
862
- if (union.length === 0) return;
863
- if (isToolAllowedByUnion(ctx.displayName, ctx.input, union)) return;
864
- const err = new AgentToolNotAllowedError({
865
- toolName: ctx.name,
866
- displayName: ctx.displayName,
867
- allowedUnion: union,
868
- activeSkills: active
869
- });
870
- ctx.block = true;
871
- ctx.reason = err.message;
872
- }
873
- function mcpGateHandler(ctx) {
874
- if (ctx.block) return;
875
- const { union, active } = effectiveUnion();
876
- if (union.length === 0) return;
877
- if (isToolAllowedByUnion(ctx.displayName, ctx.input, union)) return;
878
- const err = new AgentToolNotAllowedError({
879
- toolName: `mcp_${ctx.server}_${ctx.tool}`,
880
- displayName: ctx.displayName,
881
- allowedUnion: union,
882
- activeSkills: active
883
- });
884
- ctx.block = true;
885
- ctx.reason = err.message;
1109
+ if (body.includes("/")) {
1110
+ const colonIdx = body.indexOf(":");
1111
+ const specPart = colonIdx === -1 ? body : body.slice(0, colonIdx);
1112
+ const subdir = colonIdx === -1 ? void 0 : parseSubdir(body.slice(colonIdx + 1), raw);
1113
+ const hashIdx = specPart.indexOf("#");
1114
+ const refIdx = hashIdx >= 0 ? hashIdx : specPart.indexOf("@");
1115
+ const repoPart = refIdx === -1 ? specPart : specPart.slice(0, refIdx);
1116
+ const ref = refIdx === -1 ? void 0 : specPart.slice(refIdx + 1);
1117
+ const m = OWNER_REPO_RE.exec(repoPart);
1118
+ if (!m || ref !== void 0 && !GITHUB_REF_RE.test(ref)) throw new InstallSourceError("invalid-source", `"${raw}" is not a valid GitHub repo spec (expected owner/repo, optionally #ref and :subdir).`);
1119
+ return {
1120
+ kind: "github",
1121
+ owner: m[1],
1122
+ repo: m[2],
1123
+ ...ref ? { ref } : {},
1124
+ ...subdir ? { subdir } : {}
1125
+ };
886
1126
  }
887
- const unregisterTool = hooks.hook("tool:gate", gateHandler);
888
- const unregisterMcp = hooks.hook("mcp:tool:gate", mcpGateHandler);
889
- return function uninstall() {
890
- unregisterTool();
891
- unregisterMcp();
1127
+ if (explicitGithub) throw new InstallSourceError("invalid-source", `"${raw}" is missing the repo part (expected github:owner/repo).`);
1128
+ if (body.includes(":")) throw new InstallSourceError("invalid-source", `"${raw}": subdirectories (\`:path\`) are only supported for GitHub sources.`);
1129
+ const m = NPM_NAME_RE.exec(body);
1130
+ if (!m) throw new InstallSourceError("invalid-source", `"${raw}" is not a valid npm package spec.`);
1131
+ return {
1132
+ kind: "npm",
1133
+ pkg: m[1],
1134
+ ...m[2] ? { version: m[2] } : {}
892
1135
  };
893
1136
  }
894
- //#endregion
895
- //#region src/skills/catalog.ts
896
1137
  /**
897
- * Build the skill catalog XML and behavioral instructions for the system prompt.
1138
+ * Materialize the source into `destDir` (created by the caller, assumed
1139
+ * empty). Returns the provenance string plus, for git clones, the exact
1140
+ * commit SHA (the tarball fallback can't know it — codeload doesn't
1141
+ * expose the resolved commit).
898
1142
  */
899
- function buildCatalog(skills, options = {}) {
900
- if (skills.length === 0) return "";
901
- const skillsToolRegistered = options.skillsToolRegistered ?? true;
902
- const readToolName = options.readToolName ?? "read_file";
903
- const entries = skills.map((skill) => {
904
- const locationLine = skill.location ? `\n <location>${escapeXml(skill.location)}</location>` : "";
905
- return ` <skill>
906
- <name>${escapeXml(skill.name)}</name>
907
- <description>${escapeXml(skill.description)}</description>${locationLine}
908
- </skill>`;
909
- }).join("\n");
910
- const hasFsSkills = skills.some((s) => s.location);
911
- const hasInlineSkills = skills.some((s) => !s.location);
912
- const behavioralParts = [];
913
- behavioralParts.push("The following skills provide specialized instructions for specific tasks.", "When a task matches a skill's description, activate the skill to load its full instructions before proceeding.");
914
- if (skillsToolRegistered) behavioralParts.push("To activate a skill, call the `skills_use` tool with the skill's name. The response contains the full instructions and any bundled resources you can then load via `skills_read` (reference files) or execute via `skills_run_script` (scripts/ directory).", "Relative paths referenced in a skill's instructions resolve against the skill directory noted in the `skills_use` response.");
915
- else if (hasFsSkills) behavioralParts.push(`For skills with a <location>, use the ${readToolName} tool to read the SKILL.md file at that path.`, "When a skill references relative paths, resolve them against the skill's directory (the parent of SKILL.md) and use absolute paths in tool calls.");
916
- if (hasInlineSkills && !skillsToolRegistered) behavioralParts.push("Skills without a <location> have their instructions included directly in <instructions> tags below.");
917
- const parts = [
918
- behavioralParts.join("\n"),
919
- "",
920
- "<available_skills>",
921
- entries,
922
- "</available_skills>"
923
- ];
924
- if (hasInlineSkills && !skillsToolRegistered) {
925
- parts.push("");
926
- for (const skill of skills) if (!skill.location && skill.instructions) {
927
- parts.push(`<skill_instructions name="${escapeXml(skill.name)}">`);
928
- parts.push(skill.instructions);
929
- if (skill.resources && skill.resources.length > 0) {
930
- parts.push("");
931
- parts.push("<skill_resources>");
932
- for (const res of skill.resources) parts.push(` <file type="${res.type}">${escapeXml(res.path)}</file>`);
933
- parts.push("</skill_resources>");
1143
+ async function fetchInstallSource(source, destDir, io = {}, onProgress) {
1144
+ const exec = io.exec ?? defaultExec;
1145
+ const which = io.which ?? defaultWhich;
1146
+ const fetchImpl = io.fetch ?? fetch;
1147
+ if (source.kind === "github") {
1148
+ const label = `${source.owner}/${source.repo}${source.ref ? `@${source.ref}` : ""}${source.subdir ? `:${source.subdir}` : ""}`;
1149
+ if (which("git")) {
1150
+ onProgress?.(`cloning github.com/${label}…`);
1151
+ const cloneArgs = (url) => {
1152
+ const args = [
1153
+ "clone",
1154
+ "--depth",
1155
+ "1",
1156
+ "--single-branch"
1157
+ ];
1158
+ if (source.ref) args.push("--branch", source.ref);
1159
+ args.push(url, destDir);
1160
+ return args;
1161
+ };
1162
+ let res = await exec("git", cloneArgs(`https://github.com/${source.owner}/${source.repo}.git`), { env: { GIT_TERMINAL_PROMPT: "0" } });
1163
+ if (!res.ok && /could not read username|terminal prompts disabled|authentication failed|repository not found/i.test(res.stderr)) {
1164
+ rmSync(destDir, {
1165
+ recursive: true,
1166
+ force: true
1167
+ });
1168
+ mkdirSync(destDir, { recursive: true });
1169
+ onProgress?.(`anonymous clone rejected — retrying via SSH (git@github.com:${source.owner}/${source.repo})…`);
1170
+ const ssh = await exec("git", cloneArgs(`git@github.com:${source.owner}/${source.repo}.git`), { env: {
1171
+ GIT_TERMINAL_PROMPT: "0",
1172
+ GIT_SSH_COMMAND: sshCommandWithBatchMode(process.env.GIT_SSH_COMMAND)
1173
+ } });
1174
+ if (ssh.ok) res = ssh;
1175
+ else throw new InstallSourceError("fetch-failed", `git clone failed for ${label}: ${lastLine(res.stderr)}; SSH retry also failed: ${lastLine(ssh.stderr)} (private repo? check your GitHub SSH keys / repo access, or configure git credentials for HTTPS)`);
934
1176
  }
935
- parts.push("</skill_instructions>");
936
- }
937
- }
938
- return parts.join("\n");
939
- }
940
- //#endregion
941
- //#region src/skills/discovery.ts
942
- const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
943
- const INDENT_RE = /^[ \t]{2,}/;
944
- const KV_RE = /^([^:]+):(.*)$/;
945
- const DOUBLE_QUOTED_RE = /^"((?:\\.|[^"\\])*)"$/;
946
- const SINGLE_QUOTED_RE = /^'((?:''|[^'])*)'$/;
947
- const DQ_ESCAPE_RE = /\\(["\\/bfnrt])/g;
948
- const WHITESPACE_SPLIT_RE = /\s+/;
949
- const PARAGRAPH_SPLIT_RE = /\n\n/;
950
- const COMMA_OR_SPACE_RE = /[,\s]+/;
951
- /**
952
- * Block-list bullet (`- value`) under an indented frontmatter key. A
953
- * single dash followed by either a space or end-of-line; bare `-` is a
954
- * list item with an empty value (preserved by callers as `""` because
955
- * the spec doesn't disallow it).
956
- */
957
- const LIST_ITEM_RE = /^-(?:\s+|$)/;
958
- /**
959
- * Flow-style list scalar (`[a, b, c]`). Anchored, non-nested, single
960
- * pass — we trim the outer brackets and let the caller split on
961
- * commas. Anything more complex (nested brackets, quoted commas)
962
- * falls through to the whitespace-split branch, where the bracket
963
- * characters become visible authoring errors instead of silent garbage.
964
- *
965
- * Carefully written without overlapping `\s*` runs so it's not
966
- * ReDoS-prone: the body class `[^[\]]*` already swallows whitespace,
967
- * so wrapping it in `\s*…\s*` would let the regex engine
968
- * non-deterministically split a run of spaces between the body and
969
- * the boundary quantifiers.
970
- */
971
- const FLOW_LIST_RE = /^\[([^[\]]*)\]$/;
1177
+ if (!res.ok) throw new InstallSourceError("fetch-failed", `git clone failed for ${label}: ${lastLine(res.stderr)}`);
1178
+ const head = await exec("git", ["rev-parse", "HEAD"], { cwd: destDir });
1179
+ const commit = head.ok && /^[0-9a-f]{40}$/.test(head.stdout.trim()) ? head.stdout.trim() : void 0;
1180
+ rmSync(join(destDir, ".git"), {
1181
+ recursive: true,
1182
+ force: true
1183
+ });
1184
+ return {
1185
+ provenance: `github:${label}`,
1186
+ ...commit ? { commit } : {}
1187
+ };
1188
+ }
1189
+ onProgress?.(`downloading github.com/${label} tarball…`);
1190
+ const ref = source.ref ?? "HEAD";
1191
+ await downloadAndExtract(`https://codeload.github.com/${source.owner}/${source.repo}/tar.gz/${ref}`, destDir, {
1192
+ exec,
1193
+ fetchImpl,
1194
+ which,
1195
+ what: label
1196
+ });
1197
+ return { provenance: `github:${label}` };
1198
+ }
1199
+ onProgress?.(`resolving npm:${source.pkg}…`);
1200
+ const { version, tarball } = await resolveNpmVersion(source, fetchImpl);
1201
+ onProgress?.(`downloading npm:${source.pkg}@${version}…`);
1202
+ await downloadAndExtract(tarball, destDir, {
1203
+ exec,
1204
+ fetchImpl,
1205
+ which,
1206
+ what: `${source.pkg}@${version}`
1207
+ });
1208
+ return { provenance: `npm:${source.pkg}@${version}` };
1209
+ }
972
1210
  /**
973
- * Common kebab/camel/snake misspellings of multi-word spec frontmatter
974
- * keys. The Agent Skills spec mandates kebab-case (`allowed-tools`), but
975
- * authors raised on JS / Python idioms naturally reach for camelCase
976
- * (`allowedTools`) or snake_case (`allowed_tools`) — and the consequences
977
- * of a silent mismatch on `allowed-tools` specifically are nasty: the
978
- * skill loads fine, looks active in the UI, the model reads the body
979
- * and tells the user "tools are restricted", but the `tool:gate`
980
- * middleware finds nothing in the union and runs permissively. So we
981
- * look for these alts at parse time, migrate the value to the canonical
982
- * key, and emit a diagnostic so the next person sees the issue clearly.
983
- *
984
- * Keep the value space tight — accepting too many shapes erodes the
985
- * "lenient load, strict authoring" principle. Add entries only for keys
986
- * that are (a) multi-word (single-word keys have no ambiguity), (b) in
987
- * active use, and (c) cause silent failures when missing (gate keys,
988
- * security gates, …).
1211
+ * Registry lookup shared by the fetch path and update no-op checks.
1212
+ * Exact versions and dist-tags only — range resolution is out of scope.
989
1213
  */
990
- const FRONTMATTER_KEY_ALIASES = /* @__PURE__ */ new Map([["allowed-tools", ["allowedTools", "allowed_tools"]]]);
1214
+ async function resolveNpmVersion(source, fetchImpl) {
1215
+ const registryUrl = `https://registry.npmjs.org/${source.pkg.replace("/", "%2F")}`;
1216
+ let meta;
1217
+ try {
1218
+ const res = await fetchImpl(registryUrl);
1219
+ if (!res.ok) throw new Error(`registry returned ${res.status}`);
1220
+ meta = await res.json();
1221
+ } catch (err) {
1222
+ throw new InstallSourceError("fetch-failed", `Could not resolve npm package "${source.pkg}": ${errorText(err)}`);
1223
+ }
1224
+ const version = source.version && meta.versions?.[source.version] ? source.version : meta["dist-tags"]?.[source.version ?? "latest"];
1225
+ const tarball = version ? meta.versions?.[version]?.dist?.tarball : void 0;
1226
+ if (!version || !tarball) throw new InstallSourceError("fetch-failed", `npm package "${source.pkg}" has no ${source.version ? `version/tag "${source.version}"` : "latest version"} (exact versions and dist-tags only — ranges are not resolved).`);
1227
+ return {
1228
+ version,
1229
+ tarball
1230
+ };
1231
+ }
991
1232
  /**
992
- * Mutate `frontmatter` so any well-known alt-spelling of a canonical
993
- * spec key gets folded into the canonical slot. Idempotent — when the
994
- * canonical key is already set the function is a no-op even if an
995
- * alt-spelling is also present (canonical wins; alt is ignored).
996
- *
997
- * Pushes a `frontmatter-key-alt-spelling` diagnostic per migration so
998
- * the SkillConfig's `.diagnostics` carries an actionable hint into any
999
- * host UI surfacing skill load issues.
1233
+ * OOM guard: tarballs are buffered in memory before extraction, so an
1234
+ * unexpectedly huge (or hostile) URL must fail fast instead of ballooning
1235
+ * RSS. Install sources are source trees — 64MB is already generous.
1000
1236
  */
1001
- function normalizeKeyAliases(frontmatter, diagnostics) {
1002
- for (const [canonical, aliases] of FRONTMATTER_KEY_ALIASES) {
1003
- if (frontmatter[canonical] !== void 0) continue;
1004
- for (const alias of aliases) {
1005
- if (frontmatter[alias] === void 0) continue;
1006
- frontmatter[canonical] = frontmatter[alias];
1007
- diagnostics.push({
1008
- severity: "warning",
1009
- code: "frontmatter-key-alt-spelling",
1010
- message: `Frontmatter field "${alias}" is not a spec key — the spec uses "${canonical}" (kebab-case). Reading the value from "${alias}" as a courtesy; rename it to "${canonical}" to silence this warning.`,
1011
- field: alias
1012
- });
1013
- break;
1014
- }
1237
+ const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
1238
+ async function downloadAndExtract(url, destDir, opts) {
1239
+ let bytes;
1240
+ try {
1241
+ const res = await opts.fetchImpl(url);
1242
+ if (!res.ok) throw new Error(`download returned ${res.status}`);
1243
+ const declared = Number(res.headers.get("content-length") ?? "0");
1244
+ if (declared > 67108864) throw new Error(`archive is ${Math.round(declared / 1024 / 1024)}MB — larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB limit`);
1245
+ bytes = await res.arrayBuffer();
1246
+ if (bytes.byteLength > 67108864) throw new Error(`archive is ${Math.round(bytes.byteLength / 1024 / 1024)}MB — larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB limit`);
1247
+ } catch (err) {
1248
+ throw new InstallSourceError("fetch-failed", `Download failed for ${opts.what}: ${errorText(err)}`);
1015
1249
  }
1250
+ if (!opts.which("tar")) throw new InstallSourceError("fetch-failed", `Extracting ${opts.what} requires \`tar\` on PATH (or install git for the clone path).`);
1251
+ const tarPath = join(destDir, ".zidane-install.tgz");
1252
+ mkdirSync(destDir, { recursive: true });
1253
+ writeFileSync(tarPath, new Uint8Array(bytes));
1254
+ const res = await opts.exec("tar", [
1255
+ "-xzf",
1256
+ tarPath,
1257
+ "-C",
1258
+ destDir,
1259
+ "--strip-components=1"
1260
+ ], {});
1261
+ rmSync(tarPath, { force: true });
1262
+ if (!res.ok) throw new InstallSourceError("fetch-failed", `Extracting ${opts.what} failed: ${lastLine(res.stderr)}`);
1016
1263
  }
1017
1264
  /**
1018
- * Parse a SKILL.md file into frontmatter + body.
1019
- *
1020
- * Uses a simple regex-based YAML extractor that handles:
1021
- * - Flat key: value pairs
1022
- * - Quoted values
1023
- * - One-level nested maps (for `metadata:`)
1024
- * - One-level nested block lists, `key:` followed by indented `- item`
1025
- * lines (for `allowed-tools` and any future array fields). Items are
1026
- * unquoted with the same rules as scalar values.
1027
- * - Flow-style scalars are preserved verbatim — interpretation (e.g.
1028
- * `[a, b]` as a list) is left to per-field consumers via
1029
- * {@link takeStringOrArray}.
1030
- * - Lenient recovery from unquoted-colon values (e.g.
1031
- * `description: Use when: the user asks …`) via a quote-wrap retry.
1265
+ * Resolve a `:subdir` inside a fetched tree with PHYSICAL containment:
1266
+ * string-level prefix check first, then realpath both sides and re-check
1267
+ * so a malicious repo shipping a symlink (`evil -> /`) can't walk the
1268
+ * candidate search (and any follow-up validation) onto the local
1269
+ * filesystem. Returns the physical directory, or a typed refusal the
1270
+ * flavor maps onto its own error vocabulary.
1032
1271
  */
1033
- function parseFrontmatter(content) {
1034
- const diagnostics = [];
1035
- const match = content.match(FRONTMATTER_RE);
1036
- if (!match) return {
1037
- frontmatter: {},
1038
- body: content.trim(),
1039
- diagnostics
1272
+ function resolveSubdirRoot(fetchedInto, subdir) {
1273
+ const resolved = resolve(fetchedInto, subdir);
1274
+ if (!resolved.startsWith(fetchedInto + sep)) return {
1275
+ ok: false,
1276
+ reason: "escape"
1040
1277
  };
1041
- const yamlBlock = match[1];
1042
- const body = match[2].trim();
1043
- const frontmatter = {};
1044
- let currentKey = null;
1045
- let currentMap = null;
1046
- let currentList = null;
1047
- const flushPending = () => {
1048
- if (currentKey === null) return;
1049
- if (currentList !== null) frontmatter[currentKey] = currentList;
1050
- else if (currentMap !== null) frontmatter[currentKey] = currentMap;
1051
- currentKey = null;
1052
- currentMap = null;
1053
- currentList = null;
1278
+ let physical = null;
1279
+ try {
1280
+ physical = realpathSync(resolved);
1281
+ } catch {}
1282
+ if (physical === null || !isDirectory(physical)) return {
1283
+ ok: false,
1284
+ reason: "missing"
1054
1285
  };
1055
- for (const line of yamlBlock.split("\n")) {
1056
- if (!line.trim() || line.trim().startsWith("#")) continue;
1057
- if (currentKey !== null && INDENT_RE.test(line)) {
1058
- const stripped = line.trim();
1059
- if (LIST_ITEM_RE.test(stripped)) {
1060
- if (currentMap !== null && Object.keys(currentMap).length > 0) {
1061
- diagnostics.push({
1062
- severity: "warning",
1063
- code: "mixed-list-and-map",
1064
- message: `Frontmatter field "${currentKey}" mixes list items and map entries — keeping the map and dropping the list items.`,
1065
- field: currentKey
1066
- });
1067
- continue;
1068
- }
1069
- currentList = currentList ?? [];
1070
- currentList.push(unquoteYaml(stripped.slice(1).trim()));
1286
+ const physicalRoot = realpathSync(fetchedInto);
1287
+ if (physical !== physicalRoot && !physical.startsWith(physicalRoot + sep)) return {
1288
+ ok: false,
1289
+ reason: "escape-symlink"
1290
+ };
1291
+ return {
1292
+ ok: true,
1293
+ dir: physical
1294
+ };
1295
+ }
1296
+ function isDirectory(path) {
1297
+ try {
1298
+ return lstatSync(path).isDirectory();
1299
+ } catch {
1300
+ return false;
1301
+ }
1302
+ }
1303
+ function defaultExec(cmd, args, opts) {
1304
+ return new Promise((resolvePromise) => {
1305
+ const env = opts.env ? {
1306
+ ...process.env,
1307
+ ...opts.env
1308
+ } : void 0;
1309
+ execFile(cmd, [...args], {
1310
+ cwd: opts.cwd,
1311
+ env,
1312
+ maxBuffer: 16 * 1024 * 1024
1313
+ }, (err, stdout, stderr) => {
1314
+ resolvePromise({
1315
+ ok: !err,
1316
+ stdout: String(stdout),
1317
+ stderr: String(stderr)
1318
+ });
1319
+ });
1320
+ });
1321
+ }
1322
+ function defaultWhich(cmd) {
1323
+ const exts = process.platform === "win32" ? [
1324
+ ".exe",
1325
+ ".cmd",
1326
+ ".bat",
1327
+ ""
1328
+ ] : [""];
1329
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
1330
+ if (!dir) continue;
1331
+ for (const ext of exts) {
1332
+ const full = resolve(dir, cmd + ext);
1333
+ try {
1334
+ if (lstatSync(full).isFile()) return full;
1335
+ } catch {
1071
1336
  continue;
1072
1337
  }
1073
- const nestedMatch = stripped.match(KV_RE);
1074
- if (nestedMatch) {
1075
- if (currentList !== null) {
1076
- diagnostics.push({
1077
- severity: "warning",
1078
- code: "mixed-list-and-map",
1079
- message: `Frontmatter field "${currentKey}" mixes list items and map entries — keeping the list and dropping the map entries.`,
1080
- field: currentKey
1081
- });
1082
- continue;
1083
- }
1084
- currentMap = currentMap ?? {};
1085
- const val = nestedMatch[2].trim();
1086
- currentMap[nestedMatch[1].trim()] = unquoteYaml(val);
1087
- }
1088
- continue;
1089
1338
  }
1090
- flushPending();
1091
- const kvMatch = matchFirstColon(line);
1092
- if (!kvMatch) continue;
1093
- const key = kvMatch.key.trim();
1094
- const rawValue = kvMatch.value.trim();
1095
- if (!rawValue) currentKey = key;
1096
- else frontmatter[key] = unquoteYaml(rawValue);
1097
1339
  }
1098
- flushPending();
1099
- return {
1100
- frontmatter,
1101
- body,
1102
- diagnostics
1103
- };
1340
+ return null;
1341
+ }
1342
+ /** Last non-empty line of a command's output — the useful bit of tool noise. */
1343
+ /**
1344
+ * Insert `-oBatchMode=yes` into a `GIT_SSH_COMMAND` right after the command
1345
+ * token. OpenSSH resolves every option to the FIRST obtained value
1346
+ * (repeated `-o` included), so insertion-first beats a user-set
1347
+ * `-oBatchMode=no` — deliberate: the install flow cannot answer a prompt,
1348
+ * and a hang is strictly worse than a fast auth failure. A leading quote
1349
+ * (single or double) marks a command path containing spaces
1350
+ * (`"/my tools/ssh" -i key`) — insert after the CLOSING quote so the path
1351
+ * isn't split mid-token; an unterminated quote falls back to appending at
1352
+ * the end (mangled input, best-effort semantics).
1353
+ */
1354
+ function sshCommandWithBatchMode(configured) {
1355
+ const base = configured?.trim() || "ssh";
1356
+ const quoteChar = base[0] === "\"" || base[0] === "'" ? base[0] : null;
1357
+ if (quoteChar) {
1358
+ const closing = base.indexOf(quoteChar, 1);
1359
+ if (closing === -1) return `${base} -oBatchMode=yes`;
1360
+ return `${base.slice(0, closing + 1)} -oBatchMode=yes${base.slice(closing + 1)}`;
1361
+ }
1362
+ return base.replace(/^(\S+)/, "$1 -oBatchMode=yes");
1104
1363
  }
1105
- function matchFirstColon(line) {
1106
- const idx = line.indexOf(":");
1107
- if (idx < 0) return null;
1108
- const key = line.slice(0, idx);
1109
- const value = line.slice(idx + 1);
1110
- if (!KV_RE.test(`${key}:`)) return null;
1111
- return {
1112
- key,
1113
- value
1364
+ function lastLine(text) {
1365
+ const lines = text.trim().split("\n").filter((l) => l.trim().length > 0);
1366
+ return lines[lines.length - 1]?.trim() ?? "";
1367
+ }
1368
+ function errorText(err) {
1369
+ return err instanceof Error ? err.message : String(err);
1370
+ }
1371
+ //#endregion
1372
+ //#region src/skills/install.ts
1373
+ var SkillInstallError = class extends InstallSourceError {
1374
+ /**
1375
+ * Frontmatter metadata per candidate, parallel to `candidates` — set on
1376
+ * the selector-less `multiple-candidates` error so host pickers can show
1377
+ * descriptions without re-fetching the source. Best-effort: candidates
1378
+ * whose SKILL.md fails to parse appear with no description.
1379
+ */
1380
+ details;
1381
+ constructor(code, message, candidates, details) {
1382
+ super(code, message, candidates);
1383
+ this.name = "SkillInstallError";
1384
+ if (details) this.details = details;
1385
+ }
1386
+ };
1387
+ function rethrowAsSkillError(err) {
1388
+ if (err instanceof InstallSourceError && !(err instanceof SkillInstallError)) throw new SkillInstallError(err.code, err.message, err.candidates);
1389
+ throw err;
1390
+ }
1391
+ const SKILLS_SH_URL_RE = /^(?:https?:\/\/)?(?:www\.)?skills\.sh\/([\w.-]+)\/([\w.-]+)(?:\/([\w.-]+))?\/?$/;
1392
+ /**
1393
+ * Pasted install commands, as printed by skill READMEs and skills.sh
1394
+ * pages — `npx skills add owner/repo`, `bunx skills add …`,
1395
+ * `pnpm dlx skills add … --skill x`, or a bare `skills add …`. The
1396
+ * runner + `add` verb are stripped and the remaining tokens re-enter
1397
+ * the normal grammar, so users can copy a whole command into the
1398
+ * install input verbatim.
1399
+ */
1400
+ const SKILLS_CLI_COMMAND_RE = /^(?:(?:npx|bunx|yarn\s+dlx|pnpm\s+dlx)\s+)?skills(?:@\S+)?\s+add\s+(\S.*)$/i;
1401
+ /**
1402
+ * Parse a raw skill source. Accepts everything the shared grammar does
1403
+ * (`owner/repo[#ref][:subdir]`, GitHub URLs, `pkg@version`, …) plus
1404
+ * pasted skills.sh catalog URLs — `skills.sh/vercel-labs/agent-skills/
1405
+ * react-best-practices` maps to the backing GitHub repo with the third
1406
+ * segment as the skill selector.
1407
+ */
1408
+ function parseSkillInstallSpec(raw) {
1409
+ const spec = raw.trim();
1410
+ const cliCommand = SKILLS_CLI_COMMAND_RE.exec(spec);
1411
+ if (cliCommand) return parseCliAddCommand(cliCommand[1]);
1412
+ const skillsSh = SKILLS_SH_URL_RE.exec(spec);
1413
+ if (skillsSh) return {
1414
+ source: {
1415
+ kind: "github",
1416
+ owner: skillsSh[1],
1417
+ repo: skillsSh[2]
1418
+ },
1419
+ ...skillsSh[3] ? { skill: skillsSh[3] } : {},
1420
+ cliSpec: `${skillsSh[1]}/${skillsSh[2]}`
1114
1421
  };
1422
+ try {
1423
+ return {
1424
+ source: parseInstallSource(spec),
1425
+ cliSpec: spec
1426
+ };
1427
+ } catch (err) {
1428
+ rethrowAsSkillError(err);
1429
+ }
1115
1430
  }
1116
1431
  /**
1117
- * Strip outer quotes and decode YAML-style escapes.
1118
- *
1119
- * - Double-quoted values honor `\"`, `\\`, `\n`, `\r`, `\t`, `\b`, `\f`, `\/`.
1120
- * - Single-quoted values honor `''` (the YAML single-quote escape for a literal `'`).
1121
- * - Trailing inline comments on unquoted values (` # comment`) are dropped.
1122
- * - Unquoted values are returned as-is (trimmed by caller).
1123
- *
1124
- * Skill frontmatter in the wild uses a narrow YAML subset — this is
1125
- * sufficient for every field in the current spec and the common authoring
1126
- * tools. Block scalars (`|`, `>`) and flow sequences are intentionally left
1127
- * unsupported: they'd only appear in a bespoke workflow, and silently
1128
- * half-supporting them is worse than rejecting them obviously.
1432
+ * Parse the argument tail of a pasted `skills add …` command. Honors
1433
+ * `--skill <name>` / `--skill=<name>` (their per-skill selector — an
1434
+ * explicit selector in the URL/shorthand form wins over it), ignores
1435
+ * flags that are the dialog's job here (`-y`, `-g`, `--global`, …),
1436
+ * and routes the first non-flag token back through
1437
+ * {@link parseSkillInstallSpec} so URLs and shorthands inside the
1438
+ * command parse identically to a bare paste.
1129
1439
  */
1130
- function unquoteYaml(val) {
1131
- const dq = val.match(DOUBLE_QUOTED_RE);
1132
- if (dq) return dq[1].replace(DQ_ESCAPE_RE, (_, ch) => {
1133
- switch (ch) {
1134
- case "\"": return "\"";
1135
- case "\\": return "\\";
1136
- case "/": return "/";
1137
- case "b": return "\b";
1138
- case "f": return "\f";
1139
- case "n": return "\n";
1140
- case "r": return "\r";
1141
- case "t": return " ";
1142
- default: return ch;
1440
+ function parseCliAddCommand(rest) {
1441
+ const tokens = rest.split(/\s+/).filter((t) => t.length > 0);
1442
+ let sourceToken;
1443
+ let skill;
1444
+ for (let i = 0; i < tokens.length; i++) {
1445
+ const token = tokens[i];
1446
+ if (token === "--skill" || token === "-s") {
1447
+ skill = tokens[i + 1];
1448
+ i += 1;
1449
+ continue;
1143
1450
  }
1144
- });
1145
- const sq = val.match(SINGLE_QUOTED_RE);
1146
- if (sq) return sq[1].replace(/''/g, "'");
1147
- const hashIdx = val.indexOf(" #");
1148
- if (hashIdx >= 0) return val.slice(0, hashIdx).trimEnd();
1149
- return val;
1451
+ const inline = /^--skill=(.+)$/.exec(token);
1452
+ if (inline) {
1453
+ skill = inline[1];
1454
+ continue;
1455
+ }
1456
+ if (token.startsWith("-")) continue;
1457
+ if (sourceToken === void 0) sourceToken = token;
1458
+ }
1459
+ if (sourceToken === void 0) throw new SkillInstallError("invalid-source", "Pasted skills command has no source — expected “skills add <owner/repo>”.");
1460
+ const parsed = parseSkillInstallSpec(sourceToken);
1461
+ return {
1462
+ ...parsed,
1463
+ ...skill !== void 0 && parsed.skill === void 0 ? { skill } : {}
1464
+ };
1150
1465
  }
1151
1466
  /**
1152
- * Narrow a frontmatter field to a string. Produces a diagnostic when present
1153
- * but not a string, so malformed YAML (e.g. `name: 123`) surfaces as a
1154
- * warning instead of silently coerced downstream via `as string`.
1467
+ * How deep the candidate walk descends. Real-world collections nest
1468
+ * skills under category directories (`skills/engineering/tdd/SKILL.md`
1469
+ * in mattpocock/skills; `.github/skills/…` elsewhere) — one-level
1470
+ * enumeration missed all of them. Four levels covers every observed
1471
+ * layout while keeping the walk trivially bounded on the staged tree.
1155
1472
  */
1156
- function takeString(frontmatter, key, diagnostics) {
1157
- const raw = frontmatter[key];
1158
- if (raw === void 0 || raw === null) return void 0;
1159
- if (typeof raw === "string") return raw;
1160
- diagnostics.push({
1161
- severity: "warning",
1162
- code: "invalid-field-type",
1163
- message: `Frontmatter field "${key}" expected string, got ${Array.isArray(raw) ? "array" : typeof raw}. Skipping.`,
1164
- field: key
1165
- });
1166
- }
1473
+ const CANDIDATE_WALK_MAX_DEPTH = 4;
1167
1474
  /**
1168
- * Accept either a YAML scalar (whitespace-separated tokens, the
1169
- * spec-canonical form) OR a YAML block / flow list (`- item` or
1170
- * `[a, b]`). Returns the field as a normalized `string[]`, or
1171
- * `undefined` when the field is absent or empty.
1475
+ * List installable skills inside a fetched tree.
1172
1476
  *
1173
- * Used for list-shaped fields like `allowed-tools` whose authoring
1174
- * surface has always been "either a space-separated string OR a
1175
- * list" in skill files in the wild (Claude Code's docs use both
1176
- * interchangeably). Pre-fix, the parser silently turned the list
1177
- * forms into `[object Object]` / `["[a", "b]"]` and the
1178
- * allowed-tools gate would never match anything — opening it wide.
1477
+ * A skill is ANY directory containing a `SKILL.md` — found by a
1478
+ * bounded recursive walk (depth {@link CANDIDATE_WALK_MAX_DEPTH}),
1479
+ * skipping dot-dirs and `node_modules`, and never descending INTO a
1480
+ * skill (its `references/` / `scripts/` subtree ships with it, it
1481
+ * doesn't nest further candidates). A root-level `SKILL.md` means
1482
+ * "repo IS the skill" and short-circuits, named from the fallback.
1179
1483
  *
1180
- * Diagnostics are pushed for unexpected shapes (number, plain
1181
- * object, etc.) and the field is dropped to avoid silent corruption.
1484
+ * Candidates carry their root-relative path (`rel`) so same-named
1485
+ * skills in different categories stay individually addressable.
1486
+ * Sorted by `rel`.
1182
1487
  */
1183
- function takeStringOrArray(frontmatter, key, diagnostics) {
1184
- const raw = frontmatter[key];
1185
- if (raw === void 0 || raw === null) return void 0;
1186
- if (Array.isArray(raw)) {
1187
- const items = raw.filter((v) => v !== null && v !== void 0).map((v) => typeof v === "string" ? v : String(v)).map((v) => v.trim()).filter((v) => v.length > 0);
1188
- return items.length > 0 ? items : void 0;
1189
- }
1190
- if (typeof raw === "string") {
1191
- const trimmed = raw.trim();
1192
- if (trimmed.length === 0) return void 0;
1193
- const flowMatch = trimmed.match(FLOW_LIST_RE);
1194
- if (flowMatch) return flowMatch[1].split(",").map((s) => s.trim()).filter(Boolean);
1195
- return trimmed.split(WHITESPACE_SPLIT_RE).filter(Boolean);
1196
- }
1197
- diagnostics.push({
1198
- severity: "warning",
1199
- code: "invalid-field-type",
1200
- message: `Frontmatter field "${key}" expected string or list, got ${typeof raw}. Skipping.`,
1201
- field: key
1202
- });
1203
- }
1204
- const RESOURCE_DIRS = {
1205
- scripts: "script",
1206
- references: "reference",
1207
- assets: "asset"
1208
- };
1209
- function enumerateResources(baseDir) {
1210
- const resources = [];
1211
- for (const [dir, type] of Object.entries(RESOURCE_DIRS)) {
1212
- const dirPath = join(baseDir, dir);
1213
- if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) continue;
1214
- try {
1215
- const files = readdirSync(dirPath, { recursive: true });
1216
- for (const file of files) {
1217
- const rel = typeof file === "string" ? file : file.toString("utf-8");
1218
- if (statSync(join(dirPath, rel)).isFile()) resources.push({
1219
- path: join(dir, rel),
1220
- type
1221
- });
1222
- }
1223
- } catch {}
1224
- }
1225
- try {
1226
- for (const entry of readdirSync(baseDir)) {
1227
- if (entry === "SKILL.md") continue;
1228
- if (statSync(join(baseDir, entry)).isFile()) resources.push({
1229
- path: entry,
1230
- type: "other"
1488
+ function listSkillCandidates(dir, fallbackName) {
1489
+ if (existsSync(join(dir, "SKILL.md"))) return [{
1490
+ name: sanitizeName(fallbackName),
1491
+ dir,
1492
+ rel: "."
1493
+ }];
1494
+ const out = [];
1495
+ const walk = (current, rel, depth) => {
1496
+ if (depth > CANDIDATE_WALK_MAX_DEPTH) return;
1497
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
1498
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
1499
+ const childDir = join(current, entry.name);
1500
+ const childRel = rel.length === 0 ? entry.name : `${rel}/${entry.name}`;
1501
+ if (existsSync(join(childDir, "SKILL.md"))) out.push({
1502
+ name: entry.name,
1503
+ dir: childDir,
1504
+ rel: childRel
1231
1505
  });
1506
+ else walk(childDir, childRel, depth + 1);
1232
1507
  }
1233
- } catch {}
1234
- return resources;
1508
+ };
1509
+ walk(dir, "", 1);
1510
+ return out.sort((a, b) => a.rel.localeCompare(b.rel));
1235
1511
  }
1236
1512
  /**
1237
- * Parse a SKILL.md file into a SkillConfig (lenient).
1238
- *
1239
- * Returns `null` only when the skill is fundamentally unusable:
1240
- * - The file is missing
1241
- * - The description is absent (required by spec for disclosure)
1242
- *
1243
- * All other issues are surfaced as `SkillConfig.diagnostics` with severity
1244
- * `warning`. Deprecated top-level fields (`paths`, `model`, `thinking`) are
1245
- * auto-migrated into `metadata['zidane.*']`.
1513
+ * Frontmatter details for a host picker, parallel to the candidate list.
1514
+ * Parsing is best-effort per candidate — a malformed SKILL.md yields a
1515
+ * detail row without a description rather than failing the enumeration.
1246
1516
  */
1247
- async function parseSkillFile(filePath, options = {}) {
1248
- const absPath = resolve(filePath);
1249
- if (!existsSync(absPath)) return null;
1250
- const { frontmatter, body, diagnostics } = parseFrontmatter(readFileSync(absPath, "utf-8"));
1251
- normalizeKeyAliases(frontmatter, diagnostics);
1252
- let description = takeString(frontmatter, "description", diagnostics);
1253
- if (!description && body) {
1254
- const firstParagraph = body.split(PARAGRAPH_SPLIT_RE)[0]?.trim();
1255
- if (firstParagraph) description = firstParagraph;
1256
- }
1257
- if (!description) return null;
1258
- if (description.length > 1024) diagnostics.push({
1259
- severity: "warning",
1260
- code: "description-too-long",
1261
- message: `Description exceeds spec limit of 1024 characters (got ${description.length}). Loading anyway.`,
1262
- field: "description"
1263
- });
1264
- const baseDir = dirname(absPath);
1265
- const dirName = basename(baseDir);
1266
- const frontmatterName = takeString(frontmatter, "name", diagnostics);
1267
- const name = frontmatterName || dirName;
1268
- if (frontmatterName && frontmatterName !== dirName) diagnostics.push({
1269
- severity: "warning",
1270
- code: "name-mismatch-directory",
1271
- message: `Skill name "${frontmatterName}" does not match parent directory "${dirName}". Loading anyway.`,
1272
- field: "name"
1273
- });
1274
- if (name.length > 64) diagnostics.push({
1275
- severity: "warning",
1276
- code: "name-too-long",
1277
- message: `Skill name "${name}" exceeds spec limit of 64 characters. Loading anyway.`,
1278
- field: "name"
1279
- });
1280
- if (!validateSkillName(name)) diagnostics.push({
1281
- severity: "warning",
1282
- code: "invalid-name-format",
1283
- message: `Skill name "${name}" does not match spec format (lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens). Loading anyway.`,
1284
- field: "name"
1285
- });
1286
- const config = {
1287
- name,
1288
- description,
1289
- instructions: body,
1290
- source: options.source ?? "project",
1291
- location: absPath,
1292
- baseDir,
1293
- resources: enumerateResources(baseDir)
1294
- };
1295
- const license = takeString(frontmatter, "license", diagnostics);
1296
- if (license) config.license = license;
1297
- const compatibility = takeString(frontmatter, "compatibility", diagnostics);
1298
- if (compatibility) {
1299
- if (compatibility.length > 500) diagnostics.push({
1300
- severity: "warning",
1301
- code: "compatibility-too-long",
1302
- message: `Compatibility exceeds spec limit of 500 characters (got ${compatibility.length}). Loading anyway.`,
1303
- field: "compatibility"
1304
- });
1305
- config.compatibility = compatibility;
1306
- }
1307
- const metadata = {};
1308
- const rawMetadata = frontmatter.metadata;
1309
- if (rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)) for (const [k, v] of Object.entries(rawMetadata)) {
1310
- if (typeof v !== "string") {
1311
- diagnostics.push({
1312
- severity: "warning",
1313
- code: "invalid-metadata-value",
1314
- message: `Metadata value for "${k}" is not a string; coerced. (Spec requires string values.)`,
1315
- field: "metadata"
1517
+ async function candidateDetails(candidates) {
1518
+ return Promise.all(candidates.map(async (c) => {
1519
+ const description = (await parseSkillFile(join(c.dir, "SKILL.md")).catch(() => null))?.description.trim();
1520
+ return {
1521
+ rel: c.rel,
1522
+ ...description ? { description } : {}
1523
+ };
1524
+ }));
1525
+ }
1526
+ /** Human-readable candidate list for error messages — capped, rel-qualified when ambiguous. */
1527
+ function describeCandidates(candidates) {
1528
+ const nameCounts = /* @__PURE__ */ new Map();
1529
+ for (const c of candidates) nameCounts.set(c.name, (nameCounts.get(c.name) ?? 0) + 1);
1530
+ const labels = candidates.map((c) => (nameCounts.get(c.name) ?? 0) > 1 ? c.rel : c.name);
1531
+ const shown = labels.slice(0, 20);
1532
+ const more = labels.length - shown.length;
1533
+ return shown.join(", ") + (more > 0 ? `, … and ${more} more` : "");
1534
+ }
1535
+ /**
1536
+ * Validate a staged candidate by PARSING its SKILL.md — the same lenient
1537
+ * parser discovery runs, so "installs cleanly" and "discovers cleanly"
1538
+ * are the same predicate. Returns the frontmatter name (which wins over
1539
+ * the directory-derived candidate name for the target folder).
1540
+ */
1541
+ async function validateSkillCandidate(candidate) {
1542
+ const parsed = await parseSkillFile(join(candidate.dir, "SKILL.md"));
1543
+ if (!parsed) throw new SkillInstallError("validate-failed", `${candidate.dir}/SKILL.md is not a valid skill (missing name or description in frontmatter).`);
1544
+ const name = parsed.name;
1545
+ if (!validateSkillName(name)) throw new SkillInstallError("validate-failed", `Skill name "${name}" is invalid (lowercase alphanumerics + single hyphens, max 64 chars).`);
1546
+ return { name };
1547
+ }
1548
+ /**
1549
+ * Install a skill (or one skill out of a multi-skill source).
1550
+ *
1551
+ * Engine selection: targeted installs (an explicit skill selector) go
1552
+ * through the skills.sh CLI when present on PATH (unless
1553
+ * `preferSkillsCli: false`) — keeping their cross-agent layout and
1554
+ * lockfile — else the native fetch pipeline. Selector-less installs are
1555
+ * ALWAYS native: only native enumeration can tell single- from
1556
+ * multi-skill sources, and the host's "import all?" prompt rides on the
1557
+ * `multiple-candidates` error — delegating them to `skills add … -y`
1558
+ * would let the CLI's own multi-skill behavior silently decide instead.
1559
+ * `all: true` batches are native for the same reason (skip-existing +
1560
+ * per-skill provenance are our semantics).
1561
+ */
1562
+ async function installSkillFromSpec(opts) {
1563
+ const which = (opts.io ?? {}).which ?? defaultWhich;
1564
+ const spec = parseSkillInstallSpec(opts.source);
1565
+ const batchMode = opts.all === true || (opts.skills?.length ?? 0) > 0;
1566
+ const skillSelector = opts.skill ?? (batchMode ? void 0 : spec.skill);
1567
+ if (!batchMode && skillSelector !== void 0 && (opts.preferSkillsCli ?? true) && which("skills")) return await installViaSkillsCli(spec, skillSelector, opts);
1568
+ return await installNatively(spec, skillSelector, opts);
1569
+ }
1570
+ /**
1571
+ * Delegate to the skills.sh CLI. Non-interactive by construction: `-y`
1572
+ * skips confirmation prompts, `-g` maps our `user` scope to their
1573
+ * global scope (project scope is their default, keyed off cwd). The CLI
1574
+ * decides the on-disk layout (`.agents/skills/`), which zidane's
1575
+ * discovery already scans.
1576
+ */
1577
+ async function installViaSkillsCli(spec, skillSelector, opts) {
1578
+ const exec = opts.io?.exec ?? defaultExec;
1579
+ const args = [
1580
+ "add",
1581
+ spec.cliSpec,
1582
+ "-y"
1583
+ ];
1584
+ if (skillSelector) args.push("--skill", skillSelector);
1585
+ if (opts.scope === "user") args.push("-g");
1586
+ opts.onProgress?.(`skills add ${spec.cliSpec}${skillSelector ? ` --skill ${skillSelector}` : ""}…`);
1587
+ const res = await exec("skills", args, { cwd: opts.cwd ?? process.cwd() });
1588
+ if (!res.ok) throw new SkillInstallError("cli-failed", `skills.sh CLI failed: ${lastLine(res.stderr) || lastLine(res.stdout) || "unknown error"}`);
1589
+ return {
1590
+ installed: [skillSelector ?? spec.cliSpec],
1591
+ engine: "skills-cli"
1592
+ };
1593
+ }
1594
+ async function installNatively(spec, skillSelector, opts) {
1595
+ const io = opts.io ?? {};
1596
+ const source = spec.source;
1597
+ const stage = mkdtempSync(join(tmpdir(), "zidane-skill-install-"));
1598
+ try {
1599
+ const fetchedInto = join(stage, "fetched");
1600
+ mkdirSync(fetchedInto, { recursive: true });
1601
+ let provenance;
1602
+ try {
1603
+ provenance = (await fetchInstallSource(source, fetchedInto, io, opts.onProgress)).provenance;
1604
+ } catch (err) {
1605
+ rethrowAsSkillError(err);
1606
+ }
1607
+ let candidateRoot = fetchedInto;
1608
+ let fallbackName = source.kind === "github" ? source.repo : source.pkg;
1609
+ if (source.kind === "github" && source.subdir) {
1610
+ const rooted = resolveSubdirRoot(fetchedInto, source.subdir);
1611
+ if (!rooted.ok) {
1612
+ if (rooted.reason === "missing") throw new SkillInstallError("no-skill", `"${source.subdir}" is not a directory in ${source.owner}/${source.repo}.`);
1613
+ throw new SkillInstallError("invalid-source", `Subdirectory "${source.subdir}" escapes the repository root${rooted.reason === "escape-symlink" ? " through a symlink" : ""}.`);
1614
+ }
1615
+ candidateRoot = rooted.dir;
1616
+ fallbackName = basename(resolve(fetchedInto, source.subdir));
1617
+ }
1618
+ const candidates = listSkillCandidates(candidateRoot, fallbackName);
1619
+ if (candidates.length === 0) throw new SkillInstallError("no-skill", `No skill found in ${opts.source} — no SKILL.md anywhere in the tree (searched ${CANDIDATE_WALK_MAX_DEPTH} levels deep).`);
1620
+ let picked;
1621
+ if (!skillSelector && opts.skills && opts.skills.length > 0) {
1622
+ const byRel = /* @__PURE__ */ new Map();
1623
+ for (const sel of opts.skills) {
1624
+ const matches = candidates.filter((c) => c.name === sel || c.rel === sel);
1625
+ if (matches.length === 0) throw new SkillInstallError("no-skill", `No skill named "${sel}" in ${opts.source}. Available: ${describeCandidates(candidates)}`, candidates.map((c) => c.rel));
1626
+ if (matches.length > 1) throw new SkillInstallError("multiple-candidates", `"${sel}" is ambiguous in ${opts.source} — pick a path: ${matches.map((c) => c.rel).join(", ")}`, matches.map((c) => c.rel));
1627
+ const match = matches[0];
1628
+ byRel.set(match.rel, match);
1629
+ }
1630
+ picked = [...byRel.values()];
1631
+ } else if (skillSelector) {
1632
+ const matches = candidates.filter((c) => c.name === skillSelector || c.rel === skillSelector);
1633
+ if (matches.length === 0) throw new SkillInstallError("no-skill", `No skill named "${skillSelector}" in ${opts.source}. Available: ${describeCandidates(candidates)}`, candidates.map((c) => c.rel));
1634
+ if (matches.length > 1) throw new SkillInstallError("multiple-candidates", `"${skillSelector}" is ambiguous in ${opts.source} — pick a path: ${matches.map((c) => c.rel).join(", ")}`, matches.map((c) => c.rel));
1635
+ picked = [matches[0]];
1636
+ } else if (candidates.length > 1 && !opts.all) throw new SkillInstallError("multiple-candidates", `${opts.source} contains ${candidates.length} skills — pick one: ${describeCandidates(candidates)}`, candidates.map((c) => c.rel), await candidateDetails(candidates));
1637
+ else picked = [...candidates];
1638
+ const root = scopeRoot(opts);
1639
+ const installed = [];
1640
+ const skipped = [];
1641
+ const failed = [];
1642
+ let lastDir;
1643
+ const batch = picked.length > 1;
1644
+ const resolved = [];
1645
+ const nameToRel = /* @__PURE__ */ new Map();
1646
+ for (const candidate of picked) try {
1647
+ const { name } = await validateSkillCandidate(candidate);
1648
+ const conflict = nameToRel.get(name);
1649
+ if (conflict !== void 0) {
1650
+ failed.push(candidate.rel);
1651
+ opts.onProgress?.(`skipping "${candidate.rel}" — name "${name}" conflicts with "${conflict}"`);
1652
+ continue;
1653
+ }
1654
+ nameToRel.set(name, candidate.rel);
1655
+ resolved.push({
1656
+ candidate,
1657
+ name
1316
1658
  });
1317
- metadata[k] = String(v);
1318
- continue;
1659
+ } catch (err) {
1660
+ if (!batch) throw err;
1661
+ failed.push(candidate.rel);
1662
+ opts.onProgress?.(`skipping "${candidate.rel}" — ${errorMessage(err)}`);
1319
1663
  }
1320
- metadata[k] = v;
1321
- }
1322
- else if (rawMetadata !== void 0) diagnostics.push({
1323
- severity: "warning",
1324
- code: "invalid-metadata-shape",
1325
- message: `Frontmatter "metadata" expected a map, got ${Array.isArray(rawMetadata) ? "array" : typeof rawMetadata}. Ignored.`,
1326
- field: "metadata"
1327
- });
1328
- const pathsField = takeString(frontmatter, "paths", diagnostics);
1329
- if (pathsField) {
1330
- metadata["zidane.paths"] = pathsField.split(COMMA_OR_SPACE_RE).filter(Boolean).join(",");
1331
- diagnostics.push({
1332
- severity: "warning",
1333
- code: "deprecated-top-level-field",
1334
- message: "`paths` is not a spec field and is deprecated — moved to `metadata[\"zidane.paths\"]`.",
1335
- field: "paths"
1336
- });
1337
- }
1338
- const modelField = takeString(frontmatter, "model", diagnostics);
1339
- if (modelField) {
1340
- metadata["zidane.model"] = modelField;
1341
- diagnostics.push({
1342
- severity: "warning",
1343
- code: "deprecated-top-level-field",
1344
- message: "`model` is not a spec field and is deprecated — moved to `metadata[\"zidane.model\"]`.",
1345
- field: "model"
1346
- });
1347
- }
1348
- const thinkingField = takeString(frontmatter, "thinking", diagnostics);
1349
- const effortField = thinkingField ? void 0 : takeString(frontmatter, "effort", diagnostics);
1350
- const legacyThinking = thinkingField ?? effortField;
1351
- if (legacyThinking) {
1352
- metadata["zidane.thinking"] = legacyThinking;
1353
- diagnostics.push({
1354
- severity: "warning",
1355
- code: "deprecated-top-level-field",
1356
- message: `\`${thinkingField ? "thinking" : "effort"}\` is not a spec field and is deprecated — moved to \`metadata["zidane.thinking"]\`.`,
1357
- field: thinkingField ? "thinking" : "effort"
1664
+ for (const { candidate, name } of resolved) {
1665
+ const targetDir = join(root, name);
1666
+ const backupDir = `${targetDir}.zidane-backup`;
1667
+ if (existsSync(targetDir) && !opts.overwrite) {
1668
+ if (batch) {
1669
+ skipped.push(name);
1670
+ opts.onProgress?.(`skipping "${name}" — already installed`);
1671
+ continue;
1672
+ }
1673
+ throw new SkillInstallError("exists", `Skill "${name}" is already installed at ${targetDir}. Overwrite to replace it.`);
1674
+ }
1675
+ mkdirSync(root, { recursive: true });
1676
+ const hadPrevious = existsSync(targetDir);
1677
+ if (hadPrevious) {
1678
+ rmSync(backupDir, {
1679
+ recursive: true,
1680
+ force: true
1681
+ });
1682
+ renameSync(targetDir, backupDir);
1683
+ }
1684
+ try {
1685
+ cpSync(candidate.dir, targetDir, {
1686
+ recursive: true,
1687
+ dereference: true
1688
+ });
1689
+ writeProvenance(targetDir, {
1690
+ spec: opts.source,
1691
+ ...skillSelector ? { skill: skillSelector } : {},
1692
+ scope: opts.scope,
1693
+ provenance,
1694
+ installedAt: Date.now()
1695
+ });
1696
+ } catch (err) {
1697
+ rmSync(targetDir, {
1698
+ recursive: true,
1699
+ force: true
1700
+ });
1701
+ if (hadPrevious) renameSync(backupDir, targetDir);
1702
+ throw new SkillInstallError("io-failed", `Writing ${targetDir} failed: ${errorMessage(err)}`);
1703
+ }
1704
+ rmSync(backupDir, {
1705
+ recursive: true,
1706
+ force: true
1707
+ });
1708
+ installed.push(name);
1709
+ lastDir = targetDir;
1710
+ if (batch) opts.onProgress?.(`installed "${name}" (${installed.length}/${resolved.length})`);
1711
+ }
1712
+ if (installed.length === 0 && skipped.length > 0) throw new SkillInstallError("exists", `All ${skipped.length} installable skills from ${opts.source} are already installed: ${skipped.join(", ")}. Overwrite to replace them.`);
1713
+ if (installed.length === 0 && failed.length > 0) throw new SkillInstallError("validate-failed", `No skill from ${opts.source} was installable — invalid: ${failed.join(", ")}.`);
1714
+ return {
1715
+ installed,
1716
+ ...skipped.length > 0 ? { skipped } : {},
1717
+ ...failed.length > 0 ? { failed } : {},
1718
+ ...lastDir !== void 0 ? { dir: lastDir } : {},
1719
+ engine: "native",
1720
+ provenance
1721
+ };
1722
+ } finally {
1723
+ rmSync(stage, {
1724
+ recursive: true,
1725
+ force: true
1358
1726
  });
1359
1727
  }
1360
- if (Object.keys(metadata).length > 0) config.metadata = metadata;
1361
- const allowedTools = takeStringOrArray(frontmatter, "allowed-tools", diagnostics);
1362
- if (allowedTools && allowedTools.length > 0) config.allowedTools = allowedTools;
1363
- if (diagnostics.length > 0) config.diagnostics = diagnostics;
1364
- return config;
1365
1728
  }
1366
- const SKIP_DIRS = /* @__PURE__ */ new Set([
1367
- ".git",
1368
- "node_modules",
1369
- ".DS_Store",
1370
- "dist",
1371
- "build"
1372
- ]);
1373
- function findSkillDirs(root, maxDepth = 4, _depth = 0) {
1374
- if (_depth > maxDepth) return [];
1375
- if (!existsSync(root) || !statSync(root).isDirectory()) return [];
1376
- const results = [];
1729
+ /** Provenance sidecar — lets a future update flow replay the recorded spec. */
1730
+ const SKILL_PROVENANCE_FILE = ".zidane-install.json";
1731
+ function writeProvenance(dir, data) {
1732
+ writeFileSync(join(dir, SKILL_PROVENANCE_FILE), `${JSON.stringify(data, null, 2)}\n`);
1733
+ }
1734
+ /** Read a skill directory's install provenance, `null` for hand-authored skills. */
1735
+ function readSkillProvenance(dir) {
1377
1736
  try {
1378
- for (const entry of readdirSync(root)) {
1379
- if (SKIP_DIRS.has(entry)) continue;
1380
- const entryPath = join(root, entry);
1381
- if (!statSync(entryPath).isDirectory()) continue;
1382
- const skillFile = join(entryPath, "SKILL.md");
1383
- if (existsSync(skillFile) && statSync(skillFile).isFile()) results.push(skillFile);
1384
- else results.push(...findSkillDirs(entryPath, maxDepth, _depth + 1));
1385
- }
1386
- } catch {}
1387
- return results;
1737
+ const raw = JSON.parse(readFileSync(join(dir, SKILL_PROVENANCE_FILE), "utf8"));
1738
+ return typeof raw.spec === "string" && raw.spec.length > 0 ? raw : null;
1739
+ } catch {
1740
+ return null;
1741
+ }
1388
1742
  }
1389
1743
  /**
1390
- * Return the default scan paths tagged by source. Project-scope paths come
1391
- * first; their skills therefore win on name collisions against user-scope
1392
- * skills (first-found wins in discovery).
1744
+ * Native-install target root: the `<prefix>/skills` flavor of the
1745
+ * project/user convention (`~/.zidane/skills`, `<gitRoot>/.zidane/skills`).
1746
+ * The `.agents/skills` siblings stay read-only for us — they belong to
1747
+ * the cross-agent ecosystem (and the skills.sh CLI writes there).
1393
1748
  */
1394
- function getDefaultScanPaths() {
1395
- const home = homedir();
1396
- const cwd = process.cwd();
1397
- return [
1398
- {
1399
- path: join(cwd, ".agents", "skills"),
1400
- source: "project"
1401
- },
1402
- {
1403
- path: join(cwd, ".zidane", "skills"),
1404
- source: "project"
1405
- },
1406
- {
1407
- path: join(home, ".agents", "skills"),
1408
- source: "user"
1409
- },
1410
- {
1411
- path: join(home, ".zidane", "skills"),
1412
- source: "user"
1413
- }
1414
- ];
1749
+ function scopeRoot(opts) {
1750
+ const root = projectUserPaths({
1751
+ subPath: "skills",
1752
+ ...opts.cwd ? { cwd: opts.cwd } : {},
1753
+ ...opts.home ? { home: opts.home } : {},
1754
+ ...opts.prefix ? { prefix: opts.prefix } : {}
1755
+ }).find((r) => r.source === opts.scope && r.path.includes(`.${(opts.prefix ?? ".zidane").replace(/^\./, "")}`));
1756
+ if (!root) throw new SkillInstallError("io-failed", `No ${opts.scope} skills root resolved.`);
1757
+ return root.path;
1758
+ }
1759
+ function sanitizeName(raw) {
1760
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
1761
+ return cleaned.length > 0 ? cleaned : "skill";
1415
1762
  }
1763
+ //#endregion
1764
+ //#region src/skills/interpolate.ts
1765
+ /** Regex to match !`command` patterns in skill instructions */
1766
+ const SHELL_INTERPOLATION_RE = /!`([^`]+)`/g;
1416
1767
  /**
1417
- * Infer a source tag for a user-provided scan path.
1418
- * Paths under `$HOME` are treated as 'user'; everything else as 'project'.
1768
+ * Replace every !\`command\` pattern with a static placeholder, without
1769
+ * executing anything. Used when shell interpolation is disabled
1770
+ * (`SkillsConfig.allowShellInterpolation: false`): skill content can be
1771
+ * third-party input, and running its embedded commands on activation
1772
+ * bypasses the allowed-tools gate.
1419
1773
  */
1420
- function inferSource(path) {
1421
- return path.startsWith(homedir()) ? "user" : "project";
1774
+ function stripShellInterpolations(instructions) {
1775
+ return instructions.replace(SHELL_INTERPOLATION_RE, "[shell interpolation disabled]");
1422
1776
  }
1423
1777
  /**
1424
- * Discover skills from sourced filesystem paths.
1425
- * Each path is scanned for subdirectories containing SKILL.md.
1426
- * Earlier paths have higher priority (first-found wins on name collision).
1778
+ * Interpolate shell commands in skill instructions.
1427
1779
  *
1428
- * `signal` is checked between each `parseSkillFile` so a rapid `cwd` switch
1429
- * in the host (TUI / GUI) can stop a long scan without leaking I/O. Aborts
1430
- * reject with the signal's `reason` (or a default `AbortError`) — same shape
1431
- * `AbortSignal.throwIfAborted()` produces in modern runtimes.
1780
+ * Runs each !\`command\` through the execution context and replaces
1781
+ * the placeholder with the command's stdout. If a command fails,
1782
+ * the placeholder is replaced with an error message (and
1783
+ * `options.onFailure` is notified when provided).
1784
+ *
1785
+ * @param instructions - Raw skill instructions with potential !\`command\` patterns
1786
+ * @param execution - The execution context to run commands in
1787
+ * @param handle - The active execution handle
1788
+ * @returns Instructions with all !\`command\` patterns replaced by output
1432
1789
  */
1433
- async function discoverSkills(paths, signal) {
1434
- const skillsByName = /* @__PURE__ */ new Map();
1435
- for (const { path: scanPath, source } of paths) {
1436
- signal?.throwIfAborted?.();
1437
- const skillFiles = findSkillDirs(resolve(scanPath));
1438
- for (const file of skillFiles) {
1439
- signal?.throwIfAborted?.();
1440
- const skill = await parseSkillFile(file, { source });
1441
- if (!skill) continue;
1442
- if (skillsByName.has(skill.name)) {
1443
- const existing = skillsByName.get(skill.name);
1444
- const diag = {
1445
- severity: "warning",
1446
- code: "name-collision-shadowed",
1447
- message: `A skill with name "${skill.name}" was also found at ${file} (source: ${source}); shadowed by ${existing.location} (source: ${existing.source}).`
1448
- };
1449
- existing.diagnostics = [...existing.diagnostics ?? [], diag];
1790
+ async function interpolateShellCommands(instructions, execution, handle, options = {}) {
1791
+ const matches = [...instructions.matchAll(SHELL_INTERPOLATION_RE)];
1792
+ if (matches.length === 0) return instructions;
1793
+ const replacements = [];
1794
+ for (const match of matches) {
1795
+ const command = match[1];
1796
+ const index = match.index;
1797
+ const length = match[0].length;
1798
+ if (options.approve) {
1799
+ let approved = false;
1800
+ try {
1801
+ approved = await options.approve(command);
1802
+ } catch {
1803
+ approved = false;
1804
+ }
1805
+ if (!approved) {
1806
+ const placeholder = "[shell interpolation not approved]";
1807
+ options.onFailure?.(command, placeholder);
1808
+ replacements.push({
1809
+ index,
1810
+ length,
1811
+ output: placeholder
1812
+ });
1450
1813
  continue;
1451
1814
  }
1452
- skillsByName.set(skill.name, skill);
1815
+ }
1816
+ try {
1817
+ const result = await execution.exec(handle, command, { timeout: 30 });
1818
+ if (result.exitCode === 0) replacements.push({
1819
+ index,
1820
+ length,
1821
+ output: result.stdout.trim()
1822
+ });
1823
+ else {
1824
+ const placeholder = `[command failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}]`;
1825
+ options.onFailure?.(command, placeholder);
1826
+ replacements.push({
1827
+ index,
1828
+ length,
1829
+ output: placeholder
1830
+ });
1831
+ }
1832
+ } catch (err) {
1833
+ const placeholder = `[command error: ${errorMessage(err)}]`;
1834
+ options.onFailure?.(command, placeholder);
1835
+ replacements.push({
1836
+ index,
1837
+ length,
1838
+ output: placeholder
1839
+ });
1453
1840
  }
1454
1841
  }
1455
- return [...skillsByName.values()];
1842
+ let result = instructions;
1843
+ for (let i = replacements.length - 1; i >= 0; i--) {
1844
+ const { index, length, output } = replacements[i];
1845
+ result = result.slice(0, index) + output + result.slice(index + length);
1846
+ }
1847
+ return result;
1456
1848
  }
1457
1849
  //#endregion
1458
1850
  //#region src/skills/writer.ts
@@ -1549,7 +1941,19 @@ async function resolveSkills(config) {
1549
1941
  }
1550
1942
  const diskSkills = await discoverSkills(sourcedPaths);
1551
1943
  const skillsByName = /* @__PURE__ */ new Map();
1552
- for (const entry of config.catalog ?? []) skillsByName.set(entry.name, { ...entry });
1944
+ for (const entry of config.catalog ?? []) {
1945
+ const winner = { ...entry };
1946
+ const shadowed = skillsByName.get(entry.name);
1947
+ if (shadowed) {
1948
+ const diag = {
1949
+ severity: "warning",
1950
+ code: "name-collision-shadowed",
1951
+ message: `A skill with name "${entry.name}" was also supplied in the catalog by ${shadowed.location ?? "(catalog)"} (source: ${shadowed.source ?? "inline"}); shadowed by ${winner.location ?? "(catalog)"} (source: ${winner.source ?? "inline"}).`
1952
+ };
1953
+ winner.diagnostics = [...winner.diagnostics ?? [], diag];
1954
+ }
1955
+ skillsByName.set(entry.name, winner);
1956
+ }
1553
1957
  for (const skill of diskSkills) {
1554
1958
  const existing = skillsByName.get(skill.name);
1555
1959
  if (existing) {
@@ -1586,6 +1990,26 @@ async function resolveSkills(config) {
1586
1990
  };
1587
1991
  }
1588
1992
  //#endregion
1589
- export { validateSkillName as A, stripShellInterpolations as C, validateResourcePath as D, parseAllowedToolPattern as E, findGitRoot as M, validateResourcePathReal as O, interpolateShellCommands as S, matchesAllowedTool as T, lastLine as _, getDefaultScanPaths as a, resolveSubdirRoot as b, parseSkillFile as c, installAllowedToolsGate as d, createSkillActivationState as f, fetchInstallSource as g, defaultWhich as h, discoverSkills as i, projectUserPaths as j, validateSkillForWrite as k, buildCatalog as l, defaultExec as m, writeSkillToDisk as n, inferSource as o, InstallSourceError as p, writeSkillsToDisk as r, parseFrontmatter as s, resolveSkills as t, IMPLICITLY_ALLOWED_SKILL_TOOLS as u, parseInstallSource as v, isToolAllowedByUnion as w, escapeXml as x, resolveNpmVersion as y };
1993
+ //#region src/skills/index.ts
1994
+ /**
1995
+ * Define an inline skill configuration.
1996
+ *
1997
+ * Strict-validates at author-time — throws if the config would be rejected by
1998
+ * `writeSkillToDisk` or the spec. Source defaults to `'inline'`.
1999
+ */
2000
+ function defineSkill(config) {
2001
+ const normalized = {
2002
+ ...config,
2003
+ source: config.source ?? "inline"
2004
+ };
2005
+ const result = validateSkillForWrite(normalized);
2006
+ if (!result.valid) {
2007
+ const summary = result.errors.map((e) => ` - [${e.code}] ${e.message}`).join("\n");
2008
+ throw new Error(`Invalid skill "${normalized.name}":\n${summary}`);
2009
+ }
2010
+ return normalized;
2011
+ }
2012
+ //#endregion
2013
+ export { createSkillActivationState as A, inferSource as C, escapeXml as D, buildCatalog as E, validateResourcePathReal as F, validateSkillForWrite as I, validateSkillName as L, matchesAllowedTool as M, parseAllowedToolPattern as N, IMPLICITLY_ALLOWED_SKILL_TOOLS as O, validateResourcePath as P, projectUserPaths as R, getDefaultScanPaths as S, parseSkillFile as T, lastLine as _, interpolateShellCommands as a, resolveSubdirRoot as b, SkillInstallError as c, parseSkillInstallSpec as d, readSkillProvenance as f, fetchInstallSource as g, defaultWhich as h, writeSkillsToDisk as i, isToolAllowedByUnion as j, installAllowedToolsGate as k, installSkillFromSpec as l, defaultExec as m, resolveSkills as n, stripShellInterpolations as o, InstallSourceError as p, writeSkillToDisk as r, SKILL_PROVENANCE_FILE as s, defineSkill as t, listSkillCandidates as u, parseInstallSource as v, parseFrontmatter as w, discoverSkills as x, resolveNpmVersion as y, findGitRoot as z };
1590
2014
 
1591
- //# sourceMappingURL=resolve-CZJxds85.js.map
2015
+ //# sourceMappingURL=skills-Cp9vnBwd.js.map