#!/usr/bin/env bash # cc-session: Analyze Claude Code session JSONL logs # Zero dependencies beyond jq (required) and standard coreutils. # # Usage: # cc-session [session.jsonl] [options] # cc-session --project [options] # cc-session --dir [options] # # If no file given, uses the most recent session in the current project. set -euo pipefail CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}" PROJECTS_DIR="$CLAUDE_DIR/projects" # --- Formatting --- # Colors (disabled if not a terminal or NO_COLOR is set) if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then C_RESET='\033[0m' C_BOLD='\033[1m' C_DIM='\033[2m' C_CYAN='\033[36m' C_GREEN='\033[32m' C_YELLOW='\033[33m' C_BLUE='\033[34m' C_MAGENTA='\033[35m' C_RED='\033[31m' C_WHITE='\033[37m' else C_RESET='' C_BOLD='' C_DIM='' C_CYAN='' C_GREEN='' C_YELLOW='' C_BLUE='' C_MAGENTA='' C_RED='' C_WHITE='' fi header() { printf "${C_BOLD}${C_CYAN}%s${C_RESET}\n" "$1"; } subhead() { printf "\n${C_BOLD}%s${C_RESET}\n" "$1"; } label() { printf " ${C_DIM}%-16s${C_RESET} %s\n" "$1" "$2"; } divider() { printf "${C_DIM}%s${C_RESET}\n" "$(printf '%.0s-' {1..50})"; } bar() { # Usage: bar local val=${1:-0} max=${2:-1} width=${3:-20} [[ $max -le 0 ]] && max=1 local filled=$(( val * width / max )) [[ $filled -gt $width ]] && filled=$width local empty=$(( width - filled )) local filled_str="" empty_str="" local i for (( i=0; i&2; exit 1; } usage() { cat <<'USAGE' cc-session - Claude Code session log analyzer COMMANDS: sessions List recent sessions with size and age overview Entry type counts, duration, model info tools Tool usage frequency (sorted) tool-chain Sequential tool call trace with timing thinking Extract thinking/reasoning blocks thinking-summary First 200 chars of each thinking block errors Tool results containing errors conversation Reconstructed user/assistant turns files Files read, edited, or written turns Per-turn breakdown (duration, tools, tokens) agents Subagent spawns and their tool usage search Search across sessions (user + assistant text) cost Rough token/cost estimation timeline Event timeline with gaps highlighted summary Session summaries (compaction boundaries) OPTIONS: --project, -p Filter by project name (partial match) --dir, -d Filter by directory pattern in project path --all Search all projects (with search command) --recent Use nth most recent session (default: 1) --json Output as JSON instead of text EXAMPLES: cc-session overview # Current project, latest session cc-session tools # Tool frequency cc-session tools --recent 2 # Second most recent session cc-session search "auth" --all # Search all projects cc-session errors -p claude-mods # Errors in claude-mods project cc-session tool-chain # Full tool call sequence cc-session thinking | grep -i "decision" # Search reasoning cc-session turns --json | jq '.[] | select(.tools > 5)' USAGE exit 0 } # Resolve project directory from current working directory resolve_project() { local project_filter="${1:-}" local candidates=() if [[ -n "$project_filter" ]]; then # Find by partial match - collect all matches while IFS= read -r d; do candidates+=("$d") done < <(ls "$PROJECTS_DIR" 2>/dev/null | grep -i "$project_filter") else # Derive from cwd local encoded encoded=$(pwd | sed 's/[:\\\/]/-/g' | sed 's/--*/-/g') while IFS= read -r d; do candidates+=("$d") done < <(ls "$PROJECTS_DIR" 2>/dev/null | grep -i "${encoded##*-}") fi [[ ${#candidates[@]} -gt 0 ]] || die "No project matching '${project_filter:-$(pwd)}'" # Pick the candidate that actually has JSONL files, preferring most recent for candidate in "${candidates[@]}"; do local dir="$PROJECTS_DIR/$candidate" # Check if directory contains any .jsonl files (not just subdirs) if ls "$dir"/*.jsonl &>/dev/null; then echo "$dir" return fi done # Fallback: return first match even without JSONL files echo "$PROJECTS_DIR/${candidates[0]}" } # Resolve session file resolve_session() { local project_dir="$1" local recent="${2:-1}" # Use ls on directory then filter - portable across Git Bash / macOS / Linux ls -t "$project_dir/" 2>/dev/null | grep '\.jsonl$' | grep -v '^agent-' | sed -n "${recent}p" | while read -r f; do echo "$project_dir/$f"; done } # --- Commands --- cmd_sessions() { local project_dir="$1" json="${2:-}" if [[ "$json" == "json" ]]; then ls -t "$project_dir/" 2>/dev/null | grep '\.jsonl$' | grep -v '^agent-' | while read -r fname; do local fpath="$project_dir/$fname" local session_id="${fname%.jsonl}" local size size=$(wc -c < "$fpath" 2>/dev/null | tr -d ' ') local mod_date mod_date=$(date -r "$fpath" '+%Y-%m-%d %H:%M' 2>/dev/null || stat -c '%y' "$fpath" 2>/dev/null | cut -d. -f1) printf '{"session":"%s","size_bytes":%s,"modified":"%s"}\n' "$session_id" "$size" "$mod_date" done else header "Sessions" echo "" printf " ${C_DIM}%-4s %-38s %6s %s${C_RESET}\n" "#" "Session ID" "Size" "Last Modified" divider local n=0 ls -t "$project_dir/" 2>/dev/null | grep '\.jsonl$' | grep -v '^agent-' | while read -r fname; do n=$((n + 1)) local fpath="$project_dir/$fname" local session_id="${fname%.jsonl}" # Human-readable size local bytes bytes=$(wc -c < "$fpath" 2>/dev/null | tr -d ' ') local size_h if [[ $bytes -ge 1048576 ]]; then size_h="$(( bytes / 1048576 ))M" elif [[ $bytes -ge 1024 ]]; then size_h="$(( bytes / 1024 ))K" else size_h="${bytes}B" fi local mod_date mod_date=$(date -r "$fpath" '+%Y-%m-%d %H:%M' 2>/dev/null || stat -c '%y' "$fpath" 2>/dev/null | cut -d. -f1) if [[ $n -eq 1 ]]; then printf " ${C_GREEN}%-4s${C_RESET} ${C_BOLD}%-38s${C_RESET} %6s %s ${C_GREEN}(latest)${C_RESET}\n" "$n" "$session_id" "$size_h" "$mod_date" else printf " ${C_DIM}%-4s${C_RESET} %-38s %6s %s\n" "$n" "$session_id" "$size_h" "$mod_date" fi done echo "" printf " ${C_DIM}Use --recent to select a session, e.g.: cc-session overview --recent 3${C_RESET}\n" printf " ${C_DIM}Resume with: claude --resume ${C_RESET}\n" fi } cmd_overview() { local f="$1" json="${2:-}" if [[ "$json" == "json" ]]; then cat "$f" | jq -sc '{ file: input_filename, entries: length, types: (group_by(.type) | map({type: .[0].type, count: length})), first_ts: (map(.timestamp | select(.) | strings) | sort | first), last_ts: (map(.timestamp | select(.) | strings) | sort | last), duration_ms: ( [.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | add ), turns: ([.[] | select(.type == "system" and .subtype == "turn_duration")] | length), tool_calls: ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length), thinking_blocks: ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking")] | length), user_messages: ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text")] | length) }' 2>/dev/null else # Gather all stats in one jq pass local stats stats=$(cat "$f" | jq -rsc ' ([.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | add // 0) as $total_ms | ([.[] | select(.type == "system" and .subtype == "turn_duration")] | length) as $turns | ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text")] | length) as $user_msgs | ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length) as $tools | ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking")] | length) as $thinking | ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name] | group_by(.) | map({t: .[0], n: length}) | sort_by(-.n) | .[:5]) as $top_tools | (map(.timestamp | select(.) | strings) | sort) as $ts | ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $user_chars | ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $asst_chars | ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | .thinking | length] | add // 0) as $think_chars | (($user_chars + $asst_chars + $think_chars) / 4 | floor) as $est_tokens | { total_ms: $total_ms, total_s: ($total_ms / 1000 | floor), mins: ($total_ms / 60000 | floor), secs: (($total_ms / 1000 | floor) % 60), turns: $turns, avg_s: (if $turns > 0 then ($total_ms / $turns / 1000 | floor) else 0 end), user_msgs: $user_msgs, tools: $tools, thinking: $thinking, top_tools: $top_tools, entries: length, first_ts: ($ts | first // "unknown"), last_ts: ($ts | last // "unknown"), est_tokens_k: (($est_tokens / 1000 * 10 | floor) / 10) } | @json ' 2>/dev/null) local session_id session_id=$(basename "$f" .jsonl) header "Session Overview" echo "" label "Session" "$session_id" label "Resume" "claude --resume $session_id" label "Started" "$(echo "$stats" | jq -r '.first_ts | .[0:19] | gsub("T"; " ")')" label "Ended" "$(echo "$stats" | jq -r '.last_ts | .[0:19] | gsub("T"; " ")')" label "Entries" "$(echo "$stats" | jq -r '.entries')" subhead "Timing" divider local mins secs turns avg mins=$(echo "$stats" | jq -r '.mins') secs=$(echo "$stats" | jq -r '.secs') turns=$(echo "$stats" | jq -r '.turns') avg=$(echo "$stats" | jq -r '.avg_s') label "Duration" "${mins}m ${secs}s" label "Turns" "$turns" label "Avg turn" "${avg}s" subhead "Activity" divider local user_msgs tools thinking user_msgs=$(echo "$stats" | jq -r '.user_msgs') tools=$(echo "$stats" | jq -r '.tools') thinking=$(echo "$stats" | jq -r '.thinking') local est_tokens est_tokens=$(echo "$stats" | jq -r '.est_tokens_k') label "User messages" "$user_msgs" label "Tool calls" "$tools" label "Thinking" "$thinking blocks" label "Est. tokens" "${est_tokens}k" subhead "Top Tools" divider echo "$stats" | jq -r '.top_tools[] | "\(.t)\t\(.n)"' | tr -d '\r' | while IFS=$'\t' read -r tool count; do printf " ${C_YELLOW}%-16s${C_RESET} %3s " "$tool" "$count" bar "$count" "$tools" 20 echo "" done fi } cmd_tools() { local f="$1" json="${2:-}" if [[ "$json" == "json" ]]; then cat "$f" | jq -sc '[ .[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name ] | group_by(.) | map({tool: .[0], count: length}) | sort_by(-.count)' 2>/dev/null else header "Tool Usage" echo "" local total total=$(cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name' | wc -l) cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name' | sort | uniq -c | sort -rn | tr -d '\r' | while read -r count tool; do printf " ${C_YELLOW}%-16s${C_RESET} %3s " "$tool" "$count" bar "$count" "$total" 25 echo "" done divider label "Total" "$total calls" fi } cmd_tool_chain() { local f="$1" json="${2:-}" if [[ "$json" == "json" ]]; then cat "$f" | jq -c ' select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | {name, id, input_summary: ( if .name == "Bash" then (.input.command | .[0:120]) elif .name == "Read" then .input.file_path elif .name == "Write" then .input.file_path elif .name == "Edit" then .input.file_path elif .name == "Grep" then "\(.input.pattern) in \(.input.path // ".")" elif .name == "Glob" then .input.pattern elif .name == "Agent" then "\(.input.subagent_type // "general"): \(.input.description // "")" elif .name == "Skill" then .input.skill elif .name == "WebSearch" then .input.query elif .name == "WebFetch" then .input.url else (.input | tostring | .[0:100]) end )} ' 2>/dev/null else cat "$f" | jq -r ' select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | "\(.name | . + " " * (15 - length)) \( if .name == "Bash" then (.input.command | .[0:100]) elif .name == "Read" then .input.file_path elif .name == "Write" then .input.file_path elif .name == "Edit" then .input.file_path elif .name == "Grep" then "pattern=\(.input.pattern) path=\(.input.path // ".")" elif .name == "Glob" then .input.pattern elif .name == "Agent" then "\(.input.subagent_type // "general"): \(.input.description // "")" elif .name == "Skill" then .input.skill elif .name == "WebSearch" then .input.query elif .name == "WebFetch" then .input.url else (.input | tostring | .[0:80]) end )" ' 2>/dev/null fi } cmd_thinking() { local f="$1" cat "$f" | jq -r ' select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | .thinking ' 2>/dev/null } cmd_thinking_summary() { local f="$1" json="${2:-}" if [[ "$json" == "json" ]]; then cat "$f" | jq -sc '[ .[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | {preview: (.thinking | .[0:200])} ]' 2>/dev/null else local n=0 cat "$f" | jq -r ' select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | .thinking | .[0:200] | gsub("\n"; " ") ' 2>/dev/null | while read -r line; do n=$((n + 1)) printf "[%d] %s...\n\n" "$n" "$line" done fi } cmd_errors() { local f="$1" json="${2:-}" if [[ "$json" == "json" ]]; then cat "$f" | jq -c ' select(.type == "user") | .message.content[]? | select(.type == "tool_result") | select(.content | type == "string" and test("error|Error|ERROR|failed|Failed|FAILED")) | {tool_use_id, error: (.content | .[0:300])} ' 2>/dev/null else cat "$f" | jq -r ' select(.type == "user") | .message.content[]? | select(.type == "tool_result") | select(.content | type == "string" and test("error|Error|ERROR|failed|Failed|FAILED")) | "--- tool_use_id: \(.tool_use_id) ---\n\(.content | .[0:300])\n" ' 2>/dev/null fi } cmd_conversation() { local f="$1" cat "$f" | jq -r ' if .type == "user" then .message.content[]? | select(.type == "text") | "USER: \(.text)" elif .type == "assistant" then .message.content[]? | select(.type == "text") | "CLAUDE: \(.text | .[0:500])" else empty end ' 2>/dev/null } cmd_files() { local f="$1" json="${2:-}" if [[ "$json" == "json" ]]; then cat "$f" | jq -sc '{ read: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Read") | .input.file_path] | group_by(.) | map({file: .[0], count: length}) | sort_by(-.count), edited: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Edit") | .input.file_path] | group_by(.) | map({file: .[0], count: length}) | sort_by(-.count), written: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Write") | .input.file_path] | unique }' 2>/dev/null else header "Files Touched" subhead "Read" divider cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Read") | .input.file_path' | sort | uniq -c | sort -rn | while read -r count path; do local short="${path##*/}" printf " ${C_DIM}%3s${C_RESET} ${C_BLUE}%s${C_RESET}\n" "$count" "$short" done subhead "Edited" divider cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Edit") | .input.file_path' | sort | uniq -c | sort -rn | while read -r count path; do local short="${path##*/}" printf " ${C_DIM}%3s${C_RESET} ${C_YELLOW}%s${C_RESET}\n" "$count" "$short" done subhead "Written (new files)" divider cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Write") | .input.file_path' | sort -u | while read -r path; do local short="${path##*/}" printf " ${C_GREEN}+ %s${C_RESET}\n" "$short" done fi } cmd_turns() { local f="$1" json="${2:-}" cat "$f" | jq -sc ' [.[] | select(.type == "system" and .subtype == "turn_duration")] as $durations | [.[] | select(.type == "assistant")] as $assistants | [range(0; [$durations | length, $assistants | length] | min) | { turn: (. + 1), duration_s: ($durations[.].durationMs / 1000 | floor), tools: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name] | length), tool_names: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name]), has_thinking: ([$assistants[.].message.content[]? | select(.type == "thinking")] | length > 0), text_length: ([$assistants[.].message.content[]? | select(.type == "text") | .text | length] | add // 0) } ] ' 2>/dev/null | if [[ "$json" == "json" ]]; then cat; else local max_dur max_dur=$(jq '[.[].duration_s] | max // 1' <<< "$(cat)") # Re-read from the pipe above (need to re-run since we consumed stdin) cat "$f" | jq -sc ' [.[] | select(.type == "system" and .subtype == "turn_duration")] as $durations | [.[] | select(.type == "assistant")] as $assistants | ([.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | max // 1000) as $max_ms | [range(0; [$durations | length, $assistants | length] | min) | { turn: (. + 1), duration_s: ($durations[.].durationMs / 1000 | floor), max_s: ($max_ms / 1000 | floor), tools: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name] | length), tool_names: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name]), has_thinking: ([$assistants[.].message.content[]? | select(.type == "thinking")] | length > 0) } ] ' 2>/dev/null | jq -r '.[] | "\(.turn)\t\(.duration_s)\t\(.max_s)\t\(.tools)\t\(.tool_names | join(", ") | if . == "" then "-" else . end)\t\(.has_thinking)"' | tr -d '\r' | { header "Turns" echo "" printf " ${C_DIM}%-6s %-8s %-6s %-8s %s${C_RESET}\n" "Turn" "Time" "Tools" "" "Details" divider while IFS=$'\t' read -r turn dur max_s tools tool_names thinking; do thinking=$(echo "$thinking" | tr -d '\r ') local think_flag="" [[ "$thinking" == "true" ]] && think_flag=" ${C_MAGENTA}[T]${C_RESET}" printf " ${C_BOLD}%-6s${C_RESET} %5ss " "#$turn" "$dur" bar "$dur" "$max_s" 12 printf " %2s ${C_DIM}%s${C_RESET}%b\n" "$tools" "$tool_names" "$think_flag" done } fi } cmd_agents() { local f="$1" json="${2:-}" if [[ "$json" == "json" ]]; then cat "$f" | jq -c ' select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Agent") | { id, subagent_type: (.input.subagent_type // "general-purpose"), description: .input.description, prompt_preview: (.input.prompt | .[0:200]), background: (.input.run_in_background // false), isolation: (.input.isolation // null) } ' 2>/dev/null else cat "$f" | jq -r ' select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Agent") | "[\(.input.subagent_type // "general")] \(.input.description // "no description")\n prompt: \(.input.prompt | .[0:150] | gsub("\n"; " "))...\n" ' 2>/dev/null fi } cmd_search() { local pattern="$1" shift local search_dir="$1" rg -l "$pattern" "$search_dir"/*.jsonl 2>/dev/null | while read -r f; do local session session=$(basename "$f" .jsonl) echo "=== $session ===" cat "$f" | jq -r " if .type == \"user\" then .message.content[]? | select(.type == \"text\") | .text elif .type == \"assistant\" then .message.content[]? | select(.type == \"text\") | .text else empty end " 2>/dev/null | grep -i --color=auto -C2 "$pattern" || true echo "" done } cmd_cost() { local f="$1" json="${2:-}" cat "$f" | jq -sc ' ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $user_chars | ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $asst_chars | ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | .thinking | length] | add // 0) as $think_chars | ($user_chars + $asst_chars + $think_chars) as $total_chars | ($total_chars / 4 | floor) as $est_tokens | { user_chars: $user_chars, assistant_chars: $asst_chars, thinking_chars: $think_chars, total_chars: $total_chars, est_tokens: $est_tokens, est_tokens_k: (($est_tokens / 1000 * 10 | floor) / 10) } ' 2>/dev/null | if [[ "$json" == "json" ]]; then cat; else jq -r '.' | { header "Cost Estimate" echo "" local data data=$(cat) local est_k user asst think total est_k=$(echo "$data" | jq -r '.est_tokens_k') user=$(echo "$data" | jq -r '.user_chars') asst=$(echo "$data" | jq -r '.assistant_chars') think=$(echo "$data" | jq -r '.thinking_chars') total=$(echo "$data" | jq -r '.total_chars') label "Est. tokens" "${C_BOLD}${est_k}k${C_RESET}" label "User" "${user} chars" label "Assistant" "${asst} chars" label "Thinking" "${think} chars" divider label "Total chars" "$total" } fi } cmd_timeline() { local f="$1" cat "$f" | jq -r ' select(.type == "user" or .type == "assistant") | select(.timestamp) | "\(.timestamp) \(.type | . + " " * (12 - length)) \( if .type == "user" then (.message.content[]? | select(.type == "text") | .text | .[0:80] | gsub("\n"; " ")) // "[tool_result]" else (.message.content[]? | if .type == "tool_use" then "[\(.name)] \( if .name == "Bash" then (.input.command | .[0:60]) elif .name == "Read" then .input.file_path elif .name == "Edit" then .input.file_path elif .name == "Write" then .input.file_path else (.input | tostring | .[0:60]) end )" elif .type == "text" then .text | .[0:80] | gsub("\n"; " ") elif .type == "thinking" then "[thinking...]" else empty end ) // "" end )" ' 2>/dev/null } cmd_summary() { local f="$1" cat "$f" | jq -r ' select(.type == "summary" or (.type == "system" and .subtype == "compact_boundary")) | if .type == "summary" then "=== Summary ===\n\(.summary)\n" else "--- Compaction Boundary ---" end ' 2>/dev/null } # --- Main --- [[ $# -eq 0 ]] && usage cmd="$1" shift # Parse options session_file="" project="" recent=1 output="text" search_all=false dir_pattern="" while [[ $# -gt 0 ]]; do case "$1" in --project|-p) project="$2"; shift 2 ;; --dir|-d) dir_pattern="$2"; shift 2 ;; --recent) recent="$2"; shift 2 ;; --json) output="json"; shift ;; --all) search_all=true; shift ;; --help|-h) usage ;; *) if [[ -f "$1" ]]; then session_file="$1" elif [[ "$cmd" == "search" && -z "${search_pattern:-}" ]]; then search_pattern="$1" fi shift ;; esac done # Resolve session file if not given directly if [[ -z "$session_file" ]]; then project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}") session_file=$(resolve_session "$project_dir" "$recent") [[ -n "$session_file" ]] || die "No session files found in $project_dir" fi # Execute command case "$cmd" in sessions) project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}") cmd_sessions "$project_dir" "$output" exit 0 ;; overview) cmd_overview "$session_file" "$output" ;; tools) cmd_tools "$session_file" "$output" ;; tool-chain) cmd_tool_chain "$session_file" "$output" ;; thinking) cmd_thinking "$session_file" ;; thinking-summary) cmd_thinking_summary "$session_file" "$output" ;; errors) cmd_errors "$session_file" "$output" ;; conversation) cmd_conversation "$session_file" ;; files) cmd_files "$session_file" "$output" ;; turns) cmd_turns "$session_file" "$output" ;; agents) cmd_agents "$session_file" "$output" ;; cost) cmd_cost "$session_file" "$output" ;; timeline) cmd_timeline "$session_file" ;; summary) cmd_summary "$session_file" ;; search) [[ -n "${search_pattern:-}" ]] || die "search requires a pattern" if [[ "$search_all" == true ]]; then for d in "$PROJECTS_DIR"/*/; do echo ">>> $(basename "$d")" cmd_search "$search_pattern" "$d" done else project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}") cmd_search "$search_pattern" "$project_dir" fi ;; *) die "Unknown command: $cmd. Run cc-session --help for usage." ;; esac