use std::path::{Path, PathBuf};
pub const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";
pub const SEAMS: &[&str] = &[
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_CACHE_HOME",
];
const DIR_NAME: &str = "opencode";
pub const PLUGINS_DIR_NAME: &str = "plugins";
pub const PLUGIN_FILE_NAME: &str = "openlatch.js";
pub const MIN_SUPPORTED_VERSION: (u64, u64, u64) = (1, 17, 0);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Located {
Default,
Xdg,
}
impl Located {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Xdg => "xdg",
}
}
}
fn xdg_config_home() -> Option<PathBuf> {
xdg_config_home_from(
std::env::var_os(XDG_CONFIG_HOME_ENV),
std::env::current_dir().ok().as_deref(),
)
}
pub(crate) fn xdg_config_home_from(
value: Option<std::ffi::OsString>,
cwd: Option<&Path>,
) -> Option<PathBuf> {
let path = PathBuf::from(value.filter(|v| !v.is_empty())?);
if path.is_absolute() {
return Some(path);
}
Some(cwd?.join(path))
}
fn default_root() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".config").join(DIR_NAME))
}
#[must_use]
pub fn root_located() -> Option<(PathBuf, Located)> {
root_located_in(xdg_config_home(), dirs::home_dir().as_deref())
}
pub(crate) fn root_located_in(
xdg: Option<PathBuf>,
home: Option<&Path>,
) -> Option<(PathBuf, Located)> {
if let Some(xdg) = xdg {
return Some((xdg.join(DIR_NAME), Located::Xdg));
}
Some((home?.join(".config").join(DIR_NAME), Located::Default))
}
#[must_use]
pub fn root() -> Option<PathBuf> {
root_located().map(|(p, _)| p)
}
#[must_use]
pub fn plugins_dir(root: &Path) -> PathBuf {
root.join(PLUGINS_DIR_NAME)
}
#[must_use]
pub fn plugin_path(root: &Path) -> PathBuf {
plugins_dir(root).join(PLUGIN_FILE_NAME)
}
#[must_use]
pub fn config_is_machine_global() -> bool {
let (Some(resolved), Some(default)) = (root(), default_root()) else {
return true;
};
let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canonical(&resolved) == canonical(&default)
}
#[must_use]
pub fn absent_seam(root: &Path) -> (&'static str, PathBuf) {
(XDG_CONFIG_HOME_ENV, root.join("absent-opencode-config"))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Installed {
pub cli: Option<PathBuf>,
pub desktop: Option<PathBuf>,
}
impl Installed {
#[must_use]
pub fn any(&self) -> bool {
self.cli.is_some() || self.desktop.is_some()
}
}
#[must_use]
pub fn detect() -> Option<PathBuf> {
installed().any().then(root).flatten()
}
#[must_use]
pub fn installed() -> Installed {
match dirs::home_dir() {
Some(home) => installed_in(
&home,
Some(crate::core::user_path::effective_path().as_os_str()),
),
None => Installed::default(),
}
}
pub(crate) fn installed_in(home: &Path, path_var: Option<&std::ffi::OsStr>) -> Installed {
Installed {
cli: cli_location(home, path_var),
desktop: desktop_locations(home).into_iter().find(|p| p.exists()),
}
}
fn cli_location(home: &Path, path_var: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
if let Some(found) = find_on_path(path_var, "opencode") {
return Some(found);
}
let name = if cfg!(windows) {
"opencode.exe"
} else {
"opencode"
};
let script = home.join(".opencode").join("bin").join(name);
script.is_file().then_some(script)
}
pub(crate) fn find_on_path(path_var: Option<&std::ffi::OsStr>, name: &str) -> Option<PathBuf> {
let path_var = path_var?;
let names: Vec<String> = if cfg!(windows) {
["exe", "cmd", "ps1"]
.iter()
.map(|ext| format!("{name}.{ext}"))
.collect()
} else {
vec![name.to_string()]
};
std::env::split_paths(path_var)
.filter(|dir| dir.is_absolute())
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
.find(|p| p.is_file())
}
pub(crate) fn desktop_locations(home: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
if cfg!(target_os = "macos") {
out.push(PathBuf::from("/Applications").join("OpenCode.app")); out.push(home.join("Applications").join("OpenCode.app"));
}
if cfg!(windows) {
if let Some(local) = dirs::data_local_dir() {
let programs = local.join("Programs");
out.push(programs.join("OpenCode").join("OpenCode.exe"));
out.push(programs.join("opencode").join("OpenCode.exe"));
out.push(programs.join("ai.opencode.desktop").join("OpenCode.exe"));
}
}
if cfg!(target_os = "linux") {
out.push(PathBuf::from("/usr/bin").join("ai.opencode.desktop")); out.push(
PathBuf::from("/usr/share/applications").join("ai.opencode.desktop.desktop"), );
out.push(
home.join(".local")
.join("share")
.join("applications")
.join("ai.opencode.desktop.desktop"),
);
}
out
}
#[must_use]
pub fn managed_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
return Some(PathBuf::from("/Library/Application Support/opencode")); }
if cfg!(windows) {
return std::env::var_os("ProgramData").map(|d| PathBuf::from(d).join("opencode"));
}
Some(PathBuf::from("/etc/opencode")) }
#[must_use]
pub fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
let token = text.split_whitespace().find(|t| {
t.trim_start_matches('v')
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
})?;
let core = token
.trim_start_matches('v')
.split(['-', '+'])
.next()
.unwrap_or_default();
let mut parts = core.split('.').map(|p| p.parse::<u64>().ok());
Some((
parts.next()??,
parts.next()??,
parts.next().flatten().unwrap_or(0),
))
}
#[must_use]
pub fn version_supported(version: (u64, u64, u64)) -> bool {
version >= MIN_SUPPORTED_VERSION
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_relative_xdg_config_home_resolves_against_the_cwd_as_opencode_does() {
let cwd = std::env::temp_dir().join("work-repo");
let absolute = std::env::temp_dir().join("abs-cfg");
assert_eq!(
xdg_config_home_from(Some("agent-config".into()), Some(&cwd)),
Some(cwd.join("agent-config"))
);
assert_eq!(
xdg_config_home_from(Some(absolute.clone().into_os_string()), Some(&cwd)),
Some(absolute)
);
assert_eq!(xdg_config_home_from(Some("".into()), Some(&cwd)), None);
assert_eq!(xdg_config_home_from(None, Some(&cwd)), None);
assert_eq!(xdg_config_home_from(Some("rel".into()), None), None);
let (root, located) = root_located_in(
xdg_config_home_from(Some("agent-config".into()), Some(&cwd)),
Some(Path::new("/h")),
)
.unwrap();
assert_eq!(root, cwd.join("agent-config").join("opencode"));
assert_eq!(located, Located::Xdg);
}
#[test]
fn root_is_xdg_when_set_else_home_config() {
let (p, l) = root_located_in(Some(PathBuf::from("/x/cfg")), Some(Path::new("/h"))).unwrap();
assert_eq!(p, PathBuf::from("/x/cfg").join("opencode"));
assert_eq!(l, Located::Xdg);
let (p, l) = root_located_in(None, Some(Path::new("/h"))).unwrap();
assert_eq!(p, Path::new("/h").join(".config").join("opencode"));
assert_eq!(l, Located::Default);
}
#[test]
fn plugin_path_is_plugins_openlatch_js() {
let p = plugin_path(Path::new("/r"));
assert_eq!(p, Path::new("/r").join("plugins").join("openlatch.js"));
}
#[test]
fn a_leftover_config_root_alone_is_not_a_detection() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
assert!(!installed_in(home.path(), None).any());
}
#[test]
fn the_install_script_target_is_a_detection() {
let home = tempfile::tempdir().unwrap();
let bin = home.path().join(".opencode").join("bin");
std::fs::create_dir_all(&bin).unwrap();
let name = if cfg!(windows) {
"opencode.exe"
} else {
"opencode"
};
std::fs::write(bin.join(name), "").unwrap();
assert!(installed_in(home.path(), None).cli.is_some());
}
#[test]
fn a_cli_on_path_is_a_detection() {
let home = tempfile::tempdir().unwrap();
let dir = tempfile::tempdir().unwrap();
let name = if cfg!(windows) {
"opencode.exe"
} else {
"opencode"
};
std::fs::write(dir.path().join(name), "").unwrap();
let path = std::env::join_paths([dir.path()]).unwrap();
assert_eq!(
installed_in(home.path(), Some(&path)).cli,
Some(dir.path().join(name))
);
}
#[test]
fn versions_parse_and_compare_against_the_floor() {
assert_eq!(parse_version("1.18.35\n"), Some((1, 18, 35)));
assert_eq!(parse_version("opencode v1.17.0"), Some((1, 17, 0)));
assert_eq!(parse_version("0.0.0-dev-202610"), Some((0, 0, 0)));
assert_eq!(parse_version("garbage"), None);
assert!(version_supported((1, 17, 0)));
assert!(version_supported((1, 18, 35)));
assert!(!version_supported((1, 16, 9)));
}
}