diff --git a/plugins/config-audit/commands/manifest.md b/plugins/config-audit/commands/manifest.md new file mode 100644 index 0000000..072438a --- /dev/null +++ b/plugins/config-audit/commands/manifest.md @@ -0,0 +1,78 @@ +--- +name: config-audit:manifest +description: Show ranked token-source manifest — every CLAUDE.md, plugin, skill, MCP server, and hook ordered DESC by estimated tokens +argument-hint: "[path] [--json]" +allowed-tools: Read, Bash +model: sonnet +--- + +# Config-Audit: Manifest + +Produce a ranked, single-table view of every token source loaded for a given repo path. Where `whats-active` shows separate tables per category, `manifest` collapses everything into one ordered list — making it easy to see what's costing the most regardless of category. + +## UX Rules (MANDATORY — from `.claude/rules/ux-rules.md`) + +1. **Never show raw JSON or stderr output.** Always use `--output-file` + `2>/dev/null`. +2. **Narrate before acting.** Tell the user what you're about to do. +3. **Read, don't dump.** Read the JSON file and render a formatted table. +4. **End with context-sensitive next steps.** + +## Implementation + +### Step 1: Parse `$ARGUMENTS` + +First non-flag argument is the path (default `.`). Recognized flags: + +- `--json` — emit raw JSON instead of the rendered table. + +### Step 2: Run the CLI silently + +Tell the user: **"Building token-source manifest for ``..."** + +```bash +TMPFILE="/tmp/ca-manifest-$$.json" +node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs --output-file "$TMPFILE" 2>/dev/null; echo $? +``` + +**Exit code handling:** +- `0` → continue +- `3` → tell user: "Couldn't read configuration. Check that the path exists and is a directory." Stop. + +### Step 3: If `--json` was requested, cat the file and stop + +```bash +cat "$TMPFILE" +``` + +Do NOT render the table in JSON mode. + +### Step 4: Read JSON and render + +Use the Read tool on `$TMPFILE`. Extract `meta.repoPath`, `total`, and `sources[]`. Render the top 20 sources (or fewer if the manifest is shorter): + +```markdown +**Token-source manifest for ``** — ~{total} tokens at startup + +| Rank | Kind | Name | Source | Tokens | +|------|------|------|--------|--------| +| 1 | {kind} | `` | {source} | ~{estimated_tokens} | +| ... | ... | ... | ... | ... | + +_Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±15%._ +``` + +If `sources.length > 20`, follow the table with: _"Showing top 20 of {N} sources. Run with `--json` to see the full list."_ + +### Step 5: Suggest next steps + +```markdown +**Next steps:** +- `/config-audit tokens` — Opus-4.7 token-hotspot patterns (cache-breaking, redundant perms, deep imports, MCP budget) +- `/config-audit whats-active` — same data grouped by category, with disable suggestions +- `/config-audit feature-gap` — what *could* improve here, grouped by impact +``` + +Tone: +- High total (>50k): empathetic — "That's a heavy startup cost; tokens bullet anything you'd otherwise spend on the actual conversation." +- Moderate (10–50k): neutral — "Reasonable. Skim the top 5 to see if anything is unexpectedly large." +- Low (<10k): encouraging — "Tight setup. The model has plenty of room for the actual work." diff --git a/plugins/config-audit/scanners/manifest.mjs b/plugins/config-audit/scanners/manifest.mjs new file mode 100644 index 0000000..986a185 --- /dev/null +++ b/plugins/config-audit/scanners/manifest.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +/** + * Manifest scanner CLI (v5 N2) — produce a ranked list of every token source + * loaded for a given repo path. Built on top of readActiveConfig so the source + * inventory is identical to whats-active; this CLI flattens and ranks them. + * + * Output JSON shape: + * { + * meta: { repoPath, generatedAt, durationMs }, + * sources: [ + * { kind: 'claude-md'|'plugin'|'skill'|'mcp-server'|'hook', + * name: string, source: string, estimated_tokens: number }, + * ... + * ], + * total: + * } + * + * Usage: + * node manifest.mjs [path] [--json] [--output-file ] + * + * Exit codes: 0=ok, 3=unrecoverable error. + * Zero external dependencies. + */ + +import { resolve } from 'node:path'; +import { writeFile, stat } from 'node:fs/promises'; +import { readActiveConfig } from './lib/active-config-reader.mjs'; + +/** + * Flatten an activeConfig snapshot into a single ranked array of sources. + */ +export function buildManifest(activeConfig) { + const sources = []; + + for (const f of activeConfig.claudeMd?.files || []) { + const tokens = estimateClaudeMdEntryTokens(f, activeConfig); + sources.push({ + kind: 'claude-md', + name: f.path, + source: f.scope, + estimated_tokens: tokens, + }); + } + + for (const p of activeConfig.plugins || []) { + sources.push({ + kind: 'plugin', + name: p.name, + source: p.path, + estimated_tokens: p.estimatedTokens || 0, + }); + } + + for (const s of activeConfig.skills || []) { + sources.push({ + kind: 'skill', + name: s.name, + source: s.pluginName ? `plugin:${s.pluginName}` : s.source || 'user', + estimated_tokens: s.estimatedTokens || 0, + }); + } + + for (const m of activeConfig.mcpServers || []) { + if (m && m.enabled === false) continue; + sources.push({ + kind: 'mcp-server', + name: m.name, + source: m.source || 'unknown', + estimated_tokens: m.estimatedTokens || 0, + }); + } + + for (const h of activeConfig.hooks || []) { + sources.push({ + kind: 'hook', + name: `${h.event}${h.matcher ? `:${h.matcher}` : ''}`, + source: h.source || h.sourcePath || 'unknown', + estimated_tokens: h.estimatedTokens || 0, + }); + } + + sources.sort((a, b) => b.estimated_tokens - a.estimated_tokens); + const total = sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0); + return { sources, total }; +} + +/** + * Distribute the cascade-level estimated tokens across the individual files + * proportional to their byte size. claudeMd.estimatedTokens is computed for + * the cascade as a whole, but for ranking we want per-file figures. + */ +function estimateClaudeMdEntryTokens(file, activeConfig) { + const totalBytes = activeConfig.claudeMd?.totalBytes || 0; + const totalTokens = activeConfig.claudeMd?.estimatedTokens || 0; + if (totalBytes === 0 || totalTokens === 0) return 0; + const share = (file.bytes || 0) / totalBytes; + return Math.round(totalTokens * share); +} + +async function main() { + const args = process.argv.slice(2); + let targetPath = '.'; + let outputFile = null; + let jsonMode = false; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--json') jsonMode = true; + else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i]; + else if (!args[i].startsWith('-')) targetPath = args[i]; + } + + const absPath = resolve(targetPath); + try { + const s = await stat(absPath); + if (!s.isDirectory()) { + process.stderr.write(`Error: ${absPath} is not a directory\n`); + process.exit(3); + } + } catch { + process.stderr.write(`Error: path does not exist: ${absPath}\n`); + process.exit(3); + } + + const start = Date.now(); + const activeConfig = await readActiveConfig(absPath, { verbose: true }); + const manifest = buildManifest(activeConfig); + + const output = { + meta: { + tool: 'config-audit:manifest', + repoPath: absPath, + generatedAt: new Date().toISOString(), + durationMs: Date.now() - start, + }, + sources: manifest.sources, + total: manifest.total, + }; + + const json = JSON.stringify(output, null, 2); + + if (outputFile) { + await writeFile(outputFile, json, 'utf-8'); + } + + if (jsonMode || !outputFile) { + process.stdout.write(json + '\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); + }); +} diff --git a/plugins/config-audit/tests/scanners/manifest.test.mjs b/plugins/config-audit/tests/scanners/manifest.test.mjs new file mode 100644 index 0000000..1164eed --- /dev/null +++ b/plugins/config-audit/tests/scanners/manifest.test.mjs @@ -0,0 +1,201 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { resolve, join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const PLUGIN_ROOT = resolve(__dirname, '../..'); +const CLI = join(PLUGIN_ROOT, 'scanners', 'manifest.mjs'); + +function uniqueDir(suffix) { + return join(tmpdir(), `config-audit-manifest-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); +} + +function runCli(args, env = {}) { + const proc = spawnSync('node', [CLI, ...args], { + encoding: 'utf-8', + env: { ...process.env, ...env }, + }); + return proc; +} + +describe('manifest CLI — real-config path (plugin root)', () => { + let output; + + before(() => { + const proc = runCli([PLUGIN_ROOT, '--json']); + assert.equal(proc.status, 0, `expected exit 0, got ${proc.status}; stderr: ${proc.stderr}`); + output = JSON.parse(proc.stdout); + }); + + it('emits non-empty sources array', () => { + assert.ok(Array.isArray(output.sources)); + assert.ok(output.sources.length > 0, + `expected sources.length > 0, got ${output.sources.length}`); + }); + + it('sources are sorted DESC by estimated_tokens', () => { + for (let i = 1; i < output.sources.length; i++) { + const prev = output.sources[i - 1].estimated_tokens; + const curr = output.sources[i].estimated_tokens; + assert.ok(prev >= curr, + `sources[${i - 1}] (${prev}) should be >= sources[${i}] (${curr})`); + } + }); + + it('total ≈ sum(sources.estimated_tokens) (within rounding tolerance)', () => { + const sum = output.sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0); + assert.ok(output.total >= sum - 1 && output.total <= sum + 1, + `expected total ≈ ${sum}, got ${output.total}`); + }); + + it('every source has kind/name/source/estimated_tokens', () => { + for (const s of output.sources) { + assert.ok(typeof s.kind === 'string' && s.kind.length > 0, 's.kind missing'); + assert.ok(typeof s.name === 'string' && s.name.length > 0, 's.name missing'); + assert.ok(typeof s.source === 'string', 's.source missing'); + assert.equal(typeof s.estimated_tokens, 'number', 's.estimated_tokens not a number'); + } + }); + + it('meta.repoPath matches the requested path', () => { + assert.equal(output.meta.repoPath, PLUGIN_ROOT); + }); +}); + +describe('manifest CLI — fixture path (rich-repo with patched HOME)', () => { + let fixture; + + before(async () => { + fixture = await buildRichManifestRepo(uniqueDir('rich')); + }); + + after(async () => { + if (fixture) await rm(fixture.root, { recursive: true, force: true }); + }); + + it('discovers ≥5 sources (CLAUDE.md cascade + plugins + skills + MCP)', () => { + const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); + assert.equal(proc.status, 0, `stderr: ${proc.stderr}`); + const out = JSON.parse(proc.stdout); + assert.ok(out.sources.length >= 5, + `expected sources.length >= 5, got ${out.sources.length}: ${out.sources.map(s => `${s.kind}:${s.name}`).join(', ')}`); + }); + + it('includes both plugins (manifest-plugin-a + manifest-plugin-b)', () => { + const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); + const out = JSON.parse(proc.stdout); + const pluginNames = out.sources.filter(s => s.kind === 'plugin').map(s => s.name); + assert.ok(pluginNames.includes('manifest-plugin-a'), + `expected manifest-plugin-a in plugins; got: ${pluginNames.join(', ')}`); + assert.ok(pluginNames.includes('manifest-plugin-b'), + `expected manifest-plugin-b in plugins; got: ${pluginNames.join(', ')}`); + }); + + it('includes 3 fixture skills (alpha-skill, beta-skill, gamma-skill)', () => { + const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); + const out = JSON.parse(proc.stdout); + const skillNames = out.sources.filter(s => s.kind === 'skill').map(s => s.name); + for (const expected of ['alpha-skill', 'beta-skill', 'gamma-skill']) { + assert.ok(skillNames.includes(expected), + `expected skill ${expected}; got skills: ${skillNames.join(', ')}`); + } + }); + + it('includes the project .mcp.json server (manifest-mcp)', () => { + const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome }); + const out = JSON.parse(proc.stdout); + const mcpNames = out.sources.filter(s => s.kind === 'mcp-server').map(s => s.name); + assert.ok(mcpNames.includes('manifest-mcp'), + `expected manifest-mcp in mcp-servers; got: ${mcpNames.join(', ')}`); + }); +}); + +describe('manifest CLI — error handling', () => { + it('exits 3 for nonexistent path', () => { + const proc = runCli(['/nonexistent/path/should/not/exist', '--json']); + assert.equal(proc.status, 3); + }); + + it('--output-file writes JSON to the path', async () => { + const outPath = join(tmpdir(), `manifest-output-${Date.now()}.json`); + try { + const proc = runCli([PLUGIN_ROOT, '--output-file', outPath]); + assert.equal(proc.status, 0); + const { readFile } = await import('node:fs/promises'); + const content = await readFile(outPath, 'utf-8'); + const parsed = JSON.parse(content); + assert.ok(Array.isArray(parsed.sources)); + } finally { + await rm(outPath, { force: true }); + } + }); +}); + +/** + * Build a richer fixture for manifest tests: 2 plugins + 3 skills + project + * .mcp.json. Mirrors buildRichRepo from active-config-reader.test.mjs but + * gives every plugin/skill a unique, recognizable name so assertions can be + * substring-based instead of count-based. + */ +async function buildRichManifestRepo(root) { + const fakeHome = join(root, 'fake-home'); + await mkdir(join(root, '.git'), { recursive: true }); + await writeFile(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n'); + + await writeFile( + join(root, 'CLAUDE.md'), + '# Project\n\nManifest fixture.\n', + ); + + await mkdir(join(fakeHome, '.claude'), { recursive: true }); + await writeFile( + join(fakeHome, '.claude', 'CLAUDE.md'), + '# User\n\nFake home for manifest tests.\n', + ); + + await writeFile( + join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'manifest-mcp': { command: 'npx', args: ['fake-pkg'] }, + }, + }, null, 2), + ); + + // Plugin A — has alpha-skill + beta-skill + const pluginA = join(fakeHome, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins', 'manifest-plugin-a'); + await mkdir(join(pluginA, '.claude-plugin'), { recursive: true }); + await writeFile( + join(pluginA, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'manifest-plugin-a', version: '1.0.0', description: 'plugin a' }, null, 2), + ); + await mkdir(join(pluginA, 'skills', 'alpha-skill'), { recursive: true }); + await writeFile( + join(pluginA, 'skills', 'alpha-skill', 'SKILL.md'), + '---\nname: alpha-skill\ndescription: alpha skill from plugin a\n---\n\nAlpha body.\n', + ); + await mkdir(join(pluginA, 'skills', 'beta-skill'), { recursive: true }); + await writeFile( + join(pluginA, 'skills', 'beta-skill', 'SKILL.md'), + '---\nname: beta-skill\ndescription: beta skill from plugin a\n---\n\nBeta body.\n', + ); + + // Plugin B — has gamma-skill + const pluginB = join(fakeHome, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins', 'manifest-plugin-b'); + await mkdir(join(pluginB, '.claude-plugin'), { recursive: true }); + await writeFile( + join(pluginB, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'manifest-plugin-b', version: '1.0.0', description: 'plugin b' }, null, 2), + ); + await mkdir(join(pluginB, 'skills', 'gamma-skill'), { recursive: true }); + await writeFile( + join(pluginB, 'skills', 'gamma-skill', 'SKILL.md'), + '---\nname: gamma-skill\ndescription: gamma skill from plugin b\n---\n\nGamma body.\n', + ); + + return { root, fakeHome }; +}