Acme-Claude-Shell
view release on metacpan or search on metacpan
lib/Acme/Claude/Shell/Tools.pm view on Meta::CPAN
'Search for files by name pattern or content. Safe operation - does not require user confirmation.',
{
type => 'object',
properties => {
pattern => {
type => 'string',
description => 'File name pattern to search for (e.g., "*.pm", "config*")',
},
content => {
type => 'string',
description => 'Text pattern to search for within files (grep)',
},
path => {
type => 'string',
description => 'Directory to search in (defaults to current directory)',
},
max_depth => {
type => 'integer',
description => 'Maximum directory depth to search',
},
},
},
sub {
my ($params, $loop) = @_;
return _search_files_safe($session, $params, $loop);
},
),
# get_system_info tool - safe system information, no confirmation needed
tool(
'get_system_info',
'Get system information including OS, disk space, and memory. Safe operation - does not require user confirmation.',
{
type => 'object',
properties => {
info_type => {
type => 'string',
description => 'Type of info: "all", "os", "disk", "memory", "processes" (defaults to "all")',
enum => ['all', 'os', 'disk', 'memory', 'processes'],
},
},
},
sub {
my ($params, $loop) = @_;
return _get_system_info($session, $params, $loop);
},
),
];
}
sub _execute_command {
my ($session, $params, $loop) = @_;
my $command = $params->{command};
my $dir = $params->{working_dir} // $session->working_dir;
my $colorful = $session->colorful;
# Stop spinner before prompting for approval
if ($session->can('_spinner') && $session->_spinner) {
stop_spinner($session->_spinner);
$session->_spinner(undef);
}
# Prompt for approval before executing
my ($approved, $new_command) = _confirm_command($session, $command);
unless ($approved) {
my $future = $loop->new_future;
$future->done(_mcp_result("User cancelled command", 1));
return $future;
}
# Use potentially edited command
$command = $new_command if defined $new_command;
# Start execution spinner
if ($colorful) {
$session->_spinner(start_spinner("Executing...", $loop));
}
# Record in history
push @{$session->_history}, {
time => _timestamp(),
command => $command,
status => 'running',
};
my $future = $loop->new_future;
my $stdout = '';
my $stderr = '';
my $process = IO::Async::Process->new(
command => [ '/bin/sh', '-c', $command ],
($dir && -d $dir ? (setup => [ chdir => $dir ]) : ()),
stdout => {
into => \$stdout,
},
stderr => {
into => \$stderr,
},
on_finish => sub {
my ($self, $exitcode) = @_;
my $exit_status = $exitcode >> 8;
if ($exit_status != 0) {
$session->_history->[-1]{status} = "exit $exit_status";
my $output = $stderr || $stdout || "Command failed with exit code $exit_status";
$future->done(_mcp_result($output));
} else {
$session->_history->[-1]{status} = 'success';
$future->done(_mcp_result($stdout // ''));
}
},
on_exception => sub {
my ($self, $exception, $errno, $exitcode) = @_;
$session->_history->[-1]{status} = 'error';
$future->done(_mcp_result("Error: $exception", 1));
},
);
$loop->add($process);
return $future;
}
# Helper to format tool results in MCP format
sub _mcp_result {
my ($text, $is_error) = @_;
return {
content => [{ type => 'text', text => $text }],
is_error => $is_error ? 1 : 0,
};
}
# Dangerous command patterns
my @DANGEROUS_PATTERNS = (
{ pattern => qr/\brm\s+(-[rf]+|--recursive|--force)/i,
reason => 'Recursive or forced file deletion' },
{ pattern => qr/\bsudo\b/,
reason => 'Superuser command' },
{ pattern => qr/\bmkfs\b/,
reason => 'Filesystem formatting' },
{ pattern => qr/\bdd\b.*\bof=/,
reason => 'Direct disk write' },
{ pattern => qr/>\s*\/dev\//,
reason => 'Writing to device file' },
{ pattern => qr/\bchmod\s+(-R\s+)?777\b/,
reason => 'World-writable permissions' },
{ pattern => qr/\bchown\s+-R\b.*\//,
reason => 'Recursive ownership change' },
{ pattern => qr/\bkill\s+-9\b/,
reason => 'Forceful process termination' },
{ pattern => qr/\b(reboot|shutdown|halt|poweroff)\b/,
reason => 'System shutdown/reboot' },
{ pattern => qr/\bformat\b/,
reason => 'Disk formatting' },
{ pattern => qr/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/,
reason => 'Fork bomb detected' },
{ pattern => qr/\bwget\b.*\|\s*(ba)?sh/i,
reason => 'Piping remote script to shell' },
{ pattern => qr/\bcurl\b.*\|\s*(ba)?sh/i,
reason => 'Piping remote script to shell' },
);
sub _check_dangerous {
my ($command) = @_;
for my $check (@DANGEROUS_PATTERNS) {
if ($command =~ $check->{pattern}) {
return $check;
}
}
return undef;
}
# Confirm command with user before executing
# Returns ($approved, $new_command) - $new_command is set if user edited it
sub _confirm_command {
my ($session, $command) = @_;
my $colorful = $session->colorful;
# Check for dangerous patterns
my $danger = _check_dangerous($command);
print "\n";
if ($danger && $session->safe_mode) {
if ($colorful) {
status('warning', "Potentially dangerous command detected!");
print colored(['yellow'], " Reason: $danger->{reason}\n");
} else {
print "WARNING: Potentially dangerous command!\n";
print " Reason: $danger->{reason}\n";
}
print "\n";
}
# Show the command
if ($colorful) {
status('info', "Command: $command");
} else {
print "Command: $command\n";
}
# Show action menu
my $choice = menu("Action", [
{ key => 'a', label => 'Approve and run' },
{ key => 'd', label => 'Dry-run (preview only)' },
{ key => 'e', label => 'Edit command' },
{ key => 'x', label => 'Cancel' },
]) // 'x';
if ($choice eq 'x') {
if ($colorful) {
status('warning', "Command cancelled");
} else {
print "Cancelled.\n";
}
return (0, undef);
}
elsif ($choice eq 'd') {
if ($colorful) {
status('info', "[DRY-RUN] Would execute:");
print colored(['cyan'], " $command\n\n");
} else {
print "[DRY-RUN] Would execute: $command\n";
}
return (0, undef);
}
elsif ($choice eq 'e') {
my $new_cmd;
if ($colorful) {
$new_cmd = prompt("Edit command:", $command);
} else {
print "Edit command [$command]: ";
$new_cmd = <STDIN>;
chomp $new_cmd if defined $new_cmd;
$new_cmd = $command unless length($new_cmd // '');
}
if ($colorful) {
status('info', "Modified command:");
print colored(['bold', 'white'], " $new_cmd\n\n");
} else {
print "Modified: $new_cmd\n";
}
# For dangerous commands after editing, still require confirmation
if (_check_dangerous($new_cmd) && $session->safe_mode) {
my $confirmed;
if ($colorful) {
$confirmed = ask_yn("Are you SURE you want to run this command?", 'n');
} else {
print "Are you SURE? (y/N): ";
my $ans = <STDIN>;
chomp $ans if defined $ans;
$confirmed = ($ans // '') =~ /^y/i;
}
return (0, undef) unless $confirmed;
}
return (1, $new_cmd);
}
# 'a' - Approve
# For dangerous commands, require extra confirmation
if ($danger && $session->safe_mode) {
my $confirmed;
if ($colorful) {
$confirmed = ask_yn("Are you SURE you want to run this dangerous command?", 'n');
} else {
print "Are you SURE? (y/N): ";
my $ans = <STDIN>;
chomp $ans if defined $ans;
$confirmed = ($ans // '') =~ /^y/i;
}
unless ($confirmed) {
if ($colorful) {
status('warning', "Command cancelled");
} else {
print "Cancelled.\n";
}
return (0, undef);
}
}
return (1, undef);
}
sub _list_files {
my ($session, $params, $loop) = @_;
my $path = $params->{path} // '.';
my $pattern = $params->{pattern} // '';
my $long = $params->{long_format} // 1;
my $hidden = $params->{hidden} // 0;
# Build ls command
my @opts;
push @opts, '-l' if $long;
push @opts, '-a' if $hidden;
my $target = $pattern ? "$path/$pattern" : $path;
my $cmd = "ls @opts $target 2>/dev/null || ls @opts $path";
return _execute_command($session, { command => $cmd }, $loop);
}
sub _read_file {
my ($session, $params, $loop) = @_;
my $path = $params->{path};
my $lines = $params->{lines};
my $tail = $params->{tail};
# Build read command
my $cmd;
if ($tail) {
$cmd = "tail -n $tail " . _shell_quote($path);
} elsif ($lines) {
$cmd = "head -n $lines " . _shell_quote($path);
} else {
$cmd = "cat " . _shell_quote($path);
}
return _execute_command($session, { command => $cmd }, $loop);
}
sub _shell_quote {
my ($str) = @_;
$str =~ s/'/'\\''/g;
return "'$str'";
}
sub _timestamp {
my @t = localtime;
return sprintf("%04d-%02d-%02d %02d:%02d:%02d",
$t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1], $t[0]);
}
# Safe read_file - no command approval needed
sub _read_file_safe {
my ($session, $params, $loop) = @_;
my $path = $params->{path};
my $lines = $params->{lines};
my $tail_lines = $params->{tail};
lib/Acme/Claude/Shell/Tools.pm view on Meta::CPAN
@entries = sort @entries;
my @output;
if ($long_format) {
for my $entry (@entries) {
my $entry_path = File::Spec->catfile($full_path, $entry);
my @stat = stat($entry_path);
if (@stat) {
my $size = $stat[7];
my $mtime = $stat[9];
my $mode = $stat[2];
my $type = -d $entry_path ? 'd' : '-';
my $perms = _format_perms($mode);
my $date = _format_date($mtime);
push @output, sprintf("%s%s %8d %s %s",
$type, $perms, $size, $date, $entry);
}
else {
push @output, $entry;
}
}
}
else {
@output = @entries;
}
$future->done(_mcp_result(join("\n", @output)));
}
return $future;
}
# Safe search_files - no command approval needed
sub _search_files_safe {
my ($session, $params, $loop) = @_;
my $pattern = $params->{pattern};
my $content = $params->{content};
my $path = $params->{path} // '.';
my $max_depth = $params->{max_depth};
my $future = $loop->new_future;
unless ($pattern || $content) {
$future->done(_mcp_result("Error: Must specify 'pattern' (filename) or 'content' (text search)", 1));
return $future;
}
# Resolve path
my $full_path = $path;
if ($path !~ m{^/}) {
$full_path = File::Spec->catfile($session->working_dir // getcwd(), $path);
}
unless (-d $full_path) {
$future->done(_mcp_result("Error: Not a directory: $path", 1));
return $future;
}
my @results;
my $regex = $pattern ? _glob_to_regex($pattern) : undef;
my $content_re = $content ? qr/\Q$content\E/i : undef;
_search_recursive($full_path, $full_path, $regex, $content_re, $max_depth, 0, \@results);
if (@results) {
$future->done(_mcp_result(join("\n", @results)));
}
else {
$future->done(_mcp_result("No matches found"));
}
return $future;
}
sub _search_recursive {
my ($base, $dir, $name_re, $content_re, $max_depth, $depth, $results) = @_;
return if defined $max_depth && $depth > $max_depth;
return if @$results >= 100; # Limit results
opendir my $dh, $dir or return;
my @entries = readdir($dh);
closedir $dh;
for my $entry (sort @entries) {
next if $entry eq '.' || $entry eq '..';
my $path = File::Spec->catfile($dir, $entry);
my $rel_path = File::Spec->abs2rel($path, $base);
if (-d $path) {
_search_recursive($base, $path, $name_re, $content_re, $max_depth, $depth + 1, $results);
}
elsif (-f $path) {
# Check filename pattern
my $name_match = !$name_re || $entry =~ $name_re;
if ($name_match) {
if ($content_re) {
# Search file content
if (open my $fh, '<', $path) {
my $line_num = 0;
while (my $line = <$fh>) {
$line_num++;
if ($line =~ $content_re) {
chomp $line;
$line = substr($line, 0, 100) . '...' if length($line) > 100;
push @$results, "$rel_path:$line_num: $line";
last if @$results >= 100;
}
}
close $fh;
}
}
else {
push @$results, $rel_path;
}
}
}
last if @$results >= 100;
( run in 1.033 second using v1.01-cache-2.11-cpan-d80b1682f3f )