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

@ -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
// ─────────────────────────────────────────────────────────────────────────