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::{copilot_vscode, 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    // VS Code chat sessions are one (possibly delta-logged) JSON document, not
144    // message-per-line JSONL — replay and flatten instead of streaming.
145    if row.provider == "copilot-vscode" {
146        let session = copilot_vscode::load_session_value(Path::new(&row.file_path))?;
147        for msg in copilot_vscode::session_messages(&session) {
148            buf.push_str(&format!("## {}\n", title_case(msg.role)));
149            if let Some(dt) = msg.timestamp_ms.and_then(DateTime::from_timestamp_millis) {
150                buf.push_str(&format!("*{}*\n\n", dt.format("%Y-%m-%dT%H:%M:%S")));
151            }
152            buf.push_str(&msg.text);
153            buf.push_str("\n\n---\n\n");
154        }
155        return Ok(buf);
156    }
157
158    stream_records(Path::new(&row.file_path), |record| {
159        if let Some((role, text)) = generic_message_text(&row.provider, record) {
160            let ts = record["timestamp"]
161                .as_str()
162                .map(|s| &s[..19.min(s.len())])
163                .unwrap_or("");
164            buf.push_str(&format!("## {}\n", title_case(&role)));
165            if !ts.is_empty() {
166                buf.push_str(&format!("*{}*\n\n", ts));
167            }
168            buf.push_str(&text);
169            buf.push_str("\n\n---\n\n");
170        }
171        true
172    })?;
173    Ok(buf)
174}
175
176fn build_indexed_json_value(row: &IndexedSession) -> Result<Value> {
177    let mut records = Vec::new();
178    let mut messages = Vec::new();
179    if row.provider == "copilot-vscode" {
180        let session = copilot_vscode::load_session_value(Path::new(&row.file_path))?;
181        for msg in copilot_vscode::session_messages(&session) {
182            messages.push(serde_json::json!({
183                "role": msg.role,
184                "timestamp": msg
185                    .timestamp_ms
186                    .and_then(DateTime::from_timestamp_millis)
187                    .map(|d| d.to_rfc3339()),
188                "text": msg.text,
189            }));
190        }
191        records.push(session);
192    } else {
193        stream_records(Path::new(&row.file_path), |record| {
194            if let Some((role, text)) = generic_message_text(&row.provider, record) {
195                messages.push(serde_json::json!({
196                    "role": role,
197                    "timestamp": record["timestamp"].as_str(),
198                    "text": text,
199                }));
200            }
201            records.push(record.clone());
202            true
203        })?;
204    }
205    Ok(serde_json::json!({
206        "provider": row.provider,
207        "project": row.project_name,
208        "session_id": row.session_id,
209        "file_path": row.file_path,
210        "date": row.first_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
211        "last_activity": row.last_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
212        "model": row.model,
213        "message_count": row.message_count,
214        "duration_ms": row.duration_ms,
215        "present_on_disk": row.present_on_disk,
216        "archived_at": row.archived_at.and_then(|s| DateTime::from_timestamp(s, 0)).map(|d| d.to_rfc3339()),
217        "extras": row.extras.as_deref().and_then(|s| serde_json::from_str::<Value>(s).ok()),
218        "messages": records.clone(),
219        "normalized_messages": messages,
220        "records": records,
221    }))
222}
223
224fn generic_message_text(provider: &str, record: &Value) -> Option<(String, String)> {
225    match provider {
226        "claude" => match record["type"].as_str()? {
227            "user" => {
228                text_from_value(&record["message"]["content"]).map(|t| ("user".to_string(), t))
229            }
230            "assistant" => {
231                text_from_value(&record["message"]["content"]).map(|t| ("assistant".to_string(), t))
232            }
233            _ => None,
234        },
235        "codex" => {
236            let payload = if matches!(record["type"].as_str(), Some("response_item" | "event_msg"))
237            {
238                &record["payload"]
239            } else {
240                record
241            };
242            match payload["type"].as_str()? {
243                "message" => {
244                    let role = payload["role"].as_str()?.to_string();
245                    text_from_value(&payload["content"])
246                        .or_else(|| payload["message"].as_str().map(str::to_string))
247                        .map(|t| (role, t))
248                }
249                "user_message" => payload["message"]
250                    .as_str()
251                    .map(|t| ("user".to_string(), t.to_string())),
252                "agent_message" => payload["message"]
253                    .as_str()
254                    .map(|t| ("assistant".to_string(), t.to_string())),
255                _ => None,
256            }
257        }
258        "copilot" => {
259            let text = record["data"]["content"]
260                .as_str()
261                .filter(|t| !t.is_empty())?;
262            match record["type"].as_str()? {
263                "user.message" => Some(("user".to_string(), text.to_string())),
264                "assistant.message" => Some(("assistant".to_string(), text.to_string())),
265                _ => None,
266            }
267        }
268        "pi" | "openclaw" => {
269            let msg = if record["type"].as_str() == Some("message") {
270                &record["message"]
271            } else {
272                record
273            };
274            let role = msg["role"].as_str().or_else(|| record["role"].as_str())?;
275            if role == "user" || role == "assistant" {
276                text_from_value(&msg["content"])
277                    .or_else(|| text_from_value(&record["content"]))
278                    .map(|t| (role.to_string(), t))
279            } else {
280                None
281            }
282        }
283        _ => None,
284    }
285}
286
287fn text_from_value(value: &Value) -> Option<String> {
288    if let Some(s) = value.as_str().filter(|s| !s.is_empty()) {
289        return Some(s.to_string());
290    }
291    let parts: Vec<String> = value
292        .as_array()?
293        .iter()
294        .filter_map(|b| {
295            b["text"]
296                .as_str()
297                .or_else(|| b["content"].as_str())
298                .filter(|s| !s.is_empty())
299                .map(str::to_string)
300        })
301        .collect();
302    (!parts.is_empty()).then(|| parts.join("\n"))
303}
304
305fn title_case(role: &str) -> String {
306    let mut chars = role.chars();
307    match chars.next() {
308        Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
309        None => "Message".to_string(),
310    }
311}
312
313fn build_markdown(project: &str, path: &Path) -> Result<String> {
314    let stats = parse_session(path)?;
315    let mut buf = String::new();
316
317    let sid: String = stats
318        .session_id
319        .as_deref()
320        .unwrap_or_else(|| {
321            path.file_stem()
322                .and_then(|s| s.to_str())
323                .unwrap_or("unknown")
324        })
325        .chars()
326        .take(8)
327        .collect();
328
329    buf.push_str(&format!("# Session: {}\n\n", sid));
330    buf.push_str(&format!("**Project:** {}\n", project));
331    if let Some(dt) = stats.first_timestamp {
332        buf.push_str(&format!("**Date:** {}\n", dt.format("%Y-%m-%d %H:%M UTC")));
333    }
334    if let Some(m) = &stats.model {
335        buf.push_str(&format!("**Model:** {}\n", m.trim_start_matches("claude-")));
336    }
337    buf.push('\n');
338    buf.push_str("---\n\n");
339
340    stream_records(path, |record| {
341        let ts = record["timestamp"]
342            .as_str()
343            .map(|s| &s[..19.min(s.len())])
344            .unwrap_or("");
345
346        match record["type"].as_str().unwrap_or("") {
347            "user" => {
348                buf.push_str("## User\n");
349                if !ts.is_empty() {
350                    buf.push_str(&format!("*{}*\n\n", ts));
351                }
352                push_user_content(&mut buf, &record["message"]["content"]);
353                buf.push_str("\n---\n\n");
354            }
355            "assistant" => {
356                buf.push_str("## Assistant\n");
357                if !ts.is_empty() {
358                    buf.push_str(&format!("*{}*\n\n", ts));
359                }
360                push_assistant_content(&mut buf, &record["message"]["content"]);
361                buf.push_str("\n---\n\n");
362            }
363            _ => {}
364        }
365        true
366    })?;
367
368    Ok(buf)
369}
370
371fn push_user_content(buf: &mut String, content: &Value) {
372    if let Some(text) = content.as_str() {
373        buf.push_str(text);
374        buf.push('\n');
375    } else if let Some(arr) = content.as_array() {
376        for block in arr {
377            match block["type"].as_str().unwrap_or("") {
378                "text" => {
379                    if let Some(text) = block["text"].as_str() {
380                        buf.push_str(text);
381                        buf.push('\n');
382                    }
383                }
384                "tool_result" => {
385                    let id = block["tool_use_id"].as_str().unwrap_or("");
386                    buf.push_str(&format!("\n**Tool result** ({})\n", id));
387                    match &block["content"] {
388                        Value::Array(arr) => {
389                            for c in arr {
390                                if let Some(text) = c["text"].as_str() {
391                                    buf.push_str("```\n");
392                                    buf.push_str(text);
393                                    buf.push_str("\n```\n");
394                                }
395                            }
396                        }
397                        Value::String(s) => {
398                            buf.push_str("```\n");
399                            buf.push_str(s);
400                            buf.push_str("\n```\n");
401                        }
402                        _ => {}
403                    }
404                }
405                _ => {}
406            }
407        }
408    }
409}
410
411fn push_assistant_content(buf: &mut String, content: &Value) {
412    if let Some(arr) = content.as_array() {
413        for block in arr {
414            match block["type"].as_str().unwrap_or("") {
415                "text" => {
416                    if let Some(text) = block["text"].as_str() {
417                        buf.push_str(text);
418                        buf.push('\n');
419                    }
420                }
421                "tool_use" => {
422                    let name = block["name"].as_str().unwrap_or("unknown");
423                    buf.push_str(&format!("\n**Tool: {}**\n", name));
424                    if !block["input"].is_null() {
425                        buf.push_str("```json\n");
426                        if let Ok(json) = serde_json::to_string_pretty(&block["input"]) {
427                            buf.push_str(&json);
428                            buf.push('\n');
429                        }
430                        buf.push_str("```\n");
431                    }
432                }
433                _ => {}
434            }
435        }
436    }
437}
438
439fn build_json_value(project: &str, path: &Path) -> Result<Value> {
440    let stats = parse_session(path)?;
441    let mut messages: Vec<Value> = Vec::new();
442
443    stream_records(path, |record| {
444        if matches!(record["type"].as_str(), Some("user") | Some("assistant")) {
445            messages.push(record.clone());
446        }
447        true
448    })?;
449
450    Ok(serde_json::json!({
451        "project": project,
452        "session_id": stats.session_id,
453        "date": stats.first_timestamp.map(|d| d.to_rfc3339()),
454        "model": stats.model,
455        "message_count": stats.message_count,
456        "messages": messages,
457    }))
458}