import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { createHash } from 'node:crypto'; import { createDebug } from './debug.js'; import { getClaudeConfigDir, getClaudeConfigJsonPath, getHudPluginDir } from './claude-config-dir.js'; const debug = createDebug('config-reader'); function getMcpServerNames(filePath) { if (!fs.existsSync(filePath)) return new Set(); try { const content = fs.readFileSync(filePath, 'utf8'); const config = JSON.parse(content); if (config.mcpServers && typeof config.mcpServers === 'object') { return new Set(Object.keys(config.mcpServers)); } } catch (error) { debug(`Failed to read MCP servers from ${filePath}:`, error); } return new Set(); } function getDisabledMcpServers(filePath, key) { if (!fs.existsSync(filePath)) return new Set(); try { const content = fs.readFileSync(filePath, 'utf8'); const config = JSON.parse(content); if (Array.isArray(config[key])) { const validNames = config[key].filter((s) => typeof s === 'string'); if (validNames.length !== config[key].length) { debug(`${key} in ${filePath} contains non-string values, ignoring them`); } return new Set(validNames); } } catch (error) { debug(`Failed to read ${key} from ${filePath}:`, error); } return new Set(); } function countMcpServersInFile(filePath, excludeFrom) { const servers = getMcpServerNames(filePath); if (excludeFrom) { const exclude = getMcpServerNames(excludeFrom); for (const name of exclude) { servers.delete(name); } } return servers.size; } function countHooksInFile(filePath) { if (!fs.existsSync(filePath)) return 0; try { const content = fs.readFileSync(filePath, 'utf8'); const config = JSON.parse(content); if (config.hooks && typeof config.hooks === 'object') { return Object.keys(config.hooks).length; } } catch (error) { debug(`Failed to read hooks from ${filePath}:`, error); } return 0; } function readStringSetting(filePath, key) { if (!fs.existsSync(filePath)) return undefined; try { const content = fs.readFileSync(filePath, 'utf8'); const config = JSON.parse(content); if (typeof config[key] === 'string') { const value = config[key].trim(); return value.length > 0 ? value : undefined; } } catch (error) { debug(`Failed to read ${key} from ${filePath}:`, error); } return undefined; } function countRulesInDir(rulesDir) { if (!fs.existsSync(rulesDir)) return 0; let count = 0; try { const entries = fs.readdirSync(rulesDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(rulesDir, entry.name); if (entry.isDirectory()) { count += countRulesInDir(fullPath); } else if (entry.isFile() && entry.name.endsWith('.md')) { count++; } } } catch (error) { debug(`Failed to read rules from ${rulesDir}:`, error); } return count; } function normalizePathForComparison(inputPath) { let normalized = path.normalize(path.resolve(inputPath)); const root = path.parse(normalized).root; while (normalized.length > root.length && normalized.endsWith(path.sep)) { normalized = normalized.slice(0, -1); } return process.platform === 'win32' ? normalized.toLowerCase() : normalized; } function pathsReferToSameLocation(pathA, pathB) { if (normalizePathForComparison(pathA) === normalizePathForComparison(pathB)) { return true; } if (!fs.existsSync(pathA) || !fs.existsSync(pathB)) { return false; } try { const realPathA = fs.realpathSync.native(pathA); const realPathB = fs.realpathSync.native(pathB); return normalizePathForComparison(realPathA) === normalizePathForComparison(realPathB); } catch (err) { debug('Failed to compare paths %s and %s:', pathA, pathB, err instanceof Error ? err.message : err); return false; } } function getConfigCachePath(cwd, claudeConfigDir, homeDir) { const identity = JSON.stringify({ cwd, claudeConfigDir }); const hash = createHash('sha256').update(identity).digest('hex'); return path.join(getHudPluginDir(homeDir), 'config-cache', `${hash}.json`); } function statSentinel(filePath) { try { const stat = fs.statSync(filePath); return { mtimeMs: stat.mtimeMs, size: stat.size }; } catch (err) { debug('Failed to stat sentinel %s:', filePath, err instanceof Error ? err.message : err); return null; } } function buildSentinelPaths(claudeDir, claudeConfigJsonPath, cwd) { // Note: We sentinel CLAUDE.md directly instead of claudeDir because the // cache itself is stored under claudeDir/plugins/, which would change // claudeDir's mtime and immediately invalidate the cache on every write. const paths = [ path.join(claudeDir, 'CLAUDE.md'), path.join(claudeDir, 'rules'), path.join(claudeDir, 'settings.json'), path.join(claudeDir, 'settings.local.json'), claudeConfigJsonPath, ]; if (cwd) { paths.push(cwd, path.join(cwd, '.claude'), path.join(cwd, '.claude', 'rules'), path.join(cwd, '.mcp.json'), path.join(cwd, '.claude', 'settings.json'), path.join(cwd, '.claude', 'settings.local.json')); } return paths; } function collectRuleDirectorySentinels(rulesDir) { if (!fs.existsSync(rulesDir)) return []; const sentinels = [rulesDir]; try { const entries = fs.readdirSync(rulesDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) { continue; } sentinels.push(...collectRuleDirectorySentinels(path.join(rulesDir, entry.name))); } } catch (error) { debug(`Failed to read rule sentinel paths from ${rulesDir}:`, error); } return sentinels; } function statSentinels(paths) { const result = {}; for (const p of paths) { result[p] = statSentinel(p); } return result; } function ensurePrivateDir(dir) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); try { fs.chmodSync(dir, 0o700); } catch { // Best-effort: some filesystems do not support POSIX modes. } } function sentinelsMatch(a, b) { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { const sa = a[key]; const sb = b[key]; if (sa === null && sb === null) continue; if (sa === null || sb === null) return false; if (sa.mtimeMs !== sb.mtimeMs || sa.size !== sb.size) return false; } return true; } function isConfigCounts(value) { if (!value || typeof value !== 'object') { return false; } const counts = value; return (typeof counts.claudeMdCount === 'number' && Number.isFinite(counts.claudeMdCount) && counts.claudeMdCount >= 0 && typeof counts.rulesCount === 'number' && Number.isFinite(counts.rulesCount) && counts.rulesCount >= 0 && typeof counts.mcpCount === 'number' && Number.isFinite(counts.mcpCount) && counts.mcpCount >= 0 && typeof counts.hooksCount === 'number' && Number.isFinite(counts.hooksCount) && counts.hooksCount >= 0 && (counts.outputStyle === undefined || typeof counts.outputStyle === 'string')); } function readConfigCache(cacheKey, homeDir) { try { const cachePath = getConfigCachePath(cacheKey.cwd, cacheKey.claudeConfigDir, homeDir); const raw = fs.readFileSync(cachePath, 'utf8'); const parsed = JSON.parse(raw); if (parsed.key?.cwd !== cacheKey.cwd || parsed.key?.claudeConfigDir !== cacheKey.claudeConfigDir) { return null; } if (!isConfigCounts(parsed.data)) { return null; } return parsed; } catch (err) { debug('Failed to read config cache:', err instanceof Error ? err.message : err); return null; } } function writeConfigCache(key, data, homeDir) { try { const cachePath = getConfigCachePath(key.cwd, key.claudeConfigDir, homeDir); ensurePrivateDir(path.dirname(cachePath)); const payload = { key, data }; fs.writeFileSync(cachePath, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 }); try { fs.chmodSync(cachePath, 0o600); } catch { // Best-effort: some filesystems do not support POSIX modes. } } catch (err) { debug('Failed to write config cache:', err instanceof Error ? err.message : err); } } function computeConfigCountsFresh(cwd) { let claudeMdCount = 0; let rulesCount = 0; let hooksCount = 0; let outputStyle; const homeDir = os.homedir(); const claudeDir = getClaudeConfigDir(homeDir); // Collect all MCP servers across scopes, then subtract disabled ones const userMcpServers = new Set(); const projectMcpServers = new Set(); // === USER SCOPE === // ~/.claude/CLAUDE.md if (fs.existsSync(path.join(claudeDir, 'CLAUDE.md'))) { claudeMdCount++; } // ~/.claude/rules/*.md rulesCount += countRulesInDir(path.join(claudeDir, 'rules')); // ~/.claude/settings.json (MCPs and hooks) const userSettings = path.join(claudeDir, 'settings.json'); for (const name of getMcpServerNames(userSettings)) { userMcpServers.add(name); } hooksCount += countHooksInFile(userSettings); outputStyle = readStringSetting(userSettings, 'outputStyle'); const userLocalSettings = path.join(claudeDir, 'settings.local.json'); outputStyle = readStringSetting(userLocalSettings, 'outputStyle') ?? outputStyle; // {CLAUDE_CONFIG_DIR}.json (additional user-scope MCPs) const userClaudeJson = getClaudeConfigJsonPath(homeDir); for (const name of getMcpServerNames(userClaudeJson)) { userMcpServers.add(name); } // Get disabled user-scope MCPs from ~/.claude.json const disabledUserMcps = getDisabledMcpServers(userClaudeJson, 'disabledMcpServers'); for (const name of disabledUserMcps) { userMcpServers.delete(name); } // === PROJECT SCOPE === // Avoid double-counting when project .claude directory is the same location as user scope. const projectClaudeDir = cwd ? path.join(cwd, '.claude') : null; const projectClaudeOverlapsUserScope = projectClaudeDir ? pathsReferToSameLocation(projectClaudeDir, claudeDir) : false; if (cwd) { // {cwd}/CLAUDE.md if (fs.existsSync(path.join(cwd, 'CLAUDE.md'))) { claudeMdCount++; } // {cwd}/CLAUDE.local.md if (fs.existsSync(path.join(cwd, 'CLAUDE.local.md'))) { claudeMdCount++; } // {cwd}/.claude/CLAUDE.md (alternative location, skip when it is user scope) if (!projectClaudeOverlapsUserScope && fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.md'))) { claudeMdCount++; } // {cwd}/.claude/CLAUDE.local.md if (fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.local.md'))) { claudeMdCount++; } // {cwd}/.claude/rules/*.md (recursive) // Skip when it overlaps with user-scope rules. if (!projectClaudeOverlapsUserScope) { rulesCount += countRulesInDir(path.join(cwd, '.claude', 'rules')); } // {cwd}/.mcp.json (project MCP config) - tracked separately for disabled filtering const mcpJsonServers = getMcpServerNames(path.join(cwd, '.mcp.json')); // {cwd}/.claude/settings.json (project settings) // Skip when it overlaps with user-scope settings. const projectSettings = path.join(cwd, '.claude', 'settings.json'); if (!projectClaudeOverlapsUserScope) { for (const name of getMcpServerNames(projectSettings)) { projectMcpServers.add(name); } hooksCount += countHooksInFile(projectSettings); outputStyle = readStringSetting(projectSettings, 'outputStyle') ?? outputStyle; } // {cwd}/.claude/settings.local.json (local project settings) const localSettings = path.join(cwd, '.claude', 'settings.local.json'); for (const name of getMcpServerNames(localSettings)) { projectMcpServers.add(name); } hooksCount += countHooksInFile(localSettings); outputStyle = readStringSetting(localSettings, 'outputStyle') ?? outputStyle; // Get disabled .mcp.json servers from settings.local.json const disabledMcpJsonServers = getDisabledMcpServers(localSettings, 'disabledMcpjsonServers'); for (const name of disabledMcpJsonServers) { mcpJsonServers.delete(name); } // Add remaining .mcp.json servers to project set for (const name of mcpJsonServers) { projectMcpServers.add(name); } } // Total MCP count = user servers + project servers // Note: Deduplication only occurs within each scope, not across scopes. // A server with the same name in both user and project scope counts as 2 (separate configs). const mcpCount = userMcpServers.size + projectMcpServers.size; return { claudeMdCount, rulesCount, mcpCount, hooksCount, outputStyle }; } export async function countConfigs(cwd) { const homeDir = os.homedir(); const claudeDir = getClaudeConfigDir(homeDir); const claudeConfigJsonPath = getClaudeConfigJsonPath(homeDir); const normalizedCwd = cwd ? path.resolve(cwd) : null; const staticSentinelPaths = buildSentinelPaths(claudeDir, claudeConfigJsonPath, normalizedCwd); const cached = readConfigCache({ cwd: normalizedCwd, claudeConfigDir: claudeDir }, homeDir); const cacheValidationPaths = cached ? Array.from(new Set([...staticSentinelPaths, ...Object.keys(cached.key.sentinels)])) : staticSentinelPaths; const currentSentinels = statSentinels(cacheValidationPaths); if (cached && sentinelsMatch(cached.key.sentinels, currentSentinels)) { return cached.data; } const result = computeConfigCountsFresh(cwd); const ruleSentinelPaths = collectRuleDirectorySentinels(path.join(claudeDir, 'rules')); const projectClaudeDir = normalizedCwd ? path.join(normalizedCwd, '.claude') : null; const projectClaudeOverlapsUserScope = projectClaudeDir ? pathsReferToSameLocation(projectClaudeDir, claudeDir) : false; if (normalizedCwd && !projectClaudeOverlapsUserScope) { ruleSentinelPaths.push(...collectRuleDirectorySentinels(path.join(normalizedCwd, '.claude', 'rules'))); } const cacheSentinelPaths = Array.from(new Set([...staticSentinelPaths, ...ruleSentinelPaths])); const cacheKey = { cwd: normalizedCwd, claudeConfigDir: claudeDir, sentinels: statSentinels(cacheSentinelPaths), }; writeConfigCache(cacheKey, result, homeDir); return result; } //# sourceMappingURL=config-reader.js.map