use clap::{Parser, Subcommand}; use tracing::info; use std::collections::HashMap; pub mod parser; pub mod classifier; pub mod executor; pub mod audit; #[derive(Parser)] #[command(name = "dd0c-run", version, about = "Runbook automation agent")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Execute a runbook Run { /// Path to runbook file (YAML/Markdown) #[arg(short, long)] runbook: String, /// dd0c SaaS endpoint #[arg(long, default_value = "https://api.dd0c.dev")] endpoint: String, /// API key #[arg(long, env = "DD0C_API_KEY")] api_key: String, /// Dry run (classify only, don't execute) #[arg(long)] dry_run: bool, /// Variable overrides (key=value) #[arg(short, long, value_parser = parse_var)] var: Vec<(String, String)>, }, /// Classify a single command Classify { /// Command to classify command: String, }, /// Print version Version, } fn parse_var(s: &str) -> Result<(String, String), String> { let pos = s.find('=').ok_or_else(|| format!("invalid KEY=VALUE: no `=` found in `{s}`"))?; Ok((s[..pos].to_string(), s[pos + 1..].to_string())) } #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "dd0c_run=info".into()), ) .json() .init(); let cli = Cli::parse(); match cli.command { Commands::Run { runbook, endpoint, api_key, dry_run, var } => { info!(runbook = %runbook, dry_run, "Starting runbook execution"); // Parse runbook YAML let content = std::fs::read_to_string(&runbook) .map_err(|e| anyhow::anyhow!("Failed to read runbook '{}': {}", runbook, e))?; let rb = parser::parse_yaml(&content)?; info!(name = %rb.name, steps = rb.steps.len(), "Runbook parsed"); // Build variable map from CLI args let variables: HashMap = var.into_iter().collect(); // Validate required variables for (name, spec) in &rb.variables { if spec.required && !variables.contains_key(name) && spec.default.is_none() { anyhow::bail!("Required variable '{}' not provided (use --var {}=VALUE)", name, name); } } // Merge defaults let mut merged_vars = HashMap::new(); for (name, spec) in &rb.variables { if let Some(val) = variables.get(name) { merged_vars.insert(name.clone(), val.clone()); } else if let Some(default) = &spec.default { merged_vars.insert(name.clone(), default.clone()); } } // Auto-approve callback for read-only commands; reject everything else in dry-run let approval_callback = |cmd: &str, safety: classifier::SafetyLevel| -> bool { match safety { classifier::SafetyLevel::ReadOnly => { info!(command = %cmd, "Auto-approved (read-only)"); true } _ if dry_run => { info!(command = %cmd, safety = ?safety, "Would require approval (dry run)"); false } _ => { // In real mode, this would wait for SaaS approval via Redis pub/sub // For now, prompt on stdin eprintln!("\n⚠️ Command requires approval ({:?}):", safety); eprintln!(" {}", cmd); eprint!(" Approve? [y/N] "); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap_or(0); input.trim().eq_ignore_ascii_case("y") } } }; // Execute steps let mut failed = false; for step in &rb.steps { let command = parser::substitute_variables(&step.command, &merged_vars)?; let classification = classifier::classify(&command); info!( step = step.index, command = %command, safety = ?classification.safety, description = %step.description, "Executing step" ); let result = executor::execute_step(&command, dry_run, &approval_callback).await; match result.status { executor::StepStatus::Success => { info!(step = step.index, exit_code = result.exit_code, duration_ms = result.duration_ms, "Step completed"); } executor::StepStatus::Skipped => { info!(step = step.index, "Step skipped (dry run)"); } executor::StepStatus::Rejected => { info!(step = step.index, "Step rejected"); match step.on_failure { parser::FailureAction::Abort => { eprintln!("Step {} rejected — aborting runbook", step.index); failed = true; break; } parser::FailureAction::Continue => { eprintln!("Step {} rejected — continuing", step.index); } parser::FailureAction::Retry { .. } => { eprintln!("Step {} rejected — cannot retry rejection, aborting", step.index); failed = true; break; } } } executor::StepStatus::Failed | executor::StepStatus::TimedOut => { eprintln!("Step {} failed (exit code {})", step.index, result.exit_code); if !result.stderr.is_empty() { eprintln!(" stderr: {}", result.stderr.trim()); } match &step.on_failure { parser::FailureAction::Abort => { failed = true; break; } parser::FailureAction::Continue => {} parser::FailureAction::Retry { max_attempts } => { let mut retried = false; for attempt in 1..=*max_attempts { info!(step = step.index, attempt, "Retrying"); let retry = executor::execute_step(&command, dry_run, &approval_callback).await; if matches!(retry.status, executor::StepStatus::Success) { retried = true; break; } } if !retried { eprintln!("Step {} failed after {} retries — aborting", step.index, max_attempts); failed = true; break; } } } } _ => {} } } if failed { std::process::exit(1); } info!("Runbook '{}' completed successfully", rb.name); } Commands::Classify { command } => { let result = classifier::classify(&command); println!("{}", serde_json::to_string_pretty(&result)?); } Commands::Version => { println!("dd0c/run agent v{}", env!("CARGO_PKG_VERSION")); } } Ok(()) }