App-Raider
view release on metacpan or search on metacpan
#!/usr/bin/env perl
# PODNAME: raider
# ABSTRACT: Autonomous CLI agent with filesystem and bash access
use strict;
use warnings;
use utf8;
use Getopt::Long qw( GetOptions :config no_ignore_case bundling );
use JSON::MaybeXS ();
use YAML::PP ();
use Term::ANSIColor qw( colored color );
use Term::ReadLine; # Core; upgrades to Gnu if installed
use IO::Prompt::Tiny qw( prompt ); # Fallback when Gnu is not available
use Path::Tiny;
use App::Raider;
use App::Raider::Skill;
use Langertha::Raider;
binmode STDOUT, ':encoding(UTF-8)';
binmode STDIN, ':encoding(UTF-8)';
# --- Palette: blue tones + yellow accents -------------------------------
my %C = (
brand => 'bold bright_blue',
title => 'bold blue',
prompt => 'bold cyan',
agent => 'bright_green',
meta => 'bright_black',
accent => 'yellow',
warn => 'yellow',
err => 'red',
);
sub c { my ($k, @t) = @_; -t STDOUT ? colored([$C{$k}], join('', @t)) : join('', @t) }
# --- Options ------------------------------------------------------------
my %opt;
my @raw_engine_opts;
GetOptions(
'e|engine=s' => \$opt{engine},
'm|model=s' => \$opt{model},
'r|root=s' => \$opt{root},
'M|mission=s' => \$opt{mission},
'k|api-key=s' => \$opt{api_key},
'o|option=s@' => \@raw_engine_opts,
'i|interactive' => \$opt{interactive},
'json' => \$opt{json},
'max-iterations=i' => \$opt{max_iterations},
'no-color' => \$opt{no_color},
'trace!' => \$opt{trace},
'customize-prompt' => \$opt{customize_prompt},
'claude' => \$opt{profile_claude},
'openai|codex' => \$opt{profile_openai},
'skills=s@' => \@{ $opt{skill_dirs} //= [] },
'export-skill:s' => \$opt{export_skill},
'export-claude-skill:s'=> \$opt{export_claude_skill},
'h|help' => \$opt{help},
) or die "Bad options. Try --help.\n";
my %engine_opts;
for my $pair (@raw_engine_opts) {
my ($k, $v) = split /=/, $pair, 2;
die "bad -o spec '$pair' (expected key=value)\n" unless defined $k && defined $v;
# Auto-coerce simple numerics so temperature=0.2 lands as a number.
if ($v =~ /\A-?\d+\z/) { $v = 0 + $v }
elsif ($v =~ /\A-?\d*\.\d+(?:[eE]-?\d+)?\z/) { $v = 0 + $v }
elsif ($v eq 'true') { $v = 1 }
elsif ($v eq 'false') { $v = 0 }
$engine_opts{$k} = $v;
}
$ENV{ANSI_COLORS_DISABLED} = 1 if $opt{no_color} || $opt{json};
if ($opt{help}) {
print <<'USAGE';
Usage: raider [options] [prompt...]
Options:
-e, --engine NAME anthropic, openai, deepseek, groq, mistral, gemini,
minimax, cerebras, openrouter, ollama
(default: auto-detected from available *_API_KEY
env var â anthropic > openai > deepseek > ...)
-m, --model NAME Model identifier (engine-specific cheap default)
-k, --api-key KEY API key (overrides *_API_KEY env var)
-o, --option KEY=VALUE Engine attribute (repeatable), e.g.
-o temperature=0.2 -o response_size=4096
Merged over .raider.yml; CLI wins.
-r, --root DIR Working directory (default: cwd). File tools are
confined to this directory.
-M, --mission TEXT System prompt / mission
-i, --interactive REPL mode (default when stdin is a TTY with no
prompt argv and no pipe; forces it otherwise)
--json Emit JSON ({response, metrics, elapsed}) and exit
--max-iterations N Hard safety cap on tool rounds per raid
(default: 10000 â effectively unlimited)
--no-color Disable ANSI colors
--no-trace Hide live tool-call progress output
--customize-prompt Launch the prompt-builder at startup
--claude Load Claude Code layout: CLAUDE.md +
.claude/skills/*/SKILL.md.
--openai / --codex Load AGENTS.md (the OpenAI Codex / cross-tool
convention).
--skills DIR Load *.md files from DIR as skills (repeatable).
--export-skill [PATH]
Write a plain-markdown "how to use raider" doc
(default: ./RAIDER-SKILL.md) and exit.
--export-claude-skill [PATH]
Write a Claude Code SKILL.md with frontmatter
(default: .claude/skills/app-raider/SKILL.md)
and exit.
-h, --help Show this help
If no prompt is given and not interactive, reads the prompt from STDIN.
USAGE
exit 0;
}
my %args;
$args{engine} = $opt{engine} if defined $opt{engine};
$args{model} = $opt{model} if defined $opt{model};
$args{root} = $opt{root} if defined $opt{root};
$args{mission} = $opt{mission} if defined $opt{mission};
$args{api_key} = $opt{api_key} if defined $opt{api_key};
$args{trace} = $opt{trace} if defined $opt{trace};
$args{max_iterations} = $opt{max_iterations} if defined $opt{max_iterations};
$args{engine_options} = \%engine_opts if %engine_opts;
my @skill_specs;
my @cli_profiles;
if ($opt{profile_claude}) {
push @skill_specs, @{ $App::Raider::AGENT_PROFILES{claude} };
push @cli_profiles, 'claude';
}
if ($opt{profile_openai}) {
push @skill_specs, @{ $App::Raider::AGENT_PROFILES{openai} };
push @cli_profiles, 'openai';
}
push @skill_specs, { type => 'dir', path => $_ } for @{ $opt{skill_dirs} // [] };
# Persist profile flags to .raider.yml so the user doesn't need to retype
# --claude / --openai every invocation. Track which ones were freshly
# persisted this run for the banner "(saved)" hint.
my %saved_now;
if (@cli_profiles) {
my $root = $opt{root} // Path::Tiny::path('.')->absolute->stringify;
%saved_now = persist_profiles($root, \@cli_profiles);
}
$args{skill_sources} = \@skill_specs if @skill_specs;
my $app = App::Raider->new(%args);
# One-shot skill export paths. Empty string means "use default path".
if (defined $opt{export_skill}) {
my $path = length $opt{export_skill}
? $opt{export_skill}
: Path::Tiny::path($app->root)->child('RAIDER-SKILL.md')->stringify;
my $p = App::Raider::Skill->new(app => $app)->write_markdown($path);
print STDERR "wrote $p\n";
exit 0;
}
if (defined $opt{export_claude_skill}) {
my $path = length $opt{export_claude_skill} ? $opt{export_claude_skill} : undef;
my $p = App::Raider::Skill->new(app => $app)->write_claude_skill($path);
print STDERR "wrote $p\n";
exit 0;
}
# Default to interactive REPL when stdin is a terminal and no prompt was given
# on argv / piped in / requested as one-shot JSON.
if (!$opt{interactive} && !$opt{json} && !@ARGV && -t STDIN) {
$opt{interactive} = 1;
}
# --- Output helpers -----------------------------------------------------
my $LOGO = <<'LOGO';
__ __
.----.---.-.|__|.--| |.-----.----.
| _| _ || || _ || -__| _|
|__| |___._||__||_____||_____|__|
LOGO
sub banner {
my ($app, $rl_impl, $active_profiles, $saved_now) = @_;
$active_profiles //= [];
$saved_now //= {};
my $env = $app->api_key_env;
my $env_display = defined $env
? ($ENV{$env} ? "$env (set)" : "$env (missing)")
: '(no API key required)';
my $model = $app->has_model ? $app->model : '(engine default)';
my $custom = path($app->root)->child('.raider.md');
my $persona = -f $custom ? "custom (.raider.md loaded)" : "Langertha (default)";
print c(brand => $LOGO);
print c(accent => " perl agent - powered by Langertha"), "\n\n";
my @skills = $app->loaded_skill_names;
my @ignored = $app->ignored_agent_files;
print c(meta => "persona: "), c(title => $persona), "\n";
if (@$active_profiles) {
my @parts;
for my $p (@$active_profiles) {
my $tag = $saved_now->{$p} ? ' (saved)' : '';
push @parts, $p . $tag;
}
print c(meta => "profiles: "), c(title => join(', ', @parts)), "\n";
}
my $skills_line = @skills
? sprintf("%d loaded (%s)", scalar @skills, join(', ', @skills))
: 'none';
print c(meta => "skills: "), c(title => $skills_line), "\n";
for my $ign (@ignored) {
print c(meta => " "), c(warn => "seeing $ign->{path}, ignoring (use --$ign->{profile} to load)"), "\n";
}
print c(meta => "engine: "), c(title => $app->engine_name), "\n";
print c(meta => "model: "), c(title => $model), "\n";
print c(meta => "api key: "), c(title => $env_display), "\n";
print c(meta => "root: "), c(title => $app->root), "\n";
print c(meta => "readline: "), c(title => $rl_impl), "\n";
print c(meta => "type "), c(accent => "/help"), c(meta => " for commands, "),
c(accent => "/quit"), c(meta => " to leave"), "\n\n";
}
}
}
my @rows;
for my $id (sort @$list) {
next if $is_snap{$id};
my @snaps = sort @{$groups{$id} // []};
push @rows, {
id => $id,
snaps => \@snaps,
current => ($id eq $current || !!(grep { $_ eq $current } @snaps)),
};
}
# orphaned snapshot families whose base isn't a standalone model
my %seen = map { $_->{id} => 1 } @rows;
for my $base (sort keys %groups) {
next if $seen{$base};
my @snaps = sort @{$groups{$base}};
push @rows, {
id => $base,
snaps => \@snaps,
orphan => 1,
current => !!(grep { $_ eq $current } @snaps),
};
}
return sort { $a->{id} cmp $b->{id} } @rows;
}
sub persist_model {
my ($root, $model) = @_;
my $file = Path::Tiny::path($root)->child('.raider.yml');
my $yml = {};
if (-f $file) {
my $loaded = eval { YAML::PP->new->load_string($file->slurp_utf8) };
$yml = $loaded if ref $loaded eq 'HASH';
}
$yml->{default} = {} unless ref $yml->{default} eq 'HASH';
$yml->{default}{model} = $model;
$file->spew_utf8(YAML::PP->new->dump_string($yml));
}
sub print_agent { print c(agent => render_inline_code($_[0])), "\n" }
sub print_meta { print c(meta => $_[0]), "\n" }
sub print_err { print c(err => "error: "), c(warn => $_[0]), "\n" }
sub handle_slash {
my ($app, $line) = @_;
my ($cmd, @rest) = split /\s+/, $line;
$cmd =~ s{^/}{};
my $arg = join ' ', @rest;
if ($cmd eq 'help') {
print_meta("commands:");
print_meta(" /help show this help");
print_meta(" /clear reset conversation history");
print_meta(" /metrics show cumulative raid metrics");
print_meta(" /stats show token usage (when trace is on)");
print_meta(" /reload reload .raider.md into the mission");
print_meta(" /prompt launch the prompt-builder (edits .raider.md)");
print_meta(" /skill [PATH] export plain-markdown skill doc");
print_meta(" /skill-claude [PATH] export Claude Code SKILL.md with frontmatter");
print_meta(" /model [NAME] set+save model to .raider.yml");
print_meta(" /model list [FILTER] list available models (optionally filtered)");
print_meta(" /quit /exit :q leave the REPL");
return;
}
if ($cmd eq 'clear') {
$app->raider->clear_history;
my $t = $app->trace_plugin;
$t->token_stats({ prompt => 0, completion => 0, total => 0, calls => 0 }) if $t;
print_meta("history cleared.");
return;
}
if ($cmd eq 'metrics') {
my $m = $app->raider->metrics;
print_meta(sprintf(
"raids=%d iterations=%d tool_calls=%d time_ms=%d",
$m->{raids}, $m->{iterations}, $m->{tool_calls}, $m->{time_ms},
));
return;
}
if ($cmd eq 'stats') {
my $s = $app->token_stats;
unless ($s) {
print_meta("stats unavailable (trace is off â start without --no-trace).");
return;
}
print_meta(sprintf(
"llm calls: %d | tokens in: %d | out: %d | total: %d",
$s->{calls}, $s->{prompt}, $s->{completion}, $s->{total},
));
return;
}
if ($cmd eq 'reload') {
my $new = $app->reload_mission;
my $file = path($app->root)->child('.raider.md');
my $status = -f $file ? "custom (.raider.md loaded)" : "Langertha (default, no .raider.md)";
print_meta("mission reloaded: $status (" . length($new) . " chars)");
return;
}
if ($cmd eq 'prompt') {
run_prompt_builder($app);
return;
}
if ($cmd eq 'skill') {
my $path = $arg || Path::Tiny::path($app->root)->child('RAIDER-SKILL.md')->stringify;
my $p = App::Raider::Skill->new(app => $app)->write_markdown($path);
print_meta("wrote $p");
return;
}
if ($cmd eq 'skill-claude') {
my $path = length $arg ? $arg : undef;
my $p = App::Raider::Skill->new(app => $app)->write_claude_skill($path);
print_meta("wrote $p");
return;
}
if ($cmd eq 'model') {
my ($subcmd, $filter) = split /\s+/, $arg, 2;
if (!length $arg || ($subcmd eq 'list')) {
my $current = $app->has_model ? $app->model : '';
print c(meta => "engine: "), c(title => $app->engine_name), "\n";
print c(meta => "model: "), c(title => length $current ? $current : '(engine default)'), "\n";
my $engine = eval { $app->_engine };
if ($engine && $engine->can('list_models')) {
my $list = eval { $engine->list_models };
if ($@) {
my $err = $@; chomp $err;
print_err("list_models failed: $err");
}
elsif (@$list) {
my @filtered = length($filter // '')
? grep { index($_, $filter) >= 0 } @$list
: @$list;
my @rows = collapse_model_list(\@filtered, $current);
my $total = scalar @rows;
my $cap = (defined $subcmd && $subcmd eq 'list') ? $total : 20;
my @show = @rows[0 .. ($cap < $total ? $cap - 1 : $total - 1)];
print c(meta => "models" . (length($filter // '') ? " (/$filter/)" : "") . ":"), "\n";
for my $row (@show) {
my $star = $row->{current} ? c(accent => '*') : ' ';
my $name = c(title => $row->{id});
my $snaps = @{$row->{snaps}}
? c(meta => ' [+' . scalar(@{$row->{snaps}}) . (scalar(@{$row->{snaps}}) == 1 ? ' snapshot]' : ' snapshots]'))
: '';
print " $star $name$snaps\n";
}
if ($cap < $total) {
print c(meta => " ⦠$cap of $total shown â /model list [filter] for all"), "\n";
}
}
}
return;
}
persist_model($app->root, $arg);
print c(meta => "model saved: "), c(title => $arg), c(meta => " (takes effect on next start)"), "\n";
return;
}
print_err("unknown command: /$cmd (try /help)");
}
sub run_prompt_builder {
my ($app) = @_;
my $file = path($app->root)->child('.raider.md');
my $current = -f $file ? $file->slurp_utf8 : '(no .raider.md yet â Langertha default persona is active)';
my $meta_mission = <<"EOM";
You are the raider prompt-builder. Your only job right now is to help the user
craft a .raider.md file that customizes the persona and instructions of
"raider" (the CLI agent; the default persona is Langertha, a viking
shield-maiden).
Current .raider.md content:
---
$current
---
Rules:
- Converse naturally with the user. Ask what persona, tone, rules or
constraints they want.
- When the user is satisfied, use write_file to save to:
@{[ $file ]}
- After writing, confirm what you saved and tell the user they can type
"/done" to return to the main agent (the main agent will auto-reload the
new persona).
- If the user asks you to cancel, do not write anything; just confirm.
- Do NOT call bash or any tool other than read_file / write_file / edit_file
during this session.
EOM
my $builder_raider = Langertha::Raider->new(
engine => $app->_engine,
mission => $meta_mission,
max_iterations => 20,
);
print_meta("entering prompt-builder. /done to return, /cancel to discard.");
my $ps = 'raider:prompt> ';
while (1) {
print $ps;
my $line = <STDIN>;
last unless defined $line;
chomp $line;
$line =~ s/^\s+|\s+$//g;
next unless length $line;
if ($line =~ m{^/(?:done|back|exit|quit)$}i) {
my $new = $app->reload_mission;
print_meta("prompt-builder finished. mission reloaded (" . length($new) . " chars).");
last;
}
if ($line =~ m{^/cancel$}i) {
print_meta("prompt-builder cancelled.");
last;
}
my $f = $builder_raider->raid_f($line);
$app->loop->await($f);
my $r = eval { $f->get };
if ($@) {
my $err = $@; chomp $err;
print_err($err);
next;
}
print_agent("$r");
}
}
# --- Run one prompt -----------------------------------------------------
sub run_one {
my ($text, %o) = @_;
return unless defined $text && length $text;
my $t0 = time;
my $result;
my $ok = eval { $result = $app->run($text); 1 };
my $elapsed = time - $t0;
unless ($ok) {
my $err = $@; chomp $err;
if ($o{json}) {
print JSON::MaybeXS->new(utf8 => 1, pretty => 1, canonical => 1)
->encode({ error => $err, elapsed => $elapsed });
}
else {
print_err($err);
}
return;
}
my $m = $app->raider->metrics;
if ($o{json}) {
print JSON::MaybeXS->new(utf8 => 1, pretty => 1, canonical => 1)->encode({
response => "$result",
metrics => $m,
elapsed => $elapsed,
});
return;
}
print_agent("$result");
my $tok = $app->token_stats;
my $tok_part = $tok
? sprintf(" | tokens %d in / %d out / %d total",
$tok->{prompt}, $tok->{completion}, $tok->{total})
: '';
my $r = $app->raider;
my $msgs = scalar @{$r->history};
my $last = $r->has_last_prompt_tokens ? $r->_last_prompt_tokens : 0;
my $cap = $r->max_context_tokens;
my $pct = $cap ? int(100 * $last / $cap) : 0;
my $hist_part = sprintf(" | history %d msgs, %d/%d tok (%d%%)",
$msgs, $last, $cap, $pct);
print_meta(sprintf(
"%ds%s%s",
$elapsed, $hist_part, $tok_part,
));
}
# --- CLI entry ----------------------------------------------------------
if ($opt{interactive}) {
# Prefer Term::ReadLine::Gnu (line editing + persistent history).
# Fall back to IO::Prompt::Tiny when Gnu isn't installed.
my ($histfile, $read_line, $impl);
my $term = Term::ReadLine->new('raider');
my $have_gnu = ($term->ReadLine =~ /Gnu/);
if ($have_gnu) {
$term->ornaments(0);
$impl = $term->ReadLine;
$histfile = path($ENV{HOME} // '.')->child('.raider_history')->stringify;
eval { $term->ReadHistory($histfile) } if -f $histfile;
# Gray prompt, plain-color user input. Non-printing sequences are
# wrapped in \x01...\x02 so readline computes prompt width correctly.
my $ps = "\x01" . color('bright_black') . "\x02"
. 'raider> '
. "\x01" . color('reset') . "\x02";
$read_line = sub { $term->readline($ps) };
# Save history on Ctrl-C / SIGTERM so entries survive interrupted sessions
my $save_and_exit = sub {
eval { $term->WriteHistory($histfile) };
print c(meta => "\nbye."), "\n";
exit 0;
};
$SIG{INT} = $save_and_exit;
$SIG{TERM} = $save_and_exit;
}
else {
$impl = 'IO::Prompt::Tiny (install Term::ReadLine::Gnu for history)';
$read_line = sub { prompt('raider>') };
$SIG{INT} = sub { print c(meta => "\nbye."), "\n"; exit 0 };
}
# Collect active profile names: CLI flags + any persisted in .raider.yml.
my %seen_prof;
my @active_profiles;
for my $p (@cli_profiles) {
next if $seen_prof{$p}++;
push @active_profiles, $p;
}
{
my $ymlfile = Path::Tiny::path($app->root)->child('.raider.yml');
if (-f $ymlfile) {
my $ydata = eval { YAML::PP->new->load_string($ymlfile->slurp_utf8) };
if (ref $ydata eq 'HASH' && defined $ydata->{skills}) {
my @list = ref $ydata->{skills} eq 'ARRAY'
? @{$ydata->{skills}}
: ($ydata->{skills});
for my $it (@list) {
next if ref $it;
next unless $it eq 'claude' || $it eq 'openai' || $it eq 'codex' || $it eq 'agents';
my $norm = ($it eq 'codex' || $it eq 'agents') ? 'openai' : $it;
next if $seen_prof{$norm}++;
push @active_profiles, $norm;
}
}
}
}
banner($app, $impl, \@active_profiles, \%saved_now);
if ($opt{customize_prompt}) {
run_prompt_builder($app);
}
run_one(join(' ', @ARGV)) if @ARGV;
while (defined(my $line = $read_line->())) {
$line =~ s/^\s+|\s+$//g;
next unless length $line;
if ($line =~ m{^(?:/quit|/exit|:q|quit|exit)$}i) {
print c(meta => "bye."), "\n";
last;
}
if ($line =~ m{^/}) {
$term->addhistory($line) if $have_gnu;
handle_slash($app, $line);
next;
}
$term->addhistory($line) if $have_gnu;
run_one($line);
}
if ($have_gnu) {
eval { $term->WriteHistory($histfile) };
}
}
else {
my $text;
if (@ARGV) {
$text = join(' ', @ARGV);
}
elsif (-t STDIN) {
# Interactive terminal but no -i and no argv: ask once.
$text = prompt(c(prompt => 'raider>'));
}
else {
local $/;
$text = <STDIN>;
}
die "No prompt given.\n" unless defined $text && length $text;
run_one($text, json => $opt{json});
}
__END__
=pod
=encoding UTF-8
=head1 NAME
raider - Autonomous CLI agent with filesystem and bash access
=head1 VERSION
version 0.003
=head1 SYNOPSIS
raider "Find all TODO comments in lib/ and summarize them"
raider -e openai -m gpt-4o "Run the test suite and fix any trivial failures"
raider -i -r ~/dev/myproject
raider --json "Summarize README.md" > result.json
=head1 DESCRIPTION
F<raider> is the CLI front-end for L<App::Raider>. It wires an LLM engine
(via L<Langertha>) to a filesystem tool server and L<MCP::Run::Bash>,
then runs the L<Langertha::Raider> multi-turn agent loop on your prompt.
Interactive mode (C<-i>) opens a REPL with conversation history retained
across turns. With L<Term::ReadLine::Gnu> installed, line editing and
persistent input history (F<~/.raider_history>) are enabled automatically â
including across Ctrl-C interrupts. Slash commands available in the REPL:
C</help>, C</clear>, C</metrics>, C</stats>, C</reload>, C</prompt>,
C</skill>, C</skill-claude>, C</model>, C</quit>.
With C<--json>, F<raider> emits a single JSON object
(C<{response, metrics, elapsed}>) instead of the human-oriented output, which
is useful for scripting.
API keys come from standard environment variables
(C<ANTHROPIC_API_KEY>, C<OPENAI_API_KEY>, etc.).
=head1 SEE ALSO
=over
=item * L<App::Raider>
=item * L<Langertha::Raider>
=back
=head1 SUPPORT
=head2 Issues
Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/p5-app-raider/issues>.
=head2 IRC
Join C<#langertha> on C<irc.perl.org> or message Getty directly.
=head1 CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
=head1 AUTHOR
Torsten Raudssus <torsten@raudssus.de> L<https://raudssus.de/>
=head1 COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=cut
( run in 0.846 second using v1.01-cache-2.11-cpan-6aa56a78535 )