UNPKG

claude-buddy

Version:

Claude Buddy installation and setup script

500 lines (453 loc) 15.5 kB
/** * Manifest Module for Claude Buddy Installation Script * Defines all installable components, their dependencies, and platform-specific configurations */ const path = require('path'); const { createLogger } = require('./logger'); const logger = createLogger(); // Read version from package.json (single source of truth) const packageJson = require('../package.json'); const VERSION = packageJson.version; /** * Installation manifest definition * This defines all components that can be installed */ const INSTALLATION_MANIFEST = { version: VERSION, components: [ { name: 'config', displayName: 'Configuration Files', type: 'required', source: 'dist/.claude/', target: '.claude/', dependencies: [], filePatterns: ['hooks.json', 'CLAUDE.md', 'settings.json'], description: 'Core configuration files for Claude Buddy', affectedFeatures: [ 'Project configuration', 'User settings', 'Hook configuration' ] }, { name: 'hooks', displayName: 'Python Safety Hooks', type: 'optional', source: 'dist/.claude/hooks/', target: '.claude/hooks/', dependencies: ['uv', 'python'], filePatterns: ['*.py'], description: 'Pre-execution validation hooks for safe AI automation', affectedFeatures: [ 'Command validation', 'File protection', 'Git operation safety' ] }, { name: 'commands', displayName: 'Slash Commands', type: 'required', source: 'dist/.claude/commands/', target: '.claude/commands/', dependencies: [], filePatterns: ['**/*.md'], description: 'Slash command definitions for /buddy:* commands', affectedFeatures: [ '/buddy:commit', '/buddy:spec', '/buddy:plan', '/buddy:tasks', '/buddy:docs', '/buddy:foundation', '/buddy:persona' ] }, { name: 'agents', displayName: 'Specialized Agents', type: 'required', source: 'dist/.claude/agents/', target: '.claude/agents/', dependencies: [], filePatterns: ['**/*.md'], description: 'Agent protocols for complex workflows', affectedFeatures: [ 'Feature specification agent', 'Implementation planning agent', 'Task generation agent', 'Documentation agent' ] }, { name: 'skills', displayName: 'Claude Code Skills', type: 'required', source: 'dist/.claude/skills/', target: '.claude/skills/', dependencies: [], filePatterns: ['**/*.md'], description: 'Auto-activating Claude Code Skills for progressive context loading', affectedFeatures: [ 'Persona auto-activation (12 personas)', 'Domain-specific knowledge (react, jhipster, mulesoft)', 'Document generation skills (spec, plan, tasks, docs)', 'Progressive disclosure (30-70% token savings)' ] } ], directories: [ { path: '.claude', permissions: '755', createIfMissing: true }, { path: '.claude/hooks', permissions: '755', createIfMissing: true }, { path: '.claude/commands', permissions: '755', createIfMissing: true }, { path: '.claude/commands/buddy', permissions: '755', createIfMissing: true }, { path: '.claude/agents', permissions: '755', createIfMissing: true }, { path: '.claude/skills', permissions: '755', createIfMissing: true }, { path: '.claude/skills/personas', permissions: '755', createIfMissing: true }, { path: '.claude/skills/domains', permissions: '755', createIfMissing: true }, { path: '.claude/skills/generators', permissions: '755', createIfMissing: true } ], platformSpecific: { windows: { componentOverrides: { hooks: { filePatterns: ['*.py', '*.json', '*.bat'] } }, directoryOverrides: {}, environmentVariables: { CLAUDE_BUDDY_SHELL: 'powershell' } }, darwin: { componentOverrides: {}, directoryOverrides: {}, environmentVariables: { CLAUDE_BUDDY_SHELL: 'bash' } }, linux: { componentOverrides: {}, directoryOverrides: {}, environmentVariables: { CLAUDE_BUDDY_SHELL: 'bash' } } } }; /** * Get the installation manifest * @returns {Object} Installation manifest */ function getManifest() { return JSON.parse(JSON.stringify(INSTALLATION_MANIFEST)); // Deep clone } /** * Get the manifest with platform-specific overrides applied * @param {string} platform - Platform name ('windows', 'darwin', 'linux') * @returns {Object} Platform-specific manifest */ function getManifestForPlatform(platform) { logger.debug(`Getting manifest for platform: ${platform}`); const manifest = getManifest(); // Apply platform-specific overrides if (manifest.platformSpecific && manifest.platformSpecific[platform]) { const overrides = manifest.platformSpecific[platform]; // Apply component overrides if (overrides.componentOverrides) { for (const [componentName, componentOverride] of Object.entries(overrides.componentOverrides)) { const component = manifest.components.find(c => c.name === componentName); if (component) { Object.assign(component, componentOverride); logger.verbose(`Applied platform override for component: ${componentName}`); } } } // Apply directory overrides if (overrides.directoryOverrides) { for (const [dirPath, dirOverride] of Object.entries(overrides.directoryOverrides)) { const directory = manifest.directories.find(d => d.path === dirPath); if (directory) { Object.assign(directory, dirOverride); logger.verbose(`Applied platform override for directory: ${dirPath}`); } } } // Store environment variables for later use manifest.environmentVariables = overrides.environmentVariables || {}; } return manifest; } /** * Validate the manifest structure * @param {Object} manifest - Manifest to validate * @returns {Object} Validation result { valid: boolean, errors: string[] } */ function validateManifest(manifest) { const errors = []; // Validate version if (!manifest.version || typeof manifest.version !== 'string') { errors.push('Manifest version is missing or invalid'); } else { // Validate semver format const semverRegex = /^\d+\.\d+\.\d+$/; if (!semverRegex.test(manifest.version)) { errors.push(`Invalid semver version: ${manifest.version}`); } } // Validate components array if (!Array.isArray(manifest.components)) { errors.push('Manifest components must be an array'); } else { for (let i = 0; i < manifest.components.length; i++) { const component = manifest.components[i]; const prefix = `Component ${i} (${component.name || 'unnamed'})`; // Required fields if (!component.name) { errors.push(`${prefix}: name is required`); } if (!component.type || !['required', 'optional'].includes(component.type)) { errors.push(`${prefix}: type must be 'required' or 'optional'`); } if (!component.source) { errors.push(`${prefix}: source path is required`); } if (!component.target) { errors.push(`${prefix}: target path is required`); } if (!Array.isArray(component.dependencies)) { errors.push(`${prefix}: dependencies must be an array`); } if (!Array.isArray(component.filePatterns)) { errors.push(`${prefix}: filePatterns must be an array`); } // Validate dependencies reference known dependency names const knownDependencies = ['node', 'uv', 'python', 'git']; for (const dep of component.dependencies || []) { if (!knownDependencies.includes(dep)) { errors.push(`${prefix}: unknown dependency '${dep}'`); } } // Validate paths are not absolute if (component.source && path.isAbsolute(component.source)) { errors.push(`${prefix}: source path must be relative, not absolute`); } if (component.target && path.isAbsolute(component.target)) { errors.push(`${prefix}: target path must be relative, not absolute`); } } } // Validate directories array if (!Array.isArray(manifest.directories)) { errors.push('Manifest directories must be an array'); } else { for (let i = 0; i < manifest.directories.length; i++) { const directory = manifest.directories[i]; const prefix = `Directory ${i} (${directory.path || 'unnamed'})`; if (!directory.path) { errors.push(`${prefix}: path is required`); } if (directory.path && path.isAbsolute(directory.path)) { errors.push(`${prefix}: path must be relative, not absolute`); } if (typeof directory.createIfMissing !== 'boolean') { errors.push(`${prefix}: createIfMissing must be a boolean`); } } } return { valid: errors.length === 0, errors }; } /** * Filter components by available dependencies * @param {Object} manifest - Installation manifest * @param {Array} availableDependencies - Array of dependency detection results * @returns {Object} Filtered manifest with only installable components */ function filterComponentsByDependencies(manifest, availableDependencies) { logger.debug('Filtering components by available dependencies'); const filteredManifest = JSON.parse(JSON.stringify(manifest)); // Deep clone const dependencyMap = {}; // Build dependency availability map for (const dep of availableDependencies) { dependencyMap[dep.name] = dep.available; } logger.verbose(`Available dependencies: ${JSON.stringify(dependencyMap)}`); // Filter components filteredManifest.enabledComponents = []; filteredManifest.disabledComponents = []; for (const component of manifest.components) { // Check if all dependencies are available const allDepsAvailable = component.dependencies.every(dep => dependencyMap[dep] === true); if (component.type === 'required') { // Required components are always enabled, but we track dependency issues if (!allDepsAvailable) { component.dependencyIssues = component.dependencies.filter(dep => !dependencyMap[dep]); logger.warn(`Required component '${component.name}' has missing dependencies: ${component.dependencyIssues.join(', ')}`); } filteredManifest.enabledComponents.push(component); } else if (component.type === 'optional') { // Optional components are only enabled if dependencies are available if (allDepsAvailable) { filteredManifest.enabledComponents.push(component); logger.debug(`Optional component '${component.name}' enabled`); } else { const missingDeps = component.dependencies.filter(dep => !dependencyMap[dep]); component.disabledReason = `Missing dependencies: ${missingDeps.join(', ')}`; filteredManifest.disabledComponents.push(component); logger.info(`Optional component '${component.name}' disabled: ${component.disabledReason}`); } } } logger.debug(`Enabled components: ${filteredManifest.enabledComponents.length}, Disabled: ${filteredManifest.disabledComponents.length}`); return filteredManifest; } /** * Get component by name * @param {Object} manifest - Installation manifest * @param {string} componentName - Component name to find * @returns {Object|null} Component or null if not found */ function getComponent(manifest, componentName) { return manifest.components.find(c => c.name === componentName) || null; } /** * Get all required components * @param {Object} manifest - Installation manifest * @returns {Array} Array of required components */ function getRequiredComponents(manifest) { return manifest.components.filter(c => c.type === 'required'); } /** * Get all optional components * @param {Object} manifest - Installation manifest * @returns {Array} Array of optional components */ function getOptionalComponents(manifest) { return manifest.components.filter(c => c.type === 'optional'); } /** * Get directory specification by path * @param {Object} manifest - Installation manifest * @param {string} dirPath - Directory path to find * @returns {Object|null} Directory spec or null if not found */ function getDirectory(manifest, dirPath) { return manifest.directories.find(d => d.path === dirPath) || null; } /** * Get all directories that should be created * @param {Object} manifest - Installation manifest * @returns {Array} Array of directory specifications */ function getDirectoriesToCreate(manifest) { return manifest.directories.filter(d => d.createIfMissing); } /** * Get installation summary for display * @param {Object} manifest - Installation manifest (filtered) * @returns {Object} Summary information */ function getInstallationSummary(manifest) { const summary = { totalComponents: manifest.components.length, enabledComponents: manifest.enabledComponents ? manifest.enabledComponents.length : 0, disabledComponents: manifest.disabledComponents ? manifest.disabledComponents.length : 0, totalDirectories: manifest.directories.length, requiredDirectories: manifest.directories.filter(d => d.createIfMissing).length }; return summary; } /** * Resolve source path (relative to package) * @param {string} sourcePath - Source path from manifest * @returns {string} Absolute source path */ function resolveSourcePath(sourcePath) { const fs = require('fs'); // Source paths in manifest use 'dist/' prefix for npm packages // This file is at setup/lib/manifest.js // Package root (setup/) is one level up: ../ // Repo root is two levels up: ../../ const packageRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(__dirname, '..', '..'); // Try package-bundled path first (setup/dist/.claude/) const resolvedPath = path.join(packageRoot, sourcePath); if (fs.existsSync(resolvedPath)) { return resolvedPath; } // Fallback to development repo root (claude-buddy/.claude/) // Remove 'dist/' prefix and resolve from repo root const devSourcePath = sourcePath.replace(/^dist\//, ''); const devResolvedPath = path.join(repoRoot, devSourcePath); if (fs.existsSync(devResolvedPath)) { return devResolvedPath; } // Return original resolved path if neither exists (will fail later with clear error) return resolvedPath; } /** * Resolve target path (relative to installation directory) * @param {string} targetPath - Target path from manifest * @param {string} installDirectory - Installation directory * @returns {string} Absolute target path */ function resolveTargetPath(targetPath, installDirectory) { return path.join(installDirectory, targetPath); } module.exports = { getManifest, getManifestForPlatform, validateManifest, filterComponentsByDependencies, resolveSourcePath, resolveTargetPath, getComponent, getRequiredComponents, getOptionalComponents, getDirectory, getDirectoriesToCreate, getInstallationSummary, resolveSourcePath, resolveTargetPath };