Skip to main content

opencode/
server.rs

1use std::collections::HashMap;
2use std::path::{Component, Path, PathBuf};
3use std::process::Stdio;
4use std::time::Duration;
5
6use tokio::io::{AsyncBufReadExt, BufReader};
7use tokio::process::{Child, Command};
8use tokio::sync::mpsc;
9
10use crate::client::{OpencodeClient, OpencodeClientConfig, create_opencode_client};
11use crate::errors::{CLINotFoundError, Error, OpencodeSDKError, ProcessError, Result};
12
13/// Options for launching `opencode serve`.
14#[derive(Debug, Clone)]
15pub struct OpencodeServerOptions {
16    /// Hostname passed to `--hostname`.
17    pub hostname: String,
18    /// Port passed to `--port`.
19    pub port: u16,
20    /// Startup timeout while waiting for server URL log line.
21    pub timeout: Duration,
22    /// Optional OpenCode config JSON forwarded via `OPENCODE_CONFIG_CONTENT`.
23    pub config: Option<serde_json::Value>,
24    /// Optional explicit CLI path. If omitted, resolved via `which opencode`.
25    pub cli_path: Option<PathBuf>,
26    /// Optional extra environment variables.
27    pub env: HashMap<String, String>,
28    /// Optional working directory.
29    pub cwd: Option<PathBuf>,
30}
31
32impl Default for OpencodeServerOptions {
33    fn default() -> Self {
34        Self {
35            hostname: "127.0.0.1".to_string(),
36            port: 4096,
37            timeout: Duration::from_millis(5_000),
38            config: None,
39            cli_path: None,
40            env: HashMap::new(),
41            cwd: None,
42        }
43    }
44}
45
46/// Options for launching `opencode` TUI.
47#[derive(Debug, Clone, Default)]
48pub struct OpencodeTuiOptions {
49    /// Optional project selector passed as `--project=...`.
50    pub project: Option<String>,
51    /// Optional model selector passed as `--model=...`.
52    pub model: Option<String>,
53    /// Optional session selector passed as `--session=...`.
54    pub session: Option<String>,
55    /// Optional agent selector passed as `--agent=...`.
56    pub agent: Option<String>,
57    /// Optional OpenCode config JSON forwarded via `OPENCODE_CONFIG_CONTENT`.
58    pub config: Option<serde_json::Value>,
59    /// Optional explicit CLI path. If omitted, resolved via `which opencode`.
60    pub cli_path: Option<PathBuf>,
61    /// Optional extra environment variables.
62    pub env: HashMap<String, String>,
63    /// Optional working directory.
64    pub cwd: Option<PathBuf>,
65}
66
67/// Running OpenCode local server process.
68#[derive(Debug)]
69pub struct OpencodeServer {
70    /// Base URL parsed from OpenCode startup logs.
71    pub url: String,
72    child: Child,
73}
74
75impl OpencodeServer {
76    /// Stop the server process.
77    pub async fn close(&mut self) -> Result<()> {
78        if self.child.id().is_some() {
79            self.child.start_kill()?;
80            let _ = self.child.wait().await;
81        }
82        Ok(())
83    }
84}
85
86impl Drop for OpencodeServer {
87    fn drop(&mut self) {
88        if self.child.id().is_some() {
89            let _ = self.child.start_kill();
90        }
91    }
92}
93
94/// Running OpenCode TUI process.
95#[derive(Debug)]
96pub struct OpencodeTui {
97    child: Child,
98}
99
100impl OpencodeTui {
101    /// Stop the TUI process.
102    pub async fn close(&mut self) -> Result<()> {
103        if self.child.id().is_some() {
104            self.child.start_kill()?;
105            let _ = self.child.wait().await;
106        }
107        Ok(())
108    }
109}
110
111impl Drop for OpencodeTui {
112    fn drop(&mut self) {
113        if self.child.id().is_some() {
114            let _ = self.child.start_kill();
115        }
116    }
117}
118
119/// Bundled OpenCode server + client (equivalent to JS `createOpencode`).
120#[derive(Debug)]
121pub struct Opencode {
122    /// HTTP client bound to the launched local server URL.
123    pub client: OpencodeClient,
124    /// Handle for the launched local server process.
125    pub server: OpencodeServer,
126}
127
128/// Launch `opencode serve` and wait for startup URL.
129pub async fn create_opencode_server(
130    options: Option<OpencodeServerOptions>,
131) -> Result<OpencodeServer> {
132    let options = options.unwrap_or_default();
133    let cli_path = resolve_cli_path(options.cli_path.as_deref())?;
134
135    let mut args = vec![
136        "serve".to_string(),
137        format!("--hostname={}", options.hostname),
138        format!("--port={}", options.port),
139    ];
140
141    if let Some(log_level) = options
142        .config
143        .as_ref()
144        .and_then(|cfg| cfg.get("logLevel"))
145        .and_then(serde_json::Value::as_str)
146    {
147        args.push(format!("--log-level={log_level}"));
148    }
149
150    let mut cmd = Command::new(&cli_path);
151    cmd.args(args)
152        .stdin(Stdio::null())
153        .stdout(Stdio::piped())
154        .stderr(Stdio::piped());
155
156    if let Some(cwd) = &options.cwd {
157        cmd.current_dir(cwd);
158    }
159
160    cmd.envs(std::env::vars());
161    cmd.envs(options.env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
162    cmd.env(
163        "OPENCODE_CONFIG_CONTENT",
164        serde_json::to_string(&options.config.unwrap_or_else(|| serde_json::json!({})))?,
165    );
166
167    let mut child = cmd.spawn()?;
168
169    let stdout = child
170        .stdout
171        .take()
172        .ok_or_else(|| OpencodeSDKError::new("Failed to capture opencode stdout"))?;
173    let stderr = child
174        .stderr
175        .take()
176        .ok_or_else(|| OpencodeSDKError::new("Failed to capture opencode stderr"))?;
177
178    let (tx, mut rx) = mpsc::unbounded_channel::<String>();
179    tokio::spawn(read_lines(stdout, tx.clone()));
180    tokio::spawn(read_lines(stderr, tx));
181
182    let timeout_ms = options.timeout.as_millis() as u64;
183    let sleeper = tokio::time::sleep(options.timeout);
184    tokio::pin!(sleeper);
185
186    let mut output = String::new();
187
188    loop {
189        tokio::select! {
190            _ = &mut sleeper => {
191                terminate_child(&mut child).await;
192                return Err(Error::ServerStartupTimeout { timeout_ms });
193            }
194            maybe_line = rx.recv() => {
195                match maybe_line {
196                    Some(line) => {
197                        output.push_str(&line);
198                        output.push('\n');
199
200                        if line.starts_with("opencode server listening") {
201                            if let Some(url) = extract_url_from_line(&line) {
202                                return Ok(OpencodeServer { url, child });
203                            }
204
205                            terminate_child(&mut child).await;
206                            return Err(Error::OpencodeSDK(OpencodeSDKError::new(format!(
207                                "Failed to parse server url from output: {line}"
208                            ))));
209                        }
210                    }
211                    None => {
212                        if let Some(status) = child.try_wait()? {
213                            return Err(Error::Process(ProcessError::new(
214                                "Server exited before reporting a listening URL",
215                                status.code(),
216                                Some(output),
217                            )));
218                        }
219
220                        terminate_child(&mut child).await;
221                        return Err(Error::Process(ProcessError::new(
222                            "Server log streams closed before reporting a listening URL",
223                            None,
224                            Some(output),
225                        )));
226                    }
227                }
228            }
229            wait_result = child.wait() => {
230                let status = wait_result?;
231                return Err(Error::Process(ProcessError::new(
232                    "Server exited before startup completed",
233                    status.code(),
234                    Some(output),
235                )));
236            }
237        }
238    }
239}
240
241/// Launch OpenCode TUI process.
242pub fn create_opencode_tui(options: Option<OpencodeTuiOptions>) -> Result<OpencodeTui> {
243    let options = options.unwrap_or_default();
244    let cli_path = resolve_cli_path(options.cli_path.as_deref())?;
245
246    let mut args = Vec::new();
247    if let Some(project) = options.project {
248        args.push(format!("--project={project}"));
249    }
250    if let Some(model) = options.model {
251        args.push(format!("--model={model}"));
252    }
253    if let Some(session) = options.session {
254        args.push(format!("--session={session}"));
255    }
256    if let Some(agent) = options.agent {
257        args.push(format!("--agent={agent}"));
258    }
259
260    let mut cmd = Command::new(cli_path);
261    cmd.args(args)
262        .stdin(Stdio::inherit())
263        .stdout(Stdio::inherit())
264        .stderr(Stdio::inherit());
265
266    if let Some(cwd) = &options.cwd {
267        cmd.current_dir(cwd);
268    }
269
270    cmd.envs(std::env::vars());
271    cmd.envs(options.env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
272    cmd.env(
273        "OPENCODE_CONFIG_CONTENT",
274        serde_json::to_string(&options.config.unwrap_or_else(|| serde_json::json!({})))?,
275    );
276
277    let child = cmd.spawn()?;
278    Ok(OpencodeTui { child })
279}
280
281/// Creates a local OpenCode server and a client bound to its discovered URL.
282pub async fn create_opencode(options: Option<OpencodeServerOptions>) -> Result<Opencode> {
283    let server = create_opencode_server(options).await?;
284    let client = create_opencode_client(Some(OpencodeClientConfig {
285        base_url: server.url.clone(),
286        ..Default::default()
287    }))?;
288
289    Ok(Opencode { client, server })
290}
291
292async fn read_lines<R>(reader: R, tx: mpsc::UnboundedSender<String>)
293where
294    R: tokio::io::AsyncRead + Unpin,
295{
296    let mut lines = BufReader::new(reader).lines();
297    while let Ok(Some(line)) = lines.next_line().await {
298        let _ = tx.send(line);
299    }
300}
301
302async fn terminate_child(child: &mut Child) {
303    if child.id().is_some() {
304        let _ = child.start_kill();
305        let _ = child.wait().await;
306    }
307}
308
309fn resolve_cli_path(cli_path: Option<&Path>) -> Result<PathBuf> {
310    match cli_path {
311        Some(path) if is_bare_command(path) => which::which(path).map_err(|_| {
312            Error::CLINotFound(CLINotFoundError::new(
313                "OpenCode CLI not found in PATH",
314                Some(path.to_string_lossy().into_owned()),
315            ))
316        }),
317        Some(path) => {
318            if path.is_file() {
319                Ok(path.to_path_buf())
320            } else {
321                Err(Error::CLINotFound(CLINotFoundError::new(
322                    "OpenCode CLI not found at configured path",
323                    Some(path.to_string_lossy().into_owned()),
324                )))
325            }
326        }
327        None => which::which("opencode").map_err(|_| {
328            Error::CLINotFound(CLINotFoundError::new(
329                "OpenCode CLI not found in PATH",
330                Some("opencode".to_string()),
331            ))
332        }),
333    }
334}
335
336fn is_bare_command(path: &Path) -> bool {
337    let mut components = path.components();
338    matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
339}
340
341fn extract_url_from_line(line: &str) -> Option<String> {
342    for prefix in ["http://", "https://"] {
343        if let Some(start) = line.find(prefix) {
344            let rest = &line[start..];
345            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
346            return Some(rest[..end].to_string());
347        }
348    }
349    None
350}