oy-cli 0.8.7

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::env;
use std::fs;
use std::path::PathBuf;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthAvailability {
    Present,
    AutoConfigured,
    Missing,
}

impl AuthAvailability {
    pub fn is_available(self) -> bool {
        matches!(self, Self::Present | Self::AutoConfigured)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthStatus {
    pub adapter: String,
    pub env_var: Option<String>,
    pub availability: AuthAvailability,
    pub source: String,
    pub detail: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GitHubCopilotAuth {
    ApiKey(String),
    GitHubAccessToken(String),
}

pub(crate) fn auth_statuses() -> Vec<AuthStatus> {
    let mut items = Vec::new();
    if let Some(status) = openai_status() {
        items.push(status);
    }
    for provider in opencode_auth_providers() {
        if let Some(status) = opencode_status(&provider) {
            items.push(status);
        }
    }
    items.push(github_status());
    items
        .into_iter()
        .filter(|item| item.availability.is_available())
        .collect()
}

pub(crate) fn env_value(name: &str) -> Option<String> {
    env::var(name).ok().filter(|v| !v.trim().is_empty())
}

pub(crate) fn opencode_auth_key(provider: &str) -> Option<String> {
    provider_api_key(provider)
        .or_else(|| env_value("OPENCODE_API_KEY"))
        .or_else(|| opencode_auth_key_from_path(provider, opencode_auth_path()))
}

pub(crate) fn github_copilot_auth() -> Option<GitHubCopilotAuth> {
    copilot_api_key()
        .map(GitHubCopilotAuth::ApiKey)
        .or_else(|| github_access_token().map(GitHubCopilotAuth::GitHubAccessToken))
}

fn copilot_api_key() -> Option<String> {
    provider_api_key("github-copilot")
        .or_else(|| env_value("COPILOT_API_KEY"))
        .or_else(|| env_value("OPENCODE_API_KEY"))
        .or_else(|| opencode_auth_key_from_path("github-copilot", opencode_auth_path()))
}

fn github_access_token() -> Option<String> {
    env_value("COPILOT_GITHUB_ACCESS_TOKEN")
        .or_else(|| env_value("COPILOT_GITHUB_TOKEN"))
        .or_else(|| env_value("GH_TOKEN"))
        .or_else(|| env_value("GITHUB_TOKEN"))
        .or_else(gh_auth_token)
}

fn opencode_status(provider: &str) -> Option<AuthStatus> {
    let _key = opencode_auth_key(provider)?;
    Some(AuthStatus {
        adapter: provider.to_string(),
        env_var: Some("OPENCODE_API_KEY or OpenCode auth.json".to_string()),
        availability: AuthAvailability::Present,
        source: if env_value("OPENCODE_API_KEY").is_some() {
            "env"
        } else {
            "opencode auth.json"
        }
        .to_string(),
        detail: format!("OpenCode credentials detected for `{provider}`."),
    })
}

fn openai_status() -> Option<AuthStatus> {
    let _key = env_value("OPENAI_API_KEY")?;
    Some(AuthStatus {
        adapter: "openai".to_string(),
        env_var: Some("OPENAI_API_KEY".to_string()),
        availability: AuthAvailability::Present,
        source: "env".to_string(),
        detail: format!(
            "using {}",
            env_value("OPENAI_BASE_URL")
                .unwrap_or_else(|| "https://api.openai.com/v1".to_string())
                .trim_end_matches('/')
        ),
    })
}

fn github_status() -> AuthStatus {
    let auth = github_copilot_auth();
    let auto = github_token_auto_configured();
    let api_key = copilot_api_key();
    let present = auth.is_some();
    let availability = match (present, auto) {
        (true, _) => AuthAvailability::Present,
        (false, true) => AuthAvailability::AutoConfigured,
        (false, false) => AuthAvailability::Missing,
    };
    let detail = if api_key.is_some() {
        "Copilot API token detected; direct Copilot auth available.".to_string()
    } else if auth.is_some() && auto {
        "GitHub token available from `gh auth token`.".to_string()
    } else if auth.is_some() {
        "GitHub token detected; copilot-compatible auth available.".to_string()
    } else {
        "No GitHub auth token detected.".to_string()
    };
    AuthStatus {
        adapter: "github-copilot".to_string(),
        env_var: Some(
            "GITHUB_COPILOT_API_KEY, COPILOT_API_KEY, COPILOT_GITHUB_ACCESS_TOKEN, COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN, OpenCode auth.json".to_string(),
        ),
        availability,
        source: if api_key.is_some() {
            "copilot api token"
        } else if auto {
            "gh"
        } else if auth.is_some() {
            "env"
        } else {
            "missing"
        }
        .to_string(),
        detail,
    }
}

fn opencode_auth_providers() -> Vec<String> {
    let value = fs::read_to_string(opencode_auth_path())
        .ok()
        .and_then(|text| serde_json::from_str::<Value>(&text).ok());
    let Some(Value::Object(map)) = value else {
        return Vec::new();
    };
    let root = Value::Object(map.clone());
    let mut providers = map
        .keys()
        .filter(|provider| opencode_auth_key_from_value(provider, &root).is_some())
        .cloned()
        .collect::<Vec<_>>();
    providers.sort();
    providers.dedup();
    providers
}

fn provider_api_key(provider: &str) -> Option<String> {
    let env_name = format!(
        "{}_API_KEY",
        provider.to_ascii_uppercase().replace(['-', '.'], "_")
    );
    env_value(&env_name)
}

fn opencode_auth_path() -> PathBuf {
    dirs::data_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join("opencode")
        .join("auth.json")
}

fn opencode_auth_key_from_path(provider: &str, path: PathBuf) -> Option<String> {
    let value = fs::read_to_string(path)
        .ok()
        .and_then(|text| serde_json::from_str::<Value>(&text).ok())?;
    opencode_auth_key_from_value(provider, &value)
}

fn opencode_auth_key_from_value(provider: &str, value: &Value) -> Option<String> {
    let provider_value = value
        .get(provider)
        .or_else(|| provider_alias(provider).and_then(|alias| value.get(alias)))?;
    match provider_value.get("type").and_then(Value::as_str) {
        Some("api") => provider_value
            .get("key")
            .and_then(Value::as_str)
            .filter(|key| !key.trim().is_empty())
            .map(ToOwned::to_owned),
        Some("wellknown") => provider_value
            .get("token")
            .or_else(|| provider_value.get("key"))
            .or_else(|| provider_value.get("access"))
            .and_then(Value::as_str)
            .filter(|key| !key.trim().is_empty())
            .map(ToOwned::to_owned),
        Some("oauth") => provider_value
            .get("access")
            .or_else(|| provider_value.get("refresh"))
            .and_then(Value::as_str)
            .filter(|key| !key.trim().is_empty())
            .map(ToOwned::to_owned),
        _ => None,
    }
}

fn provider_alias(provider: &str) -> Option<&'static str> {
    match provider {
        "copilot" => Some("github-copilot"),
        "opencode-go" => Some("opencode"),
        _ => None,
    }
}

fn github_token_auto_configured() -> bool {
    copilot_api_key().is_none()
        && env_value("COPILOT_GITHUB_ACCESS_TOKEN").is_none()
        && env_value("COPILOT_GITHUB_TOKEN").is_none()
        && env_value("GH_TOKEN").is_none()
        && env_value("GITHUB_TOKEN").is_none()
        && gh_auth_token().is_some()
}

fn gh_auth_token() -> Option<String> {
    let output = std::process::Command::new("gh")
        .arg("auth")
        .arg("token")
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
    (!token.is_empty()).then_some(token)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn opencode_reads_generic_auth_json_shapes() {
        let value = serde_json::json!({
            "anthropic": { "type": "api", "key": "anthropic-token" },
            "github-copilot": { "type": "wellknown", "token": "copilot-token" },
            "github-copilot-oauth": { "type": "oauth", "refresh": "copilot-oauth-token", "access": "old-token" }
        });
        assert_eq!(
            opencode_auth_key_from_value("anthropic", &value),
            Some("anthropic-token".to_string())
        );
        assert_eq!(
            opencode_auth_key_from_value("copilot", &value),
            Some("copilot-token".to_string())
        );
        assert_eq!(
            opencode_auth_key_from_value("github-copilot-oauth", &value),
            Some("old-token".to_string())
        );
    }
}