utils.js

  1import fs from 'fs';
  2import path from 'path';
  3
  4// Per-project artifacts live inside `scripts/` of an installed skill but
  5// belong to the consuming project, not the distributable skill. The build
  6// excludes them from dist, and the harness-sync step preserves them across
  7// the rm+recopy so local state isn't destroyed on every rebuild.
  8// - config.json: legacy live-mode inject target list for existing projects.
  9//   New installs write project config at .impeccable/live/config.json instead.
 10export const PER_PROJECT_SCRIPT_ARTIFACTS = new Set(['config.json']);
 11
 12const DETECTOR_BUNDLE_DIR = 'cli/engine';
 13
 14// Walk the harness-dir skill tree and return any per-project script
 15// artifacts found, ready for restoration after a full sync rm+recopy.
 16// Returns [{ relPath, content: Buffer }], where relPath is relative to
 17// the passed-in rootDir (typically `<configDir>/skills`).
 18export function stashPerProjectArtifacts(rootDir) {
 19  if (!fs.existsSync(rootDir)) return [];
 20  const out = [];
 21  const walk = (dir) => {
 22    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
 23      const p = path.join(dir, entry.name);
 24      if (entry.isDirectory()) { walk(p); continue; }
 25      // Only preserve files inside a skill's scripts/ directory.
 26      if (path.basename(path.dirname(p)) !== 'scripts') continue;
 27      if (PER_PROJECT_SCRIPT_ARTIFACTS.has(entry.name)) {
 28        out.push({ relPath: path.relative(rootDir, p), content: fs.readFileSync(p) });
 29      }
 30    }
 31  };
 32  walk(rootDir);
 33  return out;
 34}
 35
 36export function restorePerProjectArtifacts(rootDir, stashed) {
 37  for (const { relPath, content } of stashed) {
 38    const target = path.join(rootDir, relPath);
 39    fs.mkdirSync(path.dirname(target), { recursive: true });
 40    fs.writeFileSync(target, content);
 41  }
 42}
 43
 44function readDetectorBundleScripts(rootDir) {
 45  const detectorDir = path.join(rootDir, DETECTOR_BUNDLE_DIR);
 46  if (!fs.existsSync(detectorDir)) return [];
 47
 48  const scripts = [];
 49  const walk = (dir) => {
 50    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
 51      const entryPath = path.join(dir, entry.name);
 52      if (entry.isDirectory()) {
 53        walk(entryPath);
 54        continue;
 55      }
 56      if (!entry.isFile()) continue;
 57      const relPath = path.relative(detectorDir, entryPath).split(path.sep).join('/');
 58      scripts.push({
 59        name: `detector/${relPath}`,
 60        content: fs.readFileSync(entryPath, 'utf-8'),
 61        filePath: entryPath,
 62        generated: true,
 63      });
 64    }
 65  };
 66  walk(detectorDir);
 67  return scripts;
 68}
 69
 70/**
 71 * Parse frontmatter from markdown content
 72 * Returns { frontmatter: object, body: string }
 73 */
 74export function parseFrontmatter(content) {
 75  const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
 76  const match = content.match(frontmatterRegex);
 77
 78  if (!match) {
 79    return { frontmatter: {}, body: content };
 80  }
 81
 82  const [, frontmatterText, body] = match;
 83  const frontmatter = {};
 84
 85  // Simple YAML parser (handles basic key-value and arrays)
 86  const lines = frontmatterText.split(/\r?\n/);
 87  let currentKey = null;
 88  let currentArray = null;
 89
 90  for (const line of lines) {
 91    if (!line.trim()) continue;
 92
 93    // Calculate indent level
 94    const leadingSpaces = line.length - line.trimStart().length;
 95    const trimmed = line.trim();
 96
 97    // Array item at level 2 (nested under a key)
 98    if (trimmed.startsWith('- ') && leadingSpaces >= 2) {
 99      if (currentArray) {
100        if (trimmed.startsWith('- name:')) {
101          // New object in array
102          const obj = {};
103          obj.name = trimmed.slice(7).trim();
104          currentArray.push(obj);
105        } else {
106          // Simple string item in array
107          currentArray.push(trimmed.slice(2));
108        }
109      }
110      continue;
111    }
112
113    // Property of array object (indented further)
114    if (leadingSpaces >= 4 && currentArray && currentArray.length > 0) {
115      const colonIndex = trimmed.indexOf(':');
116      if (colonIndex > 0) {
117        const key = trimmed.slice(0, colonIndex).trim();
118        const value = trimmed.slice(colonIndex + 1).trim();
119        const lastObj = currentArray[currentArray.length - 1];
120        lastObj[key] = value === 'true' ? true : value === 'false' ? false : value;
121      }
122      continue;
123    }
124
125    // Top-level key-value pair
126    if (leadingSpaces === 0) {
127      const colonIndex = trimmed.indexOf(':');
128      if (colonIndex > 0) {
129        const key = trimmed.slice(0, colonIndex).trim();
130        const value = trimmed.slice(colonIndex + 1).trim();
131        const isQuoted = /^(".*"|'.*')$/.test(value);
132        const unquotedValue = isQuoted ? value.slice(1, -1) : value;
133        const shouldCoerceBoolean =
134          key === 'user-invocable' || key === 'user-invokable' || !isQuoted;
135
136        if (value) {
137          frontmatter[key] = shouldCoerceBoolean
138            ? unquotedValue === 'true'
139              ? true
140              : unquotedValue === 'false'
141                ? false
142                : unquotedValue
143            : unquotedValue;
144          currentKey = key;
145          currentArray = null;
146        } else {
147          // Start of array
148          currentKey = key;
149          currentArray = [];
150          frontmatter[key] = currentArray;
151        }
152      }
153    }
154  }
155
156  return { frontmatter, body: body.trim() };
157}
158
159/**
160 * Recursively read all .md files from a directory
161 */
162export function readFilesRecursive(dir, fileList = []) {
163  if (!fs.existsSync(dir)) {
164    return fileList;
165  }
166
167  const files = fs.readdirSync(dir);
168
169  for (const file of files) {
170    const filePath = path.join(dir, file);
171    const stat = fs.statSync(filePath);
172
173    if (stat.isDirectory()) {
174      readFilesRecursive(filePath, fileList);
175    } else if (file.endsWith('.md')) {
176      fileList.push(filePath);
177    }
178  }
179
180  return fileList;
181}
182
183/**
184 * Read and parse the impeccable skill source.
185 * After v3.0 the repo holds exactly one user-invocable skill, flat at skill/.
186 * Returns { skills: [oneEntry] } so downstream array-shaped consumers stay happy.
187 */
188export function readSourceFiles(rootDir) {
189  const skillDir = path.join(rootDir, 'skill');
190  const skills = [];
191
192  const skillMdPath = path.join(skillDir, 'SKILL.md');
193  if (!fs.existsSync(skillMdPath)) {
194    return { skills };
195  }
196
197  const content = fs.readFileSync(skillMdPath, 'utf-8');
198  const { frontmatter, body } = parseFrontmatter(content);
199
200  const references = [];
201  const referenceDir = path.join(skillDir, 'reference');
202  if (fs.existsSync(referenceDir)) {
203    const refFiles = fs.readdirSync(referenceDir).filter(f => f.endsWith('.md'));
204    for (const refFile of refFiles) {
205      const refPath = path.join(referenceDir, refFile);
206      references.push({
207        name: path.basename(refFile, '.md'),
208        content: fs.readFileSync(refPath, 'utf-8'),
209        filePath: refPath
210      });
211    }
212  }
213
214  // PER_PROJECT_SCRIPT_ARTIFACTS (defined at module top) are excluded from
215  // the distributable skill so the build never bundles one project's state
216  // into another's.
217  const scripts = [];
218  const scriptsDir = path.join(skillDir, 'scripts');
219  if (fs.existsSync(scriptsDir)) {
220    const scriptFiles = fs.readdirSync(scriptsDir).filter(f => {
221      if (PER_PROJECT_SCRIPT_ARTIFACTS.has(f)) return false;
222      return fs.statSync(path.join(scriptsDir, f)).isFile();
223    });
224    for (const scriptFile of scriptFiles) {
225      const scriptPath = path.join(scriptsDir, scriptFile);
226      scripts.push({
227        name: scriptFile,
228        content: fs.readFileSync(scriptPath, 'utf-8'),
229        filePath: scriptPath
230      });
231    }
232  }
233  scripts.push(...readDetectorBundleScripts(rootDir));
234
235  const agents = [];
236  const agentsDir = path.join(skillDir, 'agents');
237  if (fs.existsSync(agentsDir)) {
238    const agentFiles = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md'));
239    for (const agentFile of agentFiles) {
240      const agentPath = path.join(agentsDir, agentFile);
241      const agentContent = fs.readFileSync(agentPath, 'utf-8');
242      const { frontmatter: agentFrontmatter, body: agentBody } = parseFrontmatter(agentContent);
243      const name = agentFrontmatter.name || path.basename(agentFile, '.md');
244      const providersRaw = agentFrontmatter.providers;
245      let providers = null;
246      if (Array.isArray(providersRaw)) {
247        providers = providersRaw.map(p => String(p).trim()).filter(Boolean);
248      } else if (typeof providersRaw === 'string' && providersRaw.trim()) {
249        providers = providersRaw.split(',').map(p => p.trim()).filter(Boolean);
250      }
251      agents.push({
252        name,
253        codexName: agentFrontmatter['codex-name'] || name.replace(/-/g, '_'),
254        claudeName: agentFrontmatter['claude-name'] || name,
255        description: agentFrontmatter.description || '',
256        tools: agentFrontmatter.tools || '',
257        model: agentFrontmatter.model || '',
258        effort: agentFrontmatter.effort || '',
259        maxTurns: agentFrontmatter['max-turns'] ? Number(agentFrontmatter['max-turns']) : '',
260        nicknameCandidates: agentFrontmatter['nickname-candidates'] || [],
261        providers,
262        body: agentBody,
263        filePath: agentPath,
264      });
265    }
266  }
267
268  skills.push({
269    name: frontmatter.name || 'impeccable',
270    description: frontmatter.description || '',
271    license: frontmatter.license || '',
272    compatibility: frontmatter.compatibility || '',
273    metadata: frontmatter.metadata || null,
274    allowedTools: frontmatter['allowed-tools'] || '',
275    userInvocable: frontmatter['user-invocable'] === true || frontmatter['user-invocable'] === 'true',
276    argumentHint: frontmatter['argument-hint'] || '',
277    context: frontmatter.context || null,
278    body,
279    filePath: skillMdPath,
280    references,
281    scripts,
282    agents
283  });
284
285  return { skills };
286}
287
288/**
289 * Ensure directory exists, create if needed
290 */
291export function ensureDir(dirPath) {
292  if (!fs.existsSync(dirPath)) {
293    fs.mkdirSync(dirPath, { recursive: true });
294  }
295}
296
297/**
298 * Clean directory (remove all contents)
299 */
300export function cleanDir(dirPath) {
301  if (fs.existsSync(dirPath)) {
302    fs.rmSync(dirPath, { recursive: true, force: true });
303  }
304}
305
306/**
307 * Write file with automatic directory creation
308 */
309export function writeFile(filePath, content) {
310  const dir = path.dirname(filePath);
311  ensureDir(dir);
312  fs.writeFileSync(filePath, content, 'utf-8');
313}
314
315/**
316 * Extract DO/DON'T patterns from a skill markdown file, grouped by section
317 * (h3 `### ` headings). Recognizes both formats:
318 *   - Markdown bullet form:  `**DO**: …`  /  `**DON'T**: …`
319 *   - Prose form:            `DO …`       /  `DO NOT …`
320 *
321 * Defaults to the main impeccable SKILL.md but accepts any relative path so
322 * rules in `cli/engine/detect-antipatterns.mjs` can anchor to register-specific
323 * reference files (e.g. `reference/editorial.md`) via an optional `skillFile`
324 * field. Callers that don't pass `relativePath` get the legacy behavior.
325 *
326 * Returns { patterns: [...], antipatterns: [...] }
327 */
328// Curated short-list for the homepage Antidote section. Intentionally
329// hand-written (not auto-extracted) so the copy stays tight and
330// editorial. The long-form catalog lives on /slop — this is the teaser.
331const CURATED_CATEGORIES = [
332  {
333    name: 'Typography',
334    do: [
335      'Pair a distinctive display face with a restrained body face; vary across projects.',
336      'Use a ≥1.25 scale ratio between hierarchy steps. Flat scales read as bland.',
337      'Cap body line length at 65–75ch. Wider is fatiguing.',
338    ],
339    dont: [
340      'Inter, Roboto, Plex, Fraunces, or any other reflex default. Look further.',
341      'Monospace as lazy shorthand for "technical."',
342      'Long passages in uppercase. Reserve all-caps for short labels.',
343    ],
344  },
345  {
346    name: 'Color & Contrast',
347    do: [
348      'Use OKLCH. Reduce chroma near lightness extremes.',
349      'Tint neutrals toward the brand hue. Chroma 0.005–0.01 is enough.',
350      'Pick a color strategy before picking colors (Restrained, Committed, Full, Drenched).',
351    ],
352    dont: [
353      'Pure #000 or #fff. Always tint.',
354      'Dark mode + purple-to-cyan gradients. The AI tell.',
355      'Gradient text via background-clip. Use weight or size for emphasis.',
356    ],
357  },
358  {
359    name: 'Layout & Space',
360    do: [
361      'Vary spacing for rhythm. Tight groupings, generous separations.',
362      'Use the simplest tool: Flexbox for 1D, Grid for 2D, plain flow often enough.',
363      'Let whitespace carry hierarchy before reaching for color or scale.',
364    ],
365    dont: [
366      'Wrap everything in cards. Nested cards are always wrong.',
367      'Identical card grids of icon + heading + text, repeated endlessly.',
368      'The hero-metric template: big number, small label, supporting stats, gradient accent.',
369    ],
370  },
371  {
372    name: 'Visual Details',
373    do: [
374      'Commit to an aesthetic direction and execute it with precision.',
375      'Use ornament only where it earns its place.',
376    ],
377    dont: [
378      'Side-stripe borders (border-left/-right > 1px). The dashboard tell.',
379      'Glassmorphism everywhere. Rare and purposeful or nothing.',
380      'Rounded rectangles with generic drop shadows. "Could be any AI output."',
381    ],
382  },
383  {
384    name: 'Motion',
385    do: [
386      'Use transform and opacity. Animate the composited properties only.',
387      'Ease out with exponential curves (quart / quint / expo).',
388      'Respect prefers-reduced-motion on every transition.',
389    ],
390    dont: [
391      'Animate layout (width, height, padding, margin).',
392      'Bounce or elastic easing. Feels dated and tacky.',
393      'Decorative motion for its own sake. Motion should signal state.',
394    ],
395  },
396  {
397    name: 'Interaction',
398    do: [
399      'Use optimistic UI: update immediately, sync later.',
400      'Design empty states that teach the interface, not just say "nothing here."',
401      'Progressive disclosure: start simple, reveal sophistication on demand.',
402    ],
403    dont: [
404      'Make every button primary. Hierarchy matters.',
405      'Default to a modal. Exhaust inline alternatives first.',
406      'Repeat information the user can already see.',
407    ],
408  },
409];
410
411export function readPatterns(_rootDir, _relativePath) {
412  // Hand-curated list — see CURATED_CATEGORIES above. The homepage
413  // Antidote teaser uses this; the full catalog lives on /slop.
414  return {
415    patterns: CURATED_CATEGORIES.map((c) => ({ name: c.name, items: c.do })),
416    antipatterns: CURATED_CATEGORIES.map((c) => ({ name: c.name, items: c.dont })),
417  };
418}
419
420// Previous SKILL.md parser retained below but disabled; kept as a
421// reference for how prefix-style extraction used to work.
422function _legacyReadPatterns(rootDir, relativePath = 'skill/SKILL.md') {
423  const skillPath = path.join(rootDir, relativePath);
424
425  if (!fs.existsSync(skillPath)) {
426    return { patterns: [], antipatterns: [] };
427  }
428
429  const content = fs.readFileSync(skillPath, 'utf-8');
430  const lines = content.split('\n');
431
432  const patternsMap = {};  // category -> items[]
433  const antipatternsMap = {};  // category -> items[]
434  let currentSection = null;
435
436  const pushPattern = (item) => {
437    if (!currentSection) return;
438    if (!patternsMap[currentSection]) patternsMap[currentSection] = [];
439    patternsMap[currentSection].push(item);
440  };
441  const pushAntipattern = (item) => {
442    if (!currentSection) return;
443    if (!antipatternsMap[currentSection]) antipatternsMap[currentSection] = [];
444    antipatternsMap[currentSection].push(item);
445  };
446
447  for (const line of lines) {
448    const trimmed = line.trim();
449
450    // Track section headings (### Typography, ### Color & Theme, etc.)
451    if (trimmed.startsWith('### ')) {
452      currentSection = trimmed.slice(4).trim();
453      // Normalize "Color & Theme" to "Color & Contrast" for consistency
454      if (currentSection === 'Color & Theme') {
455        currentSection = 'Color & Contrast';
456      }
457      continue;
458    }
459
460    // Markdown bullet form (legacy): **DO**: ... and **DON'T**: ...
461    if (trimmed.startsWith('**DO**:')) {
462      pushPattern(trimmed.slice(7).trim());
463      continue;
464    }
465    if (trimmed.startsWith("**DON'T**:")) {
466      pushAntipattern(trimmed.slice(10).trim());
467      continue;
468    }
469
470    // XML-block prose form (current). Both space and colon variants:
471    //   "DO NOT use ..."  /  "DO NOT: Use ..."
472    //   "DO use ..."      /  "DO: Use ..."
473    // IMPORTANT: check `DO NOT` BEFORE `DO` so the prefix doesn't get
474    // gobbled by the wrong matcher.
475    if (trimmed.startsWith('DO NOT: ')) {
476      pushAntipattern(trimmed.slice('DO NOT: '.length).trim());
477      continue;
478    }
479    if (trimmed.startsWith('DO NOT ')) {
480      pushAntipattern(trimmed.slice('DO NOT '.length).trim());
481      continue;
482    }
483    if (trimmed.startsWith('DO: ')) {
484      pushPattern(trimmed.slice('DO: '.length).trim());
485      continue;
486    }
487    if (trimmed.startsWith('DO ')) {
488      pushPattern(trimmed.slice('DO '.length).trim());
489      continue;
490    }
491  }
492
493  // Convert maps to arrays in consistent order
494  const sectionOrder = ['Typography', 'Color & Contrast', 'Layout & Space', 'Visual Details', 'Motion', 'Interaction', 'Responsive', 'UX Writing'];
495
496  const patterns = [];
497  const antipatterns = [];
498
499  for (const section of sectionOrder) {
500    if (patternsMap[section] && patternsMap[section].length > 0) {
501      patterns.push({ name: section, items: patternsMap[section] });
502    }
503    if (antipatternsMap[section] && antipatternsMap[section].length > 0) {
504      antipatterns.push({ name: section, items: antipatternsMap[section] });
505    }
506  }
507
508  return { patterns, antipatterns };
509}
510
511/**
512 * Provider-specific placeholders
513 */
514export const PROVIDER_PLACEHOLDERS = {
515  'claude-code': {
516    model: 'Claude',
517    config_file: 'CLAUDE.md',
518    ask_instruction: 'STOP and call the AskUserQuestion tool to clarify.',
519    command_prefix: '/'
520  },
521  'cursor': {
522    model: 'the model',
523    config_file: '.cursorrules',
524    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
525    command_prefix: '/'
526  },
527  'gemini': {
528    model: 'Gemini',
529    config_file: 'GEMINI.md',
530    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
531    command_prefix: '/'
532  },
533  'codex': {
534    model: 'GPT',
535    config_file: 'AGENTS.md',
536    ask_instruction: "STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.",
537    command_prefix: '$'
538  },
539  'agents': {
540    model: 'the model',
541    config_file: '.github/copilot-instructions.md',
542    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
543    command_prefix: '/'
544  },
545  'kiro': {
546    model: 'Claude',
547    config_file: '.kiro/settings.json',
548    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
549    command_prefix: '/'
550  },
551  opencode: {
552    model: 'Claude',
553    config_file: 'AGENTS.md',
554    ask_instruction: 'STOP and call the `question` tool to clarify.',
555    command_prefix: '/'
556  },
557  'pi': {
558    model: 'the model',
559    config_file: 'AGENTS.md',
560    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
561    command_prefix: '/'
562  },
563  'qoder': {
564    model: 'the model',
565    config_file: 'AGENTS.md',
566    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
567    command_prefix: '/'
568  },
569  'trae': {
570    model: 'the model',
571    config_file: 'RULES.md',
572    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
573    command_prefix: '/'
574  },
575  'rovo-dev': {
576    model: 'Rovo Dev',
577    config_file: 'AGENTS.md',
578    ask_instruction: 'ask the user directly to clarify what you cannot infer.',
579    command_prefix: '/'
580  }
581};
582
583export const PROVIDER_BLOCK_TAGS = new Set([
584  'agents',
585  'claude',
586  'claude-code',
587  'codex',
588  'cursor',
589  'gemini',
590  'github',
591  'kiro',
592  'opencode',
593  'pi',
594  'qoder',
595  'rovo-dev',
596  'trae',
597  'trae-cn',
598]);
599
600/**
601 * Compile harness-conditional markdown blocks.
602 *
603 * Known provider blocks must be written as standalone tags:
604 *
605 * <codex>
606 * Codex-only instructions.
607 * </codex>
608 *
609 * Matching blocks keep their body and drop the tags. Non-matching blocks are
610 * removed. Unknown tags are preserved so ordinary markdown/HTML is untouched.
611 */
612export function compileProviderBlocks(content, activeTags = []) {
613  const activeTagSet = new Set(activeTags);
614  const providerBlockPattern = /(^|\r?\n)[ \t]*<([a-z][a-z0-9-]*)>[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*<\/\2>[ \t]*(?=\r?\n|$)/g;
615  let didCompileBlock = false;
616
617  const compiled = content.replace(providerBlockPattern, (match, prefix, tag, body) => {
618    if (!PROVIDER_BLOCK_TAGS.has(tag)) return match;
619    didCompileBlock = true;
620    return activeTagSet.has(tag) ? `${prefix}${body}` : prefix;
621  });
622
623  return didCompileBlock ? compiled.replace(/(?:\r?\n){3,}/g, '\n\n') : compiled;
624}
625
626/**
627 * Replace all {{placeholder}} tokens with provider-specific values
628 */
629function escapeRegex(str) {
630  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
631}
632
633const EXCLUDED_FROM_SUGGESTIONS = new Set([
634  'impeccable',               // foundational skill, not a steering command
635  'teach-impeccable',         // deprecated shim
636  'frontend-design',          // deprecated shim
637]);
638
639// Sub-commands of /impeccable that should appear in {{available_commands}}.
640// These are the commands that audit/critique/etc. reference when suggesting next steps.
641const IMPECCABLE_SUB_COMMANDS = [
642  'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize',
643  'critique', 'delight', 'distill', 'document', 'harden', 'layout',
644  'onboard', 'optimize', 'overdrive', 'polish', 'quieter', 'shape', 'typeset',
645];
646
647export function replacePlaceholders(content, provider, commandNames = [], allSkillNames = []) {
648  const placeholders = PROVIDER_PLACEHOLDERS[provider] || PROVIDER_PLACEHOLDERS['cursor'];
649  const cmdPrefix = placeholders.command_prefix || '/';
650
651  // Build the available_commands list.
652  // After the v3.0 consolidation, commands are sub-commands of /impeccable.
653  // If there's only one user-invocable skill (impeccable), generate sub-command references.
654  // Otherwise fall back to listing skill names (backwards compat for forks).
655  const nonExcluded = commandNames.filter(n => !EXCLUDED_FROM_SUGGESTIONS.has(n));
656  let commandList;
657  if (nonExcluded.length === 0) {
658    // Single-skill architecture: list sub-commands as /impeccable <sub>
659    commandList = IMPECCABLE_SUB_COMMANDS
660      .map(n => `${cmdPrefix}impeccable ${n}`)
661      .join(', ');
662  } else {
663    // Multi-skill architecture (backwards compat)
664    commandList = nonExcluded.map(n => `${cmdPrefix}${n}`).join(', ');
665  }
666
667  let result = content
668    .replace(/\{\{model\}\}/g, placeholders.model)
669    .replace(/\{\{config_file\}\}/g, placeholders.config_file)
670    .replace(/\{\{ask_instruction\}\}/g, placeholders.ask_instruction)
671    .replace(/\{\{command_prefix\}\}/g, cmdPrefix)
672    .replace(/\{\{available_commands\}\}/g, commandList);
673
674  // Replace `/skillname` invocations with the correct command prefix for this provider
675  // (e.g., `/normalize` → `$normalize` for Codex)
676  if (cmdPrefix !== '/' && allSkillNames.length > 0) {
677    const sorted = [...allSkillNames].sort((a, b) => b.length - a.length);
678    for (const name of sorted) {
679      result = result.replace(
680        new RegExp(`\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
681        cmdPrefix
682      );
683    }
684  }
685
686  return result;
687}
688
689/**
690 * Decide whether a YAML scalar string value must be quoted to survive parsing.
691 *
692 * Plain (unquoted) YAML scalars cannot contain `: ` or ` #`, cannot start with
693 * a YAML indicator character, cannot look like a boolean/null/number, and
694 * cannot carry leading/trailing whitespace. parseFrontmatter strips surrounding
695 * quotes on input, so we must re-detect the need to quote on output — otherwise
696 * descriptions like "Handles: critique/review..." round-trip into invalid YAML.
697 */
698function yamlNeedsQuoting(value) {
699  if (typeof value !== 'string') return false;
700  if (value === '') return true;
701  // Leading or trailing whitespace
702  if (/^\s|\s$/.test(value)) return true;
703  // Starts with a YAML flow/indicator character
704  if (/^[\[\]{},&*!|>'"%@`#]/.test(value)) return true;
705  // Starts with `?`, `:`, or `-` followed by space or end of string
706  if (/^[?:-](\s|$)/.test(value)) return true;
707  // Contains `: ` (ends plain scalar) or ` #` (starts comment), or ends with `:`
708  if (/: |\s#|:$/.test(value)) return true;
709  // Reserved keywords that YAML 1.1 parsers coerce to boolean/null
710  if (/^(true|false|null|yes|no|on|off|~)$/i.test(value)) return true;
711  // Looks like a number
712  if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) return true;
713  return false;
714}
715
716function formatYamlScalar(value) {
717  if (typeof value !== 'string') return String(value);
718  if (yamlNeedsQuoting(value)) {
719    return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
720  }
721  return value;
722}
723
724function appendYamlObject(lines, data, indent = 0) {
725  const space = ' '.repeat(indent);
726
727  for (const [key, value] of Object.entries(data)) {
728    if (Array.isArray(value)) {
729      lines.push(`${space}${key}:`);
730      for (const item of value) {
731        if (item && typeof item === 'object' && !Array.isArray(item)) {
732          lines.push(`${space}  -`);
733          appendYamlObject(lines, item, indent + 4);
734        } else {
735          lines.push(`${space}  - ${formatYamlScalar(item)}`);
736        }
737      }
738    } else if (value && typeof value === 'object') {
739      lines.push(`${space}${key}:`);
740      appendYamlObject(lines, value, indent + 2);
741    } else if (typeof value === 'boolean') {
742      lines.push(`${space}${key}: ${value}`);
743    } else {
744      lines.push(`${space}${key}: ${formatYamlScalar(value)}`);
745    }
746  }
747}
748
749/**
750 * Generate YAML frontmatter string
751 */
752export function generateYamlFrontmatter(data) {
753  const lines = ['---'];
754
755  for (const [key, value] of Object.entries(data)) {
756    if (Array.isArray(value)) {
757      lines.push(`${key}:`);
758      for (const item of value) {
759        if (typeof item === 'object') {
760          lines.push(`  - name: ${formatYamlScalar(item.name)}`);
761          if (item.description) lines.push(`    description: ${formatYamlScalar(item.description)}`);
762          if (item.required !== undefined) lines.push(`    required: ${item.required}`);
763        } else {
764          lines.push(`  - ${formatYamlScalar(item)}`);
765        }
766      }
767    } else if (typeof value === 'boolean') {
768      lines.push(`${key}: ${value}`);
769    } else {
770      lines.push(`${key}: ${formatYamlScalar(value)}`);
771    }
772  }
773
774  lines.push('---');
775  return lines.join('\n');
776}
777
778/**
779 * Generate a plain YAML document string.
780 */
781export function generateYamlDocument(data) {
782  const lines = [];
783  appendYamlObject(lines, data);
784  return lines.join('\n');
785}