use crate::config::{default_config_path, ConfigStore};
use crate::execution::{AutomationRunErrorCategory, AutomationRunResult, AutomationRunStatus};
use crate::headless::{bootstrap_headless, run_automation, HeadlessBootstrapError};
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
pub const EXIT_COMPLETED: i32 = 0;
pub const EXIT_USAGE: i32 = 2;
pub const EXIT_CONFIG: i32 = 3;
pub const EXIT_UNSAFE_POLICY: i32 = 4;
pub const EXIT_PROVIDER_INIT: i32 = 5;
pub const EXIT_EXECUTION: i32 = 6;
pub const EXIT_CANCELLED: i32 = 7;
pub const EXIT_TIMED_OUT: i32 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliArgs {
pub task_id: String,
pub config: Option<String>,
pub workspace: Option<String>,
pub timeout: Option<String>,
pub format: Option<String>,
pub quiet: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageError {
pub message: String,
}
impl std::fmt::Display for UsageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for UsageError {}
const USAGE: &str = "\
Usage: agent-iron run <task-id> [OPTIONS]
Options:
-c, --config <path> Path to ConfigStore database
--workspace <dir> Workspace directory (default: process cwd)
--timeout <duration> Execution timeout (e.g. 30s, 5m, 1h) [required]
-o, --format <text|json> Output format (default: text)
-q, --quiet Suppress progress output on stderr
-h, --help Show this help message";
pub fn parse_args(args: &[String]) -> Result<CliArgs, UsageError> {
if args.is_empty() {
return Err(UsageError {
message: format!("missing 'run' subcommand\n\n{}", USAGE),
});
}
let subcommand = args[0].as_str();
if subcommand == "-h" || subcommand == "--help" {
return Err(UsageError {
message: USAGE.to_string(),
});
}
if subcommand != "run" {
return Err(UsageError {
message: format!(
"unknown subcommand '{}': only 'run' is supported\n\n{}",
subcommand, USAGE
),
});
}
let mut task_id: Option<String> = None;
let mut config: Option<String> = None;
let mut workspace: Option<String> = None;
let mut timeout: Option<String> = None;
let mut format: Option<String> = None;
let mut quiet = false;
let rest = &args[1..];
let mut i = 0;
while i < rest.len() {
let arg = &rest[i];
match arg.as_str() {
"--config" | "-c" => {
i += 1;
config = Some(expect_value(rest, i, "--config")?);
}
"--workspace" => {
i += 1;
workspace = Some(expect_value(rest, i, "--workspace")?);
}
"--timeout" => {
i += 1;
timeout = Some(expect_value(rest, i, "--timeout")?);
}
"--format" | "-o" => {
i += 1;
format = Some(expect_value(rest, i, "--format")?);
}
"--quiet" | "-q" => {
quiet = true;
}
"-h" | "--help" => {
return Err(UsageError {
message: USAGE.to_string(),
});
}
s if s.starts_with('-') => {
return Err(UsageError {
message: format!("unknown option '{}'\n\n{}", s, USAGE),
});
}
s => {
if task_id.is_none() {
task_id = Some(s.to_string());
} else {
return Err(UsageError {
message: format!("unexpected positional argument '{}'\n\n{}", s, USAGE),
});
}
}
}
i += 1;
}
let task_id = task_id.ok_or_else(|| UsageError {
message: format!(
"missing required <task-id> positional argument\n\n{}",
USAGE
),
})?;
Ok(CliArgs {
task_id,
config,
workspace,
timeout,
format,
quiet,
})
}
fn expect_value(args: &[String], idx: usize, flag: &str) -> Result<String, UsageError> {
args.get(idx).cloned().ok_or_else(|| UsageError {
message: format!("{} requires a value\n\n{}", flag, USAGE),
})
}
fn raw_args_request_json(args: &[String]) -> bool {
let mut i = 0;
while i < args.len() {
let a = args[i].as_str();
if a == "--format" || a == "-o" {
if let Some(v) = args.get(i + 1) {
if v.trim().eq_ignore_ascii_case("json") {
return true;
}
}
i += 2;
continue;
}
if let Some(v) = a.strip_prefix("--format=") {
if v.trim().eq_ignore_ascii_case("json") {
return true;
}
}
i += 1;
}
false
}
pub fn parse_duration(s: &str) -> Result<Duration, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("timeout must not be empty".to_string());
}
let last = trimmed.chars().last().unwrap();
let (num_str, multiplier) = match last {
's' => (&trimmed[..trimmed.len() - 1], 1u64),
'm' => (&trimmed[..trimmed.len() - 1], 60),
'h' => (&trimmed[..trimmed.len() - 1], 3600),
c if c.is_ascii_digit() => (trimmed, 1u64),
_ => {
return Err(format!(
"invalid timeout unit '{}': use 30s, 5m, or 1h",
last
))
}
};
let seconds: u64 = num_str.parse().map_err(|_| {
format!(
"invalid timeout value '{}': expected a positive number",
num_str
)
})?;
if seconds == 0 {
return Err("timeout must be greater than zero".to_string());
}
let total = seconds
.checked_mul(multiplier)
.ok_or_else(|| "timeout value is too large".to_string())?;
Ok(Duration::from_secs(total))
}
pub fn resolve_workspace(
cli: Option<&str>,
env: Option<&str>,
fallback: Option<&std::path::Path>,
) -> Result<PathBuf, String> {
let path = match cli.or(env) {
Some(raw) => PathBuf::from(raw),
None => match fallback {
Some(p) => p.to_path_buf(),
None => {
return Err(
"workspace is required: use --workspace, AGENTIRON_WORKSPACE, \
or set a project root on the automation task"
.to_string(),
)
}
},
};
if !path.exists() {
return Err(format!("workspace does not exist: {}", path.display()));
}
if !path.is_dir() {
return Err(format!("workspace is not a directory: {}", path.display()));
}
path.canonicalize().map_err(|e| {
format!(
"failed to canonicalize workspace '{}': {}",
path.display(),
e
)
})
}
pub fn resolve_config_path(cli: Option<&str>, env: Option<&str>) -> Result<PathBuf, String> {
if let Some(p) = cli {
return Ok(PathBuf::from(p));
}
if let Some(p) = env {
return Ok(PathBuf::from(p));
}
default_config_path().map_err(|e| format!("failed to determine default config path: {}", e))
}
pub fn resolve_timeout(
cli: Option<&str>,
env: Option<&str>,
fallback: Option<Duration>,
) -> Result<Duration, String> {
if let Some(raw) = cli.or(env) {
return parse_duration(raw);
}
fallback.ok_or_else(|| {
"timeout is required: use --timeout, AGENTIRON_TIMEOUT, \
or set a timeout on the automation task"
.to_string()
})
}
pub fn resolve_format(cli: Option<&str>, env: Option<&str>) -> Result<OutputFormat, String> {
let raw = cli.or(env).unwrap_or("text");
match raw.trim().to_lowercase().as_str() {
"text" => Ok(OutputFormat::Text),
"json" => Ok(OutputFormat::Json),
other => Err(format!(
"invalid format '{}': expected 'text' or 'json'",
other
)),
}
}
pub fn resolve_quiet(quiet_flag: bool, env: Option<&str>) -> bool {
if quiet_flag {
return true;
}
matches!(
env.map(|s| s.trim().to_lowercase()),
Some(ref s) if s == "1" || s == "true" || s == "yes"
)
}
pub fn exit_code_for_status(status: AutomationRunStatus) -> i32 {
match status {
AutomationRunStatus::Completed => EXIT_COMPLETED,
AutomationRunStatus::Failed => EXIT_EXECUTION,
AutomationRunStatus::Cancelled => EXIT_CANCELLED,
AutomationRunStatus::TimedOut => EXIT_TIMED_OUT,
}
}
pub fn exit_code_for_result(result: &AutomationRunResult) -> i32 {
match result.status {
AutomationRunStatus::Completed => EXIT_COMPLETED,
AutomationRunStatus::Cancelled => EXIT_CANCELLED,
AutomationRunStatus::TimedOut => EXIT_TIMED_OUT,
AutomationRunStatus::Failed => match result.error.as_ref().map(|e| &e.category) {
Some(AutomationRunErrorCategory::Config)
| Some(AutomationRunErrorCategory::Reference) => EXIT_CONFIG,
Some(AutomationRunErrorCategory::UnsafePolicy) => EXIT_UNSAFE_POLICY,
Some(AutomationRunErrorCategory::ProviderInit) => EXIT_PROVIDER_INIT,
_ => EXIT_EXECUTION,
},
}
}
pub fn exit_code_for_bootstrap_error(err: &HeadlessBootstrapError) -> i32 {
match err {
HeadlessBootstrapError::MissingDefaultProvider
| HeadlessBootstrapError::ProviderInit { .. }
| HeadlessBootstrapError::CredentialFailure { .. }
| HeadlessBootstrapError::InteractiveAuthRequired { .. } => EXIT_PROVIDER_INIT,
HeadlessBootstrapError::UnsafePolicy(_) | HeadlessBootstrapError::UnavailableTool(_) => {
EXIT_UNSAFE_POLICY
}
HeadlessBootstrapError::Config(_) | HeadlessBootstrapError::Resolution(_) => EXIT_CONFIG,
}
}
fn bootstrap_error_category(err: &HeadlessBootstrapError) -> AutomationRunErrorCategory {
match err {
HeadlessBootstrapError::MissingDefaultProvider
| HeadlessBootstrapError::ProviderInit { .. }
| HeadlessBootstrapError::CredentialFailure { .. }
| HeadlessBootstrapError::InteractiveAuthRequired { .. } => {
AutomationRunErrorCategory::ProviderInit
}
HeadlessBootstrapError::UnsafePolicy(_) | HeadlessBootstrapError::UnavailableTool(_) => {
AutomationRunErrorCategory::UnsafePolicy
}
HeadlessBootstrapError::Config(_) | HeadlessBootstrapError::Resolution(_) => {
AutomationRunErrorCategory::Config
}
}
}
pub fn format_text_output(result: &AutomationRunResult) -> String {
result.output.clone()
}
pub fn format_json_output(result: &AutomationRunResult) -> String {
serde_json::to_string_pretty(result).unwrap_or_else(|e| {
format!(
"{{\"schema_version\":1,\"status\":\"failed\",\"error\":{{\"category\":\"execution\",\"message\":\"failed to serialize result: {}\"}}}}",
e
)
})
}
async fn wait_for_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let sigterm = signal(SignalKind::terminate());
let mut sigterm = match sigterm {
Ok(s) => s,
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
return;
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
pub async fn execute_run(args: &[String]) -> i32 {
let env: Vec<(String, String)> = std::env::vars().collect();
execute_run_with_streams(
args,
&mut env.clone(),
&mut std::io::stdout(),
&mut std::io::stderr(),
)
.await
}
pub async fn execute_run_with_env(args: &[String], env: &mut [(String, String)]) -> i32 {
execute_run_with_streams(args, env, &mut std::io::stdout(), &mut std::io::stderr()).await
}
pub async fn execute_run_with_streams(
args: &[String],
env: &mut [(String, String)],
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> i32 {
let json_mode = raw_args_request_json(args)
|| env_get(env, "AGENTIRON_FORMAT")
.map(|v| v.trim().eq_ignore_ascii_case("json"))
.unwrap_or(false);
let parsed = match parse_args(args) {
Ok(p) => p,
Err(e) => {
if json_mode {
let result = AutomationRunResult::cli_failure(
"unknown",
PathBuf::from("."),
AutomationRunErrorCategory::Config,
e.message.clone(),
);
let _ = writeln!(stdout, "{}", format_json_output(&result));
} else {
let _ = writeln!(stderr, "{}", e.message);
}
return EXIT_USAGE;
}
};
let format = match resolve_format(parsed.format.as_deref(), env_get(env, "AGENTIRON_FORMAT")) {
Ok(f) => f,
Err(e) => {
let _ = writeln!(stderr, "{}", e);
return EXIT_USAGE;
}
};
let quiet = resolve_quiet(parsed.quiet, env_get(env, "AGENTIRON_QUIET"));
macro_rules! emit_failure {
($category:expr, $message:expr, $exit_code:expr, $workspace:expr) => {{
match format {
OutputFormat::Json => {
let result = AutomationRunResult::cli_failure(
&parsed.task_id,
$workspace,
$category,
$message,
);
let _ = writeln!(stdout, "{}", format_json_output(&result));
}
OutputFormat::Text => {
let _ = writeln!(stderr, "{}", &$message);
}
}
return $exit_code;
}};
}
let config_path =
match resolve_config_path(parsed.config.as_deref(), env_get(env, "AGENTIRON_CONFIG")) {
Ok(p) => p,
Err(e) => {
emit_failure!(
AutomationRunErrorCategory::Config,
e,
EXIT_CONFIG,
PathBuf::from(parsed.workspace.as_deref().unwrap_or("."))
);
}
};
if !quiet {
let _ = writeln!(stderr, "config: {}", config_path.display());
}
let store = match ConfigStore::open_at(&config_path).await {
Ok(s) => s,
Err(e) => {
emit_failure!(
AutomationRunErrorCategory::Config,
format!("failed to open config store: {}", e),
EXIT_CONFIG,
PathBuf::from(parsed.workspace.as_deref().unwrap_or("."))
);
}
};
let task_defaults = match store.get_automation_task(&parsed.task_id).await {
Ok(Some(t)) => Some(t),
Ok(None) => None,
Err(e) => {
emit_failure!(
AutomationRunErrorCategory::Config,
format!("failed to load task '{}': {}", parsed.task_id, e),
EXIT_CONFIG,
PathBuf::from(parsed.workspace.as_deref().unwrap_or("."))
);
}
};
let workspace_fallback = task_defaults
.as_ref()
.filter(|t| !t.project_root.as_os_str().is_empty())
.map(|t| t.project_root.as_path());
let timeout_fallback = task_defaults
.as_ref()
.filter(|t| t.timeout_seconds > 0)
.map(|t| Duration::from_secs(t.timeout_seconds));
let workspace = match resolve_workspace(
parsed.workspace.as_deref(),
env_get(env, "AGENTIRON_WORKSPACE"),
workspace_fallback,
) {
Ok(w) => w,
Err(e) => {
emit_failure!(
AutomationRunErrorCategory::Config,
e,
EXIT_CONFIG,
PathBuf::from(parsed.workspace.as_deref().unwrap_or("."))
);
}
};
if !quiet {
let _ = writeln!(stderr, "workspace: {}", workspace.display());
}
let timeout = match resolve_timeout(
parsed.timeout.as_deref(),
env_get(env, "AGENTIRON_TIMEOUT"),
timeout_fallback,
) {
Ok(t) => t,
Err(e) => {
emit_failure!(AutomationRunErrorCategory::Config, e, EXIT_USAGE, workspace);
}
};
if !quiet {
let _ = writeln!(stderr, "timeout: {:?}", timeout);
}
if !quiet {
let _ = writeln!(stderr, "bootstrapping headless runtime...");
}
let headless =
match bootstrap_headless(store, &parsed.task_id, workspace.clone(), timeout).await {
Ok(h) => h,
Err(e) => {
let code = exit_code_for_bootstrap_error(&e);
emit_failure!(bootstrap_error_category(&e), e.to_string(), code, workspace);
}
};
if !quiet {
let _ = writeln!(
stderr,
"running task '{}' with provider '{}' model '{}'",
parsed.task_id, headless.provider_slug, headless.model
);
}
let cancel = CancellationToken::new();
let signal_cancel = cancel.clone();
tokio::spawn(async move {
wait_for_signal().await;
signal_cancel.cancel();
});
let result = run_automation(headless, timeout, cancel).await;
match format {
OutputFormat::Text => {
let text = format_text_output(&result);
let _ = writeln!(stdout, "{}", text);
}
OutputFormat::Json => {
let json = format_json_output(&result);
let _ = writeln!(stdout, "{}", json);
}
}
exit_code_for_result(&result)
}
fn env_get<'a>(env: &'a [(String, String)], key: &str) -> Option<&'a str> {
env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
#[cfg(test)]
mod tests;