config-audit/scanners/plugin-health-scanner.mjs
Kjell Tore Guttormsen de8a7b5d51 fix(scanners): stop discarding our own stdout when it is a pipe
process.exit() terminates immediately, but Node writes stdout asynchronously
when stdout is a pipe — everything still buffered is dropped. scan-orchestrator
measured 246 854 bytes to a file against 65 536 to a pipe (131 072 on another
run; the cut point is a flush race), so every machine consumer that pipes the
envelope got truncated, unparseable JSON. The failure reads like a corrupt file,
not like a cut-off, which is what made it survive this long. Reported by
org-ops, whose census pipes our output.

Closes the class rather than the one CLI where it was visible. campaign-cli,
campaign-export-cli, campaign-write-cli, knowledge-refresh-cli, drift-cli and
fix-cli all exited the same way on their success paths and were green only
because their payloads fit the pipe buffer today; size is not correctness. All
38 sites across 14 files now set process.exitCode and return, which is the
pattern self-audit.mjs already used.

Two contracts needed care rather than substitution: fail() is a never-returns
guard at ~25 call sites, so it throws a CliUsageError the top-level catch
renders with the identical "Error: " prefix and exit code 3; the path guards
needed an explicit return so main() stops instead of running on. Exit codes and
stderr text are unchanged, and the frozen v5.0.0 snapshots are untouched.

The class sweep is landed as a test, not as fourteen edits — it caught one site
this commit had missed. Suite 1443/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8sS1DuDV6bUJcyumLwbvj
2026-07-31 21:40:15 +02:00

818 lines
33 KiB
JavaScript

