fix(acr): enumeratePlugins honors enabledPlugins + polyrepo cache installPaths (M-BUG-1)

active-config-reader walked ~/.claude/plugins/marketplaces and ignored both the
enabledPlugins toggle and the polyrepo cache layout. On a polyrepo machine it
counted disabled/uninstalled marketplaces plugins as "active" while MISSING the
actually-enabled plugins installed under plugins/cache. This corrupted the agent
listing and every pluginList consumer (manifest, AGT, whats-active, hooks, rules).

Now: when installed_plugins.json is present, inject only plugins that are in the
manifest AND enabledPlugins[key]===true, each resolved to its active installPath
(incl. cache/). When the manifest is absent (fixtures/pre-v2 installs), fall back
to the historic marketplaces walk rather than silently dropping config — mirrors
file-discovery.mjs's "trust installed_plugins.json" contract.

Verified on real machine: agent listing 114->104, ghost plugins (newsletter,
content-machine, harness, kiur, ...) gone, voyage/linkedin/ms-ai/okr now correctly
counted. Full suite 1301/0; byte-stable snapshots untouched (hermetic empty HOME).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrTb8ktf1XZWEVwgz5MTTo
This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 10:50:28 +02:00
commit be1056aac0
2 changed files with 227 additions and 7 deletions

View file

@ -327,19 +327,120 @@ export async function readClaudeJsonProjectSlice(repoPath) {
// ─────────────────────────────────────────────────────────────────────────
/**
* Enumerate all plugins installed under ~/.claude/plugins/marketplaces.
* For each plugin: counts commands, agents, skills, hooks, rules; reads version from plugin.json.
* Enumerate the plugins Claude Code actually injects for a repo.
*
* Authoritative source is `~/.claude/plugins/installed_plugins.json` (the install
* manifest) gated by the `enabledPlugins` toggle map. Only plugins that are both
* installed AND `enabledPlugins[key] === true` are injected, so only those are
* counted each resolved to its ACTIVE `installPath`, which for polyrepo plugins
* lives under `plugins/cache` (never under `plugins/marketplaces`, so the historic
* marketplaces walk missed them entirely while also counting disabled/uninstalled
* marketplaces plugins). Mirrors file-discovery.mjs's "trust installed_plugins.json"
* contract: when the manifest is absent (test fixtures, pre-v2 installs) we cannot
* tell enabled from installed, so we fall back to discovering everything under
* `plugins/marketplaces` rather than silently dropping config. (M-BUG-1)
*
* @param {string} [repoPath] - when given, project/local-scoped installs and
* project-level `enabledPlugins` overrides are resolved relative to it; omit for
* HOME/global scope (only user-scope installs + user `enabledPlugins`).
* @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() {
export async function enumeratePlugins(repoPath) {
const home = process.env.HOME || process.env.USERPROFILE || '';
if (!home) return [];
const marketplacesRoot = join(home, '.claude', 'plugins', 'marketplaces');
const pluginRoots = await discoverAllPluginsUnder(marketplacesRoot);
const installed = await readInstalledPluginsManifest(home);
// Dedupe via realpath (symlinks are common)
let pluginRoots;
if (installed) {
// Manifest present → inject only ENABLED plugins, from their active installPath.
const enabled = await readEnabledPluginsMap(home, repoPath);
pluginRoots = [];
for (const [key, recs] of Object.entries(installed)) {
if (enabled[key] !== true) continue; // not explicitly enabled → not injected
const rec = pickActivePluginRecord(recs, repoPath);
if (!rec || !rec.installPath) continue;
try {
await stat(rec.installPath); // skip enabled-but-missing installPaths
pluginRoots.push(rec.installPath);
} catch { /* installPath gone → not loadable */ }
}
} else {
// No manifest → cannot tell enabled from installed → discover all on disk.
pluginRoots = await discoverAllPluginsUnder(join(home, '.claude', 'plugins', 'marketplaces'));
}
return buildPluginRecords(pluginRoots);
}
/**
* Read the install manifest's `plugins` map ({ "name@marketplace": [record, ] }).
* Returns null when absent/unparseable so callers fall back to disk discovery.
*/
async function readInstalledPluginsManifest(home) {
const p = join(home, '.claude', 'plugins', 'installed_plugins.json');
let raw;
try { raw = await readFile(p, 'utf-8'); } catch { return null; }
const parsed = parseJson(raw);
if (!parsed || !parsed.plugins || typeof parsed.plugins !== 'object') return null;
return parsed.plugins;
}
/**
* Merge the `enabledPlugins` toggle map across the scopes Claude Code reads:
* user settings.json, then (when repoPath given) project settings + local + the
* ~/.claude.json project slice. Later scopes override earlier ones.
*/
async function readEnabledPluginsMap(home, repoPath) {
const merged = {};
const sources = [join(home, '.claude', 'settings.json')];
if (repoPath) {
sources.push(join(repoPath, '.claude', 'settings.json'));
sources.push(join(repoPath, '.claude', 'settings.local.json'));
}
for (const s of sources) {
try {
const parsed = parseJson(await readFile(s, 'utf-8'));
if (parsed && parsed.enabledPlugins && typeof parsed.enabledPlugins === 'object') {
Object.assign(merged, parsed.enabledPlugins);
}
} catch { /* missing/unreadable scope */ }
}
if (repoPath) {
try {
const slice = await readClaudeJsonProjectSlice(repoPath);
if (slice && slice.enabledPlugins && typeof slice.enabledPlugins === 'object') {
Object.assign(merged, slice.enabledPlugins);
}
} catch { /* ignore */ }
}
return merged;
}
/**
* Pick the applicable install record for a plugin. User-scope records apply
* everywhere; project/local-scope records only when repoPath is within their
* projectPath (so a project-scoped plugin never leaks into HOME/global scope).
*/
function pickActivePluginRecord(recs, repoPath) {
if (!Array.isArray(recs) || recs.length === 0) return null;
const applicable = recs.filter((r) => {
if (!r || !r.installPath) return false;
const scope = r.scope || 'user';
if (scope === 'user') return true;
if (!repoPath || !r.projectPath) return false;
const target = normalizePath(resolve(repoPath));
const pp = normalizePath(resolve(r.projectPath));
return target === pp || target.startsWith(pp + sep);
});
return applicable.find((r) => (r.scope || 'user') === 'user') || applicable[0] || null;
}
/**
* Build plugin records from a list of plugin root paths: dedupe via realpath,
* count items, read plugin.json name/version.
*/
async function buildPluginRecords(pluginRoots) {
const seen = new Set();
const results = [];
for (const root of pluginRoots) {
@ -1017,7 +1118,7 @@ export async function readActiveConfig(repoPath, opts = {}) {
detectGitRoot(absRepoPath),
walkClaudeMdCascade(absRepoPath),
readClaudeJsonProjectSlice(absRepoPath),
enumeratePlugins(),
enumeratePlugins(absRepoPath),
readSettingsCascade(absRepoPath),
]);