feat(config-audit): /config-audit manifest command (v5 N2) [skip-docs]
New scanners/manifest.mjs CLI + commands/manifest.md slash command. Reads activeConfig and produces a flat, ranked list of every token source (CLAUDE.md cascade entries, plugins, skills, MCP servers, hooks) sorted DESC by estimated_tokens. CLAUDE.md per-file tokens are derived by distributing claudeMd.estimatedTokens across the cascade proportional to bytes. Tests cover both real-config (plugin root) and fixture (rich-repo with patched HOME containing 2 plugins + 3 skills + .mcp.json) paths, plus error handling (nonexistent path → exit 3, --output-file). Builds on readActiveConfig from M1 (v5 alpha.2). [skip-docs] reason: v5 plan fences off README/CLAUDE.md badge updates to Session 5; Forgejo pre-commit-docs-gate hook requires this tag on feat commits without doc changes. Tests: 593 → 604 (+11). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b2407a09b3
commit
0420b8cc4a
3 changed files with 436 additions and 0 deletions
78
plugins/config-audit/commands/manifest.md
Normal file
78
plugins/config-audit/commands/manifest.md
Normal file
|
|
@ -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 `<path>`..."**
|
||||
|
||||
```bash
|
||||
TMPFILE="/tmp/ca-manifest-$$.json"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --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 `<repoPath>`** — ~{total} tokens at startup
|
||||
|
||||
| Rank | Kind | Name | Source | Tokens |
|
||||
|------|------|------|--------|--------|
|
||||
| 1 | {kind} | `<name>` | {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."
|
||||
157
plugins/config-audit/scanners/manifest.mjs
Normal file
157
plugins/config-audit/scanners/manifest.mjs
Normal file
|
|
@ -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: <sum of sources.estimated_tokens>
|
||||
* }
|
||||
*
|
||||
* Usage:
|
||||
* node manifest.mjs [path] [--json] [--output-file <path>]
|
||||
*
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
201
plugins/config-audit/tests/scanners/manifest.test.mjs
Normal file
201
plugins/config-audit/tests/scanners/manifest.test.mjs
Normal file
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue