use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
pub const ASSISTANT_ENV: &str = "RIGGER_ASSISTANT";
const DEFAULT_ASSISTANT: &str = "claude";
pub fn assistant() -> (String, Vec<String>) {
let raw = std::env::var(ASSISTANT_ENV).unwrap_or_else(|_| DEFAULT_ASSISTANT.to_string());
let mut parts = raw.split_whitespace().map(str::to_string);
let program = parts.next().unwrap_or_else(|| DEFAULT_ASSISTANT.to_string());
(program, parts.collect())
}
pub fn run(dir: &Path, packet: &str) -> Result<i32> {
let (program, args) = assistant();
let program = resolve(&program);
let context = || {
format!(
"cannot run `{}` in {}; set {ASSISTANT_ENV} to the command you use",
program.to_string_lossy(),
dir.display()
)
};
if takes_message_on_stdin(&program) {
let mut child = launcher(&program)
.args(&args)
.current_dir(dir)
.stdin(Stdio::piped())
.spawn()
.with_context(context)?;
child
.stdin
.take()
.context("the assistant did not accept input")?
.write_all(packet.as_bytes())
.context("cannot hand the packet to the assistant")?;
return Ok(child.wait()?.code().unwrap_or(1));
}
let status = launcher(&program).args(&args).arg(packet).current_dir(dir).status().with_context(context)?;
Ok(status.code().unwrap_or(1))
}
#[cfg(windows)]
fn takes_message_on_stdin(program: &std::ffi::OsStr) -> bool {
is_batch(program)
}
#[cfg(not(windows))]
fn takes_message_on_stdin(_program: &std::ffi::OsStr) -> bool {
false
}
#[cfg(windows)]
pub fn launcher(program: &std::ffi::OsStr) -> Command {
if !is_batch(program) {
return Command::new(program);
}
let shell = std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe"));
let mut command = Command::new(shell);
command.arg("/c").arg(program);
command
}
#[cfg(not(windows))]
pub fn launcher(program: &std::ffi::OsStr) -> Command {
Command::new(program)
}
#[cfg(windows)]
fn is_batch(program: &std::ffi::OsStr) -> bool {
Path::new(program)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat"))
}
#[cfg(windows)]
pub fn resolve(program: &str) -> std::ffi::OsString {
use std::ffi::OsString;
if Path::new(program).extension().is_some() || program.contains(['/', '\\']) {
return OsString::from(program);
}
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
for ext in pathext.split(';').filter(|e| !e.is_empty()) {
let candidate = dir.join(format!("{program}{ext}"));
if candidate.is_file() {
return candidate.into_os_string();
}
}
}
}
OsString::from(program)
}
#[cfg(not(windows))]
pub fn resolve(program: &str) -> std::ffi::OsString {
std::ffi::OsString::from(program)
}
pub fn first_message(packet: &str) -> String {
format!(
"This is where the project stands, from rigger. Pick up from the next step; \
record what you decide or find with `rigger note`, and anything for the owner with `rigger wish`.\n\n{packet}"
)
}
pub fn first_message_over_mcp(packet: &str) -> String {
format!(
"This is where the project stands, from rigger. Pick up from the next step; record what you decide \
or find with `record_decision` and `record_finding` as it happens, leave the next session a line \
with `set_next_step`, and send anything only the owner can settle to `ask_owner`.\n\n{packet}"
)
}
pub fn check_dir(dir: &Path) -> Result<()> {
if !dir.is_dir() {
bail!("{} is not a directory any more; the project moved or was removed", dir.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_assistant_is_claude() {
assert_eq!(DEFAULT_ASSISTANT, "claude");
}
#[test]
fn flags_travel_with_the_command() {
let raw = "claude --model opus";
let mut parts = raw.split_whitespace().map(str::to_string);
let program = parts.next().unwrap();
let args: Vec<String> = parts.collect();
assert_eq!(program, "claude");
assert_eq!(args, vec!["--model", "opus"]);
}
#[test]
fn the_first_message_tells_the_assistant_what_the_packet_is() {
let message = first_message("# proj\n");
assert!(message.contains("rigger note"), "{message}");
assert!(message.ends_with("# proj\n"), "the packet must come last: {message}");
}
#[cfg(windows)]
#[test]
fn a_windows_command_is_resolved_through_pathext() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("thing.cmd"), "@echo off\n").unwrap();
let path = format!("{};{}", dir.path().display(), std::env::var("PATH").unwrap_or_default());
unsafe { std::env::set_var("PATH", &path) };
let resolved = resolve("thing");
assert!(
Path::new(&resolved).extension().is_some_and(|e| e.eq_ignore_ascii_case("cmd")),
"{resolved:?} - a bare name must find the .cmd shim npm installs"
);
assert_eq!(resolve("no-such-command-anywhere"), std::ffi::OsString::from("no-such-command-anywhere"));
}
#[test]
fn an_explicit_path_is_launched_as_written() {
let spelled = if cfg!(windows) {
"C:\\tools\\my-assistant.exe"
} else {
"/usr/local/bin/my-assistant"
};
assert_eq!(resolve(spelled), std::ffi::OsString::from(spelled));
}
#[test]
fn a_missing_directory_is_reported_as_such() {
let err = check_dir(Path::new("no/such/place")).unwrap_err().to_string();
assert!(err.contains("moved or was removed"), "{err}");
}
}