aboutsummaryrefslogtreecommitdiff
path: root/src/cli
diff options
context:
space:
mode:
Diffstat (limited to 'src/cli')
-rw-r--r--src/cli/agent.rs429
-rw-r--r--src/cli/integration.rs142
-rw-r--r--src/cli/pane.rs410
-rw-r--r--src/cli/server.rs48
-rw-r--r--src/cli/status.rs299
-rw-r--r--src/cli/tab.rs194
-rw-r--r--src/cli/workspace.rs163
7 files changed, 1685 insertions, 0 deletions
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<i32> {
+ 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<i32> {
+ let Some(name) = args.first() else {
+ eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
+ return Ok(2);
+ };
+
+ let Some(separator) = args.iter().position(|arg| arg == "--") else {
+ eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
+ 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<i32> {
+ 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<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent get <target>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr agent get <target>");
+ 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<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent focus <target>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr agent focus <target>");
+ 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<i32> {
+ let (target, takeover) =
+ match super::parse_attach_target(args, "usage: herdr agent attach <target> [--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<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent wait <target> --status <idle|working|blocked|unknown> [--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 <target> --status <idle|working|blocked|unknown> [--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<serde_json::Value> {
+ super::send_request(&Request {
+ id: request_id.into(),
+ method: Method::AgentGet(AgentTarget {
+ target: target.to_owned(),
+ }),
+ })
+}
+
+fn agent_rename(args: &[String]) -> std::io::Result<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent rename <target> <name>|--clear");
+ return Ok(2);
+ };
+ if args.len() < 2 {
+ eprintln!("usage: herdr agent rename <target> <name>|--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<i32> {
+ if args.len() < 2 {
+ eprintln!("usage: herdr agent send <target> <text>");
+ 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<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent read <target> [--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<AgentStatus> {
+ 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 <target>");
+ eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
+ eprintln!(" herdr agent send <target> <text>");
+ eprintln!(" herdr agent rename <target> <name>|--clear");
+ eprintln!(" herdr agent focus <target>");
+ eprintln!(" herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]");
+ eprintln!(" herdr agent attach <target> [--takeover]");
+ eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
+ 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<i32> {
+ 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<i32> {
+ 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<i32> {
+ 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<i32> {
+ 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<String>) {
+ for message in messages {
+ println!("{message}");
+ }
+}
+
+fn parse_integration_target(
+ args: &[String],
+ action: &str,
+) -> std::io::Result<Option<IntegrationTarget>> {
+ let Some(target) = args.first().map(|arg| arg.as_str()) else {
+ eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>");
+ return Ok(None);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>");
+ 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<i32> {
+ 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<i32> {
+ 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<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!("usage: herdr pane get <pane_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr pane get <pane_id>");
+ 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<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!("usage: herdr pane rename <pane_id> <label>|--clear");
+ return Ok(2);
+ };
+ if args.len() < 2 {
+ eprintln!("usage: herdr pane rename <pane_id> <label>|--clear");
+ return Ok(2);
+ }
+ let label = if args.len() == 2 && args[1] == "--clear" {
+ None
+ } else {
+ Some(args[1..].join(" "))
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:rename".into(),
+ method: Method::PaneRename(PaneRenameParams {
+ pane_id: super::normalize_pane_id(raw_pane_id),
+ label,
+ }),
+ })?)
+}
+
+fn pane_read(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!("usage: herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
+ return Ok(2);
+ };
+
+ let pane_id = super::normalize_pane_id(raw_pane_id);
+ 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)?;
+ index += 2;
+ }
+ "--ansi" => {
+ format = ReadFormat::Ansi;
+ index += 1;
+ }
+ "--raw" => {
+ format = ReadFormat::Ansi;
+ strip_ansi = false;
+ index += 1;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ let response = super::send_request(&Request {
+ id: "cli:pane:read".into(),
+ method: Method::PaneRead(PaneReadParams {
+ pane_id,
+ source,
+ lines,
+ format,
+ strip_ansi,
+ }),
+ })?;
+
+ if let Some(error) = response.get("error") {
+ eprintln!("{}", serde_json::to_string(error).unwrap());
+ return Ok(1);
+ }
+
+ if let Some(text) = response["result"]["read"]["text"].as_str() {
+ print!("{text}");
+ }
+ Ok(0)
+}
+
+fn pane_split(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!(
+ "usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]"
+ );
+ return Ok(2);
+ };
+
+ let pane_id = super::normalize_pane_id(raw_pane_id);
+ let mut direction = None;
+ let mut cwd = None;
+ let mut focus = false;
+
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--direction" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --direction");
+ return Ok(2);
+ };
+ direction = Some(super::parse_split_direction(value)?);
+ index += 2;
+ }
+ "--cwd" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --cwd");
+ return Ok(2);
+ };
+ cwd = Some(value.clone());
+ index += 2;
+ }
+ "--focus" => {
+ focus = true;
+ index += 1;
+ }
+ "--no-focus" => {
+ focus = false;
+ index += 1;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ let Some(direction) = direction else {
+ eprintln!("missing required --direction");
+ return Ok(2);
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:split".into(),
+ method: Method::PaneSplit(PaneSplitParams {
+ workspace_id: None,
+ target_pane_id: pane_id,
+ direction,
+ cwd,
+ focus,
+ }),
+ })?)
+}
+
+fn pane_close(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!("usage: herdr pane close <pane_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr pane close <pane_id>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:close".into(),
+ method: Method::PaneClose(PaneTarget {
+ pane_id: super::normalize_pane_id(raw_pane_id),
+ }),
+ })?)
+}
+
+fn pane_send_text(args: &[String]) -> std::io::Result<i32> {
+ if args.len() < 2 {
+ eprintln!("usage: herdr pane send-text <pane_id> <text>");
+ return Ok(2);
+ }
+
+ let pane_id = super::normalize_pane_id(&args[0]);
+ let text = args[1..].join(" ");
+ super::send_ok_request(Method::PaneSendText(PaneSendTextParams { pane_id, text }))
+}
+
+fn pane_send_keys(args: &[String]) -> std::io::Result<i32> {
+ if args.len() < 2 {
+ eprintln!("usage: herdr pane send-keys <pane_id> <key> [key ...]");
+ return Ok(2);
+ }
+
+ let pane_id = super::normalize_pane_id(&args[0]);
+ let keys = args[1..].to_vec();
+ super::send_ok_request(Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys }))
+}
+
+fn pane_run(args: &[String]) -> std::io::Result<i32> {
+ if args.len() < 2 {
+ eprintln!("usage: herdr pane run <pane_id> <command>");
+ return Ok(2);
+ }
+
+ let pane_id = super::normalize_pane_id(&args[0]);
+ let text = args[1..].join(" ");
+ super::send_ok_request(Method::PaneSendInput(PaneSendInputParams {
+ pane_id,
+ text,
+ keys: vec!["Enter".into()],
+ }))
+}
+
+fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]");
+ return Ok(2);
+ };
+
+ let pane_id = super::normalize_pane_id(raw_pane_id);
+ let mut source = None;
+ let mut agent = None;
+ let mut state = None;
+ let mut message = None;
+ let mut custom_status = None;
+ let mut seq = None;
+
+ 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 = Some(value.clone());
+ index += 2;
+ }
+ "--agent" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --agent");
+ return Ok(2);
+ };
+ agent = Some(value.clone());
+ index += 2;
+ }
+ "--state" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --state");
+ return Ok(2);
+ };
+ state = Some(super::parse_pane_agent_state(value)?);
+ index += 2;
+ }
+ "--message" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --message");
+ return Ok(2);
+ };
+ message = Some(value.clone());
+ index += 2;
+ }
+ "--custom-status" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --custom-status");
+ return Ok(2);
+ };
+ custom_status = Some(value.clone());
+ index += 2;
+ }
+ "--seq" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --seq");
+ return Ok(2);
+ };
+ seq = Some(super::parse_u64_flag("--seq", value)?);
+ index += 2;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ let Some(source) = source else {
+ eprintln!("missing required --source");
+ return Ok(2);
+ };
+ let Some(agent) = agent else {
+ eprintln!("missing required --agent");
+ return Ok(2);
+ };
+ let Some(state) = state else {
+ eprintln!("missing required --state");
+ return Ok(2);
+ };
+
+ super::send_ok_request(Method::PaneReportAgent(PaneReportAgentParams {
+ pane_id,
+ source,
+ agent,
+ state,
+ message,
+ custom_status,
+ seq,
+ }))
+}
+
+fn print_pane_help() {
+ eprintln!("herdr pane commands:");
+ eprintln!(" herdr pane list [--workspace <workspace_id>]");
+ eprintln!(" herdr pane get <pane_id>");
+ eprintln!(" herdr pane rename <pane_id> <label>|--clear");
+ eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
+ eprintln!(
+ " herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]"
+ );
+ eprintln!(" herdr pane close <pane_id>");
+ eprintln!(" herdr pane send-text <pane_id> <text>");
+ eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]");
+ eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]");
+ eprintln!(" herdr pane run <pane_id> <command>");
+}
diff --git a/src/cli/server.rs b/src/cli/server.rs
new file mode 100644
index 00000000..311cc31a
--- /dev/null
+++ b/src/cli/server.rs
@@ -0,0 +1,48 @@
+use crate::api::schema::{EmptyParams, Method, Request};
+
+pub(super) fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> {
+ 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 server_stop(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr server stop");
+ return Ok(2);
+ }
+
+ super::send_ok_request(Method::ServerStop(EmptyParams::default()))
+}
+
+fn server_reload_config(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr server reload-config");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:server:reload-config".into(),
+ method: Method::ServerReloadConfig(EmptyParams::default()),
+ })?)
+}
+
+fn print_server_help() {
+ eprintln!("herdr server commands:");
+ eprintln!(" herdr server run as headless server");
+ eprintln!(" herdr server stop stop the running server via the API socket");
+ eprintln!(" herdr server reload-config reload config.toml in the running server");
+}
diff --git a/src/cli/status.rs b/src/cli/status.rs
new file mode 100644
index 00000000..9b8a1648
--- /dev/null
+++ b/src/cli/status.rs
@@ -0,0 +1,299 @@
+use serde::Serialize;
+
+use crate::api;
+use crate::api::client::{ApiClient, ApiClientError};
+
+pub(super) fn run_status_command(args: &[String]) -> std::io::Result<i32> {
+ let Some((scope, json)) = parse_status_args(args) else {
+ return Ok(2);
+ };
+
+ match scope {
+ StatusScope::Full => print_full_status(json),
+ StatusScope::Server => print_server_status(json),
+ StatusScope::Client => {
+ print_client_status(json)?;
+ Ok(0)
+ }
+ StatusScope::Help => {
+ print_status_help();
+ Ok(0)
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum StatusScope {
+ Full,
+ Server,
+ Client,
+ Help,
+}
+
+fn parse_status_args(args: &[String]) -> Option<(StatusScope, bool)> {
+ match args.first().map(|arg| arg.as_str()) {
+ None => Some((StatusScope::Full, false)),
+ Some("--json") if args.len() == 1 => Some((StatusScope::Full, true)),
+ Some("server") => {
+ parse_status_scope_args(args, StatusScope::Server, "herdr status server [--json]")
+ }
+ Some("client") => {
+ parse_status_scope_args(args, StatusScope::Client, "herdr status client [--json]")
+ }
+ Some("help" | "--help" | "-h") => {
+ if args.len() > 1 {
+ print_status_help();
+ return None;
+ }
+ Some((StatusScope::Help, false))
+ }
+ Some(_) => {
+ print_status_help();
+ None
+ }
+ }
+}
+
+fn parse_status_scope_args(
+ args: &[String],
+ scope: StatusScope,
+ usage: &str,
+) -> Option<(StatusScope, bool)> {
+ match args.get(1).map(|arg| arg.as_str()) {
+ None => Some((scope, false)),
+ Some("--json") if args.len() == 2 => Some((scope, true)),
+ _ => {
+ eprintln!("usage: {usage}");
+ None
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum ServerRuntimeStatus {
+ Running {
+ version: Option<String>,
+ protocol: Option<u32>,
+ },
+ NotRunning,
+}
+
+fn print_full_status(json: bool) -> std::io::Result<i32> {
+ let server = read_server_runtime_status()?;
+
+ if json {
+ print_json(&FullStatusJson {
+ client: client_status_json(),
+ server: server_status_json(&server),
+ update: update_status_json(&server),
+ })?;
+ return Ok(0);
+ }
+
+ println!("client:");
+ println!(" version: {}", env!("CARGO_PKG_VERSION"));
+ println!(" protocol: {}", crate::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(json: bool) -> std::io::Result<i32> {
+ let server = read_server_runtime_status()?;
+ if json {
+ print_json(&server_status_json(&server))?;
+ return Ok(0);
+ }
+ print_server_status_body(&server, "");
+ Ok(0)
+}
+
+fn print_client_status(json: bool) -> std::io::Result<()> {
+ if json {
+ print_json(&client_status_json())?;
+ return Ok(());
+ }
+
+ println!("version: {}", env!("CARGO_PKG_VERSION"));
+ println!("protocol: {}", crate::protocol::PROTOCOL_VERSION);
+ println!("binary: {}", current_exe_label());
+ Ok(())
+}
+
+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<ServerRuntimeStatus> {
+ match ApiClient::local().status() {
+ Ok(status) => Ok(ServerRuntimeStatus::Running {
+ version: status.version,
+ protocol: status.protocol,
+ }),
+ Err(ApiClientError::Io(err)) if server_not_running_error(&err) => {
+ Ok(ServerRuntimeStatus::NotRunning)
+ }
+ Err(err) => Err(api_client_error_to_io(err)),
+ }
+}
+
+fn server_not_running_error(err: &std::io::Error) -> bool {
+ matches!(
+ err.kind(),
+ std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
+ )
+}
+
+fn api_client_error_to_io(err: ApiClientError) -> std::io::Error {
+ match err {
+ ApiClientError::Io(err) => err,
+ err => std::io::Error::other(err),
+ }
+}
+
+fn option_label(value: Option<&str>) -> &str {
+ value.unwrap_or("unknown")
+}
+
+fn protocol_label(protocol: Option<u32>) -> String {
+ protocol
+ .map(|value| value.to_string())
+ .unwrap_or_else(|| "unknown".to_string())
+}
+
+fn compatibility_label(protocol: Option<u32>) -> &'static str {
+ match protocol {
+ Some(protocol) if protocol == crate::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",
+ }
+}
+
+#[derive(Serialize)]
+struct FullStatusJson {
+ client: ClientStatusJson,
+ server: ServerStatusJson,
+ update: UpdateStatusJson,
+}
+
+#[derive(Serialize)]
+struct ClientStatusJson {
+ version: &'static str,
+ protocol: u32,
+ binary: String,
+ session: Option<String>,
+}
+
+#[derive(Serialize)]
+struct ServerStatusJson {
+ status: &'static str,
+ running: bool,
+ version: Option<String>,
+ protocol: Option<u32>,
+ compatible: Option<bool>,
+ socket: String,
+ session: Option<String>,
+ restart_needed: Option<bool>,
+}
+
+#[derive(Serialize)]
+struct UpdateStatusJson {
+ restart_needed: Option<bool>,
+}
+
+fn client_status_json() -> ClientStatusJson {
+ ClientStatusJson {
+ version: env!("CARGO_PKG_VERSION"),
+ protocol: crate::protocol::PROTOCOL_VERSION,
+ binary: current_exe_label(),
+ session: crate::session::active_name(),
+ }
+}
+
+fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
+ match server {
+ ServerRuntimeStatus::Running { version, protocol } => ServerStatusJson {
+ status: "running",
+ running: true,
+ version: version.clone(),
+ protocol: *protocol,
+ compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION),
+ socket: api::socket_path().display().to_string(),
+ session: crate::session::active_name(),
+ restart_needed: restart_needed_bool(server),
+ },
+ ServerRuntimeStatus::NotRunning => ServerStatusJson {
+ status: "not_running",
+ running: false,
+ version: None,
+ protocol: None,
+ compatible: None,
+ socket: api::socket_path().display().to_string(),
+ session: crate::session::active_name(),
+ restart_needed: Some(false),
+ },
+ }
+}
+
+fn update_status_json(server: &ServerRuntimeStatus) -> UpdateStatusJson {
+ UpdateStatusJson {
+ restart_needed: restart_needed_bool(server),
+ }
+}
+
+fn restart_needed_bool(server: &ServerRuntimeStatus) -> Option<bool> {
+ match server {
+ ServerRuntimeStatus::Running { version, .. } => match version.as_deref() {
+ Some(env!("CARGO_PKG_VERSION")) => Some(false),
+ Some(_) => Some(true),
+ None => None,
+ },
+ ServerRuntimeStatus::NotRunning => Some(false),
+ }
+}
+
+fn print_json(value: &impl Serialize) -> std::io::Result<()> {
+ println!("{}", serde_json::to_string(value)?);
+ Ok(())
+}
+
+fn current_exe_label() -> String {
+ std::env::current_exe()
+ .map(|path| path.display().to_string())
+ .unwrap_or_else(|err| format!("unknown ({err})"))
+}
+
+fn print_status_help() {
+ eprintln!("herdr status commands:");
+ eprintln!(" herdr status [--json] show local client and running server status");
+ eprintln!(" herdr status server [--json] show running server status");
+ eprintln!(" herdr status client [--json] show local client binary status");
+}
diff --git a/src/cli/tab.rs b/src/cli/tab.rs
new file mode 100644
index 00000000..3fcc0647
--- /dev/null
+++ b/src/cli/tab.rs
@@ -0,0 +1,194 @@
+use crate::api::schema::{
+ Method, Request, TabCreateParams, TabListParams, TabRenameParams, TabTarget,
+};
+
+pub(super) fn run_tab_command(args: &[String]) -> std::io::Result<i32> {
+ 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 tab_list(args: &[String]) -> std::io::Result<i32> {
+ 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:tab:list".into(),
+ method: Method::TabList(TabListParams { workspace_id }),
+ })?)
+}
+
+fn tab_create(args: &[String]) -> std::io::Result<i32> {
+ let mut workspace_id = None;
+ 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() {
+ "--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;
+ }
+ "--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);
+ }
+ }
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:tab:create".into(),
+ method: Method::TabCreate(TabCreateParams {
+ workspace_id,
+ cwd,
+ focus,
+ label,
+ }),
+ })?)
+}
+
+fn tab_get(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_tab_id) = args.first() else {
+ eprintln!("usage: herdr tab get <tab_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr tab get <tab_id>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:tab:get".into(),
+ method: Method::TabGet(TabTarget {
+ tab_id: super::normalize_tab_id(raw_tab_id),
+ }),
+ })?)
+}
+
+fn tab_focus(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_tab_id) = args.first() else {
+ eprintln!("usage: herdr tab focus <tab_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr tab focus <tab_id>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:tab:focus".into(),
+ method: Method::TabFocus(TabTarget {
+ tab_id: super::normalize_tab_id(raw_tab_id),
+ }),
+ })?)
+}
+
+fn tab_rename(args: &[String]) -> std::io::Result<i32> {
+ if args.len() < 2 {
+ eprintln!("usage: herdr tab rename <tab_id> <label>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:tab:rename".into(),
+ method: Method::TabRename(TabRenameParams {
+ tab_id: super::normalize_tab_id(&args[0]),
+ label: args[1..].join(" "),
+ }),
+ })?)
+}
+
+fn tab_close(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_tab_id) = args.first() else {
+ eprintln!("usage: herdr tab close <tab_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr tab close <tab_id>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:tab:close".into(),
+ method: Method::TabClose(TabTarget {
+ tab_id: super::normalize_tab_id(raw_tab_id),
+ }),
+ })?)
+}
+
+fn print_tab_help() {
+ eprintln!("herdr tab commands:");
+ eprintln!(" herdr tab list [--workspace <workspace_id>]");
+ eprintln!(
+ " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus]"
+ );
+ eprintln!(" herdr tab get <tab_id>");
+ eprintln!(" herdr tab focus <tab_id>");
+ eprintln!(" herdr tab rename <tab_id> <label>");
+ eprintln!(" herdr tab close <tab_id>");
+}
diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs
new file mode 100644
index 00000000..ae29792c
--- /dev/null
+++ b/src/cli/workspace.rs
@@ -0,0 +1,163 @@
+use crate::api::schema::{
+ EmptyParams, Method, Request, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget,
+};
+
+pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result<i32> {
+ 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 workspace_list(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr workspace list");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:workspace:list".into(),
+ method: Method::WorkspaceList(EmptyParams::default()),
+ })?)
+}
+
+fn workspace_create(args: &[String]) -> std::io::Result<i32> {
+ 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);
+ }
+ }
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:workspace:create".into(),
+ method: Method::WorkspaceCreate(WorkspaceCreateParams { cwd, focus, label }),
+ })?)
+}
+
+fn workspace_get(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_workspace_id) = args.first() else {
+ eprintln!("usage: herdr workspace get <workspace_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr workspace get <workspace_id>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:workspace:get".into(),
+ method: Method::WorkspaceGet(WorkspaceTarget {
+ workspace_id: super::normalize_workspace_id(raw_workspace_id),
+ }),
+ })?)
+}
+
+fn workspace_focus(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_workspace_id) = args.first() else {
+ eprintln!("usage: herdr workspace focus <workspace_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr workspace focus <workspace_id>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:workspace:focus".into(),
+ method: Method::WorkspaceFocus(WorkspaceTarget {
+ workspace_id: super::normalize_workspace_id(raw_workspace_id),
+ }),
+ })?)
+}
+
+fn workspace_rename(args: &[String]) -> std::io::Result<i32> {
+ if args.len() < 2 {
+ eprintln!("usage: herdr workspace rename <workspace_id> <label>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:workspace:rename".into(),
+ method: Method::WorkspaceRename(WorkspaceRenameParams {
+ workspace_id: super::normalize_workspace_id(&args[0]),
+ label: args[1..].join(" "),
+ }),
+ })?)
+}
+
+fn workspace_close(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_workspace_id) = args.first() else {
+ eprintln!("usage: herdr workspace close <workspace_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr workspace close <workspace_id>");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:workspace:close".into(),
+ method: Method::WorkspaceClose(WorkspaceTarget {
+ workspace_id: super::normalize_workspace_id(raw_workspace_id),
+ }),
+ })?)
+}
+
+fn print_workspace_help() {
+ eprintln!("herdr workspace commands:");
+ eprintln!(" herdr workspace list");
+ eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus]");
+ eprintln!(" herdr workspace get <workspace_id>");
+ eprintln!(" herdr workspace focus <workspace_id>");
+ eprintln!(" herdr workspace rename <workspace_id> <label>");
+ eprintln!(" herdr workspace close <workspace_id>");
+}