From 41cdb0f54f600096e3a4b51a5141f2ba22178d85 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:17:38 -0400 Subject: [PATCH] Add optional command extensions --- README.md | 23 ++++++ docs/NATIVE_V1_SPEC.md | 7 ++ docs/PUBLIC_READINESS.md | 23 ++++++ src/bin/dosh-client.rs | 174 +++++++++++++++++++++++++++++++++++++-- src/config.rs | 22 +++++ 5 files changed, 241 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2e13567..53c78dc 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,29 @@ in Dosh's host config: dosh import-ssh palav homelab ``` +Optional command extensions are just config-side startup shortcuts; Dosh has no +compile-time dependency on the tools they run. For example, to make a separately +installed server-side `tm` dashboard easy to open: + +```toml +# ~/.config/dosh/client.toml +[extensions.tm] +command = "tm {args}" +description = "Open the server-side tmux dashboard" +``` + +Then `dosh palav tm` sends `tm`, and `dosh palav tm dosh` sends `tm 'dosh'`. +Remove that table to remove the integration. Hosts can override or opt out: + +```toml +# ~/.config/dosh/hosts.toml +[palav.extensions.tm] +command = "/opt/tm/bin/tm {args}" + +[other-host.extensions.tm] +disabled = true +``` + ## Develop Build: diff --git a/docs/NATIVE_V1_SPEC.md b/docs/NATIVE_V1_SPEC.md index 1be5068..a4c261f 100644 --- a/docs/NATIVE_V1_SPEC.md +++ b/docs/NATIVE_V1_SPEC.md @@ -51,6 +51,7 @@ dosh -L 8080:127.0.0.1:80 host dosh -R 9000:127.0.0.1:9000 host dosh --session work host dosh --view-only --session work host +dosh host tm ``` Compatibility expectations: @@ -107,6 +108,8 @@ Must work in v1: - `dosh update`. - `dosh doctor host` for config/auth/UDP reachability diagnostics. - `dosh sessions host` for session visibility. +- Optional command extensions such as `dosh host tm` for companion tools that are + installed separately from Dosh. Should work in v1 if it does not compromise the transport schedule: @@ -556,6 +559,10 @@ forward_agent = false send_env = ["LANG", "LC_*", "TERM", "COLORTERM"] set_env = {} forwardings = [] + +[extensions.tm] +command = "tm {args}" +description = "Open an optional server-side tmux dashboard" ``` Server: diff --git a/docs/PUBLIC_READINESS.md b/docs/PUBLIC_READINESS.md index 2ccb7e2..c402803 100644 --- a/docs/PUBLIC_READINESS.md +++ b/docs/PUBLIC_READINESS.md @@ -101,6 +101,29 @@ dosh import-ssh palav homelab This appends entries to `~/.config/dosh/hosts.toml` without trying to become an OpenSSH config parser. +## Optional Command Extensions + +Dosh can expose companion tools without taking a dependency on them. Command +extensions live in client or host config and expand only the first trailing word: + +```toml +[extensions.tm] +command = "tm {args}" +description = "Open the server-side tmux dashboard" +``` + +With that config, `dosh palav tm` runs `tm` in the remote Dosh shell and +`dosh palav tm dosh` runs `tm 'dosh'`. Removing the table removes the integration. +Host config can override a global extension, or disable it: + +```toml +[palav.extensions.tm] +command = "/opt/tm/bin/tm {args}" + +[other-host.extensions.tm] +disabled = true +``` + ## Forwarding (Implemented) SSH forwarding cannot be copied by keeping the bootstrap SSH connection open, diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index c8a4ee6..57c626e 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -212,11 +212,7 @@ async fn main() -> Result<()> { .unwrap_or_default(); let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone()); let server = ssh_with_user(&raw_server, host.user.as_deref()); - let startup_command = startup_command(&args.command).or_else(|| { - host.default_command - .as_deref() - .map(startup_command_from_string) - }); + let startup_command = resolved_startup_command(&args.command, &config, &host); let local_forwards = parse_local_forwards(&args.local_forward)?; let remote_forwards = parse_remote_forwards(&args.remote_forward)?; let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_forward)?; @@ -776,6 +772,73 @@ fn startup_command(args: &[String]) -> Option> { Some(command.into_bytes()) } +fn resolved_startup_command( + args: &[String], + config: &dosh::config::ClientConfig, + host: &dosh::config::HostConfig, +) -> Option> { + if args.is_empty() { + return host + .default_command + .as_deref() + .map(startup_command_from_string); + } + match resolve_command_extension(args, config, host) { + ExtensionResolution::Command(command) => Some(startup_command_from_string(&command)), + ExtensionResolution::Disabled => None, + ExtensionResolution::NoMatch => startup_command(args), + } +} + +enum ExtensionResolution { + Command(String), + Disabled, + NoMatch, +} + +fn resolve_command_extension( + args: &[String], + config: &dosh::config::ClientConfig, + host: &dosh::config::HostConfig, +) -> ExtensionResolution { + let Some(name) = args.first() else { + return ExtensionResolution::NoMatch; + }; + if let Some(extension) = host.extensions.get(name) { + if extension.disabled { + return ExtensionResolution::Disabled; + } + if let Some(command) = extension.command.as_deref() { + return ExtensionResolution::Command(expand_command_extension(command, &args[1..])); + } + } + if let Some(extension) = config.extensions.get(name) { + if extension.disabled { + return ExtensionResolution::Disabled; + } + if let Some(command) = extension.command.as_deref() { + return ExtensionResolution::Command(expand_command_extension(command, &args[1..])); + } + } + ExtensionResolution::NoMatch +} + +fn expand_command_extension(template: &str, args: &[String]) -> String { + let quoted_args = args + .iter() + .map(|arg| shell_word(arg)) + .collect::>() + .join(" "); + if template.contains("{args}") { + return template.replace("{args}", "ed_args); + } + if args.is_empty() { + template.to_string() + } else { + format!("{template} {quoted_args}") + } +} + fn startup_command_from_string(command: &str) -> Vec { let mut command = command.to_string(); command.push('\n'); @@ -4226,10 +4289,11 @@ mod tests { RemoteForward, SshConfig, StatusAction, auth_allows, load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until, - render_status_clear, render_status_overlay, requested_env, ssh_destination_host, - ssh_username, ssh_with_user, startup_command, toml_bare_key_or_quoted, + render_status_clear, render_status_overlay, requested_env, resolved_startup_command, + ssh_destination_host, ssh_username, ssh_with_user, startup_command, + toml_bare_key_or_quoted, }; - use dosh::config::{ClientConfig, HostConfig}; + use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; use dosh::protocol::{self, Frame, PacketKind}; use ed25519_dalek::{SigningKey, VerifyingKey}; @@ -4775,6 +4839,100 @@ mod tests { ); } + #[test] + fn resolved_startup_command_expands_global_extension() { + let mut config = ClientConfig::default(); + config.extensions.insert( + "tm".to_string(), + CommandExtension { + command: Some("tm {args}".to_string()), + description: Some("server tmux dashboard".to_string()), + disabled: false, + }, + ); + let host = HostConfig::default(); + + assert_eq!( + resolved_startup_command(&["tm".into(), "work space".into()], &config, &host), + Some(b"tm 'work space'\n".to_vec()) + ); + } + + #[test] + fn resolved_startup_command_uses_host_extension_override() { + let mut config = ClientConfig::default(); + config.extensions.insert( + "tm".to_string(), + CommandExtension { + command: Some("tm {args}".to_string()), + description: None, + disabled: false, + }, + ); + let mut host = HostConfig::default(); + host.extensions.insert( + "tm".to_string(), + CommandExtension { + command: Some("/opt/tm/bin/tm --remote {args}".to_string()), + description: None, + disabled: false, + }, + ); + + assert_eq!( + resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host), + Some(b"/opt/tm/bin/tm --remote 'dosh'\n".to_vec()) + ); + } + + #[test] + fn resolved_startup_command_host_can_disable_global_extension() { + let mut config = ClientConfig::default(); + config.extensions.insert( + "tm".to_string(), + CommandExtension { + command: Some("tm {args}".to_string()), + description: None, + disabled: false, + }, + ); + let mut host = HostConfig::default(); + host.extensions.insert( + "tm".to_string(), + CommandExtension { + command: None, + description: None, + disabled: true, + }, + ); + + assert_eq!( + resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host), + None + ); + } + + #[test] + fn resolved_startup_command_keeps_raw_command_fallback_and_default_command() { + let config = ClientConfig::default(); + let mut host = HostConfig { + default_command: Some("tm".to_string()), + ..HostConfig::default() + }; + + assert_eq!( + resolved_startup_command(&["echo".into(), "ok".into()], &config, &host), + Some(b"echo ok\n".to_vec()) + ); + assert_eq!( + resolved_startup_command(&[], &config, &host), + Some(b"tm\n".to_vec()) + ); + + host.default_command = None; + assert_eq!(resolved_startup_command(&[], &config, &host), None); + } + #[test] fn parses_local_forward_with_default_bind() { assert_eq!( diff --git a/src/config.rs b/src/config.rs index da23c5f..c57b441 100644 --- a/src/config.rs +++ b/src/config.rs @@ -136,6 +136,11 @@ pub struct ClientConfig { pub send_env: Vec, #[serde(default)] pub set_env: HashMap, + /// Optional client-side command extensions. These are just shell command + /// templates expanded into the remote session's startup input; Dosh does not + /// depend on the tools they name. + #[serde(default)] + pub extensions: HashMap, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -151,6 +156,22 @@ pub struct HostConfig { pub send_env: Option>, #[serde(default)] pub set_env: HashMap, + /// Per-host extension overrides. A host can replace a global extension or set + /// `disabled = true` to opt out of it. + #[serde(default)] + pub extensions: HashMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommandExtension { + /// Remote shell command template. `{args}` expands to shell-quoted extra + /// words after the extension name. When absent, extra args are appended. + #[serde(default)] + pub command: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub disabled: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -186,6 +207,7 @@ impl Default for ClientConfig { disconnect_status: true, send_env: default_send_env(), set_env: HashMap::new(), + extensions: HashMap::new(), } } }