Skip to main content

claw_core_protocol/
client.rs

1use crate::types::*;
2use anyhow::{bail, Context, Result};
3use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
4use tokio::net::UnixStream;
5
6/// Default daemon socket path.
7pub const DEFAULT_SOCKET_PATH: &str = "/tmp/trl.sock";
8
9/// Async client for the claw_core daemon.
10///
11/// Communicates over a Unix socket using line-delimited JSON (one request per
12/// line, one response per line).
13///
14/// # Example
15///
16/// ```no_run
17/// use claw_core_protocol::{ClawCoreClient, CreateSessionParams, ExecRunParams};
18///
19/// # async fn example() -> anyhow::Result<()> {
20/// let mut client = ClawCoreClient::connect("/tmp/trl.sock").await?;
21///
22/// let session = client.create_session(CreateSessionParams::default()).await?;
23/// let result = client.exec_run(ExecRunParams {
24///     session_id: session.session_id.clone(),
25///     command: "echo hello".into(),
26///     timeout_s: Some(30),
27///     stdin: None,
28///     env: None,
29/// }).await?;
30///
31/// println!("stdout: {}", result.stdout);
32/// # Ok(())
33/// # }
34/// ```
35pub struct ClawCoreClient {
36    reader: BufReader<tokio::io::ReadHalf<UnixStream>>,
37    writer: tokio::io::WriteHalf<UnixStream>,
38    req_counter: u64,
39}
40
41impl ClawCoreClient {
42    /// Connect to the claw_core daemon at the given Unix socket path.
43    pub async fn connect(socket_path: &str) -> Result<Self> {
44        let stream = UnixStream::connect(socket_path)
45            .await
46            .with_context(|| format!("failed to connect to claw_core daemon at {socket_path}"))?;
47        let (read_half, write_half) = tokio::io::split(stream);
48        Ok(Self {
49            reader: BufReader::new(read_half),
50            writer: write_half,
51            req_counter: 0,
52        })
53    }
54
55    /// Send a raw RPC request and return the parsed response.
56    pub async fn send_request(
57        &mut self,
58        method: &str,
59        params: serde_json::Value,
60    ) -> Result<RpcResponse> {
61        self.req_counter += 1;
62        let request = RpcRequest {
63            id: format!("req-{}", self.req_counter),
64            method: method.to_string(),
65            params,
66        };
67
68        let mut line = serde_json::to_string(&request)
69            .context("failed to serialize RPC request")?;
70        line.push('\n');
71
72        self.writer
73            .write_all(line.as_bytes())
74            .await
75            .context("failed to write to claw_core socket")?;
76        self.writer.flush().await.context("failed to flush socket")?;
77
78        let mut response_line = String::new();
79        let bytes_read = self
80            .reader
81            .read_line(&mut response_line)
82            .await
83            .context("failed to read response from claw_core socket")?;
84
85        if bytes_read == 0 {
86            bail!("claw_core daemon closed the connection");
87        }
88
89        let response: RpcResponse = serde_json::from_str(response_line.trim())
90            .context("failed to parse RPC response from claw_core")?;
91
92        Ok(response)
93    }
94
95    // -----------------------------------------------------------------------
96    // Convenience methods
97    // -----------------------------------------------------------------------
98
99    /// `system.ping` — health check.
100    pub async fn ping(&mut self) -> Result<PingResult> {
101        let resp = self.send_request("system.ping", serde_json::json!({})).await?;
102        extract_data(resp)
103    }
104
105    /// `system.stats` — runtime statistics.
106    pub async fn stats(&mut self) -> Result<SystemStats> {
107        let resp = self.send_request("system.stats", serde_json::json!({})).await?;
108        extract_data(resp)
109    }
110
111    /// `session.create` — create a new terminal session.
112    pub async fn create_session(
113        &mut self,
114        params: CreateSessionParams,
115    ) -> Result<CreateSessionResult> {
116        let value = serde_json::to_value(&params).context("serialize CreateSessionParams")?;
117        let resp = self.send_request("session.create", value).await?;
118        extract_data(resp)
119    }
120
121    /// `session.list` — list active sessions.
122    pub async fn list_sessions(&mut self) -> Result<SessionListResult> {
123        let resp = self.send_request("session.list", serde_json::json!({})).await?;
124        extract_data(resp)
125    }
126
127    /// `session.info` — get details about a session.
128    pub async fn session_info(&mut self, session_id: &str) -> Result<SessionInfo> {
129        let resp = self
130            .send_request("session.info", serde_json::json!({ "session_id": session_id }))
131            .await?;
132        extract_data(resp)
133    }
134
135    /// `session.destroy` — terminate and clean up a session.
136    pub async fn destroy_session(
137        &mut self,
138        params: DestroySessionParams,
139    ) -> Result<DestroySessionResult> {
140        let value = serde_json::to_value(&params).context("serialize DestroySessionParams")?;
141        let resp = self.send_request("session.destroy", value).await?;
142        extract_data(resp)
143    }
144
145    /// `exec.run` — execute a command in a session (buffered, waits for completion).
146    pub async fn exec_run(&mut self, params: ExecRunParams) -> Result<ExecRunResult> {
147        let value = serde_json::to_value(&params).context("serialize ExecRunParams")?;
148        let resp = self.send_request("exec.run", value).await?;
149        extract_data(resp)
150    }
151}
152
153/// Extract typed data from a successful [`RpcResponse`], or return an error.
154fn extract_data<T: serde::de::DeserializeOwned>(resp: RpcResponse) -> Result<T> {
155    if !resp.ok {
156        let err = resp.error.unwrap_or(RpcError {
157            code: "UNKNOWN".to_string(),
158            message: "unknown error".to_string(),
159        });
160        bail!("claw_core error [{}]: {}", err.code, err.message);
161    }
162    let data = resp.data.context("response ok=true but data is null")?;
163    serde_json::from_value(data).context("failed to deserialize response data")
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn default_socket_path_is_set() {
172        assert_eq!(DEFAULT_SOCKET_PATH, "/tmp/trl.sock");
173    }
174
175    #[test]
176    fn extract_data_success() {
177        let resp = RpcResponse {
178            id: "req-1".into(),
179            ok: true,
180            data: Some(serde_json::json!({ "uptime_s": 42, "version": "0.1.0" })),
181            error: None,
182        };
183        let ping: PingResult = extract_data(resp).unwrap();
184        assert_eq!(ping.uptime_s, 42);
185        assert_eq!(ping.version, "0.1.0");
186    }
187
188    #[test]
189    fn extract_data_error() {
190        let resp = RpcResponse {
191            id: "req-2".into(),
192            ok: false,
193            data: None,
194            error: Some(RpcError {
195                code: "SESSION_NOT_FOUND".into(),
196                message: "session not found".into(),
197            }),
198        };
199        let result = extract_data::<PingResult>(resp);
200        assert!(result.is_err());
201        let err_msg = result.unwrap_err().to_string();
202        assert!(err_msg.contains("SESSION_NOT_FOUND"));
203    }
204
205    #[test]
206    fn create_session_params_default_serializes_cleanly() {
207        let params = CreateSessionParams::default();
208        let json = serde_json::to_string(&params).unwrap();
209        assert_eq!(json, "{}");
210    }
211
212    #[test]
213    fn exec_run_params_serializes() {
214        let params = ExecRunParams {
215            session_id: "s-123".into(),
216            command: "echo hi".into(),
217            timeout_s: Some(30),
218            stdin: None,
219            env: None,
220        };
221        let json: serde_json::Value = serde_json::to_value(&params).unwrap();
222        assert_eq!(json["session_id"], "s-123");
223        assert_eq!(json["command"], "echo hi");
224        assert_eq!(json["timeout_s"], 30);
225        assert!(json.get("stdin").is_none());
226    }
227}