view release on metacpan or search on metacpan
lib/Acme/Claude/Shell.pm view on Meta::CPAN
=item B<query()> - Single-shot mode via C<run()>
=item B<session()> - Multi-turn context via C<shell()>
=item B<SDK MCP Tools> - 6 tools: execute_command, read_file, list_directory, search_files, get_system_info, get_working_directory
=item B<Hooks (PreToolUse)> - Audit logging of tool calls
=item B<Hooks (PostToolUse)> - Stop spinner, track statistics
=item B<Hooks (PostToolUseFailure)> - Graceful error handling
=item B<Hooks (Stop)> - Session statistics on exit
=item B<Hooks (Notification)> - Event logging (verbose mode)
=item B<Dry-run mode> - Preview without executing
=item B<IO::Async> - Non-blocking command execution and spinners
=item B<CLI utilities> - Spinners, menus, colored output
lib/Acme/Claude/Shell/Hooks.pm view on Meta::CPAN
=head1 SYNOPSIS
use Acme::Claude::Shell::Hooks qw(safety_hooks);
my $hooks = safety_hooks($session);
=head1 DESCRIPTION
Provides hooks for Acme::Claude::Shell. These hooks integrate with the
Claude::Agent SDK hook system to provide logging, statistics, and error
handling.
B<Note:> Command approval is handled directly in the tool handler (Tools.pm)
to ensure it happens synchronously before execution.
=head2 Hooks
=over 4
=item * B<PreToolUse> - Audit logging of tool calls
lib/Acme/Claude/Shell/Hooks.pm view on Meta::CPAN
is enabled.
=item * B<PostToolUse> - Stop spinner after command execution
Triggered after C<execute_command> completes successfully. Stops the
execution spinner and increments the tool usage counter.
=item * B<PostToolUseFailure> - Handle tool failures gracefully
Triggered when any shell-tools MCP tool fails. Displays a user-friendly
error message and tracks error count for session statistics.
=item * B<Stop> - Show session statistics when agent stops
Triggered when the agent stops (end of session). Displays:
=over 4
=item * Session duration
=item * Number of tools used
=item * Number of tool errors (if any)
=item * Commands in history
=back
=item * B<Notification> - Log important events
Triggered for SDK notifications. Logs notification types in verbose mode.
=back
lib/Acme/Claude/Shell/Hooks.pm view on Meta::CPAN
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) = @_;
my $tool_name = $input->{tool_name} // 'unknown';
lib/Acme/Claude/Shell/Hooks.pm view on Meta::CPAN
],
# PostToolUseFailure: Handle tool failures gracefully
PostToolUseFailure => [
Claude::Agent::Hook::Matcher->new(
matcher => $any_shell_tool,
hooks => [sub {
my ($input, $tool_use_id, $context) = @_;
my $tool_name = $input->{tool_name} // 'unknown';
my $error = $input->{error} // 'Unknown error';
# Extract short tool name
my $short_name = $tool_name;
$short_name =~ s/^mcp__shell-tools__//;
# Track error count
$session->{_tool_errors}++;
# Show error to user
if ($session->colorful) {
status('error', "Tool '$short_name' failed: $error");
}
else {
print "[ERROR] Tool '$short_name' failed: $error\n";
}
return Claude::Agent::Hook::Result->proceed();
}],
),
],
# 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;
printf " Tool errors: %d\n", $tool_errors if $tool_errors > 0;
printf " Commands in history: %d\n", $history_count;
print colored(['cyan'], "â" x 40) . "\n";
}
else {
print "\n--- Session Statistics ---\n";
printf "Duration: %.1f seconds\n", $duration;
printf "Tools used: %d\n", $tool_count;
printf "Tool errors: %d\n", $tool_errors if $tool_errors > 0;
printf "Commands in history: %d\n", $history_count;
print "--------------------------\n";
}
return Claude::Agent::Hook::Result->proceed();
}],
),
],
# Notification: Log important events
lib/Acme/Claude/Shell/Query.pm view on Meta::CPAN
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)
=item * CLI utilities - Spinners and colored output
=back
=head2 Attributes
=over 4
=item * C<loop> (required) - IO::Async::Loop instance
lib/Acme/Claude/Shell/Query.pm view on Meta::CPAN
while (my $msg = await $iter->next_async) {
if ($msg->isa('Claude::Agent::Message::Assistant')) {
$response_text .= $msg->text // '';
}
elsif ($msg->isa('Claude::Agent::Message::ToolUse')) {
# Spinner already stopped by hook before STDIN read
}
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')) {
$result = $msg;
last;
}
lib/Acme/Claude/Shell/Session.pm view on Meta::CPAN
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
lib/Acme/Claude/Shell/Session.pm view on Meta::CPAN
}
}
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;
}
}
lib/Acme/Claude/Shell/Tools.pm view on Meta::CPAN
$session->_history->[-1]{status} = "exit $exit_status";
my $output = $stderr || $stdout || "Command failed with exit code $exit_status";
$future->done(_mcp_result($output));
} else {
$session->_history->[-1]{status} = 'success';
$future->done(_mcp_result($stdout // ''));
}
},
on_exception => sub {
my ($self, $exception, $errno, $exitcode) = @_;
$session->_history->[-1]{status} = 'error';
$future->done(_mcp_result("Error: $exception", 1));
},
);
$loop->add($process);
return $future;
}
# Helper to format tool results in MCP format
sub _mcp_result {
my ($text, $is_error) = @_;
return {
content => [{ type => 'text', text => $text }],
is_error => $is_error ? 1 : 0,
};
}
# Dangerous command patterns
my @DANGEROUS_PATTERNS = (
{ pattern => qr/\brm\s+(-[rf]+|--recursive|--force)/i,
reason => 'Recursive or forced file deletion' },
{ pattern => qr/\bsudo\b/,
reason => 'Superuser command' },
{ pattern => qr/\bmkfs\b/,
t/01-tools.t view on Meta::CPAN
require IO::Async::Loop;
my $loop = IO::Async::Loop->new;
my $tool = $tool_names{read_file};
# Read full file
my $future = $tool->execute({ path => $test_file }, $loop);
my $result = $future->get;
ok($result, 'Got result');
ok(!$result->{is_error}, 'No error');
like($result->{content}[0]{text}, qr/Line 1/, 'Content contains expected text');
# Read with lines limit
$future = $tool->execute({ path => $test_file, lines => 2 }, $loop);
$result = $future->get;
ok(!$result->{is_error}, 'No error with lines param');
my $text = $result->{content}[0]{text};
my @lines = split /\n/, $text;
is(scalar(@lines), 2, 'Got only 2 lines');
# Read non-existent file
$future = $tool->execute({ path => '/nonexistent/file.txt' }, $loop);
$result = $future->get;
ok($result->{is_error}, 'Error for non-existent file');
};
# Test list_directory tool (safe, no approval needed)
subtest 'list_directory tool' => sub {
plan tests => 5;
require IO::Async::Loop;
my $loop = IO::Async::Loop->new;
my $tool = $tool_names{list_directory};
# List directory
my $future = $tool->execute({ path => $tempdir }, $loop);
my $result = $future->get;
ok(!$result->{is_error}, 'No error');
like($result->{content}[0]{text}, qr/test\.txt/, 'Found test.txt');
like($result->{content}[0]{text}, qr/test\.pm/, 'Found test.pm');
like($result->{content}[0]{text}, qr/subdir/, 'Found subdir');
# List with pattern filter
$future = $tool->execute({ path => $tempdir, pattern => '*.pm' }, $loop);
$result = $future->get;
like($result->{content}[0]{text}, qr/test\.pm/, 'Pattern filter works');
};
t/01-tools.t view on Meta::CPAN
plan tests => 4;
require IO::Async::Loop;
my $loop = IO::Async::Loop->new;
my $tool = $tool_names{search_files};
# Search by filename pattern
my $future = $tool->execute({ path => $tempdir, pattern => '*.txt' }, $loop);
my $result = $future->get;
ok(!$result->{is_error}, 'No error');
like($result->{content}[0]{text}, qr/test\.txt/, 'Found test.txt by pattern');
# Search by content
$future = $tool->execute({ path => $tempdir, content => 'package' }, $loop);
$result = $future->get;
ok(!$result->{is_error}, 'No error for content search');
like($result->{content}[0]{text}, qr/test\.pm/, 'Found file with content');
};
# Test get_system_info tool (safe, no approval needed)
subtest 'get_system_info tool' => sub {
plan tests => 4;
require IO::Async::Loop;
my $loop = IO::Async::Loop->new;
my $tool = $tool_names{get_system_info};
# Get all info
my $future = $tool->execute({ info_type => 'all' }, $loop);
my $result = $future->get;
ok(!$result->{is_error}, 'No error');
like($result->{content}[0]{text}, qr/OS Information/, 'Contains OS info');
like($result->{content}[0]{text}, qr/System:/, 'Contains system info');
# Get just OS info
$future = $tool->execute({ info_type => 'os' }, $loop);
$result = $future->get;
like($result->{content}[0]{text}, qr/Perl:/, 'Contains Perl version');
};
done_testing();
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,
audit_log => 1,
);