#!/bin/bash # Interactive Crush conversation exporter to Markdown # # REQUIRED SOFTWARE: # - bash (shell) # - sqlite3 (database queries) # - gum (interactive CLI tools) # - python3 (HTML escaping) # - jq (JSON parsing) # - base64 (encoding tool inputs) # - sed, grep, cut, tr, date (standard Unix utilities) set -e DB=".crush/crush.db" # HTML escape function using Python html_escape() { python3 -c 'import html,sys; print(html.escape(sys.stdin.read()), end="")' <<< "$1" } # Check if database exists if [ ! -f "$DB" ]; then echo "Error: Database not found at $DB" exit 1 fi # Check if gum is available if ! command -v gum >/dev/null 2>&1; then echo "Error: gum is not installed" exit 1 fi # Get sessions list with formatted display sessions=$(sqlite3 "$DB" " SELECT id, datetime(created_at, 'unixepoch'), printf('%3d', message_count), title FROM sessions ORDER BY created_at DESC ") if [ -z "$sessions" ]; then echo "No sessions found" exit 0 fi # Format sessions for gum selection and build lookup map formatted="" tmpfile=$(mktemp) while IFS='|' read -r id created_at msg_count title; do display_line="${created_at} │ ${msg_count} msgs │ ${title}" formatted="${formatted}${display_line} " # Store mapping of display line to ID printf '%s|%s\n' "$display_line" "$id" >> "$tmpfile" done << EOF $sessions EOF # Let user select a session selected=$(printf "%s" "$formatted" | gum filter --placeholder "Search for a conversation...") if [ -z "$selected" ]; then rm -f "$tmpfile" echo "No session selected" exit 0 fi # Extract session ID from lookup session_id=$(grep -F "$selected" "$tmpfile" | cut -d'|' -f2) rm -f "$tmpfile" # Get session details session_title=$(sqlite3 "$DB" "SELECT title FROM sessions WHERE id = '$session_id'") # Ask for output filename default_filename=$(echo "$session_title" | tr ' ' '-' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]//g').md output_file=$(gum input --placeholder "Output filename" --value "$default_filename") # Use default if user just pressed enter if [ -z "$output_file" ]; then output_file="$default_filename" fi # Export conversation to markdown echo "Exporting conversation: $session_title" echo "# $session_title" > "$output_file" echo "" >> "$output_file" # Temp file to store tool calls for lookup by tool_call_id tool_calls_file=$(mktemp) trap "rm -f $tool_calls_file" EXIT # Get all messages with full data in one query sqlite3 "$DB" " SELECT id, role, model, datetime(created_at, 'unixepoch'), parts FROM messages WHERE session_id = '$session_id' AND (is_summary_message = 0 OR is_summary_message IS NULL) ORDER BY created_at ASC " | while IFS='|' read -r msg_id role model created parts; do # Determine group role (user vs assistant/tool) if [ "$role" = "user" ]; then group_role="user" else group_role="assistant" fi # Check if we need a new section header (role changed) if [ "$group_role" != "$prev_group_role" ]; then # Close previous section if exists if [ -n "$prev_group_role" ]; then echo "" >> "$output_file" echo "---" >> "$output_file" echo "" >> "$output_file" fi # Start new section case "$group_role" in user) echo "## User" >> "$output_file" ;; assistant) if [ -n "$model" ]; then echo "## Crush ($model)" >> "$output_file" else echo "## Crush" >> "$output_file" fi ;; esac echo "" >> "$output_file" echo "_${created}_" >> "$output_file" echo "" >> "$output_file" prev_group_role="$group_role" first_msg_in_section=true fi # Skip timestamp for subsequent messages in same section if [ "$first_msg_in_section" = "true" ]; then first_msg_in_section=false fi # Process each part in the message last_tool_name="" echo "$parts" | jq -c '.[]' | while read -r part; do part_type=$(echo "$part" | jq -r '.type') # Skip finish parts if [ "$part_type" = "finish" ]; then continue fi # Extract and format based on part type case "$part_type" in text) text=$(echo "$part" | jq -r '.data.text') echo "$text" >> "$output_file" ;; reasoning) thinking=$(echo "$part" | jq -r '.data.thinking') if [ -n "$thinking" ] && [ "$thinking" != "null" ]; then echo "" >> "$output_file" echo "
" >> "$output_file" echo "💭 Thinking" >> "$output_file" echo "" >> "$output_file" echo "$thinking" >> "$output_file" echo "
" >> "$output_file" echo "" >> "$output_file" fi ;; tool_call) tool_name=$(echo "$part" | jq -r '.data.name') tool_input=$(echo "$part" | jq -r '.data.input') last_tool_name="$tool_name" # Format tool call header based on tool type case "$tool_name" in view) file_path=$(echo "$tool_input" | jq -r '.file_path // empty') if [ ${#file_path} -gt 60 ]; then display_path="${file_path:0:57}…" else display_path="$file_path" fi tool_summary="📄 view: $(html_escape "$display_path")" ;; ls) ls_path=$(echo "$tool_input" | jq -r '.path // "."') if [ ${#ls_path} -gt 60 ]; then display_path="${ls_path:0:57}…" else display_path="$ls_path" fi tool_summary="📂 ls: $(html_escape "$display_path")" ;; bash) description=$(echo "$tool_input" | jq -r '.description // empty') if [ ${#description} -gt 57 ]; then display_desc="${description:0:54}…" else display_desc="$description" fi tool_summary="🖥️ bash: $(html_escape "$display_desc")" ;; grep) pattern=$(echo "$tool_input" | jq -r '.pattern // empty') grep_path=$(echo "$tool_input" | jq -r '.path // "."') if [ ${#pattern} -gt 40 ]; then display_pattern="${pattern:0:37}…" else display_pattern="$pattern" fi tool_summary="🔎 grep: $(html_escape "$display_pattern") in $(html_escape "$grep_path")" ;; glob) pattern=$(echo "$tool_input" | jq -r '.pattern // empty') glob_path=$(echo "$tool_input" | jq -r '.path // "."') if [ ${#pattern} -gt 40 ]; then display_pattern="${pattern:0:37}…" else display_pattern="$pattern" fi tool_summary="🔎 glob: $(html_escape "$display_pattern") in $(html_escape "$glob_path")" ;; write|edit|multiedit) file_path=$(echo "$tool_input" | jq -r '.file_path // empty') if [ ${#file_path} -gt 60 ]; then display_path="${file_path:0:57}…" else display_path="$file_path" fi tool_summary="✏️ $tool_name: $(html_escape "$display_path")" ;; *) tool_summary="🔧 $(html_escape "$tool_name")" ;; esac # Store for lookup by tool_result (using tool_call_id) # Base64 encode tool_input to preserve newlines and special chars tool_call_id=$(echo "$part" | jq -r '.data.id') tool_input_b64=$(printf '%s' "$tool_input" | base64 -w0) printf '%s\x1f%s\x1f%s\x1f%s\n' "$tool_call_id" "$tool_summary" "$tool_input_b64" "$tool_name" >> "$tool_calls_file" ;; tool_result) tool_content=$(echo "$part" | jq -r '.data.content') is_error=$(echo "$part" | jq -r '.data.is_error') tool_call_id=$(echo "$part" | jq -r '.data.tool_call_id') # Look up tool call info by ID tool_info=$(grep "^${tool_call_id}" "$tool_calls_file" | head -1 || true) if [ -n "$tool_info" ]; then last_tool_summary=$(echo "$tool_info" | cut -d$'\x1f' -f2) last_tool_input_b64=$(echo "$tool_info" | cut -d$'\x1f' -f3) last_tool_input=$(printf '%s' "$last_tool_input_b64" | base64 -d) last_tool_name=$(echo "$tool_info" | cut -d$'\x1f' -f4) fi # Strip XML-style tags from tool output cleaned_content=$(echo "$tool_content" | sed -E '/<\/?result>/d; /<\/?file>/d; /<\/?output>/d') echo "" >> "$output_file" echo "
" >> "$output_file" echo "$last_tool_summary" >> "$output_file" echo "" >> "$output_file" echo '```json' >> "$output_file" echo "$last_tool_input" | jq . >> "$output_file" echo '```' >> "$output_file" echo "" >> "$output_file" if [ "$is_error" = "true" ]; then echo "**❌ Result**" >> "$output_file" else echo "**✅ Result**" >> "$output_file" fi echo "" >> "$output_file" # Special handling for edit tools - show before/after if [ "$last_tool_name" = "edit" ]; then old_str=$(echo "$last_tool_input" | jq -r '.old_string // empty') new_str=$(echo "$last_tool_input" | jq -r '.new_string // empty') echo "**Before:**" >> "$output_file" echo '```' >> "$output_file" echo "$old_str" >> "$output_file" echo '```' >> "$output_file" echo "" >> "$output_file" echo "**After:**" >> "$output_file" echo '```' >> "$output_file" echo "$new_str" >> "$output_file" echo '```' >> "$output_file" echo "" >> "$output_file" echo "**Result:** $(html_escape "$cleaned_content")" >> "$output_file" elif [ "$last_tool_name" = "multiedit" ]; then # Show each edit's before/after edits_count=$(echo "$last_tool_input" | jq '.edits | length') for ((i=0; i> "$output_file" echo '```' >> "$output_file" echo "$old_str" >> "$output_file" echo '```' >> "$output_file" echo "" >> "$output_file" echo "**Edit $((i+1)) - After:**" >> "$output_file" echo '```' >> "$output_file" echo "$new_str" >> "$output_file" echo '```' >> "$output_file" echo "" >> "$output_file" done echo "**Result:** $(html_escape "$cleaned_content")" >> "$output_file" else echo '```' >> "$output_file" echo "$cleaned_content" >> "$output_file" echo '```' >> "$output_file" fi echo "
" >> "$output_file" echo "" >> "$output_file" ;; esac done echo "" >> "$output_file" done # Add metadata footer echo "---" >> "$output_file" echo "" >> "$output_file" echo "## Metadata" >> "$output_file" echo "" >> "$output_file" # Get session statistics metadata=$(sqlite3 "$DB" " SELECT COUNT(*) as msg_count, datetime(MIN(created_at), 'unixepoch') as first_msg, datetime(MAX(created_at), 'unixepoch') as last_msg, MAX(created_at) - MIN(created_at) as duration_seconds FROM messages WHERE session_id = '$session_id' AND (is_summary_message = 0 OR is_summary_message IS NULL) ") msg_count=$(echo "$metadata" | cut -d'|' -f1) first_msg=$(echo "$metadata" | cut -d'|' -f2) last_msg=$(echo "$metadata" | cut -d'|' -f3) duration_seconds=$(echo "$metadata" | cut -d'|' -f4) # Calculate duration in human-readable format if [ "$duration_seconds" -ge 3600 ]; then hours=$((duration_seconds / 3600)) minutes=$(((duration_seconds % 3600) / 60)) duration="${hours}h ${minutes}m" elif [ "$duration_seconds" -ge 60 ]; then minutes=$((duration_seconds / 60)) seconds=$((duration_seconds % 60)) duration="${minutes}m ${seconds}s" else duration="${duration_seconds}s" fi echo "- **Messages:** $msg_count" >> "$output_file" echo "- **Duration:** $duration" >> "$output_file" echo "- **Started:** $first_msg" >> "$output_file" echo "- **Ended:** $last_msg" >> "$output_file" gum style --foreground 212 "✓ Exported to: $output_file"