Acme-Claude-Shell

 view release on metacpan or  search on metacpan

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

package Acme::Claude::Shell::Session;

use 5.020;
use strict;
use warnings;
use open ':std', ':encoding(UTF-8)';

use Object::Proto ();
use Claude::Agent qw(session create_sdk_mcp_server);
use Claude::Agent::Options;
use Claude::Agent::Content;
use Claude::Agent::CLI qw(
    start_spinner stop_spinner
    header divider status choose_from
);
use Acme::Claude::Shell::Tools qw(shell_tools);
use Acme::Claude::Shell::Hooks qw(safety_hooks);
use Future::AsyncAwait;
use Term::ReadLine;
use Term::ANSIColor qw(colored);
use File::Spec;

BEGIN {
    Object::Proto::define('Acme::Claude::Shell::Session',
        'loop:Object:required',
        'dry_run:Bool:default(0):predicate',
        'safe_mode:Bool:default(1):predicate',
        'working_dir:Str:default(.):predicate',
        'colorful:Bool:default(1):predicate',
        'verbose:Bool:default(0):predicate',
        'audit_log:Bool:default(0):predicate',
        'model:Str:predicate',
        'effort:Str:predicate',
        '_client',
        '_history:ArrayRef:default([])',
        '_spinner',
        '_session_start',
        '_tool_count:Int:default(0)',
        '_tool_errors:Int:default(0)',
        '_audit_log:ArrayRef:default([])',
    );
}

my $HISTORY_FILE = File::Spec->catfile($ENV{HOME} // '.', '.acme_claude_shell_history');
my $MAX_HISTORY  = 1000;

my @SPINNERS = (
    { spinner => 'moon',          spinner_color => 'yellow' },
    { spinner => 'earth',         spinner_color => 'cyan' },
    { spinner => 'clock',         spinner_color => 'blue' },
    { spinner => 'dots',          spinner_color => 'magenta' },
    { spinner => 'material',      spinner_color => 'green' },
    { spinner => 'circle_half',   spinner_color => 'cyan' },
    { spinner => 'color_circles', spinner_color => 'white' },
);

sub _random_spinner {
    return %{ $SPINNERS[rand @SPINNERS] };
}

sub _get_cursor_row {
    my $row;
    eval {
        require Term::ReadKey;
        open(my $tty, '+<', '/dev/tty') or return undef;
        Term::ReadKey::ReadMode(4, $tty);
        print STDOUT "\e[6n";
        STDOUT->flush();
        my $response = '';
        while (1) {
            my $c = Term::ReadKey::ReadKey(0.1, $tty);
            last unless defined $c;
            $response .= $c;
            last if $c eq 'R';
        }
        Term::ReadKey::ReadMode(0, $tty);
        close($tty);
        if ($response =~ /\[(\d+);(\d+)R/) {
            $row = $1;
        }
    };
    return $row;
}

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;

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

    my ($self, $msg, $tool_names) = @_;

    my $content = $msg->message->{content};
    return unless ref $content eq 'ARRAY';

    for my $block (@$content) {
        next unless ref $block eq 'HASH' && ($block->{type} // '') eq 'tool_result';
        my $result = Claude::Agent::Content->from_json($block);
        next unless ref $result && $result->can('text');
        my $name = $tool_names->{ $result->tool_use_id } // '';
        next unless $name =~ /execute_command$/;
        my $text = $result->text // '';
        if ($result->is_error) {
            next if $text =~ /^(Dry-run:|User cancelled)/;
            status('error', $text) if $self->colorful;
        }
        elsif (length $text) {
            print $text;
            print "\n" unless $text =~ /\n\z/;
        }
    }
    return;
}

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

    if ($self->colorful) {
        header("Acme::Claude::Shell");
        status('info', "AI-powered shell - describe what you want in plain English");
        status('info', "Type 'help' for commands, 'exit' to quit");
        divider();
        print "\n";
    } else {
        print "=" x 60, "\n";
        print "  Acme::Claude::Shell\n";
        print "=" x 60, "\n";
        print "AI-powered shell - describe what you want in plain English\n";
        print "Type 'help' for commands, 'exit' to quit\n";
        print "-" x 60, "\n\n";
    }
}

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

    if ($self->colorful) {
        header("Help");
    } else {
        print "\n--- Help ---\n";
    }

    print <<'HELP';
Built-in commands:
  help     - Show this help
  history  - Select and re-run previous commands
  clear    - Clear screen
  exit     - Exit shell (or 'quit')

Just type what you want in plain English:
  "find all large log files"
  "show disk usage by directory"
  "compress files older than 7 days"
  "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) = @_;

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

    push @history, @_session_prompts if @_session_prompts;

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

    my @recent = @history > 20 ? @history[-20..-1] : @history;

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

    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

IMPORTANT: Remember context from previous commands!
If the user says "now do X to those files", use the results from the
previous command to know which files they mean.

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

}

1;

__END__

=head1 NAME

Acme::Claude::Shell::Session - Multi-turn session for Acme::Claude::Shell

=head1 SYNOPSIS

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

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

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

    $session->run->get;

=head1 DESCRIPTION

Runs an interactive REPL on one L<Claude::Agent::Client> session, so Claude
remembers context from previous commands: you can say "now compress those
files" after a find command. Every shell operation goes through the
C<execute_command> tool, which asks for approval before running anything,
and the command's output is printed as it comes back.

=head1 ATTRIBUTES

=head2 loop

The L<IO::Async::Loop> to run on. Required.

=head2 dry_run

Preview mode: Claude::Agent's dry-run hooks block file-modifying built-in
tools. Default 0.

=head2 safe_mode

Ask for extra confirmation before dangerous commands. Default 1.

=head2 working_dir

Starting directory for commands. Default C<.>.

=head2 colorful

Use colored output, spinners and menus. Default 1.

=head2 verbose

Log every tool call and notification through the hooks. Default 0.

=head2 audit_log

Record every tool call in C<_audit_log>. Default 0.

=head2 model

Claude model to use. Optional.

=head2 effort

Effort level: C<low>, C<medium>, C<high>, C<xhigh> or C<max>. Optional.

=head2 _client, _history, _spinner, _session_start, _tool_count, _tool_errors, _audit_log

State the tools and hooks read and write: the L<Claude::Agent::Client>,
the commands run this session, the active spinner, and the statistics the
Stop hook reports.

=head1 METHODS

=head2 new

Takes a flat list of name/value pairs. Unknown names are ignored.

=head2 options

    my $options = $session->options;

The L<Claude::Agent::Options> for the session: the in-process shell tools
server, the safety hooks, the system prompt, and the model and effort when
set. The CLI's built-in tools are turned off, so every command goes through
C<execute_command> and its approval menu.

=head2 run

    $session->run->get;

Run the REPL until C<exit>, C<quit> or end of input. Returns a L<Future>.

=head1 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

=head1 HISTORY

Command history is persisted to C<~/.acme_claude_shell_history> and loaded
on startup. Maximum 1000 lines are kept.

=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



( run in 1.115 second using v1.01-cache-2.11-cpan-eca53f54451 )