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#[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
18fn 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
42pub 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
51fn 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 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 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 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
126pub 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 delay = if text.len() > 200 { 600 } else { 200 };
139 std::thread::sleep(std::time::Duration::from_millis(delay));
140 v2_call("surface.send_key", json!({
142 "surface_id": surface_id,
143 "key": "enter",
144 }))?;
145 Ok(())
146}
147
148pub 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 wait_for_stable_output(&surface_id, 15, 7);
180
181 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
201pub 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 loop {
217 if std::time::Instant::now() >= deadline {
218 return; }
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 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 if s.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '=')) {
236 s.to_string()
237 } else {
238 format!("'{}'", s.replace('\'', "'\\''"))
239 }
240}
241
242pub fn close(surface_id: &str) -> Result<()> {
244 v2_call(
245 "surface.close",
246 json!({ "surface_id": surface_id }),
247 )?;
248 Ok(())
249}
250
251pub fn list_surfaces() -> Result<Vec<SurfaceInfo>> {
253 let result = v2_call("surface.list", json!({}))?;
254
255 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 surface_type: s.get("type").and_then(|v| v.as_str()).unwrap_or("terminal").to_string(),
277 });
278 }
279 Ok(out)
280}
281
282pub 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
292pub 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 if let Some(b64) = result.get("base64").and_then(|v| v.as_str()) {
301 return base64_decode_str(b64);
302 }
303 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
330pub 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
336pub 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
345pub 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
357pub fn workspace_list() -> Result<Value> {
359 v2_call("workspace.list", json!({}))
360}
361
362pub fn system_tree() -> Result<Value> {
364 v2_call("system.tree", json!({}))
365}