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; mod worktree; 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..])?, "channel" => run_channel_command(&args[2..])?, "workspace" => workspace::run_workspace_command(&args[2..])?, "worktree" => worktree::run_worktree_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_channel_command(args: &[String]) -> std::io::Result { match args.first().map(|arg| arg.as_str()) { Some("set") => channel_set(&args[1..]), Some("show") if args.len() == 1 => { let config = crate::config::Config::load().config; println!("{}", config.update.channel.as_str()); Ok(0) } Some("help" | "--help" | "-h") => { print_channel_help(); Ok(0) } _ => { print_channel_help(); Ok(2) } } } fn channel_set(args: &[String]) -> std::io::Result { let Some(channel) = parse_channel_set_arg(args) else { eprintln!("usage: herdr channel set "); return Ok(2); }; if let Some(reason) = channel_set_preview_rejection( channel, crate::update::preview_channel_rejection_for_current_install(), ) { eprintln!("{reason}."); return Ok(1); } let path = crate::config::config_path(); let content = if path.exists() { std::fs::read_to_string(&path)? } else { String::new() }; if let Err(err) = content.parse::() { eprintln!( "config file at {} is invalid TOML: {err}. Fix it before changing the update channel.", path.display() ); return Ok(1); } let updated = crate::config::upsert_section_value( &content, "update", "channel", &format!("\"{channel}\""), ); if let Err(err) = updated.parse::() { eprintln!( "changing the update channel would make {} invalid TOML: {err}; leaving config unchanged", path.display() ); return Ok(1); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&path, updated)?; println!( "Herdr update channel set to {channel} in {}.", path.display() ); match channel_set_install_action( crate::update::package_manager_channel_update_guidance_for_current_install(), ) { ChannelSetInstallAction::PrintGuidance(guidance) => { println!("{guidance}"); return Ok(0); } ChannelSetInstallAction::RunSelfUpdate => {} } if let Err(err) = crate::update::self_update(crate::update::SelfUpdateOptions::default()) { eprintln!("update failed: {err}"); eprintln!("Run `herdr update` to retry."); return Ok(1); } Ok(0) } fn parse_channel_set_arg(args: &[String]) -> Option<&str> { let channel = args.first().map(|arg| arg.as_str())?; if args.len() == 1 && matches!(channel, "stable" | "preview") { Some(channel) } else { None } } fn channel_set_preview_rejection( channel: &str, install_rejection: Option<&'static str>, ) -> Option<&'static str> { (channel == "preview") .then_some(install_rejection) .flatten() } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChannelSetInstallAction { RunSelfUpdate, PrintGuidance(&'static str), } fn channel_set_install_action( package_manager_guidance: Option<&'static str>, ) -> ChannelSetInstallAction { match package_manager_guidance { Some(guidance) => ChannelSetInstallAction::PrintGuidance(guidance), None => ChannelSetInstallAction::RunSelfUpdate, } } fn print_channel_help() { eprintln!("herdr channel commands:"); eprintln!(" herdr channel show print the configured update channel"); eprintln!(" herdr channel set choose the update channel"); } 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()); } #[cfg(test)] mod tests { #[test] fn parses_channel_set_argument() { assert_eq!( super::parse_channel_set_arg(&["preview".to_string()]), Some("preview") ); assert_eq!( super::parse_channel_set_arg(&["stable".to_string()]), Some("stable") ); assert_eq!(super::parse_channel_set_arg(&["nightly".to_string()]), None); assert_eq!( super::parse_channel_set_arg(&["preview".to_string(), "stable".to_string()]), None ); } #[test] fn channel_set_rejects_package_managed_preview_before_config_write() { assert_eq!( super::channel_set_preview_rejection("preview", Some("no preview")), Some("no preview") ); assert_eq!( super::channel_set_preview_rejection("stable", Some("no preview")), None ); assert_eq!(super::channel_set_preview_rejection("preview", None), None); } #[test] fn channel_set_skips_self_update_for_package_manager_guidance() { assert_eq!( super::channel_set_install_action(Some("use package manager")), super::ChannelSetInstallAction::PrintGuidance("use package manager") ); assert_eq!( super::channel_set_install_action(None), super::ChannelSetInstallAction::RunSelfUpdate ); } }