Acme-Claude-Shell

 view release on metacpan or  search on metacpan

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


        # PostToolUse: Stop spinner after command execution and track stats
        PostToolUse => [
            Claude::Agent::Hook::Matcher->new(
                matcher => $execute_cmd_pattern,
                hooks   => [sub {
                    my ($input, $tool_use_id, $context) = @_;
                    # Stop the execution spinner
                    if ($session->_spinner) {
                        stop_spinner($session->_spinner);
                        $session->_spinner(undef);
                    }
                    # Track tool usage
                    $session->{_tool_count}++;
                    return Claude::Agent::Hook::Result->proceed();
                }],
            ),
        ],

        # PostToolUseFailure: Handle tool failures gracefully
        PostToolUseFailure => [

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

            # when STDIN was used for hook confirmation
        }
        elsif ($msg->isa('Claude::Agent::Message::Result')) {
            $result = $msg;
            last;
        }
    }

    # Stop spinner
    stop_spinner($self->_spinner, "Done") if $self->_spinner;
    $self->_spinner(undef);

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

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

    return $result;

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

on startup. Maximum 1000 lines are kept.

=cut

# Query cursor row position via /dev/tty (works even after Term::ReadLine)
# This avoids Term::ProgressSpinner's STDIN-based query which fails after readline
sub _get_cursor_row {
    my $row;
    eval {
        require Term::ReadKey;
        open(my $tty, '+<', '/dev/tty') or return undef;
        # Set raw mode on the tty
        Term::ReadKey::ReadMode(4, $tty);
        # Send cursor position query to STDOUT (terminal sees it)
        print STDOUT "\e[6n";
        STDOUT->flush();
        # Read response from the tty
        my $response = '';
        while (1) {
            my $c = Term::ReadKey::ReadKey(0.1, $tty);
            last unless defined $c;

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

    my $printed_response = 0;

    while (my $msg = await $self->_client->receive_async) {
        if ($msg->isa('Claude::Agent::Message::Assistant')) {
            # Print reasoning immediately so it appears BEFORE tool approval
            my $text = $msg->text // '';
            if ($text) {
                # Stop spinner before printing text
                if ($self->_spinner) {
                    stop_spinner($self->_spinner);
                    $self->_spinner(undef);
                }
                print "\n" unless $printed_response;
                my $output = $self->colorful ? colored(['white'], $text) : $text;
                print $output;
                $printed_response = 1;
            }
        }
        elsif ($msg->isa('Claude::Agent::Message::ToolUse')) {
            # Print newline after reasoning text before tool approval menu
            print "\n" if $printed_response;

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

            }
            # Don't restart spinner - avoids conflicts with Term::ProgressSpinner
            # when STDIN was used for hook confirmation
        }
        elsif ($msg->isa('Claude::Agent::Message::Result')) {
            last;
        }
    }

    stop_spinner($self->_spinner, "Done") if $self->_spinner;
    $self->_spinner(undef);

    # Final newline if we printed any response
    print "\n" if $printed_response;
}

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

    if ($self->colorful) {
        header("Acme::Claude::Shell");

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;
        $future->done(_mcp_result("User cancelled command", 1));
        return $future;
    }

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

      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

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

        { 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 // '');

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

        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) {

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

            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;

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

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

t/01-tools.t  view on Meta::CPAN


# Create a mock session object for testing
package MockSession;
sub new {
    my ($class, %args) = @_;
    return bless {
        working_dir => $args{working_dir} // '.',
        colorful    => $args{colorful} // 0,
        safe_mode   => $args{safe_mode} // 1,
        _history    => [],
        _spinner    => undef,
    }, $class;
}
sub working_dir { $_[0]->{working_dir} }
sub colorful { $_[0]->{colorful} }
sub safe_mode { $_[0]->{safe_mode} }
sub _history { $_[0]->{_history} }
sub _spinner {
    my $self = shift;
    if (@_) { $self->{_spinner} = shift }
    return $self->{_spinner};

t/02-hooks.t  view on Meta::CPAN

package MockSession;
sub new {
    my ($class, %args) = @_;
    return bless {
        working_dir => $args{working_dir} // '.',
        colorful    => $args{colorful} // 0,
        safe_mode   => $args{safe_mode} // 1,
        verbose     => $args{verbose} // 0,
        audit_log   => $args{audit_log} // 0,
        _history    => [],
        _spinner    => undef,
    }, $class;
}
sub working_dir { $_[0]->{working_dir} }
sub colorful { $_[0]->{colorful} }
sub safe_mode { $_[0]->{safe_mode} }
sub _history { $_[0]->{_history} }
sub _spinner {
    my $self = shift;
    if (@_) { $self->{_spinner} = shift }
    return $self->{_spinner};

t/03-dangerous-patterns.t  view on Meta::CPAN

      reason  => 'Piping remote script to shell' },
);

sub check_dangerous {
    my ($command) = @_;
    for my $check (@DANGEROUS_PATTERNS) {
        if ($command =~ $check->{pattern}) {
            return $check;
        }
    }
    return undef;
}

# Test dangerous commands
subtest 'Dangerous rm commands' => sub {
    ok(check_dangerous('rm -rf /'), 'rm -rf detected');
    ok(check_dangerous('rm -r /tmp'), 'rm -r detected');
    ok(check_dangerous('rm -f file.txt'), 'rm -f detected');
    ok(check_dangerous('rm --recursive /home'), 'rm --recursive detected');
    ok(check_dangerous('rm --force file'), 'rm --force detected');
    ok(!check_dangerous('rm file.txt'), 'rm without flags is safe');

t/04-session.t  view on Meta::CPAN

subtest 'Session spinner' => sub {
    plan tests => 3;

    require IO::Async::Loop;
    my $loop = IO::Async::Loop->new;

    my $session = Acme::Claude::Shell::Session->new(
        loop => $loop,
    );

    ok(!$session->_spinner, 'Spinner starts undefined');

    $session->_spinner('fake-spinner');
    is($session->_spinner, 'fake-spinner', 'Can set spinner');

    $session->_spinner(undef);
    ok(!$session->_spinner, 'Can clear spinner');
};

done_testing();

t/05-query.t  view on Meta::CPAN

        working_dir => '/tmp',
        colorful    => 0,
    );

    ok($query, 'Query created');
    isa_ok($query, 'Acme::Claude::Shell::Query');
    is($query->dry_run, 0, 'dry_run attribute');
    is($query->safe_mode, 1, 'safe_mode attribute');
    is($query->working_dir, '/tmp', 'working_dir attribute');
    is($query->colorful, 0, 'colorful attribute');
    ok(!$query->_spinner, '_spinner starts undefined');
};

# Test optional model attribute
subtest 'Query with model' => sub {
    plan tests => 2;

    require IO::Async::Loop;
    my $loop = IO::Async::Loop->new;

    my $query = Acme::Claude::Shell::Query->new(

t/05-query.t  view on Meta::CPAN

subtest 'Query spinner' => sub {
    plan tests => 3;

    require IO::Async::Loop;
    my $loop = IO::Async::Loop->new;

    my $query = Acme::Claude::Shell::Query->new(
        loop => $loop,
    );

    ok(!$query->_spinner, 'Spinner starts undefined');

    $query->_spinner('fake-spinner');
    is($query->_spinner, 'fake-spinner', 'Can set spinner');

    $query->_spinner(undef);
    ok(!$query->_spinner, 'Can clear spinner');
};

done_testing();



( run in 3.151 seconds using v1.01-cache-2.11-cpan-d80b1682f3f )