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 //= [];
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;
( run in 0.702 second using v1.01-cache-2.11-cpan-71847e10f99 )