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
425 .get("model")
426 .and_then(|m| m.as_str())
427 .map(String::from),
428 usage: message
429 .get("usage")
430 .and_then(|u| serde_json::from_value(u.clone()).ok()),
431 stop_reason: message
432 .get("stop_reason")
433 .and_then(|s| s.as_str())
434 .map(String::from),
435 })
436 } else if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
437 MessageContent::Text(text.to_string())
439 } else {
440 continue;
441 };
442
443 transcript.messages.push(TranscriptMessage {
444 timestamp,
445 uuid,
446 parent_uuid,
447 session_id,
448 role: msg_type.to_string(),
449 content,
450 });
451 }
452 }
453 }
454
455 Ok(transcript)
456}
457
458pub fn find_transcripts_in_range(
460 project_dir: &Path,
461 start: DateTime<Utc>,
462 end: DateTime<Utc>,
463) -> Result<Vec<PathBuf>> {
464 let all_files = list_session_files(project_dir)?;
465 let mut matching = Vec::new();
466
467 for file in all_files {
468 if let Ok(transcript) = parse_transcript(&file) {
470 if let (Some(t_start), Some(t_end)) = (transcript.started_at, transcript.ended_at) {
471 if t_start <= end && t_end >= start {
473 matching.push(file);
474 }
475 }
476 }
477 }
478
479 Ok(matching)
480}
481
482pub fn print_full_transcript(transcript: &Transcript) {
484 use colored::Colorize;
485
486 println!();
487 println!("{}", "Full Transcript".blue().bold());
488 println!("{}", "═".repeat(80).blue());
489 println!(
490 "Session: {} | Tokens: {} in / {} out",
491 transcript.session_id.cyan(),
492 transcript.total_input_tokens.to_string().dimmed(),
493 transcript.total_output_tokens.to_string().dimmed()
494 );
495 println!("{}", "═".repeat(80).blue());
496 println!();
497
498 for msg in &transcript.messages {
499 let role_display = match msg.role.as_str() {
500 "user" => "USER".green().bold(),
501 "assistant" => "ASSISTANT".blue().bold(),
502 _ => msg.role.yellow().bold(),
503 };
504
505 println!(
506 "[{}] {} ─────────────────────────────────",
507 msg.timestamp.format("%H:%M:%S"),
508 role_display
509 );
510 println!();
511
512 match &msg.content {
513 MessageContent::Text(text) => {
514 for line in text.lines() {
516 println!(" {}", line);
517 }
518 }
519 MessageContent::Structured(structured) => {
520 if let Some(content) = &structured.content {
521 print_structured_content(content, 2);
522 }
523 }
524 }
525
526 println!();
527 }
528}
529
530fn print_structured_content(content: &serde_json::Value, indent: usize) {
532 use colored::Colorize;
533 let pad = " ".repeat(indent);
534
535 match content {
536 serde_json::Value::Array(arr) => {
537 for item in arr {
538 if let Some(obj) = item.as_object() {
539 if let Some(type_val) = obj.get("type") {
540 match type_val.as_str() {
541 Some("text") => {
542 if let Some(text) = obj.get("text").and_then(|t| t.as_str()) {
543 for line in text.lines() {
544 println!("{}{}", pad, line);
545 }
546 }
547 }
548 Some("tool_use") => {
549 let name = obj
550 .get("name")
551 .and_then(|n| n.as_str())
552 .unwrap_or("unknown");
553 let id = obj.get("id").and_then(|id| id.as_str()).unwrap_or("");
554 println!();
555 println!(
556 "{}{}{}",
557 pad,
558 "▶ TOOL CALL: ".yellow().bold(),
559 name.cyan().bold()
560 );
561 println!("{} ID: {}", pad, id.dimmed());
562 if let Some(input) = obj.get("input") {
563 println!("{} Input:", pad);
564 if let Ok(json) = serde_json::to_string_pretty(input) {
565 let lines: Vec<&str> = json.lines().collect();
567 let max_lines = 20;
568 for (i, line) in lines.iter().take(max_lines).enumerate() {
569 println!("{} {}", pad, line.dimmed());
570 if i == max_lines - 1 && lines.len() > max_lines {
571 println!(
572 "{} {} more lines...",
573 pad,
574 (lines.len() - max_lines).to_string().yellow()
575 );
576 }
577 }
578 }
579 }
580 println!();
581 }
582 Some("tool_result") => {
583 let tool_id = obj
584 .get("tool_use_id")
585 .and_then(|id| id.as_str())
586 .unwrap_or("unknown");
587 let is_error = obj
588 .get("is_error")
589 .and_then(|e| e.as_bool())
590 .unwrap_or(false);
591 println!();
592 let header = if is_error {
593 "◀ TOOL ERROR".red().bold()
594 } else {
595 "◀ TOOL RESULT".green().bold()
596 };
597 println!("{}{} ({})", pad, header, tool_id.dimmed());
598 if let Some(content) = obj.get("content") {
599 let text = match content {
600 serde_json::Value::String(s) => s.clone(),
601 _ => serde_json::to_string_pretty(content)
602 .unwrap_or_default(),
603 };
604 let lines: Vec<&str> = text.lines().collect();
606 let max_lines = 30;
607 for (i, line) in lines.iter().take(max_lines).enumerate() {
608 let display = if line.len() > 120 {
609 format!("{}...", &line[..120])
610 } else {
611 line.to_string()
612 };
613 println!("{} {}", pad, display.dimmed());
614 if i == max_lines - 1 && lines.len() > max_lines {
615 println!(
616 "{} {} more lines...",
617 pad,
618 (lines.len() - max_lines).to_string().yellow()
619 );
620 }
621 }
622 }
623 println!();
624 }
625 _ => {}
626 }
627 }
628 }
629 }
630 }
631 serde_json::Value::String(text) => {
632 for line in text.lines() {
633 println!("{}{}", pad, line);
634 }
635 }
636 _ => {}
637 }
638}
639
640pub fn print_transcript_summary(transcript: &Transcript) {
642 use colored::Colorize;
643
644 println!();
645 println!("{}", "Transcript".blue().bold());
646 println!("{}", "═".repeat(60).blue());
647
648 println!(" {} {}", "Session:".dimmed(), transcript.session_id.cyan());
649
650 if let (Some(start), Some(end)) = (transcript.started_at, transcript.ended_at) {
651 let duration = end.signed_duration_since(start);
652 println!(
653 " {} {}s",
654 "Duration:".dimmed(),
655 duration.num_seconds().to_string().cyan()
656 );
657 }
658
659 println!(
660 " {} {} in / {} out",
661 "Tokens:".dimmed(),
662 transcript.total_input_tokens.to_string().cyan(),
663 transcript.total_output_tokens.to_string().cyan()
664 );
665
666 println!(
667 " {} {}",
668 "Messages:".dimmed(),
669 transcript.messages.len().to_string().cyan()
670 );
671
672 println!(
673 " {} {}",
674 "Tool calls:".dimmed(),
675 transcript.tool_calls.len().to_string().cyan()
676 );
677
678 if !transcript.tool_calls.is_empty() {
680 let mut tool_counts: HashMap<&str, usize> = HashMap::new();
681 for call in &transcript.tool_calls {
682 *tool_counts.entry(&call.name).or_insert(0) += 1;
683 }
684
685 println!();
686 println!("{}", "Tool Usage".yellow().bold());
687 println!("{}", "─".repeat(40).yellow());
688
689 let mut sorted: Vec<_> = tool_counts.into_iter().collect();
690 sorted.sort_by(|a, b| b.1.cmp(&a.1));
691
692 for (tool, count) in sorted.iter().take(10) {
693 println!(" {:30} {}", tool.dimmed(), count.to_string().cyan());
694 }
695 }
696
697 println!();
698}
699
700pub fn list_transcripts(project_root: &Path) -> Result<()> {
702 use colored::Colorize;
703
704 let project_dir = find_claude_project_dir(project_root)
705 .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
706
707 let files = list_session_files(&project_dir)?;
708
709 if files.is_empty() {
710 println!("{}", "No transcripts found.".yellow());
711 return Ok(());
712 }
713
714 println!();
715 println!("{}", "Available Transcripts".blue().bold());
716 println!("{}", "═".repeat(60).blue());
717
718 for (i, file) in files.iter().rev().enumerate().take(20) {
719 let session_id = file
720 .file_stem()
721 .and_then(|s| s.to_str())
722 .unwrap_or("unknown");
723
724 if let Ok(metadata) = fs::metadata(file) {
726 let size_kb = metadata.len() / 1024;
727 let modified = metadata.modified().ok().and_then(|t| {
728 chrono::DateTime::<Utc>::from(t)
729 .format("%Y-%m-%d %H:%M")
730 .to_string()
731 .into()
732 });
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!(
743 " {} {}",
744 format!("[{}]", i + 1).dimmed(),
745 session_id.cyan()
746 );
747 }
748 }
749
750 if files.len() > 20 {
751 println!(" ... and {} more", (files.len() - 20).to_string().dimmed());
752 }
753
754 println!();
755 println!(
756 "Use {} to view a transcript",
757 "scud transcript --session <id>".cyan()
758 );
759 println!();
760
761 Ok(())
762}
763
764pub fn view_transcript(project_root: &Path, session: Option<&str>, full: bool) -> Result<()> {
766 use colored::Colorize;
767
768 let project_dir = find_claude_project_dir(project_root)
769 .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
770
771 let files = list_session_files(&project_dir)?;
772
773 if files.is_empty() {
774 anyhow::bail!("No transcripts found");
775 }
776
777 let transcript_path = if let Some(session_id) = session {
779 files
780 .iter()
781 .find(|f| {
782 f.file_stem()
783 .and_then(|s| s.to_str())
784 .map(|s| s == session_id || s.contains(session_id))
785 .unwrap_or(false)
786 })
787 .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?
788 .clone()
789 } else {
790 files
792 .last()
793 .cloned()
794 .ok_or_else(|| anyhow::anyhow!("No transcripts found"))?
795 };
796
797 println!(
798 "{}",
799 format!("Loading transcript: {}", transcript_path.display()).dimmed()
800 );
801
802 let transcript = parse_transcript(&transcript_path)?;
803
804 if full {
805 print_full_transcript(&transcript);
806 } else {
807 print_transcript_summary(&transcript);
808 }
809
810 Ok(())
811}
812
813pub fn export_transcript_json(project_root: &Path, session: Option<&str>) -> Result<String> {
815 let project_dir = find_claude_project_dir(project_root)
816 .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
817
818 let files = list_session_files(&project_dir)?;
819
820 if files.is_empty() {
821 anyhow::bail!("No transcripts found");
822 }
823
824 let transcript_path = if let Some(session_id) = session {
825 files
826 .iter()
827 .find(|f| {
828 f.file_stem()
829 .and_then(|s| s.to_str())
830 .map(|s| s == session_id || s.contains(session_id))
831 .unwrap_or(false)
832 })
833 .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?
834 .clone()
835 } else {
836 files
837 .last()
838 .cloned()
839 .ok_or_else(|| anyhow::anyhow!("No transcripts found"))?
840 };
841
842 let transcript = parse_transcript(&transcript_path)?;
843 serde_json::to_string_pretty(&transcript).map_err(|e| anyhow::anyhow!("JSON error: {}", e))
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849
850 #[test]
851 fn test_transcript_new() {
852 let transcript = Transcript::new("session-1", "project-1");
853 assert_eq!(transcript.session_id, "session-1");
854 assert!(transcript.messages.is_empty());
855 }
856
857 #[test]
858 fn test_format_content_text() {
859 let content = serde_json::json!([
860 {"type": "text", "text": "Hello world"}
861 ]);
862
863 let mut s = String::new();
864 format_content(&mut s, &content);
865 assert!(s.contains("Hello world"));
866 }
867
868 #[test]
869 fn test_format_content_tool_use() {
870 let content = serde_json::json!([
871 {
872 "type": "tool_use",
873 "name": "Read",
874 "input": {"file_path": "/test/file.txt"}
875 }
876 ]);
877
878 let mut s = String::new();
879 format_content(&mut s, &content);
880 assert!(s.contains("Tool: Read"));
881 assert!(s.contains("file_path"));
882 }
883}