agentctl-auth 0.1.0

Unified auth pool and LLM API client for Claude Max Plan, OpenAI, and more
Documentation
//! Import credentials from auth-profiles.json format.

use crate::credential::{Credential, UsageStats};
use crate::pool::AuthPool;
use anyhow::{Context, Result};
use std::path::Path;

impl AuthPool {
    /// Import credentials from an auth-profiles.json file.
    pub fn import_from_auth_profiles_file(&mut self, path: &Path) -> Result<Vec<String>> {
        let content = std::fs::read_to_string(path)
            .with_context(|| format!("Failed to read: {}", path.display()))?;
        let json: serde_json::Value = serde_json::from_str(&content)
            .with_context(|| format!("Failed to parse JSON: {}", path.display()))?;
        Ok(self.import_from_auth_profiles_json(&json))
    }

    /// Import credentials from an auth-profiles.json structure.
    pub fn import_from_auth_profiles_json(&mut self, profiles_json: &serde_json::Value) -> Vec<String> {
        let mut imported = Vec::new();

        // Import profiles — flat structure: key = credential name (e.g. "anthropic:tonitang273")
        if let Some(profiles) = profiles_json.get("profiles").and_then(|v| v.as_object()) {
            for (cred_name, profile_data) in profiles {
                let provider = profile_data
                    .get("provider")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| {
                        cred_name
                            .split(':')
                            .next()
                            .unwrap_or(cred_name)
                            .to_string()
                    });

                let cred_type = profile_data
                    .get("type")
                    .and_then(|v| v.as_str())
                    .unwrap_or("token")
                    .to_string();

                let token = if cred_type == "oauth" {
                    profile_data
                        .get("access")
                        .or_else(|| profile_data.get("token"))
                        .and_then(|v| v.as_str())
                        .filter(|s| *s != "keychain")
                        .map(|s| s.to_string())
                } else {
                    profile_data
                        .get("token")
                        .or_else(|| profile_data.get("apiKey"))
                        .and_then(|v| v.as_str())
                        .map(|s| s.to_string())
                };

                let keychain_service = if cred_type == "oauth" {
                    profile_data
                        .get("access")
                        .and_then(|v| v.as_str())
                        .filter(|s| *s == "keychain")
                        .map(|_| format!("auth-profiles:{}", cred_name))
                } else {
                    None
                };

                let cred = Credential {
                    provider: provider.clone(),
                    cred_type,
                    token,
                    keychain_service,
                };

                self.pool.insert(cred_name.clone(), cred);
                imported.push(cred_name.clone());
            }
        }

        // Import order
        if let Some(order) = profiles_json.get("order").and_then(|v| v.as_object()) {
            for (provider, order_arr) in order {
                if let Some(arr) = order_arr.as_array() {
                    let names: Vec<String> = arr
                        .iter()
                        .filter_map(|v| v.as_str())
                        .map(|name| name.to_string())
                        .collect();
                    self.order.insert(provider.clone(), names);
                }
            }
        }

        // Import lastGood as defaults
        if let Some(last_good) = profiles_json.get("lastGood").and_then(|v| v.as_object()) {
            for (provider, name) in last_good {
                if let Some(name_str) = name.as_str() {
                    let full_name = if name_str.contains(':') {
                        name_str.to_string()
                    } else {
                        format!("{}:{}", provider, name_str)
                    };
                    self.defaults.insert(provider.clone(), full_name);
                }
            }
        }

        // Import usageStats
        if let Some(usage) = profiles_json.get("usageStats").and_then(|v| v.as_object()) {
            for (cred_name, stats) in usage {
                let usage_stat = UsageStats {
                    last_used: stats.get("lastUsed").and_then(|v| v.as_u64()),
                    error_count: stats
                        .get("errorCount")
                        .and_then(|v| v.as_u64())
                        .map(|v| v as u32),
                    cooldown_until: stats.get("cooldownUntil").and_then(|v| v.as_u64()),
                };
                self.usage_stats.insert(cred_name.clone(), usage_stat);
            }
        }

        // Ensure defaults are set for providers without lastGood
        for provider in self.providers() {
            if !self.defaults.contains_key(&provider) {
                if let Some(order) = self.order.get(&provider) {
                    if let Some(first) = order.first() {
                        self.defaults.insert(provider, first.clone());
                    }
                }
            }
        }

        imported
    }
}

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

    #[test]
    fn test_import_from_json() {
        let json = serde_json::json!({
            "profiles": {
                "anthropic:default": {
                    "type": "token",
                    "provider": "anthropic",
                    "token": "sk-ant-1"
                },
                "anthropic:tonitang273": {
                    "type": "token",
                    "provider": "anthropic",
                    "token": "sk-ant-2"
                }
            },
            "order": {
                "anthropic": ["anthropic:default", "anthropic:tonitang273"]
            },
            "lastGood": {
                "anthropic": "anthropic:default"
            },
            "usageStats": {
                "anthropic:default": {"lastUsed": 12345, "errorCount": 0}
            }
        });

        let mut pool = AuthPool::default();
        let imported = pool.import_from_auth_profiles_json(&json);

        assert_eq!(imported.len(), 2);
        assert!(pool.get("anthropic:default").is_some());
        assert!(pool.get("anthropic:tonitang273").is_some());
        assert_eq!(
            pool.defaults.get("anthropic").map(|s| s.as_str()),
            Some("anthropic:default")
        );
    }

    #[test]
    fn test_import_oauth_with_keychain() {
        let json = serde_json::json!({
            "profiles": {
                "anthropic:keychain": {
                    "type": "oauth",
                    "provider": "anthropic",
                    "access": "keychain",
                    "refresh": "keychain",
                    "expires": 9999999999999u64
                }
            },
            "order": {
                "anthropic": ["anthropic:keychain"]
            }
        });

        let mut pool = AuthPool::default();
        pool.import_from_auth_profiles_json(&json);

        let cred = pool.get("anthropic:keychain").unwrap();
        assert_eq!(cred.cred_type, "oauth");
        assert!(cred.token.is_none());
        assert!(cred.keychain_service.is_some());
    }
}