Acme-Claude-Shell

 view release on metacpan or  search on metacpan

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


Options:

    dry_run     - Preview mode, don't execute commands
    safe_mode   - Confirm dangerous commands (default: 1)
    working_dir - Starting directory (default: current)
    colorful    - Force colors on/off (default: auto-detect)

=head2 run

    my $result = run($prompt, %options);

Execute a single command and return the result. Does not maintain
session context between calls.

=cut

use Acme::Claude::Shell::Session;
use Acme::Claude::Shell::Query;
use IO::Async::Loop;

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

        safe_mode   => $args{safe_mode} // 1,
        working_dir => $args{working_dir} // '.',
        colorful    => $args{colorful} // _detect_color(),
        ($args{model} ? (model => $args{model}) : ()),
    );

    return $session->run->get;
}

sub run {
    my ($prompt, %args) = @_;

    my $loop = $args{loop} // IO::Async::Loop->new;

    my $query = Acme::Claude::Shell::Query->new(
        loop        => $loop,
        dry_run     => $args{dry_run} // 0,
        safe_mode   => $args{safe_mode} // 1,
        working_dir => $args{working_dir} // '.',
        colorful    => $args{colorful} // _detect_color(),
        ($args{model} ? (model => $args{model}) : ()),
    );

    return $query->run($prompt)->get;
}

sub _detect_color {
    return -t STDOUT ? 1 : 0;
}

=head1 EXAMPLE SESSION

    ============================================================
      Acme::Claude::Shell

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

Executes a single natural language command using Claude's query() function.
Does not maintain session context between calls - each run() is independent.

This is useful for scripting or when you want a one-shot command without
starting an interactive session.

Uses Claude::Agent SDK features:

=over 4

=item * C<query()> - Single-shot prompt execution

=item * SDK MCP tools - execute_command, read_file, list_directory, search_files, get_system_info, get_working_directory

=item * Hooks - PreToolUse (audit), PostToolUse (stats), PostToolUseFailure (errors), Stop (statistics), Notification (logging)

=item * CLI utilities - Spinners and colored output

=back

=head2 Attributes

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

=item * C<colorful> - Use colored output (default: 1)

=item * C<model> - Claude model to use (optional)

=back

=head2 Methods

=over 4

=item * C<run($prompt)> - Execute a single prompt and return a Future

=back

=cut

