From be1056aac069278795bccadc6b20fa2a0d05647a Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 24 Jun 2026 10:50:28 +0200 Subject: [PATCH] fix(acr): enumeratePlugins honors enabledPlugins + polyrepo cache installPaths (M-BUG-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01CrTb8ktf1XZWEVwgz5MTTo --- scanners/lib/active-config-reader.mjs | 115 +++++++++++++++++++++-- tests/lib/active-config-reader.test.mjs | 119 ++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 7 deletions(-) diff --git a/scanners/lib/active-config-reader.mjs b/scanners/lib/active-config-reader.mjs index 5fa017e..a5a46e1 100644 --- a/scanners/lib/active-config-reader.mjs +++ b/scanners/lib/active-config-reader.mjs @@ -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>} */ -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), ]); diff --git a/tests/lib/active-config-reader.test.mjs b/tests/lib/active-config-reader.test.mjs index 61fd5ec..0576727 100644 --- a/tests/lib/active-config-reader.test.mjs +++ b/tests/lib/active-config-reader.test.mjs @@ -415,6 +415,125 @@ describe('enumeratePlugins', () => { }); }); +// ───────────────────────────────────────────────────────────────────────── +// enumeratePlugins — enabledPlugins + installed_plugins.json (M-BUG-1) +// ───────────────────────────────────────────────────────────────────────── + +/** + * Build a fake HOME that mirrors a real machine: an installed_plugins.json + * manifest (polyrepo plugins live under plugins/cache, monorepo ones under + * plugins/marketplaces) + an enabledPlugins toggle map in settings.json. + * + * The enumerator must inject ONLY enabled plugins, resolving each to its ACTIVE + * installPath — including cache/ paths the marketplaces walk never sees — and + * must NOT count disabled plugins, plugins absent from enabledPlugins, or + * marketplaces plugins absent from the manifest entirely (the real M-BUG-1). + */ +async function buildManifestHome(home) { + const cacheRoot = join(home, '.claude', 'plugins', 'cache', 'mp'); + const mpRoot = join(home, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins'); + + async function makePlugin(root, name, version) { + await mkdir(join(root, '.claude-plugin'), { recursive: true }); + await writeFile( + join(root, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name, description: name, version }, null, 2), + ); + await mkdir(join(root, 'agents'), { recursive: true }); + await writeFile( + join(root, 'agents', `${name}-agent.md`), + `---\nname: ${name}-agent\ndescription: ${name} agent\n---\nbody\n`, + ); + } + + const enabledCachePath = join(cacheRoot, 'enabled-cache', '1.0.0'); + const disabledCachePath = join(cacheRoot, 'disabled-cache', '1.0.0'); + const enabledMpPath = join(mpRoot, 'enabled-mp'); + const notToggledMpPath = join(mpRoot, 'not-toggled-mp'); + const ghostMpPath = join(mpRoot, 'ghost-mp'); // on disk, absent from manifest + + await makePlugin(enabledCachePath, 'enabled-cache', '1.0.0'); + await makePlugin(disabledCachePath, 'disabled-cache', '1.0.0'); + await makePlugin(enabledMpPath, 'enabled-mp', '0.1.0'); + await makePlugin(notToggledMpPath, 'not-toggled-mp', '0.1.0'); + await makePlugin(ghostMpPath, 'ghost-mp', '0.1.0'); + + await mkdir(join(home, '.claude', 'plugins'), { recursive: true }); + await writeFile( + join(home, '.claude', 'plugins', 'installed_plugins.json'), + JSON.stringify({ + version: 2, + plugins: { + 'enabled-cache@mp': [{ scope: 'user', installPath: enabledCachePath, version: '1.0.0' }], + 'disabled-cache@mp': [{ scope: 'user', installPath: disabledCachePath, version: '1.0.0' }], + 'enabled-mp@mp': [{ scope: 'user', installPath: enabledMpPath, version: '0.1.0' }], + 'not-toggled-mp@mp': [{ scope: 'user', installPath: notToggledMpPath, version: '0.1.0' }], + // ghost-mp deliberately absent from the manifest + }, + }, null, 2), + ); + + await writeFile( + join(home, '.claude', 'settings.json'), + JSON.stringify({ + enabledPlugins: { + 'enabled-cache@mp': true, + 'disabled-cache@mp': false, + 'enabled-mp@mp': true, + // not-toggled-mp@mp deliberately absent (treated as not enabled) + }, + }, null, 2), + ); +} + +describe('enumeratePlugins — enabledPlugins + installed_plugins.json (M-BUG-1)', () => { + let home, originalHome; + beforeEach(async () => { + home = uniqueDir('manifest-home'); + await mkdir(home, { recursive: true }); + originalHome = process.env.HOME; + process.env.HOME = home; + }); + afterEach(async () => { + process.env.HOME = originalHome; + await rm(home, { recursive: true, force: true }); + }); + + it('injects only ENABLED plugins (honors enabledPlugins toggle)', async () => { + await buildManifestHome(home); + const names = (await enumeratePlugins()).map(p => p.name).sort(); + assert.deepEqual(names, ['enabled-cache', 'enabled-mp']); + }); + + it('excludes disabled, not-toggled, and manifest-absent (ghost) plugins', async () => { + await buildManifestHome(home); + const names = (await enumeratePlugins()).map(p => p.name); + assert.ok(!names.includes('disabled-cache'), 'disabled plugin must not be injected'); + assert.ok(!names.includes('not-toggled-mp'), 'plugin absent from enabledPlugins must not be injected'); + assert.ok(!names.includes('ghost-mp'), 'marketplaces plugin absent from manifest must not be injected'); + }); + + it('resolves an enabled plugin from plugins/cache (polyrepo installPath)', async () => { + await buildManifestHome(home); + const cached = (await enumeratePlugins()).find(p => p.name === 'enabled-cache'); + assert.ok(cached, 'cache-installed enabled plugin must be discovered'); + assert.ok(cached.path.includes(join('plugins', 'cache')), `expected a cache/ path, got ${cached.path}`); + assert.equal(cached.agents, 1); + }); + + it('falls back to the marketplaces walk when installed_plugins.json is absent', async () => { + // No manifest → cannot tell enabled from installed → discover all on disk. + const legacyRoot = join(home, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins', 'legacy'); + await mkdir(join(legacyRoot, '.claude-plugin'), { recursive: true }); + await writeFile( + join(legacyRoot, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'legacy', version: '9.9.9' }, null, 2), + ); + const plugins = await enumeratePlugins(); + assert.ok(plugins.find(p => p.name === 'legacy'), 'legacy marketplaces plugin discovered via fallback'); + }); +}); + // ───────────────────────────────────────────────────────────────────────── // enumerateSkills // ─────────────────────────────────────────────────────────────────────────