From 866aaf740e10fcfce169aaf81380ea0257fbe272 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 21 May 2026 20:19:54 +0300 Subject: refactor: separate server client boundaries --- src/cli/agent.rs | 429 +++++++++++++++++++++++++++++++++++++++++++++++++ src/cli/integration.rs | 142 ++++++++++++++++ src/cli/pane.rs | 410 ++++++++++++++++++++++++++++++++++++++++++++++ src/cli/server.rs | 48 ++++++ src/cli/status.rs | 299 ++++++++++++++++++++++++++++++++++ src/cli/tab.rs | 194 ++++++++++++++++++++++ src/cli/workspace.rs | 163 +++++++++++++++++++ 7 files changed, 1685 insertions(+) create mode 100644 src/cli/agent.rs create mode 100644 src/cli/integration.rs create mode 100644 src/cli/pane.rs create mode 100644 src/cli/server.rs create mode 100644 src/cli/status.rs create mode 100644 src/cli/tab.rs create mode 100644 src/cli/workspace.rs (limited to 'src/cli') diff --git a/src/cli/agent.rs b/src/cli/agent.rs new file mode 100644 index 00000000..5005eda1 --- /dev/null +++ b/src/cli/agent.rs @@ -0,0 +1,429 @@ +use crate::api::schema::{ + AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus, + AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, Subscription, +}; + +pub(super) 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 agent_start(args: &[String]) -> std::io::Result { + let Some(name) = args.first() else { + eprintln!("usage: herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- "); + return Ok(2); + }; + + let Some(separator) = args.iter().position(|arg| arg == "--") else { + eprintln!("usage: herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- "); + return Ok(2); + }; + if separator == args.len() - 1 { + eprintln!("agent start requires argv after --"); + return Ok(2); + } + + let mut cwd = None; + let mut workspace_id = None; + let mut tab_id = None; + let mut split = None; + let mut focus = false; + + let mut index = 1; + while index < separator { + match args[index].as_str() { + "--cwd" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(value.clone()); + index += 2; + } + "--workspace" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + "--tab" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --tab"); + return Ok(2); + }; + tab_id = Some(super::normalize_tab_id(value)); + index += 2; + } + "--split" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --split"); + return Ok(2); + }; + split = Some(super::parse_split_direction(value)?); + index += 2; + } + "--focus" => { + focus = true; + index += 1; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:start".into(), + method: Method::AgentStart(AgentStartParams { + name: name.clone(), + cwd, + workspace_id, + tab_id, + split, + focus, + argv: args[separator + 1..].to_vec(), + }), + })?) +} + +fn agent_list(args: &[String]) -> std::io::Result { + if !args.is_empty() { + eprintln!("usage: herdr agent list"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:list".into(), + method: Method::AgentList(EmptyParams::default()), + })?) +} + +fn agent_get(args: &[String]) -> std::io::Result { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent get "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr agent get "); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:get".into(), + method: Method::AgentGet(AgentTarget { + target: target.clone(), + }), + })?) +} + +fn agent_focus(args: &[String]) -> std::io::Result { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent focus "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr agent focus "); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:focus".into(), + method: Method::AgentFocus(AgentTarget { + target: target.clone(), + }), + })?) +} + +fn agent_attach(args: &[String]) -> std::io::Result { + let (target, takeover) = + match super::parse_attach_target(args, "usage: herdr agent attach [--takeover]") { + Ok(parsed) => parsed, + Err(code) => return Ok(code), + }; + + let response = resolve_agent_target(&target, "cli:agent:attach:resolve")?; + if response.get("error").is_some() { + eprintln!("{}", serde_json::to_string(&response).unwrap()); + return Ok(1); + } + let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { + eprintln!("agent attach failed: response did not include terminal_id"); + return Ok(1); + }; + crate::client::run_terminal_attach(terminal_id.to_owned(), takeover)?; + Ok(0) +} + +fn agent_wait(args: &[String]) -> std::io::Result { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent wait --status [--timeout MS]"); + return Ok(2); + }; + + 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_wait_status(value)?); + index += 2; + } + "--timeout" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --timeout"); + return Ok(2); + }; + timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); + index += 2; + } + "help" | "--help" | "-h" => { + eprintln!("usage: herdr agent wait --status [--timeout MS]"); + return Ok(0); + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(agent_status) = desired_status else { + eprintln!("missing required --status"); + return Ok(2); + }; + + let response = resolve_agent_target(target, "cli:agent:wait:resolve")?; + if response.get("error").is_some() { + eprintln!("{}", serde_json::to_string(&response).unwrap()); + return Ok(1); + } + if response["result"]["agent"]["agent_status"] + .as_str() + .is_some_and(|current| agent_wait_status_satisfied(agent_status, current)) + { + println!("{}", serde_json::to_string(&response).unwrap()); + return Ok(0); + } + + let Some(pane_id) = response["result"]["agent"]["pane_id"].as_str() else { + eprintln!("agent wait failed: response did not include pane_id"); + return Ok(1); + }; + + let subscriptions = if agent_status == AgentStatus::Idle { + vec![ + Subscription::PaneAgentStatusChanged { + pane_id: pane_id.to_owned(), + agent_status: Some(AgentStatus::Idle), + }, + Subscription::PaneAgentStatusChanged { + pane_id: pane_id.to_owned(), + agent_status: Some(AgentStatus::Done), + }, + ] + } else { + vec![Subscription::PaneAgentStatusChanged { + pane_id: pane_id.to_owned(), + agent_status: Some(agent_status), + }] + }; + + super::wait_for_agent_change( + Request { + id: "cli:agent:wait".into(), + method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams { + subscriptions, + }), + }, + timeout_ms, + "timed out waiting for agent status change", + ) +} + +fn resolve_agent_target(target: &str, request_id: &str) -> std::io::Result { + super::send_request(&Request { + id: request_id.into(), + method: Method::AgentGet(AgentTarget { + target: target.to_owned(), + }), + }) +} + +fn agent_rename(args: &[String]) -> std::io::Result { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent rename |--clear"); + return Ok(2); + }; + if args.len() < 2 { + eprintln!("usage: herdr agent rename |--clear"); + return Ok(2); + } + let name = if args.len() == 2 && args[1] == "--clear" { + None + } else { + Some(args[1..].join(" ")) + }; + + super::print_response(&super::send_request(&Request { + id: "cli:agent:rename".into(), + method: Method::AgentRename(AgentRenameParams { + target: target.clone(), + name, + }), + })?) +} + +fn agent_send(args: &[String]) -> std::io::Result { + if args.len() < 2 { + eprintln!("usage: herdr agent send "); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:send".into(), + method: Method::AgentSend(AgentSendParams { + target: args[0].clone(), + text: args[1..].join(" "), + }), + })?) +} + +fn agent_read(args: &[String]) -> std::io::Result { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent read [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); + return Ok(2); + }; + + let mut source = ReadSource::Recent; + let mut lines = None; + let mut format = ReadFormat::Text; + let mut strip_ansi = true; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = super::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(super::parse_u32_flag("--lines", value)?); + index += 2; + } + "--format" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --format"); + return Ok(2); + }; + format = super::parse_read_format(value)?; + strip_ansi = !matches!(format, ReadFormat::Ansi); + index += 2; + } + "--ansi" => { + format = ReadFormat::Ansi; + strip_ansi = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:read".into(), + method: Method::AgentRead(AgentReadParams { + target: target.clone(), + source, + lines, + format, + strip_ansi, + }), + })?) +} + +fn agent_wait_status_satisfied(desired: AgentStatus, current: &str) -> bool { + match desired { + AgentStatus::Idle => matches!(current, "idle" | "done"), + AgentStatus::Working => current == "working", + AgentStatus::Blocked => current == "blocked", + AgentStatus::Unknown => current == "unknown", + AgentStatus::Done => false, + } +} + +fn parse_agent_wait_status(value: &str) -> std::io::Result { + match value { + "idle" => Ok(AgentStatus::Idle), + "working" => Ok(AgentStatus::Working), + "blocked" => Ok(AgentStatus::Blocked), + "unknown" => Ok(AgentStatus::Unknown), + "done" => Err(std::io::Error::other( + "done is a UI attention state; use idle for CLI agent completion waits", + )), + _ => Err(std::io::Error::other(format!( + "invalid agent status: {value} (expected idle, working, blocked, or unknown)" + ))), + } +} + +fn print_agent_help() { + eprintln!("herdr agent commands:"); + eprintln!(" herdr agent list"); + eprintln!(" herdr agent get "); + eprintln!(" herdr agent read [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); + eprintln!(" herdr agent send "); + eprintln!(" herdr agent rename |--clear"); + eprintln!(" herdr agent focus "); + eprintln!(" herdr agent wait --status [--timeout MS]"); + eprintln!(" herdr agent attach [--takeover]"); + eprintln!(" herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- "); + eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids"); + eprintln!( + " agent send writes literal text; use pane run when you want command text plus Enter" + ); +} diff --git a/src/cli/integration.rs b/src/cli/integration.rs new file mode 100644 index 00000000..6239faf2 --- /dev/null +++ b/src/cli/integration.rs @@ -0,0 +1,142 @@ +use crate::api::schema::IntegrationTarget; + +pub(super) 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 integration_status(args: &[String]) -> std::io::Result { + let outdated_only = match args { + [] => false, + [flag] if flag == "--outdated-only" => true, + _ => { + eprintln!("usage: herdr integration status [--outdated-only]"); + return Ok(2); + } + }; + + if outdated_only { + crate::integration::print_outdated_update_notice(); + return Ok(0); + } + + for status in crate::integration::installed_integration_statuses() { + let target = crate::integration::integration_target_label(status.target); + let version = match status.installed_version { + Some(version) => format!("v{version}"), + None => "legacy".to_string(), + }; + let state = match status.state { + crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(), + crate::integration::IntegrationStatusKind::Current => { + format!("current ({version})") + } + crate::integration::IntegrationStatusKind::Outdated => { + format!("outdated ({version} < v{})", status.expected_version) + } + }; + println!("{target}: {state} ({})", status.path.display()); + } + + Ok(0) +} + +fn integration_install(args: &[String]) -> std::io::Result { + let Some(target) = parse_integration_target(args, "install")? else { + return Ok(2); + }; + + match crate::integration::install_target(target) { + Ok(messages) => { + print_integration_messages(messages); + Ok(0) + } + Err(err) => { + eprintln!("{err}"); + Ok(1) + } + } +} + +fn integration_uninstall(args: &[String]) -> std::io::Result { + let Some(target) = parse_integration_target(args, "uninstall")? else { + return Ok(2); + }; + + match crate::integration::uninstall_target(target) { + Ok(messages) => { + print_integration_messages(messages); + Ok(0) + } + Err(err) => { + eprintln!("{err}"); + Ok(1) + } + } +} + +fn print_integration_messages(messages: Vec) { + for message in messages { + println!("{message}"); + } +} + +fn parse_integration_target( + args: &[String], + action: &str, +) -> std::io::Result> { + let Some(target) = args.first().map(|arg| arg.as_str()) else { + eprintln!("usage: herdr integration {action} "); + return Ok(None); + }; + if args.len() != 1 { + eprintln!("usage: herdr integration {action} "); + return Ok(None); + } + + let parsed = match target { + "pi" => IntegrationTarget::Pi, + "claude" => IntegrationTarget::Claude, + "codex" => IntegrationTarget::Codex, + "opencode" => IntegrationTarget::Opencode, + "hermes" => IntegrationTarget::Hermes, + _ => { + eprintln!("unknown integration target: {target}"); + eprintln!("currently supported: pi, claude, codex, opencode, hermes"); + return Ok(None); + } + }; + + Ok(Some(parsed)) +} + +fn print_integration_help() { + eprintln!("herdr integration commands:"); + eprintln!(" herdr integration install pi"); + eprintln!(" herdr integration install claude"); + eprintln!(" herdr integration install codex"); + eprintln!(" herdr integration install opencode"); + eprintln!(" herdr integration install hermes"); + eprintln!(" herdr integration uninstall pi"); + eprintln!(" herdr integration uninstall claude"); + eprintln!(" herdr integration uninstall codex"); + eprintln!(" herdr integration uninstall opencode"); + eprintln!(" herdr integration uninstall hermes"); + eprintln!(" herdr integration status [--outdated-only]"); +} diff --git a/src/cli/pane.rs b/src/cli/pane.rs new file mode 100644 index 00000000..58d65af6 --- /dev/null +++ b/src/cli/pane.rs @@ -0,0 +1,410 @@ +use crate::api::schema::{ + Method, PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, + PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, + ReadFormat, ReadSource, Request, +}; + +pub(super) 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 pane_list(args: &[String]) -> std::io::Result { + let mut workspace_id = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:pane:list".into(), + method: Method::PaneList(PaneListParams { workspace_id }), + })?) +} + +fn pane_get(args: &[String]) -> std::io::Result { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane get "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr pane get "); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:pane:get".into(), + method: Method::PaneGet(PaneTarget { + pane_id: super::normalize_pane_id(raw_pane_id), + }), + })?) +} + +fn pane_rename(args: &[String]) -> std::io::Result { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane rename