UNPKG

@hugsylabs/hugsy-compiler

Version:

Configuration compiler for Claude Code settings

1,135 lines (1,134 loc) 66.8 kB
/** * @hugsylabs/hugsy-compiler - Configuration compiler for Claude Code * Transforms simple .hugsyrc configurations into complete Claude settings.json */ import { readFileSync, existsSync } from 'fs'; import { resolve, dirname, join } from 'path'; import { pathToFileURL, fileURLToPath } from 'url'; import ora from 'ora'; export class CompilerError extends Error { details; constructor(message, details) { super(message); this.details = details; this.name = 'CompilerError'; } } export class Compiler { projectRoot; presets = new Map(); plugins = new Map(); presetsCache = new Map(); compiledCommands = new Map(); options; constructor(optionsOrRoot) { // Support both new signature (options object) and old signature (projectRoot string) if (typeof optionsOrRoot === 'string') { this.options = { projectRoot: optionsOrRoot }; this.projectRoot = optionsOrRoot; } else { this.options = optionsOrRoot ?? {}; this.projectRoot = this.options.projectRoot ?? process.cwd(); } } /** * Validate generated settings.json format for Claude Code compatibility */ validateSettings(settings) { const errors = []; // Validate $schema field if (!settings.$schema) { errors.push('Missing required $schema field'); } else if (settings.$schema !== 'https://json.schemastore.org/claude-code-settings.json') { errors.push('Invalid $schema value, must be https://json.schemastore.org/claude-code-settings.json'); } // Validate permissions format if (settings.permissions) { const validPermissionPattern = /^[A-Z][a-zA-Z]*(\(.*\))?$/; ['allow', 'ask', 'deny'].forEach((type) => { const perms = settings.permissions?.[type]; if (perms && Array.isArray(perms)) { perms.forEach((perm) => { if (!validPermissionPattern.test(perm)) { errors.push(`Invalid permission format in ${type}: "${perm}". Must match Tool or Tool(pattern)`); } }); } }); } // Validate hooks format if (settings.hooks) { for (const [hookType, hookConfigs] of Object.entries(settings.hooks)) { if (!Array.isArray(hookConfigs)) { errors.push(`Hooks.${hookType} must be an array`); continue; } hookConfigs.forEach((hook, index) => { // Check if matcher is present if (!hook.matcher) { errors.push(`Hooks.${hookType}[${index}] missing required 'matcher' field`); } else { // Validate matcher format - should be tool name only, not with arguments if (hook.matcher.includes('(')) { errors.push(`Hooks.${hookType}[${index}].matcher "${hook.matcher}" should be tool name only (e.g., "Bash" not "Bash(git *)")`); } } // Check if hooks array is present if (!hook.hooks || !Array.isArray(hook.hooks)) { errors.push(`Hooks.${hookType}[${index}] missing required 'hooks' array`); } else { // Validate each hook in the array hook.hooks.forEach((h, hIndex) => { if (!h.type) { errors.push(`Hooks.${hookType}[${index}].hooks[${hIndex}] missing required 'type' field`); } else if (h.type !== 'command') { errors.push(`Hooks.${hookType}[${index}].hooks[${hIndex}].type must be "command", got "${String(h.type)}"`); } if (!h.command) { errors.push(`Hooks.${hookType}[${index}].hooks[${hIndex}] missing required 'command' field`); } if (h.timeout !== undefined && typeof h.timeout !== 'number') { errors.push(`Hooks.${hookType}[${index}].hooks[${hIndex}].timeout must be a number`); } }); } }); } } // Validate environment variables if (settings.env) { for (const [key, value] of Object.entries(settings.env)) { if (typeof value !== 'string') { errors.push(`Environment variable '${key}' must be a string, got ${typeof value}`); } } } // Validate statusLine if (settings.statusLine) { if (!settings.statusLine.type || !['command', 'static'].includes(settings.statusLine.type)) { errors.push(`statusLine.type must be 'command' or 'static', got '${settings.statusLine.type}'`); } if (settings.statusLine.type === 'command' && !settings.statusLine.command) { errors.push('statusLine.command is required when type is "command"'); } if (settings.statusLine.type === 'static' && !settings.statusLine.value) { errors.push('statusLine.value is required when type is "static"'); } } // Validate optional numeric fields if (settings.cleanupPeriodDays !== undefined && typeof settings.cleanupPeriodDays !== 'number') { errors.push(`cleanupPeriodDays must be a number, got ${typeof settings.cleanupPeriodDays}`); } // Validate boolean fields if (settings.includeCoAuthoredBy !== undefined && typeof settings.includeCoAuthoredBy !== 'boolean') { errors.push(`includeCoAuthoredBy must be a boolean, got ${typeof settings.includeCoAuthoredBy}`); } if (settings.enableAllProjectMcpServers !== undefined && typeof settings.enableAllProjectMcpServers !== 'boolean') { errors.push(`enableAllProjectMcpServers must be a boolean, got ${typeof settings.enableAllProjectMcpServers}`); } // Validate array fields if (settings.enabledMcpjsonServers && !Array.isArray(settings.enabledMcpjsonServers)) { errors.push('enabledMcpjsonServers must be an array'); } if (settings.disabledMcpjsonServers && !Array.isArray(settings.disabledMcpjsonServers)) { errors.push('disabledMcpjsonServers must be an array'); } return errors; } /** * Main compile function - transforms .hugsyrc to Claude settings.json */ async compile(config) { this.log('Starting compilation...'); // Check if config is an array (invalid) if (Array.isArray(config)) { throw new CompilerError('Configuration must be an object, not an array'); } // Check if config is an object if (typeof config !== 'object' || config === null) { throw new CompilerError('Configuration must be an object'); } // Sanitize configuration first (remove zero-width and control characters) config = this.sanitizeConfigValues(config); // Normalize field names (handle uppercase variants) config = this.normalizeConfig(config); // Validate configuration this.validateConfig(config); // Load extends (presets) if (config.extends) { const presetList = Array.isArray(config.extends) ? config.extends : [config.extends]; this.log(`Loading ${presetList.length} preset(s): ${presetList.join(', ')}`); await this.loadPresets(config.extends); } // Load plugins and apply transformations let transformedConfig = { ...config }; // Preserve inherited values from presets let inheritedValues = {}; for (const preset of this.presets.values()) { if (preset.includeCoAuthoredBy !== undefined && inheritedValues.includeCoAuthoredBy === undefined) { inheritedValues.includeCoAuthoredBy = preset.includeCoAuthoredBy; } if (preset.cleanupPeriodDays !== undefined && inheritedValues.cleanupPeriodDays === undefined) { inheritedValues.cleanupPeriodDays = preset.cleanupPeriodDays; } } // Merge inherited values into config if not already set if (transformedConfig.includeCoAuthoredBy === undefined && inheritedValues.includeCoAuthoredBy !== undefined) { transformedConfig.includeCoAuthoredBy = inheritedValues.includeCoAuthoredBy; } if (transformedConfig.cleanupPeriodDays === undefined && inheritedValues.cleanupPeriodDays !== undefined) { transformedConfig.cleanupPeriodDays = inheritedValues.cleanupPeriodDays; } if (config.plugins) { this.log(`Loading ${config.plugins.length} plugin(s): ${config.plugins.join(', ')}`); await this.loadPlugins(config.plugins); // Apply plugin transformations with progress tracking const transformSpinner = !this.options.verbose && this.plugins.size > 1 ? ora('Applying plugin transformations...').start() : null; let pluginIndex = 0; for (const [pluginPath, plugin] of this.plugins.entries()) { pluginIndex++; if (transformSpinner) { transformSpinner.text = `Applying transformation ${pluginIndex}/${this.plugins.size}: ${plugin.name ?? pluginPath}`; } if (plugin.transform && typeof plugin.transform === 'function') { const before = { env: transformedConfig.env ? { ...transformedConfig.env } : undefined, permissions: transformedConfig.permissions ? { ...transformedConfig.permissions } : undefined, }; const pluginName = plugin.name ?? pluginPath; try { // Support both sync and async transform functions const result = plugin.transform(transformedConfig); // Check if result is a Promise without using any if (result && typeof result === 'object' && 'then' in result && typeof result.then === 'function') { const awaitedResult = await result; // Check if the result is valid if (awaitedResult === undefined || awaitedResult === null) { throw new Error('Plugin transform returned undefined or null'); } transformedConfig = awaitedResult; } else { // Check if the result is valid if (result === undefined || result === null) { throw new Error('Plugin transform returned undefined or null'); } transformedConfig = result; } this.log(`[${pluginIndex}/${this.plugins.size}] Applying plugin: ${pluginName}`); // Log what changed if (before.env !== transformedConfig.env) { this.logChanges('env', before.env, transformedConfig.env); } if (before.permissions !== transformedConfig.permissions) { this.logChanges('permissions', before.permissions, transformedConfig.permissions); } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.warn(`Plugin '${pluginName}' transform failed: ${errorMsg}`); this.log(`⚠️ Plugin '${pluginName}' transform failed: ${errorMsg}`); this.log(` Skipping this plugin and continuing...`); // Continue with unchanged config } } } if (transformSpinner) { transformSpinner.succeed(`Applied ${this.plugins.size} plugin transformation(s)`); } } // Compile slash commands const commands = await this.compileCommands(transformedConfig); // Build the final settings using transformed config const settings = { $schema: 'https://json.schemastore.org/claude-code-settings.json', permissions: this.compilePermissions(transformedConfig), hooks: this.compileHooks(transformedConfig), env: this.compileEnvironment(transformedConfig), // Only include optional fields if they are explicitly set ...(transformedConfig.model !== undefined && { model: transformedConfig.model, }), ...(transformedConfig.statusLine !== undefined && { statusLine: this.validateStatusLine(transformedConfig.statusLine), }), ...(transformedConfig.includeCoAuthoredBy !== undefined && { includeCoAuthoredBy: transformedConfig.includeCoAuthoredBy, }), ...(transformedConfig.cleanupPeriodDays !== undefined && { cleanupPeriodDays: transformedConfig.cleanupPeriodDays, }), }; // Store commands for later file generation this.compiledCommands = commands; // Add optional settings if (transformedConfig.apiKeyHelper) settings.apiKeyHelper = transformedConfig.apiKeyHelper; if (transformedConfig.awsAuthRefresh) settings.awsAuthRefresh = transformedConfig.awsAuthRefresh; if (transformedConfig.awsCredentialExport) settings.awsCredentialExport = transformedConfig.awsCredentialExport; if (transformedConfig.enableAllProjectMcpServers !== undefined) { settings.enableAllProjectMcpServers = transformedConfig.enableAllProjectMcpServers; } if (transformedConfig.enabledMcpjsonServers) { settings.enabledMcpjsonServers = transformedConfig.enabledMcpjsonServers; } if (transformedConfig.disabledMcpjsonServers) { settings.disabledMcpjsonServers = transformedConfig.disabledMcpjsonServers; } // Run plugin validations on the final configuration const validationErrors = []; for (const [pluginName, plugin] of this.plugins) { if (plugin.validate && typeof plugin.validate === 'function') { try { const errors = plugin.validate(transformedConfig); if (Array.isArray(errors) && errors.length > 0) { errors.forEach((error) => { validationErrors.push(`[${pluginName}] ${error}`); }); } } catch (error) { this.log(`⚠️ Plugin '${pluginName}' validate function threw error: ${String(error)}`); } } } // Handle validation errors if (validationErrors.length > 0) { if (this.options.throwOnError) { throw new CompilerError('Configuration validation failed', { errors: validationErrors }); } else { console.warn('⚠️ Configuration validation warnings:'); validationErrors.forEach((error) => console.warn(` - ${error}`)); } } // Validate the generated settings const settingsValidationErrors = this.validateSettings(settings); if (settingsValidationErrors.length > 0) { if (this.options.throwOnError) { throw new CompilerError('Generated settings.json validation failed', { errors: settingsValidationErrors, }); } else { console.warn('⚠️ Generated settings.json validation warnings:'); settingsValidationErrors.forEach((error) => console.warn(` - ${error}`)); } } // Log compilation summary this.logCompilationSummary(settings); return settings; } /** * Validate configuration structure */ validateConfig(config) { // Check for unknown/invalid properties const validKeys = [ 'extends', 'plugins', 'env', 'permissions', 'hooks', 'commands', 'model', 'apiKeyHelper', 'cleanupPeriodDays', 'includeCoAuthoredBy', 'statusLine', 'forceLoginMethod', 'forceLoginOrgUUID', 'enableAllProjectMcpServers', 'enabledMcpjsonServers', 'disabledMcpjsonServers', 'awsAuthRefresh', 'awsCredentialExport', ]; for (const key of Object.keys(config)) { // Check for non-ASCII characters in field names // eslint-disable-next-line no-control-regex if (!/^[\x00-\x7F]+$/.test(key)) { this.handleError(`Invalid configuration field '${key}': field names must contain only ASCII characters`); continue; } // Check for zero-width or control characters // eslint-disable-next-line no-control-regex if (/[\u200B-\u200D\uFEFF\u0000-\u001F\u007F-\u009F]/.test(key)) { this.handleError(`Invalid configuration field '${key}': contains invisible or control characters`); continue; } if (!validKeys.includes(key)) { this.log(`⚠️ Warning: Unknown configuration property '${key}' will be ignored`); } } // Validate extends field if (config.extends !== undefined) { if (typeof config.extends !== 'string' && !Array.isArray(config.extends)) { this.handleError(`extends field must be a string or array of strings`, { field: 'extends', type: typeof config.extends, }); } if (Array.isArray(config.extends)) { for (const item of config.extends) { if (typeof item !== 'string') { this.handleError(`extends field must be a string or array of strings`, { field: 'extends', invalidItem: typeof item, }); } } } } // Validate permissions format if (config.permissions) { this.validatePermissions(config.permissions); } // Validate statusLine if present if (config.statusLine) { if (typeof config.statusLine !== 'object') { this.handleError(`Invalid statusLine: expected object, got ${typeof config.statusLine}`); } if (config.statusLine.type && !['command', 'static'].includes(config.statusLine.type)) { this.handleError(`Invalid statusLine.type: must be 'command' or 'static'`); } } // Validate model if present if (config.model && typeof config.model !== 'string') { this.handleError(`Invalid model: expected string, got ${typeof config.model}`); } // Validate cleanupPeriodDays if present if (config.cleanupPeriodDays !== undefined && typeof config.cleanupPeriodDays !== 'number') { this.handleError(`Invalid cleanupPeriodDays: expected number, got ${typeof config.cleanupPeriodDays}`, { suggestion: 'cleanupPeriodDays should be a number representing days (e.g., 7 for one week)', }); } // Validate env values are strings if (config.env) { for (const [key, value] of Object.entries(config.env)) { if (typeof value !== 'string') { this.handleError(`Invalid env value for '${key}': expected string, got ${typeof value}`, { value: JSON.stringify(value), suggestion: 'Environment variables must be strings. If you need to pass complex data, use JSON.stringify()', }); } } } } /** * Validate permission format - must be Tool(pattern) or Tool */ validatePermissions(permissions) { const validPattern = /^[A-Z][a-zA-Z]*(\(.*\))?$/; const validateList = (list, type) => { if (!Array.isArray(list)) return; const invalid = list.filter((p) => !validPattern.test(p)); if (invalid.length > 0) { this.handleError(`Invalid permission format in ${type}: ${invalid.join(', ')}. Permissions must match pattern: Tool or Tool(pattern)`); } }; if (permissions.allow) validateList(permissions.allow, 'allow'); if (permissions.ask) validateList(permissions.ask, 'ask'); if (permissions.deny) validateList(permissions.deny, 'deny'); } /** * Validate and return statusLine configuration */ validateStatusLine(statusLine) { if (!statusLine) return undefined; if (typeof statusLine !== 'object' || statusLine === null) { this.handleError(`Invalid statusLine configuration`); return undefined; } return statusLine; } /** * Handle errors based on options */ handleError(message, details) { const error = new CompilerError(message, details); if (this.options.verbose) { console.error(`[Hugsy Compiler Error] ${message}`, details ?? ''); } if (this.options.throwOnError) { throw error; } // Default: log error but continue with a clearer prefix console.error(`⚠️ ${message}`); } /** * Sanitize configuration values - remove zero-width and control characters */ sanitizeConfigValues(obj) { if (typeof obj === 'string') { // Remove zero-width and control characters from strings // eslint-disable-next-line no-control-regex return obj.replace(/[\u200B-\u200D\uFEFF\u0000-\u001F\u007F-\u009F]/g, ''); } if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) { obj[i] = this.sanitizeConfigValues(obj[i]); } } else if (obj && typeof obj === 'object' && obj !== null) { for (const key of Object.keys(obj)) { obj[key] = this.sanitizeConfigValues(obj[key]); } } return obj; } /** * Log verbose messages */ log(message) { if (this.options.verbose) { console.log(`ℹ️ [Hugsy Compiler] ${message}`); } } /** * Log changes made by plugins */ logChanges(field, before, after) { if (!this.options.verbose) return; if (field === 'env' && before && after && typeof before === 'object' && typeof after === 'object' && !Array.isArray(before) && !Array.isArray(after)) { const changes = []; const beforeObj = before; const afterObj = after; // Check for new keys for (const key in afterObj) { if (!(key in beforeObj)) { changes.push(` + ${key}: ${String(afterObj[key])}`); } else if (beforeObj[key] !== afterObj[key]) { changes.push(` ~ ${key}: ${String(beforeObj[key])} → ${String(afterObj[key])}`); } } // Check for removed keys for (const key in beforeObj) { if (!(key in afterObj)) { changes.push(` - ${key}`); } } if (changes.length > 0) { this.log(` Modified ${field}:`); changes.forEach((change) => this.log(change)); } } else if (field === 'permissions' && before && after && typeof before === 'object' && typeof after === 'object' && !Array.isArray(before) && !Array.isArray(after)) { const compareArrays = (type, beforeArr = [], afterArr = []) => { const added = afterArr.filter((p) => !beforeArr.includes(p)); const removed = beforeArr.filter((p) => !afterArr.includes(p)); if (added.length > 0) { this.log(` + ${type}: ${added.join(', ')}`); } if (removed.length > 0) { this.log(` - ${type}: ${removed.join(', ')}`); } }; const beforePerms = before; const afterPerms = after; compareArrays('allow', beforePerms.allow, afterPerms.allow); compareArrays('deny', beforePerms.deny, afterPerms.deny); compareArrays('ask', beforePerms.ask, afterPerms.ask); } } /** * Log compilation summary */ logCompilationSummary(settings) { if (!this.options.verbose) return; this.log('\n=== Compilation Summary ==='); // Presets summary if (this.presets.size > 0) { const presetNames = Array.from(this.presets.keys()); this.log(`Loaded ${this.presets.size} preset(s): ${presetNames.join(', ')}`); } // Plugins summary if (this.plugins.size > 0) { this.log(`Applied ${this.plugins.size} plugin(s) in order:`); let index = 1; for (const [path, plugin] of this.plugins.entries()) { const name = plugin.name ?? path; this.log(` ${index}. ${name}`); index++; } } // Permissions summary const allowCount = settings.permissions?.allow?.length ?? 0; const denyCount = settings.permissions?.deny?.length ?? 0; const askCount = settings.permissions?.ask?.length ?? 0; this.log(`Final permissions: ${allowCount} allow, ${denyCount} deny, ${askCount} ask`); // Environment variables summary const envCount = Object.keys(settings.env ?? {}).length; if (envCount > 0) { this.log(`Environment variables: ${envCount} defined`); } // Hooks summary if (settings.hooks && Object.keys(settings.hooks).length > 0) { const hookTypes = Object.keys(settings.hooks); this.log(`Hooks configured: ${hookTypes.join(', ')}`); } this.log('=========================\n'); } /** * Compile permissions from presets, plugins, and user config */ compilePermissions(config) { const permissions = { allow: [], ask: [], deny: [], }; // Collect from presets for (const preset of this.presets.values()) { if (preset.permissions) { permissions.allow.push(...(preset.permissions.allow ?? [])); permissions.ask.push(...(preset.permissions.ask ?? [])); permissions.deny.push(...(preset.permissions.deny ?? [])); } } // Collect from plugins for (const plugin of this.plugins.values()) { if (plugin.permissions) { permissions.allow.push(...(plugin.permissions.allow ?? [])); permissions.ask.push(...(plugin.permissions.ask ?? [])); permissions.deny.push(...(plugin.permissions.deny ?? [])); } } // Apply user config (overrides) if (config.permissions) { if (config.permissions.allow) { permissions.allow.push(...config.permissions.allow); } if (config.permissions.ask) { permissions.ask.push(...config.permissions.ask); } if (config.permissions.deny) { permissions.deny.push(...config.permissions.deny); } } // Remove duplicates permissions.allow = [...new Set(permissions.allow)]; permissions.ask = [...new Set(permissions.ask)]; permissions.deny = [...new Set(permissions.deny)]; // Handle conflicts: deny > ask > allow this.resolvePermissionConflicts(permissions); return permissions; } /** * Compile hooks from presets, plugins, and user config */ compileHooks(config) { const hooks = {}; // Collect from presets for (const preset of this.presets.values()) { if (preset.hooks) { this.mergeHooks(hooks, preset.hooks); } } // Collect from plugins for (const plugin of this.plugins.values()) { if (plugin.hooks) { this.mergeHooks(hooks, plugin.hooks); } } // Apply user config if (config.hooks) { this.mergeHooks(hooks, config.hooks); } // Convert all hooks to Claude Code expected format return this.normalizeHooksForClaude(hooks); } /** * Convert hooks to Claude Code expected format * Transforms simple {matcher, command} format to {matcher, hooks: [{type, command, timeout}]} * IMPORTANT: Merges all hooks with the same matcher into a single entry per Claude Code docs * IMPORTANT: Converts "Tool(args)" format to just "Tool" as Claude Code only supports tool-level matching */ normalizeHooksForClaude(hooks) { const normalized = {}; for (const [hookType, hookConfigs] of Object.entries(hooks)) { if (!hookConfigs) continue; const hookArray = Array.isArray(hookConfigs) ? hookConfigs : [hookConfigs]; // Use a Map to group hooks by matcher const matcherGroups = new Map(); for (const hook of hookArray) { if (typeof hook === 'string') { // String hook - shouldn't happen but handle it continue; } let matcher = '*'; // Default matcher for all tools let commands = []; // Check if it's already in correct format with hooks array if (hook.hooks && Array.isArray(hook.hooks)) { matcher = this.normalizeMatcherFormat(hook.matcher ?? '*'); commands = hook.hooks.map((h) => { if (typeof h === 'object' && 'command' in h) { return { type: 'command', command: h.command, timeout: h.timeout ?? 3000, }; } // If it's already properly formatted, keep it return h; }); } else if (hook.command) { // Simple format - convert to Claude Code format matcher = this.normalizeMatcherFormat(hook.matcher ?? '*'); commands = [ { type: 'command', command: hook.command, timeout: hook.timeout ?? 3000, }, ]; } // Add commands to the matcher group if (!matcherGroups.has(matcher)) { matcherGroups.set(matcher, []); } matcherGroups.get(matcher).push(...commands); } // Convert the Map to the final array format normalized[hookType] = Array.from(matcherGroups.entries()).map(([matcher, commands]) => ({ matcher, hooks: commands, })); } return normalized; } /** * Normalize matcher format from "Tool(args)" to "Tool" * Claude Code only supports tool-level matching, not argument patterns */ normalizeMatcherFormat(matcher) { // Handle common patterns if (matcher === '.*' || matcher === '') { return '*'; // Use * for all tools per Claude Code docs } // Extract tool name from "Tool(args)" format const regex = /^([A-Za-z]+)\(/; const match = regex.exec(matcher); if (match) { return match[1]; // Return just the tool name } // If it's already a simple tool name or pattern, return as is return matcher; } /** * Compile slash commands from presets, plugins, and user config */ async compileCommands(config) { const commands = new Map(); // 1. Collect from presets (lowest priority) for (const preset of this.presets.values()) { if (preset.commands) { // Presets might have different command formats if (Array.isArray(preset.commands)) { // Skip array format - these are preset references, not actual commands continue; } else if ('commands' in preset.commands) { // SlashCommandsConfig with nested commands const cmdConfig = preset.commands; if (cmdConfig.commands) { this.mergeCommands(commands, cmdConfig.commands); } } else { // Direct command mapping (shouldn't happen but handle it) this.mergeCommands(commands, preset.commands); } } } // 2. Collect from plugins (medium priority) for (const plugin of this.plugins.values()) { if (plugin.commands) { this.mergeCommands(commands, plugin.commands); } } // 3. Process user config (highest priority) if (config.commands) { await this.processUserCommands(commands, config.commands); } this.log(`Compiled ${commands.size} slash command(s)`); return commands; } /** * Process user command configuration */ async processUserCommands(commands, userCommands) { // Handle shorthand array syntax (list of preset names) if (Array.isArray(userCommands)) { for (const presetName of userCommands) { const preset = await this.loadModule(presetName, 'command-preset'); if (preset?.commands) { this.mergeCommands(commands, preset.commands); } } return; } // Handle full config object const config = userCommands; // Load command presets if (config.presets) { for (const presetName of config.presets) { const preset = await this.loadModule(presetName, 'command-preset'); if (preset?.commands) { this.mergeCommands(commands, preset.commands); } } } // Load command files (glob patterns) if (config.files) { await this.loadCommandFiles(commands, config.files); } // Apply direct command definitions (highest priority) if (config.commands) { this.mergeCommands(commands, config.commands); } } /** * Load commands from local markdown files */ async loadCommandFiles(commands, patterns) { try { // Dynamic import of glob module const { glob } = await import('glob'); for (const pattern of patterns) { const files = await glob(pattern, { cwd: this.projectRoot }); for (const file of files) { // Skip files without extensions (README, LICENSE, etc.) if (!file.includes('.')) { this.log(`Skipping file without extension: ${file}`); continue; } // Only process markdown files if (!/\.(md|markdown)$/i.test(file)) { this.log(`Skipping non-markdown file: ${file}`); continue; } const fullPath = resolve(this.projectRoot, file); if (existsSync(fullPath)) { const content = readFileSync(fullPath, 'utf-8'); const commandName = this.extractCommandName(file); this.log(`Loading command from ${file}`); this.log(`Content preview: ${content.substring(0, 150).replace(/\n/g, '\\n')}`); // Parse frontmatter if present const command = this.parseMarkdownCommand(content); // Preserve case in command names (use original case as key) const commandKey = commandName; // Keep original case commands.set(commandKey, command); this.log(`Loaded command '${commandKey}': argumentHint='${command.argumentHint}', category='${command.category}', description='${command.description}'`); } } } } catch (error) { this.log(`Failed to load command files: ${error instanceof Error ? error.message : String(error)}`); } } /** * Extract command name from file path */ extractCommandName(filePath) { // Remove extension and get basename const base = filePath.replace(/\.(md|markdown)$/i, ''); const parts = base.split('/'); // Keep original case of the command name return parts[parts.length - 1]; } /** * Parse markdown command file with optional frontmatter */ parseMarkdownCommand(content) { // Simple frontmatter parsing (between --- lines) const frontmatterMatch = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(content); if (frontmatterMatch) { try { // Parse YAML frontmatter const frontmatter = this.parseSimpleYaml(frontmatterMatch[1]); const commandContent = frontmatterMatch[2].trim(); this.log(`Parsed frontmatter: ${JSON.stringify(frontmatter)}`); const result = { content: commandContent, description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined, category: typeof frontmatter.category === 'string' ? frontmatter.category : undefined, argumentHint: typeof frontmatter.argumentHint === 'string' ? frontmatter.argumentHint : typeof frontmatter['argument-hint'] === 'string' ? frontmatter['argument-hint'] : Array.isArray(frontmatter['argument-hint']) && frontmatter['argument-hint'].length === 1 ? `[${frontmatter['argument-hint'][0]}]` : undefined, model: typeof frontmatter.model === 'string' ? frontmatter.model : undefined, allowedTools: Array.isArray(frontmatter.allowedTools) ? frontmatter.allowedTools : Array.isArray(frontmatter['allowed-tools']) ? frontmatter['allowed-tools'] : undefined, }; this.log(`Created command object: argumentHint='${result.argumentHint}', from frontmatter['argument-hint']='${JSON.stringify(frontmatter['argument-hint'])}'`); return result; } catch { // If frontmatter parsing fails, treat entire content as command } } // No frontmatter, use entire content return { content: content.trim(), category: undefined, }; } /** * Simple YAML parser for frontmatter */ parseSimpleYaml(yaml) { const result = {}; const lines = yaml.split('\n'); for (const line of lines) { const match = /^(\w[-\w]*)\s*:\s*(.*)$/.exec(line); if (match) { const key = match[1]; let value = match[2].trim(); // Handle arrays (simple case) if (value.startsWith('[') && value.endsWith(']')) { const arrayValue = value .slice(1, -1) .split(',') .map((s) => s.trim()); result[key] = arrayValue; } else { result[key] = value; } } } return result; } /** * Merge commands into the target map */ mergeCommands(target, source) { for (const [name, command] of Object.entries(source)) { if (typeof command === 'string') { target.set(name, { content: command }); } else { target.set(name, command); } this.log(`Added/updated command: ${name}`); } } /** * Get compiled commands (for use by CLI) */ getCompiledCommands() { return this.compiledCommands; } /** * Compile environment variables * Merge strategy: presets < plugins < user config (later overrides earlier) * All values are converted to strings as required by Claude settings */ compileEnvironment(config) { const env = {}; // 1. Collect from presets (lowest priority) for (const preset of this.presets.values()) { if (preset.env) { for (const [key, value] of Object.entries(preset.env)) { // Validation will catch non-string values env[key] = value; } this.log(`Applied env from preset: ${JSON.stringify(preset.env)}`); } } // 2. Collect from plugins (medium priority) for (const plugin of this.plugins.values()) { if (plugin.env) { for (const [key, value] of Object.entries(plugin.env)) { // Validation will catch non-string values env[key] = value; } this.log(`Applied env from plugin: ${JSON.stringify(plugin.env)}`); } } // 3. Apply user config (highest priority) if (config.env) { for (const [key, value] of Object.entries(config.env)) { // Validation will catch non-string values env[key] = value; } this.log(`Applied env from user config: ${JSON.stringify(config.env)}`); } return env; } /** * Load presets (extends) - recursively loads nested extends with progress tracking */ async loadPresets(extends_) { const presetNames = Array.isArray(extends_) ? extends_ : [extends_]; const useSpinner = !this.options.verbose && presetNames.length > 1; const spinner = useSpinner ? ora('Loading presets...').start() : null; for (let i = 0; i < presetNames.length; i++) { const presetName = presetNames[i]; if (spinner) { spinner.text = `Loading preset ${i + 1}/${presetNames.length}: ${presetName}`; } await this.loadPresetRecursive(presetName); } if (spinner) { spinner.succeed(`Loaded ${presetNames.length} preset(s)`); } } /** * Recursively load a preset and its extends */ async loadPresetRecursive(presetName, visitedPresets = new Set()) { // Detect circular dependencies if (visitedPresets.has(presetName)) { const cycle = Array.from(visitedPresets).concat(presetName).join(' -> '); throw new CompilerError(`Circular dependency detected: ${cycle}`); } // If already loaded successfully, skip if (this.presets.has(presetName)) { return; } // Mark as visiting to detect circular dependencies visitedPresets.add(presetName); const preset = await this.loadModule(presetName, 'preset'); if (preset && Object.keys(preset).length > 0) { // First, load any presets this preset extends const presetWithExtends = preset; if (presetWithExtends.extends) { const extends_ = Array.isArray(presetWithExtends.extends) ? presetWithExtends.extends : [presetWithExtends.extends]; for (const extendName of extends_) { await this.loadPresetRecursive(extendName, new Set(visitedPresets)); } } // Normalize preset config fields (handle uppercase variants) const normalizedPreset = this.normalizeConfig(preset); // Then add this preset (so it overrides its parents) this.presets.set(presetName, normalizedPreset); this.log(`Successfully loaded preset: ${presetName}`); } else { this.log(`Failed to load preset: ${presetName}`); } } /** * Load plugins with progress tracking */ async loadPlugins(plugins) { const useSpinner = !this.options.verbose && plugins.length > 1; const spinner = useSpinner ? ora('Loading plugins...').start() : null; let loadedCount = 0; for (let i = 0; i < plugins.length; i++) { const pluginName = plugins[i]; if (spinner) { spinner.text = `Loading plugin ${i + 1}/${plugins.length}: ${pluginName}`; } this.log(`Loading plugin: ${pluginName}`); const plugin = await this.loadModule(pluginName, 'plugin'); if (plugin) { this.plugins.set(pluginName, plugin); const name = plugin.name ?? pluginName; this.log(` ✓ Loaded plugin: ${name}`); if (plugin.transform) { this.log(` Has transform function`); } loadedCount++; } else { this.log(` ✗ Failed to load plugin: ${pluginName}`); // Provide detailed warning for plugin loading failures if (pluginName.startsWith('./') || pluginName.startsWith('../')) { const fullPath = resolve(this.projectRoot, pluginName); this.log(` ⚠️ Warning: Plugin file not found or failed to load`); this.log(` Expected location: ${fullPath}`); if (!existsSync(fullPath) && !existsSync(fullPath + '.js') && !existsSync(fullPath + '.mjs')) { this.log(` Suggestion: Check if the file exists and the path is correct`); } } if (spinner) { spinner.warn(`Failed to load plugin: ${pluginName}`); } } } if (spinner) { if (loadedCount === plugins.length) { spinner.succeed(`Loaded ${loadedCount} plugin(s)`); } else { spinner.warn(`Loaded ${loadedCount}/${plugins.length} plugin(s)`); } } } /** * Load a module (preset or plugin) */ async loadModule(moduleName, type) { this.log(`Loading ${type}: ${moduleName}`); // Check cache for presets if (type === 'preset' && this.presetsCache.has(moduleName)) { this.log(`Using cached preset: ${moduleName}`); return this.presetsCache.get(moduleName); } // Handle @hugsylabs/hugsy-compiler/* presets (built-in presets) // Also support legacy @hugsy/* for backward compatibility if (moduleName.startsWith('@hugsylabs/hugsy-compiler/') || moduleName.startsWith('@hugsy/')) { const presetName = moduleName .replace('@hugsylabs/hugsy-compiler/presets/', '') .replace('@hugsylabs/hugsy-compiler/', '') .replace('@hugsy/', ''); const builtinPath = resolve(this.projectRoot, 'packages', 'compiler', 'presets', `${presetName}.json`); // Try to find in node_modules first (for installed packages)