ktg-plugin-marketplace/plugins/config-audit/scanners/lib/active-config-reader.mjs
Kjell Tore Guttormsen 4f1cc7e0b7 feat(config-audit): v3.1.0 — /config-audit whats-active inventory command
New read-only command that shows everything Claude Code actually loads for a
given repo — plugins, skills, MCP servers, hooks, CLAUDE.md cascade — with
source attribution (user/project/plugin) and rough token estimates. Helps
identify candidates for disabling without guessing.

Added:
- scanners/lib/active-config-reader.mjs — pure async helper: readActiveConfig,
  detectGitRoot, walkClaudeMdCascade, readClaudeJsonProjectSlice (longest-prefix
  matching for .claude.json projects), enumeratePlugins, enumerateSkills,
  readActiveHooks, readActiveMcpServers, estimateTokens (markdown 4 c/tok,
  json 3.5 c/tok, frontmatter cap 150 tokens, item flat 15)
- scanners/whats-active.mjs — thin CLI shim: --json, --output-file, --verbose,
  --suggest-disables
- commands/whats-active.md — renders tables via Read tool; honors UX rules
- tests/lib/active-config-reader.test.mjs — 36 tests, all green (integration
  fixture built in tmpdir with fake HOME, .claude.json prefix matching,
  plugin discovery, hook/MCP merge from all scopes)

Verified:
- Performance budget: <2s wall-clock (smoke test: 102ms on real repo)
- Token estimates within ±20% of hand-computed values
- Read-only: no writeFile/mkdir/unlink in production code
- Self-audit: Plugin Health scanner reports 0 findings (Grade A)
- Full test suite: 522 tests, 512 pass (10 pre-existing conflict-detector
  failures on main — unrelated to this change, reproducible on clean HEAD)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 21:50:20 +02:00

827 lines
30 KiB
JavaScript

