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)

lib/Acme/Claude/Shell/Tools.pm  view on Meta::CPAN

                type       => 'object',
                properties => {},
            },
            sub {
                my ($params, $loop) = @_;
                my $future = $loop->new_future;
                $future->done(_mcp_result(getcwd()));
                return $future;
            },
        ),

        # read_file tool - safe read operation, no confirmation needed
        tool(
            'read_file',
            'Read the contents of a file. Safe operation - does not require user confirmation. Use this instead of execute_command for reading files.',
            {
                type       => 'object',
                properties => {
                    path => {
                        type        => 'string',
                        description => 'Path to the file to read',
                    },
                    lines => {
                        type        => 'integer',
                        description => 'Number of lines to read from the beginning (optional)',
                    },
                    tail => {
                        type        => 'integer',
                        description => 'Number of lines to read from the end (optional)',
                    },
                },
                required => ['path'],
            },
            sub {
                my ($params, $loop) = @_;
                return _read_file_safe($session, $params, $loop);
            },
        ),

        # list_directory tool - safe read operation, no confirmation needed
        tool(
            'list_directory',
            'List the contents of a directory. Safe operation - does not require user confirmation. Use this instead of execute_command for listing files.',
            {
                type       => 'object',
                properties => {
                    path => {
                        type        => 'string',
                        description => 'Path to the directory to list (defaults to current directory)',
                    },
                    pattern => {
                        type        => 'string',
                        description => 'Glob pattern to filter files (e.g., "*.pl", "*.txt")',
                    },
                    long_format => {
                        type        => 'boolean',
                        description => 'Show detailed file information (size, date, permissions)',
                    },
                    show_hidden => {
                        type        => 'boolean',
                        description => 'Include hidden files (starting with .)',
                    },
                },
            },
            sub {
                my ($params, $loop) = @_;
                return _list_directory_safe($session, $params, $loop);
            },
        ),

        # search_files tool - safe search operation, no confirmation needed
        tool(
            'search_files',
            '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' },



( run in 1.068 second using v1.01-cache-2.11-cpan-140bd7fdf52 )