use std::io::{IsTerminal, Read, Write};
use std::process::ExitCode;
use std::time::Duration;
use ratatui::crossterm::event;
use tokio::sync::mpsc;
use jev_repl::app::{App, Msg};
use jev_repl::cost::Rates;
use jev_repl::session::Session;
use jev_repl::{cost, headless, ui};
use typesafe::Client;
const OK: u8 = 0;
const FAILED: u8 = 1;
const BAD_USAGE: u8 = 2;
#[tokio::main]
async fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if let Some(command) = args.first().filter(|a| headless::is_command(a)) {
let command = command.clone();
return ExitCode::from(one_shot(&command, &args[1..]).await);
}
if args.iter().any(|a| a == "--help" || a == "-h") {
print!("{}", help());
return ExitCode::from(OK);
}
if args.iter().any(|a| a == "--version" || a == "-v") {
println!("{}", env!("CARGO_PKG_VERSION"));
return ExitCode::from(OK);
}
if let Some(unknown) = args.first() {
eprintln!("jev: unknown command {unknown:?}. jev --help lists them.");
return ExitCode::from(BAD_USAGE);
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
eprintln!(
"jev needs an interactive terminal (stdin and stdout must be a TTY).\n\
Without one, `jev run <file>` sends a saved page and prints the answers; \
jev --help lists the rest."
);
return ExitCode::from(FAILED);
}
match repl().await {
Ok(()) => ExitCode::from(OK),
Err(e) => {
eprintln!("jev: {e}");
ExitCode::from(FAILED)
}
}
}
fn help() -> String {
let commands: String = headless::COMMANDS
.iter()
.map(|(name, about)| format!(" {name:<22} {about}\n"))
.collect();
format!(
"jev — a REPL for TypeSafe AI System One questions\n\n\
Set TYPESAFE_API_KEY for live answers; without one, answers are simulated locally.\n\n\
\x20 jev the REPL: :help for commands, :lesson for the guided track,\n\
\x20 :sketch to write a request as one page, :quit to leave\n\
\x20 jev <command> [file] one shot, no terminal needed\n\n\
Commands\n{commands}\n\
The file is a .jev sketch page or a request body; `-`, or no file at all, reads stdin.\n\n\
Options\n\
\x20 --state <text> set the state, or replace the one on the page\n\
\x20 --model <name> the model to ask\n\
\x20 --threshold <0-1> what counts as a yes for a noul (default 0.5)\n\
\x20 --price <in>/<out> dollars per million tokens, input then output\n\
\x20 --timeout <seconds> per-attempt timeout for a live call\n\
\x20 --mock simulated answers, even when a key is set\n\
\x20 --json print the raw response body instead of the answer page\n\
\x20 --help, -h this message\n\
\x20 --version, -v the version of this package\n\n\
Exit status is 0 when it worked, 1 when the call or the file did not, 2 when the\n\
command line did not parse.\n"
)
}
#[derive(Debug, Default)]
struct Options {
file: String,
state: Option<String>,
model: Option<String>,
threshold: f64,
rates: Option<Rates>,
timeout: Option<Duration>,
mock: bool,
json: bool,
}
fn parse_options(args: &[String]) -> Result<Options, String> {
let mut options = Options {
file: "-".to_owned(),
threshold: 0.5,
rates: cost::rates_from_env(),
..Options::default()
};
let mut file: Option<String> = None;
let mut i = 0;
while i < args.len() {
let arg = &args[i];
let (name, inline) = match arg.strip_prefix("--").and_then(|_| arg.split_once('=')) {
Some((name, value)) => (name.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
let mut value = || -> Result<String, String> {
match &inline {
Some(v) => Ok(v.clone()),
None => {
i += 1;
args.get(i)
.cloned()
.ok_or_else(|| format!("{name} needs a value."))
}
}
};
match name.as_str() {
"--state" => options.state = Some(value()?),
"--model" => options.model = Some(value()?),
"--threshold" => {
let text = value()?;
let n: f64 = text
.parse()
.map_err(|_| "--threshold takes a number from 0 to 1.".to_owned())?;
if !(0.0..=1.0).contains(&n) {
return Err("--threshold takes a number from 0 to 1.".to_owned());
}
options.threshold = n;
}
"--price" => options.rates = Some(cost::parse_rates(&value()?)?),
"--timeout" => {
let text = value()?;
let seconds: f64 = text.parse().unwrap_or(0.0);
if !(seconds.is_finite() && seconds > 0.0) {
return Err("--timeout takes a number of seconds greater than 0.".to_owned());
}
options.timeout = Some(Duration::from_secs_f64(seconds));
}
"--mock" => options.mock = true,
"--json" => options.json = true,
other => {
if other.starts_with('-') && other != "-" {
return Err(format!("unknown option {other}. jev --help lists them."));
}
if let Some(first) = &file {
return Err(format!("expected one file, got {first:?} and {arg:?}."));
}
file = Some(arg.clone());
}
}
i += 1;
}
if let Some(path) = file {
options.file = path;
}
Ok(options)
}
fn read_input(path: &str) -> Result<String, String> {
if path == "-" {
let mut text = String::new();
return std::io::stdin()
.read_to_string(&mut text)
.map(|_| text)
.map_err(|e| format!("could not read stdin: {e}"));
}
std::fs::read_to_string(path).map_err(|e| format!("could not read {path}: {e}"))
}
async fn one_shot(command: &str, args: &[String]) -> u8 {
let options = match parse_options(args) {
Ok(options) => options,
Err(e) => {
eprintln!("jev {command}: {e}");
return BAD_USAGE;
}
};
let text = match read_input(&options.file) {
Ok(text) => text,
Err(e) => {
eprintln!("jev {command}: {e}");
return FAILED;
}
};
if command == "check" {
return match headless::check_text(&text) {
Ok(summary) => {
println!("{summary}");
OK
}
Err(problems) => {
eprintln!("{problems}");
FAILED
}
};
}
let mut session: Session = match headless::load(&text) {
Ok(session) => session,
Err(e) => {
eprintln!("jev {command}: {e}");
return FAILED;
}
};
if let Some(state) = &options.state {
session.state = serde_json::Value::String(state.clone());
}
if let Some(model) = &options.model {
session.model = Some(model.clone());
}
let client = if options.mock {
None
} else {
Client::from_env().ok()
};
let model = session
.model
.clone()
.or_else(|| client.as_ref().map(|c| c.default_model().to_owned()))
.unwrap_or_else(|| "jev-latest".to_owned());
match command {
"json" => {
print!("{}", headless::request_text(&session, &model));
return OK;
}
"cost" => {
print!("{}", headless::cost_text(&session, &model, options.rates));
return OK;
}
"rust" => {
print!(
"{}",
headless::code_text(&session, &model, options.threshold)
);
return OK;
}
_ => {}
}
if let Some(why) = headless::sendable(&session) {
eprintln!("jev run: {why}");
return FAILED;
}
let Some(client) = client else {
let answers = headless::mock_answers(&session);
if options.json {
print!("{}", headless::answers_json(&answers, &model, None));
} else {
print!("{}", headless::answers_text(&answers, options.threshold));
print!(
"{}",
headless::usage_text(&session, &model, options.rates, None)
);
eprintln!(
"Simulated answers: deterministic noise, not judgement. Set TYPESAFE_API_KEY for real ones."
);
}
return OK;
};
let mut request = client
.system_one(session.state.clone(), session.to_questions())
.model(model.clone());
if let Some(timeout) = options.timeout {
request = request.timeout(timeout);
}
match request.await {
Ok(response) => {
let answers = headless::live_answers(&session, &response);
if options.json {
print!(
"{}",
headless::answers_json(&answers, &model, Some(&response.raw))
);
} else {
print!("{}", headless::answers_text(&answers, options.threshold));
print!(
"{}",
headless::usage_text(&session, &model, options.rates, Some(&response.usage))
);
}
OK
}
Err(e) => {
let mut stderr = std::io::stderr();
for line in jev_repl::format::error_lines(&e) {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let _ = writeln!(stderr, "{text}");
}
FAILED
}
}
}
async fn repl() -> std::io::Result<()> {
let (tx, mut rx) = mpsc::unbounded_channel();
spawn_input(tx.clone());
spawn_ticker(tx.clone());
let mut terminal = ratatui::init();
let mut app = App::new(tx);
let mut redraw = true;
let result = loop {
if redraw && let Err(e) = terminal.draw(|frame| ui::render(frame, &mut app)) {
break Err(e);
}
let Some(msg) = rx.recv().await else {
break Ok(());
};
redraw = !matches!(msg, Msg::Tick) || app.pending;
app.handle(msg);
if app.quit {
break Ok(());
}
};
ratatui::restore();
result
}
fn spawn_input(tx: mpsc::UnboundedSender<Msg>) {
std::thread::spawn(move || {
while let Ok(ev) = event::read() {
if tx.send(Msg::Term(ev)).is_err() {
break;
}
}
});
}
fn spawn_ticker(tx: mpsc::UnboundedSender<Msg>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(120));
loop {
interval.tick().await;
if tx.send(Msg::Tick).is_err() {
break;
}
}
});
}