mod cli;
mod config;
mod harness;
mod install;
mod logging;
mod monitor;
mod process;
mod provider;
mod tmux;
mod usage;
use anyhow::{bail, Context, Result};
use clap::Parser;
use dialoguer::{theme::ColorfulTheme, Select};
use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tracing::{info, trace};
use cli::{Cli, Commands};
use config::Config;
use harness::{Harness, Runner};
fn check_harness_available(harness: &Harness) -> Result<()> {
let cmd = harness.exec_command();
which::which(cmd).with_context(|| {
format!(
"'{}' not found in PATH. Please install it first.",
harness.command_name()
)
})?;
Ok(())
}
fn resolve_prompt(task: &str) -> Result<String> {
let path = std::path::Path::new(task);
if path.exists() && path.is_file() {
std::fs::read_to_string(path).with_context(|| format!("Failed to read task file: {}", task))
} else {
Ok(task.to_string())
}
}
fn print_example_config() {
println!(
r#"# Ralph configuration file (.ralphrc or .ralphrc.toml)
# Place in current directory or home directory
# Agent harness: codex, claude, pi, gemini
harness = "codex"
# Model to use (optional, defaults vary by harness)
# model = "gpt-5.2-codex"
# Default task file
task = "TASK.md"
# Number of iterations (number or "inf")
iterations = "1"
# Enable dangerous mode (skip permissions)
dangerous = true
# Reasoning effort for codex
reasoning_effort = "medium"
# Provider for pi harness (anthropic, openai, google, etc.)
# provider = "anthropic"
# Usage limits
# usage_limit_daily = 80
# usage_limit_weekly = 90
# usage_check_interval = 5
# fallback_harness = "gemini"
# Tmux settings
# tmux = false
# tmux_session_prefix = "ralph"
# tmux_attach = false
# Monitor settings
# monitor_interval = "5m"
# monitor_harness = "claude"
"#
);
}
fn list_harnesses() {
println!("Available harnesses:");
println!();
println!(" codex - OpenAI Codex CLI agent (default model: gpt-5.2-codex)");
println!(" claude - Anthropic Claude CLI agent (default model: claude-opus-4-5-20251101)");
println!(" pi - Pi CLI agent (default provider: anthropic, model: claude-opus-4-5)");
println!(" gemini - Google Gemini CLI agent (default model: auto-gemini-3)");
}
#[allow(clippy::too_many_arguments)]
fn apply_usage_limits(
harness: &mut Harness,
model: &mut String,
provider: &mut String,
runner: &mut Runner,
usage_limit_daily: Option<u8>,
usage_limit_weekly: Option<u8>,
fallback_harness: Option<Harness>,
using_fallback: &mut bool,
dangerous: bool,
reasoning: &str,
) -> Result<bool> {
let check = usage::check_usage_limits(
harness.command_name(),
usage_limit_daily,
usage_limit_weekly,
);
for warning in check.warnings {
eprintln!("Usage check warning: {}", warning);
}
if !check.exceeded {
return Ok(true);
}
let reason = check.reasons.join(", ");
if let Some(fallback) = fallback_harness {
if !*using_fallback && fallback != *harness {
check_harness_available(&fallback)?;
*harness = fallback;
*model = harness.default_model().to_string();
if *harness == Harness::Pi && provider.is_empty() {
*provider = harness.default_provider().to_string();
}
*runner = Runner::new(
*harness,
model.clone(),
dangerous,
reasoning.to_string(),
provider.clone(),
);
*using_fallback = true;
eprintln!(
"Usage limit reached ({}). Switching to fallback harness: {}",
reason,
harness.command_name()
);
return Ok(true);
}
}
eprintln!("Usage limit reached ({}). Stopping.", reason);
Ok(false)
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.log_file {
println!("{}", logging::current_log_file().display());
return Ok(());
}
let _log_guard = logging::init_logging(cli.verbose, cli.log_stderr)?;
info!(version = env!("CARGO_PKG_VERSION"), "ralph starting");
trace!(?cli, "parsed CLI arguments");
if let Some(cmd) = &cli.command {
match cmd {
Commands::Providers { json } => {
let providers = provider::detect_all_providers();
if *json {
provider::print_providers_json(&providers)?;
} else {
provider::print_providers(&providers);
}
return Ok(());
}
Commands::Usage { provider, json } => {
let usage_list = if provider == "all" {
usage::get_all_usage()
} else {
match usage::get_provider_usage(provider) {
Some(info) => vec![info],
None => {
eprintln!("Unknown provider: {}", provider);
return Ok(());
}
}
};
if *json {
usage::print_usage_json(&usage_list)?;
} else {
usage::print_usage(&usage_list);
}
return Ok(());
}
Commands::Monitor {
monitor_harness,
monitor_model,
monitor_interval,
inner_harness,
inner_model,
task,
} => {
let config = Config::load()?.unwrap_or_default();
let monitor_h = Harness::from_str(monitor_harness)?;
let inner_h = Harness::from_str(inner_harness)?;
check_harness_available(&monitor_h)?;
check_harness_available(&inner_h)?;
let monitor_m = monitor_model
.clone()
.unwrap_or_else(|| monitor_h.default_model().to_string());
let inner_m = inner_model
.clone()
.unwrap_or_else(|| inner_h.default_model().to_string());
let task_str = task
.clone()
.or(cli.task.clone())
.or(config.task.clone())
.unwrap_or_else(|| "TASK.md".to_string());
let prompt = resolve_prompt(&task_str)?;
let interval = monitor::parse_duration(monitor_interval)?;
let dangerous = if cli.safe {
false
} else {
config.dangerous.unwrap_or(cli.dangerous)
};
let reasoning = config.reasoning_effort.unwrap_or(cli.reasoning.clone());
let provider_str = cli
.provider
.clone()
.or(config.provider.clone())
.unwrap_or_else(|| inner_h.default_provider().to_string());
let monitor_config = monitor::MonitorConfig {
monitor_harness: monitor_h,
monitor_model: monitor_m,
monitor_interval: interval,
inner_harness: inner_h,
inner_model: inner_m,
task: prompt,
dangerous,
reasoning_effort: reasoning,
provider: provider_str,
};
return monitor::run_monitor(monitor_config).await;
}
Commands::Install { agent, all, list } => {
install::run_install(agent.clone(), *all, *list)?;
return Ok(());
}
Commands::Ps { json, all } => {
let processes = process::list_processes();
if *json {
process::print_processes_json(&processes)?;
} else {
process::print_processes(&processes, *all);
}
return Ok(());
}
Commands::Kill {
all,
dir,
harness,
pid,
} => {
if let Some(pid) = pid {
if process::kill_process(*pid)? {
println!("Killed process {}", pid);
} else {
println!("Failed to kill process {}", pid);
}
} else if *all {
let (killed, total) = process::kill_all_processes()?;
println!("Killed {}/{} tracked processes", killed, total);
} else if let Some(dir) = dir {
let (killed, total) = process::kill_processes_by_dir(dir)?;
println!("Killed {}/{} processes in {}", killed, total, dir);
} else if let Some(harness) = harness {
let (killed, total) = process::kill_processes_by_harness(harness)?;
println!("Killed {}/{} {} processes", killed, total, harness);
} else {
let cwd = std::env::current_dir()?.display().to_string();
let (killed, total) = process::kill_processes_by_dir(&cwd)?;
println!("Killed {}/{} processes in current directory", killed, total);
}
return Ok(());
}
Commands::Cleanup {
discover,
kill_orphans,
} => {
let cleaned = process::cleanup_registry()?;
println!("Cleaned {} stale process entries", cleaned);
if *discover {
let orphans = process::discover_orphan_processes()?;
if orphans.is_empty() {
println!("No orphaned agent processes found");
} else {
println!("Found {} orphaned processes:", orphans.len());
process::print_processes(&orphans, false);
if *kill_orphans {
for proc in &orphans {
if process::kill_process(proc.pid).unwrap_or(false) {
println!("Killed orphan {}", proc.pid);
}
}
}
}
}
return Ok(());
}
Commands::Logs {
lines,
follow,
path,
clear,
} => {
let log_file = logging::current_log_file();
if *path {
println!("{}", log_file.display());
return Ok(());
}
if *clear {
let log_dir = logging::log_dir();
if log_dir.exists() {
for entry in std::fs::read_dir(&log_dir)? {
let entry = entry?;
if entry
.path()
.extension()
.map(|e| e == "log")
.unwrap_or(false)
|| entry.file_name().to_string_lossy().starts_with("ralph.")
{
std::fs::remove_file(entry.path())?;
println!("Removed: {}", entry.path().display());
}
}
}
println!("Logs cleared");
return Ok(());
}
if !log_file.exists() {
println!("No log file found at: {}", log_file.display());
println!("Run ralph with -v to enable logging");
return Ok(());
}
if *follow {
let status = std::process::Command::new("tail")
.args(["-f", &log_file.to_string_lossy()])
.status()?;
if !status.success() {
bail!("tail command failed");
}
} else {
let content = std::fs::read_to_string(&log_file)?;
let all_lines: Vec<&str> = content.lines().collect();
let start = if *lines == 0 || *lines >= all_lines.len() {
0
} else {
all_lines.len() - *lines
};
for line in &all_lines[start..] {
println!("{}", line);
}
}
return Ok(());
}
Commands::Sessions { list, attach, json } => {
if !tmux::tmux_available() {
bail!("tmux is not available. Please install tmux first.");
}
let sessions = tmux::list_ralph_sessions()?;
if sessions.is_empty() {
println!("No ralph tmux sessions found.");
return Ok(());
}
if *json {
tmux::print_sessions_json(&sessions)?;
return Ok(());
}
if let Some(name) = attach {
eprintln!("Attaching to {} (detach with Ctrl+b d)...", name);
tmux::attach_session(name)?;
return Ok(());
}
if *list || !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
tmux::print_sessions(&sessions);
return Ok(());
}
let labels: Vec<String> = sessions.iter().map(tmux::session_label).collect();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a ralph tmux session")
.items(&labels)
.default(0)
.interact_opt()?;
if let Some(index) = selection {
let name = &sessions[index].name;
eprintln!("Attaching to {} (detach with Ctrl+b d)...", name);
tmux::attach_session(name)?;
}
return Ok(());
}
}
}
if cli.list_harnesses {
list_harnesses();
return Ok(());
}
if cli.init {
print_example_config();
return Ok(());
}
let config = Config::load()?.unwrap_or_default();
let harness_str = cli
.harness
.or(config.harness)
.unwrap_or_else(|| "codex".to_string());
let harness = Harness::from_str(&harness_str)?;
check_harness_available(&harness)?;
let model = cli
.model
.or(config.model)
.unwrap_or_else(|| harness.default_model().to_string());
let dangerous = if cli.safe {
false
} else {
config.dangerous.unwrap_or(cli.dangerous)
};
let reasoning = config.reasoning_effort.unwrap_or(cli.reasoning);
let provider_str = cli
.provider
.or(config.provider)
.unwrap_or_else(|| harness.default_provider().to_string());
let usage_limit_daily = cli.usage_limit_daily.or(config.usage_limit_daily);
let usage_limit_weekly = cli.usage_limit_weekly.or(config.usage_limit_weekly);
let usage_check_interval = cli.usage_check_interval.or(config.usage_check_interval);
let fallback_harness_str = cli.fallback_harness.or(config.fallback_harness);
let fallback_harness = match &fallback_harness_str {
Some(name) => Some(Harness::from_str(name)?),
None => None,
};
let task = cli
.task
.or(config.task)
.unwrap_or_else(|| "TASK.md".to_string());
let prompt = resolve_prompt(&task)?;
let iterations = config.iterations.unwrap_or(cli.iterations);
let use_tmux = if cli.no_tmux {
false
} else if cli.tmux {
true
} else {
config.tmux.unwrap_or(false)
};
let tmux_attach = cli.tmux_attach || config.tmux_attach.unwrap_or(false);
let tmux_prefix = config
.tmux_session_prefix
.unwrap_or_else(|| "ralph".to_string());
if use_tmux && tmux::tmux_available() {
let base_session_name = cli
.tmux_session
.unwrap_or_else(|| tmux::generate_session_name(&tmux_prefix, harness.command_name()));
let session_name = tmux::unique_session_name(&base_session_name);
if session_name != base_session_name {
eprintln!(
"Session name '{}' already exists, using '{}'",
base_session_name, session_name
);
}
let mut args = vec![
"-H".to_string(),
harness_str,
"-m".to_string(),
model,
"-n".to_string(),
iterations,
"--reasoning".to_string(),
reasoning,
];
if dangerous {
args.push("--dangerous".to_string());
} else {
args.push("--safe".to_string());
}
if !provider_str.is_empty() {
args.push("--provider".to_string());
args.push(provider_str);
}
if let Some(limit) = usage_limit_daily {
args.push("--usage-limit-daily".to_string());
args.push(limit.to_string());
}
if let Some(limit) = usage_limit_weekly {
args.push("--usage-limit-weekly".to_string());
args.push(limit.to_string());
}
if let Some(interval) = usage_check_interval {
args.push("--usage-check-interval".to_string());
args.push(interval.to_string());
}
if let Some(name) = fallback_harness_str {
args.push("--fallback-harness".to_string());
args.push(name);
}
args.push("--no-tmux".to_string()); args.push(task);
return tmux::start_in_tmux(&session_name, "ralph", &args, tmux_attach);
}
let mut current_harness = harness;
let mut current_model = model;
let mut current_provider = provider_str;
let mut runner = Runner::new(
current_harness,
current_model.clone(),
dangerous,
reasoning.clone(),
current_provider.clone(),
);
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
eprintln!("\nInterrupted. Cleaning up tracked processes...");
r.store(false, Ordering::SeqCst);
if let Ok((killed, total)) = process::kill_all_processes() {
if total > 0 {
eprintln!("Killed {}/{} tracked processes", killed, total);
}
}
std::process::exit(130);
})?;
let mut using_fallback = false;
let should_continue = apply_usage_limits(
&mut current_harness,
&mut current_model,
&mut current_provider,
&mut runner,
usage_limit_daily,
usage_limit_weekly,
fallback_harness,
&mut using_fallback,
dangerous,
&reasoning,
)?;
if !should_continue {
return Ok(());
}
if iterations == "inf" {
let interval = usage_check_interval.unwrap_or(0);
let mut iteration = 0u32;
while running.load(Ordering::SeqCst) {
runner.run(&prompt).await?;
iteration = iteration.saturating_add(1);
if interval > 0 && iteration.is_multiple_of(interval) {
let should_continue = apply_usage_limits(
&mut current_harness,
&mut current_model,
&mut current_provider,
&mut runner,
usage_limit_daily,
usage_limit_weekly,
fallback_harness,
&mut using_fallback,
dangerous,
&reasoning,
)?;
if !should_continue {
break;
}
}
}
} else {
let count: u32 = iterations
.parse()
.with_context(|| format!("Invalid iteration count: {}", iterations))?;
if count == 0 {
bail!("Iteration count must be at least 1");
}
for i in 0..count {
if !running.load(Ordering::SeqCst) {
break;
}
if count > 1 {
eprintln!("--- Iteration {}/{} ---", i + 1, count);
}
runner.run(&prompt).await?;
if let Some(interval) = usage_check_interval {
if interval > 0 && (i + 1) % interval == 0 {
let should_continue = apply_usage_limits(
&mut current_harness,
&mut current_model,
&mut current_provider,
&mut runner,
usage_limit_daily,
usage_limit_weekly,
fallback_harness,
&mut using_fallback,
dangerous,
&reasoning,
)?;
if !should_continue {
break;
}
}
}
}
}
Ok(())
}