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