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:
parent
0f9e319c85
commit
be1056aac0
2 changed files with 227 additions and 7 deletions
|
|
@ -327,19 +327,120 @@ export async function readClaudeJsonProjectSlice(repoPath) {
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enumerate all plugins installed under ~/.claude/plugins/marketplaces.
|
* Enumerate the plugins Claude Code actually injects for a repo.
|
||||||
* For each plugin: counts commands, agents, skills, hooks, rules; reads version from plugin.json.
|
|
||||||
*
|
*
|
||||||
|
* 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}>>}
|
* @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 || '';
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
if (!home) return [];
|
if (!home) return [];
|
||||||
|
|
||||||
const marketplacesRoot = join(home, '.claude', 'plugins', 'marketplaces');
|
const installed = await readInstalledPluginsManifest(home);
|
||||||
const pluginRoots = await discoverAllPluginsUnder(marketplacesRoot);
|
|
||||||
|
|
||||||
// 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 seen = new Set();
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const root of pluginRoots) {
|
for (const root of pluginRoots) {
|
||||||
|
|
@ -1017,7 +1118,7 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
||||||
detectGitRoot(absRepoPath),
|
detectGitRoot(absRepoPath),
|
||||||
walkClaudeMdCascade(absRepoPath),
|
walkClaudeMdCascade(absRepoPath),
|
||||||
readClaudeJsonProjectSlice(absRepoPath),
|
readClaudeJsonProjectSlice(absRepoPath),
|
||||||
enumeratePlugins(),
|
enumeratePlugins(absRepoPath),
|
||||||
readSettingsCascade(absRepoPath),
|
readSettingsCascade(absRepoPath),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// enumerateSkills
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue