Skip to main content

opencode_ctl/
cli.rs

1//! Argv routing and the dispatch skeleton.
2
3use 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
41/// The ONE empty-is-unset rule: a value empty after trimming is unset; any other value is returned verbatim.
42pub 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
50/// The file stem of `argv0` with the directory and a `.exe` suffix stripped (either separator).
51fn 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
59/// Under the name `opencode-pair` (the launcher link) a leading `resume`, `attach`, `gc`,
60/// `install-launcher`, `doctor-json` or `--version` is that subcommand and anything else, no
61/// arguments included, is `pair`. Under the long binary names the subcommand is always explicit.
62pub 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
198/// What `serve` resolves before it starts logging: settings loaded with the RAW environment (an
199/// empty `OPENCODE_CTL_SERVER` must stay visible as the background-service override), the
200/// pairing identity resolved and applied (`pairing.rs`), and the warnings for the log.
201pub 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
278/// `emit-skill <name> --host <cc|qwen|agy|opencode>` (the two words in either order): the rendered skill, byte
279/// for byte what goes to stdout. Any other argument shape, name or host is `INVALID_ARGUMENT`.
280pub 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
329/// `doctor-json [verbose] [--host <h>]`, the two in either order; any other word is `INVALID_ARGUMENT`.
330pub 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
358/// The launcher install dir (spec Launcher, install-launcher): `~/.local/bin` on POSIX,
359/// `%LOCALAPPDATA%\opencode-ctl` on Windows. `get` is the raw environment lookup.
360pub 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
371/// `doctor-json`: the report for the real environment, as the value to print on stdout.
372async 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(&registration),
407        config_file: Some(&config),
408        env: &raw,
409    };
410    Ok(doctor_json(&engine, &ctx, args.verbose).await)
411}
412
413/// One line per pair `gc` touched, appended to that pair's `pair-<id>.log` (the `gc` subcommand
414/// opens each log `Scoped`; `pair` writes to its own already-open log instead and never calls this).
415fn 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
441/// `gc` (no arguments): the report for the real environment as the JSON to print on stdout.
442async 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
475/// The OpenCode host (the compiled constant or `OPENCODE_CTL_HOST=opencode`) is never offered the launcher.
476fn 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
481/// `pair` and `install-launcher` for the real environment; returns the exit code.
482async 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        // `resume` checks the session and posts the exit notice on the server its pair was made for.
514        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}