1import path from 'path';
2import {
3 cleanDir,
4 ensureDir,
5 writeFile,
6 generateYamlFrontmatter,
7 generateYamlDocument,
8 replacePlaceholders,
9 compileProviderBlocks,
10} from '../utils.js';
11import { SKILL_CATEGORIES, CATEGORY_ORDER } from '../sub-pages-data.js';
12
13/**
14 * Map from frontmatter field name to extraction spec.
15 *
16 * - sourceKey: property name on the skill object
17 * - yamlKey: key name in YAML frontmatter
18 * - condition: if provided, field is only emitted when this returns true
19 * - value: if provided, use this instead of skill[sourceKey]
20 */
21const FIELD_SPECS = {
22 'user-invocable': {
23 sourceKey: 'userInvocable',
24 yamlKey: 'user-invocable',
25 condition: (skill) => skill.userInvocable,
26 value: () => true,
27 },
28 'argument-hint': {
29 sourceKey: 'argumentHint',
30 yamlKey: 'argument-hint',
31 condition: (skill) => skill.userInvocable && skill.argumentHint,
32 },
33 license: {
34 sourceKey: 'license',
35 yamlKey: 'license',
36 },
37 compatibility: {
38 sourceKey: 'compatibility',
39 yamlKey: 'compatibility',
40 },
41 metadata: {
42 sourceKey: 'metadata',
43 yamlKey: 'metadata',
44 },
45 'allowed-tools': {
46 sourceKey: 'allowedTools',
47 yamlKey: 'allowed-tools',
48 },
49};
50
51function humanizeSkillName(name) {
52 return name
53 .split('-')
54 .map(part => part.charAt(0).toUpperCase() + part.slice(1))
55 .join(' ');
56}
57
58function summarizeDescription(description, maxLength = 88) {
59 if (!description || description.length <= maxLength) return description;
60 const clipped = description.slice(0, maxLength - 1);
61 const lastSpace = clipped.lastIndexOf(' ');
62 return `${(lastSpace > 48 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}...`;
63}
64
65function buildOpenAIMetadata(skill) {
66 const displayName = humanizeSkillName(skill.name);
67 return {
68 interface: {
69 display_name: displayName,
70 short_description: summarizeDescription(skill.description),
71 default_prompt: `Use ${displayName} to redesign, critique, audit, or polish this frontend.`,
72 },
73 };
74}
75
76function formatTomlString(value) {
77 return JSON.stringify(String(value));
78}
79
80function formatTomlMultiline(value) {
81 const normalized = String(value).trim().replace(/\r\n/g, '\n');
82 if (!normalized.includes("'''")) {
83 return `'''\n${normalized}\n'''`;
84 }
85 return `"""\n${normalized.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""`;
86}
87
88function formatTomlArray(values) {
89 return `[${values.map(formatTomlString).join(', ')}]`;
90}
91
92function buildCodexAgent(agent, body) {
93 const lines = [
94 `name = ${formatTomlString(agent.codexName || agent.name.replace(/-/g, '_'))}`,
95 `description = ${formatTomlString(agent.description)}`,
96 ];
97
98 if (agent.effort) {
99 lines.push(`model_reasoning_effort = ${formatTomlString(agent.effort)}`);
100 }
101
102 if (agent.nicknameCandidates?.length) {
103 lines.push(`nickname_candidates = ${formatTomlArray(agent.nicknameCandidates)}`);
104 }
105
106 lines.push(`developer_instructions = ${formatTomlMultiline(body)}`);
107 return `${lines.join('\n')}\n`;
108}
109
110function buildClaudeAgent(agent, body) {
111 const frontmatter = {
112 name: agent.claudeName || agent.name,
113 description: agent.description,
114 };
115
116 if (agent.tools) frontmatter.tools = agent.tools;
117 if (agent.model) frontmatter.model = agent.model;
118 if (agent.effort) frontmatter.effort = agent.effort;
119 if (agent.maxTurns) frontmatter.maxTurns = agent.maxTurns;
120
121 return `${generateYamlFrontmatter(frontmatter)}\n${body.trim()}\n`;
122}
123
124function buildAgentFile(config, agent, body) {
125 if (config.agentFormat === 'codex-toml') {
126 return {
127 filename: `${agent.codexName || agent.name.replace(/-/g, '_')}.toml`,
128 content: buildCodexAgent(agent, body),
129 };
130 }
131
132 if (config.agentFormat === 'claude-md') {
133 return {
134 filename: `${agent.claudeName || agent.name}.md`,
135 content: buildClaudeAgent(agent, body),
136 };
137 }
138
139 return null;
140}
141
142/**
143 * Create a transformer function for a given provider config.
144 *
145 * @param {Object} config - Provider configuration from providers.js
146 * @returns {Function} transform(skills, distDir, options?)
147 */
148export function createTransformer(config) {
149 const {
150 provider,
151 configDir,
152 displayName,
153 frontmatterFields = [],
154 bodyTransform,
155 placeholderProvider,
156 providerTags = [provider],
157 writeOpenAIMetadata = false,
158 includeVersion = true,
159 } = config;
160 const placeholderKey = placeholderProvider || provider;
161
162 const activeFields = frontmatterFields
163 .map((name) => FIELD_SPECS[name])
164 .filter(Boolean);
165
166 return function transform(skills, distDir, options = {}) {
167 const { skillsVersion = '' } = options;
168 const providerDir = path.join(distDir, provider);
169 const skillsDir = path.join(providerDir, `${configDir}/skills`);
170
171 cleanDir(providerDir);
172 ensureDir(skillsDir);
173
174 const allSkillNames = skills.map((s) => s.name);
175 const commandNames = skills
176 .filter((s) => s.userInvocable)
177 .map((s) => s.name);
178
179 let refCount = 0;
180 let scriptCount = 0;
181 let agentCount = 0;
182
183 for (const skill of skills) {
184 const skillName = skill.name;
185 const skillDir = path.join(skillsDir, skillName);
186
187 // Build frontmatter
188 const frontmatterObj = {
189 name: skillName,
190 description: skill.description,
191 };
192 if (skillsVersion && includeVersion) frontmatterObj.version = skillsVersion;
193
194 for (const spec of activeFields) {
195 if (spec.condition && !spec.condition(skill)) continue;
196 const val = spec.value ? spec.value(skill) : skill[spec.sourceKey];
197 if (val) frontmatterObj[spec.yamlKey] = val;
198 }
199
200 // Replace {{command_hint}} in argument-hint with command names from metadata,
201 // grouped by category with middle dots between groups for natural line-breaking.
202 if (frontmatterObj['argument-hint']?.includes('{{command_hint}}')) {
203 const metaScript = skill.scripts?.find(s => s.name === 'command-metadata.json');
204 if (metaScript) {
205 const commands = Object.keys(JSON.parse(metaScript.content));
206 // Derive groups from SKILL_CATEGORIES, excluding the parent skill name
207 const grouped = CATEGORY_ORDER
208 .map(cat => commands.filter(c => SKILL_CATEGORIES[c] === cat).join('|'))
209 .filter(Boolean)
210 .join(' · ');
211 frontmatterObj['argument-hint'] = frontmatterObj['argument-hint'].replace(
212 '{{command_hint}}',
213 grouped
214 );
215 }
216 }
217
218 const frontmatter = generateYamlFrontmatter(frontmatterObj);
219
220 // Build body
221 let skillBody = compileProviderBlocks(skill.body, providerTags);
222 skillBody = replacePlaceholders(skillBody, placeholderKey, commandNames, allSkillNames);
223
224 // Replace {{scripts_path}} with provider-aware path to skill's scripts directory
225 const scriptsPath = `${configDir}/skills/${skillName}/scripts`;
226 skillBody = skillBody.replace(/\{\{scripts_path\}\}/g, scriptsPath);
227 if (bodyTransform) skillBody = bodyTransform(skillBody, skill);
228
229 const content = `${frontmatter}\n\n${skillBody}`;
230 writeFile(path.join(skillDir, 'SKILL.md'), content);
231
232 if (writeOpenAIMetadata) {
233 const openaiMetadata = buildOpenAIMetadata(skill);
234 writeFile(path.join(skillDir, 'agents', 'openai.yaml'), generateYamlDocument(openaiMetadata));
235 }
236
237 // Copy reference files
238 if (skill.references && skill.references.length > 0) {
239 const refDir = path.join(skillDir, 'reference');
240 ensureDir(refDir);
241 for (const ref of skill.references) {
242 let refContent = compileProviderBlocks(ref.content, providerTags);
243 refContent = replacePlaceholders(refContent, placeholderKey, [], allSkillNames);
244 refContent = refContent.replace(/\{\{scripts_path\}\}/g, scriptsPath);
245 writeFile(path.join(refDir, `${ref.name}.md`), refContent);
246 refCount++;
247 }
248 }
249
250 // Copy script files
251 if (skill.scripts && skill.scripts.length > 0) {
252 const scriptsOutDir = path.join(skillDir, 'scripts');
253 ensureDir(scriptsOutDir);
254 for (const script of skill.scripts) {
255 writeFile(path.join(scriptsOutDir, script.name), script.content);
256 scriptCount++;
257 }
258 }
259 }
260
261 if (config.agentFormat) {
262 const agentsDir = path.join(providerDir, `${configDir}/agents`);
263 for (const skill of skills) {
264 for (const agent of skill.agents || []) {
265 // Agents can declare `providers: <list>` to limit which harnesses
266 // they emit to. Default (no field) ships everywhere with agentFormat.
267 if (agent.providers && !agent.providers.includes(provider)) continue;
268 let body = compileProviderBlocks(agent.body, providerTags);
269 body = replacePlaceholders(body, placeholderKey, [], allSkillNames);
270 const agentFile = buildAgentFile(config, agent, body);
271 if (!agentFile) continue;
272 ensureDir(agentsDir);
273 writeFile(path.join(agentsDir, agentFile.filename), agentFile.content);
274 agentCount++;
275 }
276 }
277 }
278
279 const skillWord = skills.length === 1 ? 'skill' : 'skills';
280 const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
281 const scriptInfo = scriptCount > 0 ? ` (${scriptCount} script files)` : '';
282 const agentInfo = agentCount > 0 ? ` (${agentCount} agent files)` : '';
283 console.log(`✓ ${displayName}: ${skills.length} ${skillWord}${refInfo}${scriptInfo}${agentInfo}`);
284 };
285}