utils.js

  1import fs from 'fs';
  2import path from 'path';
  3
  4/**
  5 * Parse frontmatter from markdown content
  6 * Returns { frontmatter: object, body: string }
  7 */
  8export function parseFrontmatter(content) {
  9  const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
 10  const match = content.match(frontmatterRegex);
 11
 12  if (!match) {
 13    return { frontmatter: {}, body: content };
 14  }
 15
 16  const [, frontmatterText, body] = match;
 17  const frontmatter = {};
 18
 19  // Simple YAML parser (handles basic key-value and arrays)
 20  const lines = frontmatterText.split('\n');
 21  let currentKey = null;
 22  let currentArray = null;
 23
 24  for (const line of lines) {
 25    if (!line.trim()) continue;
 26
 27    // Calculate indent level
 28    const leadingSpaces = line.length - line.trimStart().length;
 29    const trimmed = line.trim();
 30
 31    // Array item at level 2 (nested under a key)
 32    if (trimmed.startsWith('- ') && leadingSpaces >= 2) {
 33      if (currentArray) {
 34        if (trimmed.startsWith('- name:')) {
 35          // New object in array
 36          const obj = {};
 37          obj.name = trimmed.slice(7).trim();
 38          currentArray.push(obj);
 39        } else {
 40          // Simple string item in array
 41          currentArray.push(trimmed.slice(2));
 42        }
 43      }
 44      continue;
 45    }
 46
 47    // Property of array object (indented further)
 48    if (leadingSpaces >= 4 && currentArray && currentArray.length > 0) {
 49      const colonIndex = trimmed.indexOf(':');
 50      if (colonIndex > 0) {
 51        const key = trimmed.slice(0, colonIndex).trim();
 52        const value = trimmed.slice(colonIndex + 1).trim();
 53        const lastObj = currentArray[currentArray.length - 1];
 54        lastObj[key] = value === 'true' ? true : value === 'false' ? false : value;
 55      }
 56      continue;
 57    }
 58
 59    // Top-level key-value pair
 60    if (leadingSpaces === 0) {
 61      const colonIndex = trimmed.indexOf(':');
 62      if (colonIndex > 0) {
 63        const key = trimmed.slice(0, colonIndex).trim();
 64        const value = trimmed.slice(colonIndex + 1).trim();
 65
 66        if (value) {
 67          // Strip YAML quotes
 68          const unquoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
 69            ? value.slice(1, -1)
 70            : value;
 71          frontmatter[key] = unquoted === 'true' ? true : unquoted === 'false' ? false : unquoted;
 72          currentKey = key;
 73          currentArray = null;
 74        } else {
 75          // Start of array
 76          currentKey = key;
 77          currentArray = [];
 78          frontmatter[key] = currentArray;
 79        }
 80      }
 81    }
 82  }
 83
 84  return { frontmatter, body: body.trim() };
 85}
 86
 87/**
 88 * Recursively read all .md files from a directory
 89 */
 90export function readFilesRecursive(dir, fileList = []) {
 91  if (!fs.existsSync(dir)) {
 92    return fileList;
 93  }
 94
 95  const files = fs.readdirSync(dir);
 96
 97  for (const file of files) {
 98    const filePath = path.join(dir, file);
 99    const stat = fs.statSync(filePath);
100
101    if (stat.isDirectory()) {
102      readFilesRecursive(filePath, fileList);
103    } else if (file.endsWith('.md')) {
104      fileList.push(filePath);
105    }
106  }
107
108  return fileList;
109}
110
111/**
112 * Read and parse all source files (unified skills architecture)
113 * All source lives in source/skills/{name}/SKILL.md
114 * Returns { skills } where each skill has userInvocable flag
115 */
116export function readSourceFiles(rootDir) {
117  const skillsDir = path.join(rootDir, 'source/skills');
118
119  const skills = [];
120
121  if (fs.existsSync(skillsDir)) {
122    const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
123
124    for (const entry of entries) {
125      const entryPath = path.join(skillsDir, entry.name);
126
127      if (entry.isDirectory()) {
128        // Directory-based skill with potential references
129        const skillMdPath = path.join(entryPath, 'SKILL.md');
130        if (fs.existsSync(skillMdPath)) {
131          const content = fs.readFileSync(skillMdPath, 'utf-8');
132          const { frontmatter, body } = parseFrontmatter(content);
133
134          // Read reference files if they exist
135          const references = [];
136          const referenceDir = path.join(entryPath, 'reference');
137          if (fs.existsSync(referenceDir)) {
138            const refFiles = fs.readdirSync(referenceDir).filter(f => f.endsWith('.md'));
139            for (const refFile of refFiles) {
140              const refPath = path.join(referenceDir, refFile);
141              const refContent = fs.readFileSync(refPath, 'utf-8');
142              references.push({
143                name: path.basename(refFile, '.md'),
144                content: refContent,
145                filePath: refPath
146              });
147            }
148          }
149
150          // Read script files if they exist
151          const scripts = [];
152          const scriptsDir = path.join(entryPath, 'scripts');
153          if (fs.existsSync(scriptsDir)) {
154            const scriptFiles = fs.readdirSync(scriptsDir).filter(f => fs.statSync(path.join(scriptsDir, f)).isFile());
155            for (const scriptFile of scriptFiles) {
156              const scriptPath = path.join(scriptsDir, scriptFile);
157              const scriptContent = fs.readFileSync(scriptPath, 'utf-8');
158              scripts.push({
159                name: scriptFile,
160                content: scriptContent,
161                filePath: scriptPath
162              });
163            }
164          }
165
166          skills.push({
167            name: frontmatter.name || entry.name,
168            description: frontmatter.description || '',
169            license: frontmatter.license || '',
170            compatibility: frontmatter.compatibility || '',
171            metadata: frontmatter.metadata || null,
172            allowedTools: frontmatter['allowed-tools'] || '',
173            userInvocable: frontmatter['user-invocable'] === true || frontmatter['user-invocable'] === 'true',
174            argumentHint: frontmatter['argument-hint'] || '',
175            context: frontmatter.context || null,
176            body,
177            filePath: skillMdPath,
178            references,
179            scripts
180          });
181        }
182      }
183    }
184  }
185
186  return { skills };
187}
188
189/**
190 * Ensure directory exists, create if needed
191 */
192export function ensureDir(dirPath) {
193  if (!fs.existsSync(dirPath)) {
194    fs.mkdirSync(dirPath, { recursive: true });
195  }
196}
197
198/**
199 * Clean directory (remove all contents)
200 */
201export function cleanDir(dirPath) {
202  if (fs.existsSync(dirPath)) {
203    fs.rmSync(dirPath, { recursive: true, force: true });
204  }
205}
206
207/**
208 * Write file with automatic directory creation
209 */
210export function writeFile(filePath, content) {
211  const dir = path.dirname(filePath);
212  ensureDir(dir);
213  fs.writeFileSync(filePath, content, 'utf-8');
214}
215
216/**
217 * Extract patterns from frontend-design SKILL.md
218 * Parses DO/DON'T lines grouped by section headings.
219 * Recognizes both formats:
220 *   - Markdown bullet form:  `**DO**: …`  /  `**DON'T**: …`
221 *   - XML-block prose form:  `DO …`       /  `DO NOT …`  (used inside
222 *     <typography_rules>, <color_rules>, <spatial_rules>, <absolute_bans>)
223 * Returns { patterns: [...], antipatterns: [...] }
224 */
225export function readPatterns(rootDir) {
226  const skillPath = path.join(rootDir, 'source/skills/impeccable/SKILL.md');
227
228  if (!fs.existsSync(skillPath)) {
229    return { patterns: [], antipatterns: [] };
230  }
231
232  const content = fs.readFileSync(skillPath, 'utf-8');
233  const lines = content.split('\n');
234
235  const patternsMap = {};  // category -> items[]
236  const antipatternsMap = {};  // category -> items[]
237  let currentSection = null;
238
239  const pushPattern = (item) => {
240    if (!currentSection) return;
241    if (!patternsMap[currentSection]) patternsMap[currentSection] = [];
242    patternsMap[currentSection].push(item);
243  };
244  const pushAntipattern = (item) => {
245    if (!currentSection) return;
246    if (!antipatternsMap[currentSection]) antipatternsMap[currentSection] = [];
247    antipatternsMap[currentSection].push(item);
248  };
249
250  for (const line of lines) {
251    const trimmed = line.trim();
252
253    // Track section headings (### Typography, ### Color & Theme, etc.)
254    if (trimmed.startsWith('### ')) {
255      currentSection = trimmed.slice(4).trim();
256      // Normalize "Color & Theme" to "Color & Contrast" for consistency
257      if (currentSection === 'Color & Theme') {
258        currentSection = 'Color & Contrast';
259      }
260      continue;
261    }
262
263    // Markdown bullet form (legacy): **DO**: ... and **DON'T**: ...
264    if (trimmed.startsWith('**DO**:')) {
265      pushPattern(trimmed.slice(7).trim());
266      continue;
267    }
268    if (trimmed.startsWith("**DON'T**:")) {
269      pushAntipattern(trimmed.slice(10).trim());
270      continue;
271    }
272
273    // XML-block prose form (current). Both space and colon variants:
274    //   "DO NOT use ..."  /  "DO NOT: Use ..."
275    //   "DO use ..."      /  "DO: Use ..."
276    // IMPORTANT: check `DO NOT` BEFORE `DO` so the prefix doesn't get
277    // gobbled by the wrong matcher.
278    if (trimmed.startsWith('DO NOT: ')) {
279      pushAntipattern(trimmed.slice('DO NOT: '.length).trim());
280      continue;
281    }
282    if (trimmed.startsWith('DO NOT ')) {
283      pushAntipattern(trimmed.slice('DO NOT '.length).trim());
284      continue;
285    }
286    if (trimmed.startsWith('DO: ')) {
287      pushPattern(trimmed.slice('DO: '.length).trim());
288      continue;
289    }
290    if (trimmed.startsWith('DO ')) {
291      pushPattern(trimmed.slice('DO '.length).trim());
292      continue;
293    }
294  }
295
296  // Convert maps to arrays in consistent order
297  const sectionOrder = ['Typography', 'Color & Contrast', 'Layout & Space', 'Visual Details', 'Motion', 'Interaction', 'Responsive', 'UX Writing'];
298
299  const patterns = [];
300  const antipatterns = [];
301
302  for (const section of sectionOrder) {
303    if (patternsMap[section] && patternsMap[section].length > 0) {
304      patterns.push({ name: section, items: patternsMap[section] });
305    }
306    if (antipatternsMap[section] && antipatternsMap[section].length > 0) {
307      antipatterns.push({ name: section, items: antipatternsMap[section] });
308    }
309  }
310
311  return { patterns, antipatterns };
312}
313
314/**
315 * Provider-specific placeholders
316 */
317export const PROVIDER_PLACEHOLDERS = {
318  'claude-code': {
319    model: 'Claude',
320    config_file: 'CLAUDE.md',
321    ask_instruction: 'STOP and call the AskUserQuestion tool to clarify.',
322    command_prefix: '/'
323  },
324  'cursor': {
325    model: 'the model',
326    config_file: '.cursorrules',
327    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
328    command_prefix: '/'
329  },
330  'gemini': {
331    model: 'Gemini',
332    config_file: 'GEMINI.md',
333    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
334    command_prefix: '/'
335  },
336  'codex': {
337    model: 'GPT',
338    config_file: 'AGENTS.md',
339    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
340    command_prefix: '$'
341  },
342  'agents': {
343    model: 'the model',
344    config_file: '.github/copilot-instructions.md',
345    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
346    command_prefix: '/'
347  },
348  'kiro': {
349    model: 'Claude',
350    config_file: '.kiro/settings.json',
351    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
352    command_prefix: '/'
353  },
354  opencode: {
355    model: 'Claude',
356    config_file: 'AGENTS.md',
357    ask_instruction: 'STOP and call the `question` tool to clarify.',
358    command_prefix: '/'
359  },
360  'pi': {
361    model: 'the model',
362    config_file: 'AGENTS.md',
363    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
364    command_prefix: '/'
365  },
366  'trae': {
367    model: 'the model',
368    config_file: 'RULES.md',
369    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
370    command_prefix: '/'
371  },
372  'rovo-dev': {
373    model: 'Rovo Dev',
374    config_file: 'AGENTS.md',
375    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
376    command_prefix: '/'
377  }
378};
379
380/**
381 * Replace all {{placeholder}} tokens with provider-specific values
382 */
383/**
384 * Prefix skill cross-references in body text.
385 * Replaces patterns like `/skillname` and `the skillname skill` with prefixed versions.
386 *
387 * @param {string} content - The skill body text
388 * @param {string} prefix - The prefix to add (e.g., 'i-')
389 * @param {string[]} skillNames - Array of all skill names
390 * @param {string} commandPrefix - The command invocation prefix (e.g., '/' or '$')
391 */
392export function prefixSkillReferences(content, prefix, skillNames, commandPrefix = '/') {
393  if (!prefix || !skillNames || skillNames.length === 0) return content;
394
395  let result = content;
396  // Sort by length descending to avoid partial matches (e.g. 'teach-impeccable' before 'teach')
397  const sorted = [...skillNames].sort((a, b) => b.length - a.length);
398
399  for (const name of sorted) {
400    const prefixed = `${prefix}${name}`;
401
402    // Replace command invocations (e.g., `/skillname` or `$skillname`) with prefixed versions
403    const escapedPrefix = escapeRegex(commandPrefix);
404    result = result.replace(
405      new RegExp(`${escapedPrefix}(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
406      `${commandPrefix}${prefix}`
407    );
408
409    // Replace `the skillname skill` references
410    result = result.replace(
411      new RegExp(`(the) ${escapeRegex(name)} skill`, 'gi'),
412      (_, article) => `${article} ${prefixed} skill`
413    );
414  }
415
416  return result;
417}
418
419function escapeRegex(str) {
420  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
421}
422
423const EXCLUDED_FROM_SUGGESTIONS = new Set([
424  'impeccable', 'i-impeccable',               // foundational skill, not a steering command
425  'teach-impeccable', 'i-teach-impeccable',    // deprecated shim
426  'frontend-design', 'i-frontend-design',      // deprecated shim
427]);
428
429export function replacePlaceholders(content, provider, commandNames = [], allSkillNames = []) {
430  const placeholders = PROVIDER_PLACEHOLDERS[provider] || PROVIDER_PLACEHOLDERS['cursor'];
431  const cmdPrefix = placeholders.command_prefix || '/';
432  const commandList = commandNames
433    .filter(n => !EXCLUDED_FROM_SUGGESTIONS.has(n))
434    .map(n => `${cmdPrefix}${n}`)
435    .join(', ');
436
437  let result = content
438    .replace(/\{\{model\}\}/g, placeholders.model)
439    .replace(/\{\{config_file\}\}/g, placeholders.config_file)
440    .replace(/\{\{ask_instruction\}\}/g, placeholders.ask_instruction)
441    .replace(/\{\{command_prefix\}\}/g, cmdPrefix)
442    .replace(/\{\{available_commands\}\}/g, commandList);
443
444  // Replace `/skillname` invocations with the correct command prefix for this provider
445  // (e.g., `/normalize` → `$normalize` for Codex)
446  if (cmdPrefix !== '/' && allSkillNames.length > 0) {
447    const sorted = [...allSkillNames].sort((a, b) => b.length - a.length);
448    for (const name of sorted) {
449      result = result.replace(
450        new RegExp(`\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
451        cmdPrefix
452      );
453    }
454  }
455
456  return result;
457}
458
459/**
460 * Generate YAML frontmatter string
461 */
462export function generateYamlFrontmatter(data) {
463  const lines = ['---'];
464
465  for (const [key, value] of Object.entries(data)) {
466    if (Array.isArray(value)) {
467      lines.push(`${key}:`);
468      for (const item of value) {
469        if (typeof item === 'object') {
470          lines.push(`  - name: ${item.name}`);
471          if (item.description) lines.push(`    description: ${item.description}`);
472          if (item.required !== undefined) lines.push(`    required: ${item.required}`);
473        } else {
474          lines.push(`  - ${item}`);
475        }
476      }
477    } else if (typeof value === 'boolean') {
478      lines.push(`${key}: ${value}`);
479    } else {
480      const needsQuoting = typeof value === 'string' && /^[\[{]/.test(value);
481      lines.push(`${key}: ${needsQuoting ? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : value}`);
482    }
483  }
484
485  lines.push('---');
486  return lines.join('\n');
487}