#!/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"