chasm_cli/providers/cloud/
anthropic.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Anthropic (Claude) cloud provider
4//!
5//! Fetches conversation history from Claude web interface.
6//!
7//! ## Authentication
8//!
9//! Requires either:
10//! - API key via `ANTHROPIC_API_KEY` environment variable
11//! - Session token for web interface access
12//!
13//! Note: The official Anthropic API is stateless and doesn't store conversations.
14//! Web conversation history requires session authentication.
15
16use 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
26/// Anthropic Claude provider for fetching conversation history
27pub 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    /// Create provider with session token from browser cookies
45    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    /// Get organization ID from the bootstrap endpoint
66    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        // Get organization info from bootstrap
79        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        // Try to get organization UUID from various paths
98        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, // "human" or "assistant"
145    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; // Clear cached org ID
167    }
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            // Apply date filters
227            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
326/// Parse a Claude export file (if available)
327pub fn parse_claude_export(json_data: &str) -> Result<Vec<CloudConversation>> {
328    // Claude doesn't have an official export format yet
329    // This is a placeholder for when/if they add one
330    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}