/**
* Active Config Reader — enumerates everything Claude Code actually loads for a repo.
* Read-only helper used by `scanners/whats-active.mjs` and the `whats-active` command.
*
* All functions are async and side-effect-free (no writes).
* Zero external dependencies.
*/
import { readFile, readdir, stat, realpath } from 'node:fs/promises';
import { join, resolve, dirname, basename, isAbsolute, sep } from 'node:path';
import { parseFrontmatter, parseJson, findImports } from './yaml-parser.mjs';
import { lineCount, normalizePath } from './string-utils.mjs';
import { discoverPlugins } from '../plugin-health-scanner.mjs';
const SCHEMA_VERSION = '1.0.0';
// ─────────────────────────────────────────────────────────────────────────
// Token estimation
// ─────────────────────────────────────────────────────────────────────────
/**
* Estimate tokens for a given byte count and content kind.
* Deterministic heuristic — see feature plan §4 for rationale.
*
* @param {number} bytes - Byte count (or item count for kind='item')
* @param {'markdown'|'frontmatter'|'json'|'item'} kind
* @returns {number} Integer token count (rounded up)
*/
export function estimateTokens(bytes, kind = 'markdown') {
if (kind === 'item') return 15;
if (typeof bytes !== 'number' || bytes < 0 || !Number.isFinite(bytes)) return 0;
if (kind === 'frontmatter') {
const capped = Math.min(bytes, 600);
return Math.ceil(capped / 4);
}
if (kind === 'json') return Math.ceil(bytes / 3.5);
// default: markdown
return Math.ceil(bytes / 4);
}
// ─────────────────────────────────────────────────────────────────────────
// Git root detection
// ─────────────────────────────────────────────────────────────────────────
/**
* Walk up from startPath looking for a .git directory (or .git file for worktrees).
* @param {string} startPath
* @returns {Promise<string | null>} absolute path to git root, or null if none
*/
export async function detectGitRoot(startPath) {
let current = resolve(startPath);
const root = resolve('/');
while (current !== root) {
try {
await stat(join(current, '.git'));
return current;
} catch { /* not here */ }
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────
// CLAUDE.md cascade
// ─────────────────────────────────────────────────────────────────────────
/**
* Enumerate all CLAUDE.md files that load for a given repo path, in load order:
* managed → user (~/.claude/CLAUDE.md) → ancestor CLAUDE.md (walking up to $HOME) →
* repo CLAUDE.md → @imports (recursive, deduped).
*
* Each file in the result includes absolute path, scope, bytes, lines, and parent.
* Imports are marked with scope='import' and `parent` is the absolute path of the
* file that imported them.
*
* @param {string} repoPath
* @returns {Promise<{ files: Array<{path:string, scope:string, bytes:number, lines:number, parent:string|null}>, totalBytes:number, totalLines:number, estimatedTokens:number }>}
*/
export async function walkClaudeMdCascade(repoPath) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const absRepoPath = resolve(repoPath);
const files = [];
const seen = new Set();
// Managed locations (platform-dependent, best effort)
const managedCandidates = [
'/Library/Application Support/ClaudeCode/CLAUDE.md',
'/etc/claude-code/CLAUDE.md',
];
for (const p of managedCandidates) {
await tryAddClaudeMd(p, 'managed', null, files, seen);
}
// User: ~/.claude/CLAUDE.md
if (home) {
await tryAddClaudeMd(join(home, '.claude', 'CLAUDE.md'), 'user', null, files, seen);
}
// Ancestors between $HOME and repoPath (exclusive of $HOME, inclusive of repoPath)
const ancestorChain = buildAncestorChain(absRepoPath, home);
for (const ancestor of ancestorChain) {
const candidate = join(ancestor, 'CLAUDE.md');
const scope = ancestor === absRepoPath ? 'project' : 'project';
await tryAddClaudeMd(candidate, scope, null, files, seen);
// Also project-local variant
if (ancestor === absRepoPath) {
await tryAddClaudeMd(join(ancestor, 'CLAUDE.local.md'), 'local', null, files, seen);
}
}
// Recursively resolve @imports from all files found so far
const queue = files.slice();
while (queue.length > 0) {
const parent = queue.shift();
let content;
try {
content = await readFile(parent.path, 'utf-8');
} catch { continue; }
const imports = findImports(content);
for (const imp of imports) {
const resolved = resolveImportPath(imp.path, parent.path, home);
if (!resolved || seen.has(resolved)) continue;
const added = await tryAddClaudeMd(resolved, 'import', parent.path, files, seen);
if (added) queue.push(added);
}
}
const totalBytes = files.reduce((sum, f) => sum + f.bytes, 0);
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
const estimatedTokens = estimateTokens(totalBytes, 'markdown');
return { files, totalBytes, totalLines, estimatedTokens };
}
async function tryAddClaudeMd(absPath, scope, parent, files, seen) {
if (seen.has(absPath)) return null;
try {
const s = await stat(absPath);
if (!s.isFile()) return null;
const content = await readFile(absPath, 'utf-8');
const entry = {
path: absPath,
scope,
bytes: s.size,
lines: lineCount(content),
parent,
};
files.push(entry);
seen.add(absPath);
return entry;
} catch {
return null;
}
}
function buildAncestorChain(absRepoPath, home) {
const chain = [];
let current = absRepoPath;
const normalizedHome = home ? resolve(home) : null;
const fsRoot = resolve('/');
while (current !== fsRoot) {
if (normalizedHome && current === normalizedHome) break;
chain.push(current);
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
// Load order: outer → inner (so we reverse the walked-up chain)
return chain.reverse();
}
function resolveImportPath(importPath, fromFile, home) {
let p = importPath.trim();
if (!p) return null;
if (p.startsWith('~/')) p = join(home, p.slice(2));
else if (p.startsWith('~')) p = join(home, p.slice(1));
if (!isAbsolute(p)) p = resolve(dirname(fromFile), p);
return p;
}
// ─────────────────────────────────────────────────────────────────────────
// .claude.json project slice
// ─────────────────────────────────────────────────────────────────────────
/**
* Read ~/.claude.json and return the best-matching projects slice for repoPath.
* Uses longest-prefix matching — if two keys match, the deeper one wins.
* Paths are normalized (trailing slashes stripped) before comparison.
*
* @param {string} repoPath
* @returns {Promise<{ projectKey: string|null, mcpServers: object, enabledMcpjsonServers: string[], disabledMcpjsonServers: string[], enabledPlugins: object, raw: object|null }>}
*/
export async function readClaudeJsonProjectSlice(repoPath) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const claudeJsonPath = join(home, '.claude.json');
const empty = {
projectKey: null,
mcpServers: {},
enabledMcpjsonServers: [],
disabledMcpjsonServers: [],
enabledPlugins: {},
raw: null,
};
let content;
try {
const s = await stat(claudeJsonPath);
// Safety: skip pathologically large files (>10MB)
if (s.size > 10 * 1024 * 1024) return empty;
content = await readFile(claudeJsonPath, 'utf-8');
} catch {
return empty;
}
const parsed = parseJson(content);
if (!parsed) return empty;
const target = normalizePath(resolve(repoPath));
const projects = parsed.projects || {};
const keys = Object.keys(projects);
// Exact match first, then longest prefix (with path-boundary check)
let best = null;
let bestLen = -1;
for (const key of keys) {
const normKey = normalizePath(key);
if (normKey === target) { best = key; bestLen = normKey.length; break; }
// ancestor prefix: target must start with key followed by sep
if (target === normKey || target.startsWith(normKey + sep)) {
if (normKey.length > bestLen) {
best = key;
bestLen = normKey.length;
}
}
}
if (!best) return { ...empty, raw: parsed };
const slice = projects[best] || {};
return {
projectKey: best,
mcpServers: slice.mcpServers || {},
enabledMcpjsonServers: Array.isArray(slice.enabledMcpjsonServers) ? slice.enabledMcpjsonServers : [],
disabledMcpjsonServers: Array.isArray(slice.disabledMcpjsonServers) ? slice.disabledMcpjsonServers : [],
enabledPlugins: slice.enabledPlugins || {},
raw: parsed,
};
}
// ─────────────────────────────────────────────────────────────────────────
// Plugin enumeration
// ─────────────────────────────────────────────────────────────────────────
/**
* Enumerate all plugins installed under ~/.claude/plugins/marketplaces.
* For each plugin: counts commands, agents, skills, hooks, rules; reads version from plugin.json.
*
* @returns {Promise<Array<{name:string, path:string, version:string|null, commands:number, agents:number, skills:number, hooks:number, rules:number, totalBytes:number, estimatedTokens:number}>>}
*/
export async function enumeratePlugins() {
const home = process.env.HOME || process.env.USERPROFILE || '';
if (!home) return [];
const marketplacesRoot = join(home, '.claude', 'plugins', 'marketplaces');
const pluginRoots = await discoverAllPluginsUnder(marketplacesRoot);
// Dedupe via realpath (symlinks are common)
const seen = new Set();
const results = [];
for (const root of pluginRoots) {
let canonical = root;
try { canonical = await realpath(root); } catch { /* ignore */ }
if (seen.has(canonical)) continue;
seen.add(canonical);
const info = await countPluginItems(root);
let version = null;
let name = basename(root);
try {
const pluginJson = await readFile(join(root, '.claude-plugin', 'plugin.json'), 'utf-8');
const parsed = parseJson(pluginJson);
if (parsed) {
version = parsed.version || null;
if (parsed.name) name = parsed.name;
}
} catch { /* no plugin.json */ }
results.push({
name,
path: root,
version,
commands: info.commands,
agents: info.agents,
skills: info.skills,
hooks: info.hooks,
rules: info.rules,
totalBytes: info.totalBytes,
estimatedTokens: info.estimatedTokens,
});
}
return results;
}
async function discoverAllPluginsUnder(marketplacesRoot) {
const results = [];
let marketplaces;
try {
marketplaces = await readdir(marketplacesRoot, { withFileTypes: true });
} catch {
return results;
}
for (const m of marketplaces) {
if (!m.isDirectory()) continue;
const mpDir = join(marketplacesRoot, m.name);
// A marketplace has either a `plugins/` dir or plugins directly
const pluginsDir = join(mpDir, 'plugins');
const found = await discoverPlugins(pluginsDir).catch(() => []);
if (found.length > 0) {
results.push(...found);
} else {
// Fallback: treat marketplace itself as plugin root to scan
const alt = await discoverPlugins(mpDir).catch(() => []);
results.push(...alt);
}
}
return results;
}
async function countPluginItems(pluginRoot) {
const counts = { commands: 0, agents: 0, skills: 0, hooks: 0, rules: 0, totalBytes: 0, estimatedTokens: 0 };
// Commands (frontmatter — only small portion loaded at startup)
const commandsDir = join(pluginRoot, 'commands');
const commandFiles = await listMarkdownFiles(commandsDir);
counts.commands = commandFiles.length;
for (const f of commandFiles) {
counts.totalBytes += f.size;
counts.estimatedTokens += estimateTokens(f.size, 'frontmatter');
}
// Agents (frontmatter similarly)
const agentsDir = join(pluginRoot, 'agents');
const agentFiles = await listMarkdownFiles(agentsDir);
counts.agents = agentFiles.length;
for (const f of agentFiles) {
counts.totalBytes += f.size;
counts.estimatedTokens += estimateTokens(f.size, 'frontmatter');
}
// Skills (SKILL.md bodies)
const skillsDir = join(pluginRoot, 'skills');
const skillFiles = await findSkillMdFiles(skillsDir);
counts.skills = skillFiles.length;
for (const f of skillFiles) {
counts.totalBytes += f.size;
counts.estimatedTokens += estimateTokens(f.size, 'markdown');
}
// Hooks (hooks.json — count entries)
const hooksJsonPath = join(pluginRoot, 'hooks', 'hooks.json');
try {
const s = await stat(hooksJsonPath);
const content = await readFile(hooksJsonPath, 'utf-8');
const parsed = parseJson(content);
if (parsed && parsed.hooks && typeof parsed.hooks === 'object') {
for (const event of Object.keys(parsed.hooks)) {
const arr = parsed.hooks[event];
if (Array.isArray(arr)) {
for (const entry of arr) {
if (entry && Array.isArray(entry.hooks)) {
counts.hooks += entry.hooks.length;
} else {
counts.hooks += 1;
}
}
}
}
}
counts.totalBytes += s.size;
counts.estimatedTokens += estimateTokens(s.size, 'json');
} catch { /* no hooks */ }
// Rules
const rulesDir = join(pluginRoot, 'rules');
const altRulesDir = join(pluginRoot, '.claude', 'rules');
for (const d of [rulesDir, altRulesDir]) {
const rules = await listMarkdownFiles(d);
counts.rules += rules.length;
for (const f of rules) {
counts.totalBytes += f.size;
counts.estimatedTokens += estimateTokens(f.size, 'markdown');
}
}
return counts;
}
async function listMarkdownFiles(dir) {
const out = [];
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
for (const e of entries) {
if (!e.isFile()) continue;
if (!e.name.endsWith('.md')) continue;
const full = join(dir, e.name);
try {
const s = await stat(full);
out.push({ path: full, size: s.size });
} catch { /* skip */ }
}
return out;
}
async function findSkillMdFiles(dir) {
const out = [];
async function walk(d, depth) {
if (depth > 3) return;
let entries;
try { entries = await readdir(d, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = join(d, e.name);
if (e.isDirectory()) {
await walk(full, depth + 1);
} else if (e.isFile() && /^SKILL\.md$/i.test(e.name)) {
try {
const s = await stat(full);
out.push({ path: full, size: s.size });
} catch { /* skip */ }
}
}
}
await walk(dir, 0);
return out;
}
// ─────────────────────────────────────────────────────────────────────────
// Skills (user + plugin)
// ─────────────────────────────────────────────────────────────────────────
/**
* Enumerate SKILL.md files available to Claude Code: user skills under ~/.claude/skills
* plus all skills discovered via enumeratePlugins results.
*
* @param {Array<{name:string, path:string}>} pluginList
* @returns {Promise<Array<{name:string, source:'user'|'plugin', pluginName:string|null, path:string, bytes:number, estimatedTokens:number}>>}
*/
export async function enumerateSkills(pluginList = []) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const out = [];
if (home) {
const userSkillsDir = join(home, '.claude', 'skills');
const userSkills = await findSkillMdFiles(userSkillsDir);
for (const f of userSkills) {
out.push({
name: basename(dirname(f.path)),
source: 'user',
pluginName: null,
path: f.path,
bytes: f.size,
estimatedTokens: estimateTokens(f.size, 'markdown'),
});
}
}
for (const p of pluginList) {
const skillsDir = join(p.path, 'skills');
const skills = await findSkillMdFiles(skillsDir);
for (const f of skills) {
out.push({
name: basename(dirname(f.path)),
source: 'plugin',
pluginName: p.name,
path: f.path,
bytes: f.size,
estimatedTokens: estimateTokens(f.size, 'markdown'),
});
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────
// Hooks (user + project + plugin)
// ─────────────────────────────────────────────────────────────────────────
/**
* Read active hooks from user settings, project settings, and plugin hooks.json files.
* Does NOT dedupe — a hook loaded from two scopes is reported twice (different source).
*
* @param {string} repoPath
* @param {Array<{name:string, path:string}>} [pluginList]
* @returns {Promise<Array<{event:string, matcher:string|null, command:string, source:string, sourcePath:string, estimatedTokens:number}>>}
*/
export async function readActiveHooks(repoPath, pluginList = []) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const out = [];
// User settings
if (home) {
const userSettings = join(home, '.claude', 'settings.json');
await collectHooksFromSettings(userSettings, 'user', out);
}
// Project settings
const projSettings = join(repoPath, '.claude', 'settings.json');
const projLocal = join(repoPath, '.claude', 'settings.local.json');
await collectHooksFromSettings(projSettings, 'project', out);
await collectHooksFromSettings(projLocal, 'local', out);
// Plugin hooks.json
for (const p of pluginList) {
const hooksJson = join(p.path, 'hooks', 'hooks.json');
await collectHooksFromHooksJson(hooksJson, `plugin:${p.name}`, out);
}
return out;
}
async function collectHooksFromSettings(settingsPath, source, out) {
let content;
try { content = await readFile(settingsPath, 'utf-8'); } catch { return; }
const parsed = parseJson(content);
if (!parsed || !parsed.hooks || typeof parsed.hooks !== 'object') return;
collectHookEntries(parsed.hooks, source, settingsPath, out);
}
async function collectHooksFromHooksJson(hooksPath, source, out) {
let content;
try { content = await readFile(hooksPath, 'utf-8'); } catch { return; }
const parsed = parseJson(content);
if (!parsed || !parsed.hooks || typeof parsed.hooks !== 'object') return;
collectHookEntries(parsed.hooks, source, hooksPath, out);
}
function collectHookEntries(hooksObj, source, sourcePath, out) {
for (const event of Object.keys(hooksObj)) {
const arr = hooksObj[event];
if (!Array.isArray(arr)) continue;
for (const entry of arr) {
if (!entry) continue;
const matcher = entry.matcher || null;
const inner = Array.isArray(entry.hooks) ? entry.hooks : [entry];
for (const h of inner) {
if (!h) continue;
out.push({
event,
matcher,
command: h.command || h.script || '',
source,
sourcePath,
estimatedTokens: estimateTokens(0, 'item'),
});
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// MCP servers (project .mcp.json + ~/.claude.json + plugin)
// ─────────────────────────────────────────────────────────────────────────
/**
* Enumerate active MCP servers from project .mcp.json, ~/.claude.json project slice, and plugin .mcp.json.
* Honors disabledMcpjsonServers / disabledMcpServers lists.
*
* @param {string} repoPath
* @param {object} [claudeJsonSlice] - result of readClaudeJsonProjectSlice
* @param {Array<{name:string, path:string}>} [pluginList]
* @returns {Promise<Array<{name:string, source:string, command:string, enabled:boolean, disabledBy:string|null, estimatedTokens:number}>>}
*/
export async function readActiveMcpServers(repoPath, claudeJsonSlice = null, pluginList = []) {
const out = [];
const slice = claudeJsonSlice || await readClaudeJsonProjectSlice(repoPath);
const disabled = new Set(slice.disabledMcpjsonServers || []);
// Project .mcp.json
const projMcp = join(repoPath, '.mcp.json');
await collectMcpFromFile(projMcp, '.mcp.json', disabled, out);
// ~/.claude.json project slice
for (const [name, def] of Object.entries(slice.mcpServers || {})) {
out.push({
name,
source: '~/.claude.json:projects',
command: describeMcpCommand(def),
enabled: !disabled.has(name),
disabledBy: disabled.has(name) ? 'disabledMcpjsonServers' : null,
estimatedTokens: estimateTokens(0, 'item'),
});
}
// Plugin .mcp.json files
for (const p of pluginList) {
const pluginMcp = join(p.path, '.mcp.json');
await collectMcpFromFile(pluginMcp, `plugin:${p.name}`, disabled, out);
}
return out;
}
async function collectMcpFromFile(path, source, disabled, out) {
let content;
try { content = await readFile(path, 'utf-8'); } catch { return; }
const parsed = parseJson(content);
if (!parsed || !parsed.mcpServers || typeof parsed.mcpServers !== 'object') return;
for (const [name, def] of Object.entries(parsed.mcpServers)) {
out.push({
name,
source,
command: describeMcpCommand(def),
enabled: !disabled.has(name),
disabledBy: disabled.has(name) ? 'disabledMcpjsonServers' : null,
estimatedTokens: estimateTokens(0, 'item'),
});
}
}
function describeMcpCommand(def) {
if (!def || typeof def !== 'object') return '';
if (def.type === 'http' || def.type === 'sse') return def.url || '';
if (def.command) {
const args = Array.isArray(def.args) ? def.args.join(' ') : '';
return args ? `${def.command} ${args}` : def.command;
}
return '';
}
// ─────────────────────────────────────────────────────────────────────────
// Settings cascade
// ─────────────────────────────────────────────────────────────────────────
async function readSettingsCascade(repoPath) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const entries = [
{ scope: 'user', path: home ? join(home, '.claude', 'settings.json') : null },
{ scope: 'project', path: join(repoPath, '.claude', 'settings.json') },
{ scope: 'local', path: join(repoPath, '.claude', 'settings.local.json') },
];
const cascade = [];
for (const e of entries) {
if (!e.path) continue;
let exists = false;
let keyCount = 0;
try {
const content = await readFile(e.path, 'utf-8');
exists = true;
const parsed = parseJson(content);
if (parsed && typeof parsed === 'object') {
keyCount = Object.keys(parsed).length;
}
} catch { /* missing */ }
cascade.push({ scope: e.scope, path: e.path, exists, keyCount });
}
return cascade;
}
// ─────────────────────────────────────────────────────────────────────────
// Suggest disables (deterministic signals)
// ─────────────────────────────────────────────────────────────────────────
function buildSuggestDisables({ plugins, skills, mcpServers, claudeMdBodies }) {
const candidates = [];
// 1. Already disabled MCP servers
for (const m of mcpServers) {
if (!m.enabled) {
candidates.push({
kind: 'mcp',
name: m.name,
reason: `already disabled via ${m.disabledBy || 'config'}`,
confidence: 'high',
});
}
}
// 2. Plugin with zero items
for (const p of plugins) {
const total = p.commands + p.agents + p.skills + p.hooks;
if (total === 0) {
candidates.push({
kind: 'plugin',
name: p.name,
reason: 'plugin contains no commands, agents, skills, or hooks',
confidence: 'high',
});
}
}
// 3. Plugin unreferenced in CLAUDE.md cascade
const corpus = claudeMdBodies.join('\n').toLowerCase();
for (const p of plugins) {
if (p.commands + p.agents + p.skills + p.hooks === 0) continue;
if (!corpus.includes(p.name.toLowerCase())) {
candidates.push({
kind: 'plugin',
name: p.name,
reason: 'plugin name not mentioned in any CLAUDE.md in the cascade',
confidence: 'medium',
});
}
}
// 4. Skill from plugin whose plugin is missing
const pluginNames = new Set(plugins.map(p => p.name));
for (const s of skills) {
if (s.source === 'plugin' && s.pluginName && !pluginNames.has(s.pluginName)) {
candidates.push({
kind: 'skill',
name: s.name,
reason: `skill references plugin "${s.pluginName}" which is not installed`,
confidence: 'high',
});
}
}
return { candidates };
}
// ─────────────────────────────────────────────────────────────────────────
// One-shot readActiveConfig
// ─────────────────────────────────────────────────────────────────────────
/**
* Produce a full ActiveConfig snapshot for repoPath.
* Runs component enumerators in parallel where possible. Targets <2s wall-clock.
*
* @param {string} repoPath
* @param {object} [opts]
* @param {boolean} [opts.verbose=false]
* @param {boolean} [opts.suggestDisables=false]
* @returns {Promise<object>} see feature plan §3 for shape
*/
export async function readActiveConfig(repoPath, opts = {}) {
const start = Date.now();
const absRepoPath = resolve(repoPath);
const [
gitRoot,
claudeMd,
claudeJsonSlice,
plugins,
settingsCascade,
] = await Promise.all([
detectGitRoot(absRepoPath),
walkClaudeMdCascade(absRepoPath),
readClaudeJsonProjectSlice(absRepoPath),
enumeratePlugins(),
readSettingsCascade(absRepoPath),
]);
// Skills depend on plugins
const [skills, hooks, mcpServers] = await Promise.all([
enumerateSkills(plugins),
readActiveHooks(absRepoPath, plugins),
readActiveMcpServers(absRepoPath, claudeJsonSlice, plugins),
]);
// Totals
const totals = {
plugins: plugins.length,
skills: skills.length,
mcpServers: mcpServers.length,
hooks: hooks.length,
claudeMdFiles: claudeMd.files.length,
estimatedTokens: {
claudeMd: claudeMd.estimatedTokens,
plugins: plugins.reduce((s, p) => s + p.estimatedTokens, 0),
skills: skills.reduce((s, k) => s + k.estimatedTokens, 0),
mcpServers: mcpServers.reduce((s, m) => s + m.estimatedTokens, 0),
hooks: hooks.reduce((s, h) => s + h.estimatedTokens, 0),
grandTotal: 0,
},
};
totals.estimatedTokens.grandTotal =
totals.estimatedTokens.claudeMd +
totals.estimatedTokens.plugins +
totals.estimatedTokens.skills +
totals.estimatedTokens.mcpServers +
totals.estimatedTokens.hooks;
const warnings = [];
let suggestDisables = null;
if (opts.suggestDisables) {
const claudeMdBodies = await Promise.all(
claudeMd.files.map(async f => {
try { return await readFile(f.path, 'utf-8'); } catch { return ''; }
}),
);
suggestDisables = buildSuggestDisables({ plugins, skills, mcpServers, claudeMdBodies });
}
const result = {
meta: {
tool: 'config-audit:whats-active',
version: SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
repoPath: absRepoPath,
gitRoot,
projectKey: claudeJsonSlice.projectKey,
durationMs: Date.now() - start,
},
claudeMd,
plugins,
skills,
mcpServers,
hooks,
settings: { cascade: settingsCascade },
totals,
suggestDisables,
warnings,
};
// In non-verbose mode, drop per-file detail nobody asked for
if (!opts.verbose) {
// Keep claudeMd.files entries but strip `lines` to reduce noise. Actually
// plan says verbose adds per-file bytes/lines — so non-verbose still shows
// them in tables; we keep as-is. This block intentionally left empty.
}
return result;
}