use std::ffi::OsString;
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::cache::home_dir;
use crate::error::{AppError, Result};
const PROVIDER_ID: &str = "opencode";
const KEY_TYPES: &[&str] = &["api", "zen"];
pub fn read_key_from(path: &Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&text).ok()?;
let entry = value.get(PROVIDER_ID)?.as_object()?;
if !entry
.get("type")
.and_then(Value::as_str)
.is_some_and(|t| KEY_TYPES.contains(&t))
{
return None;
}
let key = entry.get("key")?.as_str()?.trim();
(!key.is_empty()).then(|| key.to_string())
}
pub fn default_auth_path() -> Result<PathBuf> {
let dir = data_dir(std::env::var_os("XDG_DATA_HOME"), home_dir()?);
Ok(dir.join("opencode").join("auth.json"))
}
fn data_dir(var: Option<OsString>, home: PathBuf) -> PathBuf {
if let Some(dir) = var.map(PathBuf::from).filter(|dir| dir.is_absolute()) {
return dir;
}
home.join(".local").join("share")
}
pub fn effective_paths(configured: Option<&[PathBuf]>) -> Result<Vec<PathBuf>> {
match configured {
Some(paths) if !paths.is_empty() => Ok(paths.to_vec()),
_ => Ok(vec![default_auth_path()?]),
}
}
pub fn probe(configured: Option<&[PathBuf]>) -> bool {
effective_paths(configured)
.ok()
.is_some_and(|paths| paths.iter().any(|path| read_key_from(path).is_some()))
}
pub fn resolve_key(
env_var_name: &str,
inline: Option<&str>,
configured: Option<&[PathBuf]>,
) -> Result<String> {
let paths = effective_paths(configured)?;
resolve_from(
env_var_name,
std::env::var(env_var_name).ok().as_deref(),
inline,
&paths,
)
}
pub fn resolve_from(
env_var_name: &str,
env_key: Option<&str>,
inline: Option<&str>,
paths: &[PathBuf],
) -> Result<String> {
if let Some(key) = non_empty(env_key).or_else(|| non_empty(inline)) {
return Ok(key.to_string());
}
for path in paths {
if let Some(key) = read_key_from(path) {
return Ok(key);
}
}
Err(AppError::Credentials(signed_out_message(env_var_name)))
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|v| !v.is_empty())
}
fn signed_out_message(env_var_name: &str) -> String {
format!(
"OpenCode Go is not signed in. Run `opencode auth login` and choose \
OpenCode, or set {env_var_name}."
)
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::*;
fn write(dir: &Path, name: &str, value: Value) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
path
}
#[test]
fn reads_the_current_api_key_shape() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"auth.json",
serde_json::json!({
"opencode": {"type": "api", "key": "sk-live-key"}
}),
);
assert_eq!(read_key_from(&path).unwrap(), "sk-live-key");
}
#[test]
fn accepts_the_legacy_zen_type() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"auth.json",
serde_json::json!({"opencode": {"type": "zen", "key": "zen-key"}}),
);
assert_eq!(read_key_from(&path).unwrap(), "zen-key");
}
#[test]
fn oauth_and_wellknown_entries_are_not_api_keys() {
let dir = tempfile::tempdir().unwrap();
let oauth = write(
dir.path(),
"oauth.json",
serde_json::json!({"opencode": {"type": "oauth", "token": "tok"}}),
);
assert!(read_key_from(&oauth).is_none());
let wellknown = write(
dir.path(),
"wellknown.json",
serde_json::json!({"opencode": {"type": "wellknown", "key": "OPENAI_API_KEY", "token": "sk-real"}}),
);
assert!(read_key_from(&wellknown).is_none());
}
#[test]
fn missing_provider_entry_is_absent() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"auth.json",
serde_json::json!({"google": {"type": "api", "key": "sk-google"}}),
);
assert_eq!(read_key_from(&path), None);
}
#[test]
fn blank_key_or_malformed_file_is_absent() {
let dir = tempfile::tempdir().unwrap();
let blank = write(
dir.path(),
"blank.json",
serde_json::json!({"opencode": {"type": "api", "key": " "}}),
);
assert_eq!(read_key_from(&blank), None);
let malformed = dir.path().join("malformed.json");
std::fs::write(&malformed, "not json").unwrap();
assert_eq!(read_key_from(&malformed), None);
assert_eq!(read_key_from(&dir.path().join("missing.json")), None);
}
#[test]
fn data_dir_prefers_an_absolute_xdg_data_home() {
#[cfg(windows)]
let absolute = PathBuf::from(r"C:\custom\data");
#[cfg(not(windows))]
let absolute = PathBuf::from("/custom/data");
let home = PathBuf::from(if cfg!(windows) {
r"C:\Users\you"
} else {
"/home/you"
});
assert_eq!(
data_dir(Some(absolute.clone().into_os_string()), home.clone()),
absolute
);
assert_eq!(
data_dir(Some(OsString::from("relative/data")), home.clone()),
home.join(".local").join("share")
);
assert_eq!(
data_dir(None, home.clone()),
home.join(".local").join("share")
);
}
#[test]
fn effective_paths_override_names_at_least_one_path() {
let paths = vec![PathBuf::from("/a/auth.json"), PathBuf::from("/b/auth.json")];
assert_eq!(effective_paths(Some(&paths)).unwrap(), paths);
}
#[test]
fn resolve_env_beats_inline_and_file() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"auth.json",
serde_json::json!({"opencode": {"type": "api", "key": "file-key"}}),
);
let paths = [path];
assert_eq!(
resolve_from(
"OPENCODE_GO_API_KEY",
Some("env-key"),
Some("inline-key"),
&paths
)
.unwrap(),
"env-key"
);
assert_eq!(
resolve_from("OPENCODE_GO_API_KEY", None, Some("inline-key"), &paths).unwrap(),
"inline-key"
);
assert_eq!(
resolve_from("OPENCODE_GO_API_KEY", None, None, &paths).unwrap(),
"file-key"
);
}
#[test]
fn probe_sees_a_live_key_and_ignores_an_absent_one() {
let dir = tempfile::tempdir().unwrap();
let live = write(
dir.path(),
"live.json",
serde_json::json!({"opencode": {"type": "api", "key": "sk-key"}}),
);
let empty = write(
dir.path(),
"empty.json",
serde_json::json!({"google": {"type": "api", "key": "sk-key"}}),
);
assert!(probe(Some(std::slice::from_ref(&live))));
assert!(!probe(Some(&[empty])));
assert!(!probe(Some(&[dir.path().join("missing.json")])));
}
#[test]
fn resolve_failure_names_both_ways_in() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"auth.json",
serde_json::json!({"google": {"type": "api", "key": "sk-key"}}),
);
let paths = [path];
let error = resolve_from("OPENCODE_GO_API_KEY", None, None, &paths).unwrap_err();
let message = format!("{error}");
assert!(message.contains("opencode auth login"), "{message}");
assert!(message.contains("OPENCODE_GO_API_KEY"), "{message}");
}
}