Skip to main content

claudex_cli/commands/
export.rs

1use std::fs;
2use std::io::{self, Write};
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use chrono::DateTime;
7use serde_json::Value;
8
9use claudex::index::{IndexStore, IndexedSession};
10use claudex::parser::{parse_session, stream_records};
11use claudex::providers::enabled_default;
12use claudex::store::{
13    SessionStore, decode_project_name, display_project_name, find_matching_sessions,
14};
15
16pub fn run(
17    selector: &str,
18    format: &str,
19    output: Option<&str>,
20    project_filter: Option<&str>,
21) -> Result<()> {
22    if !["markdown", "json"].contains(&format) {
23        anyhow::bail!("unknown format {:?}; expected markdown or json", format);
24    }
25
26    let indexed = resolve_indexed(selector, project_filter).unwrap_or_default();
27    if !indexed.is_empty() {
28        let mut buf = Vec::new();
29        if format == "json" {
30            let mut payload = Vec::new();
31            for row in &indexed {
32                payload.push(build_indexed_json_value(row)?);
33            }
34            let output = if payload.len() == 1 {
35                payload.into_iter().next().unwrap_or(Value::Null)
36            } else {
37                Value::Array(payload)
38            };
39            writeln!(buf, "{}", serde_json::to_string_pretty(&output)?)?;
40        } else {
41            for row in &indexed {
42                buf.write_all(build_indexed_markdown(row)?.as_bytes())?;
43            }
44        }
45        write_output(output, &buf)?;
46        return Ok(());
47    }
48
49    let store = SessionStore::new()?;
50    let all_files = store.all_session_files(project_filter)?;
51    let matching = find_matching_sessions(&all_files, selector);
52
53    if matching.is_empty() {
54        anyhow::bail!("no sessions found matching {:?}", selector);
55    }
56
57    let mut buf = Vec::new();
58    if format == "json" {
59        let mut payload = Vec::new();
60        for (project_raw, path) in &matching {
61            let project = display_project_name(&decode_project_name(project_raw));
62            payload.push(build_json_value(&project, path)?);
63        }
64        let output = if payload.len() == 1 {
65            payload.into_iter().next().unwrap_or(Value::Null)
66        } else {
67            Value::Array(payload)
68        };
69        writeln!(buf, "{}", serde_json::to_string_pretty(&output)?)?;
70    } else {
71        for (project_raw, path) in &matching {
72            let project = display_project_name(&decode_project_name(project_raw));
73            buf.write_all(build_markdown(&project, path)?.as_bytes())?;
74        }
75    }
76    write_output(output, &buf)?;
77
78    Ok(())
79}
80
81fn write_output(output: Option<&str>, bytes: &[u8]) -> Result<()> {
82    match output {
83        Some(path) => {
84            fs::write(path, bytes).with_context(|| format!("creating output file {path}"))
85        }
86        None => io::stdout().write_all(bytes).map_err(Into::into),
87    }
88}
89
90fn resolve_indexed(selector: &str, project_filter: Option<&str>) -> Result<Vec<IndexedSession>> {
91    let providers = enabled_default()?;
92    if providers.is_empty() {
93        return Ok(Vec::new());
94    }
95    let mut idx = IndexStore::open()?;
96    idx.ensure_fresh(&providers)?;
97    Ok(idx
98        .query_session_matches(selector, project_filter)?
99        .into_iter()
100        .filter(|row| row.present_on_disk && Path::new(&row.file_path).exists())
101        .collect())
102}
103
104fn build_indexed_markdown(row: &IndexedSession) -> Result<String> {
105    if row.provider == "claude" {
106        return build_markdown(&row.project_name, Path::new(&row.file_path));
107    }
108
109    let mut buf = String::new();
110    let sid = row
111        .session_id
112        .as_deref()
113        .or_else(|| {
114            Path::new(&row.file_path)
115                .file_stem()
116                .and_then(|s| s.to_str())
117        })
118        .unwrap_or("unknown");
119    buf.push_str(&format!(
120        "# Session: {}\n\n",
121        sid.chars().take(8).collect::<String>()
122    ));
123    buf.push_str(&format!("**Provider:** {}\n", row.provider));
124    buf.push_str(&format!("**Project:** {}\n", row.project_name));
125    buf.push_str(&format!("**File:** {}\n", row.file_path));
126    if let Some(dt) = row
127        .first_timestamp_ms
128        .and_then(DateTime::from_timestamp_millis)
129    {
130        buf.push_str(&format!("**Date:** {}\n", dt.format("%Y-%m-%d %H:%M UTC")));
131    }
132    if let Some(model) = &row.model {
133        buf.push_str(&format!(
134            "**Model:** {}\n",
135            model.trim_start_matches("claude-")
136        ));
137    }
138    if let Some(extras) = &row.extras {
139        buf.push_str(&format!("**Metadata:** `{}`\n", extras));
140    }
141    buf.push_str("\n---\n\n");
142
143    stream_records(Path::new(&row.file_path), |record| {
144        if let Some((role, text)) = generic_message_text(&row.provider, record) {
145            let ts = record["timestamp"]
146                .as_str()
147                .map(|s| &s[..19.min(s.len())])
148                .unwrap_or("");
149            buf.push_str(&format!("## {}\n", title_case(&role)));
150            if !ts.is_empty() {
151                buf.push_str(&format!("*{}*\n\n", ts));
152            }
153            buf.push_str(&text);
154            buf.push_str("\n\n---\n\n");
155        }
156        true
157    })?;
158    Ok(buf)
159}
160
161fn build_indexed_json_value(row: &IndexedSession) -> Result<Value> {
162    let mut records = Vec::new();
163    let mut messages = Vec::new();
164    stream_records(Path::new(&row.file_path), |record| {
165        if let Some((role, text)) = generic_message_text(&row.provider, record) {
166            messages.push(serde_json::json!({
167                "role": role,
168                "timestamp": record["timestamp"].as_str(),
169                "text": text,
170            }));
171        }
172        records.push(record.clone());
173        true
174    })?;
175    Ok(serde_json::json!({
176        "provider": row.provider,
177        "project": row.project_name,
178        "session_id": row.session_id,
179        "file_path": row.file_path,
180        "date": row.first_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
181        "last_activity": row.last_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
182        "model": row.model,
183        "message_count": row.message_count,
184        "duration_ms": row.duration_ms,
185        "present_on_disk": row.present_on_disk,
186        "archived_at": row.archived_at.and_then(|s| DateTime::from_timestamp(s, 0)).map(|d| d.to_rfc3339()),
187        "extras": row.extras.as_deref().and_then(|s| serde_json::from_str::<Value>(s).ok()),
188        "messages": records.clone(),
189        "normalized_messages": messages,
190        "records": records,
191    }))
192}
193
194fn generic_message_text(provider: &str, record: &Value) -> Option<(String, String)> {
195    match provider {
196        "claude" => match record["type"].as_str()? {
197            "user" => {
198                text_from_value(&record["message"]["content"]).map(|t| ("user".to_string(), t))
199            }
200            "assistant" => {
201                text_from_value(&record["message"]["content"]).map(|t| ("assistant".to_string(), t))
202            }
203            _ => None,
204        },
205        "codex" => {
206            let payload = if matches!(record["type"].as_str(), Some("response_item" | "event_msg"))
207            {
208                &record["payload"]
209            } else {
210                record
211            };
212            match payload["type"].as_str()? {
213                "message" => {
214                    let role = payload["role"].as_str()?.to_string();
215                    text_from_value(&payload["content"])
216                        .or_else(|| payload["message"].as_str().map(str::to_string))
217                        .map(|t| (role, t))
218                }
219                "user_message" => payload["message"]
220                    .as_str()
221                    .map(|t| ("user".to_string(), t.to_string())),
222                "agent_message" => payload["message"]
223                    .as_str()
224                    .map(|t| ("assistant".to_string(), t.to_string())),
225                _ => None,
226            }
227        }
228        "pi" | "openclaw" => {
229            let msg = if record["type"].as_str() == Some("message") {
230                &record["message"]
231            } else {
232                record
233            };
234            let role = msg["role"].as_str().or_else(|| record["role"].as_str())?;
235            if role == "user" || role == "assistant" {
236                text_from_value(&msg["content"])
237                    .or_else(|| text_from_value(&record["content"]))
238                    .map(|t| (role.to_string(), t))
239            } else {
240                None
241            }
242        }
243        _ => None,
244    }
245}
246
247fn text_from_value(value: &Value) -> Option<String> {
248    if let Some(s) = value.as_str().filter(|s| !s.is_empty()) {
249        return Some(s.to_string());
250    }
251    let parts: Vec<String> = value
252        .as_array()?
253        .iter()
254        .filter_map(|b| {
255            b["text"]
256                .as_str()
257                .or_else(|| b["content"].as_str())
258                .filter(|s| !s.is_empty())
259                .map(str::to_string)
260        })
261        .collect();
262    (!parts.is_empty()).then(|| parts.join("\n"))
263}
264
265fn title_case(role: &str) -> String {
266    let mut chars = role.chars();
267    match chars.next() {
268        Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
269        None => "Message".to_string(),
270    }
271}
272
273fn build_markdown(project: &str, path: &Path) -> Result<String> {
274    let stats = parse_session(path)?;
275    let mut buf = String::new();
276
277    let sid: String = stats
278        .session_id
279        .as_deref()
280        .unwrap_or_else(|| {
281            path.file_stem()
282                .and_then(|s| s.to_str())
283                .unwrap_or("unknown")
284        })
285        .chars()
286        .take(8)
287        .collect();
288
289    buf.push_str(&format!("# Session: {}\n\n", sid));
290    buf.push_str(&format!("**Project:** {}\n", project));
291    if let Some(dt) = stats.first_timestamp {
292        buf.push_str(&format!("**Date:** {}\n", dt.format("%Y-%m-%d %H:%M UTC")));
293    }
294    if let Some(m) = &stats.model {
295        buf.push_str(&format!("**Model:** {}\n", m.trim_start_matches("claude-")));
296    }
297    buf.push('\n');
298    buf.push_str("---\n\n");
299
300    stream_records(path, |record| {
301        let ts = record["timestamp"]
302            .as_str()
303            .map(|s| &s[..19.min(s.len())])
304            .unwrap_or("");
305
306        match record["type"].as_str().unwrap_or("") {
307            "user" => {
308                buf.push_str("## User\n");
309                if !ts.is_empty() {
310                    buf.push_str(&format!("*{}*\n\n", ts));
311                }
312                push_user_content(&mut buf, &record["message"]["content"]);
313                buf.push_str("\n---\n\n");
314            }
315            "assistant" => {
316                buf.push_str("## Assistant\n");
317                if !ts.is_empty() {
318                    buf.push_str(&format!("*{}*\n\n", ts));
319                }
320                push_assistant_content(&mut buf, &record["message"]["content"]);
321                buf.push_str("\n---\n\n");
322            }
323            _ => {}
324        }
325        true
326    })?;
327
328    Ok(buf)
329}
330
331fn push_user_content(buf: &mut String, content: &Value) {
332    if let Some(text) = content.as_str() {
333        buf.push_str(text);
334        buf.push('\n');
335    } else if let Some(arr) = content.as_array() {
336        for block in arr {
337            match block["type"].as_str().unwrap_or("") {
338                "text" => {
339                    if let Some(text) = block["text"].as_str() {
340                        buf.push_str(text);
341                        buf.push('\n');
342                    }
343                }
344                "tool_result" => {
345                    let id = block["tool_use_id"].as_str().unwrap_or("");
346                    buf.push_str(&format!("\n**Tool result** ({})\n", id));
347                    match &block["content"] {
348                        Value::Array(arr) => {
349                            for c in arr {
350                                if let Some(text) = c["text"].as_str() {
351                                    buf.push_str("```\n");
352                                    buf.push_str(text);
353                                    buf.push_str("\n```\n");
354                                }
355                            }
356                        }
357                        Value::String(s) => {
358                            buf.push_str("```\n");
359                            buf.push_str(s);
360                            buf.push_str("\n```\n");
361                        }
362                        _ => {}
363                    }
364                }
365                _ => {}
366            }
367        }
368    }
369}
370
371fn push_assistant_content(buf: &mut String, content: &Value) {
372    if let Some(arr) = content.as_array() {
373        for block in arr {
374            match block["type"].as_str().unwrap_or("") {
375                "text" => {
376                    if let Some(text) = block["text"].as_str() {
377                        buf.push_str(text);
378                        buf.push('\n');
379                    }
380                }
381                "tool_use" => {
382                    let name = block["name"].as_str().unwrap_or("unknown");
383                    buf.push_str(&format!("\n**Tool: {}**\n", name));
384                    if !block["input"].is_null() {
385                        buf.push_str("```json\n");
386                        if let Ok(json) = serde_json::to_string_pretty(&block["input"]) {
387                            buf.push_str(&json);
388                            buf.push('\n');
389                        }
390                        buf.push_str("```\n");
391                    }
392                }
393                _ => {}
394            }
395        }
396    }
397}
398
399fn build_json_value(project: &str, path: &Path) -> Result<Value> {
400    let stats = parse_session(path)?;
401    let mut messages: Vec<Value> = Vec::new();
402
403    stream_records(path, |record| {
404        if matches!(record["type"].as_str(), Some("user") | Some("assistant")) {
405            messages.push(record.clone());
406        }
407        true
408    })?;
409
410    Ok(serde_json::json!({
411        "project": project,
412        "session_id": stats.session_id,
413        "date": stats.first_timestamp.map(|d| d.to_rfc3339()),
414        "model": stats.model,
415        "message_count": stats.message_count,
416        "messages": messages,
417    }))
418}