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#[derive(Debug, Clone)]
15pub struct OpencodeServerOptions {
16 pub hostname: String,
18 pub port: u16,
20 pub timeout: Duration,
22 pub config: Option<serde_json::Value>,
24 pub cli_path: Option<PathBuf>,
26 pub env: HashMap<String, String>,
28 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#[derive(Debug, Clone, Default)]
48pub struct OpencodeTuiOptions {
49 pub project: Option<String>,
51 pub model: Option<String>,
53 pub session: Option<String>,
55 pub agent: Option<String>,
57 pub config: Option<serde_json::Value>,
59 pub cli_path: Option<PathBuf>,
61 pub env: HashMap<String, String>,
63 pub cwd: Option<PathBuf>,
65}
66
67#[derive(Debug)]
69pub struct OpencodeServer {
70 pub url: String,
72 child: Child,
73}
74
75impl OpencodeServer {
76 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#[derive(Debug)]
96pub struct OpencodeTui {
97 child: Child,
98}
99
100impl OpencodeTui {
101 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#[derive(Debug)]
121pub struct Opencode {
122 pub client: OpencodeClient,
124 pub server: OpencodeServer,
126}
127
128pub 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
241pub 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
281pub 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}