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}