Acme-Claude-Shell

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



( run in 1.480 second using v1.01-cache-2.11-cpan-f0fbb3f571b )