Acme-Claude-Shell
view release on metacpan or search on metacpan
lib/Acme/Claude/Shell/Session.pm view on Meta::CPAN
=head1 DESCRIPTION
Runs an interactive REPL using Claude's session() function for multi-turn
conversations. Claude remembers context from previous commands, so you can
say things like "now compress those files" after a find command.
Uses Claude::Agent SDK features:
=over 4
=item * C<session()> - Multi-turn conversation with context
=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, menus, colored output
=back
=head2 Attributes
=over 4
=item * C<loop> (required) - IO::Async::Loop instance
=item * C<dry_run> - Preview mode, don't execute commands (default: 0)
=item * C<safe_mode> - Confirm dangerous commands (default: 1)
=item * C<working_dir> - Starting directory (default: '.')
=item * C<colorful> - Use colored output (default: 1)
=item * C<model> - Claude model to use (optional)
=back
=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;
lib/Acme/Claude/Shell/Session.pm view on Meta::CPAN
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);
}
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;
$printed_response = 0; # Reset for next assistant message
}
elsif ($msg->isa('Claude::Agent::Message::ToolResult')) {
# Show result
my $content = $msg->content // '';
if ($msg->is_error) {
# Check if this was a user denial (dry-run, cancel, etc.)
# In that case, don't show error and stop processing
if ($content =~ /^(Dry-run:|User cancelled)/) {
last; # Stop the conversation here
}
status('error', $content) if $self->colorful;
} else {
print $content, "\n" if $content;
}
# 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 {
( run in 1.885 second using v1.01-cache-2.11-cpan-cdf2f3d4e48 )