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