async sub run {
    my ($self, $prompt) = @_;

    # Show header
    if ($self->colorful) {
        header("Acme::Claude::Shell - Query Mode");
    }

    # Create SDK MCP server with shell tools
    my $mcp = create_sdk_mcp_server(
        name  => 'shell-tools',
        tools => shell_tools($self),
    );

    # Create options with hooks
    my $options = Claude::Agent::Options->new(
        permission_mode => 'bypassPermissions',
        mcp_servers     => { 'shell-tools' => $mcp },
        hooks           => safety_hooks($self),
        dry_run         => $self->dry_run,
        system_prompt   => $self->_system_prompt,
        ($self->has_model ? (model => $self->model) : ()),
    );

    # Store spinner in self so hooks can stop it before reading STDIN
    # Pick a random fun spinner each time
    $self->_spinner(start_spinner("Thinking...", $self->loop,
        _random_spinner(),
    ));

    # Execute query
    my $iter = query(
        prompt  => $prompt,
        options => $options,
        loop    => $self->loop,
    );

    my $response_text = '';
    my $result;

    while (my $msg = await $iter->next_async) {
        if ($msg->isa('Claude::Agent::Message::Assistant')) {
            $response_text .= $msg->text // '';

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

    if ($response_text) {
        print "\n", $response_text, "\n";
    }

    # Cleanup
    $iter->cleanup if $iter->can('cleanup');

    return $result;
}

sub _system_prompt {
    my ($self) = @_;
    return <<'PROMPT';
You are an AI shell assistant. The user describes a task in natural language,
and you translate it into shell commands.

When the user asks you to do something:
1. Explain what command(s) you'll run and why
2. Use the execute_command tool to run them
3. Summarize the results

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

        # Restore normal mode
        Term::ReadKey::ReadMode(0, $tty);
        close($tty);
        if ($response =~ /\[(\d+);(\d+)R/) {
            $row = $1;
        }
    };
    return $row;
}

# Track session prompts for saving
my @_session_prompts;

sub _load_history {
    my ($term) = @_;
    return unless -f $HISTORY_FILE;
    open my $fh, '<:encoding(UTF-8)', $HISTORY_FILE or return;
    while (my $line = <$fh>) {
        chomp $line;
        $term->addhistory($line) if length $line;
    }
    close $fh;
}

sub _append_to_history {
    my ($input) = @_;
    push @_session_prompts, $input;

    # Append to file immediately
    open my $fh, '>>:encoding(UTF-8)', $HISTORY_FILE or return;
    print $fh "$input\n";
    close $fh;

    # Trim file if too large (occasionally)
    _trim_history_file() if @_session_prompts % 100 == 0;
}

sub _trim_history_file {
    return unless -f $HISTORY_FILE;
    open my $fh, '<:encoding(UTF-8)', $HISTORY_FILE or return;
    my @lines = <$fh>;
    close $fh;

    return if @lines <= $MAX_HISTORY;

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

        name  => 'shell-tools',
        tools => shell_tools($self),
    );

    # Create options with hooks
    my $options = Claude::Agent::Options->new(
        permission_mode => 'bypassPermissions',
        mcp_servers     => { 'shell-tools' => $mcp },
        hooks           => safety_hooks($self),
        dry_run         => $self->dry_run,
        system_prompt   => $self->_system_prompt,
        ($self->has_model ? (model => $self->model) : ()),
    );

    # Create persistent session client
    $self->_client(session(
        options => $options,
        loop    => $self->loop,
    ));

    # REPL loop
    while (1) {
        my $prompt_str = $self->colorful
            ? colored(['bold', 'green'], 'acme_claude_shell> ')
            : 'acme_claude_shell> ';

        my $input = $term->readline($prompt_str);
        last unless defined $input;

        $input =~ s/^\s+|\s+$//g;
        next unless length $input;

        # Built-in commands
        last if $input =~ /^(exit|quit)$/i;

        if ($input =~ /^history$/i) {
            my $selected = $self->_show_history;

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

  "now delete those files" (uses context from previous command)

Claude will show you the command before running it.
You can approve, edit, dry-run, or cancel.
HELP
}

sub _show_history {
    my ($self) = @_;

    # Load prompt history from file
    my @history;
    if (-f $HISTORY_FILE) {
        open my $fh, '<:encoding(UTF-8)', $HISTORY_FILE or do {
            print "Could not read history file.\n\n";
            return;
        };
        @history = <$fh>;
        close $fh;
        chomp @history;
    }

    # Add current session prompts not yet in file
    push @history, @_session_prompts if @_session_prompts;

    unless (@history) {
        if ($self->colorful) {
            status('info', "No history yet.");
        } else {
            print "No history yet.\n";
        }
        return;
    }

    # Get last 20 unique entries for selection
    my @recent = @history > 20 ? @history[-20..-1] : @history;

    # Use choose_from for interactive selection
    my $selected = choose_from(
        \@recent,
        prompt        => "Select a command to re-run (or press 'q' to cancel):",
        inline_prompt => @history > 20 ? "(Last 20 of " . scalar(@history) . ")" : "",
        layout        => 2,  # Single column for readability
    );

    return $selected;
}

sub _system_prompt {
    my ($self) = @_;
    return <<'PROMPT';
You are an AI shell assistant. The user describes tasks in natural language,
and you translate them into shell commands.

When the user asks you to do something:
1. Explain what command(s) you'll run and why
2. Use the execute_command tool to run them
3. Summarize the results

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

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


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)

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

=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',
            'Execute a shell command and return its output. Use this for ALL shell operations including listing files, reading files, etc. The user will be prompted to approve each command.',
            {
                type       => 'object',
                properties => {
                    command => {
                        type        => 'string',
                        description => 'The shell command to execute (e.g., "ls -la", "cat file.txt", "find . -name *.pl")',
                    },
                    working_dir => {
                        type        => 'string',
                        description => 'Directory to run command in (optional, defaults to current directory)',

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

    ];
}

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;

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

            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");



( run in 0.797 second using v1.01-cache-2.11-cpan-6aa56a78535 )