use std::ffi::OsString;
use std::io::Write as _;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use clap::{Args, Parser, Subcommand, ValueEnum};
use skilltest_core::{
discover_cases, validate_path, ApiJudgeProvider, CommandProvider, Config, Error, ExitCode,
JudgeConfig, OneharnessProvider, Overrides, Provider, ProviderConfig, Report, Result, Runner,
SplitProvider, StreamEvent, TestCase, ValidationReport,
};
#[derive(Parser)]
#[command(name = "skilltest", version, about, long_about = None)]
struct Cli {
#[arg(long, global = true, value_name = "FILE")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Run(RunArgs),
Validate(ValidateArgs),
Init(InitArgs),
Schema(SchemaArgs),
}
#[derive(Args)]
struct RunArgs {
#[arg(value_name = "PATH", required = true)]
paths: Vec<PathBuf>,
#[arg(short = 'p', long = "platform", value_name = "PLATFORM")]
platforms: Vec<String>,
#[arg(short = 'm', long = "model", value_name = "MODEL")]
models: Vec<String>,
#[arg(long, value_name = "CMD")]
provider: Option<String>,
#[arg(long, value_name = "PATH")]
oneharness_bin: Option<String>,
#[arg(long, value_name = "ID")]
judge_harness: Option<String>,
#[arg(long, value_name = "SECS")]
timeout: Option<u64>,
#[arg(long, value_name = "MODEL")]
judge_model: Option<String>,
#[arg(long, value_name = "N")]
max_turns: Option<u32>,
#[arg(long, value_name = "FILE")]
mocks: Option<PathBuf>,
#[arg(long)]
spy: bool,
#[arg(long, value_enum, default_value_t = Format::Human)]
format: Format,
}
#[derive(Args)]
struct ValidateArgs {
#[arg(value_name = "PATH", required = true)]
paths: Vec<PathBuf>,
#[arg(long, value_enum, default_value_t = Format::Human)]
format: Format,
}
#[derive(Args)]
struct InitArgs {
#[arg(value_name = "DIR", default_value = ".")]
dir: PathBuf,
}
#[derive(Args)]
struct SchemaArgs {
#[arg(value_enum, value_name = "TARGET")]
target: SchemaTarget,
}
#[derive(Clone, Copy, ValueEnum)]
enum SchemaTarget {
Report,
Validation,
}
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Format {
Human,
Json,
JsonStream,
}
pub fn run<I, T>(args: I) -> ExitCode
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let cli = match Cli::try_parse_from(args) {
Ok(cli) => cli,
Err(err) => {
let _ = err.print();
return if matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) {
ExitCode::Success
} else {
ExitCode::UsageError
};
}
};
let result = match &cli.command {
Command::Run(args) => cmd_run(cli.config.as_deref(), args),
Command::Validate(args) => cmd_validate(args),
Command::Init(args) => cmd_init(args),
Command::Schema(args) => cmd_schema(args),
};
match result {
Ok(code) => code,
Err(err) => report_error(&err),
}
}
fn cmd_run(config_path: Option<&Path>, args: &RunArgs) -> Result<ExitCode> {
let mut config = match config_path {
Some(path) => Config::load(path)?,
None => Config::load_or_default(Path::new("skilltest.yaml"))?,
};
let command_provider = args
.provider
.as_ref()
.map(|s| s.split_whitespace().map(String::from).collect::<Vec<_>>());
config.apply_overrides(Overrides {
command_provider,
oneharness_bin: args.oneharness_bin.clone(),
judge_harness: args.judge_harness.clone(),
timeout_secs: args.timeout,
platforms: args.platforms.clone(),
models: args.models.clone(),
judge_model: args.judge_model.clone(),
max_turns: args.max_turns,
mocks: args
.mocks
.as_deref()
.map(load_mocks_file)
.transpose()?
.unwrap_or_default(),
spy: args.spy,
})?;
let provider = build_provider(&config)?;
let mut cases = Vec::new();
for path in &args.paths {
for file in discover_cases(path)? {
cases.push(TestCase::load(&file)?);
}
}
let runner = Runner::new(provider.as_ref(), &config);
if args.format == Format::JsonStream {
return run_streamed(&runner, &cases);
}
let report = runner.run_all(&cases)?;
print_report(&report, args.format)?;
Ok(if report.passed {
ExitCode::Success
} else {
ExitCode::TestFailure
})
}
fn run_streamed(runner: &Runner, cases: &[TestCase]) -> Result<ExitCode> {
let stdout = std::io::stdout();
let mut sink = |ev: &StreamEvent| -> ControlFlow<()> {
let line = serde_json::json!({
"type": "event",
"case": ev.case,
"platform": ev.platform,
"model": ev.model,
"turn": ev.turn,
"event": ev.event,
});
if write_ndjson(&stdout, &line).is_err() {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
};
let report = runner.run_all_streaming(cases, &mut sink)?;
let passed = report.passed;
let _ = write_ndjson(
&stdout,
&serde_json::json!({ "type": "result", "report": report }),
);
Ok(if passed {
ExitCode::Success
} else {
ExitCode::TestFailure
})
}
fn write_ndjson(stdout: &std::io::Stdout, value: &serde_json::Value) -> std::io::Result<()> {
let mut lock = stdout.lock();
serde_json::to_writer(&mut lock, value)?;
lock.write_all(b"\n")?;
lock.flush()
}
fn load_mocks_file(path: &Path) -> Result<Vec<skilltest_core::MockDecl>> {
let text = std::fs::read_to_string(path).map_err(|e| {
Error::Invalid(format!(
"could not read --mocks file `{}`: {e}",
path.display()
))
})?;
serde_yaml::from_str(&text).map_err(|e| {
Error::Invalid(format!(
"--mocks file `{}` is not a valid mock declaration list: {e}",
path.display()
))
})
}
fn build_provider(config: &Config) -> Result<Box<dyn Provider>> {
let base: Box<dyn Provider> = match &config.provider {
ProviderConfig::Oneharness(oh) => Box::new(OneharnessProvider::new(oh)),
ProviderConfig::Command(c) => Box::new(CommandProvider::new(c.command.clone())?),
};
match &config.judge {
Some(JudgeConfig::Api(api)) => Ok(Box::new(SplitProvider::new(
base,
ApiJudgeProvider::new(api),
))),
None => Ok(base),
}
}
fn print_report(report: &Report, format: Format) -> Result<()> {
match format {
Format::Json | Format::JsonStream => {
let json = report
.to_json()
.map_err(|e| Error::Invalid(format!("could not serialize report: {e}")))?;
println!("{json}");
}
Format::Human => print!("{}", report.to_human()),
}
Ok(())
}
fn cmd_validate(args: &ValidateArgs) -> Result<ExitCode> {
let mut findings = Vec::new();
for path in &args.paths {
findings.extend(validate_path(path)?);
}
let valid = findings.is_empty();
match args.format {
Format::Json | Format::JsonStream => {
let report = ValidationReport::new(&findings);
let json = report
.to_json()
.map_err(|e| Error::Invalid(format!("could not serialize findings: {e}")))?;
println!("{json}");
}
Format::Human => {
if valid {
println!("OK all skill definitions valid");
} else {
for finding in &findings {
eprintln!("INVALID {}: {}", finding.skill.display(), finding.message);
}
eprintln!("FAIL {} validation finding(s)", findings.len());
}
}
}
Ok(if valid {
ExitCode::Success
} else {
ExitCode::TestFailure
})
}
fn cmd_init(args: &InitArgs) -> Result<ExitCode> {
let created = crate::scaffold::scaffold(&args.dir)?;
for path in &created {
println!("created {}", path.display());
}
println!(
"\nNext: skilltest run cases/example.yaml\n\
Try it offline: skilltest run cases/example.yaml --provider skilltest-fake-provider"
);
Ok(ExitCode::Success)
}
fn cmd_schema(args: &SchemaArgs) -> Result<ExitCode> {
let generator = schemars::generate::SchemaSettings::draft07().into_generator();
let schema = match args.target {
SchemaTarget::Report => generator.into_root_schema_for::<Report>(),
SchemaTarget::Validation => generator.into_root_schema_for::<ValidationReport>(),
};
let json = serde_json::to_string_pretty(&schema)
.map_err(|e| Error::Invalid(format!("could not serialize schema: {e}")))?;
println!("{json}");
Ok(ExitCode::Success)
}
fn report_error(err: &Error) -> ExitCode {
eprintln!("error: {err}");
match err {
Error::Provider { kind, .. } => {
let hint = match kind.as_deref() {
Some("auth") => "hint: authentication failed — check your provider credentials (e.g. `claude` login)",
Some("rate_limit") => "hint: the harness rate-limited the call — retry after a backoff",
Some("model_not_found") => {
"hint: the harness does not recognize this model — check `--model` and `oneharness list`"
}
Some("quota") => "hint: provider quota exhausted — check your account limits",
Some("overloaded") => "hint: the API is temporarily overloaded — retried already; try again shortly",
Some(other) => {
eprintln!("classified as: {other}");
"hint: see provider docs for this failure class"
}
None => {
"hint: ensure the provider command is installed and on PATH, or pass --provider"
}
};
eprintln!("{hint}");
ExitCode::ProviderError
}
_ => ExitCode::UsageError,
}
}