Skim the noise, keep the signal. Automatic, 100% local Bash output filtering for Claude Code.

Skim the noise, keep the signal.
A Claude Code Function Hooks plugin that filters known noisy Bash outputs using local TypeScript heuristics. Every modified result links to an archived copy of the full raw output. No network requests, external models or API keys are used by the plugin.
Inside Claude Code, run:
/plugin install skim --marketplace zaninifrancesco/claude-skim
/reload-plugins
Skim is enabled automatically. Keep using Claude Code normally: no API keys, .env files, manual npm commands, build step or per-session flags are needed. The one-command marketplace installer requires Claude Code 2.1.275 or later with Function Hooks support.
Alternatively, install persistently for your user from a terminal:
claude plugin marketplace add zaninifrancesco/claude-skim
claude plugin install skim@claude-skim --scope user
Reload plugins in any already-open session after installing. Start future sessions with the usual claude command from any project.
For local development only, claude --plugin-dir /path/to/claude-skim still loads the checked-out plugin for one session.
[ERROR] Failures: blocks, Gradle task failures, assertion diffs, and full stack traces.The Go adapter is not implemented. Unknown commands, shell pipelines and compound commands pass through unchanged. The generic head/tail adapter is tested as a standalone parser and is deliberately excluded from automatic routing to honor the safety rules in AGENTS.md.
Pruning requires at least 30 lines and 1,500 characters. Short, unknown, binary, image, background, interrupted or externally persisted outputs pass through unchanged. Permission failures and hard tool errors also pass through.
Only recognized noise is removed. Ambiguous formats are retained. A parser or archive failure returns the original answer. If the footer would erase the token savings, the original answer is returned too.
Every changed output includes a footer such as:
[skim: condensed 350 lines -> 12 lines. Full raw log: .claude/skim/runs/run-<timestamp>-<id>.log]
Raw stdout followed by raw stderr is written without text normalization before any modified answer is returned. Archives remain in .claude/skim/runs/ until you remove them; automatic retention cleanup is not implemented.
/skim or /skim stats: session and lifetime savings, reduction and adapter breakdown./skim raw: latest session archive path and its complete original contents./skim toggle: persistently pause or resume pruning./skim help: command reference.Claude Code adds the skim: prefix and controls the notification icon/style. The plugin keeps the lightning symbol and supplies the status text once. The status badge updates after pruning and on turn completion:
skim: ⚡ -54.2k tokens (~$0.16) · 14 logs skimmed
/skim stats displays a Markdown table with session savings, skimmed runs, average reduction and lifetime savings, followed by a tool breakdown. /skim help uses a Markdown command list. Neither output relies on raw Unicode borders or fixed-width padding. The figures above are illustrative estimates, not measured billing credits.
The status badge updates after pruning and on turn completion. Token estimates use ceil(characters / 3.5) per stream. Savings include the archive footer and use a fixed $3 per million input tokens for the approximate dollar figure. Lifetime counters assume one active session per plugin store. Filtering is local and deterministic; latency varies with the output size and local filesystem, so no fixed installation duration or universal sub-3ms runtime is promised.
Processing, archives and metrics stay local. The runtime has no external package dependencies and performs no HTTP requests or subprocess calls. node_modules/, dist/ and .claude/skim/ are excluded from Git.
npm ci
npm test
npm run lint
npm run build
claude plugin validate . --strict
Vitest tests use synthetic terminal logs in fixtures/ and cover passing summaries, full failure blocks, stream accounting, raw archiving, pass-through rules, hook registration and slash commands. Runtime code uses only the host API and JavaScript built-ins.
The shipped hooks follow the bundled runtime declarations: slash command handlers use command.run, session shutdown uses classic.SessionEnd, and filesystem operations are declared at the hook call sites for permission analysis. See specs/ and the completed backlog.
MIT © 2026 Francesco Zanini
hooks/register.ts 87 lines1import type { EngineInterface, Register } from 'claude-code';
2import { handleSkimCommand, formatStatus } from '../src/commands.js';
3import { processToolCall, MIN_LINES_THRESHOLD } from '../src/engine.js';
4import { createSessionMetrics, type LifetimeTotals, type SessionMetrics } from '../src/metrics.js';
5import { estimateDollarSavings } from '../src/utils/tokens.js';
6
7function logFailure($: EngineInterface): void {
8 try { $.ui.log('Skim: host operation failed; continuing the session.'); } catch { /* The UI may already be closed. */ }
9}
10const validCount = (value: unknown) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
11
12async function persist($: EngineInterface, state: SessionMetrics, lifetime: LifetimeTotals): Promise<void> {
13 // shortcut: totals assume one active session per store; use atomic updates for concurrent sessions.
14 const tokens = lifetime.tokensSaved + state.tokensSaved;
15 await $.store.set('skim_lifetime_metrics', {
16 lifetimeTokensSaved: tokens,
17 lifetimeRunsPruned: lifetime.runsPruned + state.runsPruned,
18 lifetimeDollarSavings: estimateDollarSavings(tokens),
19 lastPrunedTimestamp: state.lastPrunedTimestamp,
20 userConfig: { enabled: state.enabled, minLineThreshold: MIN_LINES_THRESHOLD, archiveRetentionDays: 7, showStatusBadge: true },
21 });
22}
23
24export const register: Register = (on) => {
25 const state = createSessionMetrics();
26 let restored = false;
27 const lifetime: LifetimeTotals = { tokensSaved: 0, runsPruned: 0 };
28
29 on('session.start', async ($, event, next) => {
30 try {
31 const [saved, enabled] = await Promise.all([$.store.get('skim_lifetime_metrics'), $.store.get('skim_enabled')]);
32 if (saved && typeof saved === 'object') {
33 lifetime.tokensSaved = validCount('lifetimeTokensSaved' in saved ? saved.lifetimeTokensSaved : undefined);
34 lifetime.runsPruned = validCount('lifetimeRunsPruned' in saved ? saved.lifetimeRunsPruned : undefined);
35 state.lastPrunedTimestamp = validCount('lastPrunedTimestamp' in saved ? saved.lastPrunedTimestamp : undefined);
36 }
37 if (typeof enabled === 'boolean') state.enabled = enabled;
38 restored = true;
39 } catch {
40 state.enabled = false;
41 logFailure($);
42 }
43 try {
44 await $.command.register({ name: 'skim', description: 'Manage local output pruning and inspect token savings', argumentHint: '[stats|raw|toggle|help]' });
45 } catch { logFailure($); }
46 try { $.ui.status(formatStatus(state)); } catch { logFailure($); }
47 return next(event);
48 });
49
50 on('tool.call', { tool: 'Bash' }, async ($, event, next) => {
51 const answer = await next(event);
52 const result = await processToolCall({
53 fs: { write: (path, text) => $.fs.write(path, text) },
54 ui: { log: (text) => $.ui.log(text) },
55 }, event.command, answer, state);
56 if (result !== answer) {
57 try { $.ui.status(formatStatus(state)); } catch { logFailure($); }
58 }
59 return result;
60 });
61
62 on('turn.complete', async ($, event, next) => {
63 try { if (restored) await persist($, state, lifetime); } catch { logFailure($); }
64 try { $.ui.status(formatStatus(state)); } catch { logFailure($); }
65 return next(event);
66 });
67
68 // The shipped API exposes session shutdown through the classic lifecycle event.
69 on('classic.SessionEnd', async ($, event, next) => {
70 try { if (restored) await persist($, state, lifetime); } catch { logFailure($); }
71 return next(event);
72 });
73
74 on('command.run', { command: 'skim' }, async ($, event) => {
75 try {
76 return { text: await handleSkimCommand({
77 fs: { read: (path) => $.fs.read(path) },
78 store: { set: (key, value) => $.store.set(key, value) },
79 ui: { status: (text) => $.ui.status(text), toast: (text) => $.ui.toast(text) },
80 }, event.args, state, lifetime) };
81 } catch {
82 logFailure($);
83 return { text: 'Skim: could not complete this command. See the session log for details.' };
84 }
85 });
86};
87src/commands.ts 66 lines1import type { EngineInterface } from 'claude-code';
2import type { LifetimeTotals, SessionMetrics } from './metrics.js';
3import { estimateDollarSavings } from './utils/tokens.js';
4
5type CommandHost = {
6 fs: Pick<EngineInterface['fs'], 'read'>;
7 store: Pick<EngineInterface['store'], 'set'>;
8 ui: Pick<EngineInterface['ui'], 'status' | 'toast'>;
9};
10const number = (value: number) => value.toLocaleString('en-US');
11const lifetimeNumber = (value: number) => value >= 1_000_000 ? `${(value / 1_000_000).toFixed(2)}M` : number(value);
12
13// Claude Code adds the plugin name to native status notifications.
14export function formatStatus(state: SessionMetrics): string {
15 if (!state.enabled) return '⚡ [Paused] (/skim toggle)';
16 if (!state.tokensSaved) return '⚡ Active (monitoring stdout)';
17 const tokens = state.tokensSaved >= 1000 ? `${(state.tokensSaved / 1000).toFixed(1)}k` : String(state.tokensSaved);
18 return `⚡ -${tokens} tokens (~$${estimateDollarSavings(state.tokensSaved).toFixed(2)}) · ${state.runsPruned} logs skimmed`;
19}
20
21export async function handleSkimCommand($: CommandHost, args: string, state: SessionMetrics, lifetime: LifetimeTotals): Promise<string> {
22 switch (args.trim()) {
23 case '':
24 case 'stats': {
25 const reduction = state.tokensBeforePruning ? state.tokensSaved / state.tokensBeforePruning * 100 : 0;
26 const total = lifetime.tokensSaved + state.tokensSaved;
27 const breakdown = Object.entries(state.adapterBreakdown).map(([name, stats]) => {
28 const tokens = stats.tokensSaved >= 1000 ? `${(stats.tokensSaved / 1000).toFixed(1)}k` : number(stats.tokensSaved);
29 return `- **${name}:** ${stats.runs} ${stats.runs === 1 ? 'run' : 'runs'} \`(-${tokens} tokens)\``;
30 });
31 return [
32 '### ⚡ Skim Savings Report',
33 '',
34 '| Metric | Details |',
35 '| :--- | :--- |',
36 `| **Session Saved** | \`${number(state.tokensSaved)}\` tokens (~$${estimateDollarSavings(state.tokensSaved).toFixed(2)}) |`,
37 `| **Logs Skimmed** | \`${number(state.runsPruned)}\` command runs (${reduction.toFixed(1)}% reduction) |`,
38 `| **Lifetime Saved** | \`${lifetimeNumber(total)}\` tokens (~$${estimateDollarSavings(total).toFixed(2)}) |`,
39 '| **Archive Directory** | `.claude/skim/runs/` |',
40 ...(breakdown.length ? ['', '**Breakdown by Tool:**', ...breakdown] : []),
41 ].join('\n');
42 }
43 case 'raw':
44 if (!state.lastRawLog) return 'Skim: No output has been pruned in this session.';
45 return `${state.lastRawLog}\n${await $.fs.read(state.lastRawLog)}`;
46 case 'toggle': {
47 const enabled = !state.enabled;
48 await $.store.set('skim_enabled', enabled);
49 state.enabled = enabled;
50 const message = enabled ? 'Skim: Active. Pruning enabled.' : 'Skim: Paused. Raw stdout will pass to Claude untouched.';
51 $.ui.toast(message);
52 $.ui.status(formatStatus(state));
53 return message;
54 }
55 default:
56 return [
57 '**Skim Commands:**',
58 '',
59 '- `/skim` or `/skim stats` — View session & lifetime savings report',
60 '- `/skim raw` — Inspect latest uncompressed log in full',
61 '- `/skim toggle` — Pause or resume stdout pruning',
62 '- `/skim help` — Show this help message',
63 ].join('\n');
64 }
65}
66src/engine.ts 68 lines1import type { EngineInterface, ToolCallResult } from 'claude-code';
2import { archiveRawOutput } from './archiver.js';
3import type { SessionMetrics } from './metrics.js';
4import { routeCommand } from './router.js';
5import { estimateTokens } from './utils/tokens.js';
6
7export const MIN_LINES_THRESHOLD = 30;
8type EngineHost = { fs: Pick<EngineInterface['fs'], 'write'>; ui: Pick<EngineInterface['ui'], 'log'> };
9const lineCount = (text: string) => text ? text.split('\n').length : 0;
10
11function logFailure($: EngineHost): void {
12 try { $.ui.log('Skim: pruning failed; returning the original tool output.'); } catch { /* A closed UI must not block Bash. */ }
13}
14
15export async function processToolCall(
16 $: EngineHost,
17 command: string,
18 answer: ToolCallResult<'Bash'>,
19 state: SessionMetrics,
20): Promise<ToolCallResult<'Bash'>> {
21 state.runsPassedThrough++;
22 try {
23 if (!state.enabled || answer.deny !== undefined || answer.isError) return answer;
24 const record = answer.result;
25 if (!record || typeof record.stdout !== 'string' || typeof record.stderr !== 'string') return answer;
26 if (record.interrupted || record.isImage || record.backgroundTaskId || record.persistedOutputPath || record.rawOutputPath || record.structuredContent?.length) return answer;
27 const { stdout, stderr } = record;
28 const raw = stdout + stderr;
29 if (/permission denied|operation not permitted|command not found/i.test(raw)) return answer;
30 if (raw.length < 1500 || lineCount(raw) < MIN_LINES_THRESHOLD) return answer;
31 if (/[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f]/.test(raw) || /^[A-Za-z\d+/\s]+={0,2}\s*$/.test(raw) && raw.replace(/\s/g, '').length > 200) return answer;
32 const adapter = routeCommand(command, stdout, stderr);
33 if (!adapter) return answer;
34 const pruned = adapter.prune(stdout, stderr);
35 if (pruned.tokensSaved <= 0) return answer;
36 if ('exitCode' in record && record.exitCode !== 0 && record.exitCode !== undefined && /✅/.test(pruned.condensedStdout + (pruned.condensedStderr ?? ''))) return answer;
37 const path = await archiveRawOutput($.fs, stdout, stderr);
38 if (!path) {
39 logFailure($);
40 return answer;
41 }
42 const outLines = lineCount(pruned.condensedStdout) + lineCount(pruned.condensedStderr ?? '') + 1;
43 const footer = `[skim: condensed ${lineCount(raw)} lines -> ${outLines} lines. Full raw log: ${path}]`;
44 const condensedStdout = `${pruned.condensedStdout}\n${footer}`;
45 const condensedStderr = pruned.condensedStderr ?? stderr;
46 const before = estimateTokens(stdout) + estimateTokens(stderr);
47 const after = estimateTokens(condensedStdout) + estimateTokens(condensedStderr);
48 if (after >= before) return answer;
49
50 state.tokensBeforePruning += before;
51 state.tokensAfterPruning += after;
52 state.tokensSaved += before - after;
53 state.runsPruned++;
54 state.runsPassedThrough--;
55 state.lastRawLog = path;
56 state.lastPrunedTimestamp = Date.now();
57 const breakdown = state.adapterBreakdown[adapter.name] ??= { runs: 0, tokensSaved: 0 };
58 breakdown.runs++;
59 breakdown.tokensSaved += before - after;
60 // Drop core's cached rendering so the host maps the changed result again.
61 const { ref: _ref, text: _text, ...rest } = answer;
62 return { ...rest, result: { ...record, stdout: condensedStdout, stderr: condensedStderr } };
63 } catch {
64 logFailure($);
65 return answer;
66 }
67}
68src/metrics.ts 32 lines1export interface SessionMetrics {
2 sessionStartTime: number;
3 tokensBeforePruning: number;
4 tokensAfterPruning: number;
5 tokensSaved: number;
6 runsPruned: number;
7 runsPassedThrough: number;
8 enabled: boolean;
9 adapterBreakdown: Record<string, { runs: number; tokensSaved: number }>;
10 lastRawLog?: string;
11 lastPrunedTimestamp: number;
12}
13
14export function createSessionMetrics(): SessionMetrics {
15 return {
16 sessionStartTime: Date.now(),
17 tokensBeforePruning: 0,
18 tokensAfterPruning: 0,
19 tokensSaved: 0,
20 runsPruned: 0,
21 runsPassedThrough: 0,
22 enabled: true,
23 adapterBreakdown: {},
24 lastPrunedTimestamp: 0,
25 };
26}
27
28export interface LifetimeTotals {
29 tokensSaved: number;
30 runsPruned: number;
31}
32src/utils/tokens.ts 8 lines1export function estimateTokens(text: string): number {
2 return Math.ceil(text.length / 3.5);
3}
4
5export function estimateDollarSavings(tokensSaved: number): number {
6 return tokensSaved / 1_000_000 * 3;
7}
8src/archiver.ts 18 lines1import type { EngineInterface } from 'claude-code';
2
3export async function archiveRawOutput(
4 fs: Pick<EngineInterface['fs'], 'write'>,
5 stdout: string,
6 stderr = '',
7): Promise<string | undefined> {
8 try {
9 const id = crypto.randomUUID().replaceAll('-', '');
10 const path = `.claude/skim/runs/run-${Date.now()}-${id}.log`;
11 await fs.write(path, stdout + stderr);
12 return path;
13 } catch {
14 // No archive means no pruning: the engine must return the original answer.
15 return undefined;
16 }
17}
18src/router.ts 15 lines1import { CargoTestAdapter } from './adapters/cargo.js';
2import { JestVitestAdapter } from './adapters/jest-vitest.js';
3import { PytestAdapter } from './adapters/pytest.js';
4import { PackageManagerAdapter } from './adapters/package-manager.js';
5import { CompilerLinterAdapter } from './adapters/compiler.js';
6import { JavaAdapter } from './adapters/java.js';
7import type { ToolAdapter } from './adapters/types.js';
8
9const adapters: ToolAdapter[] = [new JestVitestAdapter(), new CargoTestAdapter(), new PytestAdapter(), new JavaAdapter(), new PackageManagerAdapter(), new CompilerLinterAdapter()];
10
11export function routeCommand(command: string, stdout: string, stderr?: string): ToolAdapter | undefined {
12 // AGENTS.md requires unknown commands to pass through, without generic truncation.
13 return adapters.find((adapter) => adapter.canHandle(command, stdout, stderr));
14}
15src/adapters/cargo.ts 47 lines1import { isSimpleCommand, plainLine, pruneStreams } from './shared.js';
2import type { PruneResult, ToolAdapter } from './types.js';
3
4const summary = /^test result: ok\. (\d+) passed; (\d+) failed; (\d+) ignored; (\d+) measured; (\d+) filtered out; finished in (\d+(?:\.\d+)?)s$/;
5const passingTest = /^test .+ \.\.\. (?:ok|ignored)\s*$/;
6const compiling = /^\s*(?:Compiling|Checking|Downloading|Downloaded)\s/;
7const runner = /^\s*(?:Running\s|Doc-tests\s|running \d+ tests?\s*$)/;
8const diagnostic = /^\s*(?:failures:|error(?:\[|:)|warning:|thread .+ panicked)/;
9
10function condense(text: string): string {
11 const raw = text.split('\n');
12 const lines = raw.map(plainLine);
13 const summaries = lines.map((line) => line.match(summary)).filter((match) => match !== null);
14 if (summaries.length && summaries.every((match) => match[2] === '0') && lines.every((line) =>
15 !line.trim() || passingTest.test(line) || runner.test(line) || summary.test(line))) {
16 const totals = [0, 0, 0, 0, 0, 0];
17 for (const match of summaries) {
18 for (let i = 0; i < totals.length; i++) totals[i] = totals[i]! + Number(match[i + 1]);
19 }
20 return `✅ All Rust tests passed (${totals[0]} passed; ${totals[1]} failed; ${totals[2]} ignored; ${totals[3]} measured; ${totals[4]} filtered out in ${totals[5]!.toFixed(2)}s)`;
21 }
22 if (lines.some((line) => diagnostic.test(line))) {
23 let inDiagnostic = false;
24 return raw.filter((_, index) => {
25 const line = lines[index]!;
26 if (diagnostic.test(line)) inDiagnostic = true;
27 return inDiagnostic || !(passingTest.test(line) || compiling.test(line) || runner.test(line));
28 }).join('\n');
29 }
30 if (lines.some((line) => /^\s*Finished\s/.test(line)) && lines.every((line) => !line.trim() || compiling.test(line) || runner.test(line) || /^\s*Finished\s/.test(line))) {
31 return raw.filter((_, index) => /^\s*Finished\s/.test(lines[index]!)).join('\n');
32 }
33 return text;
34}
35
36export class CargoTestAdapter implements ToolAdapter {
37 readonly name = 'Cargo';
38
39 canHandle(command: string, _stdout: string, _stderr?: string): boolean {
40 return isSimpleCommand(command) && /^\s*cargo\s+(?:test|check|build)(?=\s|$)/.test(command);
41 }
42
43 prune(stdout: string, stderr?: string): PruneResult {
44 return pruneStreams(this.name, stdout, stderr, condense);
45 }
46}
47src/adapters/jest-vitest.ts 50 lines1import { isSimpleCommand, plainLine, pruneStreams } from './shared.js';
2import type { PruneResult, ToolAdapter } from './types.js';
3
4const passingSuite = /^\s*PASS\s+\S+\.(?:spec|test)\.[cm]?[jt]sx?\s*$/;
5const passingRow = /^\s*[✓✔]\s/;
6const failureHeader = /^\s*(?:FAIL\s|●\s)/;
7const summaryRow = /^\s*(?:Test Suites:|Test Files\s|Tests[:\s]|Snapshots:|Time:|Start at\s|Duration\s)/;
8
9function condense(text: string): string {
10 const raw = text.split('\n');
11 const lines = raw.map(plainLine);
12 if (lines.some((line) => failureHeader.test(line))) {
13 let inDiagnostic = false;
14 return raw.filter((_, i) => {
15 const line = lines[i]!;
16 if (passingSuite.test(line)) {
17 inDiagnostic = false;
18 return false;
19 }
20 if (failureHeader.test(line)) inDiagnostic = true;
21 return inDiagnostic || !passingRow.test(line);
22 }).join('\n');
23 }
24
25 const tests = lines.find((line) => /^\s*Tests[:\s]/.test(line));
26 const passed = tests?.match(/\b(\d+) passed\b/)?.[1];
27 if (!passed || lines.some((line) => /\b(?:FAIL|FAILED|Error|\w*Error|Exception)\b|\b[1-9]\d* failed\b/.test(line))) return text;
28 const knownNoise = (line: string) => !line.trim() || passingSuite.test(line) || passingRow.test(line)
29 || summaryRow.test(line) || /^\s*(?:>\s|RUN\s|Ran all test suites\.)/.test(line);
30 if (!lines.every(knownNoise)) return text;
31
32 const suites = lines.find((line) => /^\s*(?:Test Suites:|Test Files\s)/.test(line))?.match(/\b(\d+) passed\b/)?.[1];
33 const time = lines.find((line) => /^\s*(?:Time:|Duration\s)/.test(line))?.match(/(\d+(?:\.\d+)?)\s*(ms|s)\b/);
34 const snapshots = lines.find((line) => /^\s*Snapshots:/.test(line));
35 const counts = tests!.replace(/^\s*Tests:?\s+/, '').trim();
36 return `✅ Tests passed (${counts}${suites ? ` across ${suites} suites` : ''}${time ? ` in ${time[1]}${time[2]}` : ''})${snapshots ? `; ${snapshots.trim()}` : ''}`;
37}
38
39export class JestVitestAdapter implements ToolAdapter {
40 readonly name = 'Jest / Vitest';
41
42 canHandle(command: string, _stdout: string, _stderr?: string): boolean {
43 return isSimpleCommand(command) && /^\s*(?:(?:npx\s+)?(?:jest|vitest)(?=\s|$)|(?:npm|pnpm|yarn)\s+(?:test(?=\s|$)|run\s+test[\w:-]*(?=\s|$)))/.test(command);
44 }
45
46 prune(stdout: string, stderr?: string): PruneResult {
47 return pruneStreams(this.name, stdout, stderr, condense);
48 }
49}
50src/adapters/pytest.ts 48 lines1import { isSimpleCommand, plainLine, pruneStreams } from './shared.js';
2import type { PruneResult, ToolAdapter } from './types.js';
3
4const pytestSummary = /^=+\s+(.+\bpassed\b.*\bin\s+\d+(?:\.\d+)?s)\s+=+$/;
5const diagnosticStart = /^(?:=+\s+(?:FAILURES|ERRORS)\s+=+|(?:FAIL|ERROR):\s)/;
6const unittestPass = /^\S.*\s\.\.\. ok$/;
7
8function isNoise(line: string): boolean {
9 return !line.trim() || /^[.s]+$/.test(line) || unittestPass.test(line)
10 || /^=+ test session starts =+$/.test(line)
11 || /^(?:platform\s|rootdir:|plugins:|cachedir:|configfile:|collected \d+ items?)/.test(line)
12 || /^\S+\.py(?:::\S+)?\s+[.FsExX]+\s*(?:\[\s*\d+%\])?$/.test(line)
13 || /^-{5,}$/.test(line);
14}
15
16function condense(text: string): string {
17 const raw = text.split('\n');
18 const lines = raw.map(plainLine);
19 const start = lines.findIndex((line) => diagnosticStart.test(line));
20 if (start !== -1) {
21 return raw.filter((_, i) => i >= start || !isNoise(lines[i]!)).join('\n');
22 }
23
24 const summary = lines.find((line) => pytestSummary.test(line));
25 if (summary && lines.every((line) => isNoise(line) || pytestSummary.test(line))) {
26 if (/\b(?:failed|error|errors|warnings?)\b/.test(summary)) return text;
27 return `✅ All tests passed (${summary.match(pytestSummary)![1]})`;
28 }
29
30 const ran = lines.find((line) => /^Ran \d+ tests? in \d+(?:\.\d+)?s$/.test(line));
31 if (ran && lines.includes('OK') && lines.every((line) => isNoise(line) || line === ran || line === 'OK')) {
32 return `✅ All tests passed (${ran.replace(/^Ran /, '').replace(/ tests?\b/, ' passed')})`;
33 }
34 return text;
35}
36
37export class PytestAdapter implements ToolAdapter {
38 readonly name = 'Pytest / unittest';
39
40 canHandle(command: string, _stdout: string, _stderr?: string): boolean {
41 return isSimpleCommand(command) && /^\s*(?:poetry\s+run\s+)?(?:pytest(?=\s|$)|python(?:3(?:\.\d+)?)?\s+-m\s+(?:pytest|unittest)(?=\s|$))/.test(command);
42 }
43
44 prune(stdout: string, stderr?: string): PruneResult {
45 return pruneStreams(this.name, stdout, stderr, condense);
46 }
47}
48src/adapters/package-manager.ts 35 lines1import { isSimpleCommand, plainLine, pruneStreams } from './shared.js';
2import type { PruneResult, ToolAdapter } from './types.js';
3
4function condense(text: string): string {
5 const lines = text.split('\n');
6 let inError = false;
7 let removedNoise = false;
8 const kept = lines.filter((raw) => {
9 const line = plainLine(raw);
10 if (/^(?:npm (?:ERR!(?=\s|$)|error\b)|ERROR[: ]|error[: ]|Traceback\b)|\b(?:ERESOLVE|Exception|\w*Error:)\b/.test(line)) inError = true;
11 if (inError) return true;
12 if (!line.trim()) return false;
13 const noise = /^(?:npm (?:notice|warn deprecated)\b|\[[#= >\\/|\-]+\].*(?:reify|fetch)|reify:)/.test(line)
14 || /^\s*(?:\d+ packages? are looking for funding|run `npm fund`)/.test(line)
15 || /^(?:Progress: resolved |yarn (?:add|install) v|\[\d+\/\d+\] (?:Resolving|Fetching|Linking|Building) packages)/.test(line)
16 || /^(?:Collecting |Downloading |\s*Downloading |Installing collected packages:|Requirement already satisfied:|Installing dependencies from lock file)/.test(line)
17 || /^\s*- Installing \S+ \([^)]+\)\s*$/.test(line);
18 removedNoise ||= noise;
19 return !noise;
20 }).join('\n');
21 return removedNoise ? kept : text;
22}
23
24export class PackageManagerAdapter implements ToolAdapter {
25 readonly name = 'Package managers';
26
27 canHandle(command: string, _stdout: string, _stderr?: string): boolean {
28 return isSimpleCommand(command) && /^\s*(?:(?:npm|pnpm)\s+(?:i|install|ci)|yarn\s+(?:add|install)|pip3?\s+install|poetry\s+(?:add|install))(?=\s|$)/.test(command);
29 }
30
31 prune(stdout: string, stderr?: string): PruneResult {
32 return pruneStreams(this.name, stdout, stderr, condense);
33 }
34}
35src/adapters/compiler.ts 33 lines1import { isSimpleCommand, plainLine, pruneStreams } from './shared.js';
2import type { PruneResult, ToolAdapter } from './types.js';
3
4function isNoise(line: string): boolean {
5 return !line.trim() || /^>\s/.test(line)
6 || /^(?:\[[\d: ]+\]\s*)?(?:Starting compilation|File change detected|Found 0 errors\.)/.test(line)
7 || /^All checks passed!$/.test(line);
8}
9
10function condense(text: string): string {
11 const raw = text.split('\n');
12 const lines = raw.map(plainLine);
13 const start = lines.findIndex((line) => line.trim() && !isNoise(line));
14 if (start !== -1) {
15 if (lines.some((line) => /Found 0 errors\.|^All checks passed!$/.test(line))) return text;
16 return raw.slice(start).join('\n');
17 }
18 if (lines.some((line) => /Found 0 errors\.|^All checks passed!$/.test(line))) return '✅ No lint/type errors found';
19 return text;
20}
21
22export class CompilerLinterAdapter implements ToolAdapter {
23 readonly name = 'Compilers / linters';
24
25 canHandle(command: string, _stdout: string, _stderr?: string): boolean {
26 return isSimpleCommand(command) && /^\s*(?:(?:npx\s+)?(?:tsc|eslint)|ruff\s+check|flake8)(?=\s|$)/.test(command);
27 }
28
29 prune(stdout: string, stderr?: string): PruneResult {
30 return pruneStreams(this.name, stdout, stderr, condense);
31 }
32}
33