Skip to main content

opencode_ctl/transport/
cli.rs

1//! The real transport: shells out to the `opencode` CLI.
2//!
3//! Stdout goes to a temp file, never a pipe: the CLI writes the body with one un-awaited
4//! `process.stdout.write` and then calls `process.exit`, so a pipe loses whatever the reader has
5//! not drained (spec Transport). The file is deleted by a drop guard on every exit path.
6
7use std::ffi::OsStr;
8use std::fs::OpenOptions;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::sync::OnceLock;
13use std::time::Duration;
14
15use proc_guard::JobObject;
16use serde_json::{json, Value};
17use tokio::io::AsyncReadExt;
18use tokio::process::Command;
19
20use super::{service_running, Lifecycle, Params, Transport, TransportError, WaitOutcome};
21use crate::error::{ErrorCode, ToolError};
22use crate::liveness::{ProcessProbe, SystemProbe};
23use crate::openapi::{validate_params, validate_session_id, Method, Operation};
24use crate::settings::Settings;
25
26/// Runaway-output ceiling for a child's stdout file (not a budget: legitimate reads are far below).
27pub const STDOUT_READ_CEILING: u64 = 32 * 1024 * 1024;
28
29const SIZE_POLL: Duration = Duration::from_millis(100);
30const STDERR_CAP: usize = 256 * 1024;
31const STDERR_DRAIN: Duration = Duration::from_secs(2);
32
33pub struct CliTransport {
34    bin: OnceLock<Result<PathBuf, TransportError>>,
35    bin_hint: Option<PathBuf>,
36    server_url: Option<String>,
37    stdout_dir: PathBuf,
38    call_timeout: Duration,
39    body_max_bytes: usize,
40    extra_env: Vec<(String, String)>,
41    removed_env: Vec<String>,
42    path: Option<String>,
43    /// The OS home: the third input of `resolve_binary`, kept because resolution is lazy.
44    user_home: PathBuf,
45}
46
47impl CliTransport {
48    /// `home` is the ctl home (for the stdout directory), `user_home` the OS home.
49    pub fn new(settings: &Settings, home: &Path, user_home: &Path) -> Self {
50        let pid = std::process::id();
51        let start = SystemProbe
52            .starttime(pid)
53            .unwrap_or_else(|| "0".to_string());
54        Self {
55            bin: OnceLock::new(),
56            bin_hint: settings.opencode_bin.clone(),
57            server_url: settings.server_url.clone(),
58            stdout_dir: home.join("stdout").join(format!("{pid}-{start}")),
59            call_timeout: Duration::from_secs(settings.call_timeout_s),
60            body_max_bytes: settings.body_max_bytes,
61            extra_env: Vec::new(),
62            removed_env: Vec::new(),
63            path: None,
64            user_home: user_home.to_path_buf(),
65        }
66    }
67
68    /// Variables added to every child's environment.
69    pub fn with_extra_env(mut self, env: Vec<(String, String)>) -> Self {
70        self.extra_env = env;
71        self
72    }
73
74    /// Variables removed from every child's environment (applied after `with_extra_env`).
75    pub fn with_env_removed(mut self, names: Vec<String>) -> Self {
76        self.removed_env = names;
77        self
78    }
79
80    /// The PATH value `resolve_binary` searches; `None` = no PATH search.
81    pub fn with_path(mut self, path: Option<String>) -> Self {
82        self.path = path;
83        self
84    }
85
86    /// This process's stdout directory `<home>/stdout/<pid>-<starttime>` (created on first use).
87    pub fn stdout_dir(&self) -> &Path {
88        &self.stdout_dir
89    }
90
91    fn bin(&self) -> Result<&Path, TransportError> {
92        let r = self.bin.get_or_init(|| {
93            resolve_binary(
94                self.bin_hint.as_deref(),
95                self.path.as_deref(),
96                &self.user_home,
97            )
98            .map_err(from_tool)
99        });
100        match r {
101            Ok(p) => Ok(p.as_path()),
102            Err(e) => Err(e.clone()),
103        }
104    }
105}
106
107fn from_tool(e: ToolError) -> TransportError {
108    TransportError {
109        code: e.code,
110        message: e.message,
111        hint: e.hint,
112        details: e.details,
113    }
114}
115
116/// The executable an npm install of opencode keeps beside its `opencode.cmd` shim in `dir`
117/// (`node_modules\@opencode\cli\bin\opencode.exe`, the very file the shim runs), when both exist. The
118/// shim itself is never started: `cmd.exe` would expand `%VAR%` inside prompt text passed as arguments.
119pub fn npm_shim_exe(dir: &Path) -> Option<PathBuf> {
120    if !dir.join("opencode.cmd").is_file() {
121        return None;
122    }
123    let exe = dir.join("node_modules/@opencode/cli/bin/opencode.exe");
124    exe.is_file().then_some(exe)
125}
126
127/// Explicit path; else the first `opencode`/`opencode.exe` on `path` (on Windows also the executable behind
128/// an npm `opencode.cmd` shim, see `npm_shim_exe`); else `<home>/.opencode/bin/opencode[.exe]`; else
129/// `OPENCODE_NOT_FOUND`. Pure: nothing is spawned.
130pub fn resolve_binary(
131    explicit: Option<&Path>,
132    path: Option<&str>,
133    home_dir: &Path,
134) -> Result<PathBuf, ToolError> {
135    let name = if cfg!(windows) {
136        "opencode.exe"
137    } else {
138        "opencode"
139    };
140    if let Some(p) = explicit {
141        return Ok(p.to_path_buf());
142    }
143    if let Some(path) = path {
144        for dir in std::env::split_paths(OsStr::new(path)) {
145            if dir.as_os_str().is_empty() {
146                continue;
147            }
148            let cand = dir.join(name);
149            if cand.is_file() {
150                return Ok(cand);
151            }
152            if cfg!(windows) {
153                if let Some(exe) = npm_shim_exe(&dir) {
154                    return Ok(exe);
155                }
156            }
157        }
158    }
159    let fallback = home_dir.join(".opencode").join("bin").join(name);
160    if fallback.is_file() {
161        return Ok(fallback);
162    }
163    Err(
164        ToolError::new(ErrorCode::OpencodeNotFound, "opencode executable not found")
165            .hint("install opencode or set OPENCODE_CTL_BIN"),
166    )
167}
168
169/// The one mapping of a failed `Command::spawn`.
170fn spawn_error(e: &io::Error) -> TransportError {
171    match e.kind() {
172        io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied => TransportError {
173            code: ErrorCode::OpencodeNotFound,
174            message: format!("opencode executable could not be started: {e}"),
175            hint: Some("install opencode or set OPENCODE_CTL_BIN".to_string()),
176            details: None,
177        },
178        _ => TransportError {
179            code: ErrorCode::IoError,
180            message: format!("could not start opencode: {e}"),
181            hint: None,
182            details: None,
183        },
184    }
185}
186
187fn io_error(what: &str, e: &io::Error) -> TransportError {
188    TransportError {
189        code: ErrorCode::IoError,
190        message: format!("{what}: {e}"),
191        hint: None,
192        details: None,
193    }
194}
195
196/// What a failed (non-zero exit) CLI run maps to.
197pub enum Mapped {
198    Error(TransportError),
199    /// The `CLI_ERROR` to return if the reachability probe says the service is up.
200    ProbeNeeded(TransportError),
201}
202
203fn err(code: ErrorCode, message: impl Into<String>) -> TransportError {
204    TransportError {
205        code,
206        message: message.into(),
207        hint: None,
208        details: None,
209    }
210}
211
212const AUTH_HINT: &str =
213    "compare `opencode service get password` (local service) or the remote's password; \
214the CLI reads OPENCODE_PASSWORD, then OPENCODE_SERVER_PASSWORD, from the environment";
215
216fn first_line(text: &str) -> Option<&str> {
217    text.lines().map(str::trim).find(|l| !l.is_empty())
218}
219
220fn exit_text(code: Option<i32>) -> String {
221    code.map_or_else(|| "by a signal".to_string(), |c| c.to_string())
222}
223
224/// The spec's mapping table, in order. Reached only after a successful spawn.
225pub fn map_cli_failure(status: Option<i32>, stdout: &str, stderr: &str) -> Mapped {
226    // 1. A `_tag` on stdout.
227    if let Ok(Value::Object(o)) = serde_json::from_str::<Value>(stdout.trim()) {
228        if let Some(tag) = o.get("_tag").and_then(Value::as_str) {
229            let message = o.get("message").and_then(Value::as_str).unwrap_or(tag);
230            let code = match tag {
231                "SessionNotFoundError" => ErrorCode::SessionNotFound,
232                "UnauthorizedError" => ErrorCode::AuthFailed,
233                "InvalidRequestError"
234                | "InvalidCursorError"
235                | "FormInvalidAnswerError"
236                | "MessageNotFoundError"
237                | "LocationNotFoundError" => ErrorCode::InvalidRequest,
238                "ServiceUnavailableError" => ErrorCode::ServiceDown,
239                "ConflictError"
240                | "SessionBusyError"
241                | "FormAlreadySettledError"
242                | "PermissionNotFoundError"
243                | "FormNotFoundError" => ErrorCode::Conflict,
244                _ => ErrorCode::CliError,
245            };
246            let mut e = err(code, message);
247            e.details = Some(json!({ "tag": tag }));
248            if code == ErrorCode::AuthFailed {
249                e.hint = Some(AUTH_HINT.to_string());
250            }
251            return Mapped::Error(e);
252        }
253    }
254    // 2. A password complaint.
255    if stderr.contains("rejected the password") || stderr.contains("requires a password") {
256        let mut e = err(
257            ErrorCode::AuthFailed,
258            first_line(stderr).unwrap_or("the server rejected the password"),
259        );
260        e.hint = Some(AUTH_HINT.to_string());
261        return Mapped::Error(e);
262    }
263    // 3. Unreachable (before the `[cause]:` rule: that stderr carries a `[cause]: ClientError` line).
264    if stderr.contains("Could not reach server at") {
265        let line = stderr
266            .lines()
267            .map(str::trim)
268            .find(|l| l.contains("Could not reach server at"))
269            .unwrap_or("Could not reach server");
270        let mut e = err(ErrorCode::ServiceDown, line);
271        e.hint = Some("start the service with service_start, or check server_url".to_string());
272        return Mapped::Error(e);
273    }
274    // 4. Empty stdout and a `[cause]:` line: the FIRST one is the message.
275    if stdout.trim().is_empty() {
276        let cause = stderr
277            .lines()
278            .map(str::trim)
279            .find_map(|l| l.strip_prefix("[cause]:"))
280            .map(str::trim);
281        if let Some(cause) = cause {
282            let msg = cause.strip_prefix("Error: ").unwrap_or(cause);
283            let code = if cause.contains("Operation not found") {
284                ErrorCode::UnknownOperation
285            } else {
286                ErrorCode::CliError
287            };
288            return Mapped::Error(err(code, msg));
289        }
290    }
291    // 5. Anything else: decided by the reachability probe.
292    let message = first_line(stderr).map_or_else(
293        || format!("opencode exited {} without output", exit_text(status)),
294        str::to_string,
295    );
296    Mapped::ProbeNeeded(err(ErrorCode::CliError, message))
297}
298
299fn deadline_error(what: &str, deadline: Duration, mutating: bool) -> TransportError {
300    let mut e = err(
301        ErrorCode::Deadline,
302        format!("{what} did not finish within {} s", deadline.as_secs()),
303    );
304    if mutating {
305        e.details = Some(json!({ "outcome": "unknown" }));
306        e.hint = Some(
307            "the call may have been applied: check the inbox and messages before repeating it"
308                .to_string(),
309        );
310    }
311    e
312}
313
314fn ceiling_error() -> TransportError {
315    let mut e = err(
316        ErrorCode::CliError,
317        "opencode output passed the 32 MiB read ceiling and was cut",
318    );
319    e.details = Some(json!({ "reason": "stdout_ceiling" }));
320    e
321}
322
323fn unparseable(mutating: bool) -> TransportError {
324    let mut e = err(
325        ErrorCode::CliError,
326        "opencode returned no parseable JSON on stdout",
327    );
328    let mut d = json!({ "reason": "unparseable stdout" });
329    if mutating {
330        d["outcome"] = json!("unknown");
331        e.hint = Some(
332            "the call may have been applied: check the inbox and messages before repeating it"
333                .to_string(),
334        );
335    }
336    e.details = Some(d);
337    e
338}
339
340/// Deletes the stdout temp file on every path, including a failed spawn.
341struct StdoutFile {
342    path: PathBuf,
343}
344
345impl Drop for StdoutFile {
346    fn drop(&mut self) {
347        let _ = std::fs::remove_file(&self.path);
348    }
349}
350
351#[derive(Debug, PartialEq, Eq)]
352enum How {
353    Exited,
354    Deadline,
355    Ceiling,
356}
357
358struct Finished {
359    how: How,
360    code: Option<i32>,
361    success: bool,
362    stdout: String,
363    stderr: String,
364}
365
366impl CliTransport {
367    fn ensure_stdout_dir(&self) -> Result<(), TransportError> {
368        let mut b = std::fs::DirBuilder::new();
369        b.recursive(true);
370        #[cfg(unix)]
371        {
372            use std::os::unix::fs::DirBuilderExt;
373            b.mode(0o700);
374        }
375        b.create(&self.stdout_dir)
376            .map_err(|e| io_error("cannot create the stdout directory", &e))
377    }
378
379    fn server_args(&self, args: &mut Vec<String>) {
380        if let Some(url) = &self.server_url {
381            args.push("--server".to_string());
382            args.push(url.clone());
383        }
384    }
385
386    /// Spawn `opencode <args>` with stdout in a temp file, bounded by `deadline`.
387    async fn run(&self, args: &[String], deadline: Duration) -> Result<Finished, TransportError> {
388        let bin = self.bin()?;
389        self.ensure_stdout_dir()?;
390        let path = self
391            .stdout_dir
392            .join(format!("{}.json", uuid::Uuid::new_v4()));
393        let mut opts = OpenOptions::new();
394        opts.write(true).create_new(true);
395        #[cfg(unix)]
396        {
397            use std::os::unix::fs::OpenOptionsExt;
398            opts.mode(0o600);
399        }
400        let file = opts
401            .open(&path)
402            .map_err(|e| io_error("cannot create the stdout file", &e))?;
403        let guard = StdoutFile { path };
404
405        let mut cmd = Command::new(bin);
406        cmd.args(args)
407            .envs(self.extra_env.iter().map(|(k, v)| (k, v)))
408            .stdin(Stdio::null())
409            .stdout(Stdio::from(file))
410            .stderr(Stdio::piped())
411            .kill_on_drop(true);
412        for k in &self.removed_env {
413            cmd.env_remove(k);
414        }
415        let mut job =
416            JobObject::new().map_err(|e| io_error("cannot create the process guard", &e))?;
417        #[cfg(unix)]
418        job.configure_command(cmd.as_std_mut());
419        let spawned = cmd.spawn();
420        drop(cmd); // closes the parent's copy of the stdout file
421        let mut child = spawned.map_err(|e| spawn_error(&e))?;
422        #[cfg(unix)]
423        if let Some(pid) = child.id() {
424            job.assign_pid(pid);
425        }
426        #[cfg(windows)]
427        if let Some(h) = child.raw_handle() {
428            if let Err(e) = job.assign_raw_handle(h) {
429                let _ = child.start_kill();
430                return Err(io_error("cannot assign the process guard", &e));
431            }
432        }
433
434        let mut stderr_pipe = child.stderr.take();
435        let stderr_task = tokio::spawn(async move {
436            let mut buf = Vec::new();
437            if let Some(p) = stderr_pipe.as_mut() {
438                let mut chunk = [0u8; 8192];
439                while let Ok(n) = p.read(&mut chunk).await {
440                    if n == 0 {
441                        break;
442                    }
443                    let room = STDERR_CAP.saturating_sub(buf.len());
444                    buf.extend_from_slice(&chunk[..n.min(room)]);
445                }
446            }
447            buf
448        });
449
450        let sleep = tokio::time::sleep(deadline);
451        tokio::pin!(sleep);
452        let mut tick = tokio::time::interval(SIZE_POLL);
453        let mut how = How::Exited;
454        let mut status = None;
455        loop {
456            tokio::select! {
457                st = child.wait() => {
458                    status = st.ok();
459                    break;
460                }
461                _ = &mut sleep => {
462                    how = How::Deadline;
463                    break;
464                }
465                _ = tick.tick() => {
466                    if std::fs::metadata(&guard.path).map_or(0, |m| m.len()) > STDOUT_READ_CEILING {
467                        how = How::Ceiling;
468                        break;
469                    }
470                }
471            }
472        }
473        if how != How::Exited {
474            let _ = job.kill_group();
475            let _ = child.start_kill();
476            status = child.wait().await.ok();
477        }
478        // Stragglers of the process group die with it; the leader is already reaped. On Windows a call
479        // that exited normally keeps what it started: a managed-mode call auto-starts the service, and
480        // unlike POSIX (`detached: true` puts it in its own session) the service is inside this job, so
481        // killing the job here killed the service the call had just started (MEASURED, Task 25). A
482        // deadline or the ceiling still kills the tree above.
483        #[cfg(windows)]
484        if how == How::Exited && job.release().is_ok() {
485            // disarmed: nothing is killed
486        } else {
487            let _ = job.kill_group();
488        }
489        #[cfg(not(windows))]
490        let _ = job.kill_group();
491        let stderr_bytes = match tokio::time::timeout(STDERR_DRAIN, stderr_task).await {
492            Ok(Ok(b)) => b,
493            _ => Vec::new(),
494        };
495        // A fast writer cannot bypass the ceiling: check again before reading the file.
496        if how == How::Exited
497            && std::fs::metadata(&guard.path).map_or(0, |m| m.len()) > STDOUT_READ_CEILING
498        {
499            how = How::Ceiling;
500        }
501        let stdout = if how == How::Exited {
502            let bytes = tokio::fs::read(&guard.path)
503                .await
504                .map_err(|e| io_error("cannot read the stdout file", &e))?;
505            String::from_utf8_lossy(&bytes).into_owned()
506        } else {
507            String::new()
508        };
509        Ok(Finished {
510            how,
511            code: status.and_then(|s| s.code()),
512            success: status.is_some_and(|s| s.success()),
513            stdout,
514            stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
515        })
516    }
517
518    /// `opencode service status` once (deadline `call_timeout`): non-zero exit or `stopped` is
519    /// `SERVICE_DOWN`, otherwise the carried error.
520    async fn probe(&self, carried: TransportError) -> TransportError {
521        let args = ["service".to_string(), "status".to_string()];
522        match self.run(&args, self.call_timeout).await {
523            Err(e) => e,
524            Ok(f) if f.how == How::Exited && (!f.success || !service_running(&f.stdout)) => {
525                let mut e = err(
526                    ErrorCode::ServiceDown,
527                    "the opencode service is not running",
528                );
529                e.hint = Some("start it with service_start (`opencode service start`)".to_string());
530                e
531            }
532            Ok(_) => carried,
533        }
534    }
535
536    /// A failed run mapped, probed when the mapping asks for it and `server_url` is unset.
537    async fn fail(&self, mapped: Mapped) -> TransportError {
538        match mapped {
539            Mapped::Error(mut e) => {
540                if let (ErrorCode::AuthFailed, Some(url)) = (e.code, &self.server_url) {
541                    e.hint = Some(format!(
542                        "wrong password for {url}: compare `opencode service get password` (local) or \
543                         the remote's password with OPENCODE_PASSWORD / OPENCODE_SERVER_PASSWORD"
544                    ));
545                }
546                e
547            }
548            Mapped::ProbeNeeded(e) if self.server_url.is_some() => e,
549            Mapped::ProbeNeeded(e) => self.probe(e).await,
550        }
551    }
552
553    /// Run and settle everything but a successful exit: deadline, ceiling and a mapped failure.
554    async fn run_ok(
555        &self,
556        what: &str,
557        args: &[String],
558        deadline: Duration,
559        mutating: bool,
560    ) -> Result<Finished, TransportError> {
561        let f = self.run(args, deadline).await?;
562        match f.how {
563            How::Deadline => Err(deadline_error(what, deadline, mutating)),
564            How::Ceiling => Err(ceiling_error()),
565            How::Exited if f.success => Ok(f),
566            How::Exited => Err(self
567                .fail(map_cli_failure(f.code, &f.stdout, &f.stderr))
568                .await),
569        }
570    }
571}
572
573fn plain_failure(f: &Finished, what: &str) -> TransportError {
574    let message = first_line(&f.stderr).map_or_else(
575        || format!("{what} exited {} without output", exit_text(f.code)),
576        str::to_string,
577    );
578    err(ErrorCode::CliError, message)
579}
580
581fn verb(v: Lifecycle) -> &'static str {
582    match v {
583        Lifecycle::Start => "start",
584        Lifecycle::Stop => "stop",
585        Lifecycle::Restart => "restart",
586        Lifecycle::Status => "status",
587    }
588}
589
590impl Transport for CliTransport {
591    async fn call(
592        &self,
593        op: &Operation,
594        params: &Params,
595        body: Option<&Value>,
596        deadline: Duration,
597    ) -> Result<Value, TransportError> {
598        validate_params(op, params).map_err(from_tool)?;
599        for (name, value) in params {
600            if name == "sessionID" {
601                validate_session_id(value).map_err(from_tool)?;
602            }
603        }
604        let body_json = body.map(Value::to_string);
605        if body_json
606            .as_ref()
607            .is_some_and(|b| b.len() > self.body_max_bytes)
608        {
609            let mut e = err(
610                ErrorCode::BodyTooLarge,
611                format!(
612                    "the request body is larger than {} bytes",
613                    self.body_max_bytes
614                ),
615            );
616            e.hint = Some(
617                "shorten the prompt, or write it into the workspace and reference the path"
618                    .to_string(),
619            );
620            return Err(e);
621        }
622        let mut args = vec!["api".to_string(), op.id.clone()];
623        for (k, v) in params {
624            args.push("--param".to_string());
625            args.push(format!("{k}={v}"));
626        }
627        if let Some(b) = body_json {
628            args.push("-d".to_string());
629            args.push(b);
630        }
631        self.server_args(&mut args);
632
633        let mutating = op.mutating();
634        let attempts = if op.method == Method::Get { 2 } else { 1 };
635        let what = format!("opencode api {}", op.id);
636        let mut last = unparseable(mutating);
637        for _ in 0..attempts {
638            let f = self.run_ok(&what, &args, deadline, mutating).await?;
639            let text = f.stdout.trim();
640            if text.is_empty() {
641                if op.success_status == 204 {
642                    return Ok(json!({ "ok": true }));
643                }
644            } else if let Ok(v) = serde_json::from_str::<Value>(text) {
645                return Ok(v);
646            }
647            last = unparseable(mutating);
648        }
649        Err(last)
650    }
651
652    async fn lifecycle(&self, v: Lifecycle) -> Result<String, TransportError> {
653        let name = verb(v);
654        let args = ["service".to_string(), name.to_string()];
655        let what = format!("opencode service {name}");
656        let f = self.run(&args, self.call_timeout).await?;
657        match f.how {
658            How::Deadline => Err(deadline_error(
659                &what,
660                self.call_timeout,
661                v != Lifecycle::Status,
662            )),
663            How::Ceiling => Err(ceiling_error()),
664            How::Exited if f.success => Ok(f.stdout.trim().to_string()),
665            // The service verbs print no JSON: no `_tag` parsing and no probe.
666            How::Exited => Err(plain_failure(&f, &what)),
667        }
668    }
669
670    async fn wait_idle(
671        &self,
672        session_id: &str,
673        deadline: Duration,
674    ) -> Result<WaitOutcome, TransportError> {
675        validate_session_id(session_id).map_err(from_tool)?;
676        let mut args = vec![
677            "api".to_string(),
678            "experimental.session.wait".to_string(),
679            "--param".to_string(),
680            format!("sessionID={session_id}"),
681        ];
682        self.server_args(&mut args);
683        let f = self.run(&args, deadline).await?;
684        match f.how {
685            How::Deadline => Ok(WaitOutcome::DeadlineHit),
686            How::Ceiling => Err(ceiling_error()),
687            How::Exited if f.success => Ok(WaitOutcome::Idle),
688            How::Exited => Err(self
689                .fail(map_cli_failure(f.code, &f.stdout, &f.stderr))
690                .await),
691        }
692    }
693
694    async fn fetch_openapi(&self) -> Result<Value, TransportError> {
695        let mut args = vec![
696            "api".to_string(),
697            "GET".to_string(),
698            "/openapi.json".to_string(),
699        ];
700        self.server_args(&mut args);
701        let f = self
702            .run_ok(
703                "opencode api GET /openapi.json",
704                &args,
705                self.call_timeout,
706                false,
707            )
708            .await?;
709        serde_json::from_str(f.stdout.trim()).map_err(|_| unparseable(false))
710    }
711
712    async fn cli_version(&self) -> Result<String, TransportError> {
713        let args = ["--version".to_string()];
714        let f = self.run(&args, self.call_timeout).await?;
715        match f.how {
716            How::Deadline => Err(deadline_error(
717                "opencode --version",
718                self.call_timeout,
719                false,
720            )),
721            How::Ceiling => Err(ceiling_error()),
722            How::Exited if f.success => Ok(f.stdout.trim().to_string()),
723            How::Exited => Err(plain_failure(&f, "opencode --version")),
724        }
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn spawn_error_kinds() {
734        for (kind, code) in [
735            (io::ErrorKind::NotFound, ErrorCode::OpencodeNotFound),
736            (io::ErrorKind::PermissionDenied, ErrorCode::OpencodeNotFound),
737            (io::ErrorKind::Other, ErrorCode::IoError),
738        ] {
739            assert_eq!(spawn_error(&io::Error::from(kind)).code, code, "{kind:?}");
740        }
741    }
742
743    /// The sixth spawn site runs only after a spawn succeeded, so it is driven directly.
744    #[tokio::test]
745    async fn probe_spawn_failure_is_opencode_not_found() {
746        let dir = crate::testing::temp_home();
747        let settings = Settings {
748            opencode_bin: Some(dir.path().join("missing")),
749            ..Settings::default()
750        };
751        let t = CliTransport::new(&settings, dir.path(), dir.path());
752        let carried = err(ErrorCode::CliError, "carried");
753        let e = t.probe(carried).await;
754        assert_eq!(e.code, ErrorCode::OpencodeNotFound);
755        assert_eq!(
756            std::fs::read_dir(t.stdout_dir()).map_or(0, |d| d.count()),
757            0
758        );
759    }
760}