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}