From 3397e1ce2922c4ae9b814e8110e81de7d201de81 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Tue, 21 Apr 2026 16:30:04 +0300 Subject: feat: add persistent server-client sessions 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 --- src/cli.rs | 234 +++++++++++++++++++++++++++---------------------------------- 1 file changed, 103 insertions(+), 131 deletions(-) (limited to 'src/cli.rs') diff --git a/src/cli.rs b/src/cli.rs index 620a592f..feb24c70 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,8 @@ use serde::Serialize; use crate::api; use crate::api::schema::{ - AgentStatus, EmptyParams, Method, OutputMatch, PaneListParams, PaneReadParams, + 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, @@ -24,6 +25,12 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { }; 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..])?, @@ -35,6 +42,24 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { Ok(CommandOutcome::Handled(exit_code)) } +fn run_server_command(args: &[String]) -> std::io::Result> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + return Ok(None); + }; + + match subcommand { + "stop" => server_stop(&args[1..]).map(Some), + "help" | "--help" | "-h" => { + print_server_help(); + Ok(Some(0)) + } + _ => { + print_server_help(); + Ok(Some(2)) + } + } +} + fn run_workspace_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_workspace_help(); @@ -129,6 +154,15 @@ fn run_integration_command(args: &[String]) -> std::io::Result { } } +fn server_stop(args: &[String]) -> std::io::Result { + if !args.is_empty() { + eprintln!("usage: herdr server stop"); + return Ok(2); + } + + send_ok_request(Method::ServerStop(EmptyParams::default())) +} + fn workspace_list(args: &[String]) -> std::io::Result { if !args.is_empty() { eprintln!("usage: herdr workspace list"); @@ -625,153 +659,85 @@ fn pane_run(args: &[String]) -> std::io::Result { } fn integration_install(args: &[String]) -> std::io::Result { - let Some(target) = args.first().map(|arg| arg.as_str()) else { - eprintln!("usage: herdr integration install "); + let Some(target) = parse_integration_target(args, "install")? else { return Ok(2); }; - if args.len() != 1 { - eprintln!("usage: herdr integration install "); - 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); } - match target { - "pi" => { - let path = crate::integration::install_pi()?; - println!("installed pi integration to {}", path.display()); - Ok(0) - } - "claude" => { - let installed = crate::integration::install_claude()?; - println!( - "installed claude integration hook to {}", - installed.hook_path.display() - ); - println!( - "ensured claude settings at {}", - installed.settings_path.display() - ); - Ok(0) - } - "codex" => { - let installed = crate::integration::install_codex()?; - println!( - "installed codex integration hook to {}", - installed.hook_path.display() - ); - println!("ensured codex hooks at {}", installed.hooks_path.display()); - println!( - "ensured codex config at {}", - installed.config_path.display() - ); - Ok(0) - } - "opencode" => { - let installed = crate::integration::install_opencode()?; - println!( - "installed opencode integration plugin to {}", - installed.plugin_path.display() - ); - Ok(0) - } - _ => { - eprintln!("unknown integration target: {target}"); - eprintln!("currently supported: pi, claude, codex, opencode"); - Ok(2) - } + 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 { - let Some(target) = args.first().map(|arg| arg.as_str()) else { - eprintln!("usage: herdr integration uninstall "); + 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> { + 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 uninstall "); - return Ok(2); + eprintln!("usage: herdr integration {action} "); + return Ok(None); } - match target { - "pi" => { - let result = crate::integration::uninstall_pi()?; - if result.removed_extension { - println!( - "removed pi integration extension at {}", - result.extension_path.display() - ); - } else { - println!( - "no pi integration extension found at {}", - result.extension_path.display() - ); - } - Ok(0) - } - "claude" => { - let result = crate::integration::uninstall_claude()?; - if result.removed_hook_file { - println!("removed claude hook at {}", result.hook_path.display()); - } else { - println!("no claude hook found at {}", result.hook_path.display()); - } - if result.updated_settings { - println!( - "removed herdr claude hook entries from {}", - result.settings_path.display() - ); - } else { - println!( - "no herdr claude hook entries found in {}", - result.settings_path.display() - ); - } - Ok(0) - } - "codex" => { - let result = crate::integration::uninstall_codex()?; - if result.removed_hook_file { - println!("removed codex hook at {}", result.hook_path.display()); - } else { - println!("no codex hook found at {}", result.hook_path.display()); - } - if result.updated_hooks { - println!( - "removed herdr codex hook entries from {}", - result.hooks_path.display() - ); - } else { - println!( - "no herdr codex hook entries found in {}", - result.hooks_path.display() - ); - } - println!( - "left codex config unchanged at {}", - result.config_path.display() - ); - Ok(0) - } - "opencode" => { - let result = crate::integration::uninstall_opencode()?; - if result.removed_plugin { - println!( - "removed opencode integration plugin at {}", - result.plugin_path.display() - ); - } else { - println!( - "no opencode integration plugin found at {}", - result.plugin_path.display() - ); - } - Ok(0) - } + 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"); - Ok(2) + return Ok(None); } - } + }; + + Ok(Some(parsed)) } fn wait_output(args: &[String]) -> std::io::Result { @@ -1071,6 +1037,12 @@ fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result { .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"); -- cgit v1.2.3