File Coverage

lib/MCP/Run/Compress.pm
Criterion Covered Total %
statement 162 318 50.9
branch 48 154 31.1
condition 42 92 45.6
subroutine 12 26 46.1
pod 1 5 20.0
total 265 595 44.5


line stmt bran cond sub pod time code
1             package MCP::Run::Compress;
2             our $VERSION = '0.100';
3 1     1   84487 use Mojo::Base -base;
  1         9252  
  1         5  
4              
5             # ABSTRACT: Output compression for LLMs
6              
7              
8 1     1   739 use Text::Trim qw(trim);
  1         597  
  1         60  
9 1     1   5 use List::Util qw(max min);
  1         1  
  1         54  
10 1     1   626 use Getopt::Long qw(GetOptionsFromArray);
  1         13465  
  1         4  
11              
12             has filters => sub { +{} };
13              
14              
15             sub _parse_command {
16 9     9   20 my ($self, $command) = @_;
17 9         34 my @words = split /\s+/, $command;
18 9 50       20 return { program => '', subcommand => undef, flags => {}, args => [] } unless @words;
19              
20 9         12 my $program = shift @words;
21 9         9 my ($subcommand, @remaining);
22              
23             # Find the subcommand (first word not starting with -)
24 9         23 for my $i (0 .. $#words) {
25 7 100       20 if ($words[$i] !~ /^-/) {
26 3         4 $subcommand = $words[$i];
27 3         8 @remaining = @words[$i+1 .. $#words];
28 3         4 last;
29             }
30             }
31 9 100       34 @remaining = @words unless defined $subcommand;
32              
33             # Parse flags with Getopt::Long
34 9         11 my %flags;
35              
36             # Build a Getopt::Long spec for common flag types
37             my @spec = (
38 0     0   0 'stat' => sub { $flags{stat} = 1 },
39 0     0   0 'numstat' => sub { $flags{numstat} = 1 },
40 0     0   0 'shortstat' => sub { $flags{shortstat} = 1 },
41             'w=i' => \$flags{w},
42             'width=i' => \$flags{width},
43             'stat-width=i' => \$flags{'stat-width'},
44             'stat-name-width=i' => \$flags{'stat-name-width'},
45             'M=s' => \$flags{M},
46 0     0   0 'ignore-space-change' => sub { $flags{'ignore-space-change'} = 1 },
47 0     0   0 'ignore-all-space' => sub { $flags{'ignore-all-space'} = 1 },
48 0     0   0 'ignore-blank-lines' => sub { $flags{'ignore-blank-lines'} = 1 },
49             'U=i' => \$flags{U},
50             'unified=i' => \$flags{unified},
51 0     0   0 'color' => sub { $flags{color} = 1 },
52 0     0   0 'no-color' => sub { $flags{color} = 0 },
53 0     0   0 'cached' => sub { $flags{cached} = 1 },
54 0     0   0 'no-pager' => sub { $flags{'no-pager'} = 1 },
55 9         157 );
56              
57             # Use GetOptionsFromArray to parse - suppress warnings for unknown flags
58 9         20 my $warn_handler = $SIG{__WARN__};
59 9     3   48 local $SIG{__WARN__} = sub { }; # Suppress warnings during parsing
60 9         35 GetOptionsFromArray(\@remaining, @spec);
61              
62             return {
63 9         4689 program => $program,
64             subcommand => $subcommand,
65             flags => \%flags,
66             args => \@remaining,
67             };
68             }
69              
70             sub register_filter {
71 432     432 0 307 my $self = shift;
72 432         663 my %args = @_;
73              
74 432         335 my $command = delete $args{command};
75 432         383 my $parsed_command = delete $args{parsed_command};
76              
77             # Determine storage key: parsed commands use a special key format
78 432         329 my $key;
79 432 100       366 if ($parsed_command) {
80 342         213 my @flag_parts;
81 342   100     271 for my $flag (sort keys %{$parsed_command->{flags} // {}}) {
  342         724  
82 9   50     34 push @flag_parts, "$flag=" . ($parsed_command->{flags}{$flag} // 1);
83             }
84 342   50     676 $key = 'parsed:' . ($parsed_command->{program} // '') . ':' . ($parsed_command->{subcommand} // '') . ':' . join(',', @flag_parts);
      100        
85             } else {
86 90         81 $key = $command;
87             }
88              
89             $self->filters->{$key} = {
90             command => $command,
91             parsed_command => $parsed_command,
92             strip_ansi => $args{strip_ansi} // 0,
93             strip_lines_matching => $args{strip_lines_matching} // [],
94             keep_lines_matching => $args{keep_lines_matching} // [],
95             truncate_lines_at => $args{truncate_lines_at} // 0,
96             max_lines => $args{max_lines} // 0,
97             tail_lines => $args{tail_lines} // 0,
98             head_lines => $args{head_lines} // 0,
99             on_empty => $args{on_empty} // '',
100             replace => $args{replace} // [],
101             match_output => $args{match_output} // [],
102             filter_stderr => $args{filter_stderr} // 0,
103             output_detect => $args{output_detect} // undef,
104             transform => $args{transform} // undef,
105             command_transform => $args{command_transform} // undef,
106 432   50     5449 };
      100        
      50        
      100        
      100        
      100        
      100        
      100        
      50        
      100        
      100        
      100        
      100        
      100        
107              
108 432         1398 return;
109             }
110              
111             sub _match_parsed_command {
112 148     148   130 my ($self, $filter_spec, $parsed) = @_;
113 148 50 33     275 return 0 unless $parsed && $filter_spec;
114              
115             # Must match program
116 148 100 50     319 return 0 if ($filter_spec->{program} // '') ne ($parsed->{program} // '');
      50        
117              
118             # Subcommand must match if specified
119 2 50       4 if (defined $filter_spec->{subcommand}) {
120 2 50 50     6 return 0 if ($filter_spec->{subcommand} // '') ne ($parsed->{subcommand} // '');
      50        
121             }
122              
123             # All specified flags must be present and truthy
124 0   0     0 for my $flag (keys %{$filter_spec->{flags} // {}}) {
  0         0  
125 0 0       0 return 0 unless $parsed->{flags}{$flag};
126             }
127              
128 0         0 return 1;
129             }
130              
131             sub _build_default_filters {
132 9     9   10 my $self = shift;
133              
134             # ls: ultra-compact for long listing - only keep type + filename
135             $self->register_filter(
136             command => '^ls\b',
137             output_detect => qr(^[d-][rwx-]{9}.*\s+\d+\s+),
138             strip_lines_matching => [
139             qr(^\s*$),
140             qr(^total\s+\d+),
141             qr(^\s*Device:),
142             qr(^\s*Inode:),
143             qr(^\s*Birth:),
144             qr(^/node_modules/),
145             qr(^/\.git/),
146             qr(^/\.target/),
147             qr(^/\.next/),
148             qr(^/\.nuxt/),
149             qr(^/\.cache/),
150             qr(^/__pycache__/),
151             qr(^/\.DS_Store/),
152             qr(^/vendor/bundle/),
153             ],
154             transform => sub {
155 9     9   8 my ($line) = @_;
156 9 100       25 if ($line =~ m{^([d-])[rwx-]{9}\s+\d+\s+\S+\s+\S+\s+\d+\s+\w+\s+\d+\s+[\d:]+\s+(.+)$}) {
157 6         12 return $1 . " " . $2;
158             }
159 3         7 return $line;
160             },
161 9         141 truncate_lines_at => 100,
162             max_lines => 50,
163             );
164              
165             # stat: strip device/inode/birth (Linux)
166 9         62 $self->register_filter(
167             command => '^stat\b',
168             strip_lines_matching => [
169             qr(^\s*$),
170             qr(^\s*Device:),
171             qr(^\s*Inode:),
172             qr(^\s*Birth:),
173             ],
174             truncate_lines_at => 120,
175             max_lines => 20,
176             );
177              
178             # grep: truncate lines, group by file
179 9         15 $self->register_filter(
180             command => '^grep\b',
181             truncate_lines_at => 150,
182             max_lines => 100,
183             );
184              
185             # make: strip entering/leaving directory
186 9         70 $self->register_filter(
187             command => '^make\b',
188             strip_lines_matching => [
189             qr(^\s*$),
190             qr(^make\[\d+\]:),
191             qr(^Entering directory),
192             qr(^Leaving directory),
193             qr(Nothing to be done),
194             qr(^make:\s+Nothing to be done),
195             ],
196             match_output => [
197             { pattern => qr(^make\[\d+\]:.*?make\[\d+\]:), message => 'make: circular dependency detected' },
198             { pattern => qr(^gcc.*?Error), message => 'make: compilation error' },
199             ],
200             max_lines => 50,
201             on_empty => 'make: ok',
202             );
203              
204             # git status: compact output
205 9         44 $self->register_filter(
206             parsed_command => {
207             program => 'git',
208             subcommand => 'status',
209             },
210             strip_lines_matching => [
211             qr(^\s*$),
212             qr(^On branch),
213             qr(^Your branch is),
214             qr(^Initial commit),
215             ],
216             max_lines => 30,
217             );
218              
219             # git diff: keep only diff content
220 9         43 $self->register_filter(
221             command => '^git\s+diff\b',
222             strip_lines_matching => [
223             qr(^\s*$),
224             qr(^diff --git),
225             qr(^index ),
226             qr(^---\s+a/),
227             qr(^\+\+\+\s+b/),
228             ],
229             truncate_lines_at => 150,
230             max_lines => 200,
231             );
232              
233             # git diff --stat: compact format "N+M- filename"
234             $self->register_filter(
235             parsed_command => {
236             program => 'git',
237             subcommand => 'diff',
238             flags => { stat => 1 },
239             },
240             transform => sub {
241 0     0   0 my ($line) = @_;
242             # Format: " 1 file changed, 3 insertions(+), 2 deletions(-)" or
243             # " file1.txt | 5 +++ ---"
244             # Transform to "N+M- filename" for file lines
245 0 0       0 if ($line =~ /^\s*(\S+)\s*\|\s*(\d+)\s*\+(\d+)\s*-\s*(\d+)/) {
246             # "| 5 +++ ---" format -> "3+2- filename"
247 0         0 return "$2+$3-$1";
248             }
249 0 0       0 if ($line =~ /^\s*(\S+)\s*\|\s*(\d+)\s+\+(\d+),?\s*(\d+)?\s*-/) {
250             # "| 5 +3 -2" format
251 0   0     0 my ($file, $changes, $add, $del) = ($1, $2, $3, $4 // 0);
252 0         0 return "$add+$del-$file";
253             }
254             # Remove summary line "X files changed"
255 0 0       0 if ($line =~ /^\s*\d+\s+files?\s+changed/) {
256 0         0 return undef; # Skip this line
257             }
258 0         0 return $line;
259             },
260 9         82 strip_lines_matching => [
261             qr(^\s*$),
262             qr(^\s*\d+\s+files?\s+changed),
263             qr(^\s*\d+\s+insertions?\(\+\)),
264             qr(^\s*\d+\s+deletions?\(\-\)),
265             ],
266             max_lines => 100,
267             );
268              
269             # cat: detect and filter code
270 9         28 $self->register_filter(
271             command => '^cat\b',
272             strip_lines_matching => [qr(^\s*$)],
273             truncate_lines_at => 500,
274             max_lines => 100,
275             );
276              
277             # find: strip permission denied, limit results
278 9         26 $self->register_filter(
279             command => '^find\b',
280             strip_lines_matching => [
281             qr(^\s*$),
282             qr(^find:.*permission denied),
283             ],
284             max_lines => 50,
285             );
286              
287             # ps: compact output
288 9         24 $self->register_filter(
289             command => '^ps\b',
290             strip_lines_matching => [
291             qr(^\s*$),
292             ],
293             truncate_lines_at => 120,
294             max_lines => 30,
295             );
296              
297             # df: truncate wide columns
298 9         22 $self->register_filter(
299             command => '^df\b',
300             strip_lines_matching => [qr(^\s*$)],
301             truncate_lines_at => 80,
302             max_lines => 20,
303             );
304              
305             # docker ps: compact output
306 9         25 $self->register_filter(
307             parsed_command => {
308             program => 'docker',
309             subcommand => 'ps',
310             },
311             strip_lines_matching => [qr(^\s*$)],
312             truncate_lines_at => 120,
313             max_lines => 30,
314             );
315              
316             # docker images: compact output
317 9         26 $self->register_filter(
318             parsed_command => {
319             program => 'docker',
320             subcommand => 'images',
321             },
322             strip_lines_matching => [qr(^\s*$)],
323             truncate_lines_at => 120,
324             max_lines => 30,
325             );
326              
327             # terraform plan: strip refresh progress
328 9         40 $self->register_filter(
329             parsed_command => {
330             program => 'terraform',
331             subcommand => 'plan',
332             },
333             strip_lines_matching => [
334             qr(^\s*$),
335             qr(^Refreshing state\.\.\.),
336             qr(^Terraform used the),
337             qr(^tfe-outputs:),
338             ],
339             max_lines => 100,
340             );
341              
342             # terraform apply: strip progress
343 9         55 $self->register_filter(
344             parsed_command => {
345             program => 'terraform',
346             subcommand => 'apply',
347             },
348             strip_lines_matching => [
349             qr(^\s*$),
350             qr(^Refreshing state\.\.\.),
351             qr(^Terraform will perform),
352             qr(^Proceeding with the following),
353             qr(^tfe-outputs:),
354             ],
355             max_lines => 100,
356             );
357              
358             # docker build: strip build progress
359 9         48 $self->register_filter(
360             parsed_command => {
361             program => 'docker',
362             subcommand => 'build',
363             },
364             strip_lines_matching => [
365             qr(^\s*$),
366             qr(^#\s*\d+\s+\[\s*\d+\s+/\s*\d+\]),
367             qr(^Step \d+/\d+:),
368             ],
369             max_lines => 50,
370             );
371              
372             # docker run: compact output
373 9         47 $self->register_filter(
374             parsed_command => {
375             program => 'docker',
376             subcommand => 'run',
377             },
378             strip_lines_matching => [
379             qr(^\s*$),
380             qr(^Unable to find image),
381             qr(^Pulling from library/),
382             qr(^Digest:),
383             qr(^Status:),
384             ],
385             max_lines => 30,
386             );
387              
388             # kubectl get: compact output
389 9         29 $self->register_filter(
390             parsed_command => {
391             program => 'kubectl',
392             subcommand => 'get',
393             },
394             strip_lines_matching => [qr(^\s*$)],
395             truncate_lines_at => 150,
396             max_lines => 50,
397             );
398              
399             # kubectl describe: strip noise
400 9         104 $self->register_filter(
401             parsed_command => {
402             program => 'kubectl',
403             subcommand => 'describe',
404             },
405             strip_lines_matching => [
406             qr(^\s*$),
407             qr(^Name:\s+\w+),
408             qr(^Namespace:\s+\w+),
409             qr(^Labels:\s*$),
410             qr(^Annotations:\s*$/),
411             ],
412             truncate_lines_at => 200,
413             max_lines => 100,
414             );
415              
416             # cargo build: strip compile progress
417 9         56 $self->register_filter(
418             parsed_command => {
419             program => 'cargo',
420             subcommand => 'build',
421             },
422             strip_lines_matching => [
423             qr(^\s*$),
424             qr(^Compiling\s+\w+),
425             qr(^Fresh\s+\w+),
426             qr(^Finished\s+),
427             ],
428             max_lines => 50,
429             );
430              
431             # cargo test: compact output
432 9         39 $self->register_filter(
433             parsed_command => {
434             program => 'cargo',
435             subcommand => 'test',
436             },
437             strip_lines_matching => [
438             qr(^\s*$),
439             qr(^Compiling\s+\w+),
440             qr(^Running\s+/),
441             qr(^test result:),
442             ],
443             max_lines => 100,
444             );
445              
446             # cpanm: strip cpanm noise
447 9         57 $self->register_filter(
448             command => '^cpanm\b',
449             strip_lines_matching => [
450             qr(^\s*$),
451             qr(^--> ),
452             qr(^OK$),
453             qr(^FAIL$),
454             qr(^Working on),
455             qr(^Fetching),
456             qr(^Configuring),
457             qr(^Building and testing),
458             ],
459             match_output => [
460             { pattern => qr{^\s*Successfully installed}, message => 'cpanm: ok' },
461             ],
462             max_lines => 30,
463             );
464              
465             # npm install: strip noise
466 9         45 $self->register_filter(
467             parsed_command => {
468             program => 'npm',
469             subcommand => 'install',
470             },
471             strip_lines_matching => [
472             qr(^\s*$),
473             qr(^added\s+\d+\s+packages?),
474             qr(^found\s+\d+\s+packages?),
475             qr(^npm warn),
476             ],
477             max_lines => 30,
478             );
479              
480             # yarn install: similar to npm
481 9         37 $self->register_filter(
482             parsed_command => {
483             program => 'yarn',
484             subcommand => 'install',
485             },
486             strip_lines_matching => [
487             qr(^\s*$),
488             qr(^Done in\s+),
489             qr(^Resolving completed),
490             qr(^Linking completed),
491             ],
492             max_lines => 30,
493             );
494              
495             # yarn add: similar to npm
496 9         34 $self->register_filter(
497             parsed_command => {
498             program => 'yarn',
499             subcommand => 'add',
500             },
501             strip_lines_matching => [
502             qr(^\s*$),
503             qr(^Done in\s+),
504             qr(^Resolving completed),
505             qr(^Linking completed),
506             ],
507             max_lines => 30,
508             );
509              
510             # pnpm install: similar to npm
511 9         45 $self->register_filter(
512             parsed_command => {
513             program => 'pnpm',
514             subcommand => 'install',
515             },
516             strip_lines_matching => [
517             qr(^\s*$),
518             qr(^Done in\s+),
519             qr(^Resolving completed),
520             qr(^Linking completed),
521             ],
522             max_lines => 30,
523             );
524              
525             # pnpm add: similar to npm
526 9         41 $self->register_filter(
527             parsed_command => {
528             program => 'pnpm',
529             subcommand => 'add',
530             },
531             strip_lines_matching => [
532             qr(^\s*$),
533             qr(^Done in\s+),
534             qr(^Resolving completed),
535             qr(^Linking completed),
536             ],
537             max_lines => 30,
538             );
539              
540             # pip install: strip progress
541 9         61 $self->register_filter(
542             parsed_command => {
543             program => 'pip',
544             subcommand => 'install',
545             },
546             strip_lines_matching => [
547             qr(^\s*$),
548             qr(^Collecting\s+),
549             qr(^Downloading\s+),
550             qr(^Installing collected packages:),
551             qr(^Successfully installed),
552             ],
553             max_lines => 50,
554             );
555              
556             # pytest: compact output
557 9         118 $self->register_filter(
558             parsed_command => {
559             program => 'pytest',
560             },
561             strip_lines_matching => [
562             qr(^\s*$),
563             qr(^=+.*=+$/),
564             qr(^Coverage report:),
565             qr(^HTML report:),
566             ],
567             truncate_lines_at => 150,
568             max_lines => 100,
569             );
570              
571             # curl: strip headers
572 9         40 $self->register_filter(
573             parsed_command => {
574             program => 'curl',
575             },
576             filter_stderr => 1,
577             strip_lines_matching => [
578             qr(^\s*$),
579             qr(^ % Total),
580             qr(^ Resolving),
581             qr(^Connected to),
582             qr(^HTTP/\d[\d.]*\s+\d+),
583             ],
584             truncate_lines_at => 200,
585             max_lines => 50,
586             );
587              
588             # wget: strip progress
589 9         68 $self->register_filter(
590             parsed_command => {
591             program => 'wget',
592             },
593             strip_lines_matching => [
594             qr(^\s*$),
595             qr(^--\d{4}-\d{2}-\d{2}),
596             qr(^Resolving),
597             qr(^Connecting to),
598             qr(^Length:\s+\d+),
599             qr(^Saving to:),
600             qr(^\d+%\s+\[),
601             ],
602             truncate_lines_at => 200,
603             max_lines => 50,
604             );
605              
606             # helm install: strip progress
607 9         48 $self->register_filter(
608             parsed_command => {
609             program => 'helm',
610             subcommand => 'install',
611             },
612             strip_lines_matching => [
613             qr(^\s*$),
614             qr(^NAME:\s+\w+),
615             qr(^NAMESPACE:\s+\w+),
616             qr(^STATUS:\s+),
617             qr(^REVISION:\s+\d+),
618             qr(^NOTES:$),
619             ],
620             max_lines => 50,
621             );
622              
623             # helm upgrade: strip progress
624 9         61 $self->register_filter(
625             parsed_command => {
626             program => 'helm',
627             subcommand => 'upgrade',
628             },
629             strip_lines_matching => [
630             qr(^\s*$),
631             qr(^NAME:\s+\w+),
632             qr(^NAMESPACE:\s+\w+),
633             qr(^STATUS:\s+),
634             qr(^REVISION:\s+\d+),
635             qr(^NOTES:$),
636             ],
637             max_lines => 50,
638             );
639              
640             # ansible-playbook: strip progress
641 9         72 $self->register_filter(
642             parsed_command => {
643             program => 'ansible-playbook',
644             },
645             strip_lines_matching => [
646             qr(^\s*$),
647             qr(^PLAY\s+\[),
648             qr(^TASK\s+\[),
649             qr(^RUNNING HANDLER),
650             qr(^changed:\s+\[\d+\]),
651             qr(^ok:\s+\[\d+\]),
652             qr(^fatal:\s+\[\d+\]),
653             qr(^PLAY RECAP),
654             ],
655             max_lines => 100,
656             );
657              
658             # rsync: strip progress
659 9         48 $self->register_filter(
660             parsed_command => {
661             program => 'rsync',
662             },
663             strip_lines_matching => [
664             qr(^\s*$),
665             qr(^sent\s+\d+\s+bytes),
666             qr(^received\s+\d+\s+bytes),
667             qr(^total size is),
668             ],
669             max_lines => 30,
670             );
671              
672             # iptables -L: compact
673 9         28 $self->register_filter(
674             parsed_command => {
675             program => 'iptables',
676             },
677             strip_lines_matching => [qr(^\s*$)],
678             truncate_lines_at => 150,
679             max_lines => 50,
680             );
681              
682             # ping: strip progress
683 9         39 $self->register_filter(
684             parsed_command => {
685             program => 'ping',
686             },
687             strip_lines_matching => [
688             qr(^\s*$),
689             qr(^PING\s+),
690             qr(^64 bytes from),
691             qr(^---.*ping statistics---),
692             ],
693             max_lines => 20,
694             );
695              
696             # netstat: compact output for -tulpn
697 9         23 $self->register_filter(
698             parsed_command => {
699             program => 'netstat',
700             },
701             strip_lines_matching => [qr(^\s*$)],
702             truncate_lines_at => 150,
703             max_lines => 50,
704             );
705              
706             # ip addr: compact
707 9         25 $self->register_filter(
708             parsed_command => {
709             program => 'ip',
710             subcommand => 'addr',
711             },
712             strip_lines_matching => [qr(^\s*$)],
713             truncate_lines_at => 150,
714             max_lines => 50,
715             );
716              
717             # ip route: compact
718 9         30 $self->register_filter(
719             parsed_command => {
720             program => 'ip',
721             subcommand => 'route',
722             },
723             strip_lines_matching => [qr(^\s*$)],
724             max_lines => 30,
725             );
726              
727             # ip link: compact
728 9         32 $self->register_filter(
729             parsed_command => {
730             program => 'ip',
731             subcommand => 'link',
732             },
733             strip_lines_matching => [qr(^\s*$)],
734             max_lines => 30,
735             );
736              
737             # mount: strip noise
738 9         25 $self->register_filter(
739             parsed_command => {
740             program => 'mount',
741             },
742             strip_lines_matching => [qr(^\s*$)],
743             truncate_lines_at => 200,
744             max_lines => 50,
745             );
746              
747             # lsblk: compact block device listing
748 9         26 $self->register_filter(
749             parsed_command => {
750             program => 'lsblk',
751             },
752             strip_lines_matching => [qr(^\s*$)],
753             truncate_lines_at => 150,
754             max_lines => 50,
755             );
756              
757             # blkid: compact block device attributes
758 9         23 $self->register_filter(
759             parsed_command => {
760             program => 'blkid',
761             },
762             strip_lines_matching => [qr(^\s*$)],
763             truncate_lines_at => 200,
764             max_lines => 50,
765             );
766              
767             # git log: compact
768 9         44 $self->register_filter(
769             parsed_command => {
770             program => 'git',
771             subcommand => 'log',
772             },
773             strip_lines_matching => [
774             qr(^\s*$),
775             qr(^commit\s+[a-f0-9]+),
776             qr(^Author:\s+),
777             qr(^Date:\s+),
778             ],
779             head_lines => 20,
780             tail_lines => 10,
781             max_lines => 30,
782             );
783              
784             # git branch: compact
785 9         31 $self->register_filter(
786             parsed_command => {
787             program => 'git',
788             subcommand => 'branch',
789             },
790             strip_lines_matching => [qr(^\s*$)],
791             max_lines => 30,
792             );
793              
794             # git stash: compact
795 9         21 $self->register_filter(
796             parsed_command => {
797             program => 'git',
798             subcommand => 'stash',
799             },
800             strip_lines_matching => [qr(^\s*$)],
801             max_lines => 30,
802             );
803              
804             # git commit: transform Co-Authored-By
805             $self->register_filter(
806             parsed_command => {
807             program => 'git',
808             subcommand => 'commit',
809             },
810             command_transform => sub {
811 0     0   0 my ($cmd) = @_;
812 0   0     0 my $model = $ENV{CO_AUTHORED_BY} || $ENV{ANTHROPIC_MODEL};
813 0 0       0 return $cmd unless $model;
814 0 0       0 if ($cmd =~ /Co-Authored-By:/i) {
815 0         0 $cmd =~ s/Co-Authored-By: [^\n]+/Co-Authored-By: $model/g;
816             } else {
817 0         0 $cmd =~ s/(")\s*$/$1\n\nCo-Authored-By: $model/m;
818             }
819 0         0 return $cmd;
820             },
821 9         48 );
822              
823 9         11 return;
824             }
825              
826             sub new {
827 9     9 1 254474 my $self = shift->SUPER::new(@_);
828 9         56 $self->_build_default_filters;
829 9         18 return $self;
830             }
831              
832             sub compress {
833 9     9 0 153 my ($self, $command, $stdout, $stderr) = @_;
834              
835 9         6 my $matched_filter;
836 9         17 my $parsed = $self->_parse_command($command);
837              
838 9         16 for my $key (keys %{$self->filters}) {
  9         18  
839 196         269 my $filter = $self->filters->{$key};
840              
841 196         431 my $matches = 0;
842              
843             # Check parsed_command first if present
844 196 100       616 if (my $pc = $filter->{parsed_command}) {
    100          
845 148 50       136 $matches = 1 if $self->_match_parsed_command($pc, $parsed);
846             }
847             # Fall back to regex matching for legacy filters
848             elsif ($command =~ /$key/) {
849 8         9 $matches = 1;
850             }
851              
852 196 100       250 next unless $matches;
853              
854             # Check output_detect if present - only apply filter if output matches
855 8 100       14 if (my $detect = $filter->{output_detect}) {
856 1         4 my @lines = split(/\n/, $stdout);
857 1         2 my $has_match = grep { /$detect/ } @lines;
  9         27  
858 1 50       3 unless ($has_match) {
859 0         0 next; # Skip this filter, try next one
860             }
861             }
862              
863 8         5 $matched_filter = $filter;
864 8         12 last; # Found matching filter
865             }
866              
867             # Apply command_transform if any filter has it and env var allows it
868 9 50 66     38 if ($matched_filter && $matched_filter->{command_transform} && !$ENV{MCP_RUN_COMPRESS_NO_CO_AUTHORED}) {
      33        
869 0         0 $command = $matched_filter->{command_transform}->($command);
870             }
871              
872 9 100       13 if (!$matched_filter) {
873 1         6 return ($stdout, $stderr);
874             }
875              
876 8   50     14 my ($out, $err) = ($stdout, $stderr // '');
877              
878             # Filter stderr into stdout if configured
879 8 50       14 if ($matched_filter->{filter_stderr}) {
880 0 0       0 $out .= "\n$err" if length $err;
881 0         0 $err = '';
882             }
883              
884             # Stage 1: Strip ANSI
885 8 50       12 if ($matched_filter->{strip_ansi}) {
886 0         0 $out =~ s/\x1b\[[0-9;]*[a-zA-Z]//g;
887 0         0 $err =~ s/\x1b\[[0-9;]*[a-zA-Z]//g;
888             }
889              
890             # Stage 2: Match output short-circuit
891 8         6 for my $match (@{$matched_filter->{match_output}}) {
  8         17  
892 4 50       19 if ($out =~ /$match->{pattern}/) {
893 0         0 return ($match->{message}, $err);
894             }
895             }
896              
897             # Stage 3: Transform lines (if transform coderef provided)
898 8 100       12 if ($matched_filter->{transform}) {
899 1         4 $out = join("\n", map { $matched_filter->{transform}->($_) } split(/\n/, $out));
  9         9  
900             }
901              
902             # Stage 4: Strip lines matching
903 8 100       9 if (@{$matched_filter->{strip_lines_matching}}) {
  8         11  
904 7         30 my @out_lines = split(/\n/, $out);
905             @out_lines = grep {
906 7         14 my $keep = 1;
  100         75  
907 100         60 for my $pattern (@{$matched_filter->{strip_lines_matching}}) {
  100         87  
908 317 100       552 if (/$pattern/) {
909 15         15 $keep = 0;
910 15         13 last;
911             }
912             }
913 100         102 $keep;
914             } @out_lines;
915 7         23 $out = join("\n", @out_lines);
916             }
917              
918             # Stage 5: Keep lines matching (if any)
919 8 50       9 if (@{$matched_filter->{keep_lines_matching}}) {
  8         16  
920 0         0 my @out_lines = split(/\n/, $out);
921             @out_lines = grep {
922 0         0 my $keep = 0;
  0         0  
923 0         0 for my $pattern (@{$matched_filter->{keep_lines_matching}}) {
  0         0  
924 0 0       0 if (/$pattern/) {
925 0         0 $keep = 1;
926 0         0 last;
927             }
928             }
929 0         0 $keep;
930             } @out_lines;
931 0         0 $out = join("\n", @out_lines);
932             }
933              
934             # Stage 6: Truncate lines at N chars
935 8 100       11 if ($matched_filter->{truncate_lines_at} > 0) {
936 5         6 my $max = $matched_filter->{truncate_lines_at};
937 5 100       10 $out = join("\n", map { length $_ > $max ? substr($_, 0, $max) . '...' : $_ } split(/\n/, $out));
  28         44  
938 5 0       11 $err = join("\n", map { length $_ > $max ? substr($_, 0, $max) . '...' : $_ } split(/\n/, $err)) if length $err;
  0 50       0  
939             }
940              
941             # Stage 7: Head/Tail lines
942 8 50       30 if ($matched_filter->{head_lines} > 0) {
    50          
943 0         0 my @lines = split(/\n/, $out);
944 0         0 my $head = $matched_filter->{head_lines};
945 0   0     0 my $tail = $matched_filter->{tail_lines} // 0;
946 0         0 my $omit = @lines - $head - $tail;
947 0 0 0     0 if ($omit > 0 && $tail > 0) {
    0          
948 0         0 $out = (join("\n", @lines[0..$head-1])) . "\n... $omit lines omitted ...\n" . (join("\n", @lines[-$tail..-1]));
949             }
950             elsif ($omit > 0) {
951 0         0 $out = join("\n", @lines[0..min($head, @lines)-1]);
952             }
953             }
954             elsif ($matched_filter->{tail_lines} > 0) {
955 0         0 my @lines = split(/\n/, $out);
956 0         0 $out = join("\n", @lines[-min($matched_filter->{tail_lines}, @lines)..-1]);
957             }
958              
959             # Stage 8: Max lines
960 8 50       12 if ($matched_filter->{max_lines} > 0) {
961 8         18 my @out_lines = split(/\n/, $out);
962 8 100       16 if (@out_lines > $matched_filter->{max_lines}) {
963 1         26 $out = join("\n", @out_lines[0..$matched_filter->{max_lines}-1]) . "\n... " . (@out_lines - $matched_filter->{max_lines}) . " more lines ...";
964             }
965             }
966              
967             # Stage 9: On empty
968 8 50 66     22 if (!length trim($out) && $matched_filter->{on_empty}) {
969 1         17 $out = $matched_filter->{on_empty};
970             }
971              
972 8         185 return ($out, $err);
973             }
974              
975             sub process {
976 0     0 0   my ($self, $command, $stdout, $stderr) = @_;
977              
978 0           my $matched_filter;
979 0           my $parsed = $self->_parse_command($command);
980              
981 0           for my $key (keys %{$self->filters}) {
  0            
982 0           my $filter = $self->filters->{$key};
983              
984 0           my $matches = 0;
985              
986 0 0         if (my $pc = $filter->{parsed_command}) {
    0          
987 0 0         $matches = 1 if $self->_match_parsed_command($pc, $parsed);
988             }
989             elsif ($command =~ /$key/) {
990 0           $matches = 1;
991             }
992              
993 0 0         next unless $matches;
994              
995 0 0         if (my $detect = $filter->{output_detect}) {
996 0           my @lines = split(/\n/, $stdout);
997 0           my $has_match = grep { /$detect/ } @lines;
  0            
998 0 0         unless ($has_match) {
999 0           next;
1000             }
1001             }
1002              
1003 0           $matched_filter = $filter;
1004 0           last;
1005             }
1006              
1007 0   0       my ($out, $err) = ($stdout, $stderr // '');
1008              
1009 0 0 0       if ($matched_filter && $matched_filter->{command_transform} && !$ENV{MCP_RUN_COMPRESS_NO_CO_AUTHORED}) {
      0        
1010 0           $command = $matched_filter->{command_transform}->($command);
1011             }
1012              
1013 0 0         if (!$matched_filter) {
1014 0           return { command => $command, stdout => $out, stderr => $err };
1015             }
1016              
1017 0 0         if ($matched_filter->{filter_stderr}) {
1018 0 0         $out .= "\n$err" if length $err;
1019 0           $err = '';
1020             }
1021              
1022 0 0         if ($matched_filter->{strip_ansi}) {
1023 0           $out =~ s/\x1b\[[0-9;]*[a-zA-Z]//g;
1024 0           $err =~ s/\x1b\[[0-9;]*[a-zA-Z]//g;
1025             }
1026              
1027 0   0       for my $match (@{$matched_filter->{match_output} // []}) {
  0            
1028 0 0         if ($out =~ /$match->{pattern}/) {
1029 0           return { command => $command, stdout => $match->{message}, stderr => $err };
1030             }
1031             }
1032              
1033 0 0         if ($matched_filter->{transform}) {
1034 0           $out = join("\n", map { $matched_filter->{transform}->($_) } split(/\n/, $out));
  0            
1035             }
1036              
1037 0 0 0       if (@{$matched_filter->{strip_lines_matching} // []}) {
  0            
1038 0           my @out_lines = split(/\n/, $out);
1039             @out_lines = grep {
1040 0           my $keep = 1;
  0            
1041 0           for my $pattern (@{$matched_filter->{strip_lines_matching}}) {
  0            
1042 0 0         if (/$pattern/) {
1043 0           $keep = 0;
1044 0           last;
1045             }
1046             }
1047 0           $keep;
1048             } @out_lines;
1049 0           $out = join("\n", @out_lines);
1050             }
1051              
1052 0 0 0       if (@{$matched_filter->{keep_lines_matching} // []}) {
  0            
1053 0           my @out_lines = split(/\n/, $out);
1054             @out_lines = grep {
1055 0           my $keep = 0;
  0            
1056 0           for my $pattern (@{$matched_filter->{keep_lines_matching}}) {
  0            
1057 0 0         if (/$pattern/) {
1058 0           $keep = 1;
1059 0           last;
1060             }
1061             }
1062 0           $keep;
1063             } @out_lines;
1064 0           $out = join("\n", @out_lines);
1065             }
1066              
1067 0 0         if ($matched_filter->{truncate_lines_at} > 0) {
1068 0           my $max = $matched_filter->{truncate_lines_at};
1069 0 0         $out = join("\n", map { length $_ > $max ? substr($_, 0, $max) . '...' : $_ } split(/\n/, $out));
  0            
1070 0 0         $err = join("\n", map { length $_ > $max ? substr($_, 0, $max) . '...' : $_ } split(/\n/, $err)) if length $err;
  0 0          
1071             }
1072              
1073 0 0         if ($matched_filter->{head_lines} > 0) {
    0          
1074 0           my @lines = split(/\n/, $out);
1075 0           my $head = $matched_filter->{head_lines};
1076 0   0       my $tail = $matched_filter->{tail_lines} // 0;
1077 0           my $omit = @lines - $head - $tail;
1078 0 0 0       if ($omit > 0 && $tail > 0) {
    0          
1079 0           $out = (join("\n", @lines[0..$head-1])) . "\n... $omit lines omitted ...\n" . (join("\n", @lines[-$tail..-1]));
1080             }
1081             elsif ($omit > 0) {
1082 0           $out = join("\n", @lines[0..min($head, @lines)-1]);
1083             }
1084             }
1085             elsif ($matched_filter->{tail_lines} > 0) {
1086 0           my @lines = split(/\n/, $out);
1087 0           $out = join("\n", @lines[-min($matched_filter->{tail_lines}, @lines)..-1]);
1088             }
1089              
1090 0 0         if ($matched_filter->{max_lines} > 0) {
1091 0           my @out_lines = split(/\n/, $out);
1092 0 0         if (@out_lines > $matched_filter->{max_lines}) {
1093 0           $out = join("\n", @out_lines[0..$matched_filter->{max_lines}-1]) . "\n... " . (@out_lines - $matched_filter->{max_lines}) . " more lines ...";
1094             }
1095             }
1096              
1097 0 0 0       if (!length trim($out) && $matched_filter->{on_empty}) {
1098 0           $out = $matched_filter->{on_empty};
1099             }
1100              
1101 0           return { command => $command, stdout => $out, stderr => $err };
1102             }
1103              
1104             sub transform_command {
1105 0     0 0   my ($self, $command) = @_;
1106 0 0         return $command if $ENV{MCP_RUN_COMPRESS_NO_CO_AUTHORED};
1107              
1108 0           my $parsed = $self->_parse_command($command);
1109              
1110 0           for my $key (keys %{$self->filters}) {
  0            
1111 0           my $filter = $self->filters->{$key};
1112              
1113 0           my $matches = 0;
1114              
1115 0 0         if (my $pc = $filter->{parsed_command}) {
    0          
1116 0 0         $matches = 1 if $self->_match_parsed_command($pc, $parsed);
1117             }
1118             elsif ($command =~ /$key/) {
1119 0           $matches = 1;
1120             }
1121              
1122 0 0         next unless $matches;
1123              
1124 0 0         if ($filter->{command_transform}) {
1125 0           $command = $filter->{command_transform}->($command);
1126 0           last; # Only apply first matching command_transform
1127             }
1128             }
1129              
1130 0           return $command;
1131             }
1132              
1133             1;
1134              
1135             __END__