opencode_ctl/
transport.rs1use 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 fn cli_version(&self) -> impl Future<Output = Result<String, TransportError>> + Send;
73}
74
75pub 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
86pub fn service_running(status_stdout: &str) -> bool {
88 status_stdout.trim() != "stopped"
89}
90
91pub 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
112pub 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
152pub 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}