Files
pbakaus_impeccable/scripts/lib/utils.js
T
Paul BakausandClaude Opus 4.6 bac8696851 fix: correct AskUserQuestionTool to AskUserQuestion tool
The Claude Code tool is called AskUserQuestion, not AskUserQuestionTool.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:36:17 -07:00

388 lines
12 KiB
JavaScript

import fs from 'fs';
import path from 'path';
/**
* Parse frontmatter from markdown content
* Returns { frontmatter: object, body: string }
*/
export function parseFrontmatter(content) {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
return { frontmatter: {}, body: content };
}
const [, frontmatterText, body] = match;
const frontmatter = {};
// Simple YAML parser (handles basic key-value and arrays)
const lines = frontmatterText.split('\n');
let currentKey = null;
let currentArray = null;
for (const line of lines) {
if (!line.trim()) continue;
// Calculate indent level
const leadingSpaces = line.length - line.trimStart().length;
const trimmed = line.trim();
// Array item at level 2 (nested under a key)
if (trimmed.startsWith('- ') && leadingSpaces >= 2) {
if (currentArray) {
if (trimmed.startsWith('- name:')) {
// New object in array
const obj = {};
obj.name = trimmed.slice(7).trim();
currentArray.push(obj);
}
}
continue;
}
// Property of array object (indented further)
if (leadingSpaces >= 4 && currentArray && currentArray.length > 0) {
const colonIndex = trimmed.indexOf(':');
if (colonIndex > 0) {
const key = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
const lastObj = currentArray[currentArray.length - 1];
lastObj[key] = value === 'true' ? true : value === 'false' ? false : value;
}
continue;
}
// Top-level key-value pair
if (leadingSpaces === 0) {
const colonIndex = trimmed.indexOf(':');
if (colonIndex > 0) {
const key = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
if (value) {
frontmatter[key] = value === 'true' ? true : value === 'false' ? false : value;
currentKey = key;
currentArray = null;
} else {
// Start of array
currentKey = key;
currentArray = [];
frontmatter[key] = currentArray;
}
}
}
}
return { frontmatter, body: body.trim() };
}
/**
* Recursively read all .md files from a directory
*/
export function readFilesRecursive(dir, fileList = []) {
if (!fs.existsSync(dir)) {
return fileList;
}
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
readFilesRecursive(filePath, fileList);
} else if (file.endsWith('.md')) {
fileList.push(filePath);
}
}
return fileList;
}
/**
* Read and parse all source files (unified skills architecture)
* All source lives in source/skills/{name}/SKILL.md
* Returns { skills } where each skill has userInvokable flag
*/
export function readSourceFiles(rootDir) {
const skillsDir = path.join(rootDir, 'source/skills');
const skills = [];
if (fs.existsSync(skillsDir)) {
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(skillsDir, entry.name);
if (entry.isDirectory()) {
// Directory-based skill with potential references
const skillMdPath = path.join(entryPath, 'SKILL.md');
if (fs.existsSync(skillMdPath)) {
const content = fs.readFileSync(skillMdPath, 'utf-8');
const { frontmatter, body } = parseFrontmatter(content);
// Read reference files if they exist
const references = [];
const referenceDir = path.join(entryPath, 'reference');
if (fs.existsSync(referenceDir)) {
const refFiles = fs.readdirSync(referenceDir).filter(f => f.endsWith('.md'));
for (const refFile of refFiles) {
const refPath = path.join(referenceDir, refFile);
const refContent = fs.readFileSync(refPath, 'utf-8');
references.push({
name: path.basename(refFile, '.md'),
content: refContent,
filePath: refPath
});
}
}
skills.push({
name: frontmatter.name || entry.name,
description: frontmatter.description || '',
license: frontmatter.license || '',
compatibility: frontmatter.compatibility || '',
metadata: frontmatter.metadata || null,
allowedTools: frontmatter['allowed-tools'] || '',
userInvokable: frontmatter['user-invokable'] === true || frontmatter['user-invokable'] === 'true',
args: frontmatter.args || [],
context: frontmatter.context || null,
body,
filePath: skillMdPath,
references
});
}
}
}
}
return { skills };
}
/**
* Ensure directory exists, create if needed
*/
export function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
/**
* Clean directory (remove all contents)
*/
export function cleanDir(dirPath) {
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
}
/**
* Write file with automatic directory creation
*/
export function writeFile(filePath, content) {
const dir = path.dirname(filePath);
ensureDir(dir);
fs.writeFileSync(filePath, content, 'utf-8');
}
/**
* Extract patterns from frontend-design SKILL.md
* Parses **DO**: and **DON'T**: lines, grouped by section headings
* Returns { patterns: [...], antipatterns: [...] }
*/
export function readPatterns(rootDir) {
const skillPath = path.join(rootDir, 'source/skills/frontend-design/SKILL.md');
if (!fs.existsSync(skillPath)) {
return { patterns: [], antipatterns: [] };
}
const content = fs.readFileSync(skillPath, 'utf-8');
const lines = content.split('\n');
const patternsMap = {}; // category -> items[]
const antipatternsMap = {}; // category -> items[]
let currentSection = null;
for (const line of lines) {
const trimmed = line.trim();
// Track section headings (### Typography, ### Color & Theme, etc.)
if (trimmed.startsWith('### ')) {
currentSection = trimmed.slice(4).trim();
// Normalize "Color & Theme" to "Color & Contrast" for consistency
if (currentSection === 'Color & Theme') {
currentSection = 'Color & Contrast';
}
continue;
}
// Parse **DO**: lines
if (trimmed.startsWith('**DO**:') && currentSection) {
const item = trimmed.slice(7).trim();
if (!patternsMap[currentSection]) {
patternsMap[currentSection] = [];
}
patternsMap[currentSection].push(item);
continue;
}
// Parse **DON'T**: lines
if (trimmed.startsWith("**DON'T**:") && currentSection) {
const item = trimmed.slice(10).trim();
if (!antipatternsMap[currentSection]) {
antipatternsMap[currentSection] = [];
}
antipatternsMap[currentSection].push(item);
continue;
}
}
// Convert maps to arrays in consistent order
const sectionOrder = ['Typography', 'Color & Contrast', 'Layout & Space', 'Visual Details', 'Motion', 'Interaction', 'Responsive', 'UX Writing'];
const patterns = [];
const antipatterns = [];
for (const section of sectionOrder) {
if (patternsMap[section] && patternsMap[section].length > 0) {
patterns.push({ name: section, items: patternsMap[section] });
}
if (antipatternsMap[section] && antipatternsMap[section].length > 0) {
antipatterns.push({ name: section, items: antipatternsMap[section] });
}
}
return { patterns, antipatterns };
}
/**
* Provider-specific placeholders
*/
export const PROVIDER_PLACEHOLDERS = {
'claude-code': {
model: 'Claude',
config_file: 'CLAUDE.md',
ask_instruction: 'STOP and call the AskUserQuestion tool to clarify.'
},
'cursor': {
model: 'the model',
config_file: '.cursorrules',
ask_instruction: 'ask the user directly to clarify what you cannot infer.'
},
'gemini': {
model: 'Gemini',
config_file: 'GEMINI.md',
ask_instruction: 'ask the user directly to clarify what you cannot infer.'
},
'codex': {
model: 'GPT',
config_file: 'AGENTS.md',
ask_instruction: 'ask the user directly to clarify what you cannot infer.'
},
'agents': {
model: 'the model',
config_file: '.github/copilot-instructions.md',
ask_instruction: 'ask the user directly to clarify what you cannot infer.'
},
'kiro': {
model: 'Claude',
config_file: '.kiro/settings.json',
ask_instruction: 'ask the user directly to clarify what you cannot infer.'
},
opencode: {
model: 'Claude',
config_file: 'AGENTS.md',
ask_instruction: 'STOP and call the `question` tool to clarify.',
},
'pi': {
model: 'the model',
config_file: 'AGENTS.md',
ask_instruction: 'ask the user directly to clarify what you cannot infer.'
}
};
/**
* Replace all {{placeholder}} tokens with provider-specific values
*/
/**
* Prefix skill cross-references in body text.
* Replaces patterns like `/skillname` and `the skillname skill` with prefixed versions.
*
* @param {string} content - The skill body text
* @param {string} prefix - The prefix to add (e.g., 'i-')
* @param {string[]} skillNames - Array of all skill names
*/
export function prefixSkillReferences(content, prefix, skillNames) {
if (!prefix || !skillNames || skillNames.length === 0) return content;
let result = content;
// Sort by length descending to avoid partial matches (e.g. 'teach-impeccable' before 'teach')
const sorted = [...skillNames].sort((a, b) => b.length - a.length);
for (const name of sorted) {
const prefixed = `${prefix}${name}`;
// Replace `/skillname` references (command invocations)
result = result.replace(new RegExp(`\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'), `/${prefix}`);
// Replace `the skillname skill` references
result = result.replace(new RegExp(`the ${escapeRegex(name)} skill`, 'gi'), `the ${prefixed} skill`);
}
return result;
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const EXCLUDED_FROM_SUGGESTIONS = new Set(['teach-impeccable', 'i-teach-impeccable']);
export function replacePlaceholders(content, provider, commandNames = []) {
const placeholders = PROVIDER_PLACEHOLDERS[provider] || PROVIDER_PLACEHOLDERS['cursor'];
const commandList = commandNames
.filter(n => !EXCLUDED_FROM_SUGGESTIONS.has(n))
.map(n => `/${n}`)
.join(', ');
return content
.replace(/\{\{model\}\}/g, placeholders.model)
.replace(/\{\{config_file\}\}/g, placeholders.config_file)
.replace(/\{\{ask_instruction\}\}/g, placeholders.ask_instruction)
.replace(/\{\{available_commands\}\}/g, commandList);
}
/**
* Generate YAML frontmatter string
*/
export function generateYamlFrontmatter(data) {
const lines = ['---'];
for (const [key, value] of Object.entries(data)) {
if (Array.isArray(value)) {
lines.push(`${key}:`);
for (const item of value) {
if (typeof item === 'object') {
lines.push(` - name: ${item.name}`);
if (item.description) lines.push(` description: ${item.description}`);
if (item.required !== undefined) lines.push(` required: ${item.required}`);
} else {
lines.push(` - ${item}`);
}
}
} else if (typeof value === 'boolean') {
lines.push(`${key}: ${value}`);
} else {
lines.push(`${key}: ${value}`);
}
}
lines.push('---');
return lines.join('\n');
}