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;