@hugsylabs/hugsy-compiler
Version:
Configuration compiler for Claude Code settings
1,135 lines (1,134 loc) • 66.8 kB
JavaScript
/**
* @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)