diff --git a/src/config-reader.ts b/src/config-reader.ts index c703870..89f4155 100644 --- a/src/config-reader.ts +++ b/src/config-reader.ts @@ -1,6 +1,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { createDebug } from './debug.js'; + +const debug = createDebug('config'); export interface ConfigCounts { claudeMdCount: number; @@ -9,6 +12,9 @@ export interface ConfigCounts { hooksCount: number; } +// Valid keys for disabled MCP arrays in config files +type DisabledMcpKey = 'disabledMcpServers' | 'disabledMcpjsonServers'; + function getMcpServerNames(filePath: string): Set { if (!fs.existsSync(filePath)) return new Set(); try { @@ -17,8 +23,26 @@ function getMcpServerNames(filePath: string): Set { if (config.mcpServers && typeof config.mcpServers === 'object') { return new Set(Object.keys(config.mcpServers)); } - } catch { - // Ignore errors + } catch (error) { + debug(`Failed to read MCP servers from ${filePath}:`, error); + } + return new Set(); +} + +function getDisabledMcpServers(filePath: string, key: DisabledMcpKey): Set { + 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: unknown) => 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(); } @@ -42,8 +66,8 @@ function countHooksInFile(filePath: string): number { if (config.hooks && typeof config.hooks === 'object') { return Object.keys(config.hooks).length; } - } catch { - // Ignore errors + } catch (error) { + debug(`Failed to read hooks from ${filePath}:`, error); } return 0; } @@ -61,8 +85,8 @@ function countRulesInDir(rulesDir: string): number { count++; } } - } catch { - // Ignore errors + } catch (error) { + debug(`Failed to read rules from ${rulesDir}:`, error); } return count; } @@ -70,12 +94,15 @@ function countRulesInDir(rulesDir: string): number { export async function countConfigs(cwd?: string): Promise { let claudeMdCount = 0; let rulesCount = 0; - let mcpCount = 0; let hooksCount = 0; const homeDir = os.homedir(); const claudeDir = path.join(homeDir, '.claude'); + // Collect all MCP servers across scopes, then subtract disabled ones + const userMcpServers = new Set(); + const projectMcpServers = new Set(); + // === USER SCOPE === // ~/.claude/CLAUDE.md @@ -88,12 +115,22 @@ export async function countConfigs(cwd?: string): Promise { // ~/.claude/settings.json (MCPs and hooks) const userSettings = path.join(claudeDir, 'settings.json'); - mcpCount += countMcpServersInFile(userSettings); + for (const name of getMcpServerNames(userSettings)) { + userMcpServers.add(name); + } hooksCount += countHooksInFile(userSettings); - // ~/.claude.json (additional user-scope MCPs, dedupe by counting unique) + // ~/.claude.json (additional user-scope MCPs) const userClaudeJson = path.join(homeDir, '.claude.json'); - mcpCount += countMcpServersInFile(userClaudeJson, userSettings); + 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 === @@ -121,20 +158,40 @@ export async function countConfigs(cwd?: string): Promise { // {cwd}/.claude/rules/*.md (recursive) rulesCount += countRulesInDir(path.join(cwd, '.claude', 'rules')); - // {cwd}/.mcp.json (project MCP config) - mcpCount += countMcpServersInFile(path.join(cwd, '.mcp.json')); + // {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) const projectSettings = path.join(cwd, '.claude', 'settings.json'); - mcpCount += countMcpServersInFile(projectSettings); + for (const name of getMcpServerNames(projectSettings)) { + projectMcpServers.add(name); + } hooksCount += countHooksInFile(projectSettings); // {cwd}/.claude/settings.local.json (local project settings) const localSettings = path.join(cwd, '.claude', 'settings.local.json'); - mcpCount += countMcpServersInFile(localSettings); + for (const name of getMcpServerNames(localSettings)) { + projectMcpServers.add(name); + } hooksCount += countHooksInFile(localSettings); + + // 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 }; } diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..18c43b3 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,16 @@ +// Shared debug logging utility +// Enable via: DEBUG=claude-hud or DEBUG=* + +const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*'; + +/** + * Create a namespaced debug logger + * @param namespace - Tag for log messages (e.g., 'config', 'usage') + */ +export function createDebug(namespace: string) { + return function debug(msg: string, ...args: unknown[]): void { + if (DEBUG) { + console.error(`[claude-hud:${namespace}] ${msg}`, ...args); + } + }; +} diff --git a/src/usage-api.ts b/src/usage-api.ts index fb3e2af..51aea17 100644 --- a/src/usage-api.ts +++ b/src/usage-api.ts @@ -3,16 +3,11 @@ import * as path from 'path'; import * as os from 'os'; import * as https from 'https'; import type { UsageData } from './types.js'; +import { createDebug } from './debug.js'; export type { UsageData } from './types.js'; -// Debug logging (enabled via DEBUG=claude-hud or DEBUG=*) -const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*'; -function debug(msg: string, ...args: unknown[]): void { - if (DEBUG) { - console.error(`[claude-hud:usage] ${msg}`, ...args); - } -} +const debug = createDebug('usage'); interface CredentialsFile { claudeAiOauth?: { diff --git a/tests/core.test.js b/tests/core.test.js index cb06765..ed5a5da 100644 --- a/tests/core.test.js +++ b/tests/core.test.js @@ -253,6 +253,94 @@ test('countConfigs honors project and global config locations', async () => { } }); +test('countConfigs excludes disabled user-scope MCPs', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + await mkdir(path.join(homeDir, '.claude'), { recursive: true }); + // 3 MCPs defined in settings.json + await writeFile( + path.join(homeDir, '.claude', 'settings.json'), + JSON.stringify({ mcpServers: { server1: {}, server2: {}, server3: {} } }), + 'utf8' + ); + // 1 MCP disabled in ~/.claude.json + await writeFile( + path.join(homeDir, '.claude.json'), + JSON.stringify({ disabledMcpServers: ['server2'] }), + 'utf8' + ); + + const counts = await countConfigs(); + assert.equal(counts.mcpCount, 2); // 3 - 1 disabled = 2 + } finally { + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('countConfigs excludes disabled project .mcp.json servers', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const projectDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-project-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + await mkdir(path.join(homeDir, '.claude'), { recursive: true }); + await mkdir(path.join(projectDir, '.claude'), { recursive: true }); + + // 4 MCPs in .mcp.json + await writeFile( + path.join(projectDir, '.mcp.json'), + JSON.stringify({ mcpServers: { mcp1: {}, mcp2: {}, mcp3: {}, mcp4: {} } }), + 'utf8' + ); + // 2 disabled via disabledMcpjsonServers + await writeFile( + path.join(projectDir, '.claude', 'settings.local.json'), + JSON.stringify({ disabledMcpjsonServers: ['mcp2', 'mcp4'] }), + 'utf8' + ); + + const counts = await countConfigs(projectDir); + assert.equal(counts.mcpCount, 2); // 4 - 2 disabled = 2 + } finally { + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + await rm(projectDir, { recursive: true, force: true }); + } +}); + +test('countConfigs handles all MCPs disabled', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + await mkdir(path.join(homeDir, '.claude'), { recursive: true }); + // 2 MCPs defined + await writeFile( + path.join(homeDir, '.claude', 'settings.json'), + JSON.stringify({ mcpServers: { serverA: {}, serverB: {} } }), + 'utf8' + ); + // Both disabled + await writeFile( + path.join(homeDir, '.claude.json'), + JSON.stringify({ disabledMcpServers: ['serverA', 'serverB'] }), + 'utf8' + ); + + const counts = await countConfigs(); + assert.equal(counts.mcpCount, 0); // All disabled + } finally { + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + } +}); + test('countConfigs tolerates rule directory read errors', async () => { const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); const originalHome = process.env.HOME; @@ -271,3 +359,168 @@ test('countConfigs tolerates rule directory read errors', async () => { await rm(homeDir, { recursive: true, force: true }); } }); + +test('countConfigs ignores non-string values in disabledMcpServers', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + await mkdir(path.join(homeDir, '.claude'), { recursive: true }); + // 3 MCPs defined + await writeFile( + path.join(homeDir, '.claude', 'settings.json'), + JSON.stringify({ mcpServers: { server1: {}, server2: {}, server3: {} } }), + 'utf8' + ); + // disabledMcpServers contains mixed types - only 'server2' is a valid string + await writeFile( + path.join(homeDir, '.claude.json'), + JSON.stringify({ disabledMcpServers: [123, null, 'server2', { name: 'server3' }, [], true] }), + 'utf8' + ); + + const counts = await countConfigs(); + assert.equal(counts.mcpCount, 2); // Only 'server2' disabled, server1 and server3 remain + } finally { + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('countConfigs counts same-named servers in different scopes separately', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const projectDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-project-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + await mkdir(path.join(homeDir, '.claude'), { recursive: true }); + await mkdir(path.join(projectDir, '.claude'), { recursive: true }); + + // User scope: server named 'shared-server' + await writeFile( + path.join(homeDir, '.claude', 'settings.json'), + JSON.stringify({ mcpServers: { 'shared-server': {}, 'user-only': {} } }), + 'utf8' + ); + + // Project scope: also has 'shared-server' (different config, same name) + await writeFile( + path.join(projectDir, '.mcp.json'), + JSON.stringify({ mcpServers: { 'shared-server': {}, 'project-only': {} } }), + 'utf8' + ); + + const counts = await countConfigs(projectDir); + // 'shared-server' counted in BOTH scopes (user + project) = 4 total + assert.equal(counts.mcpCount, 4); + } finally { + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + await rm(projectDir, { recursive: true, force: true }); + } +}); + +test('countConfigs uses case-sensitive matching for disabled servers', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + await mkdir(path.join(homeDir, '.claude'), { recursive: true }); + // MCP named 'MyServer' (mixed case) + await writeFile( + path.join(homeDir, '.claude', 'settings.json'), + JSON.stringify({ mcpServers: { MyServer: {}, otherServer: {} } }), + 'utf8' + ); + // Try to disable with wrong case - should NOT work + await writeFile( + path.join(homeDir, '.claude.json'), + JSON.stringify({ disabledMcpServers: ['myserver', 'MYSERVER', 'OTHERSERVER'] }), + 'utf8' + ); + + const counts = await countConfigs(); + // Both servers should still be enabled (case mismatch means not disabled) + assert.equal(counts.mcpCount, 2); + } finally { + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + } +}); + +// Regression test for GitHub Issue #3: +// "MCP count showing 5 when user has 6, still showing 5 when all disabled" +// https://github.com/jarrodwatts/claude-hud/issues/3 +test('Issue #3: MCP count updates correctly when servers are disabled', async () => { + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + const originalHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + await mkdir(path.join(homeDir, '.claude'), { recursive: true }); + + // User has 6 MCPs configured (simulating the issue reporter's setup) + await writeFile( + path.join(homeDir, '.claude.json'), + JSON.stringify({ + mcpServers: { + mcp1: { command: 'cmd1' }, + mcp2: { command: 'cmd2' }, + mcp3: { command: 'cmd3' }, + mcp4: { command: 'cmd4' }, + mcp5: { command: 'cmd5' }, + mcp6: { command: 'cmd6' }, + }, + }), + 'utf8' + ); + + // Scenario 1: No servers disabled - should show 6 + let counts = await countConfigs(); + assert.equal(counts.mcpCount, 6, 'Should show all 6 MCPs when none disabled'); + + // Scenario 2: 1 server disabled - should show 5 (this was the initial bug report state) + await writeFile( + path.join(homeDir, '.claude.json'), + JSON.stringify({ + mcpServers: { + mcp1: { command: 'cmd1' }, + mcp2: { command: 'cmd2' }, + mcp3: { command: 'cmd3' }, + mcp4: { command: 'cmd4' }, + mcp5: { command: 'cmd5' }, + mcp6: { command: 'cmd6' }, + }, + disabledMcpServers: ['mcp1'], + }), + 'utf8' + ); + counts = await countConfigs(); + assert.equal(counts.mcpCount, 5, 'Should show 5 MCPs when 1 is disabled'); + + // Scenario 3: ALL servers disabled - should show 0 (this was the main bug) + await writeFile( + path.join(homeDir, '.claude.json'), + JSON.stringify({ + mcpServers: { + mcp1: { command: 'cmd1' }, + mcp2: { command: 'cmd2' }, + mcp3: { command: 'cmd3' }, + mcp4: { command: 'cmd4' }, + mcp5: { command: 'cmd5' }, + mcp6: { command: 'cmd6' }, + }, + disabledMcpServers: ['mcp1', 'mcp2', 'mcp3', 'mcp4', 'mcp5', 'mcp6'], + }), + 'utf8' + ); + counts = await countConfigs(); + assert.equal(counts.mcpCount, 0, 'Should show 0 MCPs when all are disabled'); + } finally { + process.env.HOME = originalHome; + await rm(homeDir, { recursive: true, force: true }); + } +});