mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
1103 lines
33 KiB
Rust
1103 lines
33 KiB
Rust
use std::io::{BufRead, BufReader, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::time::Duration;
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::api;
|
|
use crate::api::schema::{
|
|
AgentStatus, EmptyParams, IntegrationInstallParams, IntegrationTarget,
|
|
IntegrationUninstallParams, Method, OutputMatch, PaneListParams, PaneReadParams,
|
|
PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget,
|
|
PaneWaitForOutputParams, 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<CommandOutcome> {
|
|
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
|
|
}
|
|
"workspace" => run_workspace_command(&args[2..])?,
|
|
"tab" => run_tab_command(&args[2..])?,
|
|
"pane" => run_pane_command(&args[2..])?,
|
|
"wait" => run_wait_command(&args[2..])?,
|
|
"integration" => run_integration_command(&args[2..])?,
|
|
_ => return Ok(CommandOutcome::NotCli),
|
|
};
|
|
|
|
Ok(CommandOutcome::Handled(exit_code))
|
|
}
|
|
|
|
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),
|
|
"help" | "--help" | "-h" => {
|
|
print_server_help();
|
|
Ok(Some(0))
|
|
}
|
|
_ => {
|
|
print_server_help();
|
|
Ok(Some(2))
|
|
}
|
|
}
|
|
}
|
|
|
|
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..]),
|
|
_ => {
|
|
print_workspace_help();
|
|
Ok(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
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..]),
|
|
_ => {
|
|
print_tab_help();
|
|
Ok(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
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..]),
|
|
"split" => pane_split(&args[1..]),
|
|
"close" => pane_close(&args[1..]),
|
|
"send-text" => pane_send_text(&args[1..]),
|
|
"send-keys" => pane_send_keys(&args[1..]),
|
|
"run" => pane_run(&args[1..]),
|
|
_ => {
|
|
print_pane_help();
|
|
Ok(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_wait_command(args: &[String]) -> std::io::Result<i32> {
|
|
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..]),
|
|
_ => {
|
|
print_wait_help();
|
|
Ok(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
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..]),
|
|
_ => {
|
|
print_integration_help();
|
|
Ok(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn server_stop(args: &[String]) -> std::io::Result<i32> {
|
|
if !args.is_empty() {
|
|
eprintln!("usage: herdr server stop");
|
|
return Ok(2);
|
|
}
|
|
|
|
send_ok_request(Method::ServerStop(EmptyParams::default()))
|
|
}
|
|
|
|
fn workspace_list(args: &[String]) -> std::io::Result<i32> {
|
|
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<i32> {
|
|
let mut cwd = None;
|
|
let mut focus = true;
|
|
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;
|
|
}
|
|
"--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<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);
|
|
}
|
|
|
|
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<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);
|
|
}
|
|
|
|
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<i32> {
|
|
if args.len() < 2 {
|
|
eprintln!("usage: herdr workspace rename <workspace_id> <label>");
|
|
return Ok(2);
|
|
}
|
|
|
|
print_response(&send_request(&Request {
|
|
id: "cli:workspace:rename".into(),
|
|
method: Method::WorkspaceRename(WorkspaceRenameParams {
|
|
workspace_id: 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);
|
|
}
|
|
|
|
print_response(&send_request(&Request {
|
|
id: "cli:workspace:close".into(),
|
|
method: Method::WorkspaceClose(WorkspaceTarget {
|
|
workspace_id: normalize_workspace_id(raw_workspace_id),
|
|
}),
|
|
})?)
|
|
}
|
|
|
|
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(normalize_workspace_id(value));
|
|
index += 2;
|
|
}
|
|
other => {
|
|
eprintln!("unknown option: {other}");
|
|
return Ok(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
print_response(&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 = true;
|
|
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(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;
|
|
}
|
|
"--no-focus" => {
|
|
focus = false;
|
|
index += 1;
|
|
}
|
|
other => {
|
|
eprintln!("unknown option: {other}");
|
|
return Ok(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
print_response(&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);
|
|
}
|
|
|
|
print_response(&send_request(&Request {
|
|
id: "cli:tab:get".into(),
|
|
method: Method::TabGet(TabTarget {
|
|
tab_id: 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);
|
|
}
|
|
|
|
print_response(&send_request(&Request {
|
|
id: "cli:tab:focus".into(),
|
|
method: Method::TabFocus(TabTarget {
|
|
tab_id: 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);
|
|
}
|
|
|
|
print_response(&send_request(&Request {
|
|
id: "cli:tab:rename".into(),
|
|
method: Method::TabRename(TabRenameParams {
|
|
tab_id: 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);
|
|
}
|
|
|
|
print_response(&send_request(&Request {
|
|
id: "cli:tab:close".into(),
|
|
method: Method::TabClose(TabTarget {
|
|
tab_id: normalize_tab_id(raw_tab_id),
|
|
}),
|
|
})?)
|
|
}
|
|
|
|
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(normalize_workspace_id(value));
|
|
index += 2;
|
|
}
|
|
other => {
|
|
eprintln!("unknown option: {other}");
|
|
return Ok(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
print_response(&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);
|
|
}
|
|
|
|
print_response(&send_request(&Request {
|
|
id: "cli:pane:get".into(),
|
|
method: Method::PaneGet(PaneTarget {
|
|
pane_id: normalize_pane_id(raw_pane_id),
|
|
}),
|
|
})?)
|
|
}
|
|
|
|
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]");
|
|
return Ok(2);
|
|
};
|
|
|
|
let pane_id = normalize_pane_id(raw_pane_id);
|
|
let mut source = ReadSource::Recent;
|
|
let mut lines = None;
|
|
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 = 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;
|
|
}
|
|
"--raw" => {
|
|
strip_ansi = false;
|
|
index += 1;
|
|
}
|
|
other => {
|
|
eprintln!("unknown option: {other}");
|
|
return Ok(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
let response = send_request(&Request {
|
|
id: "cli:pane:read".into(),
|
|
method: Method::PaneRead(PaneReadParams {
|
|
pane_id,
|
|
source,
|
|
lines,
|
|
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] [--no-focus]"
|
|
);
|
|
return Ok(2);
|
|
};
|
|
|
|
let pane_id = normalize_pane_id(raw_pane_id);
|
|
let mut direction = None;
|
|
let mut cwd = None;
|
|
let mut focus = true;
|
|
|
|
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(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;
|
|
}
|
|
"--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);
|
|
};
|
|
|
|
print_response(&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);
|
|
}
|
|
|
|
print_response(&send_request(&Request {
|
|
id: "cli:pane:close".into(),
|
|
method: Method::PaneClose(PaneTarget {
|
|
pane_id: 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 = normalize_pane_id(&args[0]);
|
|
let text = args[1..].join(" ");
|
|
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 = normalize_pane_id(&args[0]);
|
|
let keys = args[1..].to_vec();
|
|
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 = normalize_pane_id(&args[0]);
|
|
let text = args[1..].join(" ");
|
|
send_ok_request(Method::PaneSendInput(PaneSendInputParams {
|
|
pane_id,
|
|
text,
|
|
keys: vec!["Enter".into()],
|
|
}))
|
|
}
|
|
|
|
fn integration_install(args: &[String]) -> std::io::Result<i32> {
|
|
let Some(target) = parse_integration_target(args, "install")? else {
|
|
return Ok(2);
|
|
};
|
|
|
|
let response = send_request(&Request {
|
|
id: "cli:integration:install".into(),
|
|
method: Method::IntegrationInstall(IntegrationInstallParams { target }),
|
|
})?;
|
|
|
|
if let Some(error) = response.get("error") {
|
|
eprintln!("{}", serde_json::to_string(error).unwrap());
|
|
return Ok(1);
|
|
}
|
|
|
|
let Some(messages) = response["result"]["details"]["messages"].as_array() else {
|
|
eprintln!("invalid integration install response");
|
|
return Ok(1);
|
|
};
|
|
|
|
for message in messages.iter().filter_map(|entry| entry.as_str()) {
|
|
println!("{message}");
|
|
}
|
|
|
|
Ok(0)
|
|
}
|
|
|
|
fn integration_uninstall(args: &[String]) -> std::io::Result<i32> {
|
|
let Some(target) = parse_integration_target(args, "uninstall")? else {
|
|
return Ok(2);
|
|
};
|
|
|
|
let response = send_request(&Request {
|
|
id: "cli:integration:uninstall".into(),
|
|
method: Method::IntegrationUninstall(IntegrationUninstallParams { target }),
|
|
})?;
|
|
|
|
if let Some(error) = response.get("error") {
|
|
eprintln!("{}", serde_json::to_string(error).unwrap());
|
|
return Ok(1);
|
|
}
|
|
|
|
let Some(messages) = response["result"]["details"]["messages"].as_array() else {
|
|
eprintln!("invalid integration uninstall response");
|
|
return Ok(1);
|
|
};
|
|
|
|
for message in messages.iter().filter_map(|entry| entry.as_str()) {
|
|
println!("{message}");
|
|
}
|
|
|
|
Ok(0)
|
|
}
|
|
|
|
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>");
|
|
return Ok(None);
|
|
};
|
|
if args.len() != 1 {
|
|
eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode>");
|
|
return Ok(None);
|
|
}
|
|
|
|
let parsed = match target {
|
|
"pi" => IntegrationTarget::Pi,
|
|
"claude" => IntegrationTarget::Claude,
|
|
"codex" => IntegrationTarget::Codex,
|
|
"opencode" => IntegrationTarget::Opencode,
|
|
_ => {
|
|
eprintln!("unknown integration target: {target}");
|
|
eprintln!("currently supported: pi, claude, codex, opencode");
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
Ok(Some(parsed))
|
|
}
|
|
|
|
fn wait_output(args: &[String]) -> std::io::Result<i32> {
|
|
let Some(raw_pane_id) = args.first() else {
|
|
eprintln!("usage: herdr wait output <pane_id> --match <text> [--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<i32> {
|
|
let Some(raw_pane_id) = args.first() else {
|
|
eprintln!("usage: herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--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",
|
|
)
|
|
}
|
|
|
|
fn wait_for_agent_change(
|
|
request: Request,
|
|
timeout_ms: Option<u64>,
|
|
timeout_message: &str,
|
|
) -> std::io::Result<i32> {
|
|
let mut stream = UnixStream::connect(api::socket_path())?;
|
|
stream.write_all(serde_json::to_string(&request)?.as_bytes())?;
|
|
stream.write_all(b"\n")?;
|
|
stream.flush()?;
|
|
|
|
if let Some(timeout_ms) = timeout_ms {
|
|
stream.set_read_timeout(Some(Duration::from_millis(timeout_ms)))?;
|
|
}
|
|
|
|
let mut reader = BufReader::new(stream);
|
|
let mut ack = String::new();
|
|
reader.read_line(&mut ack)?;
|
|
if ack.trim().is_empty() {
|
|
eprintln!("empty subscription ack");
|
|
return Ok(1);
|
|
}
|
|
let ack_value: serde_json::Value = serde_json::from_str(&ack)?;
|
|
if ack_value.get("error").is_some() {
|
|
eprintln!("{}", serde_json::to_string(&ack_value).unwrap());
|
|
return Ok(1);
|
|
}
|
|
|
|
let mut event = String::new();
|
|
match reader.read_line(&mut event) {
|
|
Ok(0) => {
|
|
eprintln!("subscription closed before event arrived");
|
|
Ok(1)
|
|
}
|
|
Ok(_) => {
|
|
let event_value: serde_json::Value = serde_json::from_str(&event)?;
|
|
println!("{}", serde_json::to_string(&event_value).unwrap());
|
|
Ok(0)
|
|
}
|
|
Err(err)
|
|
if matches!(
|
|
err.kind(),
|
|
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
|
|
) =>
|
|
{
|
|
eprintln!("{timeout_message}");
|
|
Ok(1)
|
|
}
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
fn print_response(response: &serde_json::Value) -> std::io::Result<i32> {
|
|
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 send_ok_request(method: Method) -> std::io::Result<i32> {
|
|
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)
|
|
}
|
|
|
|
fn send_request(request: &Request) -> std::io::Result<serde_json::Value> {
|
|
let mut stream = UnixStream::connect(api::socket_path())?;
|
|
stream.write_all(serde_json::to_string(request)?.as_bytes())?;
|
|
stream.write_all(b"\n")?;
|
|
stream.flush()?;
|
|
|
|
let mut reader = BufReader::new(stream);
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line)?;
|
|
serde_json::from_str(&line).map_err(std::io::Error::other)
|
|
}
|
|
|
|
fn normalize_workspace_id(value: &str) -> String {
|
|
value.to_string()
|
|
}
|
|
|
|
fn normalize_tab_id(value: &str) -> String {
|
|
value.to_string()
|
|
}
|
|
|
|
fn normalize_pane_id(value: &str) -> String {
|
|
value.to_string()
|
|
}
|
|
|
|
fn parse_split_direction(value: &str) -> std::io::Result<SplitDirection> {
|
|
match value {
|
|
"right" => Ok(SplitDirection::Right),
|
|
"down" => Ok(SplitDirection::Down),
|
|
_ => Err(std::io::Error::other(format!(
|
|
"invalid split direction: {value}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_read_source(value: &str) -> std::io::Result<ReadSource> {
|
|
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}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_agent_status(value: &str) -> std::io::Result<AgentStatus> {
|
|
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)"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_u32_flag(flag: &str, value: &str) -> std::io::Result<u32> {
|
|
value
|
|
.parse::<u32>()
|
|
.map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}")))
|
|
}
|
|
|
|
fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result<u64> {
|
|
value
|
|
.parse::<u64>()
|
|
.map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}")))
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
fn print_workspace_help() {
|
|
eprintln!("herdr workspace commands:");
|
|
eprintln!(" herdr workspace list");
|
|
eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--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>");
|
|
}
|
|
|
|
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] [--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>");
|
|
}
|
|
|
|
fn print_pane_help() {
|
|
eprintln!("herdr pane commands:");
|
|
eprintln!(" herdr pane list [--workspace <workspace_id>]");
|
|
eprintln!(" herdr pane get <pane_id>");
|
|
eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N]");
|
|
eprintln!(" herdr pane split <pane_id> --direction right|down [--cwd PATH] [--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 run <pane_id> <command>");
|
|
}
|
|
|
|
fn print_wait_help() {
|
|
eprintln!("herdr wait commands:");
|
|
eprintln!(" herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]");
|
|
eprintln!(
|
|
" herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS]"
|
|
);
|
|
}
|
|
|
|
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 uninstall pi");
|
|
eprintln!(" herdr integration uninstall claude");
|
|
eprintln!(" herdr integration uninstall codex");
|
|
eprintln!(" herdr integration uninstall opencode");
|
|
}
|
|
|
|
fn _print_json<T: Serialize>(value: &T) {
|
|
println!("{}", serde_json::to_string(value).unwrap());
|
|
}
|