aboutsummaryrefslogtreecommitdiff
path: root/src/cli
diff options
context:
space:
mode:
Diffstat (limited to 'src/cli')
-rw-r--r--src/cli/pane.rs16
-rw-r--r--src/cli/plugin.rs21
-rw-r--r--src/cli/server.rs4
-rw-r--r--src/cli/spec.rs1
-rw-r--r--src/cli/status.rs28
-rw-r--r--src/cli/target.rs426
-rw-r--r--src/cli/worktree.rs30
7 files changed, 501 insertions, 25 deletions
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 17d3c144..896cee45 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -98,9 +98,7 @@ fn pane_get(args: &[String]) -> std::io::Result<i32> {
}
fn pane_current(args: &[String]) -> std::io::Result<i32> {
- let env_pane_id = std::env::var("HERDR_PANE_ID")
- .ok()
- .filter(|value| !value.trim().is_empty());
+ let env_pane_id = super::target::caller_pane_id();
let caller_pane_id = match parse_pane_current_args(args, env_pane_id.as_deref()) {
Ok(caller_pane_id) => caller_pane_id,
Err(message) => {
@@ -225,9 +223,7 @@ fn pane_resize(args: &[String]) -> std::io::Result<i32> {
}
fn parse_optional_current_pane_args_from_env(args: &[String]) -> Result<Option<String>, String> {
- let env_pane_id = std::env::var("HERDR_PANE_ID")
- .ok()
- .filter(|value| !value.trim().is_empty());
+ let env_pane_id = super::target::caller_pane_id();
parse_optional_current_pane_args(args, env_pane_id.as_deref())
}
@@ -541,9 +537,7 @@ fn parse_pane_read_args(args: &[String]) -> Result<PaneReadParams, String> {
}
fn pane_input(args: &[String]) -> std::io::Result<i32> {
- let env_pane_id = std::env::var("HERDR_PANE_ID")
- .ok()
- .filter(|value| !value.trim().is_empty());
+ let env_pane_id = super::target::caller_pane_id();
let params = match parse_pane_input_args(args, env_pane_id.as_deref()) {
Ok(params) => params,
Err(message) => {
@@ -621,9 +615,7 @@ fn parse_right_click_target(value: &str) -> Result<PaneRightClickTarget, String>
}
fn pane_split(args: &[String]) -> std::io::Result<i32> {
- let env_pane_id = std::env::var("HERDR_PANE_ID")
- .ok()
- .filter(|value| !value.trim().is_empty());
+ let env_pane_id = super::target::caller_pane_id();
let params = match parse_pane_split_args(args, env_pane_id.as_deref()) {
Ok(params) => params,
Err(message) => {
diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs
index efdb9eca..a2c1e6c3 100644
--- a/src/cli/plugin.rs
+++ b/src/cli/plugin.rs
@@ -701,6 +701,15 @@ fn parse_split_direction(value: &str) -> Option<SplitDirection> {
}
fn normalize_plugin_path_arg(value: &str) -> std::io::Result<String> {
+ if super::target::is_remote() {
+ if super::target::remote_path_is_absolute(value) {
+ return Ok(value.to_owned());
+ }
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "remote plugin paths must be absolute",
+ ));
+ }
let path = crate::worktree::expand_tilde_path(value);
let absolute = if path.is_absolute() {
path
@@ -1618,6 +1627,9 @@ fn current_unix_ms() -> u64 {
}
fn is_connection_error(err: &std::io::Error) -> bool {
+ if super::target::is_remote() {
+ return false;
+ }
// A `server_not_running` marker is a connect failure for recovery purposes:
// treating it as a connection error lets plugin commands fall back to the
// offline registry. The marker carries (but does not print) a friendly
@@ -1672,6 +1684,15 @@ fn print_plugin_pane_help() {
mod tests {
use super::*;
+ #[test]
+ fn machine_plugin_connection_errors_never_use_local_offline_state() {
+ crate::cli::target::with_test_client(crate::api::client::ApiClient::local(), || {
+ assert!(!is_connection_error(&std::io::Error::from(
+ std::io::ErrorKind::ConnectionRefused
+ )));
+ });
+ }
+
fn unique_plugin_id(label: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
diff --git a/src/cli/server.rs b/src/cli/server.rs
index ff87cab1..2045f420 100644
--- a/src/cli/server.rs
+++ b/src/cli/server.rs
@@ -30,6 +30,10 @@ fn server_stop(args: &[String]) -> std::io::Result<i32> {
return Ok(2);
}
+ if super::target::is_remote() {
+ return super::send_ok_request(Method::ServerStop(EmptyParams::default()));
+ }
+
match crate::session::stop_active_server() {
Ok(()) => Ok(0),
Err(err) => {
diff --git a/src/cli/spec.rs b/src/cli/spec.rs
index 2da0a25c..0d8a58ea 100644
--- a/src/cli/spec.rs
+++ b/src/cli/spec.rs
@@ -12,6 +12,7 @@ pub(super) fn command() -> Command {
.disable_version_flag(true)
.arg(help_flag())
.arg(option("session", "NAME").help("Use or create a named persistent session"))
+ .arg(option("machine", "LABEL-OR-ID").help("Run an API command on a saved SSH machine"))
.arg(option("remote", "TARGET").help("Attach through SSH to a remote Herdr server"))
.arg(
option("remote-keybindings", "MODE")
diff --git a/src/cli/status.rs b/src/cli/status.rs
index c6dda462..fa008bdc 100644
--- a/src/cli/status.rs
+++ b/src/cli/status.rs
@@ -1,7 +1,7 @@
use serde::Serialize;
use crate::api;
-use crate::api::client::{ApiClient, ApiClientError};
+use crate::api::client::ApiClientError;
pub(super) fn run_status_command(args: &[String]) -> std::io::Result<i32> {
let Some((scope, json)) = parse_status_args(args) else {
@@ -164,33 +164,29 @@ fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) {
"{indent}private_protocol_compatible: {}",
compatibility_label(*protocol)
);
- println!("{indent}socket: {}", api::socket_path().display());
+ println!("{indent}socket: {}", super::target::socket_label());
}
ServerRuntimeStatus::NotRunning => {
println!("{indent}status: not running");
- println!("{indent}socket: {}", api::socket_path().display());
+ println!("{indent}socket: {}", super::target::socket_label());
}
}
}
fn read_server_runtime_status() -> std::io::Result<ServerRuntimeStatus> {
- match ApiClient::local().status() {
+ match super::target::api_client()?.status() {
Ok(status) => Ok(ServerRuntimeStatus::Running {
version: status.version,
protocol: status.protocol,
capabilities: status.capabilities,
}),
+ Err(err) if super::target::is_remote() => Err(super::target::remote_error(
+ super::api_client_error_to_io(err),
+ )),
Err(ApiClientError::Io(err)) if super::server_not_running_error(&err) => {
Ok(ServerRuntimeStatus::NotRunning)
}
- Err(err) => Err(api_client_error_to_io(err)),
- }
-}
-
-fn api_client_error_to_io(err: ApiClientError) -> std::io::Error {
- match err {
- ApiClientError::Io(err) => err,
- err => std::io::Error::other(err),
+ Err(err) => Err(super::api_client_error_to_io(err)),
}
}
@@ -307,7 +303,7 @@ fn client_status_json() -> ClientStatusJson {
}
fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
- match server {
+ let mut status = match server {
ServerRuntimeStatus::Running {
version,
protocol,
@@ -350,7 +346,13 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
restart_needed: Some(false),
server_binary_stale: Some(false),
},
+ };
+ if let Some((_, session)) = super::target::remote_identity() {
+ status.socket = super::target::socket_label();
+ status.session = Some(session);
+ status.server_binary_stale = None;
}
+ status
}
fn update_status_json(server: &ServerRuntimeStatus) -> UpdateStatusJson {
diff --git a/src/cli/target.rs b/src/cli/target.rs
new file mode 100644
index 00000000..64c072f9
--- /dev/null
+++ b/src/cli/target.rs
@@ -0,0 +1,426 @@
+use std::cell::RefCell;
+use std::io;
+
+use crate::api::client::{ApiClient, ConnectionTarget};
+use crate::client::endpoint::{EndpointCatalog, SavedSshEndpoint};
+
+thread_local! {
+ // CLI dispatch is synchronous. Scope routing to this command, never the runtime or TUI.
+ static TARGET: RefCell<Option<MachineTarget>> = const { RefCell::new(None) };
+}
+
+struct MachineTarget {
+ profile: SavedSshEndpoint,
+ bridge: Option<crate::remote::SavedSshApiBridge>,
+ #[cfg(test)]
+ client_override: Option<ApiClient>,
+}
+
+struct TargetScope(Option<MachineTarget>);
+
+impl Drop for TargetScope {
+ fn drop(&mut self) {
+ TARGET.with(|target| *target.borrow_mut() = self.0.take());
+ }
+}
+
+pub(super) fn maybe_run(args: &[String]) -> Option<io::Result<super::CommandOutcome>> {
+ let (selector, args) = match parse_machine_prefix(args) {
+ Ok(Some(target)) => target,
+ Ok(None) => return None,
+ Err(error) => return Some(usage_error(error)),
+ };
+ Some((|| {
+ if let Err(error) = validate_machine_command(&args) {
+ return usage_error(error);
+ }
+ if super::spec::print_requested_help(&args)? {
+ return Ok(super::CommandOutcome::Handled(0));
+ }
+ let profiles = EndpointCatalog::load_profiles().map_err(io::Error::other)?;
+ let profile = match resolve_machine(&profiles, &selector) {
+ Ok(profile) => profile.clone(),
+ Err(error) => return usage_error(error),
+ };
+ let _scope = TARGET.with(|target| {
+ TargetScope(target.replace(Some(MachineTarget {
+ profile,
+ bridge: None,
+ #[cfg(test)]
+ client_override: None,
+ })))
+ });
+ super::maybe_run(&args)
+ })())
+}
+
+fn usage_error(error: String) -> io::Result<super::CommandOutcome> {
+ eprintln!("error: {error}");
+ Ok(super::CommandOutcome::Handled(2))
+}
+
+pub(super) fn is_remote() -> bool {
+ TARGET.with(|target| target.borrow().is_some())
+}
+
+pub(super) fn api_client() -> io::Result<ApiClient> {
+ TARGET.with(|target| {
+ let mut target = target.borrow_mut();
+ let Some(target) = target.as_mut() else {
+ return Ok(ApiClient::local());
+ };
+ #[cfg(test)]
+ if let Some(client) = &target.client_override {
+ return Ok(client.clone());
+ }
+ if target.bridge.is_none() {
+ target.bridge = Some(
+ crate::remote::SavedSshApiBridge::start(
+ target.profile.id.as_str(),
+ &target.profile.target,
+ &target.profile.session,
+ )
+ .map_err(|error| {
+ io::Error::new(
+ error.kind(),
+ format!("machine '{}': {error}", target.profile.label),
+ )
+ })?,
+ );
+ }
+ let bridge = target
+ .bridge
+ .as_ref()
+ .ok_or_else(|| io::Error::other("machine bridge unavailable"))?;
+ Ok(ApiClient::for_target(ConnectionTarget::SocketPath(
+ bridge.socket_path().to_owned(),
+ )))
+ })
+}
+
+pub(super) fn remote_error(error: io::Error) -> io::Error {
+ TARGET.with(|target| {
+ let target = target.borrow();
+ let Some(target) = target.as_ref() else {
+ return error;
+ };
+ let error = target
+ .bridge
+ .as_ref()
+ .and_then(|bridge| bridge.reported_failure())
+ .unwrap_or(error);
+ io::Error::new(
+ error.kind(),
+ format!(
+ "machine '{}' (session {}): {error}",
+ target.profile.label, target.profile.session
+ ),
+ )
+ })
+}
+
+pub(super) fn restart_guidance() -> String {
+ TARGET.with(|target| match target.borrow().as_ref() {
+ Some(target) => format!("Update Herdr and restart the server on machine '{}' (session {}). Stopping the server exits its pane processes.", target.profile.label, target.profile.session),
+ None => crate::session::active_restart_after_update_guidance(),
+ })
+}
+
+pub(super) fn remote_identity() -> Option<(String, String)> {
+ TARGET.with(|target| {
+ target.borrow().as_ref().map(|target| {
+ (
+ target.profile.id.to_string(),
+ target.profile.session.clone(),
+ )
+ })
+ })
+}
+
+pub(super) fn socket_label() -> String {
+ match remote_identity() {
+ Some((id, session)) => format!("machine:{id}/{session}"),
+ None => crate::api::socket_path().display().to_string(),
+ }
+}
+
+pub(super) fn remote_path_is_absolute(path: &str) -> bool {
+ let bytes = path.as_bytes();
+ path.starts_with('/')
+ || path.starts_with("\\\\")
+ || (bytes.len() >= 3
+ && bytes[0].is_ascii_alphabetic()
+ && bytes[1] == b':'
+ && matches!(bytes[2], b'/' | b'\\'))
+}
+
+pub(super) fn caller_pane_id() -> Option<String> {
+ if is_remote() {
+ return None;
+ }
+ std::env::var("HERDR_PANE_ID")
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+}
+
+fn parse_machine_prefix(args: &[String]) -> Result<Option<(String, Vec<String>)>, String> {
+ let mut index = 1;
+ let mut machine = None;
+ let mut other_prefix = false;
+ while let Some(arg) = args.get(index) {
+ if arg == "--machine" || arg.starts_with("--machine=") {
+ if machine.is_some() {
+ return Err("--machine can only be specified once".into());
+ }
+ let value = if let Some(value) = arg.strip_prefix("--machine=") {
+ value.to_owned()
+ } else {
+ index += 1;
+ args.get(index)
+ .cloned()
+ .ok_or("missing value for --machine")?
+ };
+ if value.trim().is_empty() || value.starts_with('-') {
+ return Err("--machine requires a saved machine label or profile ID".into());
+ }
+ machine = Some(value);
+ } else if arg.starts_with('-') && arg != "--" {
+ other_prefix = true;
+ if matches!(
+ arg.as_str(),
+ "--session" | "--remote" | "--remote-keybindings"
+ ) {
+ index += 1;
+ }
+ } else {
+ break;
+ }
+ index += 1;
+ }
+ let Some(machine) = machine else {
+ return Ok(None);
+ };
+ if other_prefix {
+ return Err("--machine cannot be combined with other launch options; it uses the saved machine's session".into());
+ }
+ if index >= args.len() || args[index] == "--" {
+ return Err("usage: herdr --machine <label-or-id> <command>".into());
+ }
+ let mut cleaned = vec![args[0].clone()];
+ cleaned.extend_from_slice(&args[index..]);
+ Ok(Some((machine, cleaned)))
+}
+
+fn resolve_machine<'a>(
+ profiles: &'a [SavedSshEndpoint],
+ selector: &str,
+) -> Result<&'a SavedSshEndpoint, String> {
+ let profile = if let Some(profile) = profiles
+ .iter()
+ .find(|profile| profile.id.as_str() == selector)
+ {
+ profile
+ } else {
+ let mut matches = profiles.iter().filter(|profile| profile.label == selector);
+ let profile = matches
+ .next()
+ .ok_or_else(|| format!("unknown machine '{selector}'; use `herdr machine list`"))?;
+ if matches.next().is_some() {
+ return Err(format!(
+ "machine label '{selector}' is ambiguous; use its profile ID"
+ ));
+ }
+ profile
+ };
+ if !profile.enabled {
+ return Err(format!("machine '{selector}' is disabled"));
+ }
+ Ok(profile)
+}
+
+fn validate_machine_command(args: &[String]) -> Result<(), String> {
+ let command = args.get(1).map(String::as_str).unwrap_or_default();
+ let subcommand = args.get(2).map(String::as_str).unwrap_or_default();
+ let supported = match command {
+ "workspace" | "worktree" | "tab" | "pane" | "notification" => true,
+ "agent" => {
+ subcommand != "attach"
+ && !(subcommand == "explain"
+ && args[3..]
+ .iter()
+ .any(|arg| arg == "--file" || arg.starts_with("--file=")))
+ }
+ "api" => subcommand == "snapshot",
+ "status" => subcommand == "server",
+ "plugin" => matches!(
+ subcommand,
+ "link" | "unlink" | "enable" | "disable" | "list" | "action" | "log" | "logs" | "pane"
+ ),
+ "server" => matches!(
+ subcommand,
+ "stop" | "reload-config" | "agent-manifests" | "reload-agent-manifests"
+ ),
+ _ => false,
+ };
+ if supported {
+ Ok(())
+ } else {
+ Err(format!("`{command} {subcommand}` is not an API-backed machine command; --machine does not run local management commands or attach a TUI"))
+ }
+}
+
+#[cfg(test)]
+pub(super) fn with_test_client<T>(client: ApiClient, run: impl FnOnce() -> T) -> T {
+ let _scope = TARGET.with(|target| {
+ TargetScope(
+ target.replace(Some(MachineTarget {
+ profile: SavedSshEndpoint::new("test-machine", "unused", "remote-session")
+ .expect("valid test profile"),
+ bridge: None,
+ client_override: Some(client),
+ })),
+ )
+ });
+ run()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn args(values: &[&str]) -> Vec<String> {
+ values.iter().map(|value| (*value).into()).collect()
+ }
+
+ #[test]
+ fn machine_prefix_routes_without_consuming_command_payload() {
+ for prefix in [args(&["--machine", "mac"]), args(&["--machine=mac"])] {
+ let mut input = args(&["herdr"]);
+ input.extend(prefix);
+ input.extend(args(&["agent", "prompt", "w4:p1", "--machine"]));
+ assert_eq!(
+ parse_machine_prefix(&input).unwrap(),
+ Some((
+ "mac".into(),
+ args(&["herdr", "agent", "prompt", "w4:p1", "--machine"])
+ ))
+ );
+ }
+ assert_eq!(
+ parse_machine_prefix(&args(&[
+ "herdr",
+ "agent",
+ "prompt",
+ "w4:p1",
+ "--machine=mac"
+ ]))
+ .unwrap(),
+ None
+ );
+ }
+
+ #[test]
+ fn machine_prefix_rejects_missing_target_and_conflicting_global_options() {
+ for input in [
+ args(&["herdr", "--machine"]),
+ args(&["herdr", "--machine="]),
+ args(&["herdr", "--machine", "--help"]),
+ args(&["herdr", "--machine", "mac"]),
+ args(&[
+ "herdr",
+ "--machine",
+ "mac",
+ "--machine",
+ "other",
+ "agent",
+ "list",
+ ]),
+ args(&[
+ "herdr",
+ "--machine",
+ "mac",
+ "--session",
+ "other",
+ "agent",
+ "list",
+ ]),
+ args(&[
+ "herdr",
+ "--session",
+ "other",
+ "--machine",
+ "mac",
+ "agent",
+ "list",
+ ]),
+ args(&[
+ "herdr",
+ "--remote",
+ "other",
+ "--machine",
+ "mac",
+ "agent",
+ "list",
+ ]),
+ ] {
+ assert!(parse_machine_prefix(&input).is_err(), "{input:?}");
+ }
+ }
+
+ #[test]
+ fn machine_resolution_requires_a_unique_enabled_saved_machine() {
+ let mac = SavedSshEndpoint::new("mac", "mac-ssh", "agents").unwrap();
+ let other = SavedSshEndpoint::new("build", "builder", "default").unwrap();
+ let profiles = vec![mac.clone(), other];
+ assert_eq!(resolve_machine(&profiles, "mac").unwrap(), &mac);
+ assert_eq!(resolve_machine(&profiles, mac.id.as_str()).unwrap(), &mac);
+ let shadow = SavedSshEndpoint::new(mac.id.as_str(), "shadow", "default").unwrap();
+ assert_eq!(
+ resolve_machine(&[mac.clone(), shadow], mac.id.as_str()).unwrap(),
+ &mac
+ );
+ assert!(resolve_machine(&profiles, "mac-ssh").is_err());
+ assert!(resolve_machine(&profiles, "missing").is_err());
+ let duplicate = SavedSshEndpoint::new("mac", "other", "default").unwrap();
+ assert!(resolve_machine(&[mac.clone(), duplicate], "mac").is_err());
+ let mut disabled = mac;
+ disabled.enabled = false;
+ assert!(resolve_machine(&[disabled], "mac").is_err());
+ }
+
+ #[test]
+ fn machine_commands_reject_local_side_effects_and_tui_attach() {
+ for command in [
+ &["update"][..],
+ &["config", "reset-keys"],
+ &["machine", "remove", "mac"],
+ &["session", "delete", "default"],
+ &["server", "live-handoff"],
+ &["agent", "attach", "w4:p1"],
+ &["terminal", "attach", "w4:p1"],
+ &["terminal", "session", "control", "w4:p1"],
+ &["plugin", "install", "./plugin"],
+ &["integration", "install", "pi"],
+ &["api", "schema", "--output", "schema.json"],
+ &["status", "client"],
+ ] {
+ let mut input = args(&["herdr"]);
+ input.extend(args(command));
+ assert!(validate_machine_command(&input).is_err(), "{input:?}");
+ }
+ for command in [
+ &["agent", "list"][..],
+ &["agent", "wait", "w4:p1"],
+ &["pane", "split", "w4:p1", "--direction", "right"],
+ &["workspace", "list"],
+ &["worktree", "create", "--branch", "feature"],
+ &["tab", "list"],
+ &["api", "snapshot"],
+ &["server", "stop"],
+ ] {
+ let mut input = args(&["herdr"]);
+ input.extend(args(command));
+ assert!(validate_machine_command(&input).is_ok(), "{input:?}");
+ }
+ }
+}
diff --git a/src/cli/worktree.rs b/src/cli/worktree.rs
index 4003eb6a..ba492e97 100644
--- a/src/cli/worktree.rs
+++ b/src/cli/worktree.rs
@@ -323,6 +323,16 @@ fn print_worktree_help() {
}
fn normalize_path_arg(value: &str) -> std::io::Result<String> {
+ if super::target::is_remote() {
+ if super::target::remote_path_is_absolute(value) || value == "~" || value.starts_with("~/")
+ {
+ return Ok(value.to_owned());
+ }
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "remote worktree paths must be absolute or start with ~/",
+ ));
+ }
let path = crate::worktree::expand_tilde_path(value);
let absolute = if path.is_absolute() {
path
@@ -331,3 +341,23 @@ fn normalize_path_arg(value: &str) -> std::io::Result<String> {
};
Ok(absolute.display().to_string())
}
+
+#[cfg(test)]
+mod machine_tests {
+ #[test]
+ fn remote_worktree_paths_are_not_expanded_on_the_caller_machine() {
+ crate::cli::target::with_test_client(crate::api::client::ApiClient::local(), || {
+ for path in [
+ "~/Projects/herdr",
+ "/Users/can/Projects/herdr",
+ r"C:\work\repo",
+ "C:/work/repo",
+ r"\\host\share\repo",
+ ] {
+ assert_eq!(super::normalize_path_arg(path).unwrap(), path);
+ }
+ assert!(super::normalize_path_arg("../other").is_err());
+ assert!(super::normalize_path_arg("C:relative").is_err());
+ });
+ }
+}