Skip to main content

agentctl_auth/
import.rs

1//! Import credentials from auth-profiles.json format.
2
3use crate::credential::{Credential, UsageStats};
4use crate::pool::AuthPool;
5use anyhow::{Context, Result};
6use std::path::Path;
7
8impl AuthPool {
9    /// Import credentials from an auth-profiles.json file.
10    pub fn import_from_auth_profiles_file(&mut self, path: &Path) -> Result<Vec<String>> {
11        let content = std::fs::read_to_string(path)
12            .with_context(|| format!("Failed to read: {}", path.display()))?;
13        let json: serde_json::Value = serde_json::from_str(&content)
14            .with_context(|| format!("Failed to parse JSON: {}", path.display()))?;
15        Ok(self.import_from_auth_profiles_json(&json))
16    }
17
18    /// Import credentials from an auth-profiles.json structure.
19    pub fn import_from_auth_profiles_json(&mut self, profiles_json: &serde_json::Value) -> Vec<String> {
20        let mut imported = Vec::new();
21
22        // Import profiles — flat structure: key = credential name (e.g. "anthropic:tonitang273")
23        if let Some(profiles) = profiles_json.get("profiles").and_then(|v| v.as_object()) {
24            for (cred_name, profile_data) in profiles {
25                let provider = profile_data
26                    .get("provider")
27                    .and_then(|v| v.as_str())
28                    .map(|s| s.to_string())
29                    .unwrap_or_else(|| {
30                        cred_name
31                            .split(':')
32                            .next()
33                            .unwrap_or(cred_name)
34                            .to_string()
35                    });
36
37                let cred_type = profile_data
38                    .get("type")
39                    .and_then(|v| v.as_str())
40                    .unwrap_or("token")
41                    .to_string();
42
43                let token = if cred_type == "oauth" {
44                    profile_data
45                        .get("access")
46                        .or_else(|| profile_data.get("token"))
47                        .and_then(|v| v.as_str())
48                        .filter(|s| *s != "keychain")
49                        .map(|s| s.to_string())
50                } else {
51                    profile_data
52                        .get("token")
53                        .or_else(|| profile_data.get("apiKey"))
54                        .and_then(|v| v.as_str())
55                        .map(|s| s.to_string())
56                };
57
58                let keychain_service = if cred_type == "oauth" {
59                    profile_data
60                        .get("access")
61                        .and_then(|v| v.as_str())
62                        .filter(|s| *s == "keychain")
63                        .map(|_| format!("auth-profiles:{}", cred_name))
64                } else {
65                    None
66                };
67
68                let cred = Credential {
69                    provider: provider.clone(),
70                    cred_type,
71                    token,
72                    keychain_service,
73                };
74
75                self.pool.insert(cred_name.clone(), cred);
76                imported.push(cred_name.clone());
77            }
78        }
79
80        // Import order
81        if let Some(order) = profiles_json.get("order").and_then(|v| v.as_object()) {
82            for (provider, order_arr) in order {
83                if let Some(arr) = order_arr.as_array() {
84                    let names: Vec<String> = arr
85                        .iter()
86                        .filter_map(|v| v.as_str())
87                        .map(|name| name.to_string())
88                        .collect();
89                    self.order.insert(provider.clone(), names);
90                }
91            }
92        }
93
94        // Import lastGood as defaults
95        if let Some(last_good) = profiles_json.get("lastGood").and_then(|v| v.as_object()) {
96            for (provider, name) in last_good {
97                if let Some(name_str) = name.as_str() {
98                    let full_name = if name_str.contains(':') {
99                        name_str.to_string()
100                    } else {
101                        format!("{}:{}", provider, name_str)
102                    };
103                    self.defaults.insert(provider.clone(), full_name);
104                }
105            }
106        }
107
108        // Import usageStats
109        if let Some(usage) = profiles_json.get("usageStats").and_then(|v| v.as_object()) {
110            for (cred_name, stats) in usage {
111                let usage_stat = UsageStats {
112                    last_used: stats.get("lastUsed").and_then(|v| v.as_u64()),
113                    error_count: stats
114                        .get("errorCount")
115                        .and_then(|v| v.as_u64())
116                        .map(|v| v as u32),
117                    cooldown_until: stats.get("cooldownUntil").and_then(|v| v.as_u64()),
118                };
119                self.usage_stats.insert(cred_name.clone(), usage_stat);
120            }
121        }
122
123        // Ensure defaults are set for providers without lastGood
124        for provider in self.providers() {
125            if !self.defaults.contains_key(&provider) {
126                if let Some(order) = self.order.get(&provider) {
127                    if let Some(first) = order.first() {
128                        self.defaults.insert(provider, first.clone());
129                    }
130                }
131            }
132        }
133
134        imported
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_import_from_json() {
144        let json = serde_json::json!({
145            "profiles": {
146                "anthropic:default": {
147                    "type": "token",
148                    "provider": "anthropic",
149                    "token": "sk-ant-1"
150                },
151                "anthropic:tonitang273": {
152                    "type": "token",
153                    "provider": "anthropic",
154                    "token": "sk-ant-2"
155                }
156            },
157            "order": {
158                "anthropic": ["anthropic:default", "anthropic:tonitang273"]
159            },
160            "lastGood": {
161                "anthropic": "anthropic:default"
162            },
163            "usageStats": {
164                "anthropic:default": {"lastUsed": 12345, "errorCount": 0}
165            }
166        });
167
168        let mut pool = AuthPool::default();
169        let imported = pool.import_from_auth_profiles_json(&json);
170
171        assert_eq!(imported.len(), 2);
172        assert!(pool.get("anthropic:default").is_some());
173        assert!(pool.get("anthropic:tonitang273").is_some());
174        assert_eq!(
175            pool.defaults.get("anthropic").map(|s| s.as_str()),
176            Some("anthropic:default")
177        );
178    }
179
180    #[test]
181    fn test_import_oauth_with_keychain() {
182        let json = serde_json::json!({
183            "profiles": {
184                "anthropic:keychain": {
185                    "type": "oauth",
186                    "provider": "anthropic",
187                    "access": "keychain",
188                    "refresh": "keychain",
189                    "expires": 9999999999999u64
190                }
191            },
192            "order": {
193                "anthropic": ["anthropic:keychain"]
194            }
195        });
196
197        let mut pool = AuthPool::default();
198        pool.import_from_auth_profiles_json(&json);
199
200        let cred = pool.get("anthropic:keychain").unwrap();
201        assert_eq!(cred.cred_type, "oauth");
202        assert!(cred.token.is_none());
203        assert!(cred.keychain_service.is_some());
204    }
205}