1use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use rmcp::ServiceExt;
7
8use crate::clock::{Clock, SystemClock};
9use crate::doctor::{default_config_file, default_registration_file, doctor_json, DoctorCtx};
10use crate::engine::{Engine, EnvSnapshot};
11use crate::error::{ErrorCode, ToolError};
12use crate::host::{self, Host};
13use crate::liveness::{ProcessProbe, SystemProbe};
14use crate::logging::LogScope;
15use crate::pairing;
16use crate::server::OpencodeCtlServer;
17use crate::settings;
18use crate::skill_asset::{render, SkillName};
19use crate::startup::startup;
20use crate::transport::{build_transport, home_required};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Route {
24 Version,
25 Usage,
26 Sub { name: String, rest: Vec<String> },
27 Pair(Vec<String>),
28}
29
30const SUBCOMMANDS: [&str; 8] = [
31 "serve",
32 "pair",
33 "resume",
34 "attach",
35 "gc",
36 "install-launcher",
37 "emit-skill",
38 "doctor-json",
39];
40
41pub fn blank_is_unset(v: Option<String>) -> Option<String> {
43 v.filter(|v| !v.trim().is_empty())
44}
45
46pub fn env_nonempty(name: &str) -> Option<String> {
47 blank_is_unset(std::env::var(name).ok())
48}
49
50fn argv0_stem(argv0: &str) -> &str {
52 let name = argv0.rsplit(['/', '\\']).next().unwrap_or(argv0);
53 match name.len().checked_sub(4) {
54 Some(i) if name.is_char_boundary(i) && name[i..].eq_ignore_ascii_case(".exe") => &name[..i],
55 _ => name,
56 }
57}
58
59pub fn route(argv0: Option<&str>, args: &[String]) -> Route {
63 if argv0.map(argv0_stem) == Some("opencode-pair") {
64 return match args.first().map(String::as_str) {
65 Some("--version") => Route::Version,
66 Some(w @ ("resume" | "attach" | "gc" | "install-launcher" | "doctor-json")) => {
67 Route::Sub {
68 name: w.to_string(),
69 rest: args[1..].to_vec(),
70 }
71 }
72 _ => Route::Pair(args.to_vec()),
73 };
74 }
75 match args.first().map(String::as_str) {
76 Some("--version") | Some("-V") => Route::Version,
77 Some(w) if SUBCOMMANDS.contains(&w) => Route::Sub {
78 name: w.to_string(),
79 rest: args[1..].to_vec(),
80 },
81 _ => Route::Usage,
82 }
83}
84
85pub fn version_line(product: &str, version: &str) -> String {
86 format!("{product} {version}")
87}
88
89pub fn usage() -> String {
90 "usage: serve [--host h] | pair | resume | attach | gc | install-launcher | emit-skill <name> --host <h> | doctor-json [verbose] [--host h] | --version"
91 .to_string()
92}
93
94pub async fn dispatch(
95 host: Host,
96 product: &str,
97 version: &str,
98 argv0: Option<&str>,
99 args: Vec<String>,
100) -> i32 {
101 let r = route(argv0, &args);
102 let user_home = match home_required(&r, &|k| env_nonempty(k)) {
103 Ok(h) => h,
104 Err(e) => {
105 eprintln!("{}", e.message);
106 return 1;
107 }
108 };
109 let probe: Arc<dyn ProcessProbe> = Arc::new(SystemProbe);
110 match r {
111 Route::Version => {
112 println!("{}", version_line(product, version));
113 0
114 }
115 Route::Usage => {
116 eprintln!("{}", usage());
117 2
118 }
119 Route::Sub { name, rest } if name == "serve" => {
120 match serve(host, &user_home, &rest, probe).await {
121 Ok(()) => 0,
122 Err(msg) => {
123 eprintln!("{msg}");
124 1
125 }
126 }
127 }
128 Route::Sub { name, rest } if name == "emit-skill" => match emit_skill(&rest) {
129 Ok(text) => {
130 use std::io::Write;
131 let mut out = std::io::stdout().lock();
132 match out.write_all(text.as_bytes()).and_then(|()| out.flush()) {
133 Ok(()) => 0,
134 Err(e) => {
135 eprintln!("{}", ToolError::from(e).to_json());
136 1
137 }
138 }
139 }
140 Err(e) => {
141 eprintln!("{}", e.to_json());
142 2
143 }
144 },
145 Route::Sub { name, rest } if name == "doctor-json" => {
146 match doctor_main(host, version, &user_home, &rest, probe).await {
147 Ok(report) => {
148 println!("{report}");
149 0
150 }
151 Err(e) => {
152 eprintln!("{}", e.to_json());
153 2
154 }
155 }
156 }
157 Route::Sub { name, rest } if name == "gc" => {
158 match gc_main(host, &user_home, &rest, probe).await {
159 Ok(report) => {
160 println!("{report}");
161 0
162 }
163 Err(e) => {
164 eprintln!("{}", e.to_json());
165 2
166 }
167 }
168 }
169 Route::Pair(_) if launcher_withheld(host) => {
170 eprintln!("pair: {}", crate::launcher::NOT_OFFERED);
171 2
172 }
173 Route::Pair(rest) => launcher_main(host, version, &user_home, "pair", &rest, probe).await,
174 Route::Sub { name, .. }
175 if matches!(
176 name.as_str(),
177 "pair" | "install-launcher" | "resume" | "attach"
178 ) && launcher_withheld(host) =>
179 {
180 eprintln!("{name}: {}", crate::launcher::NOT_OFFERED);
181 2
182 }
183 Route::Sub { name, rest }
184 if matches!(
185 name.as_str(),
186 "pair" | "install-launcher" | "resume" | "attach"
187 ) =>
188 {
189 launcher_main(host, version, &user_home, &name, &rest, probe).await
190 }
191 Route::Sub { .. } => {
192 eprintln!("not implemented ({host:?}, argv0={argv0:?})");
193 2
194 }
195 }
196}
197
198pub struct ServePrep {
202 pub settings: settings::Settings,
203 pub notes: Vec<settings::Note>,
204 pub env: EnvSnapshot,
205 pub identity_warnings: Vec<crate::error::ToolError>,
206}
207
208pub fn prepare_serve(
209 host: Host,
210 ctl_home: &std::path::Path,
211 cwd: std::path::PathBuf,
212 get: &dyn Fn(&str) -> Option<String>,
213) -> ServePrep {
214 let mut env = EnvSnapshot::from_lookup(get, cwd, ctl_home.to_path_buf());
215 let (mut settings, notes) = settings::load_from_fs(host, ctl_home, &env.cwd, get);
216 let (identity, mut identity_warnings) = pairing::resolve_identity(&env, ctl_home);
217 identity_warnings.extend(pairing::apply_identity(&mut env, &mut settings, &identity));
218 ServePrep {
219 settings,
220 notes,
221 env,
222 identity_warnings,
223 }
224}
225
226async fn serve(
227 host: Host,
228 user_home: &std::path::Path,
229 rest: &[String],
230 probe: Arc<dyn ProcessProbe>,
231) -> Result<(), String> {
232 let host_arg = match rest {
233 [] => None,
234 [flag, value] if flag == "--host" => Some(value.as_str()),
235 _ => return Err(usage()),
236 };
237 let env_host = env_nonempty("OPENCODE_CTL_HOST");
238 let host = host::resolve(host, host_arg, env_host.as_deref()).map_err(|e| e.message)?;
239 let ctl_home = settings::ctl_home(&|k| env_nonempty(k));
240 let cwd = std::env::current_dir().unwrap_or_default();
241 let prep = prepare_serve(host, &ctl_home, cwd, &|k| std::env::var(k).ok());
242 let debug = env_nonempty("OPENCODE_CTL_LOG").is_some_and(|v| v.eq_ignore_ascii_case("debug"));
243 let report = startup(
244 &ctl_home,
245 host,
246 &prep.env,
247 probe.as_ref(),
248 SystemClock.now_ms(),
249 debug,
250 LogScope::Global,
251 &prep.identity_warnings,
252 );
253 if let Some(w) = &report.log_warning {
254 eprintln!("{w}");
255 }
256 for line in &report.identity_stderr {
257 eprintln!("opencode-ctl: {line}");
258 }
259 let transport = build_transport(&prep.settings, &ctl_home, user_home);
260 let engine = Arc::new(Engine::new(
261 prep.settings,
262 prep.notes,
263 host,
264 prep.env,
265 transport,
266 SystemClock,
267 probe,
268 ));
269 let running = OpencodeCtlServer::new(engine)
270 .serve(rmcp::transport::stdio())
271 .await
272 .map_err(|e| e.to_string())?;
273 running.waiting().await.map_err(|e| e.to_string())?;
274 drop(report);
275 Ok(())
276}
277
278pub fn emit_skill(rest: &[String]) -> Result<String, ToolError> {
281 let bad = |msg: String| ToolError::new(ErrorCode::InvalidArgument, msg);
282 let mut name: Option<&str> = None;
283 let mut host_arg: Option<&str> = None;
284 let mut it = rest.iter().map(String::as_str);
285 while let Some(w) = it.next() {
286 if w == "--host" {
287 let v = it
288 .next()
289 .ok_or_else(|| bad("--host needs a value (cc|qwen|agy|opencode)".into()))?;
290 if host_arg.replace(v).is_some() {
291 return Err(bad("--host given twice".into()));
292 }
293 } else if name.replace(w).is_some() {
294 return Err(bad(format!("unexpected argument '{w}'")));
295 }
296 }
297 let name = name.ok_or_else(|| {
298 bad(
299 "emit-skill <opencode-ctl-driver|opencode-ctl-doctor> --host <cc|qwen|agy|opencode>"
300 .into(),
301 )
302 })?;
303 let skill = SkillName::parse(name).ok_or_else(|| {
304 bad(format!(
305 "unknown skill '{name}' (opencode-ctl-driver or opencode-ctl-doctor)"
306 ))
307 })?;
308 let host = match host_arg {
309 Some("cc") => Host::Claude,
310 Some("qwen") => Host::Qwen,
311 Some("agy") => Host::Agy,
312 Some("opencode") => Host::Opencode,
313 Some(other) => {
314 return Err(bad(format!(
315 "unknown --host value '{other}' (cc, qwen, agy or opencode)"
316 )))
317 }
318 None => return Err(bad("--host <cc|qwen|agy|opencode> is required".into())),
319 };
320 render(skill, host)
321}
322
323#[derive(Debug, PartialEq, Eq)]
324pub struct DoctorArgs {
325 pub verbose: bool,
326 pub host_arg: Option<String>,
327}
328
329pub fn parse_doctor_args(rest: &[String]) -> Result<DoctorArgs, ToolError> {
331 let bad = |msg: String| ToolError::new(ErrorCode::InvalidArgument, msg);
332 let mut args = DoctorArgs {
333 verbose: false,
334 host_arg: None,
335 };
336 let mut it = rest.iter().map(String::as_str);
337 while let Some(w) = it.next() {
338 match w {
339 "verbose" => args.verbose = true,
340 "--host" => {
341 let v = it
342 .next()
343 .ok_or_else(|| bad("--host needs a value (opencode)".into()))?;
344 if args.host_arg.replace(v.to_string()).is_some() {
345 return Err(bad("--host given twice".into()));
346 }
347 }
348 other => {
349 return Err(bad(format!(
350 "unexpected argument '{other}' (doctor-json [verbose] [--host h])"
351 )))
352 }
353 }
354 }
355 Ok(args)
356}
357
358pub fn launcher_bin_dir(user_home: &Path, get: &dyn Fn(&str) -> Option<String>) -> PathBuf {
361 if cfg!(windows) {
362 blank_is_unset(get("LOCALAPPDATA"))
363 .map(PathBuf::from)
364 .unwrap_or_else(|| user_home.join("AppData").join("Local"))
365 .join("opencode-ctl")
366 } else {
367 user_home.join(".local").join("bin")
368 }
369}
370
371async fn doctor_main(
373 host: Host,
374 version: &str,
375 user_home: &Path,
376 rest: &[String],
377 probe: Arc<dyn ProcessProbe>,
378) -> Result<serde_json::Value, ToolError> {
379 let args = parse_doctor_args(rest)?;
380 let env_host = env_nonempty("OPENCODE_CTL_HOST");
381 let host = host::resolve(host, args.host_arg.as_deref(), env_host.as_deref())?;
382 let raw = |k: &str| std::env::var(k).ok();
383 let ctl_home = settings::ctl_home(&|k| env_nonempty(k));
384 let cwd = std::env::current_dir().unwrap_or_default();
385 let prep = prepare_serve(host, &ctl_home, cwd, &raw);
386 let transport = build_transport(&prep.settings, &ctl_home, user_home);
387 let engine = Engine::new(
388 prep.settings,
389 prep.notes,
390 host,
391 prep.env,
392 transport,
393 SystemClock,
394 probe,
395 );
396 let bin_dir = launcher_bin_dir(user_home, &raw);
397 let running_exe = std::env::current_exe().unwrap_or_default();
398 let registration = default_registration_file(&raw, user_home);
399 let config = default_config_file(&raw, user_home);
400 let ctx = DoctorCtx {
401 bin_dir: &bin_dir,
402 running_exe: &running_exe,
403 version,
404 user_home,
405 path: env_nonempty("PATH"),
406 registration_file: Some(®istration),
407 config_file: Some(&config),
408 env: &raw,
409 };
410 Ok(doctor_json(&engine, &ctx, args.verbose).await)
411}
412
413fn log_gc_report(home: &Path, report: &crate::gc::GcReport, debug: bool) {
416 let touched = report
417 .pairs_deleted
418 .iter()
419 .map(|id| (id, "deleted"))
420 .chain(
421 report
422 .dead_seen_written
423 .iter()
424 .map(|id| (id, "dead-seen written")),
425 )
426 .chain(
427 report
428 .dead_seen_cleared
429 .iter()
430 .map(|id| (id, "dead-seen cleared")),
431 );
432 for (id, what) in touched {
433 let path = home.join("logs").join(format!("pair-{id}.log"));
434 if let Ok(handle) = crate::logging::init_file_logging(&path, debug, LogScope::Scoped) {
435 tracing::info!("gc: pair {id} {what}");
436 drop(handle);
437 }
438 }
439}
440
441async fn gc_main(
443 host: Host,
444 user_home: &Path,
445 rest: &[String],
446 probe: Arc<dyn ProcessProbe>,
447) -> Result<serde_json::Value, ToolError> {
448 if !rest.is_empty() {
449 return Err(ToolError::new(
450 ErrorCode::InvalidArgument,
451 format!("unexpected argument '{}' (gc takes none)", rest[0]),
452 ));
453 }
454 let raw = |k: &str| std::env::var(k).ok();
455 let ctl_home = settings::ctl_home(&|k| env_nonempty(k));
456 let cwd = std::env::current_dir().unwrap_or_default();
457 let prep = prepare_serve(host, &ctl_home, cwd, &raw);
458 let transport = build_transport(&prep.settings, &ctl_home, user_home);
459 let engine = Engine::new(
460 prep.settings,
461 prep.notes,
462 host,
463 prep.env,
464 transport,
465 SystemClock,
466 probe.clone(),
467 );
468 let now = SystemClock.now_ms();
469 let report = crate::gc::gc(&engine, probe.as_ref(), now, None).await;
470 let debug = env_nonempty("OPENCODE_CTL_LOG").is_some_and(|v| v.eq_ignore_ascii_case("debug"));
471 log_gc_report(&ctl_home, &report, debug);
472 serde_json::to_value(&report).map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))
473}
474
475fn launcher_withheld(host: Host) -> bool {
477 let env_host = env_nonempty("OPENCODE_CTL_HOST");
478 host::resolve(host, None, env_host.as_deref()).is_ok_and(|h| !h.offers_launcher())
479}
480
481async fn launcher_main(
483 host: Host,
484 version: &str,
485 user_home: &Path,
486 name: &str,
487 rest: &[String],
488 probe: Arc<dyn ProcessProbe>,
489) -> i32 {
490 match launcher_run(host, version, user_home, name, rest, probe).await {
491 Ok(code) => code,
492 Err(e) => {
493 eprintln!("{}", e.to_json());
494 2
495 }
496 }
497}
498
499async fn launcher_run(
500 host: Host,
501 version: &str,
502 user_home: &Path,
503 name: &str,
504 rest: &[String],
505 probe: Arc<dyn ProcessProbe>,
506) -> Result<i32, ToolError> {
507 use crate::launcher::{self, LauncherCtx};
508 let raw = |k: &str| std::env::var(k).ok();
509 let ctl_home = settings::ctl_home(&|k| env_nonempty(k));
510 let cwd = std::env::current_dir().unwrap_or_default();
511 let mut prep = prepare_serve(host, &ctl_home, cwd, &raw);
512 if name == "resume" {
513 if let Ok((id, _, _)) = launcher::parse_resume_args(rest) {
515 let mut scratch = prep.env.clone();
516 for w in pairing::apply_record_server(&ctl_home, &id, &mut scratch, &mut prep.settings)
517 {
518 tracing::warn!("{}", w.message);
519 }
520 }
521 }
522 let opencode_bin = crate::transport::cli::resolve_binary(
523 prep.settings.opencode_bin.as_deref(),
524 env_nonempty("PATH").as_deref(),
525 user_home,
526 )
527 .ok();
528 let transport = build_transport(&prep.settings, &ctl_home, user_home);
529 let engine = Engine::new(
530 prep.settings,
531 prep.notes,
532 host,
533 prep.env,
534 transport,
535 SystemClock,
536 probe.clone(),
537 );
538 let bin_dir = launcher_bin_dir(user_home, &raw);
539 let running_exe = std::env::current_exe().unwrap_or_default();
540 let password = env_nonempty("OPENCODE_SERVER_PASSWORD");
541 let now = || SystemClock.now_ms();
542 let mut stdout = std::io::stdout();
543 let mut ctx = LauncherCtx {
544 home: &ctl_home,
545 bin_dir: &bin_dir,
546 running_exe: &running_exe,
547 host,
548 probe: probe.as_ref(),
549 now_ms: &now,
550 path_has: &launcher::system_path_has,
551 opencode_bin: opencode_bin.as_deref(),
552 version,
553 parent_chain: &launcher::system_parent_chain,
554 server_password: password.as_deref(),
555 spawn_detached: &launcher::system_spawn_detached,
556 spawn_and_wait: &launcher::system_spawn_and_wait,
557 before_claim: None,
558 out: &mut stdout,
559 };
560 match name {
561 "install-launcher" => {
562 let force = match rest {
563 [] => false,
564 [f] if f == "--force" => true,
565 [other, ..] => {
566 return Err(ToolError::new(
567 ErrorCode::InvalidArgument,
568 format!("unexpected argument '{other}' (install-launcher [--force])"),
569 ))
570 }
571 };
572 launcher::install_launcher(&mut ctx, force).map(|()| 0)
573 }
574 "resume" => {
575 let (id, controller, args) = launcher::parse_resume_args(rest)?;
576 launcher::resume_with(&id, controller.as_deref(), &args, &mut ctx, &engine).await
577 }
578 "attach" => {
579 let target = launcher::parse_attach_args(rest)?;
580 launcher::attach(&target, &mut ctx, &engine).await
581 }
582 _ => {
583 let args = launcher::parse_pair_args(rest)?;
584 launcher::pair(args, &mut ctx, &engine).await
585 }
586 }
587}