Plugin subagents silently ignore `hooks`/`mcpServers`/`permissionMode` frontmatter — these are honored only for user/project agents in .claude/agents/ (code.claude.com/docs sub-agents). Setting them in a plugin agent is dead config; `permissionMode` is MEDIUM because it implies a restriction Claude Code does not apply (false security). hooks/mcpServers are LOW. Additive to PLH's agent-frontmatter loop (no new scanner, count stays 13). One humanizer pattern covers the three field titles. Hermetic temp-fixture test (positive + negative). Suite 954 -> 957, byte-stable. Part of v5.5.0 "steering-model I". Foundation (active-config-reader enumeration) deferred to v5.6 with B — A/E are additive and don't consume it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
720 lines
29 KiB
JavaScript
720 lines
29 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 } 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);
|
|
const known = new Set(['plugin.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 };
|
|
}
|
|
|
|
/**
|
|
* Scan one or more plugins and return aggregated results.
|
|
* @param {string} targetPath - Plugin dir or marketplace root
|
|
* @returns {Promise<object>} Scanner result
|
|
*/
|
|
export async function scan(targetPath) {
|
|
const start = Date.now();
|
|
resetCounter();
|
|
|
|
const pluginDirs = await discoverPlugins(resolve(targetPath));
|
|
|
|
if (pluginDirs.length === 0) {
|
|
return 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);
|
|
}
|
|
|
|
const allFindings = [];
|
|
const pluginResults = [];
|
|
|
|
for (const dir of pluginDirs) {
|
|
const result = await scanSinglePlugin(dir);
|
|
pluginResults.push(result);
|
|
allFindings.push(...result.findings);
|
|
}
|
|
|
|
// 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 scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
|
|
}
|
|
|
|
/**
|
|
* 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 issueCount = p.findings.length;
|
|
const score = Math.max(0, 100 - issueCount * 10);
|
|
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
|
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 ---
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let targetPath = '.';
|
|
let jsonMode = false;
|
|
let rawMode = false;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--json') {
|
|
jsonMode = true;
|
|
} else if (args[i] === '--raw') {
|
|
rawMode = true;
|
|
} else if (!args[i].startsWith('-')) {
|
|
targetPath = args[i];
|
|
}
|
|
}
|
|
|
|
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 = await scan(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`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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.exit(3);
|
|
});
|
|
}
|