/** * Config file discovery for config-audit. * Finds CLAUDE.md, settings.json, hooks.json, .mcp.json, rules/, plugin.json, etc. * Zero external dependencies. */ import { readdir, stat, readFile } from 'node:fs/promises'; import { join, resolve, relative, extname, basename, dirname, sep } from 'node:path'; const SKIP_DIRS = new Set([ 'node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__', '.next', '.nuxt', '.output', '.cache', '.turbo', '.parcel-cache', 'vendor', 'venv', '.venv', '.tox', ]); /** Config file patterns to discover */ const CONFIG_PATTERNS = { claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i, settingsJson: /^settings\.json$|^settings\.local\.json$/, mcpJson: /^\.mcp\.json$/, pluginJson: /^plugin\.json$/, hooksJson: /^hooks\.json$/, rulesDir: /^rules$/, agentsMd: /\.md$/, commandsMd: /\.md$/, skillsMd: /^SKILL\.md$/i, keybindings: /^keybindings\.json$/, claudeJson: /^\.claude\.json$/, }; /** * Discover all Claude Code config files under a target path. * @param {string} targetPath * @param {object} [opts] * @param {number} [opts.maxFiles=500] - max files to return * @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/ * @returns {Promise<{ files: ConfigFile[], skipped: number }>} * * @typedef {{ absPath: string, relPath: string, type: string, scope: string, size: number }} ConfigFile */ export async function discoverConfigFiles(targetPath, opts = {}) { const maxFiles = opts.maxFiles || 2000; const maxDepth = opts.maxDepth || 10; const files = []; const skippedRef = { count: 0 }; await walkForConfig(targetPath, targetPath, files, skippedRef, maxFiles, undefined, maxDepth); if (opts.includeGlobal) { const home = process.env.HOME || process.env.USERPROFILE || ''; const claudeDir = join(home, '.claude'); try { await stat(claudeDir); await walkForConfig(claudeDir, claudeDir, files, skippedRef, maxFiles, 'user', maxDepth); } catch { /* .claude dir doesn't exist */ } // ~/.claude.json const claudeJson = join(home, '.claude.json'); try { const s = await stat(claudeJson); files.push({ absPath: claudeJson, relPath: '.claude.json', type: 'claude-json', scope: 'user', size: s.size, }); } catch { /* doesn't exist */ } } return { files, skipped: skippedRef.count }; } /** * Walk directory tree looking for config files. */ async function walkForConfig(dir, basePath, files, skippedRef, maxFiles, forceScope, maxDepth) { if (files.length >= maxFiles) return; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (files.length >= maxFiles) break; const fullPath = join(dir, entry.name); const rel = relative(basePath, fullPath); if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) { skippedRef.count++; continue; } // Check for .claude directory (contains settings, rules, etc.) if (entry.name === '.claude' || entry.name === '.claude-plugin') { await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth); continue; } // Check for rules/ inside .claude if (entry.name === 'rules' && dirname(rel).includes('.claude')) { await walkRulesDir(fullPath, basePath, files, maxFiles, forceScope || classifyScope(rel, basePath)); continue; } // Check for agents/, commands/, skills/, hooks/ dirs if (['agents', 'commands', 'skills', 'hooks'].includes(entry.name)) { await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth); continue; } // Recurse into subdirectories (configurable depth limit) const depth = rel.split(sep).length; if (depth < maxDepth) { await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth); } } else if (entry.isFile()) { const fileType = classifyFile(entry.name, rel); if (fileType) { let s; try { s = await stat(fullPath); } catch { continue; } files.push({ absPath: fullPath, relPath: rel, type: fileType, scope: forceScope || classifyScope(rel, basePath), size: s.size, }); } } } } /** * Walk a rules directory and collect all files (including non-.md for validation). */ async function walkRulesDir(dir, basePath, files, maxFiles, scope) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (files.length >= maxFiles) break; const fullPath = join(dir, entry.name); if (entry.isFile()) { let s; try { s = await stat(fullPath); } catch { continue; } files.push({ absPath: fullPath, relPath: relative(basePath, fullPath), type: 'rule', scope, size: s.size, }); } else if (entry.isDirectory()) { await walkRulesDir(fullPath, basePath, files, maxFiles, scope); } } } /** * Classify a file by name and path. * @returns {string | null} */ function classifyFile(name, relPath) { if (CONFIG_PATTERNS.claudeMd.test(name)) return 'claude-md'; if (name === 'settings.json' || name === 'settings.local.json') { if (relPath.includes('.claude')) return 'settings-json'; } if (name === '.mcp.json') return 'mcp-json'; if (name === 'plugin.json' && relPath.includes('.claude-plugin')) return 'plugin-json'; if (name === 'hooks.json' && relPath.includes('hooks')) return 'hooks-json'; if (name === 'keybindings.json') return 'keybindings-json'; if (name === '.claude.json') return 'claude-json'; // Agent/command/skill markdown files if (name.endsWith('.md') && relPath.includes(`agents${sep}`)) return 'agent-md'; if (name.endsWith('.md') && relPath.includes(`commands${sep}`)) return 'command-md'; if (/^SKILL\.md$/i.test(name)) return 'skill-md'; return null; } /** * Determine the scope of a config file. * @returns {'managed' | 'user' | 'project' | 'local' | 'plugin'} */ function classifyScope(relPath, basePath) { if (relPath.includes('managed-settings')) return 'managed'; if (basePath.includes(`.claude${sep}plugins`)) return 'plugin'; if (relPath.includes('.local.')) return 'local'; const home = process.env.HOME || process.env.USERPROFILE || ''; if (basePath.startsWith(join(home, '.claude'))) return 'user'; return 'project'; } /** Common developer directory names under $HOME */ const DEV_DIRS = ['repos', 'projects', 'src', 'code', 'dev', 'work', 'Sites', 'Developer']; /** * Discover all root paths for a full-machine scan. * Only returns paths that actually exist on the filesystem. * @returns {Promise>} */ export async function discoverFullMachinePaths() { const home = process.env.HOME || process.env.USERPROFILE || ''; const candidates = [ // ~/.claude — deepest (plugins can be 6+ levels deep) { path: join(home, '.claude'), maxDepth: 10 }, // Managed system paths { path: '/Library/Application Support/ClaudeCode', maxDepth: 5 }, { path: '/etc/claude-code', maxDepth: 5 }, // Common developer directories ...DEV_DIRS.map(d => ({ path: join(home, d), maxDepth: 5 })), ]; const existing = []; for (const c of candidates) { try { const s = await stat(c.path); if (s.isDirectory()) existing.push(c); } catch { /* not present */ } } return existing; } /** * Discover config files across multiple root paths. * Calls discoverConfigFiles() per root with correct basePath (preserves scope/relPath). * Deduplicates files by absPath — first occurrence wins. * @param {Array<{ path: string, maxDepth: number }>} roots * @param {object} [opts] * @param {number} [opts.maxFiles=2000] - global max across all roots * @returns {Promise<{ files: ConfigFile[], skipped: number }>} */ export async function discoverConfigFilesMulti(roots, opts = {}) { const maxFiles = opts.maxFiles || 2000; const seen = new Set(); const allFiles = []; let totalSkipped = 0; for (const root of roots) { if (allFiles.length >= maxFiles) break; const result = await discoverConfigFiles(root.path, { maxFiles: maxFiles - allFiles.length, maxDepth: root.maxDepth, }); totalSkipped += result.skipped; for (const f of result.files) { if (!seen.has(f.absPath)) { seen.add(f.absPath); allFiles.push(f); } } } // Handle ~/.claude.json separately (single file, not a directory) const home = process.env.HOME || process.env.USERPROFILE || ''; const claudeJson = join(home, '.claude.json'); if (allFiles.length < maxFiles && !seen.has(claudeJson)) { try { const s = await stat(claudeJson); allFiles.push({ absPath: claudeJson, relPath: '.claude.json', type: 'claude-json', scope: 'user', size: s.size, }); } catch { /* doesn't exist */ } } return { files: allFiles, skipped: totalSkipped }; } /** * Read a file as UTF-8 text. Returns null on error or if binary. * @param {string} absPath * @returns {Promise} */ export async function readTextFile(absPath) { try { const content = await readFile(absPath, 'utf-8'); // Check for binary (null bytes in first 8KB) const sample = content.slice(0, 8192); if (sample.includes('\0')) return null; return content; } catch { return null; } }