1#!/bin/bash
2# Interactive Crush conversation exporter to Markdown
3#
4# REQUIRED SOFTWARE:
5# - bash (shell)
6# - sqlite3 (database queries)
7# - gum (interactive CLI tools)
8# - python3 (HTML escaping)
9# - jq (JSON parsing)
10# - base64 (encoding tool inputs)
11# - sed, grep, cut, tr, date (standard Unix utilities)
12
13set -e
14
15DB=".crush/crush.db"
16
17# HTML escape function using Python
18html_escape() {
19 python3 -c 'import html,sys; print(html.escape(sys.stdin.read()), end="")' <<< "$1"
20}
21
22# Check if database exists
23if [ ! -f "$DB" ]; then
24 echo "Error: Database not found at $DB"
25 exit 1
26fi
27
28# Check if gum is available
29if ! command -v gum >/dev/null 2>&1; then
30 echo "Error: gum is not installed"
31 exit 1
32fi
33
34# Get sessions list with formatted display
35sessions=$(sqlite3 "$DB" "
36SELECT
37 id,
38 datetime(created_at, 'unixepoch'),
39 printf('%3d', message_count),
40 title
41FROM sessions
42ORDER BY created_at DESC
43")
44
45if [ -z "$sessions" ]; then
46 echo "No sessions found"
47 exit 0
48fi
49
50# Format sessions for gum selection and build lookup map
51formatted=""
52tmpfile=$(mktemp)
53while IFS='|' read -r id created_at msg_count title; do
54 display_line="${created_at} │ ${msg_count} msgs │ ${title}"
55 formatted="${formatted}${display_line}
56"
57 # Store mapping of display line to ID
58 printf '%s|%s\n' "$display_line" "$id" >> "$tmpfile"
59done << EOF
60$sessions
61EOF
62
63# Let user select a session
64selected=$(printf "%s" "$formatted" | gum filter --placeholder "Search for a conversation...")
65
66if [ -z "$selected" ]; then
67 rm -f "$tmpfile"
68 echo "No session selected"
69 exit 0
70fi
71
72# Extract session ID from lookup
73session_id=$(grep -F "$selected" "$tmpfile" | cut -d'|' -f2)
74rm -f "$tmpfile"
75
76# Get session details
77session_title=$(sqlite3 "$DB" "SELECT title FROM sessions WHERE id = '$session_id'")
78
79# Ask for output filename
80default_filename=$(echo "$session_title" | tr ' ' '-' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]//g').md
81output_file=$(gum input --placeholder "Output filename" --value "$default_filename")
82
83# Use default if user just pressed enter
84if [ -z "$output_file" ]; then
85 output_file="$default_filename"
86fi
87
88# Export conversation to markdown
89echo "Exporting conversation: $session_title"
90echo "# $session_title" > "$output_file"
91echo "" >> "$output_file"
92
93# Temp file to store tool calls for lookup by tool_call_id
94tool_calls_file=$(mktemp)
95trap "rm -f $tool_calls_file" EXIT
96
97# Get all messages with full data in one query
98sqlite3 "$DB" "
99SELECT
100 id,
101 role,
102 model,
103 datetime(created_at, 'unixepoch'),
104 parts
105FROM messages
106WHERE session_id = '$session_id'
107AND (is_summary_message = 0 OR is_summary_message IS NULL)
108ORDER BY created_at ASC
109" | while IFS='|' read -r msg_id role model created parts; do
110 # Determine group role (user vs assistant/tool)
111 if [ "$role" = "user" ]; then
112 group_role="user"
113 else
114 group_role="assistant"
115 fi
116
117 # Check if we need a new section header (role changed)
118 if [ "$group_role" != "$prev_group_role" ]; then
119 # Close previous section if exists
120 if [ -n "$prev_group_role" ]; then
121 echo "" >> "$output_file"
122 echo "---" >> "$output_file"
123 echo "" >> "$output_file"
124 fi
125
126 # Start new section
127 case "$group_role" in
128 user)
129 echo "## User" >> "$output_file"
130 ;;
131 assistant)
132 if [ -n "$model" ]; then
133 echo "## Crush ($model)" >> "$output_file"
134 else
135 echo "## Crush" >> "$output_file"
136 fi
137 ;;
138 esac
139
140 echo "" >> "$output_file"
141 echo "_${created}_" >> "$output_file"
142 echo "" >> "$output_file"
143 prev_group_role="$group_role"
144 first_msg_in_section=true
145 fi
146
147 # Skip timestamp for subsequent messages in same section
148 if [ "$first_msg_in_section" = "true" ]; then
149 first_msg_in_section=false
150 fi
151
152 # Process each part in the message
153 last_tool_name=""
154 echo "$parts" | jq -c '.[]' | while read -r part; do
155 part_type=$(echo "$part" | jq -r '.type')
156
157 # Skip finish parts
158 if [ "$part_type" = "finish" ]; then
159 continue
160 fi
161
162 # Extract and format based on part type
163 case "$part_type" in
164 text)
165 text=$(echo "$part" | jq -r '.data.text')
166 echo "$text" >> "$output_file"
167 ;;
168 reasoning)
169 thinking=$(echo "$part" | jq -r '.data.thinking')
170 if [ -n "$thinking" ] && [ "$thinking" != "null" ]; then
171 echo "" >> "$output_file"
172 echo "<details>" >> "$output_file"
173 echo "<summary>💭 Thinking</summary>" >> "$output_file"
174 echo "" >> "$output_file"
175 echo "$thinking" >> "$output_file"
176 echo "</details>" >> "$output_file"
177 echo "" >> "$output_file"
178 fi
179 ;;
180 tool_call)
181 tool_name=$(echo "$part" | jq -r '.data.name')
182 tool_input=$(echo "$part" | jq -r '.data.input')
183 last_tool_name="$tool_name"
184
185 # Format tool call header based on tool type
186 case "$tool_name" in
187 view)
188 file_path=$(echo "$tool_input" | jq -r '.file_path // empty')
189 if [ ${#file_path} -gt 60 ]; then
190 display_path="${file_path:0:57}…"
191 else
192 display_path="$file_path"
193 fi
194 tool_summary="📄 view: <code>$(html_escape "$display_path")</code>"
195 ;;
196 ls)
197 ls_path=$(echo "$tool_input" | jq -r '.path // "."')
198 if [ ${#ls_path} -gt 60 ]; then
199 display_path="${ls_path:0:57}…"
200 else
201 display_path="$ls_path"
202 fi
203 tool_summary="📂 ls: <code>$(html_escape "$display_path")</code>"
204 ;;
205 bash)
206 description=$(echo "$tool_input" | jq -r '.description // empty')
207 if [ ${#description} -gt 57 ]; then
208 display_desc="${description:0:54}…"
209 else
210 display_desc="$description"
211 fi
212 tool_summary="🖥️ bash: $(html_escape "$display_desc")"
213 ;;
214 grep)
215 pattern=$(echo "$tool_input" | jq -r '.pattern // empty')
216 grep_path=$(echo "$tool_input" | jq -r '.path // "."')
217 if [ ${#pattern} -gt 40 ]; then
218 display_pattern="${pattern:0:37}…"
219 else
220 display_pattern="$pattern"
221 fi
222 tool_summary="🔎 grep: <code>$(html_escape "$display_pattern")</code> in <code>$(html_escape "$grep_path")</code>"
223 ;;
224 glob)
225 pattern=$(echo "$tool_input" | jq -r '.pattern // empty')
226 glob_path=$(echo "$tool_input" | jq -r '.path // "."')
227 if [ ${#pattern} -gt 40 ]; then
228 display_pattern="${pattern:0:37}…"
229 else
230 display_pattern="$pattern"
231 fi
232 tool_summary="🔎 glob: <code>$(html_escape "$display_pattern")</code> in <code>$(html_escape "$glob_path")</code>"
233 ;;
234 write|edit|multiedit)
235 file_path=$(echo "$tool_input" | jq -r '.file_path // empty')
236 if [ ${#file_path} -gt 60 ]; then
237 display_path="${file_path:0:57}…"
238 else
239 display_path="$file_path"
240 fi
241 tool_summary="✏️ $tool_name: <code>$(html_escape "$display_path")</code>"
242 ;;
243 *)
244 tool_summary="🔧 $(html_escape "$tool_name")"
245 ;;
246 esac
247
248 # Store for lookup by tool_result (using tool_call_id)
249 # Base64 encode tool_input to preserve newlines and special chars
250 tool_call_id=$(echo "$part" | jq -r '.data.id')
251 tool_input_b64=$(printf '%s' "$tool_input" | base64 -w0)
252 printf '%s\x1f%s\x1f%s\x1f%s\n' "$tool_call_id" "$tool_summary" "$tool_input_b64" "$tool_name" >> "$tool_calls_file"
253 ;;
254 tool_result)
255 tool_content=$(echo "$part" | jq -r '.data.content')
256 is_error=$(echo "$part" | jq -r '.data.is_error')
257 tool_call_id=$(echo "$part" | jq -r '.data.tool_call_id')
258
259 # Look up tool call info by ID
260 tool_info=$(grep "^${tool_call_id}" "$tool_calls_file" | head -1 || true)
261 if [ -n "$tool_info" ]; then
262 last_tool_summary=$(echo "$tool_info" | cut -d$'\x1f' -f2)
263 last_tool_input_b64=$(echo "$tool_info" | cut -d$'\x1f' -f3)
264 last_tool_input=$(printf '%s' "$last_tool_input_b64" | base64 -d)
265 last_tool_name=$(echo "$tool_info" | cut -d$'\x1f' -f4)
266 fi
267
268 # Strip XML-style tags from tool output
269 cleaned_content=$(echo "$tool_content" | sed -E '/<\/?result>/d; /<\/?file>/d; /<\/?output>/d')
270
271 echo "" >> "$output_file"
272 echo "<details>" >> "$output_file"
273 echo "<summary>$last_tool_summary</summary>" >> "$output_file"
274 echo "" >> "$output_file"
275 echo '```json' >> "$output_file"
276 echo "$last_tool_input" | jq . >> "$output_file"
277 echo '```' >> "$output_file"
278 echo "" >> "$output_file"
279 if [ "$is_error" = "true" ]; then
280 echo "**❌ Result**" >> "$output_file"
281 else
282 echo "**✅ Result**" >> "$output_file"
283 fi
284 echo "" >> "$output_file"
285 # Special handling for edit tools - show before/after
286 if [ "$last_tool_name" = "edit" ]; then
287 old_str=$(echo "$last_tool_input" | jq -r '.old_string // empty')
288 new_str=$(echo "$last_tool_input" | jq -r '.new_string // empty')
289 echo "**Before:**" >> "$output_file"
290 echo '```' >> "$output_file"
291 echo "$old_str" >> "$output_file"
292 echo '```' >> "$output_file"
293 echo "" >> "$output_file"
294 echo "**After:**" >> "$output_file"
295 echo '```' >> "$output_file"
296 echo "$new_str" >> "$output_file"
297 echo '```' >> "$output_file"
298 echo "" >> "$output_file"
299 echo "**Result:** $(html_escape "$cleaned_content")" >> "$output_file"
300 elif [ "$last_tool_name" = "multiedit" ]; then
301 # Show each edit's before/after
302 edits_count=$(echo "$last_tool_input" | jq '.edits | length')
303 for ((i=0; i<edits_count; i++)); do
304 old_str=$(echo "$last_tool_input" | jq -r ".edits[$i].old_string // empty")
305 new_str=$(echo "$last_tool_input" | jq -r ".edits[$i].new_string // empty")
306 echo "**Edit $((i+1)) - Before:**" >> "$output_file"
307 echo '```' >> "$output_file"
308 echo "$old_str" >> "$output_file"
309 echo '```' >> "$output_file"
310 echo "" >> "$output_file"
311 echo "**Edit $((i+1)) - After:**" >> "$output_file"
312 echo '```' >> "$output_file"
313 echo "$new_str" >> "$output_file"
314 echo '```' >> "$output_file"
315 echo "" >> "$output_file"
316 done
317 echo "**Result:** $(html_escape "$cleaned_content")" >> "$output_file"
318 else
319 echo '```' >> "$output_file"
320 echo "$cleaned_content" >> "$output_file"
321 echo '```' >> "$output_file"
322 fi
323 echo "</details>" >> "$output_file"
324 echo "" >> "$output_file"
325 ;;
326 esac
327 done
328
329 echo "" >> "$output_file"
330done
331
332# Add metadata footer
333echo "---" >> "$output_file"
334echo "" >> "$output_file"
335echo "## Metadata" >> "$output_file"
336echo "" >> "$output_file"
337
338# Get session statistics
339metadata=$(sqlite3 "$DB" "
340SELECT
341 COUNT(*) as msg_count,
342 datetime(MIN(created_at), 'unixepoch') as first_msg,
343 datetime(MAX(created_at), 'unixepoch') as last_msg,
344 MAX(created_at) - MIN(created_at) as duration_seconds
345FROM messages
346WHERE session_id = '$session_id'
347AND (is_summary_message = 0 OR is_summary_message IS NULL)
348")
349
350msg_count=$(echo "$metadata" | cut -d'|' -f1)
351first_msg=$(echo "$metadata" | cut -d'|' -f2)
352last_msg=$(echo "$metadata" | cut -d'|' -f3)
353duration_seconds=$(echo "$metadata" | cut -d'|' -f4)
354
355# Calculate duration in human-readable format
356if [ "$duration_seconds" -ge 3600 ]; then
357 hours=$((duration_seconds / 3600))
358 minutes=$(((duration_seconds % 3600) / 60))
359 duration="${hours}h ${minutes}m"
360elif [ "$duration_seconds" -ge 60 ]; then
361 minutes=$((duration_seconds / 60))
362 seconds=$((duration_seconds % 60))
363 duration="${minutes}m ${seconds}s"
364else
365 duration="${duration_seconds}s"
366fi
367
368echo "- **Messages:** $msg_count" >> "$output_file"
369echo "- **Duration:** $duration" >> "$output_file"
370echo "- **Started:** $first_msg" >> "$output_file"
371echo "- **Ended:** $last_msg" >> "$output_file"
372
373gum style --foreground 212 "✓ Exported to: $output_file"