Acme-Claude-Shell

 view release on metacpan or  search on metacpan

bin/acme_claude_shell  view on Meta::CPAN

=item B<--no-color>

Disable colored output.

=item B<-d, --directory> DIR

Set working directory (default: current directory).

=item B<-c, --command> CMD

Run a single command and exit instead of starting interactive mode.
Use '-' to read the command from stdin for piping.

=item B<-m, --model> MODEL

Specify the Claude model to use (e.g., claude-opus-4-5, claude-sonnet-4-5).

=item B<-h, --help>

Show help message.

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

=cut

sub safety_hooks {
    my ($session) = @_;

    # Tool name pattern - matches mcp__shell-tools__execute_command
    my $execute_cmd_pattern = 'execute_command$';
    # Match any tool from our shell-tools server
    my $any_shell_tool = 'shell-tools__';

    # Track session start time
    $session->{_session_start} //= time();
    $session->{_tool_count} //= 0;
    $session->{_tool_errors} //= 0;

    return {
        # PreToolUse: Audit logging - track what tools Claude wants to use
        PreToolUse => [
            Claude::Agent::Hook::Matcher->new(
                matcher => $any_shell_tool,
                hooks   => [sub {
                    my ($input, $tool_use_id, $context) = @_;

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

            ),
        ],

        # Stop: Show session statistics when the agent stops
        Stop => [
            Claude::Agent::Hook::Matcher->new(
                matcher => '.*',  # Match any stop reason
                hooks   => [sub {
                    my ($input, $tool_use_id, $context) = @_;

                    my $duration = time() - ($session->{_session_start} // time());
                    my $tool_count = $session->{_tool_count} // 0;
                    my $tool_errors = $session->{_tool_errors} // 0;
                    my $history_count = scalar(@{$session->_history // []});

                    if ($session->colorful) {
                        print "\n";
                        print colored(['cyan'], "─" x 40) . "\n";
                        status('info', "Session Statistics");
                        printf "  Duration: %.1f seconds\n", $duration;
                        printf "  Tools used: %d\n", $tool_count;

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

    'dry_run?'     => sub { 0 },
    'safe_mode?'   => sub { 1 },
    'working_dir?' => sub { '.' },
    'colorful?'    => sub { 1 },
    'model?'       => Str,
    '_spinner==.';

use Claude::Agent qw(query create_sdk_mcp_server);
use Claude::Agent::Options;
use Claude::Agent::CLI qw(
    start_spinner stop_spinner
    header divider status
);
use Acme::Claude::Shell::Tools qw(shell_tools);
use Acme::Claude::Shell::Hooks qw(safety_hooks);
use Future::AsyncAwait;

# Fun spinner styles with matching colors
my @SPINNERS = (
    { spinner => 'moon',          spinner_color => 'yellow' },
    { spinner => 'earth',         spinner_color => 'cyan' },

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

    );

    my $result = $query->run("find all large log files")->get;

=head1 DESCRIPTION

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)

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

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

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

            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')) {
            $result = $msg;
            last;
        }
    }

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

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

    'colorful?'    => sub { 1 },
    'model?'       => Str,
    '_client==.',
    '_history==.'  => sub { [] },
    '_spinner==.',
    '_pending_approval==.';  # Future for tool approval (set by hooks, awaited by tool handler)

use Claude::Agent qw(session create_sdk_mcp_server);
use Claude::Agent::Options;
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;

# History file location

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


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

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

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

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

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

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

                    pattern => {
                        type        => 'string',
                        description => 'Glob pattern to filter files (e.g., "*.pl", "*.txt")',
                    },
                    long_format => {
                        type        => 'boolean',
                        description => 'Show detailed file information (size, date, permissions)',
                    },
                    show_hidden => {
                        type        => 'boolean',
                        description => 'Include hidden files (starting with .)',
                    },
                },
            },
            sub {
                my ($params, $loop) = @_;
                return _list_directory_safe($session, $params, $loop);
            },
        ),

        # search_files tool - safe search operation, no confirmation needed

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

        my $future = $loop->new_future;
        $future->done(_mcp_result("User cancelled command", 1));
        return $future;
    }

    # Use potentially edited command
    $command = $new_command if defined $new_command;

    # Start execution spinner
    if ($colorful) {
        $session->_spinner(start_spinner("Executing...", $loop));
    }

    # Record in history
    push @{$session->_history}, {
        time    => _timestamp(),
        command => $command,
        status  => 'running',
    };

    my $future = $loop->new_future;

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

};

# Test session statistics are initialized
subtest 'Session statistics initialization' => sub {
    plan tests => 3;

    # Create fresh session
    my $fresh_session = MockSession->new(colorful => 0);
    my $fresh_hooks = safety_hooks($fresh_session);

    ok(exists $fresh_session->{_session_start}, 'Session start time initialized');
    ok(exists $fresh_session->{_tool_count}, 'Tool count initialized');
    ok(exists $fresh_session->{_tool_errors}, 'Tool errors initialized');
};

# Test audit log option
subtest 'Audit log option' => sub {
    plan tests => 2;

    my $audit_session = MockSession->new(
        colorful  => 0,

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

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

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

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

    is(scalar(@{$session->_history}), 0, 'History starts empty');

    push @{$session->_history}, { command => 'ls', status => 'success' };
    is(scalar(@{$session->_history}), 1, 'Can add to history');

    push @{$session->_history}, { command => 'pwd', status => 'success' };
    is(scalar(@{$session->_history}), 2, 'Can add multiple entries');
};

# Test _spinner attribute
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();

t/07-cli-bin.t  view on Meta::CPAN

use File::Spec;

# Test the CLI binary exists and has correct structure

my $bin = File::Spec->catfile('bin', 'acme_claude_shell');

# Check file exists
ok(-f $bin, 'CLI binary exists');
ok(-r $bin, 'CLI binary is readable');

# Check it starts with shebang
open my $fh, '<', $bin or die "Cannot open $bin: $!";
my $first_line = <$fh>;
close $fh;

like($first_line, qr/^#!/, 'Starts with shebang');
like($first_line, qr/perl/, 'Uses perl interpreter');

# Check syntax by compiling (but not running)
subtest 'Syntax check' => sub {
    plan tests => 1;



( run in 3.226 seconds using v1.01-cache-2.11-cpan-cdf2f3d4e48 )