| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694 |
- #!/usr/bin/env bash
- # cc-session: Analyze Claude Code session JSONL logs
- # Zero dependencies beyond jq (required) and standard coreutils.
- #
- # Usage:
- # cc-session <command> [session.jsonl] [options]
- # cc-session <command> --project <project-name> [options]
- # cc-session <command> --dir <directory-pattern> [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 <value> <max> <width>
- 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<filled; i++ )); do filled_str+="█"; done
- for (( i=0; i<empty; i++ )); do empty_str+="░"; done
- printf "${C_GREEN}%s${C_DIM}%s${C_RESET}" "$filled_str" "$empty_str"
- }
- # --- Helpers ---
- die() { printf "${C_RED}error:${C_RESET} %s\n" "$1" >&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 <pattern> 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 <name> Filter by project name (partial match)
- --dir, -d <pattern> Filter by directory pattern in project path
- --all Search all projects (with search command)
- --recent <n> 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 <n> to select a session, e.g.: cc-session overview --recent 3${C_RESET}\n"
- printf " ${C_DIM}Resume with: claude --resume <full-session-id>${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
|