use std::path::PathBuf; use clap::{Parser, Subcommand}; use ops_keeper::{ config::KeeperConfig, nats_client::{KeeperNats, PeekedMessage}, pending::parse_pending, policy::{load_policy, PolicyMatcher}, signer::Signer, PendingOp, }; use tracing::warn; #[derive(Parser)] #[command(name = "keeper-cli", about = "Interactive keeper operations CLI (ADR-037)")] struct Cli { #[arg(short, long, default_value = "keeper-daemon.toml")] config: PathBuf, #[command(subcommand)] command: Cmd, } #[derive(Subcommand)] enum Cmd { /// List pending ops waiting for a keeper signature List { #[arg(short, long, default_value_t = 20)] max: usize, }, /// Show details of a specific pending op by jti Describe { /// JTI or subject prefix to match jti: String, }, /// Sign a pending op (loads private key from config) Sign { /// op_type:target to match (e.g., "deploy:staging-vapora") target: String, }, /// Show the compiled policy decision for a hypothetical op Simulate { op_type: String, target: String, }, } #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let cli = Cli::parse(); let cfg = KeeperConfig::from_toml(&cli.config)?; match cli.command { Cmd::List { max } => { let nats = KeeperNats::connect(&cfg).await?; let peeked = nats.peek_pending(max).await?; if peeked.is_empty() { println!("No pending ops in queue."); return Ok(()); } for msg in &peeked { print_list_row(msg); } println!("{} pending op(s) shown (max {})", peeked.len(), max); } Cmd::Describe { jti } => { let nats = KeeperNats::connect(&cfg).await?; let peeked = nats.peek_pending(100).await?; let mut found = false; for msg in &peeked { found |= describe_msg(msg, &jti); } if !found { eprintln!("No pending op matching '{jti}' found."); std::process::exit(1); } } Cmd::Sign { target } => { let private_pem = std::fs::read(&cfg.private_key_path)?; let public_pem = std::fs::read(&cfg.public_key_path)?; let signer = Signer::from_pem_files( &private_pem, &public_pem, cfg.issuer_id.clone(), cfg.workspace.clone(), cfg.token_validity_secs, )?; let nats = KeeperNats::connect(&cfg).await?; let peeked = nats.peek_pending(100).await?; let (op_type_filter, target_filter) = target .split_once(':') .map(|(a, b)| (a.to_string(), b.to_string())) .unwrap_or_else(|| ("*".to_string(), target.clone())); let mut signed = 0usize; for msg in &peeked { let did_sign = sign_msg(msg, &signer, &nats, &op_type_filter, &target_filter).await; match did_sign { Ok(true) => signed += 1, Ok(false) => {} Err(e) => return Err(e), } } println!("{signed} op(s) signed."); } Cmd::Simulate { op_type, target } => { let policy = load_policy(&cfg.policy_path)?; let matcher = PolicyMatcher::from_policy(&policy)?; let op = PendingOp { op_type: op_type.clone(), target: target.clone(), sub: "simulate".to_string(), expected_state_version: "v0".to_string(), image: String::new(), params: serde_json::Value::Null, }; let decision = matcher.decide(&op); println!("simulate op_type={op_type} target={target}"); println!("decision: {}", decision.as_str()); } } Ok(()) } fn print_list_row(msg: &PeekedMessage) { match parse_pending(&msg.payload) { Ok(op) => println!( "{:<30} op_type={:<15} target={:<25} sub={}", msg.subject, op.op_type, op.target, op.sub ), Err(_) => println!("{:<30} ", msg.subject), } } fn describe_msg(msg: &PeekedMessage, jti: &str) -> bool { if !msg.subject.contains(jti) { return false; } match parse_pending(&msg.payload) { Ok(op) => { println!("subject: {}", msg.subject); println!("op_type: {}", op.op_type); println!("target: {}", op.target); println!("sub: {}", op.sub); println!("version: {}", op.expected_state_version); println!("image: {}", op.image); println!("params: {}", op.params); true } Err(e) => { eprintln!("parse error for {}: {e}", msg.subject); false } } } async fn sign_msg( msg: &PeekedMessage, signer: &Signer, nats: &KeeperNats, op_type_filter: &str, target_filter: &str, ) -> Result> { let op = match parse_pending(&msg.payload) { Ok(op) => op, Err(e) => { warn!(error = %e, "skipping unparseable message"); return Ok(false); } }; let op_match = op_type_filter == "*" || op_type_filter == op.op_type; let target_match = target_filter == "*" || op.target.contains(target_filter); if !op_match || !target_match { return Ok(false); } let jwt = signer.sign_op(&op)?; nats.publish_signed(&op.op_type, &jwt).await?; println!("signed: {} → {}", msg.subject, op.target); Ok(true) }