Skip to content

Commit b6fd987

Browse files
authored
Merge pull request #20 from udecode/codex/skills-propagation-compile-skillmd
Fix skills sync: compile SKILL.md for non-Claude agents
2 parents 751c83f + 30e5315 commit b6fd987

File tree

2 files changed

+321
-38
lines changed

2 files changed

+321
-38
lines changed

src/core/SkillsProcessor.ts

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,131 @@ import { walkSkillsTree, copySkillsDirectory } from './SkillsUtils';
1313
import { parseFrontmatter } from './FrontmatterParser';
1414
import type { IAgent } from '../agents/IAgent';
1515

16+
/**
17+
* For non-Claude agents, compile a wrapper SKILL.md (body is a single @reference)
18+
* into a standalone SKILL.md with the referenced file's body inlined.
19+
*
20+
* We intentionally keep this conservative: only expand when the body is *just*
21+
* an @reference line, to avoid accidentally treating email addresses or
22+
* "@mentions" inside real content as file references.
23+
*/
24+
async function compileSkillMdForNonClaudeAgents(
25+
skillMdContent: string,
26+
projectRoot: string,
27+
skillFolderPath: string,
28+
): Promise<string> {
29+
const { frontmatter, rawFrontmatter, body } =
30+
parseFrontmatter(skillMdContent);
31+
const refCheck = isReferenceBody(body);
32+
33+
if (!refCheck.isReference || !refCheck.referencePath) {
34+
return skillMdContent;
35+
}
36+
37+
const referencePath = refCheck.referencePath;
38+
const absoluteRefPath =
39+
referencePath.startsWith('./') || referencePath.startsWith('../')
40+
? path.resolve(skillFolderPath, referencePath)
41+
: path.resolve(projectRoot, referencePath);
42+
43+
// Security: only inline references within the project root.
44+
const normalizedProjectRoot = path.resolve(projectRoot);
45+
const normalizedAbsoluteRefPath = path.resolve(absoluteRefPath);
46+
if (!normalizedAbsoluteRefPath.startsWith(normalizedProjectRoot + path.sep)) {
47+
return skillMdContent;
48+
}
49+
50+
let referencedContent: string;
51+
try {
52+
referencedContent = await fs.readFile(normalizedAbsoluteRefPath, 'utf8');
53+
} catch {
54+
return skillMdContent;
55+
}
56+
57+
const { body: referencedBody } = parseFrontmatter(referencedContent);
58+
59+
const fmData =
60+
rawFrontmatter && Object.keys(rawFrontmatter).length > 0
61+
? rawFrontmatter
62+
: frontmatter && Object.keys(frontmatter).length > 0
63+
? frontmatter
64+
: null;
65+
66+
if (fmData) {
67+
return `---
68+
${yaml.dump(fmData, { lineWidth: -1, noRefs: true }).trim()}
69+
---
70+
71+
${referencedBody}
72+
`;
73+
}
74+
75+
return `${referencedBody}\n`;
76+
}
77+
78+
/**
79+
* Copies a single skill directory to an agent skill directory:
80+
* - SKILL.md is compiled (inlines @reference wrapper content)
81+
* - .mdc files are excluded (Claude-only sources)
82+
* - all other files are copied as-is
83+
*/
84+
async function copySkillDirectoryForNonClaudeAgents(
85+
src: string,
86+
dest: string,
87+
projectRoot: string,
88+
skillFolderPath: string,
89+
depth: number = 0,
90+
): Promise<void> {
91+
// Security: Prevent DoS via deeply nested directories
92+
if (depth >= MAX_RECURSION_DEPTH) {
93+
return;
94+
}
95+
96+
const stat = await fs.stat(src);
97+
98+
if (stat.isDirectory()) {
99+
await fs.mkdir(dest, { recursive: true });
100+
const entries = await fs.readdir(src, { withFileTypes: true });
101+
102+
for (const entry of entries) {
103+
// Exclude all .mdc files from agent skills directories.
104+
if (entry.isFile() && entry.name.endsWith('.mdc')) {
105+
continue;
106+
}
107+
108+
const srcPath = path.join(src, entry.name);
109+
const destPath = path.join(dest, entry.name);
110+
await copySkillDirectoryForNonClaudeAgents(
111+
srcPath,
112+
destPath,
113+
projectRoot,
114+
skillFolderPath,
115+
depth + 1,
116+
);
117+
}
118+
return;
119+
}
120+
121+
// Files
122+
if (path.basename(src) === SKILL_MD_FILENAME) {
123+
const content = await fs.readFile(src, 'utf8');
124+
const compiled = await compileSkillMdForNonClaudeAgents(
125+
content,
126+
projectRoot,
127+
skillFolderPath,
128+
);
129+
await fs.writeFile(dest, compiled, 'utf8');
130+
return;
131+
}
132+
133+
// Extra guard: skip .mdc even if reached via recursion.
134+
if (src.endsWith('.mdc')) {
135+
return;
136+
}
137+
138+
await fs.copyFile(src, dest);
139+
}
140+
16141
/**
17142
* Check if SKILL.md body is just a reference (single non-empty line starting with @).
18143
* This replaces the previous synced: true frontmatter detection.
@@ -281,7 +406,9 @@ ${yaml.dump(skillFrontmatter || { name: skillName }, { lineWidth: -1, noRefs: tr
281406
if (refCheck.referencePath?.includes('/rules/')) {
282407
const refFileName = path.basename(refCheck.referencePath);
283408
const refBaseName = path.basename(refFileName, '.mdc');
284-
candidatePaths.push(path.join(skillsDir, refBaseName, refFileName));
409+
candidatePaths.push(
410+
path.join(skillsDir, refBaseName, refFileName),
411+
);
285412
}
286413

287414
let referencedContent: string | null = null;
@@ -444,6 +571,7 @@ export async function discoverSkills(
444571
export async function copySkillsToAgent(
445572
sourceSkillsDir: string,
446573
targetSkillsDir: string,
574+
projectRoot: string,
447575
verbose: boolean,
448576
dryRun: boolean,
449577
): Promise<{ copied: number; warnings: string[] }> {
@@ -481,7 +609,12 @@ export async function copySkillsToAgent(
481609
const targetSkillPath = path.join(targetSkillsDir, relativeSkillPath);
482610

483611
if (!dryRun) {
484-
await copySkillsDirectory(skillPath, targetSkillPath);
612+
await copySkillDirectoryForNonClaudeAgents(
613+
skillPath,
614+
targetSkillPath,
615+
projectRoot,
616+
skillPath,
617+
);
485618
}
486619

487620
logVerboseInfo(
@@ -619,6 +752,7 @@ export async function propagateSkills(
619752
const result = await copySkillsToAgent(
620753
skillsDir,
621754
targetPath,
755+
projectRoot,
622756
verbose,
623757
dryRun,
624758
);

0 commit comments

Comments
 (0)