zidane 6.4.3 → 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.
- package/README.md +1 -0
- package/dist/{acp-Kr7LyExq.js → acp-B-AOovHz.js} +3 -3
- package/dist/{acp-Kr7LyExq.js.map → acp-B-AOovHz.js.map} +1 -1
- package/dist/acp-cli.js +3 -2
- package/dist/acp-cli.js.map +1 -1
- package/dist/acp.d.ts +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-CEvzc1Ao.js → agent-SQ6rxfpT.js} +298 -110
- package/dist/agent-SQ6rxfpT.js.map +1 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +2 -2
- package/dist/chat/pure.d.ts +3 -3
- package/dist/chat.d.ts +13 -44
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +5 -4
- package/dist/chat.js.map +1 -1
- package/dist/{context-transport-DbzJctSl.js → context-transport-CDBatvQc.js} +3 -3
- package/dist/{context-transport-DbzJctSl.js.map → context-transport-CDBatvQc.js.map} +1 -1
- package/dist/contexts/daytona.d.ts +1 -1
- package/dist/contexts/e2b.d.ts +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/extensions.d.ts +2 -2
- package/dist/extensions.js +2 -2
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +1 -1
- package/dist/{index-W_8010Lt.d.ts → index-CWBSrqI6.d.ts} +931 -3
- package/dist/index-CWBSrqI6.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/{login-Blkiy-58.js → login-XZDS4K2o.js} +3 -3
- package/dist/{login-Blkiy-58.js.map → login-XZDS4K2o.js.map} +1 -1
- package/dist/{mcp-CDN-m5lp.js → mcp-D27rP47I.js} +87 -6
- package/dist/mcp-D27rP47I.js.map +1 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +3 -2
- package/dist/origin-pinned-fetch-CqG8VZIq.js +159 -0
- package/dist/origin-pinned-fetch-CqG8VZIq.js.map +1 -0
- package/dist/output/stream-json.d.ts +1 -1
- package/dist/output/terminal.d.ts +1 -1
- package/dist/plugins-2PMTVcp-.js +1174 -0
- package/dist/plugins-2PMTVcp-.js.map +1 -0
- package/dist/plugins.d.ts +2 -0
- package/dist/plugins.js +2 -0
- package/dist/presets.d.ts +1 -1
- package/dist/presets.js +1 -1
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/arcee.d.ts +1 -1
- package/dist/providers/baseten.d.ts +1 -1
- package/dist/providers/cerebras.d.ts +1 -1
- package/dist/providers/local.d.ts +1 -1
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/restate.d.ts +52 -2
- package/dist/restate.d.ts.map +1 -1
- package/dist/restate.js +156 -36
- package/dist/restate.js.map +1 -1
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/{resolve-CZJxds85.js → skills-Cp9vnBwd.js} +1430 -1006
- package/dist/skills-Cp9vnBwd.js.map +1 -0
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +1 -2
- package/dist/{stdio-DWQKYKad.js → stdio-O-m9WVkE.js} +2 -2
- package/dist/{stdio-DWQKYKad.js.map → stdio-O-m9WVkE.js.map} +1 -1
- package/dist/{stdio-loader-3uz37d_3.js → stdio-loader-BSGQmG4L.js} +2 -2
- package/dist/{stdio-loader-3uz37d_3.js.map → stdio-loader-BSGQmG4L.js.map} +1 -1
- package/dist/{tool-formatters-bhC2Xp8z.d.ts → tool-formatters-gFqQbzPY.d.ts} +2 -2
- package/dist/{tool-formatters-bhC2Xp8z.d.ts.map → tool-formatters-gFqQbzPY.d.ts.map} +1 -1
- package/dist/{read-state-Dt7UbVsd.js → tool-state-DTs-3ZHH.js} +218 -2
- package/dist/tool-state-DTs-3ZHH.js.map +1 -0
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +2 -2
- package/dist/{transcript-anchors-D7wVCZ2S.js → transcript-anchors-D4IxJifV.js} +42 -5
- package/dist/transcript-anchors-D4IxJifV.js.map +1 -0
- package/dist/{transcript-anchors-mcMLuXM1.d.ts → transcript-anchors-qCs3wZpu.d.ts} +98 -92
- package/dist/transcript-anchors-qCs3wZpu.d.ts.map +1 -0
- package/dist/tui.d.ts +38 -4
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +893 -148
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-B5vVcG0Z.d.ts → turn-operations-M9-jUyDd.d.ts} +3 -3
- package/dist/{turn-operations-B5vVcG0Z.d.ts.map → turn-operations-M9-jUyDd.d.ts.map} +1 -1
- package/dist/types-BIarq1qE.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/PLUGINS.md +340 -0
- package/docs/RESTATE.md +133 -3
- package/docs/SKILL.md +24 -8
- package/package.json +6 -1
- package/dist/agent-CEvzc1Ao.js.map +0 -1
- package/dist/index-W_8010Lt.d.ts.map +0 -1
- package/dist/mcp-CDN-m5lp.js.map +0 -1
- package/dist/read-state-Dt7UbVsd.js.map +0 -1
- package/dist/resolve-CZJxds85.js.map +0 -1
- package/dist/skills-Dd6XCi4s.js +0 -420
- package/dist/skills-Dd6XCi4s.js.map +0 -1
- package/dist/transcript-anchors-D7wVCZ2S.js.map +0 -1
- package/dist/transcript-anchors-mcMLuXM1.d.ts.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/
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
*
|
|
375
|
+
* Register `tool:gate` / `mcp:tool:gate` handlers that enforce the union of
|
|
376
|
+
* `allowedTools` across active skills.
|
|
344
377
|
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
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
|
-
*
|
|
351
|
-
*
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
const {
|
|
410
|
-
|
|
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
|
-
|
|
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/
|
|
449
|
+
//#region src/skills/catalog.ts
|
|
435
450
|
/**
|
|
436
|
-
*
|
|
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
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
|
|
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
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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
|
-
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
634
|
-
*
|
|
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
|
-
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
*
|
|
688
|
-
*
|
|
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
|
|
695
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|
1009
|
+
return [...skillsByName.values()];
|
|
763
1010
|
}
|
|
764
|
-
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/install/source.ts
|
|
765
1013
|
/**
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
* (
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
*
|
|
841
|
-
* `allowedTools` across active skills.
|
|
1066
|
+
* Classify a raw source string:
|
|
842
1067
|
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
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
|
-
*
|
|
847
|
-
*
|
|
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
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
856
|
-
|
|
1104
|
+
kind: "npm",
|
|
1105
|
+
pkg: m[1],
|
|
1106
|
+
...m[2] ? { version: m[2] } : {}
|
|
857
1107
|
};
|
|
858
1108
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
*
|
|
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
|
|
900
|
-
|
|
901
|
-
const
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
*
|
|
974
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
* -
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
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
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
1106
|
-
const
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
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
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
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
|
-
*
|
|
1181
|
-
*
|
|
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
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
}
|
|
1234
|
-
|
|
1508
|
+
};
|
|
1509
|
+
walk(dir, "", 1);
|
|
1510
|
+
return out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
1235
1511
|
}
|
|
1236
1512
|
/**
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
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
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
});
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
});
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
const
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
if (!batch) throw err;
|
|
1661
|
+
failed.push(candidate.rel);
|
|
1662
|
+
opts.onProgress?.(`skipping "${candidate.rel}" — ${errorMessage(err)}`);
|
|
1319
1663
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
* skills
|
|
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
|
|
1395
|
-
const
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
{
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
-
*
|
|
1418
|
-
*
|
|
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
|
|
1421
|
-
return
|
|
1774
|
+
function stripShellInterpolations(instructions) {
|
|
1775
|
+
return instructions.replace(SHELL_INTERPOLATION_RE, "[shell interpolation disabled]");
|
|
1422
1776
|
}
|
|
1423
1777
|
/**
|
|
1424
|
-
*
|
|
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
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1431
|
-
* `
|
|
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
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? [])
|
|
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
|
-
|
|
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=
|
|
2015
|
+
//# sourceMappingURL=skills-Cp9vnBwd.js.map
|