1use crate::cli::cost::format_cost;
2use crate::cli::init_store;
3use crate::cli::ndjson;
4use crate::cli::task_ref;
5use crate::store::Task;
6use crate::store::{TaskStatus, TaskStore};
7use anyhow::Context;
8use serde::Serialize;
9use std::collections::BTreeMap;
10use std::collections::BTreeSet;
11use std::collections::HashSet;
12use std::fs;
13use std::str::FromStr;
14use std::sync::Arc;
15
16pub async fn export(
17 task_id: &str,
18 attempt: Option<u32>,
19 format: ExportFormat,
20) -> anyhow::Result<()> {
21 let store = Arc::new(init_store().await?);
22
23 let resolved = task_ref::resolve(&store, task_id).await?;
24 let internal_id = resolved
25 .store_id
26 .ok_or_else(|| anyhow::anyhow!("task not found in store: {task_id}"))?;
27 let repo = resolved.repo.clone();
28 let task = store.get(internal_id).await.context("task not found")?;
29
30 let attempt = if let Some(n) = attempt {
31 n
32 } else {
33 let runs = store.get_runs(internal_id).await?;
34 if runs.is_empty() {
35 anyhow::bail!("no attempts found for task");
36 }
37 let max_attempt = runs.iter().map(|r| r.attempt).max().unwrap_or(1);
38 u32::try_from(max_attempt).unwrap_or(1)
39 };
40
41 let output_path = task_ref::state_dir(&resolved)?
42 .join("attempts")
43 .join(attempt.to_string())
44 .join("output.json");
45
46 if !output_path.exists() {
47 anyhow::bail!(
48 "attempt {attempt} not found (no output file at {})",
49 output_path.display()
50 );
51 }
52
53 let content = fs::read_to_string(&output_path)?;
54
55 match format {
56 ExportFormat::Markdown => export_markdown(&task, attempt, &content, &store, &repo).await,
57 ExportFormat::Json => export_json(&task, attempt, &content),
58 ExportFormat::Raw => {
59 println!("{content}");
60 Ok(())
61 }
62 }
63}
64
65struct ParsedOutput {
67 files_changed: Vec<String>,
69 files_read: Vec<String>,
71 tool_counts: BTreeMap<String, usize>,
73 git_commands: Vec<String>,
75 key_messages: Vec<String>,
77}
78
79impl ParsedOutput {
80 fn is_empty(&self) -> bool {
81 self.files_changed.is_empty()
82 && self.files_read.is_empty()
83 && self.tool_counts.is_empty()
84 && self.git_commands.is_empty()
85 && self.key_messages.is_empty()
86 }
87}
88
89fn parse_output(content: &str) -> ParsedOutput {
96 let mut files_changed: BTreeSet<String> = BTreeSet::new();
97 let mut files_read: BTreeSet<String> = BTreeSet::new();
98 let mut tool_counts: BTreeMap<String, usize> = BTreeMap::default();
99 let mut git_commands: Vec<String> = Vec::new();
100 let mut key_messages: Vec<String> = Vec::new();
101
102 for line in content.lines() {
103 let trimmed = line.trim();
104 if trimmed.is_empty() {
105 continue;
106 }
107 let v: serde_json::Value = match serde_json::from_str(trimmed) {
108 Ok(v) => v,
109 Err(_) => continue,
110 };
111
112 let event_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
113
114 match event_type {
115 "assistant" => {
117 if let Some(text) = extract_claude_text(&v)
118 && text.len() > 40
119 && key_messages.len() < 10
120 {
121 key_messages.push(first_sentence(&text, 160));
122 }
123 }
124
125 "tool_use" if v.get("tool").is_some() => {
126 if let Some(tool) = v.get("tool") {
128 let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("?");
129 *tool_counts.entry(name.to_string()).or_default() += 1;
130 let input = tool.get("input");
131 record_file_or_command(
132 name,
133 input,
134 &mut files_changed,
135 &mut files_read,
136 &mut git_commands,
137 );
138 }
139 }
140
141 "text" => {
143 let text = v
144 .get("part")
145 .and_then(|p| p.get("text"))
146 .and_then(|t| t.as_str())
147 .or_else(|| v.get("text").and_then(|t| t.as_str()))
148 .unwrap_or("");
149 if text.len() > 40 && key_messages.len() < 10 {
150 key_messages.push(first_sentence(text, 160));
151 }
152 }
153
154 "tool_use" if v.get("part").is_some() => {
155 if let Some(part) = v.get("part") {
157 let name = part.get("tool").and_then(|t| t.as_str()).unwrap_or("?");
158 *tool_counts.entry(name.to_string()).or_default() += 1;
159 let input = part.get("state").and_then(|s| s.get("input"));
160 record_file_or_command(
161 name,
162 input,
163 &mut files_changed,
164 &mut files_read,
165 &mut git_commands,
166 );
167 }
168 }
169
170 "item.completed" => {
172 if let Some(item) = v.get("item") {
173 let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
174 match item_type {
175 "agent_message" => {
176 let text = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
177 if text.len() > 40 && key_messages.len() < 10 {
178 key_messages.push(first_sentence(text, 160));
179 }
180 }
181 "command_execution" => {
182 let cmd = item.get("command").and_then(|c| c.as_str()).unwrap_or("");
183 *tool_counts.entry("bash".to_string()).or_default() += 1;
184 let first_line = cmd.lines().next().unwrap_or("").trim();
185 if (first_line.starts_with("git ") || first_line == "git")
186 && git_commands.len() < 20
187 {
188 git_commands.push(ndjson::truncate(first_line, 120));
189 }
190 }
191 _ => {}
192 }
193 }
194 }
195
196 _ => {}
197 }
198 }
199
200 ParsedOutput {
201 files_changed: files_changed
202 .into_iter()
203 .chain(ndjson::extract_files_changed(content))
204 .collect::<BTreeSet<_>>()
205 .into_iter()
206 .collect(),
207 files_read: files_read.into_iter().collect(),
208 tool_counts,
209 git_commands,
210 key_messages,
211 }
212}
213
214fn record_file_or_command(
216 name: &str,
217 input: Option<&serde_json::Value>,
218 files_changed: &mut BTreeSet<String>,
219 files_read: &mut BTreeSet<String>,
220 git_commands: &mut Vec<String>,
221) {
222 let Some(input) = input else { return };
223 match name {
224 "Write" | "write" | "NotebookEdit" | "Edit" | "edit" | "MultiEdit" => {
225 if let Some(p) = str_field(input, "file_path") {
226 files_changed.insert(p);
227 }
228 }
229 "Read" | "read" => {
230 if let Some(p) = str_field(input, "file_path") {
231 files_read.insert(p);
232 }
233 }
234 "Bash" | "bash" => {
235 if let Some(cmd) = str_field(input, "command") {
236 let first_line = cmd.lines().next().unwrap_or("").trim();
237 if (first_line.starts_with("git ") || first_line == "git")
238 && git_commands.len() < 20
239 {
240 git_commands.push(ndjson::truncate(first_line, 120));
241 }
242 }
243 }
244 _ => {}
245 }
246}
247
248async fn export_markdown(
249 task: &Task,
250 attempt: u32,
251 content: &str,
252 store: &Arc<TaskStore>,
253 _repo: &str,
254) -> anyhow::Result<()> {
255 let runs = store.get_runs(task.id).await?;
256 let attempt_i32 = i32::try_from(attempt).ok();
257 let attempt_run = runs.iter().find(|r| Some(r.attempt) == attempt_i32);
258
259 let agent = task.agent.as_deref().unwrap_or("unknown");
260 let model = task.model.as_deref().unwrap_or("-");
261
262 let duration = attempt_run.map_or(0.0, |r| r.duration_secs);
263 let duration_str = format_duration(duration);
264
265 let input_tokens = task.input_tokens;
266 let output_tokens = task.output_tokens;
267
268 let cost = task.total_cost_usd;
269
270 println!(
271 "# Session: {} (Attempt {})",
272 task_id_display(task.id, task.external_id.as_ref()),
273 attempt
274 );
275 println!();
276 println!("**Agent**: {agent} ({model})");
277 println!("**Duration**: {duration_str}");
278 println!(
279 "**Tokens**: {} in / {} out",
280 format_num(input_tokens),
281 format_num(output_tokens)
282 );
283 if input_tokens > 0 || output_tokens > 0 || cost > 0.0 {
284 println!("**Cost**: {}", format_cost(cost));
285 }
286 println!("---");
287
288 if content.trim().is_empty() {
289 println!("(no output captured)");
290 return Ok(());
291 }
292
293 let parsed = parse_output(content);
295 if !parsed.is_empty() {
296 println!();
297 println!("## Summary");
298
299 if !parsed.files_changed.is_empty() {
300 println!();
301 println!("### Files Changed");
302 for f in &parsed.files_changed {
303 println!("- `{f}`");
304 }
305 }
306
307 if !parsed.tool_counts.is_empty() {
308 println!();
309 println!("### Tool Usage");
310 let mut counts: Vec<(&String, &usize)> = parsed.tool_counts.iter().collect();
312 counts.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
313 for (tool, count) in &counts {
314 println!("- **{tool}**: {count}");
315 }
316 }
317
318 if !parsed.git_commands.is_empty() {
319 println!();
320 println!("### Git Context");
321 let mut seen = HashSet::new();
323 for cmd in &parsed.git_commands {
324 if seen.insert(cmd.as_str()) {
325 println!("- `{cmd}`");
326 }
327 }
328 }
329
330 if !parsed.key_messages.is_empty() {
331 println!();
332 println!("### Key Decisions");
333 for msg in &parsed.key_messages {
334 println!("- {msg}");
335 }
336 }
337 }
338
339 println!();
341 println!("## Output");
342 println!();
343
344 let mut formatter = ndjson::Formatter::new(false);
345 for line in content.lines() {
346 ndjson::print(&formatter.format(line));
347 }
348
349 let summary = &task.summary;
351 if !summary.is_empty() {
352 println!();
353 println!("---");
354 println!();
355 println!("## Result");
356 println!();
357
358 let status = if task.status == TaskStatus::Done {
359 "success"
360 } else if task.status == TaskStatus::Blocked {
361 "blocked"
362 } else {
363 "incomplete"
364 };
365
366 println!("**Status**: {status}");
367 println!("**Summary**: {summary}");
368 }
369
370 let error = &task.last_error;
371 if !error.is_empty() {
372 println!();
373 println!("**Error**: {error}");
374 }
375
376 Ok(())
377}
378
379fn export_json(task: &Task, attempt: u32, content: &str) -> anyhow::Result<()> {
380 #[derive(Serialize)]
381 struct SessionExport {
382 task_id: i64,
383 external_id: Option<String>,
384 attempt: u32,
385 agent: Option<String>,
386 model: Option<String>,
387 input_tokens: i64,
388 output_tokens: i64,
389 total_cost_usd: f64,
390 summary: String,
391 last_error: String,
392 status: String,
393 files_changed: Vec<String>,
394 files_read: Vec<String>,
395 tool_counts: BTreeMap<String, usize>,
396 git_commands: Vec<String>,
397 key_messages: Vec<String>,
398 output: Vec<serde_json::Value>,
399 }
400
401 let output_lines: Vec<serde_json::Value> = content
402 .lines()
403 .filter_map(|line| {
404 let trimmed = line.trim();
405 if trimmed.is_empty() {
406 return None;
407 }
408 serde_json::from_str(trimmed).ok()
409 })
410 .collect();
411
412 let parsed = parse_output(content);
413
414 let export = SessionExport {
415 task_id: task.id,
416 external_id: task.external_id.clone(),
417 attempt,
418 agent: task.agent.clone(),
419 model: task.model.clone(),
420 input_tokens: task.input_tokens,
421 output_tokens: task.output_tokens,
422 total_cost_usd: task.total_cost_usd,
423 summary: task.summary.clone(),
424 last_error: task.last_error.clone(),
425 status: task.status.as_str().to_string(),
426 files_changed: parsed.files_changed,
427 files_read: parsed.files_read,
428 tool_counts: parsed.tool_counts,
429 git_commands: parsed.git_commands,
430 key_messages: parsed.key_messages,
431 output: output_lines,
432 };
433
434 println!("{}", serde_json::to_string_pretty(&export)?);
435
436 Ok(())
437}
438
439fn task_id_display(internal_id: i64, external_id: Option<&String>) -> String {
440 match external_id {
441 Some(ext) => format!("{ext}:{internal_id}"),
442 None => format!("internal:{internal_id}"),
443 }
444}
445
446fn format_num(n: i64) -> String {
447 let s = n.to_string();
448 let mut result = String::new();
449 for (i, c) in s.chars().rev().enumerate() {
450 if i > 0 && i % 3 == 0 {
451 result.push(',');
452 }
453 result.push(c);
454 }
455 result.chars().rev().collect()
456}
457
458fn format_duration(secs: f64) -> String {
459 if secs < 60.0 {
460 format!("{secs:.0}s")
461 } else if secs < 3600.0 {
462 let mins = secs / 60.0;
463 if mins < 10.0 {
464 format!("{mins:.1}m")
465 } else {
466 format!("{mins:.0}m")
467 }
468 } else {
469 let hours = secs / 3600.0;
470 format!("{hours:.1}h")
471 }
472}
473
474fn extract_claude_text(v: &serde_json::Value) -> Option<String> {
476 let content = v.get("message")?.get("content")?.as_array()?;
477 let text: String = content
478 .iter()
479 .filter_map(|item| {
480 if item.get("type").and_then(|t| t.as_str()) == Some("text") {
481 item.get("text")
482 .and_then(|t| t.as_str())
483 .map(str::to_string)
484 } else {
485 None
486 }
487 })
488 .collect::<String>();
489 if text.is_empty() { None } else { Some(text) }
490}
491
492fn str_field(v: &serde_json::Value, key: &str) -> Option<String> {
494 v.get(key).and_then(|s| s.as_str()).map(str::to_string)
495}
496
497fn first_sentence(s: &str, max: usize) -> String {
499 let sentence_end = s
501 .char_indices()
502 .find(|(_, c)| matches!(c, '.' | '!' | '?'))
503 .map_or(s.len(), |(i, _)| i + 1);
504 let candidate = &s[..sentence_end.min(s.len())];
505 ndjson::truncate(candidate.trim(), max)
506}
507
508#[derive(Debug, Clone, Copy, Default)]
509pub enum ExportFormat {
510 #[default]
511 Markdown,
512 Json,
513 Raw,
514}
515
516impl FromStr for ExportFormat {
517 type Err = String;
518
519 fn from_str(s: &str) -> Result<Self, Self::Err> {
520 match s.to_lowercase().as_str() {
521 "markdown" | "md" => Ok(Self::Markdown),
522 "json" | "js" => Ok(Self::Json),
523 "raw" | "ndjson" => Ok(Self::Raw),
524 _ => Err(format!("unknown format: {s}")),
525 }
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn parse_claude_write_edit() {
535 let content = r#"
536{"type":"tool_use","tool":{"name":"Write","input":{"file_path":"src/lib.rs","content":"..."}}}
537{"type":"tool_use","tool":{"name":"Edit","input":{"file_path":"src/main.rs","old_string":"foo","new_string":"bar"}}}
538{"type":"tool_use","tool":{"name":"Read","input":{"file_path":"Cargo.toml"}}}
539"#;
540 let p = parse_output(content);
541 assert_eq!(p.files_changed, vec!["src/lib.rs", "src/main.rs"]);
542 assert_eq!(p.files_read, vec!["Cargo.toml"]);
543 assert_eq!(p.tool_counts["Write"], 1);
544 assert_eq!(p.tool_counts["Edit"], 1);
545 assert_eq!(p.tool_counts["Read"], 1);
546 }
547
548 #[test]
549 fn parse_claude_git_bash() {
550 let content = r#"
551{"type":"tool_use","tool":{"name":"Bash","input":{"command":"git status"}}}
552{"type":"tool_use","tool":{"name":"Bash","input":{"command":"cargo build"}}}
553{"type":"tool_use","tool":{"name":"Bash","input":{"command":"git commit -m \"fix\""}}}
554"#;
555 let p = parse_output(content);
556 assert_eq!(p.git_commands.len(), 2);
557 assert!(p.git_commands.contains(&"git status".to_string()));
558 assert!(
559 p.git_commands
560 .contains(&"git commit -m \"fix\"".to_string())
561 );
562 assert_eq!(p.tool_counts["Bash"], 3);
563 }
564
565 #[test]
566 fn parse_opencode_tool_use() {
567 let content = r#"
568{"type":"tool_use","part":{"tool":"write","state":{"input":{"file_path":"README.md"}}}}
569{"type":"tool_use","part":{"tool":"bash","state":{"input":{"command":"git diff"}}}}
570"#;
571 let p = parse_output(content);
572 assert_eq!(p.files_changed, vec!["README.md"]);
573 assert_eq!(p.git_commands, vec!["git diff"]);
574 }
575
576 #[test]
577 fn parse_codex_command_execution() {
578 let content = r#"
579{"type":"item.completed","item":{"type":"command_execution","command":"git log --oneline -5"}}
580{"type":"item.completed","item":{"type":"command_execution","command":"cargo test"}}
581"#;
582 let p = parse_output(content);
583 assert_eq!(p.git_commands, vec!["git log --oneline -5"]);
584 assert_eq!(p.tool_counts["bash"], 2);
585 }
586
587 #[test]
588 fn parse_key_messages_from_assistant() {
589 let content = r#"
590{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"This is a short msg."}]}}
591{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I have analyzed the codebase and found that the main issue is in the authentication module."}]}}
592"#;
593 let p = parse_output(content);
594 assert_eq!(p.key_messages.len(), 1);
596 assert!(p.key_messages[0].contains("authentication"));
597 }
598
599 #[test]
600 fn parse_empty_content() {
601 let p = parse_output("");
602 assert!(p.is_empty());
603 }
604
605 #[test]
606 fn first_sentence_with_period() {
607 let s = "I will fix the bug. Then run tests.";
608 assert_eq!(first_sentence(s, 200), "I will fix the bug.");
609 }
610
611 #[test]
612 fn first_sentence_truncated() {
613 let long = "abcdefghijklmnopqrstuvwxyz".repeat(10);
614 let result = first_sentence(&long, 20);
615 assert!(result.ends_with('…'));
616 }
617
618 #[test]
619 fn first_sentence_cuts_on_char_count_not_bytes() {
620 assert_eq!(first_sentence("ééééé", 3), "ééé…");
621 assert_eq!(first_sentence("ééé", 3), "ééé");
622 }
623
624 #[test]
625 fn first_sentence_without_terminator_keeps_whole_text() {
626 assert_eq!(first_sentence(" no terminator ", 50), "no terminator");
627 assert_eq!(first_sentence("", 5), "");
628 }
629
630 #[test]
631 fn format_num_thousands() {
632 assert_eq!(format_num(1_234_567), "1,234,567");
633 assert_eq!(format_num(999), "999");
634 }
635
636 #[test]
637 fn format_duration_variants() {
638 assert_eq!(format_duration(45.0), "45s");
639 assert_eq!(format_duration(90.0), "1.5m");
640 assert_eq!(format_duration(600.0), "10m");
641 assert_eq!(format_duration(7200.0), "2.0h");
642 }
643}