@zaganjade/pi-multi-skill 1.3.1 → 1.3.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/src/build.ts CHANGED
@@ -1,270 +1,395 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { homedir } from "node:os";
4
- import type { BuildOptions, EnrichedSkillInfo, LoadMode } from "./types.ts";
5
- import { stripFrontmatter } from "./metadata.ts";
6
- import { buildParallelDispatchBlock } from "./subagents.ts";
7
-
8
- const SUBAGENT_STOP = "<SUBAGENT-STOP>";
9
- const SKILL_CHECK_MARKERS = [
10
- "If you think there is even a 1% chance a skill might apply",
11
- "Invoke relevant or requested skills BEFORE any response",
12
- ];
13
-
14
- function getAgentDir(): string {
15
- return join(homedir(), ".pi", "agent");
16
- }
17
-
18
- function readSkillFile(skill: EnrichedSkillInfo, cwd: string): string | null {
19
- try {
20
- return readFileSync(skill.filePath, "utf-8");
21
- } catch {
22
- // fall through
23
- }
24
-
25
- const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
26
- for (const dir of dirs) {
27
- for (const candidate of [
28
- join(dir, skill.name, "SKILL.md"),
29
- join(dir, `${skill.name}.md`),
30
- ]) {
31
- try {
32
- return readFileSync(candidate, "utf-8");
33
- } catch {
34
- // continue
35
- }
36
- }
37
- }
38
- return null;
39
- }
40
-
41
- function extractCommandsSection(body: string): string {
42
- const match = body.match(
43
- /## Available Commands[\s\S]*?(?=\n## |\n# |$)/i,
44
- );
45
- return match ? match[0].trim() : "";
46
- }
47
-
48
- function formatMetaBlock(skill: EnrichedSkillInfo): string {
49
- const commands = skill.metadata.commands.length
50
- ? `\n\nCommands: ${skill.metadata.commands.join(", ")}`
51
- : "";
52
- const commandsSection = extractCommandsSection(skill.body);
53
- const sectionText = commandsSection ? `\n\n${commandsSection}` : "";
54
-
55
- return `<skill name="${skill.name}" location="${skill.filePath}" mode="meta">
56
- ${skill.metadata.description}${commands}${sectionText}
57
- </skill>`;
58
- }
59
-
60
- function formatLazyBlock(skill: EnrichedSkillInfo, body: string): string {
61
- const baseDir = dirname(skill.filePath);
62
- const intro = body
63
- .split("\n")
64
- .filter((l) => l.trim() && !l.startsWith("#"))
65
- .slice(0, 8)
66
- .join("\n");
67
-
68
- return `<skill name="${skill.name}" location="${skill.filePath}" mode="lazy">
69
- References are relative to ${baseDir}. Load reference files with \`read\` only when the active workflow step requires them.
70
-
71
- ${intro}
72
-
73
- ${extractCommandsSection(body)}
74
- </skill>`;
75
- }
76
-
77
- function formatFullBlock(skill: EnrichedSkillInfo, body: string): string {
78
- const baseDir = dirname(skill.filePath);
79
- return `<skill name="${skill.name}" location="${skill.filePath}" mode="full">
80
- References are relative to ${baseDir}.
81
-
82
- ${body}
83
- </skill>`;
84
- }
85
-
86
- function dedupeBody(name: string, body: string, seen: Set<string>): string {
87
- let result = body;
88
-
89
- if (body.includes(SUBAGENT_STOP)) {
90
- if (seen.has(SUBAGENT_STOP)) {
91
- result = result.replace(
92
- new RegExp(`${SUBAGENT_STOP}[\\s\\S]*?${SUBAGENT_STOP}`, "g"),
93
- "",
94
- );
95
- } else {
96
- seen.add(SUBAGENT_STOP);
97
- }
98
- }
99
-
100
- if (name !== "using-superpowers") {
101
- for (const marker of SKILL_CHECK_MARKERS) {
102
- if (result.includes(marker) && seen.has(marker)) {
103
- result = result.replace(
104
- new RegExp(`## Instruction Priority[\\s\\S]*?(?=\\n## |$)`, "i"),
105
- "",
106
- );
107
- }
108
- if (result.includes(marker)) seen.add(marker);
109
- }
110
- }
111
-
112
- return result.trim();
113
- }
114
-
115
- function renderSkillBlock(
116
- skill: EnrichedSkillInfo,
117
- cwd: string,
118
- mode: LoadMode,
119
- seenDedup: Set<string>,
120
- ): string | null {
121
- const effectiveMode = skill.metadata.tokenBudget ?? mode;
122
- const raw = readSkillFile(skill, cwd);
123
- if (!raw) return null;
124
-
125
- const body = dedupeBody(skill.name, stripFrontmatter(raw).trim(), seenDedup);
126
- switch (effectiveMode) {
127
- case "meta":
128
- return formatMetaBlock(skill);
129
- case "lazy":
130
- return formatLazyBlock(skill, body);
131
- default:
132
- return formatFullBlock(skill, body);
133
- }
134
- }
135
-
136
- export function buildCombinedMessage(
137
- skills: EnrichedSkillInfo[],
138
- cwd: string,
139
- options: BuildOptions,
140
- ): { message: string; notFound: string[]; skippedDuplicates: string[] } {
141
- const expandedBlocks: string[] = [];
142
- const notFound: string[] = [];
143
- const seenDedup = new Set<string>();
144
- const skippedDuplicates: string[] = [];
145
-
146
- for (const skill of skills) {
147
- const before = seenDedup.size;
148
- const block = renderSkillBlock(skill, cwd, options.mode, seenDedup);
149
- if (!block) {
150
- notFound.push(skill.name);
151
- continue;
152
- }
153
- if (seenDedup.size > before && before > 0) {
154
- skippedDuplicates.push(`${skill.name} (deduplicated sections)`);
155
- }
156
- expandedBlocks.push(block);
157
- }
158
-
159
- const skillNames = skills.map((s) => s.name).join(", ");
160
- const bundleAttr =
161
- options.bundles && options.bundles.length > 0
162
- ? ` bundles="${options.bundles.map((b) => `@${b}`).join(",")}"`
163
- : "";
164
- const parts: string[] = [
165
- `<manually_attached_skills count="${skills.length}"${bundleAttr}>`,
166
- "The user activated multiple skills. Follow ALL of them before responding.",
167
- "Priority: process skills planning → implementation. User instructions override conflicts.",
168
- `Skills: ${skillNames}`,
169
- `Load mode: ${options.mode}`,
170
- ];
171
-
172
- if (options.parallel) {
173
- const tasks =
174
- options.parallelTasks && options.parallelTasks.length > 0
175
- ? options.parallelTasks
176
- : options.instructions
177
- ? [options.instructions]
178
- : [];
179
- parts.push(
180
- buildParallelDispatchBlock({
181
- tasks,
182
- subagentAvailable: options.subagentAvailable ?? false,
183
- }),
184
- );
185
- }
186
-
187
- parts.push("");
188
- parts.push(...expandedBlocks);
189
-
190
- if (options.bmadStatusBlock) {
191
- parts.push("");
192
- parts.push(options.bmadStatusBlock);
193
- }
194
-
195
- if (options.embeddedCommand) {
196
- parts.push("");
197
- parts.push(
198
- `<embedded_command>${options.embeddedCommand}</embedded_command>`,
199
- );
200
- parts.push(
201
- "Execute the embedded command workflow as part of fulfilling the user request.",
202
- );
203
- }
204
-
205
- if (options.instructions) {
206
- parts.push("");
207
- parts.push("<user_query>");
208
- parts.push(options.instructions);
209
- parts.push("</user_query>");
210
- }
211
-
212
- if (notFound.length > 0) {
213
- parts.push("");
214
- parts.push(`> ⚠️ Skills not found: ${notFound.join(", ")}`);
215
- }
216
-
217
- if (options.conflictWarnings?.length) {
218
- parts.push("");
219
- parts.push(`> ⚠️ Conflicts: ${options.conflictWarnings.join("; ")}`);
220
- }
221
-
222
- if (options.skippedDuplicates?.length) {
223
- parts.push("");
224
- parts.push(
225
- `> ℹ️ Deduplicated: ${options.skippedDuplicates.join(", ")}`,
226
- );
227
- }
228
-
229
- parts.push("</manually_attached_skills>");
230
-
231
- return {
232
- message: parts.join("\n"),
233
- notFound,
234
- skippedDuplicates,
235
- };
236
- }
237
-
238
- export function resolveAndReadLegacySkill(
239
- skillName: string,
240
- filePath: string,
241
- cwd: string,
242
- ): string | null {
243
- if (existsSync(filePath)) {
244
- try {
245
- const content = readFileSync(filePath, "utf-8");
246
- const body = stripFrontmatter(content).trim();
247
- const baseDir = dirname(filePath);
248
- return `<skill name="${skillName}" location="${filePath}">\nReferences are relative to ${baseDir}.\n\n${body}\n</skill>`;
249
- } catch {
250
- // fall through
251
- }
252
- }
253
-
254
- const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
255
- for (const dir of dirs) {
256
- for (const candidate of [
257
- join(dir, skillName, "SKILL.md"),
258
- join(dir, `${skillName}.md`),
259
- ]) {
260
- try {
261
- const content = readFileSync(candidate, "utf-8");
262
- const body = stripFrontmatter(content).trim();
263
- return `<skill name="${skillName}" location="${candidate}">\nReferences are relative to ${dirname(candidate)}.\n\n${body}\n</skill>`;
264
- } catch {
265
- // continue
266
- }
267
- }
268
- }
269
- return null;
270
- }
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import type { BuildOptions, EnrichedSkillInfo, LoadMode } from "./types.ts";
5
+ import { stripFrontmatter } from "./metadata.ts";
6
+ import { buildParallelDispatchBlock } from "./subagents.ts";
7
+
8
+ const SUBAGENT_STOP = "<SUBAGENT-STOP>";
9
+ const MULTI_SKILL_LOCATION = "pi-multi-skill";
10
+
11
+ /**
12
+ * Closing tag for INNER per-skill blocks inside a multi-skill payload.
13
+ *
14
+ * MUST NOT be `</skill>`. Pi's display parser (`parseSkillBlock`) uses a
15
+ * non-greedy regex that cannot distinguish a nested `</skill>` from the outer
16
+ * envelope's own closing tag. With nested `</skill>`, the parser split at the
17
+ * last inner `</skill>` and dumped every trailing section (`<user_query>`,
18
+ * `</manually_attached_skills>`, the outer `</skill>`) into the rendered
19
+ * `userMessage` — appearing as raw tags in the chat. Using a non-colliding
20
+ * closer keeps the outer envelope parseable so those sections stay inside the
21
+ * collapsed content.
22
+ *
23
+ * The opening tag stays `<skill name="…" location="…">` so pi-usage can still
24
+ * attribute each skill individually via `/<skill\s+name="([^"]+)"/g`; pi-usage
25
+ * only reads openings, so a non-colliding closer is safe. `</skill-block>` is
26
+ * chosen because it contains neither `</skill>` nor `<skill ` (with a space),
27
+ * so it cannot match either parser's regex.
28
+ */
29
+ const INNER_SKILL_CLOSE = "</skill-block>";
30
+ const SKILL_CHECK_MARKERS = [
31
+ "If you think there is even a 1% chance a skill might apply",
32
+ "Invoke relevant or requested skills BEFORE any response",
33
+ ];
34
+
35
+ /** Pi TUI collapses user messages that match this envelope (see pi-coding-agent parseSkillBlock). */
36
+ const PI_SKILL_BLOCK_RE =
37
+ /^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/;
38
+
39
+ function getAgentDir(): string {
40
+ return join(homedir(), ".pi", "agent");
41
+ }
42
+
43
+ function escapeXml(text: string): string {
44
+ return text
45
+ .replace(/&/g, "&amp;")
46
+ .replace(/"/g, "&quot;")
47
+ .replace(/</g, "&lt;");
48
+ }
49
+
50
+ /** Wrap content in Pi's native skill block so the TUI shows `[skill] name`. */
51
+ export function formatPiSkillBlock(
52
+ name: string,
53
+ location: string,
54
+ content: string,
55
+ ): string {
56
+ return `<skill name="${escapeXml(name)}" location="${escapeXml(location)}">\n${content}\n</skill>`;
57
+ }
58
+
59
+ /**
60
+ * Wrap a single skill's content for inclusion INSIDE a multi-skill payload.
61
+ *
62
+ * Uses a non-colliding closer (see INNER_SKILL_CLOSE) so the outer native skill
63
+ * block parses cleanly with no tag leakage. The opening stays
64
+ * `<skill name="…">` to preserve pi-usage per-skill attribution.
65
+ */
66
+ function formatInnerSkillItem(
67
+ name: string,
68
+ location: string,
69
+ content: string,
70
+ ): string {
71
+ return `<skill name="${escapeXml(name)}" location="${escapeXml(location)}">\n${content}\n${INNER_SKILL_CLOSE}`;
72
+ }
73
+
74
+ export function isPiSkillBlock(text: string): boolean {
75
+ return PI_SKILL_BLOCK_RE.test(text);
76
+ }
77
+
78
+ function formatCollapsedSkillName(skills: EnrichedSkillInfo[]): string {
79
+ const names = skills.map((s) => s.name);
80
+ if (names.length === 1) return names[0];
81
+ if (names.length <= 3) return names.join(", ");
82
+ return `${names.slice(0, 2).join(", ")} +${names.length - 2} more`;
83
+ }
84
+
85
+ function readSkillFile(skill: EnrichedSkillInfo, cwd: string): string | null {
86
+ try {
87
+ return readFileSync(skill.filePath, "utf-8");
88
+ } catch {
89
+ // fall through
90
+ }
91
+
92
+ const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
93
+ for (const dir of dirs) {
94
+ for (const candidate of [
95
+ join(dir, skill.name, "SKILL.md"),
96
+ join(dir, `${skill.name}.md`),
97
+ ]) {
98
+ try {
99
+ return readFileSync(candidate, "utf-8");
100
+ } catch {
101
+ // continue
102
+ }
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ function extractCommandsSection(body: string): string {
109
+ const match = body.match(
110
+ /## Available Commands[\s\S]*?(?=\n## |\n# |$)/i,
111
+ );
112
+ return match ? match[0].trim() : "";
113
+ }
114
+
115
+ function formatMetaBody(skill: EnrichedSkillInfo): string {
116
+ const commands = skill.metadata.commands.length
117
+ ? `\n\nCommands: ${skill.metadata.commands.join(", ")}`
118
+ : "";
119
+ const commandsSection = extractCommandsSection(skill.body);
120
+ const sectionText = commandsSection ? `\n\n${commandsSection}` : "";
121
+
122
+ return `(load mode: meta)\n\n${skill.metadata.description}${commands}${sectionText}`;
123
+ }
124
+
125
+ function formatLazyBody(skill: EnrichedSkillInfo, body: string): string {
126
+ const baseDir = dirname(skill.filePath);
127
+ const intro = body
128
+ .split("\n")
129
+ .filter((l) => l.trim() && !l.startsWith("#"))
130
+ .slice(0, 8)
131
+ .join("\n");
132
+
133
+ return [
134
+ `(load mode: lazy)`,
135
+ `References are relative to ${baseDir}. Load reference files with \`read\` only when the active workflow step requires them.`,
136
+ "",
137
+ intro,
138
+ extractCommandsSection(body),
139
+ ]
140
+ .filter(Boolean)
141
+ .join("\n");
142
+ }
143
+
144
+ function formatFullBody(skill: EnrichedSkillInfo, body: string): string {
145
+ const baseDir = dirname(skill.filePath);
146
+ return `References are relative to ${baseDir}.\n\n${body}`;
147
+ }
148
+
149
+ function dedupeBody(name: string, body: string, seen: Set<string>): string {
150
+ let result = body;
151
+
152
+ if (body.includes(SUBAGENT_STOP)) {
153
+ if (seen.has(SUBAGENT_STOP)) {
154
+ result = result.replace(
155
+ new RegExp(`${SUBAGENT_STOP}[\\s\\S]*?${SUBAGENT_STOP}`, "g"),
156
+ "",
157
+ );
158
+ } else {
159
+ seen.add(SUBAGENT_STOP);
160
+ }
161
+ }
162
+
163
+ if (name !== "using-superpowers") {
164
+ for (const marker of SKILL_CHECK_MARKERS) {
165
+ if (result.includes(marker) && seen.has(marker)) {
166
+ result = result.replace(
167
+ new RegExp(`## Instruction Priority[\\s\\S]*?(?=\\n## |$)`, "i"),
168
+ "",
169
+ );
170
+ }
171
+ if (result.includes(marker)) seen.add(marker);
172
+ }
173
+ }
174
+
175
+ return result.trim();
176
+ }
177
+
178
+ function renderSkillBlock(
179
+ skill: EnrichedSkillInfo,
180
+ cwd: string,
181
+ mode: LoadMode,
182
+ seenDedup: Set<string>,
183
+ ): string | null {
184
+ const effectiveMode = skill.metadata.tokenBudget ?? mode;
185
+ const raw = readSkillFile(skill, cwd);
186
+ if (!raw) return null;
187
+
188
+ const body = dedupeBody(skill.name, stripFrontmatter(raw).trim(), seenDedup);
189
+ let content: string;
190
+ switch (effectiveMode) {
191
+ case "meta":
192
+ content = formatMetaBody(skill);
193
+ break;
194
+ case "lazy":
195
+ content = formatLazyBody(skill, body);
196
+ break;
197
+ default:
198
+ content = formatFullBody(skill, body);
199
+ }
200
+
201
+ return formatInnerSkillItem(skill.name, skill.filePath, content);
202
+ }
203
+
204
+ function buildAgentPayload(
205
+ skills: EnrichedSkillInfo[],
206
+ expandedBlocks: string[],
207
+ options: BuildOptions,
208
+ notFound: string[],
209
+ skippedDuplicates: string[],
210
+ ): string {
211
+ const skillNames = skills.map((s) => s.name).join(", ");
212
+ const bundleAttr =
213
+ options.bundles && options.bundles.length > 0
214
+ ? ` bundles="${options.bundles.map((b) => `@${b}`).join(",")}"`
215
+ : "";
216
+ const parts: string[] = [
217
+ `<manually_attached_skills count="${skills.length}"${bundleAttr}>`,
218
+ "The user activated multiple skills. Follow ALL of them before responding.",
219
+ "Priority: process skills → planning → implementation. User instructions override conflicts.",
220
+ `Skills: ${skillNames}`,
221
+ `Load mode: ${options.mode}`,
222
+ ];
223
+
224
+ if (options.parallel) {
225
+ const tasks =
226
+ options.parallelTasks && options.parallelTasks.length > 0
227
+ ? options.parallelTasks
228
+ : options.instructions
229
+ ? [options.instructions]
230
+ : [];
231
+ parts.push(
232
+ buildParallelDispatchBlock({
233
+ tasks,
234
+ subagentAvailable: options.subagentAvailable ?? false,
235
+ }),
236
+ );
237
+ }
238
+
239
+ parts.push("");
240
+ parts.push(...expandedBlocks);
241
+
242
+ if (options.bmadStatusBlock) {
243
+ parts.push("");
244
+ parts.push(options.bmadStatusBlock);
245
+ }
246
+
247
+ if (options.embeddedCommand) {
248
+ parts.push("");
249
+ parts.push(
250
+ `<embedded_command>${options.embeddedCommand}</embedded_command>`,
251
+ );
252
+ parts.push(
253
+ "Execute the embedded command workflow as part of fulfilling the user request.",
254
+ );
255
+ }
256
+
257
+ // NOTE: options.instructions is intentionally NOT added inside the payload.
258
+ // It is appended after the outer </skill> envelope in buildCombinedMessage
259
+ // so Pi renders it as the visible user message (the `userMessage` tail of
260
+ // parseSkillBlock) while the skill content stays collapsed. Wrapping it in
261
+ // <user_query> tags here would leak those tags into the rendered message.
262
+
263
+ if (notFound.length > 0) {
264
+ parts.push("");
265
+ parts.push(`> ⚠️ Skills not found: ${notFound.join(", ")}`);
266
+ }
267
+
268
+ if (options.conflictWarnings?.length) {
269
+ parts.push("");
270
+ parts.push(`> ⚠️ Conflicts: ${options.conflictWarnings.join("; ")}`);
271
+ }
272
+
273
+ if (skippedDuplicates.length > 0) {
274
+ parts.push("");
275
+ parts.push(`> ℹ️ Deduplicated: ${skippedDuplicates.join("; ")}`);
276
+ }
277
+
278
+ parts.push("</manually_attached_skills>");
279
+ return parts.join("\n");
280
+ }
281
+
282
+ function shouldWrapForDisplay(
283
+ skills: EnrichedSkillInfo[],
284
+ options: BuildOptions,
285
+ ): boolean {
286
+ if (skills.length > 1) return true;
287
+ if (options.parallel || options.bmadStatusBlock || options.embeddedCommand) {
288
+ return true;
289
+ }
290
+ if (options.instructions) return true;
291
+ if (options.bundles && options.bundles.length > 0) return true;
292
+ return false;
293
+ }
294
+
295
+ export function buildCombinedMessage(
296
+ skills: EnrichedSkillInfo[],
297
+ cwd: string,
298
+ options: BuildOptions,
299
+ ): { message: string; notFound: string[]; skippedDuplicates: string[] } {
300
+ const expandedBlocks: string[] = [];
301
+ const notFound: string[] = [];
302
+ const seenDedup = new Set<string>();
303
+ const skippedDuplicates: string[] = [];
304
+
305
+ for (const skill of skills) {
306
+ const before = seenDedup.size;
307
+ const block = renderSkillBlock(skill, cwd, options.mode, seenDedup);
308
+ if (!block) {
309
+ notFound.push(skill.name);
310
+ continue;
311
+ }
312
+ if (seenDedup.size > before && before > 0) {
313
+ skippedDuplicates.push(`${skill.name} (deduplicated sections)`);
314
+ }
315
+ expandedBlocks.push(block);
316
+ }
317
+
318
+ const payload = buildAgentPayload(
319
+ skills,
320
+ expandedBlocks,
321
+ options,
322
+ notFound,
323
+ skippedDuplicates,
324
+ );
325
+
326
+ let message: string;
327
+ if (expandedBlocks.length === 0) {
328
+ message = payload;
329
+ } else if (shouldWrapForDisplay(skills, options)) {
330
+ message = formatPiSkillBlock(
331
+ formatCollapsedSkillName(skills),
332
+ skills.length === 1 ? skills[0].filePath : MULTI_SKILL_LOCATION,
333
+ payload,
334
+ );
335
+ } else {
336
+ message = expandedBlocks[0];
337
+ }
338
+
339
+ // Surface the user's free-text instructions as Pi's `userMessage` tail
340
+ // (the optional text after `</skill>\n\n`). Pi renders the skill block
341
+ // collapsed as `[skill] a, b (ctrl+o to expand)` and this tail as a normal
342
+ // visible user message below it — which is the desired display. Plain text
343
+ // only: wrapping tags here would render literally.
344
+ if (options.instructions && expandedBlocks.length > 0) {
345
+ message = `${message}\n\n${options.instructions}`;
346
+ }
347
+
348
+ return {
349
+ message,
350
+ notFound,
351
+ skippedDuplicates,
352
+ };
353
+ }
354
+
355
+ export function resolveAndReadLegacySkill(
356
+ skillName: string,
357
+ filePath: string,
358
+ cwd: string,
359
+ ): string | null {
360
+ if (existsSync(filePath)) {
361
+ try {
362
+ const content = readFileSync(filePath, "utf-8");
363
+ const body = stripFrontmatter(content).trim();
364
+ const baseDir = dirname(filePath);
365
+ return formatPiSkillBlock(
366
+ skillName,
367
+ filePath,
368
+ `References are relative to ${baseDir}.\n\n${body}`,
369
+ );
370
+ } catch {
371
+ // fall through
372
+ }
373
+ }
374
+
375
+ const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
376
+ for (const dir of dirs) {
377
+ for (const candidate of [
378
+ join(dir, skillName, "SKILL.md"),
379
+ join(dir, `${skillName}.md`),
380
+ ]) {
381
+ try {
382
+ const content = readFileSync(candidate, "utf-8");
383
+ const body = stripFrontmatter(content).trim();
384
+ return formatPiSkillBlock(
385
+ skillName,
386
+ candidate,
387
+ `References are relative to ${dirname(candidate)}.\n\n${body}`,
388
+ );
389
+ } catch {
390
+ // continue
391
+ }
392
+ }
393
+ }
394
+ return null;
395
+ }