#!/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'|'skill'|'rule'|'agent'|'output-style'|'mcp-server'|'hook', * name: string, source: string, estimated_tokens: number, * loadPattern: 'always'|'on-demand'|'external'|'unknown', * survivesCompaction: 'yes'|'no'|'n/a', * derivationConfidence: 'confirmed'|'inferred' }, * ... * ], * summary: { * always: { tokens, count }, // enter context every turn before you type * onDemand: { tokens, count }, // loaded on invoke / on file read * external: { tokens, count }, // run outside the context window (hooks) * unknown: { tokens, count }, * }, * total: * } * * v5.6 B — load-pattern accounting. Sources are component-level: the coarse * "plugin" roll-up was dropped because a plugin's contributions (skills, rules, * agents, output styles, hooks, MCP) are each enumerated once on their own — * keeping the roll-up double-counted them and corrupted the always-loaded * subtotal. Every record now carries the load pattern derived from the * published Claude Code loading model (deriveLoadPattern). * * 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, deriveLoadPattern } from './lib/active-config-reader.mjs'; // CLAUDE.md cascade files are all discovered by walking UP from the repo, so // each one is always-loaded; the scope only changes the derivation confidence. const CLAUDE_MD_SCOPE_KIND = { project: 'claude-md-root', local: 'claude-md-root', user: 'claude-md-user', managed: 'claude-md-managed', import: 'claude-md-import', }; /** Spread the three load-pattern fields onto a source record. */ function withLoadPattern(record, lp) { return { ...record, loadPattern: lp.loadPattern, survivesCompaction: lp.survivesCompaction, derivationConfidence: lp.derivationConfidence, }; } const sourceLabel = (item, fallback) => item.pluginName ? `plugin:${item.pluginName}` : item.source || fallback; /** * Flatten an activeConfig snapshot into a single ranked array of sources, each * tagged with its load pattern, plus a load-pattern summary. */ export function buildManifest(activeConfig) { const sources = []; for (const f of activeConfig.claudeMd?.files || []) { const tokens = estimateClaudeMdEntryTokens(f, activeConfig); const kind = CLAUDE_MD_SCOPE_KIND[f.scope] || 'claude-md-root'; sources.push(withLoadPattern({ kind: 'claude-md', name: f.path, source: f.scope, estimated_tokens: tokens, }, deriveLoadPattern(kind))); } // Skills: the measured tokens are the skill BODY (full file), paid on invoke. // The always-loaded part (name+description listing) is small and tracked // separately (skill-listing-budget / posture), so the body is tagged // on-demand here rather than inflating the always-loaded subtotal. for (const s of activeConfig.skills || []) { sources.push(withLoadPattern({ kind: 'skill', name: s.name, source: sourceLabel(s, 'user'), estimated_tokens: s.estimatedTokens || 0, }, deriveLoadPattern('skill-body'))); } // Rules / agents / output styles — the foundation enumeration already derived // the load pattern (rules vary by `scoped`), so propagate it verbatim. for (const r of activeConfig.rules || []) { sources.push(withLoadPattern({ kind: 'rule', name: r.name, source: sourceLabel(r, 'project'), estimated_tokens: r.estimatedTokens || 0, }, r)); } for (const a of activeConfig.agents || []) { sources.push(withLoadPattern({ kind: 'agent', name: a.name, source: sourceLabel(a, 'project'), estimated_tokens: a.estimatedTokens || 0, }, a)); } for (const o of activeConfig.outputStyles || []) { sources.push(withLoadPattern({ kind: 'output-style', name: o.name, source: sourceLabel(o, 'project'), estimated_tokens: o.estimatedTokens || 0, }, o)); } for (const m of activeConfig.mcpServers || []) { if (m && m.enabled === false) continue; sources.push(withLoadPattern({ kind: 'mcp-server', name: m.name, source: m.source || 'unknown', estimated_tokens: m.estimatedTokens || 0, }, deriveLoadPattern('mcp'))); } for (const h of activeConfig.hooks || []) { sources.push(withLoadPattern({ kind: 'hook', name: `${h.event}${h.matcher ? `:${h.matcher}` : ''}`, source: h.source || h.sourcePath || 'unknown', estimated_tokens: h.estimatedTokens || 0, }, deriveLoadPattern('hook'))); } sources.sort((a, b) => b.estimated_tokens - a.estimated_tokens); const total = sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0); const summary = summarizeByLoadPattern(sources); return { sources, total, summary }; } /** * Bucket sources by load pattern into {tokens, count} subtotals. The `always` * bucket is the headline: tokens that enter context every turn before the user * types anything. */ export function summarizeByLoadPattern(sources) { const mk = () => ({ tokens: 0, count: 0 }); const summary = { always: mk(), onDemand: mk(), external: mk(), unknown: mk() }; const BUCKET = { always: 'always', 'on-demand': 'onDemand', external: 'external' }; for (const s of sources) { const key = BUCKET[s.loadPattern] || 'unknown'; summary[key].tokens += s.estimated_tokens || 0; summary[key].count += 1; } return summary; } /** * Source strings (the `source` field buildManifest stamps) that belong to the * SHARED GLOBAL layer — config paid once per machine and identical in every * repo: the global ~/.claude CLAUDE.md (`user`) and managed enterprise policy * (`managed`). Installed plugins are also shared but are matched by the * `plugin:` prefix below, not by this set. * * Deliberately NOT here: `~/.claude.json:projects`. Although that file lives in * HOME, `readClaudeJsonProjectSlice` returns the slice keyed to the SPECIFIC * repo path — those MCP servers are per-repo, load only in their own project, * and differ across repos, so they are a delta (folding them into the * once-counted shared layer would drop every repo's slice but the first). The * only machine-global MCP is plugin-provided (caught by the `plugin:` prefix). */ const SHARED_GLOBAL_SOURCES = Object.freeze(new Set(['user', 'managed'])); /** * Classify one manifest source as part of the once-counted shared global layer * or a per-repo delta (v5.9 B2b). Anything not positively identified as global * (project / local / .mcp.json / ~/.claude.json:projects / @import / unrecognized) * falls to `delta`, so a source is never silently folded into the shared layer — * a wrong fold would HIDE machine-wide cost, whereas a wrong delta is at worst * attributed visibly to a repo. * @param {string} source * @returns {'shared'|'delta'} */ export function classifyOwnership(source) { if (typeof source === 'string') { if (source.startsWith('plugin:')) return 'shared'; // installed plugins are machine-global if (SHARED_GLOBAL_SOURCES.has(source)) return 'shared'; } return 'delta'; } /** * Partition manifest sources by ownership for the machine-wide token roll-up, * returning two load-pattern summaries in the exact shape `summarizeByLoadPattern` * emits ({always,onDemand,external,unknown:{tokens,count}}), so the campaign * ledger setters (`setSharedGlobal` / `setRepoTokens`) consume them verbatim. * * - `shared`: the global layer, identical across repos — set ONCE on the ledger * root so the roll-up counts it exactly once (the structural double-count guard). * - `delta`: this repo's own project/local contribution beyond the shared layer. * * The split is total: every source lands in exactly one layer. * @param {Array<{source:string, loadPattern:string, estimated_tokens:number}>} sources * @returns {{shared:object, delta:object}} */ export function splitManifestByOwnership(sources) { const shared = []; const delta = []; for (const s of sources || []) { (classifyOwnership(s.source) === 'shared' ? shared : delta).push(s); } return { shared: summarizeByLoadPattern(shared), delta: summarizeByLoadPattern(delta) }; } /** * 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; // --raw is accepted for CLI surface consistency but is a no-op here: // manifest produces a token-source inventory, not findings. 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] === '--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, summary: manifest.summary, total: manifest.total, }; const json = JSON.stringify(output, null, 2); if (outputFile) { await writeFile(outputFile, json, 'utf-8'); } if (jsonMode || rawMode || !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); }); }