#![cfg(feature = "cli")]
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "proserpina",
version,
about = "Multi-agent critique and cross-examination pipeline",
long_about = "Cross-examine a document with a panel of LLM-backed critic personas.\n\n\
Quick start:\n \
proserpina critique doc.md --panel panel\n\n\
Validate your keys:\n \
proserpina auth check\n"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Critique {
input: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(long)]
echo: bool,
#[arg(long)]
seed: Option<u64>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
panel: Option<String>,
#[arg(long)]
max_attempts: Option<u32>,
#[arg(long)]
timeout: Option<u64>,
},
Capabilities {
#[arg(long)]
json: bool,
},
Auth {
#[command(subcommand)]
action: Option<AuthAction>,
},
Panels {
#[arg(long)]
config: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
enum AuthAction {
Check {
#[arg(long)]
config: Option<PathBuf>,
},
List {
#[arg(long)]
config: Option<PathBuf>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.command {
Command::Capabilities { json } => cmd_capabilities(json),
Command::Critique {
input,
out,
echo,
seed,
config,
json,
dry_run,
panel,
max_attempts,
timeout,
} => match run_critique_cmd(
&input,
echo,
seed,
config.as_deref(),
json,
dry_run,
panel.as_deref(),
max_attempts,
timeout,
) {
Ok(output) => {
match out {
Some(path) => {
if let Err(e) = std::fs::write(&path, output) {
eprintln!("failed to write {path:?}: {e}");
return ExitCode::FAILURE;
}
}
None => print!("{output}"),
}
ExitCode::SUCCESS
}
Err(err) => {
emit_error(&err, json);
ExitCode::from(err.exit_code())
}
},
Command::Auth { action } => match action.unwrap_or(AuthAction::Check { config: None }) {
AuthAction::Check { config } => cmd_auth_check(config.as_deref()),
AuthAction::List { config } => cmd_auth_list(config.as_deref()),
},
Command::Panels { config } => cmd_panels(config.as_deref()),
}
}
fn cmd_capabilities(json: bool) -> ExitCode {
#[cfg(feature = "backend-http")]
{
let caps = proserpina::Capabilities::with_current_auth();
if json {
let j = serde_json::to_string_pretty(&caps).unwrap_or_else(|_| "{}".to_owned());
println!("{j}");
} else {
print_human_capabilities(&caps);
}
ExitCode::SUCCESS
}
#[cfg(not(feature = "backend-http"))]
{
let caps = proserpina::Capabilities::static_info();
if json {
let j = serde_json::to_string_pretty(&caps).unwrap_or_else(|_| "{}".to_owned());
println!("{j}");
} else {
print_human_capabilities(&caps);
}
ExitCode::SUCCESS
}
}
fn print_human_capabilities(caps: &proserpina::Capabilities) {
println!("Proserpina v{}", caps.version);
println!();
println!("Providers:");
for p in &caps.providers {
let status = if p.authed { "✓ authed" } else { " —" };
println!(" {status} {} ({})", p.name, p.model);
}
println!();
println!("Panels: {}", caps.panels.join(", "));
println!("Topologies: {}", caps.topologies.join(", "));
println!("Output: {}", caps.output_formats.join(", "));
}
#[cfg(feature = "backend-http")]
fn cmd_auth_check(config: Option<&std::path::Path>) -> ExitCode {
use proserpina::backend::credentials::authed_configs_with;
use proserpina::backend::http::{validate_provider, RetryPolicy};
let configs = match authed_configs_with(config) {
Ok(c) if !c.is_empty() => c,
Ok(_) => {
eprintln!("No provider keys found. Set DEEPSEEK_API_KEY (or another provider key)");
eprintln!("or create ~/.config/proserpina/credentials.toml.");
return ExitCode::from(10);
}
Err(e) => {
eprintln!("proserpina: {e}");
return ExitCode::from(e.exit_code());
}
};
let policy = RetryPolicy::NONE; let mut all_ok = true;
println!("Checking {} authed provider(s):\n", configs.len());
for cfg in &configs {
let label = format!("{} ({})", identify_provider(cfg), cfg.model);
match validate_provider(cfg, &policy) {
Ok(()) => println!(" ✓ {label}"),
Err(e) => {
println!(" ✗ {label}");
println!(" {e}");
println!(" This key is set but invalid. Check or remove it.");
all_ok = false;
}
}
}
if all_ok {
println!("\nAll keys valid.");
ExitCode::SUCCESS
} else {
println!("\nSome keys failed. Run `proserpina auth list` to see which are set.");
ExitCode::from(11)
}
}
#[cfg(not(feature = "backend-http"))]
fn cmd_auth_check(_config: Option<&std::path::Path>) -> ExitCode {
eprintln!("Built without `backend-http`; no HTTP providers to check.");
ExitCode::FAILURE
}
#[cfg(feature = "backend-http")]
fn identify_provider(cfg: &proserpina::backend::http::HttpConfig) -> String {
for p in proserpina::backend::roster::Provider::registry() {
if p.model() == cfg.model || p.base_url() == cfg.base_url {
return p.name().to_owned();
}
}
"custom".to_owned()
}
#[cfg(feature = "backend-http")]
fn cmd_auth_list(config: Option<&std::path::Path>) -> ExitCode {
use proserpina::backend::credentials::Credentials;
use proserpina::backend::roster::Provider;
let creds = Credentials::discover_or(config).unwrap_or_default();
println!("Provider key status:\n");
for p in Provider::registry() {
let env_set = std::env::var(p.key_env_var()).is_ok();
let config_set = creds
.override_for(p.name())
.and_then(|o| o.api_key.as_ref())
.is_some();
let status = if env_set || config_set {
"✓ set"
} else {
" —"
};
let source = if env_set {
"(env)"
} else if config_set {
"(config)"
} else {
""
};
println!(" {status} {:12} {} {source}", p.name(), p.key_env_var());
}
println!("\nUse `proserpina auth check` to validate that keys actually work.");
ExitCode::SUCCESS
}
#[cfg(not(feature = "backend-http"))]
fn cmd_auth_list(_config: Option<&std::path::Path>) -> ExitCode {
eprintln!("Built without `backend-http`.");
ExitCode::FAILURE
}
fn cmd_panels(config: Option<&std::path::Path>) -> ExitCode {
println!("Built-in panels:\n");
println!(" default 1 critic Devil's Advocate");
println!(" duo 2 critics Devil's Advocate + Methodologist");
println!(" panel 5 critics Devil's Advocate, Methodologist, Red Team,");
println!(" Domain Expert, Editor");
#[cfg(feature = "backend-http")]
{
if let Ok(creds) = proserpina::backend::credentials::Credentials::discover_or(config) {
if !creds.panels().is_empty() {
println!("\nCustom panels (from config):\n");
for (name, panel) in creds.panels() {
let count = panel.personas.len();
let names: Vec<&str> = panel.personas.iter().map(|p| p.name.as_str()).collect();
println!(" {name:<10} {count} critics {}", names.join(", "));
}
}
}
}
println!("\nUse `--panel <name>` with `proserpina critique`.");
ExitCode::SUCCESS
}
fn emit_error(err: &proserpina::ProserpinaError, json: bool) {
if json {
#[cfg(feature = "json")]
{
eprintln!("{}", err.to_error_json());
return;
}
}
eprintln!("proserpina: {err}");
let msg = err.to_string();
if msg.contains("401") || msg.contains("invalid_api_key") || msg.contains("Incorrect API key") {
eprintln!();
eprintln!(" A provider rejected your API key. Run `proserpina auth check` to");
eprintln!(" diagnose which key is invalid.");
} else if msg.contains("429") {
eprintln!();
eprintln!(" A provider rate-limited the request. Consider increasing");
eprintln!(" --max-attempts or --timeout, or reducing the panel size.");
}
}
#[allow(clippy::too_many_arguments)]
fn run_critique_cmd(
input: &std::path::Path,
echo: bool,
seed: Option<u64>,
config: Option<&std::path::Path>,
json: bool,
dry_run: bool,
panel: Option<&str>,
max_attempts: Option<u32>,
timeout: Option<u64>,
) -> Result<String, proserpina::ProserpinaError> {
let source = input.to_string_lossy().to_string();
let text = std::fs::read_to_string(input).map_err(|e| {
proserpina::ProserpinaError::agent_failure(input.to_string_lossy(), e.to_string())
})?;
if echo {
return proserpina::cli::run_critique_echo(&text, &source);
}
#[cfg(feature = "backend-http")]
{
let seed = seed.unwrap_or_else(rand::random);
let retry_config = proserpina::backend::credentials::Credentials::discover_or(config)
.map(|c| c.retry().clone())
.unwrap_or_default();
let policy =
proserpina::backend::http::RetryPolicy::resolve(&retry_config, max_attempts, timeout);
if dry_run {
return proserpina::cli::plan_critique(&text, &source, seed, config, json, panel);
}
if !json {
let panel_name = panel.unwrap_or("default");
eprintln!("Proserpina v0.1.0 — panel: {panel_name}\n");
}
proserpina::cli::run_critique(&text, &source, seed, config, json, panel, policy)
}
#[cfg(not(feature = "backend-http"))]
{
let _ = (seed, config, json, dry_run, panel, max_attempts, timeout);
let mut report = proserpina::cli::run_critique_echo(&text, &source)?;
report.push_str("\n_(built without `backend-http`; used the echo backend)_\n");
Ok(report)
}
}