Skip to main content

rz_cli/
cmux.rs

1use eyre::{bail, Context, Result};
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use std::io::{BufRead, BufReader, Write};
5use std::os::unix::net::UnixStream;
6
7/// Information about a cmux surface (analogous to a Zellij pane).
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SurfaceInfo {
10    pub id: String,
11    pub title: String,
12    pub workspace_id: String,
13    pub workspace_name: Option<String>,
14    pub is_focused: bool,
15    pub surface_type: String,
16}
17
18/// Generate a simple UUID v4-style random ID for JSON-RPC requests.
19fn generate_request_id() -> String {
20    use std::collections::hash_map::RandomState;
21    use std::hash::{BuildHasher, Hasher};
22    let s = RandomState::new();
23    let mut h = s.build_hasher();
24    h.write_u64(std::time::SystemTime::now()
25        .duration_since(std::time::UNIX_EPOCH)
26        .unwrap_or_default()
27        .as_nanos() as u64);
28    let a = h.finish();
29    let mut h2 = s.build_hasher();
30    h2.write_u64(a.wrapping_mul(6364136223846793005));
31    let b = h2.finish();
32    format!(
33        "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
34        (a >> 32) as u32,
35        (a >> 16) as u16 & 0xffff,
36        a as u16 & 0x0fff,
37        (b >> 48) as u16 & 0x3fff | 0x8000,
38        b & 0xffffffffffff
39    )
40}
41
42/// Resolve the cmux socket path.
43pub fn socket_path() -> Result<String> {
44    if let Ok(path) = std::env::var("CMUX_SOCKET_PATH") {
45        return Ok(path);
46    }
47    let home = std::env::var("HOME").wrap_err("HOME not set")?;
48    Ok(format!("{}/.local/share/cmux/cmux.sock", home))
49}
50
51/// Connect to the cmux socket and make a JSON-RPC v2 call.
52/// Each call creates a fresh connection (no pooling).
53fn v2_call(method: &str, params: Value) -> Result<Value> {
54    let path = socket_path()?;
55    let mut stream = UnixStream::connect(&path)
56        .wrap_err_with(|| format!("failed to connect to cmux socket at {}", path))?;
57
58    let id = generate_request_id();
59    let request = json!({
60        "id": id,
61        "method": method,
62        "params": params,
63    });
64
65    let mut payload = serde_json::to_string(&request)?;
66    payload.push('\n');
67    stream
68        .write_all(payload.as_bytes())
69        .wrap_err("failed to write to cmux socket")?;
70    stream.flush()?;
71
72    // Set a read timeout so we don't hang forever.
73    stream.set_read_timeout(Some(std::time::Duration::from_secs(15)))?;
74
75    let mut reader = BufReader::new(&stream);
76    let mut line = String::new();
77    reader
78        .read_line(&mut line)
79        .wrap_err("failed to read response from cmux socket")?;
80
81    if line.is_empty() {
82        bail!("empty response from cmux socket");
83    }
84
85    let trimmed = line.trim();
86
87    // cmux may return plain-text errors before JSON (e.g. "ERROR: Access denied ...")
88    if trimmed.starts_with("ERROR:") {
89        bail!("{}", trimmed);
90    }
91
92    let resp: Value = serde_json::from_str(trimmed)
93        .wrap_err("failed to parse cmux response")?;
94
95    // Check response ID matches
96    if resp.get("id").and_then(|v| v.as_str()) != Some(&id) {
97        bail!("response id mismatch");
98    }
99
100    if resp.get("ok") == Some(&json!(false)) {
101        let err = resp
102            .get("error")
103            .cloned()
104            .unwrap_or_else(|| json!({"message": "unknown error"}));
105        let msg = err
106            .get("message")
107            .and_then(|v| v.as_str())
108            .unwrap_or("unknown error");
109        let msg = if msg == "A JavaScript exception occurred" {
110            if method.contains("eval") {
111                "JavaScript error (check script syntax or that surface is still loaded)".to_string()
112            } else if method.contains("click") || method.contains("fill") {
113                "Element not found or surface not ready".to_string()
114            } else {
115                msg.to_string()
116            }
117        } else {
118            msg.to_string()
119        };
120        bail!("cmux error [{}]: {}", method, msg);
121    }
122
123    Ok(resp.get("result").cloned().unwrap_or(Value::Null))
124}
125
126/// Send text to a surface (terminal pane) and submit with Enter.
127///
128/// A short delay between paste and Enter is critical: TUI apps like
129/// Claude Code need time to process pasted text before the Enter key
130/// can trigger submission.
131pub fn send(surface_id: &str, text: &str) -> Result<()> {
132    v2_call("surface.send_text", json!({
133        "surface_id": surface_id,
134        "text": text,
135    }))?;
136    // Let the TUI process the pasted text before pressing Enter.
137    // Long messages (@@RZ: envelopes) need more time for the TUI to render.
138    let delay = if text.len() > 200 { 600 } else { 200 };
139    std::thread::sleep(std::time::Duration::from_millis(delay));
140    // surface.send_text pastes but doesn't submit — follow with Enter
141    v2_call("surface.send_key", json!({
142        "surface_id": surface_id,
143        "key": "enter",
144    }))?;
145    Ok(())
146}
147
148/// Spawn a new terminal surface in the current workspace, run a command in it.
149///
150/// Two-phase startup:
151///   Phase 1 — wait for the shell prompt (surface ready)
152///   Phase 2 — type and submit the command
153///
154/// The caller is responsible for a third phase: waiting for the command's
155/// own interactive prompt before sending further input (e.g. bootstrap).
156///
157/// Returns the new surface's ID.
158pub fn spawn(cmd: &str, args: &[&str], name: Option<&str>) -> Result<String> {
159    let workspace_id = std::env::var("CMUX_WORKSPACE_ID")
160        .wrap_err("CMUX_WORKSPACE_ID not set — are you running inside cmux?")?;
161
162    let mut params = json!({
163        "workspace_id": workspace_id,
164        "direction": "right",
165    });
166    if let Some(n) = name {
167        params["title"] = json!(n);
168    }
169
170    let result = v2_call("surface.split", params)?;
171    let surface_id = result
172        .get("surface_id")
173        .and_then(|v| v.as_str())
174        .map(|s| s.to_string())
175        .ok_or_else(|| eyre::eyre!("surface.split did not return surface_id"))?;
176
177    if !cmd.is_empty() {
178        // Phase 1: wait up to 15s for shell to appear, then settle 7s.
179        wait_for_stable_output(&surface_id, 15, 7);
180
181        // Type and submit the command.
182        let mut full_cmd = shell_escape_arg(cmd);
183        for arg in args {
184            full_cmd.push(' ');
185            full_cmd.push_str(&shell_escape_arg(arg));
186        }
187
188        v2_call("surface.send_text", json!({
189            "surface_id": surface_id,
190            "text": full_cmd,
191        }))?;
192        v2_call("surface.send_key", json!({
193            "surface_id": surface_id,
194            "key": "enter",
195        }))?;
196    }
197
198    Ok(surface_id)
199}
200
201/// Wait until a surface has output, then wait a fixed settle time.
202///
203/// Two-step: first poll until any output appears (the process has started),
204/// then sleep `settle_secs` to let the process finish its loading sequence
205/// and reach its interactive prompt.
206///
207/// `max_secs`    — give up and proceed after this many seconds regardless
208/// `settle_secs` — fixed pause after output first appears
209///
210/// Always returns — callers should proceed regardless.
211pub fn wait_for_stable_output(surface_id: &str, max_secs: u64, settle_secs: u64) {
212    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(max_secs);
213    let poll = std::time::Duration::from_millis(300);
214
215    // Step 1: wait until any output appears
216    loop {
217        if std::time::Instant::now() >= deadline {
218            return; // nothing appeared — proceed anyway
219        }
220        let text = read_text(surface_id).unwrap_or_default();
221        if !text.trim().is_empty() {
222            break;
223        }
224        std::thread::sleep(poll);
225    }
226
227    // Step 2: fixed settle time, capped at remaining budget
228    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
229    let settle = std::time::Duration::from_secs(settle_secs).min(remaining);
230    std::thread::sleep(settle);
231}
232
233pub fn shell_escape_arg(s: &str) -> String {
234    // Single-quote wrap with internal single-quote escaping
235    if s.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '=')) {
236        s.to_string()
237    } else {
238        format!("'{}'", s.replace('\'', "'\\''"))
239    }
240}
241
242/// Close a surface.
243pub fn close(surface_id: &str) -> Result<()> {
244    v2_call(
245        "surface.close",
246        json!({ "surface_id": surface_id }),
247    )?;
248    Ok(())
249}
250
251/// List all surfaces with full info.
252pub fn list_surfaces() -> Result<Vec<SurfaceInfo>> {
253    let result = v2_call("surface.list", json!({}))?;
254
255    // Response is { surfaces: [...], workspace_id: ..., ... }
256    let workspace_id = result
257        .get("workspace_id")
258        .and_then(|v| v.as_str())
259        .unwrap_or("")
260        .to_string();
261
262    let surfaces = result
263        .get("surfaces")
264        .and_then(|v| v.as_array())
265        .ok_or_else(|| eyre::eyre!("surface.list did not return surfaces array"))?;
266
267    let mut out = Vec::with_capacity(surfaces.len());
268    for s in surfaces {
269        out.push(SurfaceInfo {
270            id: s.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
271            title: s.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(),
272            workspace_id: workspace_id.clone(),
273            workspace_name: None,
274            is_focused: s.get("focused").and_then(|v| v.as_bool()).unwrap_or(false),
275            // API uses "type" not "surface_type"
276            surface_type: s.get("type").and_then(|v| v.as_str()).unwrap_or("terminal").to_string(),
277        });
278    }
279    Ok(out)
280}
281
282/// List IDs of terminal surfaces only.
283pub fn list_surface_ids() -> Result<Vec<String>> {
284    let surfaces = list_surfaces()?;
285    Ok(surfaces
286        .into_iter()
287        .filter(|s| s.surface_type == "terminal")
288        .map(|s| s.id)
289        .collect())
290}
291
292/// Read text content from a surface's terminal.
293pub fn read_text(surface_id: &str) -> Result<String> {
294    let result = v2_call(
295        "surface.read_text",
296        json!({ "surface_id": surface_id }),
297    )?;
298
299    // API returns { base64: "..." } — decode it
300    if let Some(b64) = result.get("base64").and_then(|v| v.as_str()) {
301        return base64_decode_str(b64);
302    }
303    // Fallback: plain text field or raw string
304    result
305        .get("text").and_then(|v| v.as_str()).map(|s| s.to_string())
306        .or_else(|| result.as_str().map(|s| s.to_string()))
307        .ok_or_else(|| eyre::eyre!("surface.read_text did not return text or base64"))
308}
309
310fn base64_decode_str(input: &str) -> Result<String> {
311    let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
312    let mut buf = Vec::with_capacity(input.len() * 3 / 4);
313    let mut acc: u32 = 0;
314    let mut bits: u32 = 0;
315    for &byte in input.as_bytes() {
316        if byte == b'=' || byte == b'\n' || byte == b'\r' || byte == b' ' { continue; }
317        let val = table.iter().position(|&b| b == byte)
318            .ok_or_else(|| eyre::eyre!("invalid base64 character: {}", byte as char))? as u32;
319        acc = (acc << 6) | val;
320        bits += 6;
321        if bits >= 8 {
322            bits -= 8;
323            buf.push((acc >> bits) as u8);
324            acc &= (1 << bits) - 1;
325        }
326    }
327    String::from_utf8(buf).wrap_err("surface text is not valid UTF-8")
328}
329
330/// Get this surface's own ID from the environment.
331pub fn own_surface_id() -> Result<String> {
332    std::env::var("CMUX_SURFACE_ID")
333        .wrap_err("CMUX_SURFACE_ID not set — are you running inside cmux?")
334}
335
336/// Create a notification.
337pub fn notify(title: &str, body: Option<&str>, surface_id: Option<&str>) -> Result<()> {
338    let mut params = json!({ "title": title });
339    if let Some(b) = body { params["body"] = json!(b); }
340    if let Some(s) = surface_id { params["surface_id"] = json!(s); }
341    v2_call("notification.create", params)?;
342    Ok(())
343}
344
345/// Create a new workspace. Returns workspace_id.
346pub fn workspace_create(name: Option<&str>, cwd: Option<&str>) -> Result<String> {
347    let mut params = json!({});
348    if let Some(n) = name { params["name"] = json!(n); }
349    if let Some(c) = cwd { params["cwd"] = json!(c); }
350    let result = v2_call("workspace.create", params)?;
351    result.get("workspace_id")
352        .and_then(|v| v.as_str())
353        .map(|s| s.to_string())
354        .ok_or_else(|| eyre::eyre!("workspace.create did not return workspace_id"))
355}
356
357/// List all workspaces.
358pub fn workspace_list() -> Result<Value> {
359    v2_call("workspace.list", json!({}))
360}
361
362/// Get full system tree.
363pub fn system_tree() -> Result<Value> {
364    v2_call("system.tree", json!({}))
365}