factory.js

  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}