diff --git a/Cargo.lock b/Cargo.lock index 3c5ccc8..589b0b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3908,6 +3908,7 @@ dependencies = [ "notify-rust", "rfd", "rmux-api", + "rmux-cli", "rmux-config", "rmux-terminal", "serde", diff --git a/crates/rmux-api/src/methods.rs b/crates/rmux-api/src/methods.rs index 9fbd83f..1ac8f6d 100644 --- a/crates/rmux-api/src/methods.rs +++ b/crates/rmux-api/src/methods.rs @@ -294,6 +294,12 @@ pub struct NotificationCreateParams { /// Optional body text. #[serde(default)] pub body: Option, + /// Optional workspace id (cmux / agent routing). + #[serde(default)] + pub workspace_id: Option, + /// Optional pane id (cmux surface / agent routing). + #[serde(default)] + pub pane_id: Option, } /// Result of [`NOTIFICATION_CREATE`]. diff --git a/crates/rmux-app/Cargo.toml b/crates/rmux-app/Cargo.toml index c7493fe..4d26495 100644 --- a/crates/rmux-app/Cargo.toml +++ b/crates/rmux-app/Cargo.toml @@ -23,6 +23,7 @@ thiserror = { workspace = true } tokio = { workspace = true } rmux-terminal = { path = "../rmux-terminal" } rmux-api = { path = "../rmux-api" } +rmux-cli = { path = "../rmux-cli" } rmux-config = { path = "../rmux-config" } wry = { workspace = true } arboard = "3" diff --git a/crates/rmux-app/src/api_dispatch.rs b/crates/rmux-app/src/api_dispatch.rs index 3e227ce..c09b965 100644 --- a/crates/rmux-app/src/api_dispatch.rs +++ b/crates/rmux-app/src/api_dispatch.rs @@ -249,10 +249,21 @@ fn notification_create(app: &mut RmuxApp, params: Value) -> Result Some(subtitle), (None, body) => body, }; - let id = app.notifications.add(params.title.clone(), body.clone(), None, None); + let id = app.notifications.add( + params.title.clone(), + body.clone(), + params.pane_id, + params.workspace_id, + ); app.publish_event( "notification", - json!({ "id": id, "title": params.title, "body": body, "pane_id": null, "workspace_id": null }), + json!({ + "id": id, + "title": params.title, + "body": body, + "pane_id": params.pane_id, + "workspace_id": params.workspace_id, + }), ); Ok(json!({ "id": id })) } diff --git a/crates/rmux-app/src/app.rs b/crates/rmux-app/src/app.rs index 03931bc..159391c 100644 --- a/crates/rmux-app/src/app.rs +++ b/crates/rmux-app/src/app.rs @@ -933,7 +933,9 @@ fn attach_terminal( cwd: Option<&std::path::Path>, bg_opacity: f32, ) { - match TerminalPane::spawn_with_cwd(INITIAL_COLS, INITIAL_ROWS, font_size, cwd) { + let workspace_id = manager.active().id.0; + let env = agent_integration_env(workspace_id, pane_id.0); + match TerminalPane::spawn_with_env(INITIAL_COLS, INITIAL_ROWS, font_size, cwd, &env) { Ok(mut terminal) => { terminal.set_theme(rmux_terminal::TerminalTheme::default().named(named_theme)); terminal.set_bg_opacity(bg_opacity); @@ -942,3 +944,37 @@ fn attach_terminal( Err(e) => tracing::error!(pane_id = pane_id.0, "Failed to spawn terminal pane: {e}"), } } + +/// Environment variables injected into every PTY so agent notify plugins +/// (OpenCode kdco-notify / cmux-compatible tools) can reach this workspace. +/// +/// Sets both `RMUX_*` and `CMUX_*` aliases. Also ensures a `cmux` shim exists +/// under `~/.local/bin` so plugins that shell out to `cmux notify` succeed +/// instead of falling back to the external `alerter` binary on macOS. +fn agent_integration_env(workspace_id: u64, pane_id: u64) -> Vec<(String, String)> { + // Best-effort: install/update the cmux PATH shim once per process. + static SHIM_ONCE: std::sync::Once = std::sync::Once::new(); + SHIM_ONCE.call_once(|| { + if let Some(dir) = rmux_cli::cmux_compat::ensure_local_shim() { + tracing::info!(path = %dir.join("cmux").display(), "ensured cmux notify shim"); + } else { + tracing::debug!("could not install cmux notify shim (HOME/rmux-cli missing?)"); + } + }); + + let socket = rmux_api::default_socket_path(); + let socket_s = socket.display().to_string(); + let ws = workspace_id.to_string(); + let pane = pane_id.to_string(); + + vec![ + ("RMUX_WORKSPACE_ID".to_owned(), ws.clone()), + ("RMUX_PANE_ID".to_owned(), pane.clone()), + ("RMUX_SOCKET_PATH".to_owned(), socket_s.clone()), + // cmux-compatible aliases used by OpenCode notify / kdco plugins + ("CMUX_WORKSPACE_ID".to_owned(), ws), + ("CMUX_SURFACE_ID".to_owned(), pane), + ("CMUX_SOCKET_PATH".to_owned(), socket_s), + ("CMUX_SOCKET_MODE".to_owned(), "allowall".to_owned()), + ] +} diff --git a/crates/rmux-app/src/ui/terminal_pane.rs b/crates/rmux-app/src/ui/terminal_pane.rs index 0196d84..2a55089 100644 --- a/crates/rmux-app/src/ui/terminal_pane.rs +++ b/crates/rmux-app/src/ui/terminal_pane.rs @@ -168,7 +168,21 @@ impl TerminalPane { font_size: f32, cwd: Option<&Path>, ) -> Result { - let mut backend = PtyBackend::spawn_with_cwd(cols, rows, cwd)?; + Self::spawn_with_env(cols, rows, font_size, cwd, &[]) + } + + /// Spawn a terminal and inject `extra_env` into the shell process. + /// + /// Used to export `RMUX_*` / `CMUX_*` so agent notify plugins can reach + /// this pane via the socket API (cmux-compatible path). + pub fn spawn_with_env( + cols: u16, + rows: u16, + font_size: f32, + cwd: Option<&Path>, + extra_env: &[(String, String)], + ) -> Result { + let mut backend = PtyBackend::spawn_with_env(cols, rows, cwd, extra_env)?; let state = TermState::new(cols, rows, 10_000); let renderer = TerminalRenderer::new(font_size); let input_mapper = InputMapper::new(); diff --git a/crates/rmux-cli/src/cmux_compat.rs b/crates/rmux-cli/src/cmux_compat.rs new file mode 100644 index 0000000..f0c03ed --- /dev/null +++ b/crates/rmux-cli/src/cmux_compat.rs @@ -0,0 +1,228 @@ +//! cmux CLI compatibility for agent notify plugins (OpenCode kdco-notify, etc.). +//! +//! Those plugins prefer: +//! ```text +//! cmux notify --title … [--subtitle …] --body … +//! cmux set-status +//! cmux clear-status +//! ``` +//! when `CMUX_WORKSPACE_ID` (or socket mode) is set and `cmux` is on `PATH`. +//! Without this, macOS falls back to the external `alerter` binary. +//! +//! Invoked as `rmux-cli __cmux-compat …` or when the binary argv0 is `cmux` +//! (install symlink / PATH shim). + +use std::env; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde_json::json; + +use crate::socket; + +/// Run a cmux-compatible command (`args` without the program name). +/// +/// # Errors +/// +/// Returns an error when arguments are invalid or the socket call fails. +pub fn run(socket_path: &Path, args: &[String]) -> Result<()> { + let mut it = args.iter().map(String::as_str); + let Some(cmd) = it.next() else { + bail!("cmux: missing command (expected notify, set-status, or clear-status)"); + }; + + match cmd { + "notify" => run_notify(socket_path, &args[1..])?, + "set-status" => { + // cmux: set-status + let key = it.next().unwrap_or(""); + let text_parts: Vec<&str> = it.collect(); + if text_parts.is_empty() { + bail!("cmux set-status: expected "); + } + let text = if key.is_empty() { + text_parts.join(" ") + } else { + // Prefer the human-visible text; key is a session id for cmux. + text_parts.join(" ") + }; + let workspace_id = workspace_id_from_env(); + let params = json!({ "workspace_id": workspace_id, "status": text }); + let _ = socket::call(socket_path, "sidebar.set_status", params)?; + } + "clear-status" => { + // cmux: clear-status + let _key = it.next(); + let workspace_id = workspace_id_from_env(); + let params = json!({ "workspace_id": workspace_id }); + let _ = socket::call(socket_path, "sidebar.clear_status", params)?; + } + // Soft-success for other cmux probes so plugins don't hard-fail. + other => { + eprintln!("rmux cmux-compat: ignoring unsupported command: {other}"); + } + } + Ok(()) +} + +fn run_notify(socket_path: &Path, args: &[String]) -> Result<()> { + let mut title: Option = None; + let mut subtitle: Option = None; + let mut body: Option = None; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--title" => { + i += 1; + title = args.get(i).cloned(); + } + "--subtitle" => { + i += 1; + subtitle = args.get(i).cloned(); + } + "--body" => { + i += 1; + body = args.get(i).cloned(); + } + flag if flag.starts_with("--") => { + // Skip unknown flags and their optional values. + if args.get(i + 1).is_some_and(|v| !v.starts_with("--")) { + i += 1; + } + } + _ => {} + } + i += 1; + } + + let title = title.context("cmux notify: --title is required")?; + let workspace_id = workspace_id_from_env(); + let pane_id = env_u64("CMUX_SURFACE_ID").or_else(|| env_u64("RMUX_PANE_ID")); + + let params = json!({ + "title": title, + "subtitle": subtitle, + "body": body, + "workspace_id": workspace_id, + "pane_id": pane_id, + }); + let _ = socket::call(socket_path, "notification.create", params)?; + Ok(()) +} + +fn workspace_id_from_env() -> Option { + env_u64("CMUX_WORKSPACE_ID").or_else(|| env_u64("RMUX_WORKSPACE_ID")) +} + +fn env_u64(key: &str) -> Option { + env::var(key).ok().and_then(|s| s.parse().ok()) +} + +/// Resolve the socket for cmux-compat: CLI flag path is passed in; env +/// fallbacks honor both `CMUX_SOCKET_PATH` and `RMUX_SOCKET_PATH`. +pub fn effective_socket_path(flag: Option) -> PathBuf { + if let Some(p) = flag { + return p; + } + if let Ok(p) = env::var("CMUX_SOCKET_PATH") { + let trimmed = p.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + socket::effective_socket_path(None) +} + +/// Ensure `~/.local/bin/cmux` (or platform equivalent) is a shim to `rmux-cli +/// __cmux-compat`. Returns the directory containing the shim when successful. +/// +/// Idempotent: rewrites the shim if the target path changed. +pub fn ensure_local_shim() -> Option { + let bin_dir = local_bin_dir()?; + let rmux_cli = discover_rmux_cli()?; + let shim = bin_dir.join("cmux"); + + let body = format!( + "#!/bin/sh\n# rmux cmux shim — auto-managed; do not edit\nexec {} __cmux-compat \"$@\"\n", + shell_quote_path(&rmux_cli) + ); + + let needs_write = match std::fs::read_to_string(&shim) { + Ok(existing) => existing != body, + Err(_) => true, + }; + + if needs_write { + std::fs::create_dir_all(&bin_dir).ok()?; + std::fs::write(&shim, body).ok()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&shim).ok()?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&shim, perms).ok()?; + } + } + + Some(bin_dir) +} + +fn local_bin_dir() -> Option { + // Prefer XDG/local conventions that login shells usually put on PATH. + if let Some(home) = env::var_os("HOME") { + return Some(PathBuf::from(home).join(".local").join("bin")); + } + None +} + +fn discover_rmux_cli() -> Option { + // 1. Same directory as the running binary (cargo run / install layout). + if let Ok(exe) = env::current_exe() + && let Some(dir) = exe.parent() + { + let candidate = dir.join("rmux-cli"); + if candidate.is_file() { + return Some(candidate); + } + // When this process *is* rmux-cli (shim target), use ourselves. + if exe.file_name().and_then(|s| s.to_str()) == Some("rmux-cli") { + return Some(exe); + } + } + // 2. PATH lookup. + env::var_os("PATH").and_then(|paths| { + env::split_paths(&paths).find_map(|dir| { + let candidate = dir.join("rmux-cli"); + candidate.is_file().then_some(candidate) + }) + }) +} + +fn shell_quote_path(path: &Path) -> String { + let s = path.display().to_string(); + if s.chars().all(|c| c.is_ascii_alphanumeric() || "/._-+".contains(c)) { + s + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_quote_path_escapes_spaces() { + assert_eq!(shell_quote_path(Path::new("/tmp/rmux-cli")), "/tmp/rmux-cli"); + let quoted = shell_quote_path(Path::new("/tmp/my cli")); + assert!(quoted.starts_with('\''), "{quoted}"); + assert!(quoted.contains("my cli"), "{quoted}"); + } + + #[test] + fn env_u64_parses() { + // Pure parse helper path via missing keys. + assert!(env_u64("RMUX_TEST_NO_SUCH_ENV_VAR_XYZ").is_none()); + } +} diff --git a/crates/rmux-cli/src/commands/mod.rs b/crates/rmux-cli/src/commands/mod.rs index e6b2b67..0b7e710 100644 --- a/crates/rmux-cli/src/commands/mod.rs +++ b/crates/rmux-cli/src/commands/mod.rs @@ -62,6 +62,13 @@ pub enum Command { Events(EventsCommand), /// Invoke any socket method with raw JSON params (escape hatch) Call(CallCommand), + /// cmux CLI compatibility for agent notify plugins (PATH shim target) + #[command(name = "__cmux-compat", hide = true)] + CmuxCompat { + /// cmux argv after the program name (`notify …`, `set-status …`, …) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Back-compat flat aliases from the Phase 3 CLI #[command(flatten)] Alias(AliasCommand), @@ -83,6 +90,7 @@ pub fn run(command: Command, socket_path: &Path, opts: OutputOpts) -> Result<()> Command::App(cmd) => app_cmd::run(cmd, socket_path, opts), Command::Events(cmd) => events::run(cmd, socket_path, opts), Command::Call(cmd) => call::run(cmd, socket_path, opts), + Command::CmuxCompat { args } => crate::cmux_compat::run(socket_path, &args), Command::Alias(cmd) => aliases::run(cmd, socket_path, opts), } } diff --git a/crates/rmux-cli/src/lib.rs b/crates/rmux-cli/src/lib.rs index 77da896..a33bdbf 100644 --- a/crates/rmux-cli/src/lib.rs +++ b/crates/rmux-cli/src/lib.rs @@ -9,9 +9,11 @@ //! //! - [`socket`] — socket path resolution and the blocking line-protocol client //! - [`commands`] — hierarchical domain commands + back-compat aliases +//! - [`cmux_compat`] — cmux CLI shim (`notify` / `set-status` / `clear-status`) //! - [`output`] — shared stdout formatting (`--json` / tables) //! - [`util`] — escape interpretation and id extraction +pub mod cmux_compat; pub mod commands; pub mod output; pub mod socket; diff --git a/crates/rmux-cli/src/main.rs b/crates/rmux-cli/src/main.rs index 55df8b9..aa85a7c 100644 --- a/crates/rmux-cli/src/main.rs +++ b/crates/rmux-cli/src/main.rs @@ -53,6 +53,26 @@ fn run(cli: Cli) -> anyhow::Result<()> { } fn main() -> ExitCode { + // Multi-call: when installed/symlinked as `cmux`, accept cmux argv shape. + if is_invoked_as_cmux() { + let args: Vec = std::env::args().skip(1).collect(); + let socket_path = rmux_cli::cmux_compat::effective_socket_path(None); + return match rmux_cli::cmux_compat::run(&socket_path, &args) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + if let Some(connect) = err.downcast_ref::() { + eprintln!( + "error: cannot connect to rmux at {} — is rmux running?", + connect.path.display() + ); + return ExitCode::from(2); + } + eprintln!("error: {err:#}"); + ExitCode::from(1) + } + }; + } + let cli = Cli::parse(); match run(cli) { Ok(()) => ExitCode::SUCCESS, @@ -74,6 +94,16 @@ fn main() -> ExitCode { } } +fn is_invoked_as_cmux() -> bool { + std::env::args() + .next() + .as_deref() + .map(std::path::Path::new) + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + == Some("cmux") +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rmux-terminal/src/backend.rs b/crates/rmux-terminal/src/backend.rs index f22d2ba..ce3590d 100644 --- a/crates/rmux-terminal/src/backend.rs +++ b/crates/rmux-terminal/src/backend.rs @@ -91,6 +91,24 @@ impl PtyBackend { /// Returns [`PtyError::OpenPty`] if the PTY could not be created. /// Returns [`PtyError::SpawnProcess`] if the shell process could not be spawned. pub fn spawn_with_cwd(cols: u16, rows: u16, cwd: Option<&Path>) -> PtyResult { + Self::spawn_with_env(cols, rows, cwd, &[]) + } + + /// Spawn a shell like [`Self::spawn_with_cwd`], also injecting `extra_env`. + /// + /// Used by the app to export `RMUX_*` / `CMUX_*` so agent notify plugins + /// (OpenCode kdco-notify, etc.) can route notifications back to this pane + /// without falling back to external tools like `alerter`. + /// + /// # Errors + /// + /// Same as [`Self::spawn_with_cwd`]. + pub fn spawn_with_env( + cols: u16, + rows: u16, + cwd: Option<&Path>, + extra_env: &[(String, String)], + ) -> PtyResult { // Determine which shell to use let shell = std::env::var("SHELL").unwrap_or_else(|_| { #[cfg(unix)] @@ -122,6 +140,10 @@ impl PtyBackend { } } + for (key, value) in extra_env { + cmd.env(key, value); + } + let child = pair.slave.spawn_command(cmd).map_err(PtyError::SpawnProcess)?; let reader = pair.master.try_clone_reader().map_err(PtyError::IoSetup)?; diff --git a/scripts/install.sh b/scripts/install.sh index c34f55d..788ce3c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -159,11 +159,15 @@ elif git rev-parse --verify "${RMUX_VERSION}" >/dev/null 2>&1; then fi info "Building rmux (release) — this may take a few minutes" -cargo build --release -p rmux-app --bin rmux +cargo build --release -p rmux-app --bin rmux -p rmux-cli --bin rmux-cli mkdir -p "${RMUX_INSTALL_DIR}" install -m 755 target/release/rmux "${RMUX_INSTALL_DIR}/rmux" +install -m 755 target/release/rmux-cli "${RMUX_INSTALL_DIR}/rmux-cli" +# cmux-compatible name so OpenCode notify plugins call us instead of alerter +ln -sfn rmux-cli "${RMUX_INSTALL_DIR}/cmux" info "Installed binary → ${RMUX_INSTALL_DIR}/rmux" +info "Installed CLI → ${RMUX_INSTALL_DIR}/rmux-cli (+ cmux shim)" # Ensure PATH hint case ":${PATH}:" in