use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; use crate::api::client::{ApiClient, ApiClientError}; use crate::api::schema::{ AgentStatus, Method, OutputMatch, PaneAgentState, PaneWaitForOutputParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription, }; mod agent; mod integration; mod pane; mod server; mod status; mod tab; mod workspace; pub enum CommandOutcome { Handled(i32), NotCli, } pub fn maybe_run(args: &[String]) -> std::io::Result { let Some(command) = args.get(1).map(|arg| arg.as_str()) else { return Ok(CommandOutcome::NotCli); }; let exit_code = match command { "server" => { let Some(exit_code) = server::run_server_command(&args[2..])? else { return Ok(CommandOutcome::NotCli); }; exit_code } "status" => status::run_status_command(&args[2..])?, "config" => run_config_command(&args[2..])?, "workspace" => workspace::run_workspace_command(&args[2..])?, "tab" => tab::run_tab_command(&args[2..])?, "agent" => agent::run_agent_command(&args[2..])?, "terminal" => run_terminal_command(&args[2..])?, "pane" => pane::run_pane_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, "integration" => integration::run_integration_command(&args[2..])?, "session" => run_session_command(&args[2..])?, _ => return Ok(CommandOutcome::NotCli), }; Ok(CommandOutcome::Handled(exit_code)) } fn run_config_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_config_help(); return Ok(2); }; match subcommand { "reset-keys" => config_reset_keys(&args[1..]), "help" | "--help" | "-h" => { print_config_help(); Ok(0) } _ => { print_config_help(); Ok(2) } } } fn config_reset_keys(args: &[String]) -> std::io::Result { if !args.is_empty() { eprintln!("usage: herdr config reset-keys"); return Ok(2); } let path = crate::config::config_path(); if !path.exists() { println!( "No config file found at {}. Built-in v2 keybindings already apply.", path.display() ); return Ok(0); } let content = std::fs::read_to_string(&path)?; let parsed = match content.parse::() { Ok(value) => value, Err(err) => { eprintln!( "config file at {} is invalid TOML: {err}. Fix it manually or move it aside to use defaults.", path.display() ); return Ok(1); } }; let Some(table) = parsed.as_table() else { eprintln!( "config file at {} is invalid TOML: top-level config must be a table.", path.display() ); return Ok(1); }; if !table.contains_key("keys") { println!( "No [keys] config found in {}. Built-in v2 keybindings already apply.", path.display() ); return Ok(0); } let (updated, removed) = crate::config::remove_keybinding_config_sections(&content); if !removed { eprintln!( "could not safely remove keybinding config from {} without rewriting comments; edit the file manually or remove the top-level keys setting.", path.display() ); return Ok(1); } if let Err(err) = updated.parse::() { eprintln!( "removing keybinding config would make {} invalid TOML: {err}; leaving config unchanged", path.display() ); return Ok(1); } let backup_path = key_config_backup_path(&path); std::fs::copy(&path, &backup_path)?; std::fs::write(&path, updated)?; println!("Created backup: {}", backup_path.display()); println!( "Removed [keys], [keys.indexed], and [[keys.command]] from {}.", path.display() ); println!("Built-in v2 keybindings will apply after Herdr restarts or reloads config."); println!("If a Herdr server is running, run `herdr server reload-config` to apply this now."); println!( "To restore: cp {} {}", backup_path.display(), path.display() ); Ok(0) } fn key_config_backup_path(path: &std::path::Path) -> std::path::PathBuf { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0); let file_name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("config.toml"); path.with_file_name(format!("{file_name}.bak-keybind-v2-{timestamp}")) } fn run_terminal_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_terminal_help(); return Ok(2); }; match subcommand { "attach" => terminal_attach(&args[1..]), "help" | "--help" | "-h" => { print_terminal_help(); Ok(0) } _ => { print_terminal_help(); Ok(2) } } } fn run_wait_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_wait_help(); return Ok(2); }; match subcommand { "output" => wait_output(&args[1..]), "agent-status" => wait_agent_status(&args[1..]), "help" | "--help" | "-h" => { print_wait_help(); Ok(0) } _ => { print_wait_help(); Ok(2) } } } fn run_session_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_session_help(); return Ok(2); }; match subcommand { "list" => session_list(&args[1..]), "attach" => session_attach_help(&args[1..]), "stop" => session_stop(&args[1..]), "delete" => session_delete(&args[1..]), "help" | "--help" | "-h" => { print_session_help(); Ok(0) } _ => { print_session_help(); Ok(2) } } } fn session_attach_help(args: &[String]) -> std::io::Result { if matches!( args.first().map(String::as_str), Some("help" | "--help" | "-h") ) { eprintln!("usage: herdr session attach "); return Ok(0); } eprintln!("usage: herdr session attach "); Ok(2) } fn session_list(args: &[String]) -> std::io::Result { let json = match parse_session_json_only(args, "usage: herdr session list [--json]") { Ok(json) => json, Err(code) => return Ok(code), }; let sessions = crate::session::list_sessions()?; if json { _print_json(&serde_json::json!({ "sessions": sessions, })); } else { print_session_table(&sessions); } Ok(0) } fn session_stop(args: &[String]) -> std::io::Result { let (name, json) = match parse_session_name_and_json(args, "usage: herdr session stop [--json]") { Ok(parsed) => parsed, Err(code) => return Ok(code), }; let target = match crate::session::parse_target_name(&name) { Ok(target) => target, Err(message) => { print_session_error("invalid_session_name", &message); return Ok(1); } }; match crate::session::stop_session(target.as_deref()) { Ok(session) => { if json { _print_json(&serde_json::json!({ "stopped": true, "session": session, })); } else { println!("stopped session {}", session.name); } Ok(0) } Err(message) => { print_session_error("session_stop_failed", &message); Ok(1) } } } fn session_delete(args: &[String]) -> std::io::Result { let (name, json) = match parse_session_name_and_json(args, "usage: herdr session delete [--json]") { Ok(parsed) => parsed, Err(code) => return Ok(code), }; match crate::session::delete_session(&name) { Ok(session) => { if json { _print_json(&serde_json::json!({ "deleted": true, "session": session, })); } else { println!("deleted session {}", session.name); } Ok(0) } Err(message) => { print_session_error("session_delete_failed", &message); Ok(1) } } } fn terminal_attach(args: &[String]) -> std::io::Result { let (terminal_id, takeover) = match parse_attach_target( args, "usage: herdr terminal attach [--takeover]", ) { Ok(parsed) => parsed, Err(code) => return Ok(code), }; crate::client::run_terminal_attach(terminal_id, takeover)?; Ok(0) } pub(super) fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i32> { let Some(target) = args.first() else { eprintln!("{usage}"); return Err(2); }; let mut takeover = false; for arg in &args[1..] { match arg.as_str() { "--takeover" => takeover = true, "help" | "--help" | "-h" => { eprintln!("{usage}"); return Err(0); } other => { eprintln!("unknown option: {other}"); return Err(2); } } } Ok((target.clone(), takeover)) } fn wait_output(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { eprintln!("usage: herdr wait output --match [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]"); return Ok(2); }; let pane_id = normalize_pane_id(raw_pane_id); let mut source = ReadSource::Recent; let mut lines = None; let mut timeout_ms = None; let mut strip_ansi = true; let mut regex = false; let mut match_value = None; let mut index = 1; while index < args.len() { match args[index].as_str() { "--match" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --match"); return Ok(2); }; match_value = Some(value.clone()); index += 2; } "--source" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --source"); return Ok(2); }; source = parse_read_source(value)?; index += 2; } "--lines" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --lines"); return Ok(2); }; lines = Some(parse_u32_flag("--lines", value)?); index += 2; } "--timeout" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --timeout"); return Ok(2); }; timeout_ms = Some(parse_u64_flag("--timeout", value)?); index += 2; } "--regex" => { regex = true; index += 1; } "--raw" => { strip_ansi = false; index += 1; } other => { eprintln!("unknown option: {other}"); return Ok(2); } } } let Some(match_value) = match_value else { eprintln!("missing required --match"); return Ok(2); }; let matcher = if regex { OutputMatch::Regex { value: match_value } } else { OutputMatch::Substring { value: match_value } }; let response = send_request(&Request { id: "cli:wait:output".into(), method: Method::PaneWaitForOutput(PaneWaitForOutputParams { pane_id, source, lines, r#match: matcher, timeout_ms, strip_ansi, }), })?; if response.get("error").is_some() { eprintln!("{}", serde_json::to_string(&response).unwrap()); return Ok(1); } println!("{}", serde_json::to_string(&response).unwrap()); Ok(0) } fn wait_agent_status(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { eprintln!("usage: herdr wait agent-status --status [--timeout MS]"); return Ok(2); }; let pane_id = normalize_pane_id(raw_pane_id); let mut timeout_ms = None; let mut desired_status = None; let mut index = 1; while index < args.len() { match args[index].as_str() { "--status" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --status"); return Ok(2); }; desired_status = Some(parse_agent_status(value)?); index += 2; } "--timeout" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --timeout"); return Ok(2); }; timeout_ms = Some(parse_u64_flag("--timeout", value)?); index += 2; } other => { eprintln!("unknown option: {other}"); return Ok(2); } } } let Some(agent_status) = desired_status else { eprintln!("missing required --status"); return Ok(2); }; wait_for_agent_change( Request { id: "cli:wait:agent-status".into(), method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams { subscriptions: vec![Subscription::PaneAgentStatusChanged { pane_id, agent_status: Some(agent_status), }], }), }, timeout_ms, "timed out waiting for agent status change", ) } pub(super) fn wait_for_agent_change( request: Request, timeout_ms: Option, timeout_message: &str, ) -> std::io::Result { let read_timeout = timeout_ms.map(Duration::from_millis); let (ack, mut stream) = ApiClient::local() .subscribe_value(&request, read_timeout) .map_err(api_client_error_to_io)?; if let Err(err) = crate::api::client::parse_response_value(ack) { if let ApiClientError::ErrorResponse(response) = err { eprintln!("{}", serde_json::to_string(&response).unwrap()); return Ok(1); } return Err(api_client_error_to_io(err)); } match stream.next_event() { Ok(None) => { eprintln!("subscription closed before event arrived"); Ok(1) } Ok(Some(event_value)) => { println!("{}", serde_json::to_string(&event_value).unwrap()); Ok(0) } Err(ApiClientError::Io(err)) if api_timeout_error(&err) => { eprintln!("{timeout_message}"); Ok(1) } Err(err) => Err(api_client_error_to_io(err)), } } pub(super) fn print_response(response: &serde_json::Value) -> std::io::Result { if response.get("error").is_some() { eprintln!("{}", serde_json::to_string(response).unwrap()); return Ok(1); } println!("{}", serde_json::to_string(response).unwrap()); Ok(0) } pub(super) fn send_ok_request(method: Method) -> std::io::Result { let response = send_request(&Request { id: "cli:request".into(), method, })?; if response.get("error").is_some() { eprintln!("{}", serde_json::to_string(&response).unwrap()); return Ok(1); } Ok(0) } pub(super) fn send_request(request: &Request) -> std::io::Result { ApiClient::local() .request_value(request) .map_err(api_client_error_to_io) } fn api_timeout_error(err: &std::io::Error) -> bool { matches!( err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock ) } fn api_client_error_to_io(err: ApiClientError) -> std::io::Error { match err { ApiClientError::Io(err) => err, err => std::io::Error::other(err), } } pub(super) fn normalize_workspace_id(value: &str) -> String { value.to_string() } pub(super) fn normalize_tab_id(value: &str) -> String { value.to_string() } pub(super) fn normalize_pane_id(value: &str) -> String { value.to_string() } pub(super) fn parse_split_direction(value: &str) -> std::io::Result { match value { "right" => Ok(SplitDirection::Right), "down" => Ok(SplitDirection::Down), _ => Err(std::io::Error::other(format!( "invalid split direction: {value}" ))), } } pub(super) fn parse_read_source(value: &str) -> std::io::Result { match value { "visible" => Ok(ReadSource::Visible), "recent" => Ok(ReadSource::Recent), "recent-unwrapped" | "recent_unwrapped" => Ok(ReadSource::RecentUnwrapped), _ => Err(std::io::Error::other(format!( "invalid read source: {value}" ))), } } pub(super) fn parse_read_format(value: &str) -> std::io::Result { match value { "text" => Ok(ReadFormat::Text), "ansi" => Ok(ReadFormat::Ansi), _ => Err(std::io::Error::other(format!( "invalid read format: {value}" ))), } } fn parse_agent_status(value: &str) -> std::io::Result { match value { "idle" => Ok(AgentStatus::Idle), "working" => Ok(AgentStatus::Working), "blocked" => Ok(AgentStatus::Blocked), "done" => Ok(AgentStatus::Done), "unknown" => Ok(AgentStatus::Unknown), _ => Err(std::io::Error::other(format!( "invalid agent status: {value} (expected idle, working, blocked, done, or unknown)" ))), } } pub(super) fn parse_pane_agent_state(value: &str) -> std::io::Result { match value { "idle" => Ok(PaneAgentState::Idle), "working" => Ok(PaneAgentState::Working), "blocked" => Ok(PaneAgentState::Blocked), "unknown" => Ok(PaneAgentState::Unknown), _ => Err(std::io::Error::other(format!( "invalid pane agent state: {value} (expected idle, working, blocked, or unknown)" ))), } } pub(super) fn parse_u32_flag(flag: &str, value: &str) -> std::io::Result { value .parse::() .map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}"))) } pub(super) fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result { value .parse::() .map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}"))) } fn parse_session_json_only(args: &[String], usage: &str) -> Result { match args { [] => Ok(false), [flag] if flag == "--json" => Ok(true), _ => { eprintln!("{usage}"); Err(2) } } } fn parse_session_name_and_json(args: &[String], usage: &str) -> Result<(String, bool), i32> { let mut name = None; let mut json = false; for arg in args { if arg == "--json" { json = true; } else if name.is_none() { name = Some(arg.clone()); } else { eprintln!("{usage}"); return Err(2); } } let Some(name) = name else { eprintln!("{usage}"); return Err(2); }; Ok((name, json)) } fn print_session_table(sessions: &[crate::session::SessionInfo]) { println!("{:<20} {:<8} {:<48} socket", "name", "status", "directory"); for session in sessions { println!( "{:<20} {:<8} {:<48} {}", session.name, if session.running { "running" } else { "stopped" }, session.session_dir, session.socket_path ); } } fn print_session_error(code: &str, message: &str) { eprintln!( "{}", serde_json::to_string(&serde_json::json!({ "error": { "code": code, "message": message, } })) .unwrap() ); } fn print_config_help() { eprintln!("herdr config commands:"); eprintln!(" herdr config reset-keys back up config.toml and remove custom keybindings"); } fn print_terminal_help() { eprintln!("herdr terminal commands:"); eprintln!(" herdr terminal attach [--takeover]"); eprintln!(" detach from direct attach with ctrl+b q; send literal ctrl+b with ctrl+b ctrl+b"); } fn print_wait_help() { eprintln!("herdr wait commands:"); eprintln!(" herdr wait output --match [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw]"); eprintln!( " herdr wait agent-status --status [--timeout MS]" ); } fn print_session_help() { eprintln!("herdr session commands:"); eprintln!(" herdr session list [--json]"); eprintln!(" herdr session attach "); eprintln!(" herdr session stop [--json]"); eprintln!(" herdr session delete [--json]"); eprintln!(" use 'default' as to target the default session for stop"); } fn _print_json(value: &T) { println!("{}", serde_json::to_string(value).unwrap()); }