use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; use crate::api; use crate::api::schema::{ AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus, AgentTarget, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneAgentState, PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, PingParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription, TabCreateParams, TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, }; 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) = run_server_command(&args[2..])? else { return Ok(CommandOutcome::NotCli); }; exit_code } "status" => run_status_command(&args[2..])?, "config" => run_config_command(&args[2..])?, "workspace" => run_workspace_command(&args[2..])?, "tab" => run_tab_command(&args[2..])?, "agent" => run_agent_command(&args[2..])?, "terminal" => run_terminal_command(&args[2..])?, "pane" => run_pane_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, "integration" => run_integration_command(&args[2..])?, "session" => run_session_command(&args[2..])?, _ => return Ok(CommandOutcome::NotCli), }; Ok(CommandOutcome::Handled(exit_code)) } fn run_server_command(args: &[String]) -> std::io::Result> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { return Ok(None); }; match subcommand { "stop" => server_stop(&args[1..]).map(Some), "reload-config" => server_reload_config(&args[1..]).map(Some), "help" | "--help" | "-h" => { print_server_help(); Ok(Some(0)) } _ => { print_server_help(); Ok(Some(2)) } } } fn run_status_command(args: &[String]) -> std::io::Result { match args.first().map(|arg| arg.as_str()) { None => print_full_status(), Some("server") => { if args.len() > 1 { eprintln!("usage: herdr status server"); return Ok(2); } print_server_status() } Some("client") => { if args.len() > 1 { eprintln!("usage: herdr status client"); return Ok(2); } print_client_status(); Ok(0) } Some("help" | "--help" | "-h") => { print_status_help(); Ok(0) } Some(_) => { print_status_help(); Ok(2) } } } 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}")) } #[derive(Debug, Clone, PartialEq, Eq)] enum ServerRuntimeStatus { Running { version: Option, protocol: Option, }, NotRunning, } fn print_full_status() -> std::io::Result { let server = read_server_runtime_status()?; println!("client:"); println!(" version: {}", env!("CARGO_PKG_VERSION")); println!(" protocol: {}", crate::server::protocol::PROTOCOL_VERSION); println!(); println!("server:"); print_server_status_body(&server, " "); println!(); println!("update:"); println!(" restart_needed: {}", restart_needed_label(&server)); Ok(0) } fn print_server_status() -> std::io::Result { let server = read_server_runtime_status()?; print_server_status_body(&server, ""); Ok(0) } fn print_client_status() { println!("version: {}", env!("CARGO_PKG_VERSION")); println!("protocol: {}", crate::server::protocol::PROTOCOL_VERSION); println!("binary: {}", current_exe_label()); } fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) { match server { ServerRuntimeStatus::Running { version, protocol } => { println!("{indent}status: running"); println!("{indent}version: {}", option_label(version.as_deref())); println!("{indent}protocol: {}", protocol_label(*protocol)); println!("{indent}compatible: {}", compatibility_label(*protocol)); println!("{indent}socket: {}", api::socket_path().display()); } ServerRuntimeStatus::NotRunning => { println!("{indent}status: not running"); println!("{indent}socket: {}", api::socket_path().display()); } } } fn read_server_runtime_status() -> std::io::Result { match send_request(&Request { id: "cli:status:server".into(), method: Method::Ping(PingParams::default()), }) { Ok(response) => { if response.get("error").is_some() { return Err(std::io::Error::other(format!( "server status request failed: {}", response ))); } let result = &response["result"]; Ok(ServerRuntimeStatus::Running { version: result .get("version") .and_then(|value| value.as_str()) .map(str::to_owned), protocol: result .get("protocol") .and_then(|value| value.as_u64()) .and_then(|value| u32::try_from(value).ok()), }) } Err(err) if server_not_running_error(&err) => Ok(ServerRuntimeStatus::NotRunning), Err(err) => Err(err), } } fn server_not_running_error(err: &std::io::Error) -> bool { matches!( err.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused ) } fn option_label(value: Option<&str>) -> &str { value.unwrap_or("unknown") } fn protocol_label(protocol: Option) -> String { protocol .map(|value| value.to_string()) .unwrap_or_else(|| "unknown".to_string()) } fn compatibility_label(protocol: Option) -> &'static str { match protocol { Some(protocol) if protocol == crate::server::protocol::PROTOCOL_VERSION => "yes", Some(_) => "no", None => "unknown", } } fn restart_needed_label(server: &ServerRuntimeStatus) -> &'static str { match server { ServerRuntimeStatus::Running { version, .. } => match version.as_deref() { Some(env!("CARGO_PKG_VERSION")) => "no", Some(_) => "yes", None => "unknown", }, ServerRuntimeStatus::NotRunning => "no", } } fn current_exe_label() -> String { std::env::current_exe() .map(|path| path.display().to_string()) .unwrap_or_else(|err| format!("unknown ({err})")) } fn run_workspace_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_workspace_help(); return Ok(2); }; match subcommand { "list" => workspace_list(&args[1..]), "create" => workspace_create(&args[1..]), "get" => workspace_get(&args[1..]), "focus" => workspace_focus(&args[1..]), "rename" => workspace_rename(&args[1..]), "close" => workspace_close(&args[1..]), "help" | "--help" | "-h" => { print_workspace_help(); Ok(0) } _ => { print_workspace_help(); Ok(2) } } } fn run_tab_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_tab_help(); return Ok(2); }; match subcommand { "list" => tab_list(&args[1..]), "create" => tab_create(&args[1..]), "get" => tab_get(&args[1..]), "focus" => tab_focus(&args[1..]), "rename" => tab_rename(&args[1..]), "close" => tab_close(&args[1..]), "help" | "--help" | "-h" => { print_tab_help(); Ok(0) } _ => { print_tab_help(); Ok(2) } } } fn run_agent_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_agent_help(); return Ok(2); }; match subcommand { "list" => agent_list(&args[1..]), "get" => agent_get(&args[1..]), "read" => agent_read(&args[1..]), "send" => agent_send(&args[1..]), "rename" => agent_rename(&args[1..]), "focus" => agent_focus(&args[1..]), "wait" => agent_wait(&args[1..]), "attach" => agent_attach(&args[1..]), "start" => agent_start(&args[1..]), "help" | "--help" | "-h" => { print_agent_help(); Ok(0) } _ => { print_agent_help(); Ok(2) } } } 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_pane_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_pane_help(); return Ok(2); }; match subcommand { "list" => pane_list(&args[1..]), "get" => pane_get(&args[1..]), "read" => pane_read(&args[1..]), "rename" => pane_rename(&args[1..]), "split" => pane_split(&args[1..]), "close" => pane_close(&args[1..]), "send-text" => pane_send_text(&args[1..]), "send-keys" => pane_send_keys(&args[1..]), "report-agent" => pane_report_agent(&args[1..]), "run" => pane_run(&args[1..]), "help" | "--help" | "-h" => { print_pane_help(); Ok(0) } _ => { print_pane_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_integration_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_integration_help(); return Ok(2); }; match subcommand { "install" => integration_install(&args[1..]), "uninstall" => integration_uninstall(&args[1..]), "status" => integration_status(&args[1..]), "help" | "--help" | "-h" => { print_integration_help(); Ok(0) } _ => { print_integration_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 server_stop(args: &[String]) -> std::io::Result { if !args.is_empty() { eprintln!("usage: herdr server stop"); return Ok(2); } send_ok_request(Method::ServerStop(EmptyParams::default())) } fn server_reload_config(args: &[String]) -> std::io::Result { if !args.is_empty() { eprintln!("usage: herdr server reload-config"); return Ok(2); } print_response(&send_request(&Request { id: "cli:server:reload-config".into(), method: Method::ServerReloadConfig(EmptyParams::default()), })?) } 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 workspace_list(args: &[String]) -> std::io::Result { if !args.is_empty() { eprintln!("usage: herdr workspace list"); return Ok(2); } print_response(&send_request(&Request { id: "cli:workspace:list".into(), method: Method::WorkspaceList(EmptyParams::default()), })?) } fn workspace_create(args: &[String]) -> std::io::Result { let mut cwd = None; let mut focus = false; let mut label = None; let mut index = 0; while index < args.len() { match args[index].as_str() { "--cwd" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --cwd"); return Ok(2); }; cwd = Some(value.clone()); index += 2; } "--label" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --label"); return Ok(2); }; label = Some(value.clone()); index += 2; } "--focus" => { focus = true; index += 1; } "--no-focus" => { focus = false; index += 1; } other => { eprintln!("unknown option: {other}"); return Ok(2); } } } print_response(&send_request(&Request { id: "cli:workspace:create".into(), method: Method::WorkspaceCreate(WorkspaceCreateParams { cwd, focus, label }), })?) } fn workspace_get(args: &[String]) -> std::io::Result { let Some(raw_workspace_id) = args.first() else { eprintln!("usage: herdr workspace get "); return Ok(2); }; if args.len() != 1 { eprintln!("usage: herdr workspace get "); return Ok(2); } print_response(&send_request(&Request { id: "cli:workspace:get".into(), method: Method::WorkspaceGet(WorkspaceTarget { workspace_id: normalize_workspace_id(raw_workspace_id), }), })?) } fn workspace_focus(args: &[String]) -> std::io::Result { let Some(raw_workspace_id) = args.first() else { eprintln!("usage: herdr workspace focus "); return Ok(2); }; if args.len() != 1 { eprintln!("usage: herdr workspace focus "); return Ok(2); } print_response(&send_request(&Request { id: "cli:workspace:focus".into(), method: Method::WorkspaceFocus(WorkspaceTarget { workspace_id: normalize_workspace_id(raw_workspace_id), }), })?) } fn workspace_rename(args: &[String]) -> std::io::Result { if args.len() < 2 { eprintln!("usage: herdr workspace rename