chasm_cli/providers/cloud/
anthropic.rs1use super::common::{
17 build_http_client, CloudConversation, CloudMessage, CloudProvider, FetchOptions,
18 HttpClientConfig,
19};
20use anyhow::{anyhow, Result};
21use chrono::{DateTime, Utc};
22use serde::Deserialize;
23
24const ANTHROPIC_WEB_API: &str = "https://claude.ai/api";
25
26pub struct AnthropicProvider {
28 api_key: Option<String>,
29 session_token: Option<String>,
30 organization_id: Option<String>,
31 client: Option<reqwest::blocking::Client>,
32}
33
34impl AnthropicProvider {
35 pub fn new(api_key: Option<String>) -> Self {
36 Self {
37 api_key,
38 session_token: None,
39 organization_id: None,
40 client: None,
41 }
42 }
43
44 pub fn with_session_token(session_token: String) -> Self {
46 Self {
47 api_key: None,
48 session_token: Some(session_token),
49 organization_id: None,
50 client: None,
51 }
52 }
53
54 fn ensure_client(&mut self) -> Result<&reqwest::blocking::Client> {
55 if self.client.is_none() {
56 let config = HttpClientConfig {
57 user_agent: "".to_string(),
58 ..Default::default()
59 };
60 self.client = Some(build_http_client(&config)?);
61 }
62 Ok(self.client.as_ref().unwrap())
63 }
64
65 fn get_organization_id(&mut self) -> Result<String> {
67 if let Some(ref org_id) = self.organization_id {
68 return Ok(org_id.clone());
69 }
70
71 let session_token = self
72 .session_token
73 .clone()
74 .ok_or_else(|| anyhow!("No session token available"))?;
75
76 let client = self.ensure_client()?;
77
78 let response = client
80 .get("https://claude.ai/api/bootstrap")
81 .header("Cookie", format!("sessionKey={}", session_token))
82 .header("Accept", "application/json")
83 .send()
84 .map_err(|e| anyhow!("Failed to get organization info: {}", e))?;
85
86 if !response.status().is_success() {
87 return Err(anyhow!(
88 "Bootstrap endpoint returned {}: authentication may have expired",
89 response.status()
90 ));
91 }
92
93 let bootstrap: serde_json::Value = response
94 .json()
95 .map_err(|e| anyhow!("Failed to parse bootstrap response: {}", e))?;
96
97 let org_id = bootstrap
99 .get("account")
100 .and_then(|a| a.get("memberships"))
101 .and_then(|m| m.as_array())
102 .and_then(|arr| arr.first())
103 .and_then(|m| m.get("organization"))
104 .and_then(|o| o.get("uuid"))
105 .and_then(|v| v.as_str())
106 .ok_or_else(|| anyhow!("Could not find organization ID in bootstrap response"))?
107 .to_string();
108
109 self.organization_id = Some(org_id.clone());
110 Ok(org_id)
111 }
112}
113
114#[derive(Debug, Deserialize)]
115struct ClaudeConversationList {
116 conversations: Vec<ClaudeConversationSummary>,
117}
118
119#[derive(Debug, Deserialize)]
120struct ClaudeConversationSummary {
121 uuid: String,
122 name: Option<String>,
123 created_at: String,
124 updated_at: String,
125 #[serde(default)]
126 model: Option<String>,
127}
128
129#[derive(Debug, Deserialize)]
130struct ClaudeConversationDetail {
131 uuid: String,
132 name: Option<String>,
133 created_at: String,
134 updated_at: String,
135 chat_messages: Vec<ClaudeMessage>,
136 #[serde(default)]
137 model: Option<String>,
138}
139
140#[derive(Debug, Deserialize)]
141struct ClaudeMessage {
142 uuid: String,
143 text: String,
144 sender: String, created_at: String,
146 #[serde(default)]
147 attachments: Vec<serde_json::Value>,
148}
149
150impl CloudProvider for AnthropicProvider {
151 fn name(&self) -> &'static str {
152 "Claude"
153 }
154
155 fn api_base_url(&self) -> &str {
156 ANTHROPIC_WEB_API
157 }
158
159 fn is_authenticated(&self) -> bool {
160 self.api_key.is_some() || self.session_token.is_some()
161 }
162
163 fn set_credentials(&mut self, api_key: Option<String>, session_token: Option<String>) {
164 self.api_key = api_key;
165 self.session_token = session_token;
166 self.organization_id = None; }
168
169 fn list_conversations(&self, options: &FetchOptions) -> Result<Vec<CloudConversation>> {
170 let mut provider = AnthropicProvider {
171 api_key: self.api_key.clone(),
172 session_token: self.session_token.clone(),
173 organization_id: self.organization_id.clone(),
174 client: None,
175 };
176
177 if !provider.is_authenticated() {
178 return Err(anyhow!(
179 "Claude requires authentication. Provide a session token from browser cookies.\n\
180 Run 'chasm harvest scan --web' to check browser authentication status."
181 ));
182 }
183
184 if provider.session_token.is_none() {
185 return Err(anyhow!(
186 "Claude conversation history requires web session authentication.\n\
187 The Anthropic API is stateless and doesn't store conversation history."
188 ));
189 }
190
191 let session_token = provider.session_token.clone().unwrap();
192 let org_id = provider.get_organization_id()?;
193 let client = provider.ensure_client()?;
194
195 let url = format!(
196 "{}/organizations/{}/chat_conversations",
197 ANTHROPIC_WEB_API, org_id
198 );
199
200 let response = client
201 .get(&url)
202 .header("Cookie", format!("sessionKey={}", session_token))
203 .header("Accept", "application/json")
204 .send()
205 .map_err(|e| anyhow!("Failed to fetch conversations: {}", e))?;
206
207 if !response.status().is_success() {
208 let status = response.status();
209 return Err(anyhow!(
210 "Claude API returned {}: session may have expired - log in to claude.ai in your browser.",
211 status
212 ));
213 }
214
215 let conversations: Vec<ClaudeConversationSummary> = response
216 .json()
217 .map_err(|e| anyhow!("Failed to parse conversation list: {}", e))?;
218
219 let mut result = Vec::new();
220 let limit = options.limit.unwrap_or(usize::MAX);
221
222 for conv in conversations.into_iter().take(limit) {
223 let created = parse_iso_timestamp(&conv.created_at)?;
224 let updated = parse_iso_timestamp(&conv.updated_at).ok();
225
226 if let Some(after) = options.after {
228 if created < after {
229 continue;
230 }
231 }
232 if let Some(before) = options.before {
233 if created > before {
234 continue;
235 }
236 }
237
238 result.push(CloudConversation {
239 id: conv.uuid,
240 title: conv.name,
241 created_at: created,
242 updated_at: updated,
243 model: conv.model,
244 messages: Vec::new(),
245 metadata: None,
246 });
247 }
248
249 Ok(result)
250 }
251
252 fn fetch_conversation(&self, id: &str) -> Result<CloudConversation> {
253 let mut provider = AnthropicProvider {
254 api_key: self.api_key.clone(),
255 session_token: self.session_token.clone(),
256 organization_id: self.organization_id.clone(),
257 client: None,
258 };
259
260 if provider.session_token.is_none() {
261 return Err(anyhow!(
262 "Claude requires session token for conversation details"
263 ));
264 }
265
266 let session_token = provider.session_token.clone().unwrap();
267 let org_id = provider.get_organization_id()?;
268 let client = provider.ensure_client()?;
269
270 let url = format!(
271 "{}/organizations/{}/chat_conversations/{}",
272 ANTHROPIC_WEB_API, org_id, id
273 );
274
275 let response = client
276 .get(&url)
277 .header("Cookie", format!("sessionKey={}", session_token))
278 .header("Accept", "application/json")
279 .send()
280 .map_err(|e| anyhow!("Failed to fetch conversation {}: {}", id, e))?;
281
282 if !response.status().is_success() {
283 return Err(anyhow!(
284 "Failed to fetch conversation {}: HTTP {}",
285 id,
286 response.status()
287 ));
288 }
289
290 let detail: ClaudeConversationDetail = response
291 .json()
292 .map_err(|e| anyhow!("Failed to parse conversation {}: {}", id, e))?;
293
294 let messages: Vec<CloudMessage> = detail
295 .chat_messages
296 .into_iter()
297 .map(|msg| CloudMessage {
298 id: Some(msg.uuid),
299 role: if msg.sender == "human" {
300 "user".to_string()
301 } else {
302 "assistant".to_string()
303 },
304 content: msg.text,
305 timestamp: parse_iso_timestamp(&msg.created_at).ok(),
306 model: detail.model.clone(),
307 })
308 .collect();
309
310 Ok(CloudConversation {
311 id: detail.uuid,
312 title: detail.name,
313 created_at: parse_iso_timestamp(&detail.created_at)?,
314 updated_at: parse_iso_timestamp(&detail.updated_at).ok(),
315 model: detail.model,
316 messages,
317 metadata: None,
318 })
319 }
320
321 fn api_key_env_var(&self) -> &'static str {
322 "ANTHROPIC_API_KEY"
323 }
324}
325
326pub fn parse_claude_export(json_data: &str) -> Result<Vec<CloudConversation>> {
328 let conversations: Vec<ClaudeExportConversation> = serde_json::from_str(json_data)?;
331
332 Ok(conversations
333 .into_iter()
334 .map(|conv| CloudConversation {
335 id: conv.uuid,
336 title: conv.name,
337 created_at: parse_iso_timestamp(&conv.created_at).unwrap_or_else(|_| Utc::now()),
338 updated_at: Some(parse_iso_timestamp(&conv.updated_at).unwrap_or_else(|_| Utc::now())),
339 model: conv.model,
340 messages: conv
341 .messages
342 .into_iter()
343 .map(|msg| CloudMessage {
344 id: Some(msg.uuid),
345 role: if msg.sender == "human" {
346 "user".to_string()
347 } else {
348 "assistant".to_string()
349 },
350 content: msg.text,
351 timestamp: parse_iso_timestamp(&msg.created_at).ok(),
352 model: None,
353 })
354 .collect(),
355 metadata: None,
356 })
357 .collect())
358}
359
360#[derive(Debug, Deserialize)]
361struct ClaudeExportConversation {
362 uuid: String,
363 name: Option<String>,
364 created_at: String,
365 updated_at: String,
366 messages: Vec<ClaudeExportMessage>,
367 #[serde(default)]
368 model: Option<String>,
369}
370
371#[derive(Debug, Deserialize)]
372struct ClaudeExportMessage {
373 uuid: String,
374 text: String,
375 sender: String,
376 created_at: String,
377}
378
379fn parse_iso_timestamp(s: &str) -> Result<DateTime<Utc>> {
380 DateTime::parse_from_rfc3339(s)
381 .map(|dt| dt.with_timezone(&Utc))
382 .map_err(|e| anyhow!("Failed to parse timestamp: {}", e))
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use chrono::Datelike;
389
390 #[test]
391 fn test_anthropic_provider_new() {
392 let provider = AnthropicProvider::new(Some("test-key".to_string()));
393 assert_eq!(provider.name(), "Claude");
394 assert!(provider.is_authenticated());
395 }
396
397 #[test]
398 fn test_anthropic_provider_unauthenticated() {
399 let provider = AnthropicProvider::new(None);
400 assert!(!provider.is_authenticated());
401 }
402
403 #[test]
404 fn test_parse_iso_timestamp() {
405 let ts = "2024-01-15T10:30:00Z";
406 let dt = parse_iso_timestamp(ts).unwrap();
407 assert_eq!(dt.year(), 2024);
408 assert_eq!(dt.month(), 1);
409 assert_eq!(dt.day(), 15);
410 }
411}