Skip to main content

opencode_ctl/
transport.rs

1//! The seam between the engine and the opencode CLI.
2
3use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use serde_json::Value;
8
9pub mod cli;
10pub use cli::CliTransport;
11
12use crate::cli::{blank_is_unset, env_nonempty, Route};
13use crate::compact::redact_text;
14use crate::error::{ErrorCode, ToolError};
15use crate::openapi::Operation;
16use crate::settings::Settings;
17
18pub type Params = Vec<(String, String)>;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum Lifecycle {
22    Start,
23    Stop,
24    Restart,
25    Status,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum WaitOutcome {
30    Idle,
31    DeadlineHit,
32}
33
34#[derive(Debug, Clone)]
35pub struct TransportError {
36    pub code: ErrorCode,
37    pub message: String,
38    pub hint: Option<String>,
39    pub details: Option<Value>,
40}
41
42impl From<TransportError> for ToolError {
43    fn from(e: TransportError) -> Self {
44        ToolError {
45            code: e.code,
46            message: redact_text(&e.message),
47            hint: e.hint.map(|h| redact_text(&h)),
48            details: e.details,
49        }
50    }
51}
52
53pub trait Transport: Send + Sync + 'static {
54    fn call(
55        &self,
56        op: &Operation,
57        params: &Params,
58        body: Option<&Value>,
59        deadline: Duration,
60    ) -> impl Future<Output = Result<Value, TransportError>> + Send;
61    fn lifecycle(
62        &self,
63        verb: Lifecycle,
64    ) -> impl Future<Output = Result<String, TransportError>> + Send;
65    fn wait_idle(
66        &self,
67        session_id: &str,
68        deadline: Duration,
69    ) -> impl Future<Output = Result<WaitOutcome, TransportError>> + Send;
70    fn fetch_openapi(&self) -> impl Future<Output = Result<Value, TransportError>> + Send;
71    /// Raw `opencode --version` stdout, trimmed, e.g. `opencode v2.0.32`.
72    fn cli_version(&self) -> impl Future<Output = Result<String, TransportError>> + Send;
73}
74
75/// `opencode v2.0.32` / `v2.0.32` / `2.0.32` -> `2.0.32`.
76pub fn parse_cli_version(raw: &str) -> Option<String> {
77    let t = raw.trim();
78    let t = t.strip_prefix("opencode ").unwrap_or(t).trim();
79    let t = t.strip_prefix('v').unwrap_or(t);
80    if t.is_empty() || !t.starts_with(|c: char| c.is_ascii_digit()) {
81        return None;
82    }
83    Some(t.to_string())
84}
85
86/// The `service status` stdout is the URL when running and `stopped` otherwise.
87pub fn service_running(status_stdout: &str) -> bool {
88    status_stdout.trim() != "stopped"
89}
90
91/// The OS home for `route`, or the missing-home refusal. `Version`, `Usage` and `emit-skill` need no home.
92pub fn home_required(
93    route: &Route,
94    get: &dyn Fn(&str) -> Option<String>,
95) -> Result<PathBuf, ToolError> {
96    let home = blank_is_unset(get("HOME")).or_else(|| blank_is_unset(get("USERPROFILE")));
97    let exempt = match route {
98        Route::Version | Route::Usage => true,
99        Route::Sub { name, .. } => name == "emit-skill",
100        Route::Pair(_) => false,
101    };
102    match home {
103        Some(h) => Ok(PathBuf::from(h)),
104        None if exempt => Ok(PathBuf::new()),
105        None => Err(ToolError::new(
106            ErrorCode::IoError,
107            "no home directory: set HOME (USERPROFILE on Windows)",
108        )),
109    }
110}
111
112/// Every call fails `OPENCODE_NOT_FOUND` (kept for the server tests that need a transport with no CLI).
113pub struct NotFoundTransport;
114
115fn not_found() -> TransportError {
116    TransportError {
117        code: ErrorCode::OpencodeNotFound,
118        message: "opencode executable not found".to_string(),
119        hint: Some("install opencode or set OPENCODE_CTL_BIN".to_string()),
120        details: None,
121    }
122}
123
124impl Transport for NotFoundTransport {
125    async fn call(
126        &self,
127        _op: &Operation,
128        _params: &Params,
129        _body: Option<&Value>,
130        _deadline: Duration,
131    ) -> Result<Value, TransportError> {
132        Err(not_found())
133    }
134    async fn lifecycle(&self, _verb: Lifecycle) -> Result<String, TransportError> {
135        Err(not_found())
136    }
137    async fn wait_idle(
138        &self,
139        _session_id: &str,
140        _deadline: Duration,
141    ) -> Result<WaitOutcome, TransportError> {
142        Err(not_found())
143    }
144    async fn fetch_openapi(&self) -> Result<Value, TransportError> {
145        Err(not_found())
146    }
147    async fn cli_version(&self) -> Result<String, TransportError> {
148        Err(not_found())
149    }
150}
151
152/// `home` is the ctl home, `user_home` the OS home.
153pub fn build_transport(settings: &Settings, home: &Path, user_home: &Path) -> impl Transport {
154    CliTransport::new(settings, home, user_home).with_path(env_nonempty("PATH"))
155}