Acme-Claude-Shell

 view release on metacpan or  search on metacpan

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

=head2 Built-in Commands

=over 4

=item * C<help> - Show help message

=item * C<history> - Select and re-run previous commands

=item * C<clear> - Clear the screen

=item * C<exit> / C<quit> - Exit the shell

=back

=head2 History

Command history is persisted to C<~/.acme_claude_shell_history> and loaded
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;
            $response .= $c;
            last if $c eq 'R';
        }
        # 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;

    # Keep only last MAX_HISTORY lines
    @lines = @lines[-$MAX_HISTORY..-1];

    open $fh, '>:encoding(UTF-8)', $HISTORY_FILE or return;
    print $fh @lines;
    close $fh;
}

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

    # Show colorful header
    $self->_show_banner;

    my $term = Term::ReadLine->new('acme_claude_shell');

    # Load persistent history
    _load_history($term);

    # 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) : ()),
    );

    # 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;
            if (defined $selected && length $selected) {
                # User selected a command to re-run - process it
                $term->addhistory($selected);
                _append_to_history($selected);
                await $self->_process_input($selected);
            }
            next;
        }
        if ($input =~ /^clear$/i) {
            system('clear');
            next;
        }
        if ($input =~ /^help$/i) {
            $self->_show_help;
            next;
        }

        # Add to readline history and persist to file
        $term->addhistory($input);
        _append_to_history($input);

        # Process with Claude
        await $self->_process_input($input);
    }

    status('info', "Goodbye!") if $self->colorful;
    return 1;
}

async sub _process_input {
    my ($self, $input) = @_;

    # Query cursor position via /dev/tty before starting spinner
    # This avoids Term::ProgressSpinner's STDIN query which fails after Term::ReadLine
    my $cursor_row = _get_cursor_row();

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

    # First turn or follow-up
    if ($self->_client->session_id) {
        $self->_client->send($input);
    } else {
        $self->_client->connect($input);
    }

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



( run in 2.030 seconds using v1.01-cache-2.11-cpan-140bd7fdf52 )