1use std::collections::HashMap;
8use std::fs::{self, File};
9use std::io::{BufRead, BufReader};
10use std::path::{Path, PathBuf};
11
12use anyhow::Result;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TranscriptMessage {
19 pub timestamp: DateTime<Utc>,
20 pub uuid: String,
21 pub parent_uuid: Option<String>,
22 pub session_id: String,
23 pub role: String, pub content: MessageContent,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(untagged)]
30pub enum MessageContent {
31 Text(String),
32 Structured(StructuredContent),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct StructuredContent {
38 pub role: Option<String>,
39 pub content: Option<serde_json::Value>,
40 pub model: Option<String>,
41 pub usage: Option<Usage>,
42 #[serde(rename = "stop_reason")]
43 pub stop_reason: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Usage {
49 pub input_tokens: Option<u64>,
50 pub output_tokens: Option<u64>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ToolCall {
56 pub id: String,
57 pub name: String,
58 pub input: serde_json::Value,
59 pub timestamp: DateTime<Utc>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ToolResult {
65 pub tool_use_id: String,
66 pub content: String,
67 pub is_error: bool,
68 pub timestamp: DateTime<Utc>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct Transcript {
74 pub session_id: String,
75 pub project_path: String,
76 pub started_at: Option<DateTime<Utc>>,
77 pub ended_at: Option<DateTime<Utc>>,
78 pub messages: Vec<TranscriptMessage>,
79 pub tool_calls: Vec<ToolCall>,
80 pub tool_results: Vec<ToolResult>,
81 pub total_input_tokens: u64,
82 pub total_output_tokens: u64,
83}
84
85impl Transcript {
86 pub fn new(session_id: &str, project_path: &str) -> Self {
88 Self {
89 session_id: session_id.to_string(),
90 project_path: project_path.to_string(),
91 started_at: None,
92 ended_at: None,
93 messages: Vec::new(),
94 tool_calls: Vec::new(),
95 tool_results: Vec::new(),
96 total_input_tokens: 0,
97 total_output_tokens: 0,
98 }
99 }
100
101 pub fn to_text(&self) -> String {
103 use std::fmt::Write;
104 let mut s = String::new();
105
106 writeln!(s, "# Transcript: {}", self.session_id).unwrap();
107 writeln!(s).unwrap();
108
109 if let Some(start) = self.started_at {
110 writeln!(s, "Started: {}", start.format("%Y-%m-%d %H:%M:%S")).unwrap();
111 }
112 if let Some(end) = self.ended_at {
113 writeln!(s, "Ended: {}", end.format("%Y-%m-%d %H:%M:%S")).unwrap();
114 }
115
116 writeln!(
117 s,
118 "Tokens: {} in / {} out",
119 self.total_input_tokens, self.total_output_tokens
120 )
121 .unwrap();
122 writeln!(s, "Tool calls: {}", self.tool_calls.len()).unwrap();
123 writeln!(s).unwrap();
124 writeln!(s, "---").unwrap();
125 writeln!(s).unwrap();
126
127 for msg in &self.messages {
128 let role_prefix = match msg.role.as_str() {
129 "user" => "## User",
130 "assistant" => "## Assistant",
131 _ => "## Unknown",
132 };
133
134 writeln!(s, "{} ({})", role_prefix, msg.timestamp.format("%H:%M:%S")).unwrap();
135 writeln!(s).unwrap();
136
137 match &msg.content {
138 MessageContent::Text(text) => {
139 writeln!(s, "{}", text).unwrap();
140 }
141 MessageContent::Structured(structured) => {
142 if let Some(content) = &structured.content {
143 format_content(&mut s, content);
144 }
145 }
146 }
147
148 writeln!(s).unwrap();
149 }
150
151 s
152 }
153}
154
155fn format_content(s: &mut String, content: &serde_json::Value) {
157 use std::fmt::Write;
158
159 match content {
160 serde_json::Value::Array(arr) => {
161 for item in arr {
162 if let Some(obj) = item.as_object() {
163 if let Some(type_val) = obj.get("type") {
164 match type_val.as_str() {
165 Some("text") => {
166 if let Some(text) = obj.get("text").and_then(|t| t.as_str()) {
167 writeln!(s, "{}", text).unwrap();
168 }
169 }
170 Some("tool_use") => {
171 let name = obj
172 .get("name")
173 .and_then(|n| n.as_str())
174 .unwrap_or("unknown");
175 writeln!(s, "**Tool: {}**", name).unwrap();
176 if let Some(input) = obj.get("input") {
177 if let Ok(json) = serde_json::to_string(input) {
179 let truncated = if json.len() > 200 {
180 format!("{}...", &json[..200])
181 } else {
182 json
183 };
184 writeln!(s, "```json\n{}\n```", truncated).unwrap();
185 }
186 }
187 }
188 Some("tool_result") => {
189 let tool_id = obj
190 .get("tool_use_id")
191 .and_then(|id| id.as_str())
192 .unwrap_or("unknown");
193 writeln!(s, "**Tool Result** ({})", tool_id).unwrap();
194 if let Some(content) = obj.get("content") {
195 let text = match content {
196 serde_json::Value::String(s) => s.clone(),
197 _ => serde_json::to_string(content).unwrap_or_default(),
198 };
199 let truncated = if text.len() > 500 {
200 format!("{}...", &text[..500])
201 } else {
202 text
203 };
204 writeln!(s, "```\n{}\n```", truncated).unwrap();
205 }
206 }
207 _ => {}
208 }
209 }
210 }
211 }
212 }
213 serde_json::Value::String(text) => {
214 writeln!(s, "{}", text).unwrap();
215 }
216 _ => {}
217 }
218}
219
220pub fn find_claude_project_dir(working_dir: &Path) -> Option<PathBuf> {
222 let home = dirs::home_dir()?;
223 let claude_dir = home.join(".claude").join("projects");
224
225 if !claude_dir.exists() {
226 return None;
227 }
228
229 let project_name = working_dir
232 .to_string_lossy()
233 .replace('/', "-")
234 .trim_start_matches('-')
235 .to_string();
236
237 let exact_path = claude_dir.join(&format!("-{}", project_name));
239 if exact_path.exists() {
240 return Some(exact_path);
241 }
242
243 let no_dash_path = claude_dir.join(&project_name);
245 if no_dash_path.exists() {
246 return Some(no_dash_path);
247 }
248
249 if let Ok(entries) = fs::read_dir(&claude_dir) {
251 for entry in entries.flatten() {
252 let name = entry.file_name().to_string_lossy().to_string();
253 let normalized_name = name.replace('-', "/");
255 if normalized_name.contains(&working_dir.to_string_lossy().to_string()) {
256 return Some(entry.path());
257 }
258 }
259 }
260
261 None
262}
263
264pub fn list_session_files(project_dir: &Path) -> Result<Vec<PathBuf>> {
266 let mut files = Vec::new();
267
268 if !project_dir.exists() {
269 return Ok(files);
270 }
271
272 for entry in fs::read_dir(project_dir)? {
273 let entry = entry?;
274 let path = entry.path();
275 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
276 files.push(path);
277 }
278 }
279
280 files.sort_by_key(|p| fs::metadata(p).and_then(|m| m.modified()).ok());
282
283 Ok(files)
284}
285
286pub fn parse_transcript(path: &Path) -> Result<Transcript> {
288 let file = File::open(path)?;
289 let reader = BufReader::new(file);
290
291 let session_id = path
292 .file_stem()
293 .and_then(|s| s.to_str())
294 .unwrap_or("unknown")
295 .to_string();
296
297 let project_path = path
298 .parent()
299 .and_then(|p| p.file_name())
300 .and_then(|s| s.to_str())
301 .unwrap_or("unknown")
302 .to_string();
303
304 let mut transcript = Transcript::new(&session_id, &project_path);
305
306 for line in reader.lines() {
307 let line = line?;
308 if line.trim().is_empty() {
309 continue;
310 }
311
312 if let Ok(entry) = serde_json::from_str::<serde_json::Value>(&line) {
313 let timestamp = entry
315 .get("timestamp")
316 .and_then(|t| t.as_str())
317 .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
318 .map(|dt| dt.with_timezone(&Utc))
319 .unwrap_or_else(Utc::now);
320
321 if transcript.started_at.is_none() || Some(timestamp) < transcript.started_at {
323 transcript.started_at = Some(timestamp);
324 }
325 if transcript.ended_at.is_none() || Some(timestamp) > transcript.ended_at {
326 transcript.ended_at = Some(timestamp);
327 }
328
329 let msg_type = entry
331 .get("type")
332 .and_then(|t| t.as_str())
333 .unwrap_or("unknown");
334
335 let uuid = entry
336 .get("uuid")
337 .and_then(|u| u.as_str())
338 .unwrap_or("")
339 .to_string();
340
341 let parent_uuid = entry
342 .get("parentUuid")
343 .and_then(|u| u.as_str())
344 .map(String::from);
345
346 let session_id = entry
347 .get("sessionId")
348 .and_then(|s| s.as_str())
349 .unwrap_or("")
350 .to_string();
351
352 if let Some(message) = entry.get("message") {
353 if msg_type == "assistant" {
355 if let Some(usage) = message.get("usage") {
356 if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) {
357 transcript.total_input_tokens += input;
358 }
359 if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) {
360 transcript.total_output_tokens += output;
361 }
362 }
363
364 if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
366 for item in content {
367 if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
368 let tool_call = ToolCall {
369 id: item
370 .get("id")
371 .and_then(|id| id.as_str())
372 .unwrap_or("")
373 .to_string(),
374 name: item
375 .get("name")
376 .and_then(|n| n.as_str())
377 .unwrap_or("")
378 .to_string(),
379 input: item.get("input").cloned().unwrap_or_default(),
380 timestamp,
381 };
382 transcript.tool_calls.push(tool_call);
383 }
384 }
385 }
386 }
387
388 if msg_type == "user" {
390 if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
391 for item in content {
392 if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
393 let tool_result = ToolResult {
394 tool_use_id: item
395 .get("tool_use_id")
396 .and_then(|id| id.as_str())
397 .unwrap_or("")
398 .to_string(),
399 content: item
400 .get("content")
401 .map(|c| match c {
402 serde_json::Value::String(s) => s.clone(),
403 _ => serde_json::to_string(c).unwrap_or_default(),
404 })
405 .unwrap_or_default(),
406 is_error: item
407 .get("is_error")
408 .and_then(|e| e.as_bool())
409 .unwrap_or(false),
410 timestamp,
411 };
412 transcript.tool_results.push(tool_result);
413 }
414 }
415 }
416 }
417
418 let content = if let Some(role) = message.get("role").and_then(|r| r.as_str()) {
420 MessageContent::Structured(StructuredContent {
422 role: Some(role.to_string()),
423 content: message.get("content").cloned(),
424 model: message.get("model").and_then(|m| m.as_str()).map(String::from),
425 usage: message.get("usage").and_then(|u| serde_json::from_value(u.clone()).ok()),
426 stop_reason: message
427 .get("stop_reason")
428 .and_then(|s| s.as_str())
429 .map(String::from),
430 })
431 } else if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
432 MessageContent::Text(text.to_string())
434 } else {
435 continue;
436 };
437
438 transcript.messages.push(TranscriptMessage {
439 timestamp,
440 uuid,
441 parent_uuid,
442 session_id,
443 role: msg_type.to_string(),
444 content,
445 });
446 }
447 }
448 }
449
450 Ok(transcript)
451}
452
453pub fn find_transcripts_in_range(
455 project_dir: &Path,
456 start: DateTime<Utc>,
457 end: DateTime<Utc>,
458) -> Result<Vec<PathBuf>> {
459 let all_files = list_session_files(project_dir)?;
460 let mut matching = Vec::new();
461
462 for file in all_files {
463 if let Ok(transcript) = parse_transcript(&file) {
465 if let (Some(t_start), Some(t_end)) = (transcript.started_at, transcript.ended_at) {
466 if t_start <= end && t_end >= start {
468 matching.push(file);
469 }
470 }
471 }
472 }
473
474 Ok(matching)
475}
476
477pub fn print_full_transcript(transcript: &Transcript) {
479 use colored::Colorize;
480
481 println!();
482 println!("{}", "Full Transcript".blue().bold());
483 println!("{}", "═".repeat(80).blue());
484 println!(
485 "Session: {} | Tokens: {} in / {} out",
486 transcript.session_id.cyan(),
487 transcript.total_input_tokens.to_string().dimmed(),
488 transcript.total_output_tokens.to_string().dimmed()
489 );
490 println!("{}", "═".repeat(80).blue());
491 println!();
492
493 for msg in &transcript.messages {
494 let role_display = match msg.role.as_str() {
495 "user" => "USER".green().bold(),
496 "assistant" => "ASSISTANT".blue().bold(),
497 _ => msg.role.yellow().bold(),
498 };
499
500 println!(
501 "[{}] {} ─────────────────────────────────",
502 msg.timestamp.format("%H:%M:%S"),
503 role_display
504 );
505 println!();
506
507 match &msg.content {
508 MessageContent::Text(text) => {
509 for line in text.lines() {
511 println!(" {}", line);
512 }
513 }
514 MessageContent::Structured(structured) => {
515 if let Some(content) = &structured.content {
516 print_structured_content(content, 2);
517 }
518 }
519 }
520
521 println!();
522 }
523}
524
525fn print_structured_content(content: &serde_json::Value, indent: usize) {
527 use colored::Colorize;
528 let pad = " ".repeat(indent);
529
530 match content {
531 serde_json::Value::Array(arr) => {
532 for item in arr {
533 if let Some(obj) = item.as_object() {
534 if let Some(type_val) = obj.get("type") {
535 match type_val.as_str() {
536 Some("text") => {
537 if let Some(text) = obj.get("text").and_then(|t| t.as_str()) {
538 for line in text.lines() {
539 println!("{}{}", pad, line);
540 }
541 }
542 }
543 Some("tool_use") => {
544 let name = obj
545 .get("name")
546 .and_then(|n| n.as_str())
547 .unwrap_or("unknown");
548 let id = obj
549 .get("id")
550 .and_then(|id| id.as_str())
551 .unwrap_or("");
552 println!();
553 println!(
554 "{}{}{}",
555 pad,
556 "▶ TOOL CALL: ".yellow().bold(),
557 name.cyan().bold()
558 );
559 println!("{} ID: {}", pad, id.dimmed());
560 if let Some(input) = obj.get("input") {
561 println!("{} Input:", pad);
562 if let Ok(json) = serde_json::to_string_pretty(input) {
563 let lines: Vec<&str> = json.lines().collect();
565 let max_lines = 20;
566 for (i, line) in lines.iter().take(max_lines).enumerate() {
567 println!("{} {}", pad, line.dimmed());
568 if i == max_lines - 1 && lines.len() > max_lines {
569 println!(
570 "{} {} more lines...",
571 pad,
572 (lines.len() - max_lines).to_string().yellow()
573 );
574 }
575 }
576 }
577 }
578 println!();
579 }
580 Some("tool_result") => {
581 let tool_id = obj
582 .get("tool_use_id")
583 .and_then(|id| id.as_str())
584 .unwrap_or("unknown");
585 let is_error = obj
586 .get("is_error")
587 .and_then(|e| e.as_bool())
588 .unwrap_or(false);
589 println!();
590 let header = if is_error {
591 "◀ TOOL ERROR".red().bold()
592 } else {
593 "◀ TOOL RESULT".green().bold()
594 };
595 println!("{}{} ({})", pad, header, tool_id.dimmed());
596 if let Some(content) = obj.get("content") {
597 let text = match content {
598 serde_json::Value::String(s) => s.clone(),
599 _ => serde_json::to_string_pretty(content)
600 .unwrap_or_default(),
601 };
602 let lines: Vec<&str> = text.lines().collect();
604 let max_lines = 30;
605 for (i, line) in lines.iter().take(max_lines).enumerate() {
606 let display = if line.len() > 120 {
607 format!("{}...", &line[..120])
608 } else {
609 line.to_string()
610 };
611 println!("{} {}", pad, display.dimmed());
612 if i == max_lines - 1 && lines.len() > max_lines {
613 println!(
614 "{} {} more lines...",
615 pad,
616 (lines.len() - max_lines).to_string().yellow()
617 );
618 }
619 }
620 }
621 println!();
622 }
623 _ => {}
624 }
625 }
626 }
627 }
628 }
629 serde_json::Value::String(text) => {
630 for line in text.lines() {
631 println!("{}{}", pad, line);
632 }
633 }
634 _ => {}
635 }
636}
637
638pub fn print_transcript_summary(transcript: &Transcript) {
640 use colored::Colorize;
641
642 println!();
643 println!("{}", "Transcript".blue().bold());
644 println!("{}", "═".repeat(60).blue());
645
646 println!(
647 " {} {}",
648 "Session:".dimmed(),
649 transcript.session_id.cyan()
650 );
651
652 if let (Some(start), Some(end)) = (transcript.started_at, transcript.ended_at) {
653 let duration = end.signed_duration_since(start);
654 println!(
655 " {} {}s",
656 "Duration:".dimmed(),
657 duration.num_seconds().to_string().cyan()
658 );
659 }
660
661 println!(
662 " {} {} in / {} out",
663 "Tokens:".dimmed(),
664 transcript.total_input_tokens.to_string().cyan(),
665 transcript.total_output_tokens.to_string().cyan()
666 );
667
668 println!(
669 " {} {}",
670 "Messages:".dimmed(),
671 transcript.messages.len().to_string().cyan()
672 );
673
674 println!(
675 " {} {}",
676 "Tool calls:".dimmed(),
677 transcript.tool_calls.len().to_string().cyan()
678 );
679
680 if !transcript.tool_calls.is_empty() {
682 let mut tool_counts: HashMap<&str, usize> = HashMap::new();
683 for call in &transcript.tool_calls {
684 *tool_counts.entry(&call.name).or_insert(0) += 1;
685 }
686
687 println!();
688 println!("{}", "Tool Usage".yellow().bold());
689 println!("{}", "─".repeat(40).yellow());
690
691 let mut sorted: Vec<_> = tool_counts.into_iter().collect();
692 sorted.sort_by(|a, b| b.1.cmp(&a.1));
693
694 for (tool, count) in sorted.iter().take(10) {
695 println!(" {:30} {}", tool.dimmed(), count.to_string().cyan());
696 }
697 }
698
699 println!();
700}
701
702pub fn list_transcripts(project_root: &Path) -> Result<()> {
704 use colored::Colorize;
705
706 let project_dir = find_claude_project_dir(project_root)
707 .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
708
709 let files = list_session_files(&project_dir)?;
710
711 if files.is_empty() {
712 println!("{}", "No transcripts found.".yellow());
713 return Ok(());
714 }
715
716 println!();
717 println!("{}", "Available Transcripts".blue().bold());
718 println!("{}", "═".repeat(60).blue());
719
720 for (i, file) in files.iter().rev().enumerate().take(20) {
721 let session_id = file
722 .file_stem()
723 .and_then(|s| s.to_str())
724 .unwrap_or("unknown");
725
726 if let Ok(metadata) = fs::metadata(file) {
728 let size_kb = metadata.len() / 1024;
729 let modified = metadata
730 .modified()
731 .ok()
732 .and_then(|t| chrono::DateTime::<Utc>::from(t).format("%Y-%m-%d %H:%M").to_string().into());
733
734 println!(
735 " {} {} ({} KB) - {}",
736 format!("[{}]", i + 1).dimmed(),
737 session_id.cyan(),
738 size_kb.to_string().dimmed(),
739 modified.unwrap_or_else(|| "unknown".to_string()).dimmed()
740 );
741 } else {
742 println!(" {} {}", format!("[{}]", i + 1).dimmed(), session_id.cyan());
743 }
744 }
745
746 if files.len() > 20 {
747 println!(" ... and {} more", (files.len() - 20).to_string().dimmed());
748 }
749
750 println!();
751 println!(
752 "Use {} to view a transcript",
753 "scud transcript --session <id>".cyan()
754 );
755 println!();
756
757 Ok(())
758}
759
760pub fn view_transcript(project_root: &Path, session: Option<&str>, full: bool) -> Result<()> {
762 use colored::Colorize;
763
764 let project_dir = find_claude_project_dir(project_root)
765 .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
766
767 let files = list_session_files(&project_dir)?;
768
769 if files.is_empty() {
770 anyhow::bail!("No transcripts found");
771 }
772
773 let transcript_path = if let Some(session_id) = session {
775 files
776 .iter()
777 .find(|f| {
778 f.file_stem()
779 .and_then(|s| s.to_str())
780 .map(|s| s == session_id || s.contains(session_id))
781 .unwrap_or(false)
782 })
783 .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?
784 .clone()
785 } else {
786 files.last().cloned().ok_or_else(|| anyhow::anyhow!("No transcripts found"))?
788 };
789
790 println!(
791 "{}",
792 format!("Loading transcript: {}", transcript_path.display()).dimmed()
793 );
794
795 let transcript = parse_transcript(&transcript_path)?;
796
797 if full {
798 print_full_transcript(&transcript);
799 } else {
800 print_transcript_summary(&transcript);
801 }
802
803 Ok(())
804}
805
806pub fn export_transcript_json(project_root: &Path, session: Option<&str>) -> Result<String> {
808 let project_dir = find_claude_project_dir(project_root)
809 .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
810
811 let files = list_session_files(&project_dir)?;
812
813 if files.is_empty() {
814 anyhow::bail!("No transcripts found");
815 }
816
817 let transcript_path = if let Some(session_id) = session {
818 files
819 .iter()
820 .find(|f| {
821 f.file_stem()
822 .and_then(|s| s.to_str())
823 .map(|s| s == session_id || s.contains(session_id))
824 .unwrap_or(false)
825 })
826 .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?
827 .clone()
828 } else {
829 files.last().cloned().ok_or_else(|| anyhow::anyhow!("No transcripts found"))?
830 };
831
832 let transcript = parse_transcript(&transcript_path)?;
833 serde_json::to_string_pretty(&transcript).map_err(|e| anyhow::anyhow!("JSON error: {}", e))
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839
840 #[test]
841 fn test_transcript_new() {
842 let transcript = Transcript::new("session-1", "project-1");
843 assert_eq!(transcript.session_id, "session-1");
844 assert!(transcript.messages.is_empty());
845 }
846
847 #[test]
848 fn test_format_content_text() {
849 let content = serde_json::json!([
850 {"type": "text", "text": "Hello world"}
851 ]);
852
853 let mut s = String::new();
854 format_content(&mut s, &content);
855 assert!(s.contains("Hello world"));
856 }
857
858 #[test]
859 fn test_format_content_tool_use() {
860 let content = serde_json::json!([
861 {
862 "type": "tool_use",
863 "name": "Read",
864 "input": {"file_path": "/test/file.txt"}
865 }
866 ]);
867
868 let mut s = String::new();
869 format_content(&mut s, &content);
870 assert!(s.contains("Tool: Read"));
871 assert!(s.contains("file_path"));
872 }
873}