Acme-Claude-Shell
view release on metacpan or search on metacpan
lib/Acme/Claude/Shell/Tools.pm view on Meta::CPAN
package Acme::Claude::Shell::Tools;
use 5.020;
use strict;
use warnings;
use Exporter 'import';
our @EXPORT_OK = qw(shell_tools);
use Claude::Agent qw(tool);
use Claude::Agent::CLI qw(menu status ask_yn prompt start_spinner stop_spinner);
use IO::Async::Process;
use Future;
use Cwd qw(abs_path getcwd);
use File::Spec;
use Term::ANSIColor qw(colored);
=head1 NAME
Acme::Claude::Shell::Tools - SDK MCP tool definitions for Acme::Claude::Shell
=head1 SYNOPSIS
use Acme::Claude::Shell::Tools qw(shell_tools);
my $tools = shell_tools($session);
=head1 DESCRIPTION
Defines the SDK MCP tools that Claude can use to interact with the shell.
Each tool returns a Future for async execution.
=head2 Tools
=over 4
=item * B<execute_command> - Run shell commands (with user confirmation)
Executes arbitrary shell commands. The user is prompted to approve, edit,
dry-run, or cancel each command before execution. Dangerous commands
(rm -rf, sudo, mkfs, etc.) trigger additional warnings.
=item * B<read_file> - Read file contents (safe, no confirmation)
Read file contents directly without shell commands. Supports C<lines>
parameter to read first N lines, and C<tail> parameter to read last N lines.
=item * B<list_directory> - List directory contents (safe, no confirmation)
List directory contents with optional glob C<pattern> filtering,
C<long_format> for detailed output, and C<show_hidden> for dotfiles.
=item * B<search_files> - Search for files by pattern (safe, no confirmation)
Search recursively by filename C<pattern> (glob) or file C<content> (grep).
Supports C<max_depth> limit. Results capped at 100 matches.
=item * B<get_system_info> - Get system information (safe, no confirmation)
Returns OS, disk, memory, and process information. Use C<info_type> to
filter: 'all', 'os', 'disk', 'memory', or 'processes'.
=item * B<get_working_directory> - Get current working directory (safe)
Returns the current working directory path.
=back
=head2 Command Approval
The C<execute_command> tool handles user approval directly (not via hooks)
to ensure synchronous confirmation before execution. Users can:
=over 4
=item * B<[a]> Approve and run the command
=item * B<[d]> Dry-run (preview only, don't execute)
=item * B<[e]> Edit the command before running
=item * B<[x]> Cancel
=back
=head2 Dangerous Command Detection
The following patterns trigger additional safety warnings:
=over 4
=item * C<rm -rf>, C<rm --recursive>, C<rm --force>
=item * C<sudo> commands
=item * C<mkfs>, C<dd of=>, device writes
=item * C<chmod 777>, C<chown -R>
=item * C<kill -9>, C<reboot>, C<shutdown>, C<halt>, C<poweroff>
=item * Fork bombs, remote script piping (curl/wget | sh)
=back
=cut
sub shell_tools {
my ($session) = @_;
return [
# execute_command tool - ALL shell operations go through this
# so the PreToolUse hook can confirm each command
tool(
'execute_command',
lib/Acme/Claude/Shell/Tools.pm view on Meta::CPAN
push @buffer, $line;
shift @buffer if @buffer > $tail_lines;
}
$content = join('', @buffer);
}
else {
local $/;
$content = <$fh>;
}
close $fh;
$future->done(_mcp_result($content // ''));
}
else {
$future->done(_mcp_result("Error reading file: $!", 1));
}
return $future;
}
# Safe list_directory - no command approval needed
sub _list_directory_safe {
my ($session, $params, $loop) = @_;
my $path = $params->{path} // '.';
my $pattern = $params->{pattern};
my $long_format = $params->{long_format};
my $show_hidden = $params->{show_hidden};
my $future = $loop->new_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;
}
unless (opendir my $dh, $full_path) {
$future->done(_mcp_result("Error: Cannot open directory: $!", 1));
return $future;
}
else {
my @entries = readdir($dh);
closedir $dh;
# Filter hidden files
unless ($show_hidden) {
@entries = grep { !/^\./ } @entries;
}
else {
# Remove . and .. but keep other hidden files
@entries = grep { $_ ne '.' && $_ ne '..' } @entries;
}
# Apply pattern filter
if ($pattern) {
my $regex = _glob_to_regex($pattern);
@entries = grep { /$regex/ } @entries;
}
# Sort entries
@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;
}
}
}
lib/Acme/Claude/Shell/Tools.pm view on Meta::CPAN
}
# Get system info - no command approval needed
sub _get_system_info {
my ($session, $params, $loop) = @_;
my $info_type = $params->{info_type} // 'all';
my $future = $loop->new_future;
my @info;
if ($info_type eq 'all' || $info_type eq 'os') {
push @info, "=== OS Information ===";
push @info, "System: $^O";
push @info, "Perl: $^V";
if (open my $fh, '<', '/etc/os-release') {
while (<$fh>) {
chomp;
push @info, $_ if /^(NAME|VERSION|PRETTY_NAME)=/;
}
close $fh;
}
push @info, "";
}
if ($info_type eq 'all' || $info_type eq 'disk') {
push @info, "=== Disk Usage ===";
my $cwd = $session->working_dir // getcwd();
# Use POSIX statvfs if available, otherwise report working directory
push @info, "Working directory: $cwd";
push @info, "";
}
if ($info_type eq 'all' || $info_type eq 'memory') {
push @info, "=== Memory ===";
if ($^O eq 'darwin') {
push @info, "(Memory info requires system command on macOS)";
}
elsif (-r '/proc/meminfo') {
if (open my $fh, '<', '/proc/meminfo') {
while (<$fh>) {
push @info, $_ if /^(MemTotal|MemFree|MemAvailable|Buffers|Cached):/;
}
close $fh;
}
}
push @info, "";
}
if ($info_type eq 'all' || $info_type eq 'processes') {
push @info, "=== Current Process ===";
push @info, "PID: $$";
push @info, "User: " . (getpwuid($<) // $<);
push @info, "";
}
$future->done(_mcp_result(join("\n", @info)));
return $future;
}
# Helper: convert glob pattern to regex
sub _glob_to_regex {
my ($glob) = @_;
$glob =~ s/\./\\./g;
$glob =~ s/\*/.*/g;
$glob =~ s/\?/./g;
return qr/^$glob$/i;
}
# Helper: format file permissions
sub _format_perms {
my ($mode) = @_;
my $perms = '';
$perms .= ($mode & 0400) ? 'r' : '-';
$perms .= ($mode & 0200) ? 'w' : '-';
$perms .= ($mode & 0100) ? 'x' : '-';
$perms .= ($mode & 0040) ? 'r' : '-';
$perms .= ($mode & 0020) ? 'w' : '-';
$perms .= ($mode & 0010) ? 'x' : '-';
$perms .= ($mode & 0004) ? 'r' : '-';
$perms .= ($mode & 0002) ? 'w' : '-';
$perms .= ($mode & 0001) ? 'x' : '-';
return $perms;
}
# Helper: format date for ls output
sub _format_date {
my ($time) = @_;
my @t = localtime($time);
return sprintf("%s %2d %02d:%02d",
(qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec))[$t[4]],
$t[3], $t[2], $t[1]);
}
=head1 AUTHOR
LNATION, C<< <email at lnation.org> >>
=head1 LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)
=cut
1;
( run in 2.280 seconds using v1.01-cache-2.11-cpan-e1769b4cff6 )