diff --git a/mux/Cargo.lock b/mux/Cargo.lock index f214f3e7512c..eaa2dd0ef14e 100644 --- a/mux/Cargo.lock +++ b/mux/Cargo.lock @@ -593,6 +593,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.7" @@ -828,6 +838,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "toml", "unicode-width", ] @@ -1423,6 +1434,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serial2" version = "0.2.37" @@ -1732,6 +1752,47 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tungstenite" version = "0.29.0" @@ -2002,6 +2063,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/mux/Cargo.toml b/mux/Cargo.toml index a58f5ceb19db..0aa1ed4f0821 100644 --- a/mux/Cargo.toml +++ b/mux/Cargo.toml @@ -33,6 +33,7 @@ mux-cdp = { path = "crates/mux-cdp" } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" portable-pty = "0.9" crossterm = "0.29" ratatui = "0.30" diff --git a/mux/crates/mux-tui/Cargo.toml b/mux/crates/mux-tui/Cargo.toml index 2196bda6b3fe..ab7aceb7b04e 100644 --- a/mux/crates/mux-tui/Cargo.toml +++ b/mux/crates/mux-tui/Cargo.toml @@ -23,5 +23,6 @@ anyhow.workspace = true unicode-width.workspace = true serde.workspace = true serde_json.workspace = true +toml.workspace = true base64.workspace = true libc.workspace = true diff --git a/mux/crates/mux-tui/src/cli.rs b/mux/crates/mux-tui/src/cli.rs index 3e68774a1031..f8c7a8a5bc22 100644 --- a/mux/crates/mux-tui/src/cli.rs +++ b/mux/crates/mux-tui/src/cli.rs @@ -10,6 +10,7 @@ const REQUEST_ID: u64 = 1; type BuildFn = fn(&FlagMap) -> Result; type PrintFn = fn(&Value, &mut dyn Write) -> io::Result<()>; +type LocalFn = fn(&GlobalArgs, &FlagMap) -> i32; pub struct UsageError(String); @@ -34,346 +35,324 @@ struct FlagMap { struct VerbSpec { name: &'static str, + help: &'static str, allowed: &'static [&'static str], - build: BuildFn, - print: PrintFn, - stream: bool, + kind: VerbKind, +} + +#[derive(Clone, Copy)] +enum VerbKind { + Socket { build: BuildFn, print: PrintFn, stream: bool }, + Local(LocalFn), } const VERBS: &[VerbSpec] = &[ VerbSpec { name: "identify", + help: "Print session metadata.", allowed: &[], - build: build_no_args, - print: print_identify, - stream: false, + kind: socket(build_no_args, print_identify, false), + }, + VerbSpec { + name: "ping", + help: "Check session liveness.", + allowed: &[], + kind: socket(build_no_args, print_ping, false), }, - VerbSpec { name: "ping", allowed: &[], build: build_no_args, print: print_ping, stream: false }, VerbSpec { name: "reload-config", + help: "Ask a running TUI to reload mux.json.", allowed: &[], - build: build_no_args, - print: print_empty, - stream: false, + kind: socket(build_no_args, print_empty, false), }, VerbSpec { name: "set-window-title", + help: "Set the host terminal window title.", allowed: &["title"], - build: build_set_window_title, - print: print_empty, - stream: false, + kind: socket(build_set_window_title, print_empty, false), }, VerbSpec { name: "clear-window-title", + help: "Clear the host terminal window title.", allowed: &[], - build: build_no_args, - print: print_empty, - stream: false, + kind: socket(build_no_args, print_empty, false), }, VerbSpec { name: "list-workspaces", + help: "List workspaces, screens, panes, and surfaces.", allowed: &[], - build: build_no_args, - print: print_tree, - stream: false, + kind: socket(build_no_args, print_tree, false), }, VerbSpec { name: "export-layout", + help: "Export a screen layout.", allowed: &["screen"], - build: build_export_layout, - print: print_json_data, - stream: false, + kind: socket(build_export_layout, print_json_data, false), }, VerbSpec { name: "apply-layout", + help: "Apply a screen layout.", allowed: &["workspace", "name", "layout"], - build: build_apply_layout, - print: print_applied_layout, - stream: false, + kind: socket(build_apply_layout, print_applied_layout, false), }, VerbSpec { name: "send", + help: "Send text or bytes to a surface.", allowed: &["surface", "text", "bytes"], - build: build_send, - print: print_empty, - stream: false, + kind: socket(build_send, print_empty, false), }, VerbSpec { name: "read-screen", + help: "Print visible screen text for a surface.", allowed: &["surface"], - build: build_surface, - print: print_read_screen, - stream: false, + kind: socket(build_surface, print_read_screen, false), }, VerbSpec { name: "wait-for", + help: "Wait for a regex in visible screen text.", allowed: &["surface", "pattern", "timeout-ms"], - build: build_wait_for, - print: print_empty, - stream: false, + kind: socket(build_wait_for, print_empty, false), }, VerbSpec { name: "run", + help: "Run a command in a new or existing pane.", allowed: &["pane", "new-workspace", "cwd", "name", "command"], - build: build_run, - print: print_surface, - stream: false, + kind: socket(build_run, print_surface, false), }, VerbSpec { name: "send-key", + help: "Send encoded key names to a surface.", allowed: &["surface"], - build: build_send_key, - print: print_empty, - stream: false, + kind: socket(build_send_key, print_empty, false), }, VerbSpec { name: "copy", + help: "Copy text from a surface.", allowed: &["surface", "mode"], - build: build_copy, - print: print_read_screen, - stream: false, + kind: socket(build_copy, print_read_screen, false), + }, + VerbSpec { + name: "ids", + help: "List ids and short ids.", + allowed: &["kind"], + kind: socket(build_ids, print_ids, false), }, - VerbSpec { name: "ids", allowed: &["kind"], build: build_ids, print: print_ids, stream: false }, VerbSpec { name: "notify", + help: "Show a cmux notification.", allowed: &["title", "body", "level", "surface"], - build: build_notify, - print: print_notification, - stream: false, + kind: socket(build_notify, print_notification, false), }, VerbSpec { name: "list-agents", + help: "List reported agent states.", allowed: &["surface", "state"], - build: build_list_agents, - print: print_agents, - stream: false, + kind: socket(build_list_agents, print_agents, false), }, VerbSpec { name: "report-agent", + help: "Report an agent state.", allowed: &["surface", "state", "source", "session"], - build: build_report_agent, - print: print_empty, - stream: false, + kind: socket(build_report_agent, print_empty, false), }, VerbSpec { name: "vt-state", + help: "Print base64 terminal state for a surface.", allowed: &["surface"], - build: build_surface, - print: print_vt_state, - stream: false, + kind: socket(build_surface, print_vt_state, false), }, VerbSpec { name: "new-tab", + help: "Create a new tab.", allowed: &["pane", "cwd", "cols", "rows"], - build: build_new_tab, - print: print_surface, - stream: false, + kind: socket(build_new_tab, print_surface, false), }, VerbSpec { name: "new-browser-tab", + help: "Create a browser tab.", allowed: &["url", "pane", "cols", "rows"], - build: build_new_browser_tab, - print: print_surface, - stream: false, + kind: socket(build_new_browser_tab, print_surface, false), }, VerbSpec { name: "new-workspace", + help: "Create a workspace.", allowed: &["name", "cols", "rows"], - build: build_new_workspace, - print: print_surface, - stream: false, + kind: socket(build_new_workspace, print_surface, false), }, VerbSpec { name: "new-screen", + help: "Create a screen.", allowed: &["workspace", "cols", "rows"], - build: build_new_screen, - print: print_surface, - stream: false, + kind: socket(build_new_screen, print_surface, false), }, VerbSpec { name: "split", + help: "Split a pane.", allowed: &["pane", "dir", "cols", "rows"], - build: build_split, - print: print_surface, - stream: false, + kind: socket(build_split, print_surface, false), }, VerbSpec { name: "set-ratio", + help: "Set a split ratio.", allowed: &["pane", "dir", "ratio"], - build: build_set_ratio, - print: print_empty, - stream: false, + kind: socket(build_set_ratio, print_empty, false), }, VerbSpec { name: "pane-neighbor", + help: "Find a pane neighbor.", allowed: &["pane", "dir"], - build: build_pane_direction, - print: print_optional_pane, - stream: false, + kind: socket(build_pane_direction, print_optional_pane, false), }, VerbSpec { name: "focus-direction", + help: "Focus a pane by direction.", allowed: &["pane", "dir"], - build: build_optional_pane_direction, - print: print_pane, - stream: false, + kind: socket(build_optional_pane_direction, print_pane, false), }, VerbSpec { name: "swap-pane", + help: "Swap panes.", allowed: &["pane", "dir", "target"], - build: build_swap_pane, - print: print_empty, - stream: false, + kind: socket(build_swap_pane, print_empty, false), }, VerbSpec { name: "zoom-pane", + help: "Toggle or set pane zoom.", allowed: &["pane", "mode"], - build: build_zoom_pane, - print: print_zoom_state, - stream: false, + kind: socket(build_zoom_pane, print_zoom_state, false), }, VerbSpec { name: "process-info", + help: "Print process metadata for a surface.", allowed: &["surface"], - build: build_surface, - print: print_process_info, - stream: false, + kind: socket(build_surface, print_process_info, false), }, VerbSpec { name: "set-default-colors", + help: "Set default terminal colors.", allowed: &["fg", "bg"], - build: build_set_default_colors, - print: print_empty, - stream: false, + kind: socket(build_set_default_colors, print_empty, false), }, VerbSpec { name: "close-surface", + help: "Close a surface.", allowed: &["surface"], - build: build_surface, - print: print_empty, - stream: false, + kind: socket(build_surface, print_empty, false), }, VerbSpec { name: "close-pane", + help: "Close a pane.", allowed: &["pane"], - build: build_pane, - print: print_empty, - stream: false, + kind: socket(build_pane, print_empty, false), }, VerbSpec { name: "close-screen", + help: "Close a screen.", allowed: &["screen"], - build: build_screen, - print: print_empty, - stream: false, + kind: socket(build_screen, print_empty, false), }, VerbSpec { name: "close-workspace", + help: "Close a workspace.", allowed: &["workspace"], - build: build_workspace, - print: print_empty, - stream: false, + kind: socket(build_workspace, print_empty, false), }, VerbSpec { name: "rename-pane", + help: "Rename a pane.", allowed: &["pane", "name"], - build: build_rename_pane, - print: print_empty, - stream: false, + kind: socket(build_rename_pane, print_empty, false), }, VerbSpec { name: "rename-surface", + help: "Rename a surface.", allowed: &["surface", "name"], - build: build_rename_surface, - print: print_empty, - stream: false, + kind: socket(build_rename_surface, print_empty, false), }, VerbSpec { name: "rename-screen", + help: "Rename a screen.", allowed: &["screen", "name"], - build: build_rename_screen, - print: print_empty, - stream: false, + kind: socket(build_rename_screen, print_empty, false), }, VerbSpec { name: "rename-workspace", + help: "Rename a workspace.", allowed: &["workspace", "name"], - build: build_rename_workspace, - print: print_empty, - stream: false, + kind: socket(build_rename_workspace, print_empty, false), }, VerbSpec { name: "resize-surface", + help: "Resize a surface PTY.", allowed: &["surface", "cols", "rows"], - build: build_resize_surface, - print: print_empty, - stream: false, + kind: socket(build_resize_surface, print_empty, false), }, VerbSpec { name: "focus-pane", + help: "Focus a pane.", allowed: &["pane"], - build: build_pane, - print: print_empty, - stream: false, + kind: socket(build_pane, print_empty, false), }, VerbSpec { name: "select-tab", + help: "Select a tab by index or delta.", allowed: &["pane", "index", "delta"], - build: build_select_tab, - print: print_empty, - stream: false, + kind: socket(build_select_tab, print_empty, false), }, VerbSpec { name: "select-screen", + help: "Select a screen by index or delta.", allowed: &["index", "delta"], - build: build_select_screen, - print: print_empty, - stream: false, + kind: socket(build_select_screen, print_empty, false), }, VerbSpec { name: "select-workspace", + help: "Select a workspace by index or delta.", allowed: &["index", "delta"], - build: build_select_workspace, - print: print_empty, - stream: false, + kind: socket(build_select_workspace, print_empty, false), }, VerbSpec { name: "move-tab", + help: "Move a tab to a pane and index.", allowed: &["surface", "pane", "index"], - build: build_move_tab, - print: print_empty, - stream: false, + kind: socket(build_move_tab, print_empty, false), }, VerbSpec { name: "move-workspace", + help: "Move a workspace to an index.", allowed: &["workspace", "index"], - build: build_move_workspace, - print: print_empty, - stream: false, + kind: socket(build_move_workspace, print_empty, false), }, VerbSpec { name: "scroll-surface", + help: "Scroll a surface.", allowed: &["surface", "delta"], - build: build_scroll_surface, - print: print_empty, - stream: false, + kind: socket(build_scroll_surface, print_empty, false), }, VerbSpec { name: "subscribe", + help: "Subscribe to session events.", allowed: &[], - build: build_no_args, - print: print_empty, - stream: true, + kind: socket(build_no_args, print_empty, true), }, VerbSpec { name: "attach-surface", + help: "Attach to a surface stream.", allowed: &["surface"], - build: build_surface, - print: print_empty, - stream: true, + kind: socket(build_surface, print_empty, true), + }, + VerbSpec { + name: "plugin", + help: "Manage installed sidebar plugins locally.", + allowed: &["name", "force", "builtin"], + kind: VerbKind::Local(run_plugin), }, ]; +const fn socket(build: BuildFn, print: PrintFn, stream: bool) -> VerbKind { + VerbKind::Socket { build, print, stream } +} + pub fn is_cli_invocation(args: &[String]) -> bool { matches!(first_command_arg(args), FirstCommand::Help | FirstCommand::Verb) } @@ -381,7 +360,7 @@ pub fn is_cli_invocation(args: &[String]) -> bool { pub fn run(args: &[String], usage: &str) -> i32 { match parse(args) { Ok(Parsed::Help) => { - print!("{usage}"); + print_help(usage); 0 } Ok(Parsed::Command(args)) => run_command(args), @@ -392,6 +371,15 @@ pub fn run(args: &[String], usage: &str) -> i32 { } } +pub fn print_help(usage: &str) { + print!("{usage}"); + println!(); + println!("VERB HELP"); + for verb in VERBS { + println!(" {:<18} {}", verb.name, verb.help); + } +} + enum FirstCommand { None, Help, @@ -474,7 +462,7 @@ fn parse(args: &[String]) -> Result { if !spec.allowed.contains(&name) { return Err(UsageError(format!("unknown flag {arg:?} for {}", spec.name))); } - if spec.name == "run" && name == "new-workspace" { + if is_boolean_flag(spec, name) { if flags.values.insert(name.to_string(), "true".to_string()).is_some() { return Err(UsageError(format!("duplicate flag {arg:?}"))); } @@ -489,7 +477,7 @@ fn parse(args: &[String]) -> Result { } _ if verb.is_some() => { let spec = verb.unwrap(); - if spec.name == "send-key" { + if spec.name == "send-key" || matches!(spec.kind, VerbKind::Local(_)) { flags.positionals.push(arg.to_string()); i += 1; } else { @@ -513,7 +501,11 @@ fn verb_by_name(name: &str) -> Option<&'static VerbSpec> { } fn run_command(args: CliArgs) -> i32 { - let request = match (args.verb.build)(&args.flags) { + let (build, print, stream_mode) = match args.verb.kind { + VerbKind::Socket { build, print, stream } => (build, print, stream), + VerbKind::Local(run) => return run(&args.global, &args.flags), + }; + let request = match build(&args.flags) { Ok(mut value) => { value["cmd"] = json!(args.verb.name); value["id"] = json!(REQUEST_ID); @@ -532,7 +524,7 @@ fn run_command(args: CliArgs) -> i32 { return 3; } }; - if args.verb.stream { + if stream_mode { let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); } else { let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); @@ -551,13 +543,32 @@ fn run_command(args: CliArgs) -> i32 { } let mut reader = BufReader::new(stream); - if args.verb.stream { + if stream_mode { run_stream(reader) } else { - run_one_response(&mut reader, args.global.json, args.verb.print) + run_one_response(&mut reader, args.global.json, print) } } +fn is_boolean_flag(spec: &VerbSpec, name: &str) -> bool { + (spec.name == "run" && name == "new-workspace") + || (spec.name == "plugin" && matches!(name, "force" | "builtin")) +} + +fn run_plugin(global: &GlobalArgs, flags: &FlagMap) -> i32 { + crate::plugin_manager::run( + &flags.positionals, + crate::plugin_manager::CliOptions { + json: global.json, + socket: global.socket.clone(), + session: global.session.clone(), + name: flags.optional("name"), + force: flags.optional("force").is_some(), + builtin: flags.optional("builtin").is_some(), + }, + ) +} + fn resolve_socket(global: &GlobalArgs) -> PathBuf { if let Some(path) = &global.socket { return path.clone(); @@ -1330,3 +1341,23 @@ fn atom(value: Option<&Value>) -> String { Some(value) => value.to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_verb_is_registered_as_local_with_help() { + let plugin = verb_by_name("plugin").expect("plugin verb registered"); + assert!(matches!(plugin.kind, VerbKind::Local(_))); + assert!(plugin.allowed.contains(&"name")); + assert!(plugin.allowed.contains(&"force")); + assert!(plugin.allowed.contains(&"builtin")); + assert!(plugin.help.contains("sidebar plugins")); + } + + #[test] + fn registered_verbs_have_help_text() { + assert!(VERBS.iter().all(|verb| !verb.help.is_empty())); + } +} diff --git a/mux/crates/mux-tui/src/config.rs b/mux/crates/mux-tui/src/config.rs index 745b5daa757b..fc3f6905641f 100644 --- a/mux/crates/mux-tui/src/config.rs +++ b/mux/crates/mux-tui/src/config.rs @@ -89,6 +89,9 @@ //! keys. use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use mux_core::SidebarPluginOptions; @@ -96,7 +99,7 @@ use mux_core::SurfaceOptions; use mux_core::platform; use ratatui::style::Color; use serde::{Deserialize, Deserializer}; -use serde_json::Value; +use serde_json::{Value, json}; /// For a field typed `Option>`: makes an explicit `null` in the /// input deserialize to `Some(None)` rather than the `None` an absent key @@ -736,6 +739,12 @@ pub struct Config { pub keys: Keys, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SidebarPluginConfig { + pub command: Vec, + pub cwd: Option, +} + /// Load the config: defaults, overlaid with the user's Ghostty selection /// colors, overlaid with `mux.json`. pub fn load() -> Config { @@ -917,6 +926,81 @@ fn load_raw_config() -> RawConfig { } } +pub fn config_path() -> anyhow::Result { + platform::config_path().ok_or_else(|| anyhow::anyhow!("could not resolve mux config path")) +} + +pub fn write_sidebar_plugin(plugin: Option<&SidebarPluginConfig>) -> anyhow::Result { + let path = config_path()?; + write_sidebar_plugin_at_path(&path, plugin)?; + Ok(path) +} + +pub fn write_sidebar_plugin_at_path( + path: &Path, + plugin: Option<&SidebarPluginConfig>, +) -> anyhow::Result<()> { + let mut root = read_config_value(path)?; + let Some(root_object) = root.as_object_mut() else { + anyhow::bail!("{} must contain a JSON object", path.display()); + }; + match plugin { + Some(plugin) => { + let sidebar = root_object.entry("sidebar").or_insert_with(|| json!({})); + if !sidebar.is_object() { + *sidebar = json!({}); + } + let sidebar_object = sidebar.as_object_mut().expect("sidebar was just made an object"); + let mut plugin_value = json!({ "command": &plugin.command }); + if let Some(cwd) = &plugin.cwd { + plugin_value["cwd"] = json!(cwd); + } + sidebar_object.insert("plugin".to_string(), plugin_value); + } + None => { + if let Some(sidebar) = root_object.get_mut("sidebar") + && let Some(sidebar_object) = sidebar.as_object_mut() + { + sidebar_object.remove("plugin"); + } + } + } + write_config_value_atomic(path, &root) +} + +fn read_config_value(path: &Path) -> anyhow::Result { + match std::fs::read_to_string(path) { + Ok(text) if text.trim().is_empty() => Ok(json!({})), + Ok(text) => serde_json::from_str(&text) + .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display())), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), + Err(err) => Err(anyhow::anyhow!("failed to read {}: {err}", path.display())), + } +} + +fn write_config_value_atomic(path: &Path, value: &Value) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("mux.json"); + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); + let tmp_path = parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), stamp)); + let result = (|| -> anyhow::Result<()> { + let mut file = std::fs::File::create(&tmp_path)?; + serde_json::to_writer_pretty(&mut file, value)?; + file.write_all(b"\n")?; + file.sync_all()?; + drop(file); + std::fs::rename(&tmp_path, path)?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp_path); + } + result +} + /// `#rrggbb`, `#rgb`, or an xterm-256 index in a string. fn parse_color(s: &str) -> Option { let s = s.trim(); @@ -1242,4 +1326,46 @@ mod tests { ); assert_eq!(config.browser.capture_scale, None); } + + #[test] + fn sidebar_plugin_write_preserves_unrelated_config_keys() { + let dir = std::env::temp_dir().join(format!( + "mux-config-write-test-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("mux.json"); + std::fs::write( + &path, + r#"{ + "theme": {"sidebar_rail": 42}, + "sidebar": {"width": 31}, + "future": {"unknown": true} + }"#, + ) + .unwrap(); + + write_sidebar_plugin_at_path( + &path, + Some(&SidebarPluginConfig { + command: vec!["/tmp/plugin".to_string(), "--mode".to_string(), "test".to_string()], + cwd: Some("/tmp".to_string()), + }), + ) + .unwrap(); + let value: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(value["theme"]["sidebar_rail"], json!(42)); + assert_eq!(value["sidebar"]["width"], json!(31)); + assert_eq!(value["future"]["unknown"], json!(true)); + assert_eq!(value["sidebar"]["plugin"]["command"][0], json!("/tmp/plugin")); + assert_eq!(value["sidebar"]["plugin"]["cwd"], json!("/tmp")); + + write_sidebar_plugin_at_path(&path, None).unwrap(); + let value: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(value["sidebar"]["width"], json!(31)); + assert!(value["sidebar"].get("plugin").is_none()); + assert_eq!(value["future"]["unknown"], json!(true)); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/mux/crates/mux-tui/src/main.rs b/mux/crates/mux-tui/src/main.rs index 9faa541283b6..4e99b16b8cd4 100644 --- a/mux/crates/mux-tui/src/main.rs +++ b/mux/crates/mux-tui/src/main.rs @@ -12,6 +12,7 @@ mod cli; mod config; mod host_colors; mod keys; +mod plugin_manager; mod session; mod ui; @@ -47,6 +48,7 @@ USAGE: cmux-mux [OPTIONS] Start a session (TUI + control socket) cmux-mux attach [OPTIONS] Attach to an existing session's socket cmux-mux [OPTIONS] Run one control-socket command + cmux-mux plugin Manage sidebar plugins locally OPTIONS: --session Session name (default: main). Determines the socket path. @@ -85,6 +87,15 @@ CLI VERBS select-workspace, move-tab, move-workspace, scroll-surface, subscribe, attach-surface, wait-for, run, send-key, copy, ids, notify, list-agents, report-agent + +PLUGIN VERBS (local; no socket protocol command) + plugin install [--name ] [--force] + plugin list [--json] + plugin use + plugin use --builtin + plugin disable + plugin update + plugin remove "; struct Args { @@ -149,7 +160,7 @@ fn main() { install_signal_handlers(); let raw_args = std::env::args().skip(1).collect::>(); if raw_args.first().map(|arg| arg.as_str()) == Some("help") { - print!("{USAGE}"); + cli::print_help(USAGE); std::process::exit(0); } if cli::is_cli_invocation(&raw_args) { diff --git a/mux/crates/mux-tui/src/plugin_manager.rs b/mux/crates/mux-tui/src/plugin_manager.rs new file mode 100644 index 000000000000..be6bcea6ddc6 --- /dev/null +++ b/mux/crates/mux-tui/src/plugin_manager.rs @@ -0,0 +1,582 @@ +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use mux_core::platform::transport; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::config::{self, SidebarPluginConfig}; + +#[derive(Debug, Clone, Default)] +pub struct CliOptions { + pub json: bool, + pub socket: Option, + pub session: Option, + pub name: Option, + pub force: bool, + pub builtin: bool, +} + +#[derive(Debug)] +enum ManagerError { + Usage(String), + Failure(anyhow::Error), +} + +impl From for ManagerError { + fn from(error: anyhow::Error) -> Self { + Self::Failure(error) + } +} + +impl From for ManagerError { + fn from(error: std::io::Error) -> Self { + Self::Failure(error.into()) + } +} + +impl From for ManagerError { + fn from(error: serde_json::Error) -> Self { + Self::Failure(error.into()) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct PluginManifest { + plugin: ManifestPlugin, + run: ManifestRun, + build: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ManifestPlugin { + name: String, + kind: String, + version: Option, + description: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ManifestRun { + command: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct ManifestBuild { + command: Vec, +} + +#[derive(Debug, Clone)] +struct InstalledPlugin { + manifest: PluginManifest, + dir: PathBuf, + selected: bool, +} + +pub fn run(positionals: &[String], options: CliOptions) -> i32 { + let result = match positionals.first().map(String::as_str) { + Some("install") => install_command(positionals, &options), + Some("list") => list_command(positionals, &options), + Some("use") => use_command(positionals, &options), + Some("disable") => disable_command(positionals, &options), + Some("update") => update_command(positionals, &options), + Some("remove") => remove_command(positionals, &options), + Some(other) => Err(ManagerError::Usage(format!("unknown plugin subcommand {other:?}"))), + None => Err(ManagerError::Usage("plugin subcommand is required".to_string())), + }; + match result { + Ok(()) => 0, + Err(ManagerError::Usage(message)) => { + eprintln!("cmux-mux: {message}"); + 2 + } + Err(ManagerError::Failure(error)) => { + eprintln!("cmux-mux: {error}"); + 1 + } + } +} + +fn install_command(positionals: &[String], options: &CliOptions) -> Result<(), ManagerError> { + reject_plugin_flags(options, true, true, false)?; + if positionals.len() != 2 { + return Err(ManagerError::Usage( + "usage: cmux-mux plugin install [--name ] [--force]".to_string(), + )); + } + let root = install_root()?; + fs::create_dir_all(&root)?; + let temp_dir = root.join(format!(".install-{}-{}", std::process::id(), now_nanos())); + let clone_result = + run_git(["clone", "--depth", "1", positionals[1].as_str()], Some(&temp_dir), None); + if let Err(error) = clone_result { + let _ = fs::remove_dir_all(&temp_dir); + return Err(error.into()); + } + + let result = (|| -> Result<(), ManagerError> { + let manifest = read_manifest(&temp_dir)?; + let name = installed_name(&manifest, options.name.as_deref())?; + let target = root.join(&name); + if target.exists() && !options.force { + return Err(ManagerError::Failure(anyhow::anyhow!( + "plugin {name:?} is already installed at {}; use --force to replace it", + target.display() + ))); + } + run_build_if_needed(&manifest, &temp_dir)?; + let command = resolved_run_command(&manifest, &temp_dir)?; + verify_executable(&command[0])?; + if target.exists() { + fs::remove_dir_all(&target)?; + } + fs::rename(&temp_dir, &target)?; + println!("installed {}{} at {}", name, version_suffix(&manifest), target.display()); + println!("next: cmux-mux plugin use {name}"); + Ok(()) + })(); + if result.is_err() && temp_dir.exists() { + let _ = fs::remove_dir_all(&temp_dir); + } + result +} + +fn list_command(positionals: &[String], options: &CliOptions) -> Result<(), ManagerError> { + reject_plugin_flags(options, false, false, false)?; + if positionals.len() != 1 { + return Err(ManagerError::Usage("usage: cmux-mux plugin list [--json]".to_string())); + } + let plugins = installed_plugins()?; + if options.json { + let value = json!({ + "plugins": plugins.iter().map(plugin_json).collect::>(), + }); + println!("{}", serde_json::to_string(&value)?); + } else { + for plugin in plugins { + println!( + "{}\t{}\t{}\t{}\t{}", + plugin.manifest.plugin.name, + plugin.manifest.plugin.version.as_deref().unwrap_or(""), + if plugin.selected { "selected" } else { "" }, + plugin.dir.display(), + plugin.manifest.plugin.description.as_deref().unwrap_or("") + ); + } + } + Ok(()) +} + +fn use_command(positionals: &[String], options: &CliOptions) -> Result<(), ManagerError> { + reject_plugin_flags(options, false, false, true)?; + match (positionals.len(), options.builtin) { + (1, true) => return write_builtin_config(options), + (2, false) => {} + _ => { + return Err(ManagerError::Usage( + "usage: cmux-mux plugin use | cmux-mux plugin use --builtin".to_string(), + )); + } + } + let name = &positionals[1]; + validate_plugin_name(name)?; + let dir = install_root()?.join(name); + if !dir.is_dir() { + return Err(ManagerError::Failure(anyhow::anyhow!("plugin {name:?} is not installed"))); + } + let manifest = read_manifest(&dir)?; + let command = resolved_run_command(&manifest, &dir)?; + verify_executable(&command[0])?; + let cwd = canonical_path(&dir)?; + let path = config::write_sidebar_plugin(Some(&SidebarPluginConfig { + command, + cwd: Some(cwd.display().to_string()), + }))?; + println!("using {name}; wrote {}", path.display()); + report_reload_config(options); + Ok(()) +} + +fn disable_command(positionals: &[String], options: &CliOptions) -> Result<(), ManagerError> { + reject_plugin_flags(options, false, false, false)?; + if positionals.len() != 1 { + return Err(ManagerError::Usage("usage: cmux-mux plugin disable".to_string())); + } + write_builtin_config(options) +} + +fn update_command(positionals: &[String], options: &CliOptions) -> Result<(), ManagerError> { + reject_plugin_flags(options, false, false, false)?; + if positionals.len() != 2 { + return Err(ManagerError::Usage("usage: cmux-mux plugin update ".to_string())); + } + let name = &positionals[1]; + validate_plugin_name(name)?; + let dir = install_root()?.join(name); + if !dir.is_dir() { + return Err(ManagerError::Failure(anyhow::anyhow!("plugin {name:?} is not installed"))); + } + run_git(["pull", "--ff-only"], None, Some(&dir))?; + let manifest = read_manifest(&dir)?; + run_build_if_needed(&manifest, &dir)?; + let command = resolved_run_command(&manifest, &dir)?; + verify_executable(&command[0])?; + println!("updated {name}{}", version_suffix(&manifest)); + Ok(()) +} + +fn remove_command(positionals: &[String], options: &CliOptions) -> Result<(), ManagerError> { + reject_plugin_flags(options, false, false, false)?; + if positionals.len() != 2 { + return Err(ManagerError::Usage("usage: cmux-mux plugin remove ".to_string())); + } + let name = &positionals[1]; + validate_plugin_name(name)?; + let dir = install_root()?.join(name); + if !dir.exists() { + return Err(ManagerError::Failure(anyhow::anyhow!("plugin {name:?} is not installed"))); + } + let selected = selected_plugin_cwd()?.is_some_and(|cwd| same_path(&cwd, &dir)); + fs::remove_dir_all(&dir)?; + println!("removed {name}"); + if selected { + let path = config::write_sidebar_plugin(None)?; + println!("cleared sidebar.plugin in {}", path.display()); + report_reload_config(options); + } + Ok(()) +} + +fn write_builtin_config(options: &CliOptions) -> Result<(), ManagerError> { + let path = config::write_sidebar_plugin(None)?; + println!("using built-in sidebar; wrote {}", path.display()); + report_reload_config(options); + Ok(()) +} + +fn reject_plugin_flags( + options: &CliOptions, + allow_name: bool, + allow_force: bool, + allow_builtin: bool, +) -> Result<(), ManagerError> { + if !allow_name && options.name.is_some() { + return Err(ManagerError::Usage("--name is only valid for plugin install".to_string())); + } + if !allow_force && options.force { + return Err(ManagerError::Usage("--force is only valid for plugin install".to_string())); + } + if !allow_builtin && options.builtin { + return Err(ManagerError::Usage("--builtin is only valid for plugin use".to_string())); + } + Ok(()) +} + +fn installed_plugins() -> anyhow::Result> { + let root = install_root()?; + let selected = selected_plugin_cwd()?; + let mut plugins = Vec::new(); + let Ok(entries) = fs::read_dir(&root) else { return Ok(plugins) }; + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let dir = entry.path(); + if dir.file_name().and_then(|name| name.to_str()).is_some_and(|name| name.starts_with('.')) + { + continue; + } + match read_manifest(&dir) { + Ok(manifest) => { + let selected = selected.as_ref().is_some_and(|cwd| same_path(cwd, &dir)); + plugins.push(InstalledPlugin { manifest, dir, selected }); + } + Err(error) => eprintln!("cmux-mux: skipping invalid plugin {}: {error}", dir.display()), + } + } + plugins.sort_by(|a, b| a.manifest.plugin.name.cmp(&b.manifest.plugin.name)); + Ok(plugins) +} + +fn read_manifest(dir: &Path) -> anyhow::Result { + let path = dir.join("cmux-plugin.toml"); + let text = fs::read_to_string(&path) + .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", path.display()))?; + parse_manifest(&text) +} + +fn parse_manifest(text: &str) -> anyhow::Result { + let manifest: PluginManifest = + toml::from_str(text).map_err(|err| anyhow::anyhow!("invalid cmux-plugin.toml: {err}"))?; + validate_manifest(&manifest)?; + Ok(manifest) +} + +fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { + validate_plugin_name(&manifest.plugin.name)?; + if manifest.plugin.kind != "sidebar" { + anyhow::bail!("plugin.kind must be \"sidebar\""); + } + if manifest.run.command.first().is_none_or(|command| command.trim().is_empty()) { + anyhow::bail!("run.command must not be empty"); + } + if let Some(build) = &manifest.build + && build.command.first().is_none_or(|command| command.trim().is_empty()) + { + anyhow::bail!("build.command must not be empty when present"); + } + Ok(()) +} + +fn validate_plugin_name(name: &str) -> anyhow::Result<()> { + if name.is_empty() + || !name.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_' + }) + { + anyhow::bail!("plugin name must match [a-z0-9-_]+"); + } + Ok(()) +} + +fn installed_name( + manifest: &PluginManifest, + override_name: Option<&str>, +) -> anyhow::Result { + match override_name { + Some(name) => { + validate_plugin_name(name)?; + Ok(name.to_string()) + } + None => Ok(manifest.plugin.name.clone()), + } +} + +fn run_build_if_needed(manifest: &PluginManifest, dir: &Path) -> anyhow::Result<()> { + let Some(build) = &manifest.build else { return Ok(()) }; + let status = + Command::new(&build.command[0]).args(&build.command[1..]).current_dir(dir).status()?; + if !status.success() { + anyhow::bail!("build command failed with status {status}"); + } + Ok(()) +} + +fn resolved_run_command(manifest: &PluginManifest, dir: &Path) -> anyhow::Result> { + let mut command = manifest.run.command.clone(); + let first = Path::new(&command[0]); + if first.is_relative() { + command[0] = canonical_path(&dir.join(first))?.display().to_string(); + } + Ok(command) +} + +fn verify_executable(path: &str) -> anyhow::Result<()> { + let path = Path::new(path); + let metadata = fs::metadata(path).map_err(|err| { + anyhow::anyhow!("run.command[0] {} is not readable: {err}", path.display()) + })?; + if !metadata.is_file() { + anyhow::bail!("run.command[0] {} is not a file", path.display()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + anyhow::bail!("run.command[0] {} is not executable", path.display()); + } + } + Ok(()) +} + +fn run_git( + args: [&str; N], + final_arg_path: Option<&Path>, + current_dir: Option<&Path>, +) -> anyhow::Result<()> { + let mut command = Command::new("git"); + command.args(["-c", "protocol.file.allow=always"]).args(args); + if let Some(path) = final_arg_path { + command.arg(path); + } + if let Some(dir) = current_dir { + command.current_dir(dir); + } + let status = command.status()?; + if !status.success() { + anyhow::bail!("git failed with status {status}"); + } + Ok(()) +} + +fn install_root() -> anyhow::Result { + if let Some(data_home) = non_empty_env_path("XDG_DATA_HOME") { + return Ok(data_home.join("cmux").join("mux-plugins")); + } + let home = mux_core::platform::home_dir() + .ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?; + Ok(home.join(".local").join("share").join("cmux").join("mux-plugins")) +} + +fn selected_plugin_cwd() -> anyhow::Result> { + let path = config::config_path()?; + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(anyhow::anyhow!("failed to read {}: {err}", path.display())), + }; + let value: Value = serde_json::from_str(&text) + .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display()))?; + Ok(value + .get("sidebar") + .and_then(|sidebar| sidebar.get("plugin")) + .and_then(|plugin| plugin.get("cwd")) + .and_then(Value::as_str) + .map(PathBuf::from)) +} + +fn report_reload_config(options: &CliOptions) { + let socket = resolve_socket(options); + match send_reload_config(&socket) { + Ok(()) => println!("reload-config: sent to {}", socket.display()), + Err(error) => { + println!( + "reload-config: not sent to {} ({error}); run cmux-mux reload-config", + socket.display() + ); + } + } +} + +fn send_reload_config(socket: &Path) -> anyhow::Result<()> { + let mut stream = transport::connect(socket)?; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + stream.write_all(br#"{"id":1,"cmd":"reload-config"}"#)?; + stream.write_all(b"\n")?; + let mut reader = BufReader::new(stream); + loop { + let mut line = String::new(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + anyhow::bail!("transport closed before response"); + } + let value: Value = serde_json::from_str(&line)?; + if value.get("event").is_some() { + continue; + } + if value.get("ok").and_then(Value::as_bool) == Some(true) { + return Ok(()); + } + let error = value.get("error").and_then(Value::as_str).unwrap_or("unknown error"); + anyhow::bail!("{error}"); + } +} + +fn resolve_socket(options: &CliOptions) -> PathBuf { + if let Some(socket) = &options.socket { + return socket.clone(); + } + if let Some(socket) = std::env::var_os("CMUX_MUX_SOCKET") + && !socket.is_empty() + { + return PathBuf::from(socket); + } + mux_core::server::default_socket_path(options.session.as_deref().unwrap_or("main")) +} + +fn plugin_json(plugin: &InstalledPlugin) -> Value { + json!({ + "name": &plugin.manifest.plugin.name, + "version": &plugin.manifest.plugin.version, + "description": &plugin.manifest.plugin.description, + "dir": plugin.dir.display().to_string(), + "selected": plugin.selected, + }) +} + +fn version_suffix(manifest: &PluginManifest) -> String { + manifest.plugin.version.as_ref().map(|version| format!(" {version}")).unwrap_or_default() +} + +fn canonical_path(path: &Path) -> anyhow::Result { + fs::canonicalize(path) + .map_err(|err| anyhow::anyhow!("failed to resolve {}: {err}", path.display())) +} + +fn same_path(left: &Path, right: &Path) -> bool { + let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + left == right +} + +fn non_empty_env_path(name: &str) -> Option { + std::env::var_os(name).filter(|value| !value.is_empty()).map(PathBuf::from) +} + +fn now_nanos() -> u128 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest_text(name: &str) -> String { + format!( + r#" + [plugin] + name = "{name}" + kind = "sidebar" + version = "0.1.0" + description = "test plugin" + + [run] + command = ["bin/sidebar"] + "# + ) + } + + #[test] + fn manifest_parse_validates_required_fields() { + let manifest = parse_manifest(&manifest_text("fzf")).unwrap(); + assert_eq!(manifest.plugin.name, "fzf"); + assert_eq!(manifest.plugin.kind, "sidebar"); + assert_eq!(manifest.run.command, vec!["bin/sidebar"]); + } + + #[test] + fn manifest_rejects_bad_kind() { + let text = manifest_text("fzf").replace("sidebar", "pane"); + let error = parse_manifest(&text).unwrap_err().to_string(); + assert!(error.contains("plugin.kind")); + } + + #[test] + fn manifest_rejects_bad_name_chars() { + let error = parse_manifest(&manifest_text("../bad")).unwrap_err().to_string(); + assert!(error.contains("[a-z0-9-_]+")); + } + + #[test] + fn manifest_rejects_missing_run_command() { + let text = r#" + [plugin] + name = "fzf" + kind = "sidebar" + "#; + let error = parse_manifest(text).unwrap_err().to_string(); + assert!(error.contains("missing field `run`") || error.contains("run.command")); + } + + #[test] + fn installed_name_uses_manifest_or_override() { + let manifest = parse_manifest(&manifest_text("fzf")).unwrap(); + assert_eq!(installed_name(&manifest, None).unwrap(), "fzf"); + assert_eq!(installed_name(&manifest, Some("custom-name")).unwrap(), "custom-name"); + assert!(installed_name(&manifest, Some("Bad")).is_err()); + } +} diff --git a/mux/crates/mux-tui/tests/cli.rs b/mux/crates/mux-tui/tests/cli.rs index ea0e3b7c9d7d..6bf2fc99e895 100644 --- a/mux/crates/mux-tui/tests/cli.rs +++ b/mux/crates/mux-tui/tests/cli.rs @@ -273,6 +273,139 @@ fn stream_preserves_partial_line_across_read_timeout() { ); } +#[test] +fn help_lists_plugin_verbs() { + let output = Command::new(bin()).arg("--help").env_remove("CMUX_MUX_SOCKET").output().unwrap(); + assert_success(&output); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("plugin install ")); + assert!(stdout.contains("plugin use --builtin")); + assert!(stdout.contains("Manage installed sidebar plugins locally.")); +} + +#[cfg(unix)] +#[test] +fn plugin_install_use_and_list_work_against_local_git_repo() { + let dir = unique_temp_dir("plugin-install"); + let source = dir.join("source"); + // The runnable is NOT committed: [build] must create it, so this fixture + // exercises the build step and the post-build executable verification. + fs::create_dir_all(&source).unwrap(); + fs::write( + source.join("cmux-plugin.toml"), + r#" + [plugin] + name = "fixture" + kind = "sidebar" + version = "0.1.0" + description = "Fixture sidebar" + + [run] + command = ["bin/sidebar"] + + [build] + command = ["/bin/sh", "build.sh"] + "#, + ) + .unwrap(); + let build_script = concat!( + "#!/bin/sh\n", + "mkdir -p bin\n", + "cat > bin/sidebar <<'EOF'\n", + "#!/bin/sh\n", + "printf 'fixture sidebar\\n'\n", + "EOF\n", + "chmod 755 bin/sidebar\n" + ); + fs::write(source.join("build.sh"), build_script).unwrap(); + git(&source, &["init"]); + git(&source, &["add", "."]); + git( + &source, + &[ + "-c", + "user.name=cmux", + "-c", + "user.email=cmux@example.invalid", + "commit", + "-m", + "fixture", + ], + ); + + let data_home = dir.join("data"); + let config_path = dir.join("config").join("mux.json"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, r#"{"future":{"keep":true},"sidebar":{"width":33}}"#).unwrap(); + let missing_socket = dir.join("missing.sock"); + let url = format!("file://{}", source.display()); + + let install = plugin_cli( + &data_home, + &config_path, + &[ + "--socket", + missing_socket.to_str().unwrap(), + "plugin", + "install", + &url, + "--name", + "fixture", + ], + ); + assert_success(&install); + assert!(String::from_utf8_lossy(&install.stdout).contains("next: cmux-mux plugin use fixture")); + let installed_dir = data_home.join("cmux").join("mux-plugins").join("fixture"); + assert!(installed_dir.join("cmux-plugin.toml").is_file()); + + let list = plugin_cli(&data_home, &config_path, &["--json", "plugin", "list"]); + assert_success(&list); + let listed: serde_json::Value = serde_json::from_slice(&list.stdout).unwrap(); + assert_eq!(listed["plugins"][0]["name"].as_str(), Some("fixture")); + assert_eq!(listed["plugins"][0]["selected"].as_bool(), Some(false)); + + let use_plugin = plugin_cli( + &data_home, + &config_path, + &["--socket", missing_socket.to_str().unwrap(), "plugin", "use", "fixture"], + ); + assert_success(&use_plugin); + let stdout = String::from_utf8(use_plugin.stdout).unwrap(); + assert!(stdout.contains("using fixture")); + assert!(stdout.contains("reload-config: not sent")); + + let written: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + assert_eq!(written["future"]["keep"].as_bool(), Some(true)); + assert_eq!(written["sidebar"]["width"].as_u64(), Some(33)); + // plugin use canonicalizes paths; /tmp is a symlink to /private/tmp on + // macOS, so compare against the canonicalized install dir. + let canonical_dir = fs::canonicalize(&installed_dir).unwrap(); + assert_eq!(written["sidebar"]["plugin"]["cwd"].as_str(), Some(canonical_dir.to_str().unwrap())); + assert_eq!( + written["sidebar"]["plugin"]["command"][0].as_str(), + Some(canonical_dir.join("bin/sidebar").to_str().unwrap()) + ); + + let list = plugin_cli(&data_home, &config_path, &["--json", "plugin", "list"]); + assert_success(&list); + let listed: serde_json::Value = serde_json::from_slice(&list.stdout).unwrap(); + assert_eq!(listed["plugins"][0]["selected"].as_bool(), Some(true)); + + let builtin = plugin_cli( + &data_home, + &config_path, + &["--socket", missing_socket.to_str().unwrap(), "plugin", "use", "--builtin"], + ); + assert_success(&builtin); + let written: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + assert!(written["sidebar"].get("plugin").is_none()); + assert_eq!(written["future"]["keep"].as_bool(), Some(true)); + + let _ = fs::remove_dir_all(&dir); +} + fn wait_for_screen(server: &HeadlessServer, surface: u64, marker: &str) -> String { let deadline = Instant::now() + Duration::from_secs(10); let mut last = String::new(); @@ -288,6 +421,21 @@ fn wait_for_screen(server: &HeadlessServer, surface: u64, marker: &str) -> Strin last } +fn plugin_cli(data_home: &PathBuf, config_path: &PathBuf, args: &[&str]) -> Output { + Command::new(bin()) + .args(args) + .env("XDG_DATA_HOME", data_home) + .env("CMUX_MUX_CONFIG", config_path) + .env_remove("CMUX_MUX_SOCKET") + .output() + .unwrap() +} + +fn git(dir: &PathBuf, args: &[&str]) { + let output = Command::new("git").arg("-C").arg(dir).args(args).output().unwrap(); + assert_success(&output); +} + fn cli(server: &HeadlessServer, args: &[&str]) -> Output { Command::new(bin()) .args(["--socket"]) diff --git a/mux/docs/configuration.md b/mux/docs/configuration.md index 778c871cf861..f5a94c2779c0 100644 --- a/mux/docs/configuration.md +++ b/mux/docs/configuration.md @@ -45,6 +45,30 @@ Tabs are numbered by default. A recognized agent program can appear after the nu Live sidebar dragging also leaves at least 40 columns for pane content. +### Sidebar plugins + +Sidebar plugins can be installed from git repositories: + +```bash +cmux-mux plugin install https://github.com/manaflow-ai/cmux-sidebar-fzf +cmux-mux plugin use fzf +``` + +`plugin install` clones into `~/.local/share/cmux/mux-plugins/` (or +`$XDG_DATA_HOME/cmux/mux-plugins/`), validates `cmux-plugin.toml`, runs +the optional build command, and verifies the resolved run command is +executable. `plugin use ` writes `sidebar.plugin.command` as an absolute +argv and `sidebar.plugin.cwd` as the plugin directory, preserving unrelated +`mux.json` keys. A running TUI applies it after `reload-config`; `plugin use` +sends that reload automatically when the resolved session socket is reachable. + +Return to the built-in sidebar with either command: + +```bash +cmux-mux plugin use --builtin +cmux-mux plugin disable +``` + ## Browser | Key | Type | Default | Effect | diff --git a/mux/spec/cli.md b/mux/spec/cli.md index e0e2641ec19a..df8e3697ac91 100644 --- a/mux/spec/cli.md +++ b/mux/spec/cli.md @@ -99,6 +99,18 @@ The generated CLI requires one of `--index` or `--delta` for `select-tab`, `sele | `notify` | implemented | `--title --body <body>` | `--level info\|warning\|error`, `--surface <id>` | notification id | | `list-agents` | implemented | none | `--surface <id>`, `--state <state>` | agent lines | | `report-agent` | implemented | `--surface <id> --state <state> --source socket\|hook` | `--session <id>` | none | +| `plugin install` | implemented, CLI-only | `<git-url>` | `--name <name>`, `--force` | install summary and next step | +| `plugin list` | implemented, CLI-only | none | `--json` | installed plugin lines | +| `plugin use` | implemented, CLI-only | `<name>` or `--builtin` | global socket flags for best-effort reload | config write and reload status | +| `plugin disable` | implemented, CLI-only | none | global socket flags for best-effort reload | config write and reload status | +| `plugin update` | implemented, CLI-only | `<name>` | none | update summary | +| `plugin remove` | implemented, CLI-only | `<name>` | global socket flags for best-effort reload when selected | removal summary | + +The grouped `plugin ...` verbs run entirely in the `cmux-mux` CLI process. They +do not send plugin-specific socket commands and do not change the protocol. +`plugin use`, `plugin use --builtin`, `plugin disable`, and selected-plugin +removal edit `mux.json` locally, then best-effort send the existing +`reload-config` command to the resolved session socket. ## Worked Examples diff --git a/mux/spec/plugins.md b/mux/spec/plugins.md index 43c7b0b4adc6..5ab9c7badf33 100644 --- a/mux/spec/plugins.md +++ b/mux/spec/plugins.md @@ -73,10 +73,31 @@ command = ["target/release/cmux-sidebar-fzf"] command = ["cargo", "build", "--release"] ``` -The host reads the already-installed command from `mux.json` in this round. Plugin manager install/build verbs are separate follow-up work. The install-directory convention for that follow-up is: +The host reads the already-installed command from `mux.json`. The plugin +manager installs sidebar plugins from git repositories and writes the resolved +command into `mux.json`. + +## Install Layout + +Installed plugins live under: ```text ~/.local/share/cmux/mux-plugins/<name> ``` -Relative manifest commands are resolved by the plugin manager before it writes the runnable command into `mux.json`. +When `$XDG_DATA_HOME` is set, the equivalent directory is: + +```text +$XDG_DATA_HOME/cmux/mux-plugins/<name> +``` + +`<name>` is either `[plugin].name` from `cmux-plugin.toml` or the +`cmux-mux plugin install --name <override>` value. Names must match +`[a-z0-9-_]+`; path traversal and mixed-case names are rejected. Install clones +to a temporary directory first, validates the manifest, runs `[build].command` +when present, verifies the resolved `[run].command[0]` exists and is +executable, then moves the directory into place. Existing installs are refused +unless `--force` is supplied. + +Relative manifest run commands are resolved to absolute paths under the plugin +directory before `plugin use` writes the runnable command into `mux.json`.