#!/usr/bin/env node
/**
* PLH Scanner — Plugin Health
* Validates Claude Code plugin structure, frontmatter, and cross-plugin coherence.
* Finding IDs: CA-PLH-NNN
* NOT included in scan-orchestrator — runs independently on plugin directories.
* Zero external dependencies.
*/
import { readdir, stat, readFile, writeFile } from 'node:fs/promises';
import { join, basename, resolve, sep } from 'node:path';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
const SCANNER = 'PLH';
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
// A CLAUDE.md need only document the component types the plugin actually ships. Mirrors the
// optional-frontmatter rule: do not demand docs for commands/agents/hooks that do not exist.
async function pluginShipsComponent(pluginDir, section) {
if (section === 'hooks') {
try { await readFile(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8'); return true; }
catch { return false; }
}
try {
const entries = await readdir(join(pluginDir, section));
return entries.some(f => f.endsWith('.md'));
} catch { return false; }
}
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores).
// Field requirements are pinned to the primary docs, NOT to "every field a plugin could set":
// - Commands/skills (code.claude.com/docs slash-commands): "All fields are optional. Only
// `description` is recommended." `name` defaults to the directory name; `model` and
// `allowed-tools` are optional. So only `description` is flagged.
// - Subagents (code.claude.com/docs sub-agents): "Only `name` and `description` are required."
// `model` (defaults to `inherit`) and `tools` (inherits all) are optional.
const REQUIRED_COMMAND_FRONTMATTER = [
{ key: 'description', display: 'description' },
];
const REQUIRED_AGENT_FRONTMATTER = [
{ key: 'name', display: 'name' },
{ key: 'description', display: 'description' },
];
// Plugin subagents silently ignore these frontmatter keys — they are honored
// ONLY for user/project agents in .claude/agents/ (code.claude.com/docs
// sub-agents, "ignored for plugin subagents"). Setting them in a plugin agent
// is dead config; permissionMode is MEDIUM because it implies a restriction
// that Claude Code does not actually apply (false sense of security).
const PLUGIN_AGENT_IGNORED_FIELDS = [
{ key: 'permissionMode', severity: SEVERITY.medium },
{ key: 'hooks', severity: SEVERITY.low },
{ key: 'mcpServers', severity: SEVERITY.low },
];
// Component-path keys that REPLACE the default folder (per code.claude.com/docs
// plugins-reference#path-behavior-rules). When such a key is set, Claude Code
// stops scanning the default folder; if that folder still exists, its contents
// are silently ignored (dead config). CC v2.1.140+ flags this in /doctor and
// `claude plugin list`. Excluded by design: `skills` (ADDS to the default —
// both load, never a shadow), and `hooks`/`mcpServers`/`lspServers` (own merge
// rules, not a folder shadow). Experimental themes/monitors are omitted: the
// docs warn their manifest schema may change between releases.
const SHADOWING_PATH_FIELDS = [
{ key: 'commands', defaultDir: 'commands' },
{ key: 'agents', defaultDir: 'agents' },
{ key: 'outputStyles', defaultDir: 'output-styles' },
];
/** Normalize a manifest path: strip a leading "./" and trailing slashes. */
function normalizeManifestPath(p) {
return String(p).replace(/^\.\//, '').replace(/\/+$/, '');
}
/**
* True when a custom manifest path addresses the default folder (equals it or
* points inside it) — Claude Code shows no warning in that case because the
* folder is referenced explicitly (e.g. "commands": ["./commands/deploy.md"]).
*/
function addressesDefaultDir(customPath, defaultDir) {
const norm = normalizeManifestPath(customPath);
return norm === defaultDir || norm.startsWith(defaultDir + '/');
}
/** True when `p` exists and is a directory. */
async function dirExists(p) {
try {
return (await stat(p)).isDirectory();
} catch {
return false;
}
}
/** Stat `p`, or null when it does not exist (distinguishes missing from file/dir). */
async function statOrNull(p) {
try {
return await stat(p);
} catch {
return null;
}
}
/**
* True when a `skills` entry resolves outside the plugin root. Installed plugins
* cannot reference files outside their own directory (docs: path-traversal
* limitations), so "../shared" or an absolute path will not load.
*/
function skillsEntryEscapesRoot(pluginDir, entry) {
const resolved = resolve(pluginDir, entry.replace(/^\.\//, ''));
return resolved !== pluginDir && !resolved.startsWith(pluginDir + sep);
}
// Per-problem prose for a malformed `skills` entry. Each `title` starts with
// `plugin.json "skills" entry` so the family is greppable.
const SKILLS_ENTRY_MESSAGES = {
'non-string': {
title: e => `plugin.json "skills" entry is not a string: ${JSON.stringify(e)}`,
description: 'Each "skills" entry must be a relative path string (starting with "./") to a skill directory.',
recommendation: 'Replace the non-string entry with a path like "./my-skill/", or remove it.',
},
'escapes-root': {
title: e => `plugin.json "skills" entry escapes the plugin root: ${e}`,
description: 'Installed plugins cannot reference files outside their own directory, so a skills path that traverses outside the plugin root (e.g. "../shared") will not load.',
recommendation: 'Point the entry at a directory inside the plugin, or vendor the skill into the plugin.',
},
'not-found': {
title: e => `plugin.json "skills" entry does not exist: ${e}`,
description: 'The "skills" entry points at a path that does not exist in the plugin, so no skill loads from it.',
recommendation: 'Create the directory, fix the path, or remove the entry.',
},
'not-a-directory': {
title: e => `plugin.json "skills" entry is a file, not a directory: ${e}`,
description: 'A "skills" entry must be a directory containing a SKILL.md (or <name>/SKILL.md), not a file.',
recommendation: 'Point the entry at the skill directory (the folder that contains SKILL.md), not the file.',
},
};
/**
* Discover plugins under a path.
* Looks for .claude-plugin/plugin.json pattern.
* @param {string} targetPath
* @returns {Promise<string[]>} Array of plugin root directories
*/
export async function discoverPlugins(targetPath) {
const plugins = [];
// Check if targetPath itself is a plugin
if (await isPlugin(targetPath)) {
plugins.push(targetPath);
return plugins;
}
// Look for plugins in subdirectories (marketplace layout: plugins/<name>/)
try {
const entries = await readdir(targetPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const subDir = join(targetPath, entry.name);
if (await isPlugin(subDir)) {
plugins.push(subDir);
continue;
}
// Also check one level deeper (plugins/<name>/ layout)
try {
const subEntries = await readdir(subDir, { withFileTypes: true });
for (const subEntry of subEntries) {
if (!subEntry.isDirectory()) continue;
const deepDir = join(subDir, subEntry.name);
if (await isPlugin(deepDir)) {
plugins.push(deepDir);
}
}
} catch { /* skip */ }
}
} catch { /* skip */ }
return plugins;
}
/**
* Check if a directory is a Claude Code plugin.
* @param {string} dir
* @returns {Promise<boolean>}
*/
async function isPlugin(dir) {
try {
await stat(join(dir, '.claude-plugin', 'plugin.json'));
return true;
} catch {
return false;
}
}
/**
* Scan a single plugin for health issues.
* @param {string} pluginDir - Plugin root directory
* @returns {Promise<{ name: string, findings: object[], commandCount: number, agentCount: number }>}
*/
async function scanSinglePlugin(pluginDir) {
const findings = [];
const pluginName = basename(pluginDir);
let commandCount = 0;
let agentCount = 0;
// Declared namespace from plugin.json `name` (the prefix for /name:command,
// name:skill, agent "name"). Folder basename is NOT the namespace.
let declaredName = null;
// 1. Validate plugin.json
const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
try {
const content = await readFile(pluginJsonPath, 'utf-8');
let parsed;
try {
parsed = JSON.parse(content);
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Invalid plugin.json',
description: `plugin.json is not valid JSON in ${pluginName}`,
file: pluginJsonPath,
}));
parsed = null;
}
if (parsed) {
if (typeof parsed.name === 'string' && parsed.name.trim()) {
declaredName = parsed.name.trim();
}
for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
if (!parsed[field]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: `Missing required field in plugin.json: ${field}`,
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
file: pluginJsonPath,
recommendation: `Add "${field}" to plugin.json`,
}));
}
}
// Shadow check: a manifest component-path key that REPLACES a default
// folder which still exists → that folder is silently ignored (dead config).
for (const { key, defaultDir } of SHADOWING_PATH_FIELDS) {
const value = parsed[key];
if (value === undefined || value === null) continue;
const customPaths = (Array.isArray(value) ? value : [value]).filter(p => typeof p === 'string');
if (customPaths.length === 0) continue;
// If any custom path addresses the default folder, CC keeps scanning it → no shadow.
if (customPaths.some(p => addressesDefaultDir(p, defaultDir))) continue;
if (!(await dirExists(join(pluginDir, defaultDir)))) continue;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `plugin.json "${key}" path shadows the default ${defaultDir}/ folder`,
description:
`Plugin "${pluginName}" sets "${key}" in plugin.json to ${customPaths.map(p => `"${p}"`).join(', ')}, ` +
`which replaces the default ${defaultDir}/ folder. That folder still exists but Claude Code no longer ` +
`scans it, so its contents are silently ignored (dead config). Claude Code flags this in /doctor and ` +
'`claude plugin list` (v2.1.140+).',
file: pluginJsonPath,
evidence: `${key}=${JSON.stringify(value)}; ignored folder=${defaultDir}/`,
recommendation:
`Either remove the unused ${defaultDir}/ folder, or keep it by listing it explicitly in "${key}" ` +
`(e.g. "${key}": ["./${defaultDir}/", ...]).`,
category: 'plugin-hygiene',
details: { field: key, ignoredDir: defaultDir, customPaths },
}));
}
// skills:-array validation: each entry must resolve to an existing
// directory inside the plugin root. Mirrors `claude plugin validate`.
// skills is string|array (a single string is one entry). Unlike the
// shadow check, skills ADDS to the default skills/ scan, so a custom path
// here is never a shadow — it just has to be a real directory.
if (parsed.skills !== undefined && parsed.skills !== null) {
const entries = Array.isArray(parsed.skills) ? parsed.skills : [parsed.skills];
for (const entry of entries) {
let problem = null;
if (typeof entry !== 'string') {
problem = 'non-string';
} else if (skillsEntryEscapesRoot(pluginDir, entry)) {
problem = 'escapes-root';
} else {
const st = await statOrNull(resolve(pluginDir, entry.replace(/^\.\//, '')));
if (!st) problem = 'not-found';
else if (!st.isDirectory()) problem = 'not-a-directory';
}
if (!problem) continue;
const m = SKILLS_ENTRY_MESSAGES[problem];
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: m.title(entry),
description: `Plugin "${pluginName}": ${m.description}`,
file: pluginJsonPath,
evidence: `skills entry=${JSON.stringify(entry)}; problem=${problem}`,
recommendation: m.recommendation,
category: 'plugin-hygiene',
details: { field: 'skills', entry, problem },
}));
}
}
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Missing plugin.json',
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
file: pluginDir,
recommendation: 'Create .claude-plugin/plugin.json with name, description, version',
}));
}
// 2. Validate CLAUDE.md
const claudeMdPath = join(pluginDir, 'CLAUDE.md');
try {
const content = await readFile(claudeMdPath, 'utf-8');
const lower = content.toLowerCase();
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
// Only require a section for a component the plugin actually ships (mirrors the
// optional-frontmatter rule — no docs demanded for absent commands/agents/hooks).
if (!(await pluginShipsComponent(pluginDir, section))) continue;
// Look for markdown table header or section header
const hasSection = lower.includes(`## ${section}`) ||
lower.includes(`| ${section}`) ||
lower.includes(`|${section}`);
if (!hasSection) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `CLAUDE.md missing ${section} section`,
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
file: claudeMdPath,
recommendation: `Add a "## ${section.charAt(0).toUpperCase() + section.slice(1)}" section with a table`,
}));
}
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Missing CLAUDE.md',
description: `Plugin "${pluginName}" has no CLAUDE.md`,
file: pluginDir,
recommendation: 'Create CLAUDE.md with Commands, Agents, and Hooks tables',
}));
}
// 3. Validate commands frontmatter
const commandsDir = join(pluginDir, 'commands');
try {
const entries = await readdir(commandsDir);
const mdFiles = entries.filter(f => f.endsWith('.md'));
commandCount = mdFiles.length;
for (const file of mdFiles) {
const filePath = join(commandsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (!frontmatter) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Command missing frontmatter',
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
file: filePath,
recommendation: 'Add YAML frontmatter with a description (other command fields are optional)',
}));
continue;
}
for (const { key, display } of REQUIRED_COMMAND_FRONTMATTER) {
if (!frontmatter[key]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `Command missing frontmatter field: ${display}`,
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
file: filePath,
recommendation: `Add "${display}" to frontmatter`,
}));
}
}
}
} catch { /* no commands dir */ }
// 4. Validate agents frontmatter
const agentsDir = join(pluginDir, 'agents');
try {
const entries = await readdir(agentsDir);
const mdFiles = entries.filter(f => f.endsWith('.md'));
agentCount = mdFiles.length;
for (const file of mdFiles) {
const filePath = join(agentsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (!frontmatter) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Agent missing frontmatter',
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
file: filePath,
recommendation: 'Add YAML frontmatter with name and description (model and tools are optional)',
}));
continue;
}
for (const { key, display } of REQUIRED_AGENT_FRONTMATTER) {
if (!frontmatter[key]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `Agent missing frontmatter field: ${display}`,
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
file: filePath,
recommendation: `Add "${display}" to frontmatter`,
}));
}
}
// Plugin subagents ignore hooks/mcpServers/permissionMode frontmatter (V15)
// — dead config (permissionMode = medium: false sense of restriction).
for (const { key, severity } of PLUGIN_AGENT_IGNORED_FIELDS) {
if (frontmatter[key] !== undefined) {
findings.push(finding({
scanner: SCANNER,
severity,
title: `Plugin agent sets "${key}", which Claude Code ignores`,
description: `Agent "${file}" in plugin "${pluginName}" sets "${key}" in frontmatter, but Claude Code ignores ${key} for plugin subagents — ${key === 'permissionMode' ? 'the agent runs with default permissions, not the restricted mode this implies' : 'this configuration has no effect'}.`,
file: filePath,
evidence: `${key}: ${JSON.stringify(frontmatter[key])}`,
recommendation: `Remove "${key}" from the agent frontmatter, or ship the agent as a user/project agent in .claude/agents/, where ${key} is honored.`,
autoFixable: false,
}));
}
}
}
} catch { /* no agents dir */ }
// 5. Validate hooks.json (if exists)
const hooksJsonPath = join(pluginDir, 'hooks', 'hooks.json');
try {
const content = await readFile(hooksJsonPath, 'utf-8');
try {
const parsed = JSON.parse(content);
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Invalid hooks.json structure',
description: `hooks.json in "${pluginName}" missing "hooks" object`,
file: hooksJsonPath,
recommendation: 'hooks.json must have a "hooks" key with event-keyed object',
}));
} else if (Array.isArray(parsed.hooks)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'hooks.json uses array instead of object',
description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
file: hooksJsonPath,
recommendation: 'Change hooks from array to object: { "PreToolUse": [...], ... }',
}));
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Invalid hooks.json',
description: `hooks.json is not valid JSON in "${pluginName}"`,
file: hooksJsonPath,
}));
}
} catch { /* no hooks.json — fine */ }
// 6. Check for unknown files in .claude-plugin/
const pluginMetaDir = join(pluginDir, '.claude-plugin');
try {
const entries = await readdir(pluginMetaDir);
// `marketplace.json` belongs here: it is the documented, required location
// for a marketplace catalog (code.claude.com/docs plugin-marketplaces —
// "Create `.claude-plugin/marketplace.json` in your repository root"), and a
// marketplace entry with `"source": "./"` makes the repo root its own
// plugin. Such a repo legitimately carries both files, so flagging the
// catalog as an unknown file was a false positive.
const known = new Set(['plugin.json', 'marketplace.json']);
for (const entry of entries) {
if (!known.has(entry)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Unknown file in .claude-plugin/',
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
file: join(pluginMetaDir, entry),
recommendation: 'Only plugin.json should be in .claude-plugin/',
}));
}
}
} catch { /* skip */ }
return { name: pluginName, declaredName, findings, commandCount, agentCount };
}
/**
* Per-plugin score and grade. Single source for both the terminal report and
* the --output-file payload — the grade formula used to live only inside
* `formatPluginHealthReport`, which nothing called.
* @param {number} issueCount
* @returns {{ score: number, grade: string }}
*/
export function pluginGrade(issueCount) {
const score = Math.max(0, 100 - issueCount * 10);
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
return { score, grade };
}
/**
* Scan one or more plugins and return aggregated results.
*
* The envelope is frozen at the v5.0.0 shape (byte-stable `--raw`/`--json`), so
* per-plugin rows and the cross-plugin/per-plugin split are NOT in it. Callers
* that need those — the `--output-file` payload, and therefore
* `/config-audit plugin-health` — use `scanDetailed`.
*
* @param {string} targetPath - Plugin dir or marketplace root
* @returns {Promise<object>} Scanner result
*/
export async function scan(targetPath) {
return (await scanDetailed(targetPath)).result;
}
/**
* Scan, and also return what `scan()`'s frozen envelope cannot carry: one row
* per plugin (name, declared namespace, component counts, grade) and the
* cross-plugin findings as a distinct set.
*
* @param {string} targetPath - Plugin dir or marketplace root
* @returns {Promise<{ result: object, plugins: object[], crossPluginFindings: object[] }>}
*/
export async function scanDetailed(targetPath) {
const start = Date.now();
resetCounter();
const pluginDirs = await discoverPlugins(resolve(targetPath));
if (pluginDirs.length === 0) {
return {
result: scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'No plugins found',
description: `No Claude Code plugins found under ${targetPath}`,
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
}),
], 0, Date.now() - start),
plugins: [],
crossPluginFindings: [],
};
}
const allFindings = [];
const pluginResults = [];
for (const dir of pluginDirs) {
const result = await scanSinglePlugin(dir);
pluginResults.push(result);
allFindings.push(...result.findings);
}
// Everything pushed from here on is a cross-plugin finding — the boundary the
// payload uses to split them out (they are flattened into `findings` in the
// frozen envelope, where `category: 'plugin-hygiene'` cannot tell them apart
// from the per-plugin shadow/skills findings that share it).
const crossPluginStart = allFindings.length;
// Cross-plugin checks: command-name ambiguity across DIFFERENT plugin namespaces.
// Commands are namespaced by the plugin's declared name (/name:command), so a
// shared command name across DIFFERENT plugins is ambiguity — not a hard
// conflict — mirroring COL's plugin-vs-plugin skill check (low). When two
// plugins share the SAME declared namespace, the namespace-collision finding
// below already covers it, so this check keys on the namespace and fires only
// when a command name spans 2+ DISTINCT namespaces.
const commandsByNamespace = new Map(); // cmdName → Map<namespace, { path }>
for (let idx = 0; idx < pluginResults.length; idx++) {
const namespace = pluginResults[idx].declaredName || basename(pluginDirs[idx]);
const commandsDir = join(pluginDirs[idx], 'commands');
try {
const entries = await readdir(commandsDir);
for (const file of entries.filter(f => f.endsWith('.md'))) {
const filePath = join(commandsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && frontmatter.name) {
const cmdName = frontmatter.name;
if (!commandsByNamespace.has(cmdName)) commandsByNamespace.set(cmdName, new Map());
const nsMap = commandsByNamespace.get(cmdName);
if (!nsMap.has(namespace)) nsMap.set(namespace, { path: filePath });
}
}
} catch { /* no commands dir */ }
}
for (const [cmdName, nsMap] of commandsByNamespace) {
if (nsMap.size < 2) continue; // single namespace → no cross-plugin ambiguity
const entries = [...nsMap.entries()].map(([namespace, v]) => ({ namespace, path: v.path }));
const namespaceList = entries.map(e => e.namespace).join(', ');
allFindings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: `Command name "${cmdName}" used by multiple plugins`,
description:
`${entries.length} plugins (${namespaceList}) expose a command named "${cmdName}". ` +
'Even when invocation is namespaced via /plugin:command, shared names create ambiguity ' +
'in error messages, search results, and the command listing.',
file: entries[0].path,
evidence: `name="${cmdName}"; plugins=${entries.map(e => e.namespace).join(',')}`,
recommendation:
'Coordinate command naming across plugins, or rename one to clarify intent. The shared ' +
'name forces every reader to disambiguate by plugin.',
category: 'plugin-hygiene',
details: {
namespaces: entries.map(e => ({ source: `plugin:${e.namespace}`, name: cmdName, path: e.path })),
},
}));
}
// Cross-plugin checks: plugin namespace (declared name) collisions.
// Claude Code namespaces every plugin component by the plugin's declared
// `name` (/name:command, name:skill, agent "name"). Two plugins that declare
// the SAME name collapse into one namespace; the resolution between two
// installed plugins is undocumented, so one plugin's components are silently
// shadowed. Name-less plugins are flagged elsewhere and never grouped here.
const byDeclaredName = new Map(); // declaredName → string[] of plugin dirs
for (let idx = 0; idx < pluginResults.length; idx++) {
const declaredName = pluginResults[idx].declaredName;
if (!declaredName) continue;
if (!byDeclaredName.has(declaredName)) byDeclaredName.set(declaredName, []);
byDeclaredName.get(declaredName).push(pluginDirs[idx]);
}
for (const [declaredName, dirs] of byDeclaredName) {
if (dirs.length < 2) continue;
allFindings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `Plugin namespace collision: "${declaredName}"`,
description:
`${dirs.length} plugins declare the same name "${declaredName}" in plugin.json. ` +
`Claude Code namespaces every plugin component by that name ` +
`(/${declaredName}:command, ${declaredName}:skill, agent "${declaredName}"), so the ` +
'namespaces collapse into one. Resolution between two installed plugins of the same ' +
"name is undocumented — one plugin's commands, skills, and agents are silently shadowed " +
'and become unreachable.',
file: join(dirs[0], '.claude-plugin', 'plugin.json'),
evidence: `name="${declaredName}"; plugins=${dirs.map(d => basename(d)).join(',')}`,
recommendation:
'Rename one plugin\'s "name" in plugin.json so each plugin owns a distinct namespace. ' +
'The folder name does not matter — the declared "name" field is the namespace.',
category: 'plugin-hygiene',
details: {
namespaces: dirs.map(d => ({
source: `plugin:${basename(d)}`,
name: declaredName,
path: join(d, '.claude-plugin', 'plugin.json'),
})),
},
}));
}
return {
result: scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start),
plugins: pluginResults.map((p, idx) => ({
name: p.name,
declaredName: p.declaredName,
path: pluginDirs[idx],
commandCount: p.commandCount,
agentCount: p.agentCount,
findingCount: p.findings.length,
...pluginGrade(p.findings.length),
})),
crossPluginFindings: allFindings.slice(crossPluginStart),
};
}
/**
* Format a plugin health report for terminal output.
* @param {object} scanResult - Scanner result from scan()
* @param {Array<{ name: string, findings: object[], commandCount: number, agentCount: number }>} pluginResults
* @returns {string}
*/
export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
const lines = [];
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push(' Plugin Health Report');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push('');
for (const p of pluginResults) {
const { score, grade } = pluginGrade(p.findings.length);
const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
}
lines.push('');
if (crossPluginFindings.length > 0) {
lines.push(` Cross-plugin issues (${crossPluginFindings.length}):`);
for (const f of crossPluginFindings) {
lines.push(` - [${f.severity}] ${f.title}`);
}
} else {
lines.push(' Cross-plugin issues (0):');
lines.push(' (none)');
}
lines.push('');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
return lines.join('\n');
}
// --- CLI entry point ---
const BOOL_FLAGS = ['--json', '--raw'];
const VALUE_FLAGS = ['--output-file'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let jsonMode = false;
let rawMode = false;
let outputFile = null;
// M-BUG-21, third arm: this loop used to end in
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
// unknown-flag branch. An unrecognised flag was dropped silently and its
// VALUE became the scan target, so `--output-file /tmp/x.json` scanned
// /tmp/x.json. Unlike drift-cli, the result LOOKS fine: a non-existent path
// discovers no plugins, so the scanner reported "No plugins found" (info) and
// exit 0 — a green answer to a question nobody asked. Now it fails loudly.
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (BOOL_FLAGS.includes(arg)) {
if (arg === '--json') jsonMode = true;
else if (arg === '--raw') rawMode = true;
} else if (VALUE_FLAGS.includes(arg)) {
const value = args[i + 1];
if (value === undefined || value.startsWith('-')) {
throw new Error(`Option ${arg} requires a value.`);
}
outputFile = value;
i++;
} else if (arg.startsWith('-')) {
throw new Error(
`Unknown option: ${arg}\n` +
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
);
} else {
targetPath = arg;
}
}
const humanizedProgress = !jsonMode && !rawMode;
process.stderr.write(humanizedProgress ? `Plugin Health v2.1.0\n` : `Plugin Health Scanner v2.1.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
const { result, plugins, crossPluginFindings } = await scanDetailed(targetPath);
if (jsonMode || rawMode) {
// --json and --raw both write the v5.0.0-shape result (byte-identical).
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
// Default mode humanizes finding titles before writing the brief summary.
const findings = humanizeFindings(result.findings);
const count = findings.length;
process.stderr.write(`Findings: ${count}\n`);
for (const f of findings) {
process.stderr.write(` [${f.severity}] ${f.title}\n`);
}
// ux-rules rule 2: the command runs with `2>/dev/null`, so anything it must
// ACT on has to ride in the --output-file payload. Everything above this
// point is stderr, i.e. invisible to `/config-audit plugin-health`.
if (outputFile) {
const crossIds = new Set(crossPluginFindings.map(f => f.id));
for (const f of findings) {
if (crossIds.has(f.id)) f.crossPlugin = true;
}
const payload = {
...result,
findings,
plugins,
cross_plugin_findings: findings.filter(f => crossIds.has(f.id)),
};
await writeFile(outputFile, JSON.stringify(payload, null, 2), 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
}
}
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exitCode = 3;
});
}