#!/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 /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} 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//) 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// 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} */ 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} 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 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); }); }