SLOPSHOPPER

skim

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

guardcommandtoaststatus
v0.1.3MITupdated 2026-10-18zaninifrancesco/claude-skim
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skim
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /skim ⎿ skim: ### ⚡ Skim Savings Report ⎿ skim: ⎿ skim: | Metric | Details | ⎿ skim: | :--- | :--- | ⎿ skim: | **Session Saved** | `0` tokens (~$0.00) | ⎿ skim: | **Logs Skimmed** | `0` command runs (0.0% reduction) | ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ skim: ⚡ Active (monitoring stdout)
README

⚡ Claude Skim

Local processing Runtime dependencies License: MIT

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.

Install once

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.

Supported output

  • Jest / Vitest: compact passing summaries; preserve failing test names, assertion diffs, source snippets and stack traces.
  • Cargo test / check / build: compact passing Rust test targets and compilation progress; preserve failed tests, panic backtraces and compiler diagnostics.
  • Pytest / unittest: remove passing progress output; preserve failure and collection-error blocks, tracebacks and summaries.
  • Java (Maven / Gradle / JUnit): compact verbose dependency download tickers, plugin executions, and passing test suites; preserve [ERROR] Failures: blocks, Gradle task failures, assertion diffs, and full stack traces.
  • npm / pnpm / yarn / pip / Poetry: remove recognized installation progress; preserve package counts, security summaries, unknown messages and fatal errors.
  • TypeScript / ESLint / Ruff / Flake8: remove known compiler banners; preserve diagnostic paths, line numbers, source excerpts, rules and warnings.

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.

Behavior

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.

Commands

  • /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.

Privacy

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.

Development

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.

License

MIT © 2026 Francesco Zanini

Source 15 files
hooks/register.ts 87 lines
1import 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};
87
src/commands.ts 66 lines
1import 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}
66
src/engine.ts 68 lines
1import 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}
68
src/metrics.ts 32 lines
1export 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}
32
src/utils/tokens.ts 8 lines
1export 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}
8
src/archiver.ts 18 lines
1import 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}
18
src/router.ts 15 lines
1import { 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}
15
src/adapters/cargo.ts 47 lines
1import { 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}
47
src/adapters/jest-vitest.ts 50 lines
1import { 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}
50
src/adapters/pytest.ts 48 lines
1import { 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}
48
src/adapters/package-manager.ts 35 lines
1import { 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}
35
src/adapters/compiler.ts 33 lines
1import { 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