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 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    /// Get organization ID from the bootstrap endpoint
64    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        // Get organization info from bootstrap
77        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        // Try to get organization UUID from various paths
96        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, // "human" or "assistant"
143    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; // Clear cached org ID
165    }
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            // Apply date filters
225            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
324/// Parse a Claude export file (if available)
325pub fn parse_claude_export(json_data: &str) -> Result<Vec<CloudConversation>> {
326    // Claude doesn't have an official export format yet
327    // This is a placeholder for when/if they add one
328    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}