zidane 4.0.2 → 4.1.4
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 +196 -614
- package/dist/agent-BoV5Twdl.d.ts +2347 -0
- package/dist/agent-BoV5Twdl.d.ts.map +1 -0
- package/dist/contexts-3Arvn7yR.js +321 -0
- package/dist/contexts-3Arvn7yR.js.map +1 -0
- package/dist/contexts.d.ts +2 -25
- package/dist/contexts.js +2 -10
- package/dist/errors-D1lhd6mX.js +118 -0
- package/dist/errors-D1lhd6mX.js.map +1 -0
- package/dist/index-28otmfLX.d.ts +400 -0
- package/dist/index-28otmfLX.d.ts.map +1 -0
- package/dist/index-BfSdALzk.d.ts +113 -0
- package/dist/index-BfSdALzk.d.ts.map +1 -0
- package/dist/index-DPsd0qwm.d.ts +254 -0
- package/dist/index-DPsd0qwm.d.ts.map +1 -0
- package/dist/index.d.ts +5 -95
- package/dist/index.js +141 -271
- package/dist/index.js.map +1 -0
- package/dist/interpolate-CukJwP2G.js +887 -0
- package/dist/interpolate-CukJwP2G.js.map +1 -0
- package/dist/mcp-8wClKY-3.js +771 -0
- package/dist/mcp-8wClKY-3.js.map +1 -0
- package/dist/mcp.d.ts +2 -4
- package/dist/mcp.js +2 -13
- package/dist/messages-z5Pq20p7.js +1020 -0
- package/dist/messages-z5Pq20p7.js.map +1 -0
- package/dist/presets-Cs7_CsMk.js +39 -0
- package/dist/presets-Cs7_CsMk.js.map +1 -0
- package/dist/presets.d.ts +2 -43
- package/dist/presets.js +2 -17
- package/dist/providers-CX-R-Oy-.js +969 -0
- package/dist/providers-CX-R-Oy-.js.map +1 -0
- package/dist/providers.d.ts +2 -4
- package/dist/providers.js +3 -23
- package/dist/session/sqlite.d.ts +7 -12
- package/dist/session/sqlite.d.ts.map +1 -0
- package/dist/session/sqlite.js +67 -79
- package/dist/session/sqlite.js.map +1 -0
- package/dist/session-Cn68UASv.js +440 -0
- package/dist/session-Cn68UASv.js.map +1 -0
- package/dist/session.d.ts +2 -4
- package/dist/session.js +3 -27
- package/dist/skills.d.ts +3 -322
- package/dist/skills.js +24 -47
- package/dist/skills.js.map +1 -0
- package/dist/stats-DoKUtF5T.js +58 -0
- package/dist/stats-DoKUtF5T.js.map +1 -0
- package/dist/tools-DpeWKzP1.js +3941 -0
- package/dist/tools-DpeWKzP1.js.map +1 -0
- package/dist/tools.d.ts +3 -95
- package/dist/tools.js +2 -40
- package/dist/tui.d.ts +533 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +2004 -0
- package/dist/tui.js.map +1 -0
- package/dist/types-Bx_F8jet.js +39 -0
- package/dist/types-Bx_F8jet.js.map +1 -0
- package/dist/types.d.ts +4 -55
- package/dist/types.js +4 -28
- package/package.json +38 -4
- package/dist/agent-BAHrGtqu.d.ts +0 -2425
- package/dist/chunk-4ILGBQ23.js +0 -803
- package/dist/chunk-4LPBN547.js +0 -3540
- package/dist/chunk-64LLNY7F.js +0 -28
- package/dist/chunk-6STZTA4N.js +0 -830
- package/dist/chunk-7GQ7P6DM.js +0 -566
- package/dist/chunk-IC7FT4OD.js +0 -37
- package/dist/chunk-JCOB6IYO.js +0 -22
- package/dist/chunk-JH6IAAFA.js +0 -28
- package/dist/chunk-LNN5UTS2.js +0 -97
- package/dist/chunk-PMCQOMV4.js +0 -490
- package/dist/chunk-UD25QF3H.js +0 -304
- package/dist/chunk-W57VY6DJ.js +0 -834
- package/dist/sandbox-D7v6Wy62.d.ts +0 -28
- package/dist/skills-use-DwZrNmcw.d.ts +0 -80
- package/dist/types-Bai5rKpa.d.ts +0 -89
- package/dist/validation-Pm--dQEU.d.ts +0 -185
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
import { i as AgentToolNotAllowedError } from "./errors-D1lhd6mX.js";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
//#region src/skills/activation.ts
|
|
6
|
+
function createSkillActivationState(options = {}) {
|
|
7
|
+
const byName = /* @__PURE__ */ new Map();
|
|
8
|
+
const maxActive = typeof options.maxActive === "number" && options.maxActive > 0 ? options.maxActive : void 0;
|
|
9
|
+
return {
|
|
10
|
+
active() {
|
|
11
|
+
return [...byName.values()];
|
|
12
|
+
},
|
|
13
|
+
isActive(name) {
|
|
14
|
+
return byName.has(name);
|
|
15
|
+
},
|
|
16
|
+
get(name) {
|
|
17
|
+
return byName.get(name);
|
|
18
|
+
},
|
|
19
|
+
activate(skill, via) {
|
|
20
|
+
if (byName.has(skill.name)) return "already-active";
|
|
21
|
+
if (maxActive !== void 0 && byName.size >= maxActive) return "cap-reached";
|
|
22
|
+
byName.set(skill.name, {
|
|
23
|
+
skill,
|
|
24
|
+
activatedAt: Date.now(),
|
|
25
|
+
activatedVia: via
|
|
26
|
+
});
|
|
27
|
+
return "ok";
|
|
28
|
+
},
|
|
29
|
+
deactivate(name) {
|
|
30
|
+
const existing = byName.get(name);
|
|
31
|
+
if (!existing) return void 0;
|
|
32
|
+
byName.delete(name);
|
|
33
|
+
return existing;
|
|
34
|
+
},
|
|
35
|
+
clear() {
|
|
36
|
+
const snapshot = [...byName.values()];
|
|
37
|
+
byName.clear();
|
|
38
|
+
return snapshot;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/skills/validate.ts
|
|
44
|
+
const NAME_MAX = 64;
|
|
45
|
+
const DESCRIPTION_MAX = 1024;
|
|
46
|
+
const COMPATIBILITY_MAX = 500;
|
|
47
|
+
const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
48
|
+
const CONSECUTIVE_HYPHENS_RE = /--/;
|
|
49
|
+
const ALLOWED_TOOL_PATTERN_RE = /^([\w-]+)(?:\(([^)]*)\))?$/;
|
|
50
|
+
const ABS_WINDOWS_PATH_RE = /^[a-z]:[\\/]/i;
|
|
51
|
+
const PATH_SEPARATOR_RE = /[\\/]/;
|
|
52
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
53
|
+
/**
|
|
54
|
+
* Validate a skill name per the spec:
|
|
55
|
+
* - 1–64 characters
|
|
56
|
+
* - Lowercase alphanumeric + hyphens only
|
|
57
|
+
* - Must not start or end with a hyphen
|
|
58
|
+
* - Must not contain consecutive hyphens
|
|
59
|
+
*
|
|
60
|
+
* The parent-directory match is validated separately (it requires knowing the
|
|
61
|
+
* skill's `location`, which this function does not).
|
|
62
|
+
*/
|
|
63
|
+
function validateSkillName(name) {
|
|
64
|
+
if (typeof name !== "string") return false;
|
|
65
|
+
if (name.length < 1 || name.length > NAME_MAX) return false;
|
|
66
|
+
if (CONSECUTIVE_HYPHENS_RE.test(name)) return false;
|
|
67
|
+
return SKILL_NAME_RE.test(name);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Strict validation for a skill that is about to be authored / written to disk.
|
|
71
|
+
* Rejects anything that would violate the spec. Use the lenient parser path
|
|
72
|
+
* (`parseSkillFile`) for loading third-party skills.
|
|
73
|
+
*/
|
|
74
|
+
function validateSkillForWrite(skill) {
|
|
75
|
+
const errors = [];
|
|
76
|
+
if (!validateSkillName(skill.name)) errors.push({
|
|
77
|
+
code: "invalid-name",
|
|
78
|
+
message: `Skill name "${skill.name}" must be 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens.`,
|
|
79
|
+
field: "name"
|
|
80
|
+
});
|
|
81
|
+
if (skill.location) {
|
|
82
|
+
const dirName = basename(skill.baseDir ?? "");
|
|
83
|
+
if (dirName && dirName !== skill.name) errors.push({
|
|
84
|
+
code: "name-mismatch-directory",
|
|
85
|
+
message: `Skill name "${skill.name}" must match parent directory name "${dirName}".`,
|
|
86
|
+
field: "name"
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (typeof skill.description !== "string" || skill.description.length < 1) errors.push({
|
|
90
|
+
code: "missing-description",
|
|
91
|
+
message: "Skill description is required (non-empty).",
|
|
92
|
+
field: "description"
|
|
93
|
+
});
|
|
94
|
+
else if (skill.description.length > DESCRIPTION_MAX) errors.push({
|
|
95
|
+
code: "description-too-long",
|
|
96
|
+
message: `Skill description must be at most ${DESCRIPTION_MAX} characters (got ${skill.description.length}).`,
|
|
97
|
+
field: "description"
|
|
98
|
+
});
|
|
99
|
+
if (skill.compatibility !== void 0) {
|
|
100
|
+
if (typeof skill.compatibility !== "string" || skill.compatibility.length === 0) errors.push({
|
|
101
|
+
code: "invalid-compatibility",
|
|
102
|
+
message: "Compatibility must be a non-empty string when provided.",
|
|
103
|
+
field: "compatibility"
|
|
104
|
+
});
|
|
105
|
+
else if (skill.compatibility.length > COMPATIBILITY_MAX) errors.push({
|
|
106
|
+
code: "compatibility-too-long",
|
|
107
|
+
message: `Compatibility must be at most ${COMPATIBILITY_MAX} characters (got ${skill.compatibility.length}).`,
|
|
108
|
+
field: "compatibility"
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (skill.metadata) {
|
|
112
|
+
for (const [key, value] of Object.entries(skill.metadata)) if (typeof value !== "string") errors.push({
|
|
113
|
+
code: "invalid-metadata-value",
|
|
114
|
+
message: `Metadata value for "${key}" must be a string (spec: "A map from string keys to string values").`,
|
|
115
|
+
field: "metadata"
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (skill.allowedTools) {
|
|
119
|
+
for (const pattern of skill.allowedTools) if (!ALLOWED_TOOL_PATTERN_RE.test(pattern)) errors.push({
|
|
120
|
+
code: "invalid-allowed-tool-pattern",
|
|
121
|
+
message: `Allowed-tools entry "${pattern}" is not a recognized pattern (expected "ToolName" or "ToolName(arg:*)").`,
|
|
122
|
+
field: "allowed-tools"
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
valid: errors.length === 0,
|
|
127
|
+
errors
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Validate that `relPath` stays inside `baseDir` when resolved.
|
|
132
|
+
*
|
|
133
|
+
* Rejects:
|
|
134
|
+
* - Absolute paths (`/etc/passwd`, `C:\…`)
|
|
135
|
+
* - Parent traversal (`..` segments that escape baseDir)
|
|
136
|
+
* - Null-byte tricks
|
|
137
|
+
*
|
|
138
|
+
* Returns `{ valid: true, absolutePath }` on success or `{ valid: false, error }`
|
|
139
|
+
* with a human-readable reason.
|
|
140
|
+
*/
|
|
141
|
+
function validateResourcePath(relPath, baseDir) {
|
|
142
|
+
if (typeof relPath !== "string" || relPath.length === 0) return {
|
|
143
|
+
valid: false,
|
|
144
|
+
error: "Resource path must be a non-empty string."
|
|
145
|
+
};
|
|
146
|
+
if (relPath.includes("\0")) return {
|
|
147
|
+
valid: false,
|
|
148
|
+
error: "Resource path contains a null byte."
|
|
149
|
+
};
|
|
150
|
+
if (relPath.startsWith("/") || ABS_WINDOWS_PATH_RE.test(relPath)) return {
|
|
151
|
+
valid: false,
|
|
152
|
+
error: `Absolute paths are not allowed ("${relPath}").`
|
|
153
|
+
};
|
|
154
|
+
const segments = [];
|
|
155
|
+
for (const segment of relPath.split(PATH_SEPARATOR_RE)) {
|
|
156
|
+
if (segment === "" || segment === ".") continue;
|
|
157
|
+
if (segment === "..") {
|
|
158
|
+
if (segments.length === 0) return {
|
|
159
|
+
valid: false,
|
|
160
|
+
error: `Resource path "${relPath}" escapes the skill directory.`
|
|
161
|
+
};
|
|
162
|
+
segments.pop();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
segments.push(segment);
|
|
166
|
+
}
|
|
167
|
+
if (segments.length === 0) return {
|
|
168
|
+
valid: false,
|
|
169
|
+
error: "Resource path resolves to the skill root itself."
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
valid: true,
|
|
173
|
+
absolutePath: `${baseDir.replace(TRAILING_SLASHES_RE, "")}/${segments.join("/")}`
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Parse a single `allowed-tools` entry into its tool name + optional argument pattern.
|
|
178
|
+
*
|
|
179
|
+
* Examples:
|
|
180
|
+
* - `Read` → `{ tool: 'Read' }`
|
|
181
|
+
* - `Bash(git:*)` → `{ tool: 'Bash', argPrefix: 'git' }`
|
|
182
|
+
*/
|
|
183
|
+
function parseAllowedToolPattern(entry) {
|
|
184
|
+
const m = entry.trim().match(ALLOWED_TOOL_PATTERN_RE);
|
|
185
|
+
if (!m) return null;
|
|
186
|
+
const tool = m[1];
|
|
187
|
+
const arg = m[2];
|
|
188
|
+
if (!arg) return { tool };
|
|
189
|
+
if (arg.endsWith(":*")) return {
|
|
190
|
+
tool,
|
|
191
|
+
argPrefix: arg.slice(0, -2)
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
tool,
|
|
195
|
+
argPrefix: arg
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Check whether a tool call (identified by its wire/displayName and argument input)
|
|
200
|
+
* matches a skill's allow-list entry.
|
|
201
|
+
*
|
|
202
|
+
* Matching rules (Zidane's interpretation of the spec's unspecified syntax):
|
|
203
|
+
* - Exact match: displayName === pattern (no parens).
|
|
204
|
+
* - Prefix match: for `Tool(arg:*)`, displayName === 'Tool' AND **any** string
|
|
205
|
+
* value in the input object starts with `arg`. This is intentionally
|
|
206
|
+
* permissive: it doesn't depend on a convention about which property carries
|
|
207
|
+
* the "command" (schemas vary across tools), and for the common
|
|
208
|
+
* `shell({ command: 'git …' })` shape it behaves identically to a "primary
|
|
209
|
+
* string" rule.
|
|
210
|
+
* - For tools whose inputs carry no string values, the arg-match returns false.
|
|
211
|
+
*/
|
|
212
|
+
function matchesAllowedTool(displayName, input, pattern) {
|
|
213
|
+
const parsed = parseAllowedToolPattern(pattern);
|
|
214
|
+
if (!parsed) return false;
|
|
215
|
+
if (parsed.tool !== displayName) return false;
|
|
216
|
+
if (parsed.argPrefix === void 0) return true;
|
|
217
|
+
for (const value of Object.values(input)) if (typeof value === "string" && value.startsWith(parsed.argPrefix)) return true;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Test whether a tool call is allowed by the union of `allowedTools` across a set
|
|
222
|
+
* of active skills. Returns `true` when the union is empty (permissive default)
|
|
223
|
+
* OR when any entry matches.
|
|
224
|
+
*/
|
|
225
|
+
function isToolAllowedByUnion(displayName, input, union) {
|
|
226
|
+
if (union.length === 0) return true;
|
|
227
|
+
return union.some((pattern) => matchesAllowedTool(displayName, input, pattern));
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/skills/allowed-tools.ts
|
|
231
|
+
/** Tools that are always allowed regardless of the active skills' allow-list. */
|
|
232
|
+
const IMPLICITLY_ALLOWED_SKILL_TOOLS = [
|
|
233
|
+
"skills_use",
|
|
234
|
+
"skills_read",
|
|
235
|
+
"skills_run_script"
|
|
236
|
+
];
|
|
237
|
+
/**
|
|
238
|
+
* Register `tool:gate` / `mcp:tool:gate` handlers that enforce the union of
|
|
239
|
+
* `allowedTools` across active skills.
|
|
240
|
+
*
|
|
241
|
+
* No-op when no active skill declares an allow-list (permissive default —
|
|
242
|
+
* matches the spec's "experimental" note for `allowed-tools`).
|
|
243
|
+
*
|
|
244
|
+
* Returns an `uninstall` fn. The agent calls this at run end to detach the
|
|
245
|
+
* handlers and prevent cross-run hook leaks.
|
|
246
|
+
*/
|
|
247
|
+
function installAllowedToolsGate(hooks, state) {
|
|
248
|
+
function effectiveUnion() {
|
|
249
|
+
const active = state.active();
|
|
250
|
+
const declared = [];
|
|
251
|
+
for (const record of active) if (record.skill.allowedTools?.length) declared.push(...record.skill.allowedTools);
|
|
252
|
+
return {
|
|
253
|
+
union: declared.length > 0 ? [...declared, ...IMPLICITLY_ALLOWED_SKILL_TOOLS] : [],
|
|
254
|
+
active: active.map((a) => a.skill.name)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function gateHandler(ctx) {
|
|
258
|
+
const { union, active } = effectiveUnion();
|
|
259
|
+
if (union.length === 0) return;
|
|
260
|
+
if (isToolAllowedByUnion(ctx.displayName, ctx.input, union)) return;
|
|
261
|
+
const err = new AgentToolNotAllowedError({
|
|
262
|
+
toolName: ctx.name,
|
|
263
|
+
displayName: ctx.displayName,
|
|
264
|
+
allowedUnion: union,
|
|
265
|
+
activeSkills: active
|
|
266
|
+
});
|
|
267
|
+
ctx.block = true;
|
|
268
|
+
ctx.reason = err.message;
|
|
269
|
+
}
|
|
270
|
+
function mcpGateHandler(ctx) {
|
|
271
|
+
const { union, active } = effectiveUnion();
|
|
272
|
+
if (union.length === 0) return;
|
|
273
|
+
if (isToolAllowedByUnion(ctx.displayName, ctx.input, union)) return;
|
|
274
|
+
const err = new AgentToolNotAllowedError({
|
|
275
|
+
toolName: `mcp_${ctx.server}_${ctx.tool}`,
|
|
276
|
+
displayName: ctx.displayName,
|
|
277
|
+
allowedUnion: union,
|
|
278
|
+
activeSkills: active
|
|
279
|
+
});
|
|
280
|
+
ctx.block = true;
|
|
281
|
+
ctx.reason = err.message;
|
|
282
|
+
}
|
|
283
|
+
const unregisterTool = hooks.hook("tool:gate", gateHandler);
|
|
284
|
+
const unregisterMcp = hooks.hook("mcp:tool:gate", mcpGateHandler);
|
|
285
|
+
return function uninstall() {
|
|
286
|
+
unregisterTool();
|
|
287
|
+
unregisterMcp();
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/xml.ts
|
|
292
|
+
/**
|
|
293
|
+
* Minimal XML helpers used by catalog/result builders that emit
|
|
294
|
+
* model-facing pseudo-XML (skills catalog, searchable_tools, tool_search
|
|
295
|
+
* results, skill_content wrappers).
|
|
296
|
+
*
|
|
297
|
+
* Scope: attribute / text-node escaping only — we do NOT emit a full XML
|
|
298
|
+
* document, the model sees these strings as free-form text. Centralised
|
|
299
|
+
* here so the four near-identical copies in `agent.ts`, `skills/catalog.ts`,
|
|
300
|
+
* `tools/skills-use.ts`, and `tools/tool-search.ts` don't drift.
|
|
301
|
+
*/
|
|
302
|
+
const RE_AMP = /&/g;
|
|
303
|
+
const RE_LT = /</g;
|
|
304
|
+
const RE_GT = />/g;
|
|
305
|
+
const RE_QUOT = /"/g;
|
|
306
|
+
function escapeXml(str) {
|
|
307
|
+
return str.replace(RE_AMP, "&").replace(RE_LT, "<").replace(RE_GT, ">").replace(RE_QUOT, """);
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/skills/catalog.ts
|
|
311
|
+
/**
|
|
312
|
+
* Build the skill catalog XML and behavioral instructions for the system prompt.
|
|
313
|
+
*/
|
|
314
|
+
function buildCatalog(skills, options = {}) {
|
|
315
|
+
if (skills.length === 0) return "";
|
|
316
|
+
const skillsToolRegistered = options.skillsToolRegistered ?? true;
|
|
317
|
+
const readToolName = options.readToolName ?? "read_file";
|
|
318
|
+
const entries = skills.map((skill) => {
|
|
319
|
+
const locationLine = skill.location ? `\n <location>${escapeXml(skill.location)}</location>` : "";
|
|
320
|
+
return ` <skill>
|
|
321
|
+
<name>${escapeXml(skill.name)}</name>
|
|
322
|
+
<description>${escapeXml(skill.description)}</description>${locationLine}
|
|
323
|
+
</skill>`;
|
|
324
|
+
}).join("\n");
|
|
325
|
+
const hasFsSkills = skills.some((s) => s.location);
|
|
326
|
+
const hasInlineSkills = skills.some((s) => !s.location);
|
|
327
|
+
const behavioralParts = [];
|
|
328
|
+
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.");
|
|
329
|
+
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.");
|
|
330
|
+
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.");
|
|
331
|
+
if (hasInlineSkills && !skillsToolRegistered) behavioralParts.push("Skills without a <location> have their instructions included directly in <instructions> tags below.");
|
|
332
|
+
const parts = [
|
|
333
|
+
behavioralParts.join("\n"),
|
|
334
|
+
"",
|
|
335
|
+
"<available_skills>",
|
|
336
|
+
entries,
|
|
337
|
+
"</available_skills>"
|
|
338
|
+
];
|
|
339
|
+
if (hasInlineSkills && !skillsToolRegistered) {
|
|
340
|
+
parts.push("");
|
|
341
|
+
for (const skill of skills) if (!skill.location && skill.instructions) {
|
|
342
|
+
parts.push(`<skill_instructions name="${escapeXml(skill.name)}">`);
|
|
343
|
+
parts.push(skill.instructions);
|
|
344
|
+
if (skill.resources && skill.resources.length > 0) {
|
|
345
|
+
parts.push("");
|
|
346
|
+
parts.push("<skill_resources>");
|
|
347
|
+
for (const res of skill.resources) parts.push(` <file type="${res.type}">${escapeXml(res.path)}</file>`);
|
|
348
|
+
parts.push("</skill_resources>");
|
|
349
|
+
}
|
|
350
|
+
parts.push("</skill_instructions>");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return parts.join("\n");
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/skills/discovery.ts
|
|
357
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
358
|
+
const INDENT_RE = /^[ \t]{2,}/;
|
|
359
|
+
const KV_RE = /^([^:]+):(.*)$/;
|
|
360
|
+
const DOUBLE_QUOTED_RE = /^"((?:\\.|[^"\\])*)"$/;
|
|
361
|
+
const SINGLE_QUOTED_RE = /^'((?:''|[^'])*)'$/;
|
|
362
|
+
const DQ_ESCAPE_RE = /\\(["\\/bfnrt])/g;
|
|
363
|
+
const WHITESPACE_SPLIT_RE = /\s+/;
|
|
364
|
+
const PARAGRAPH_SPLIT_RE = /\n\n/;
|
|
365
|
+
const COMMA_OR_SPACE_RE = /[,\s]+/;
|
|
366
|
+
/**
|
|
367
|
+
* Parse a SKILL.md file into frontmatter + body.
|
|
368
|
+
*
|
|
369
|
+
* Uses a simple regex-based YAML extractor that handles:
|
|
370
|
+
* - Flat key: value pairs
|
|
371
|
+
* - Quoted values
|
|
372
|
+
* - One-level nested maps (for `metadata:`)
|
|
373
|
+
* - Lenient recovery from unquoted-colon values (e.g.
|
|
374
|
+
* `description: Use when: the user asks …`) via a quote-wrap retry.
|
|
375
|
+
*/
|
|
376
|
+
function parseFrontmatter(content) {
|
|
377
|
+
const diagnostics = [];
|
|
378
|
+
const match = content.match(FRONTMATTER_RE);
|
|
379
|
+
if (!match) return {
|
|
380
|
+
frontmatter: {},
|
|
381
|
+
body: content.trim(),
|
|
382
|
+
diagnostics
|
|
383
|
+
};
|
|
384
|
+
const yamlBlock = match[1];
|
|
385
|
+
const body = match[2].trim();
|
|
386
|
+
const frontmatter = {};
|
|
387
|
+
let currentKey = null;
|
|
388
|
+
let currentMap = null;
|
|
389
|
+
for (const line of yamlBlock.split("\n")) {
|
|
390
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
391
|
+
if (currentKey && currentMap && INDENT_RE.test(line)) {
|
|
392
|
+
const nestedMatch = line.trim().match(KV_RE);
|
|
393
|
+
if (nestedMatch) {
|
|
394
|
+
const val = nestedMatch[2].trim();
|
|
395
|
+
currentMap[nestedMatch[1].trim()] = unquoteYaml(val);
|
|
396
|
+
}
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (currentKey && currentMap) {
|
|
400
|
+
frontmatter[currentKey] = currentMap;
|
|
401
|
+
currentKey = null;
|
|
402
|
+
currentMap = null;
|
|
403
|
+
}
|
|
404
|
+
const kvMatch = matchFirstColon(line);
|
|
405
|
+
if (!kvMatch) continue;
|
|
406
|
+
const key = kvMatch.key.trim();
|
|
407
|
+
const rawValue = kvMatch.value.trim();
|
|
408
|
+
if (!rawValue) {
|
|
409
|
+
currentKey = key;
|
|
410
|
+
currentMap = {};
|
|
411
|
+
} else frontmatter[key] = unquoteYaml(rawValue);
|
|
412
|
+
}
|
|
413
|
+
if (currentKey && currentMap) frontmatter[currentKey] = currentMap;
|
|
414
|
+
return {
|
|
415
|
+
frontmatter,
|
|
416
|
+
body,
|
|
417
|
+
diagnostics
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function matchFirstColon(line) {
|
|
421
|
+
const idx = line.indexOf(":");
|
|
422
|
+
if (idx < 0) return null;
|
|
423
|
+
const key = line.slice(0, idx);
|
|
424
|
+
const value = line.slice(idx + 1);
|
|
425
|
+
if (!KV_RE.test(`${key}:`)) return null;
|
|
426
|
+
return {
|
|
427
|
+
key,
|
|
428
|
+
value
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Strip outer quotes and decode YAML-style escapes.
|
|
433
|
+
*
|
|
434
|
+
* - Double-quoted values honor `\"`, `\\`, `\n`, `\r`, `\t`, `\b`, `\f`, `\/`.
|
|
435
|
+
* - Single-quoted values honor `''` (the YAML single-quote escape for a literal `'`).
|
|
436
|
+
* - Trailing inline comments on unquoted values (` # comment`) are dropped.
|
|
437
|
+
* - Unquoted values are returned as-is (trimmed by caller).
|
|
438
|
+
*
|
|
439
|
+
* Skill frontmatter in the wild uses a narrow YAML subset — this is
|
|
440
|
+
* sufficient for every field in the current spec and the common authoring
|
|
441
|
+
* tools. Block scalars (`|`, `>`) and flow sequences are intentionally left
|
|
442
|
+
* unsupported: they'd only appear in a bespoke workflow, and silently
|
|
443
|
+
* half-supporting them is worse than rejecting them obviously.
|
|
444
|
+
*/
|
|
445
|
+
function unquoteYaml(val) {
|
|
446
|
+
const dq = val.match(DOUBLE_QUOTED_RE);
|
|
447
|
+
if (dq) return dq[1].replace(DQ_ESCAPE_RE, (_, ch) => {
|
|
448
|
+
switch (ch) {
|
|
449
|
+
case "\"": return "\"";
|
|
450
|
+
case "\\": return "\\";
|
|
451
|
+
case "/": return "/";
|
|
452
|
+
case "b": return "\b";
|
|
453
|
+
case "f": return "\f";
|
|
454
|
+
case "n": return "\n";
|
|
455
|
+
case "r": return "\r";
|
|
456
|
+
case "t": return " ";
|
|
457
|
+
default: return ch;
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
const sq = val.match(SINGLE_QUOTED_RE);
|
|
461
|
+
if (sq) return sq[1].replace(/''/g, "'");
|
|
462
|
+
const hashIdx = val.indexOf(" #");
|
|
463
|
+
if (hashIdx >= 0) return val.slice(0, hashIdx).trimEnd();
|
|
464
|
+
return val;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Narrow a frontmatter field to a string. Produces a diagnostic when present
|
|
468
|
+
* but not a string, so malformed YAML (e.g. `name: 123`) surfaces as a
|
|
469
|
+
* warning instead of silently coerced downstream via `as string`.
|
|
470
|
+
*/
|
|
471
|
+
function takeString(frontmatter, key, diagnostics) {
|
|
472
|
+
const raw = frontmatter[key];
|
|
473
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
474
|
+
if (typeof raw === "string") return raw;
|
|
475
|
+
diagnostics.push({
|
|
476
|
+
severity: "warning",
|
|
477
|
+
code: "invalid-field-type",
|
|
478
|
+
message: `Frontmatter field "${key}" expected string, got ${typeof raw}. Coerced.`,
|
|
479
|
+
field: key
|
|
480
|
+
});
|
|
481
|
+
return String(raw);
|
|
482
|
+
}
|
|
483
|
+
const RESOURCE_DIRS = {
|
|
484
|
+
scripts: "script",
|
|
485
|
+
references: "reference",
|
|
486
|
+
assets: "asset"
|
|
487
|
+
};
|
|
488
|
+
function enumerateResources(baseDir) {
|
|
489
|
+
const resources = [];
|
|
490
|
+
for (const [dir, type] of Object.entries(RESOURCE_DIRS)) {
|
|
491
|
+
const dirPath = join(baseDir, dir);
|
|
492
|
+
if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) continue;
|
|
493
|
+
try {
|
|
494
|
+
const files = readdirSync(dirPath, { recursive: true });
|
|
495
|
+
for (const file of files) {
|
|
496
|
+
const rel = typeof file === "string" ? file : file.toString("utf-8");
|
|
497
|
+
if (statSync(join(dirPath, rel)).isFile()) resources.push({
|
|
498
|
+
path: join(dir, rel),
|
|
499
|
+
type
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
} catch {}
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
for (const entry of readdirSync(baseDir)) {
|
|
506
|
+
if (entry === "SKILL.md") continue;
|
|
507
|
+
if (statSync(join(baseDir, entry)).isFile()) resources.push({
|
|
508
|
+
path: entry,
|
|
509
|
+
type: "other"
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
} catch {}
|
|
513
|
+
return resources;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Parse a SKILL.md file into a SkillConfig (lenient).
|
|
517
|
+
*
|
|
518
|
+
* Returns `null` only when the skill is fundamentally unusable:
|
|
519
|
+
* - The file is missing
|
|
520
|
+
* - The description is absent (required by spec for disclosure)
|
|
521
|
+
*
|
|
522
|
+
* All other issues are surfaced as `SkillConfig.diagnostics` with severity
|
|
523
|
+
* `warning`. Deprecated top-level fields (`paths`, `model`, `thinking`) are
|
|
524
|
+
* auto-migrated into `metadata['zidane.*']`.
|
|
525
|
+
*/
|
|
526
|
+
async function parseSkillFile(filePath, options = {}) {
|
|
527
|
+
const absPath = resolve(filePath);
|
|
528
|
+
if (!existsSync(absPath)) return null;
|
|
529
|
+
const { frontmatter, body, diagnostics } = parseFrontmatter(readFileSync(absPath, "utf-8"));
|
|
530
|
+
let description = takeString(frontmatter, "description", diagnostics);
|
|
531
|
+
if (!description && body) {
|
|
532
|
+
const firstParagraph = body.split(PARAGRAPH_SPLIT_RE)[0]?.trim();
|
|
533
|
+
if (firstParagraph) description = firstParagraph;
|
|
534
|
+
}
|
|
535
|
+
if (!description) return null;
|
|
536
|
+
if (description.length > 1024) diagnostics.push({
|
|
537
|
+
severity: "warning",
|
|
538
|
+
code: "description-too-long",
|
|
539
|
+
message: `Description exceeds spec limit of 1024 characters (got ${description.length}). Loading anyway.`,
|
|
540
|
+
field: "description"
|
|
541
|
+
});
|
|
542
|
+
const baseDir = dirname(absPath);
|
|
543
|
+
const dirName = basename(baseDir);
|
|
544
|
+
const frontmatterName = takeString(frontmatter, "name", diagnostics);
|
|
545
|
+
const name = frontmatterName || dirName;
|
|
546
|
+
if (frontmatterName && frontmatterName !== dirName) diagnostics.push({
|
|
547
|
+
severity: "warning",
|
|
548
|
+
code: "name-mismatch-directory",
|
|
549
|
+
message: `Skill name "${frontmatterName}" does not match parent directory "${dirName}". Loading anyway.`,
|
|
550
|
+
field: "name"
|
|
551
|
+
});
|
|
552
|
+
if (name.length > 64) diagnostics.push({
|
|
553
|
+
severity: "warning",
|
|
554
|
+
code: "name-too-long",
|
|
555
|
+
message: `Skill name "${name}" exceeds spec limit of 64 characters. Loading anyway.`,
|
|
556
|
+
field: "name"
|
|
557
|
+
});
|
|
558
|
+
if (!validateSkillName(name)) diagnostics.push({
|
|
559
|
+
severity: "warning",
|
|
560
|
+
code: "invalid-name-format",
|
|
561
|
+
message: `Skill name "${name}" does not match spec format (lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens). Loading anyway.`,
|
|
562
|
+
field: "name"
|
|
563
|
+
});
|
|
564
|
+
const config = {
|
|
565
|
+
name,
|
|
566
|
+
description,
|
|
567
|
+
instructions: body,
|
|
568
|
+
source: options.source ?? "project",
|
|
569
|
+
location: absPath,
|
|
570
|
+
baseDir,
|
|
571
|
+
resources: enumerateResources(baseDir)
|
|
572
|
+
};
|
|
573
|
+
const license = takeString(frontmatter, "license", diagnostics);
|
|
574
|
+
if (license) config.license = license;
|
|
575
|
+
const compatibility = takeString(frontmatter, "compatibility", diagnostics);
|
|
576
|
+
if (compatibility) {
|
|
577
|
+
if (compatibility.length > 500) diagnostics.push({
|
|
578
|
+
severity: "warning",
|
|
579
|
+
code: "compatibility-too-long",
|
|
580
|
+
message: `Compatibility exceeds spec limit of 500 characters (got ${compatibility.length}). Loading anyway.`,
|
|
581
|
+
field: "compatibility"
|
|
582
|
+
});
|
|
583
|
+
config.compatibility = compatibility;
|
|
584
|
+
}
|
|
585
|
+
const metadata = {};
|
|
586
|
+
const rawMetadata = frontmatter.metadata;
|
|
587
|
+
if (rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)) for (const [k, v] of Object.entries(rawMetadata)) {
|
|
588
|
+
if (typeof v !== "string") {
|
|
589
|
+
diagnostics.push({
|
|
590
|
+
severity: "warning",
|
|
591
|
+
code: "invalid-metadata-value",
|
|
592
|
+
message: `Metadata value for "${k}" is not a string; coerced. (Spec requires string values.)`,
|
|
593
|
+
field: "metadata"
|
|
594
|
+
});
|
|
595
|
+
metadata[k] = String(v);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
metadata[k] = v;
|
|
599
|
+
}
|
|
600
|
+
else if (rawMetadata !== void 0) diagnostics.push({
|
|
601
|
+
severity: "warning",
|
|
602
|
+
code: "invalid-metadata-shape",
|
|
603
|
+
message: `Frontmatter "metadata" expected a map, got ${Array.isArray(rawMetadata) ? "array" : typeof rawMetadata}. Ignored.`,
|
|
604
|
+
field: "metadata"
|
|
605
|
+
});
|
|
606
|
+
const pathsField = takeString(frontmatter, "paths", diagnostics);
|
|
607
|
+
if (pathsField) {
|
|
608
|
+
metadata["zidane.paths"] = pathsField.split(COMMA_OR_SPACE_RE).filter(Boolean).join(",");
|
|
609
|
+
diagnostics.push({
|
|
610
|
+
severity: "warning",
|
|
611
|
+
code: "deprecated-top-level-field",
|
|
612
|
+
message: "`paths` is not a spec field and is deprecated — moved to `metadata[\"zidane.paths\"]`.",
|
|
613
|
+
field: "paths"
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
const modelField = takeString(frontmatter, "model", diagnostics);
|
|
617
|
+
if (modelField) {
|
|
618
|
+
metadata["zidane.model"] = modelField;
|
|
619
|
+
diagnostics.push({
|
|
620
|
+
severity: "warning",
|
|
621
|
+
code: "deprecated-top-level-field",
|
|
622
|
+
message: "`model` is not a spec field and is deprecated — moved to `metadata[\"zidane.model\"]`.",
|
|
623
|
+
field: "model"
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
const thinkingField = takeString(frontmatter, "thinking", diagnostics);
|
|
627
|
+
const effortField = thinkingField ? void 0 : takeString(frontmatter, "effort", diagnostics);
|
|
628
|
+
const legacyThinking = thinkingField ?? effortField;
|
|
629
|
+
if (legacyThinking) {
|
|
630
|
+
metadata["zidane.thinking"] = legacyThinking;
|
|
631
|
+
diagnostics.push({
|
|
632
|
+
severity: "warning",
|
|
633
|
+
code: "deprecated-top-level-field",
|
|
634
|
+
message: `\`${thinkingField ? "thinking" : "effort"}\` is not a spec field and is deprecated — moved to \`metadata["zidane.thinking"]\`.`,
|
|
635
|
+
field: thinkingField ? "thinking" : "effort"
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (Object.keys(metadata).length > 0) config.metadata = metadata;
|
|
639
|
+
const allowedTools = takeString(frontmatter, "allowed-tools", diagnostics);
|
|
640
|
+
if (allowedTools) config.allowedTools = allowedTools.split(WHITESPACE_SPLIT_RE).filter(Boolean);
|
|
641
|
+
if (diagnostics.length > 0) config.diagnostics = diagnostics;
|
|
642
|
+
return config;
|
|
643
|
+
}
|
|
644
|
+
const SKIP_DIRS = new Set([
|
|
645
|
+
".git",
|
|
646
|
+
"node_modules",
|
|
647
|
+
".DS_Store",
|
|
648
|
+
"dist",
|
|
649
|
+
"build"
|
|
650
|
+
]);
|
|
651
|
+
function findSkillDirs(root, maxDepth = 4, _depth = 0) {
|
|
652
|
+
if (_depth > maxDepth) return [];
|
|
653
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) return [];
|
|
654
|
+
const results = [];
|
|
655
|
+
try {
|
|
656
|
+
for (const entry of readdirSync(root)) {
|
|
657
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
658
|
+
const entryPath = join(root, entry);
|
|
659
|
+
if (!statSync(entryPath).isDirectory()) continue;
|
|
660
|
+
const skillFile = join(entryPath, "SKILL.md");
|
|
661
|
+
if (existsSync(skillFile) && statSync(skillFile).isFile()) results.push(skillFile);
|
|
662
|
+
else results.push(...findSkillDirs(entryPath, maxDepth, _depth + 1));
|
|
663
|
+
}
|
|
664
|
+
} catch {}
|
|
665
|
+
return results;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Return the default scan paths tagged by source. Project-scope paths come
|
|
669
|
+
* first; their skills therefore win on name collisions against user-scope
|
|
670
|
+
* skills (first-found wins in discovery).
|
|
671
|
+
*/
|
|
672
|
+
function getDefaultScanPaths() {
|
|
673
|
+
const home = homedir();
|
|
674
|
+
const cwd = process.cwd();
|
|
675
|
+
return [
|
|
676
|
+
{
|
|
677
|
+
path: join(cwd, ".agents", "skills"),
|
|
678
|
+
source: "project"
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
path: join(cwd, ".zidane", "skills"),
|
|
682
|
+
source: "project"
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
path: join(home, ".agents", "skills"),
|
|
686
|
+
source: "user"
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
path: join(home, ".zidane", "skills"),
|
|
690
|
+
source: "user"
|
|
691
|
+
}
|
|
692
|
+
];
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Infer a source tag for a user-provided scan path.
|
|
696
|
+
* Paths under `$HOME` are treated as 'user'; everything else as 'project'.
|
|
697
|
+
*/
|
|
698
|
+
function inferSource(path) {
|
|
699
|
+
return path.startsWith(homedir()) ? "user" : "project";
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Discover skills from sourced filesystem paths.
|
|
703
|
+
* Each path is scanned for subdirectories containing SKILL.md.
|
|
704
|
+
* Earlier paths have higher priority (first-found wins on name collision).
|
|
705
|
+
*/
|
|
706
|
+
async function discoverSkills(paths) {
|
|
707
|
+
const skillsByName = /* @__PURE__ */ new Map();
|
|
708
|
+
for (const { path: scanPath, source } of paths) {
|
|
709
|
+
const skillFiles = findSkillDirs(resolve(scanPath));
|
|
710
|
+
for (const file of skillFiles) {
|
|
711
|
+
const skill = await parseSkillFile(file, { source });
|
|
712
|
+
if (!skill) continue;
|
|
713
|
+
if (skillsByName.has(skill.name)) {
|
|
714
|
+
const existing = skillsByName.get(skill.name);
|
|
715
|
+
const diag = {
|
|
716
|
+
severity: "warning",
|
|
717
|
+
code: "name-collision-shadowed",
|
|
718
|
+
message: `A skill with name "${skill.name}" was also found at ${file} (source: ${source}); shadowed by ${existing.location} (source: ${existing.source}).`
|
|
719
|
+
};
|
|
720
|
+
existing.diagnostics = [...existing.diagnostics ?? [], diag];
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
skillsByName.set(skill.name, skill);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return [...skillsByName.values()];
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/skills/writer.ts
|
|
730
|
+
const YAML_RESERVED_RE = /[:#&*!|>%@`]/;
|
|
731
|
+
const YAML_EDGE_OR_QUOTE_RE = /^\s|\s$|["']/;
|
|
732
|
+
const DQUOTE_RE = /"/g;
|
|
733
|
+
const LEADING_NEWLINES_RE = /^\n+/;
|
|
734
|
+
function yamlEscape(value) {
|
|
735
|
+
if (YAML_RESERVED_RE.test(value) || YAML_EDGE_OR_QUOTE_RE.test(value) || value === "") return `"${value.replace(DQUOTE_RE, "\\\"")}"`;
|
|
736
|
+
return value;
|
|
737
|
+
}
|
|
738
|
+
function serializeFrontmatter(skill) {
|
|
739
|
+
const lines = ["---"];
|
|
740
|
+
lines.push(`name: ${yamlEscape(skill.name)}`);
|
|
741
|
+
lines.push(`description: ${yamlEscape(skill.description)}`);
|
|
742
|
+
if (skill.license) lines.push(`license: ${yamlEscape(skill.license)}`);
|
|
743
|
+
if (skill.compatibility) lines.push(`compatibility: ${yamlEscape(skill.compatibility)}`);
|
|
744
|
+
if (skill.allowedTools?.length) lines.push(`allowed-tools: ${skill.allowedTools.join(" ")}`);
|
|
745
|
+
if (skill.metadata && Object.keys(skill.metadata).length > 0) {
|
|
746
|
+
lines.push("metadata:");
|
|
747
|
+
for (const [key, value] of Object.entries(skill.metadata)) lines.push(` ${key}: "${value.replace(DQUOTE_RE, "\\\"")}"`);
|
|
748
|
+
}
|
|
749
|
+
lines.push("---");
|
|
750
|
+
return lines.join("\n");
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Write a `SkillConfig` to disk as a proper skill directory with SKILL.md.
|
|
754
|
+
* Returns the path to the written SKILL.md file.
|
|
755
|
+
*
|
|
756
|
+
* Throws if the skill fails `validateSkillForWrite` — the authoring path is
|
|
757
|
+
* strict on purpose. For loading third-party skills, use `parseSkillFile`
|
|
758
|
+
* directly (lenient).
|
|
759
|
+
*/
|
|
760
|
+
function writeSkillToDisk(skill, targetDir) {
|
|
761
|
+
const result = validateSkillForWrite(skill);
|
|
762
|
+
if (!result.valid) {
|
|
763
|
+
const summary = result.errors.map((e) => ` - [${e.code}] ${e.message}`).join("\n");
|
|
764
|
+
throw new Error(`Cannot write invalid skill "${skill.name}":\n${summary}`);
|
|
765
|
+
}
|
|
766
|
+
const skillDir = join(targetDir, skill.name);
|
|
767
|
+
mkdirSync(skillDir, { recursive: true });
|
|
768
|
+
const frontmatter = serializeFrontmatter(skill);
|
|
769
|
+
const bodyTrimmed = skill.instructions ? skill.instructions.replace(LEADING_NEWLINES_RE, "") : "";
|
|
770
|
+
const content = bodyTrimmed ? `${frontmatter}\n\n${bodyTrimmed}\n` : `${frontmatter}\n`;
|
|
771
|
+
const skillPath = join(skillDir, "SKILL.md");
|
|
772
|
+
writeFileSync(skillPath, content);
|
|
773
|
+
return skillPath;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Write multiple `SkillConfig` objects to a target directory.
|
|
777
|
+
* Each skill gets its own subdirectory with a SKILL.md file.
|
|
778
|
+
* Returns the target directory path (for use as a scan path).
|
|
779
|
+
*/
|
|
780
|
+
function writeSkillsToDisk(skills, targetDir) {
|
|
781
|
+
mkdirSync(targetDir, { recursive: true });
|
|
782
|
+
for (const skill of skills) writeSkillToDisk(skill, targetDir);
|
|
783
|
+
return targetDir;
|
|
784
|
+
}
|
|
785
|
+
//#endregion
|
|
786
|
+
//#region src/skills/resolve.ts
|
|
787
|
+
/**
|
|
788
|
+
* Resolve all skills from a SkillsConfig:
|
|
789
|
+
*
|
|
790
|
+
* 1. Materialize `config.write` entries to a temp directory, tagged as `inline`
|
|
791
|
+
* 2. Combine with default + user-provided scan paths (project-first, user-next)
|
|
792
|
+
* 3. Run lenient discovery
|
|
793
|
+
* 4. Apply filters: `exclude`, `enabled` allowlist, optional project-skill trust gate
|
|
794
|
+
*
|
|
795
|
+
* Returns `{ skills, cleanup }` — call `cleanup()` on agent destroy to remove
|
|
796
|
+
* the temp directory created for inline `config.write` skills. The agent
|
|
797
|
+
* factory wires this automatically; standalone callers must invoke it
|
|
798
|
+
* themselves to avoid leaking OS temp.
|
|
799
|
+
*/
|
|
800
|
+
async function resolveSkills(config) {
|
|
801
|
+
const sourcedPaths = [];
|
|
802
|
+
let writeDir;
|
|
803
|
+
if (!config.skipDefaultPaths) sourcedPaths.push(...getDefaultScanPaths());
|
|
804
|
+
for (const p of config.scan ?? []) sourcedPaths.push({
|
|
805
|
+
path: p,
|
|
806
|
+
source: inferSource(p)
|
|
807
|
+
});
|
|
808
|
+
if (config.write?.length) {
|
|
809
|
+
writeDir = mkdtempSync(join(tmpdir(), "zidane-skills-"));
|
|
810
|
+
writeSkillsToDisk(config.write, writeDir);
|
|
811
|
+
sourcedPaths.push({
|
|
812
|
+
path: writeDir,
|
|
813
|
+
source: "inline"
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
let skills = await discoverSkills(sourcedPaths);
|
|
817
|
+
if (config.trustProjectSkills === false) skills = skills.filter((s) => s.source !== void 0 && s.source !== "project");
|
|
818
|
+
const exclude = new Set(config.exclude ?? []);
|
|
819
|
+
let filtered = skills.filter((s) => !exclude.has(s.name));
|
|
820
|
+
if (Array.isArray(config.enabled)) {
|
|
821
|
+
const allowlist = new Set(config.enabled);
|
|
822
|
+
filtered = filtered.filter((s) => allowlist.has(s.name));
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
skills: filtered,
|
|
826
|
+
cleanup: writeDir ? () => {
|
|
827
|
+
try {
|
|
828
|
+
rmSync(writeDir, {
|
|
829
|
+
recursive: true,
|
|
830
|
+
force: true
|
|
831
|
+
});
|
|
832
|
+
} catch {}
|
|
833
|
+
} : () => {}
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/skills/interpolate.ts
|
|
838
|
+
/** Regex to match !`command` patterns in skill instructions */
|
|
839
|
+
const SHELL_INTERPOLATION_RE = /!`([^`]+)`/g;
|
|
840
|
+
/**
|
|
841
|
+
* Interpolate shell commands in skill instructions.
|
|
842
|
+
*
|
|
843
|
+
* Runs each !\`command\` through the execution context and replaces
|
|
844
|
+
* the placeholder with the command's stdout. If a command fails,
|
|
845
|
+
* the placeholder is replaced with an error message.
|
|
846
|
+
*
|
|
847
|
+
* @param instructions - Raw skill instructions with potential !\`command\` patterns
|
|
848
|
+
* @param execution - The execution context to run commands in
|
|
849
|
+
* @param handle - The active execution handle
|
|
850
|
+
* @returns Instructions with all !\`command\` patterns replaced by output
|
|
851
|
+
*/
|
|
852
|
+
async function interpolateShellCommands(instructions, execution, handle) {
|
|
853
|
+
const matches = [...instructions.matchAll(SHELL_INTERPOLATION_RE)];
|
|
854
|
+
if (matches.length === 0) return instructions;
|
|
855
|
+
const replacements = [];
|
|
856
|
+
for (const match of matches) {
|
|
857
|
+
const command = match[1];
|
|
858
|
+
const index = match.index;
|
|
859
|
+
const length = match[0].length;
|
|
860
|
+
try {
|
|
861
|
+
const result = await execution.exec(handle, command, { timeout: 30 });
|
|
862
|
+
const output = result.exitCode === 0 ? result.stdout.trim() : `[command failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}]`;
|
|
863
|
+
replacements.push({
|
|
864
|
+
index,
|
|
865
|
+
length,
|
|
866
|
+
output
|
|
867
|
+
});
|
|
868
|
+
} catch (err) {
|
|
869
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
870
|
+
replacements.push({
|
|
871
|
+
index,
|
|
872
|
+
length,
|
|
873
|
+
output: `[command error: ${message}]`
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
let result = instructions;
|
|
878
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
879
|
+
const { index, length, output } = replacements[i];
|
|
880
|
+
result = result.slice(0, index) + output + result.slice(index + length);
|
|
881
|
+
}
|
|
882
|
+
return result;
|
|
883
|
+
}
|
|
884
|
+
//#endregion
|
|
885
|
+
export { validateResourcePath as _, discoverSkills as a, createSkillActivationState as b, parseFrontmatter as c, escapeXml as d, IMPLICITLY_ALLOWED_SKILL_TOOLS as f, parseAllowedToolPattern as g, matchesAllowedTool as h, writeSkillsToDisk as i, parseSkillFile as l, isToolAllowedByUnion as m, resolveSkills as n, getDefaultScanPaths as o, installAllowedToolsGate as p, writeSkillToDisk as r, inferSource as s, interpolateShellCommands as t, buildCatalog as u, validateSkillForWrite as v, validateSkillName as y };
|
|
886
|
+
|
|
887
|
+
//# sourceMappingURL=interpolate-CukJwP2G.js.map
|