cc-session 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. #!/usr/bin/env bash
  2. # cc-session: Analyze Claude Code session JSONL logs
  3. # Zero dependencies beyond jq (required) and standard coreutils.
  4. #
  5. # Usage:
  6. # cc-session <command> [session.jsonl] [options]
  7. # cc-session <command> --project <project-name> [options]
  8. # cc-session <command> --dir <directory-pattern> [options]
  9. #
  10. # If no file given, uses the most recent session in the current project.
  11. set -euo pipefail
  12. CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}"
  13. PROJECTS_DIR="$CLAUDE_DIR/projects"
  14. # --- Helpers ---
  15. die() { printf 'error: %s\n' "$1" >&2; exit 1; }
  16. usage() {
  17. cat <<'USAGE'
  18. cc-session - Claude Code session log analyzer
  19. COMMANDS:
  20. overview Entry type counts, duration, model info
  21. tools Tool usage frequency (sorted)
  22. tool-chain Sequential tool call trace with timing
  23. thinking Extract thinking/reasoning blocks
  24. thinking-summary First 200 chars of each thinking block
  25. errors Tool results containing errors
  26. conversation Reconstructed user/assistant turns
  27. files Files read, edited, or written
  28. turns Per-turn breakdown (duration, tools, tokens)
  29. agents Subagent spawns and their tool usage
  30. search <pattern> Search across sessions (user + assistant text)
  31. cost Rough token/cost estimation
  32. timeline Event timeline with gaps highlighted
  33. summary Session summaries (compaction boundaries)
  34. OPTIONS:
  35. --project, -p <name> Filter by project name (partial match)
  36. --dir, -d <pattern> Filter by directory pattern in project path
  37. --all Search all projects (with search command)
  38. --recent <n> Use nth most recent session (default: 1)
  39. --json Output as JSON instead of text
  40. EXAMPLES:
  41. cc-session overview # Current project, latest session
  42. cc-session tools # Tool frequency
  43. cc-session tools --recent 2 # Second most recent session
  44. cc-session search "auth" --all # Search all projects
  45. cc-session errors -p claude-mods # Errors in claude-mods project
  46. cc-session tool-chain # Full tool call sequence
  47. cc-session thinking | grep -i "decision" # Search reasoning
  48. cc-session turns --json | jq '.[] | select(.tools > 5)'
  49. USAGE
  50. exit 0
  51. }
  52. # Resolve project directory from current working directory
  53. resolve_project() {
  54. local project_filter="${1:-}"
  55. local candidates=()
  56. if [[ -n "$project_filter" ]]; then
  57. # Find by partial match - collect all matches
  58. while IFS= read -r d; do
  59. candidates+=("$d")
  60. done < <(ls "$PROJECTS_DIR" 2>/dev/null | grep -i "$project_filter")
  61. else
  62. # Derive from cwd
  63. local encoded
  64. encoded=$(pwd | sed 's/[:\\\/]/-/g' | sed 's/--*/-/g')
  65. while IFS= read -r d; do
  66. candidates+=("$d")
  67. done < <(ls "$PROJECTS_DIR" 2>/dev/null | grep -i "${encoded##*-}")
  68. fi
  69. [[ ${#candidates[@]} -gt 0 ]] || die "No project matching '${project_filter:-$(pwd)}'"
  70. # Pick the candidate that actually has JSONL files, preferring most recent
  71. for candidate in "${candidates[@]}"; do
  72. local dir="$PROJECTS_DIR/$candidate"
  73. # Check if directory contains any .jsonl files (not just subdirs)
  74. if ls "$dir"/*.jsonl &>/dev/null; then
  75. echo "$dir"
  76. return
  77. fi
  78. done
  79. # Fallback: return first match even without JSONL files
  80. echo "$PROJECTS_DIR/${candidates[0]}"
  81. }
  82. # Resolve session file
  83. resolve_session() {
  84. local project_dir="$1"
  85. local recent="${2:-1}"
  86. # Use ls on directory then filter - portable across Git Bash / macOS / Linux
  87. ls -t "$project_dir/" 2>/dev/null | grep '\.jsonl$' | grep -v '^agent-' | sed -n "${recent}p" |
  88. while read -r f; do echo "$project_dir/$f"; done
  89. }
  90. # --- Commands ---
  91. cmd_overview() {
  92. local f="$1" json="${2:-}"
  93. if [[ "$json" == "json" ]]; then
  94. cat "$f" | jq -sc '{
  95. file: input_filename,
  96. entries: length,
  97. types: (group_by(.type) | map({type: .[0].type, count: length})),
  98. first_ts: (map(.timestamp | select(.) | strings) | sort | first),
  99. last_ts: (map(.timestamp | select(.) | strings) | sort | last),
  100. duration_ms: (
  101. [.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | add
  102. ),
  103. turns: ([.[] | select(.type == "system" and .subtype == "turn_duration")] | length),
  104. tool_calls: ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length),
  105. thinking_blocks: ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking")] | length),
  106. user_messages: ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text")] | length)
  107. }' 2>/dev/null
  108. else
  109. echo "=== Session Overview ==="
  110. echo "File: $(basename "$f")"
  111. echo ""
  112. echo "--- Entry Types ---"
  113. cat "$f" | jq -r '.type' | sort | uniq -c | sort -rn
  114. echo ""
  115. echo "--- Timing ---"
  116. cat "$f" | jq -rsc '
  117. ([.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | add // 0) as $total |
  118. ([.[] | select(.type == "system" and .subtype == "turn_duration")] | length) as $turns |
  119. "Total time: \($total / 1000 | floor)s (\($total / 60000 | floor)m \(($total / 1000 | floor) % 60)s)\nTurns: \($turns)\nAvg turn: \(if $turns > 0 then ($total / $turns / 1000 | floor) else 0 end)s"
  120. ' 2>/dev/null
  121. echo ""
  122. echo "--- Content ---"
  123. cat "$f" | jq -rsc '
  124. ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text")] | length) as $user_msgs |
  125. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length) as $tools |
  126. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking")] | length) as $thinking |
  127. "User messages: \($user_msgs)\nTool calls: \($tools)\nThinking blocks: \($thinking)"
  128. ' 2>/dev/null
  129. fi
  130. }
  131. cmd_tools() {
  132. local f="$1" json="${2:-}"
  133. if [[ "$json" == "json" ]]; then
  134. cat "$f" | jq -sc '[
  135. .[] | select(.type == "assistant") | .message.content[]? |
  136. select(.type == "tool_use") | .name
  137. ] | group_by(.) | map({tool: .[0], count: length}) | sort_by(-.count)' 2>/dev/null
  138. else
  139. cat "$f" | jq -r '
  140. select(.type == "assistant") | .message.content[]? |
  141. select(.type == "tool_use") | .name
  142. ' | sort | uniq -c | sort -rn
  143. fi
  144. }
  145. cmd_tool_chain() {
  146. local f="$1" json="${2:-}"
  147. if [[ "$json" == "json" ]]; then
  148. cat "$f" | jq -c '
  149. select(.type == "assistant") | .message.content[]? |
  150. select(.type == "tool_use") |
  151. {name, id, input_summary: (
  152. if .name == "Bash" then (.input.command | .[0:120])
  153. elif .name == "Read" then .input.file_path
  154. elif .name == "Write" then .input.file_path
  155. elif .name == "Edit" then .input.file_path
  156. elif .name == "Grep" then "\(.input.pattern) in \(.input.path // ".")"
  157. elif .name == "Glob" then .input.pattern
  158. elif .name == "Agent" then "\(.input.subagent_type // "general"): \(.input.description // "")"
  159. elif .name == "Skill" then .input.skill
  160. elif .name == "WebSearch" then .input.query
  161. elif .name == "WebFetch" then .input.url
  162. else (.input | tostring | .[0:100])
  163. end
  164. )}
  165. ' 2>/dev/null
  166. else
  167. cat "$f" | jq -r '
  168. select(.type == "assistant") | .message.content[]? |
  169. select(.type == "tool_use") |
  170. "\(.name | . + " " * (15 - length)) \(
  171. if .name == "Bash" then (.input.command | .[0:100])
  172. elif .name == "Read" then .input.file_path
  173. elif .name == "Write" then .input.file_path
  174. elif .name == "Edit" then .input.file_path
  175. elif .name == "Grep" then "pattern=\(.input.pattern) path=\(.input.path // ".")"
  176. elif .name == "Glob" then .input.pattern
  177. elif .name == "Agent" then "\(.input.subagent_type // "general"): \(.input.description // "")"
  178. elif .name == "Skill" then .input.skill
  179. elif .name == "WebSearch" then .input.query
  180. elif .name == "WebFetch" then .input.url
  181. else (.input | tostring | .[0:80])
  182. end
  183. )"
  184. ' 2>/dev/null
  185. fi
  186. }
  187. cmd_thinking() {
  188. local f="$1"
  189. cat "$f" | jq -r '
  190. select(.type == "assistant") | .message.content[]? |
  191. select(.type == "thinking") | .thinking
  192. ' 2>/dev/null
  193. }
  194. cmd_thinking_summary() {
  195. local f="$1" json="${2:-}"
  196. if [[ "$json" == "json" ]]; then
  197. cat "$f" | jq -sc '[
  198. .[] | select(.type == "assistant") | .message.content[]? |
  199. select(.type == "thinking") | {preview: (.thinking | .[0:200])}
  200. ]' 2>/dev/null
  201. else
  202. local n=0
  203. cat "$f" | jq -r '
  204. select(.type == "assistant") | .message.content[]? |
  205. select(.type == "thinking") | .thinking | .[0:200] | gsub("\n"; " ")
  206. ' 2>/dev/null | while read -r line; do
  207. n=$((n + 1))
  208. printf "[%d] %s...\n\n" "$n" "$line"
  209. done
  210. fi
  211. }
  212. cmd_errors() {
  213. local f="$1" json="${2:-}"
  214. if [[ "$json" == "json" ]]; then
  215. cat "$f" | jq -c '
  216. select(.type == "user") | .message.content[]? |
  217. select(.type == "tool_result") |
  218. select(.content | type == "string" and test("error|Error|ERROR|failed|Failed|FAILED")) |
  219. {tool_use_id, error: (.content | .[0:300])}
  220. ' 2>/dev/null
  221. else
  222. cat "$f" | jq -r '
  223. select(.type == "user") | .message.content[]? |
  224. select(.type == "tool_result") |
  225. select(.content | type == "string" and test("error|Error|ERROR|failed|Failed|FAILED")) |
  226. "--- tool_use_id: \(.tool_use_id) ---\n\(.content | .[0:300])\n"
  227. ' 2>/dev/null
  228. fi
  229. }
  230. cmd_conversation() {
  231. local f="$1"
  232. cat "$f" | jq -r '
  233. if .type == "user" then
  234. .message.content[]? | select(.type == "text") | "USER: \(.text)"
  235. elif .type == "assistant" then
  236. .message.content[]? | select(.type == "text") | "CLAUDE: \(.text | .[0:500])"
  237. else empty end
  238. ' 2>/dev/null
  239. }
  240. cmd_files() {
  241. local f="$1" json="${2:-}"
  242. if [[ "$json" == "json" ]]; then
  243. cat "$f" | jq -sc '{
  244. 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),
  245. 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),
  246. written: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Write") | .input.file_path] | unique
  247. }' 2>/dev/null
  248. else
  249. echo "=== Files Read ==="
  250. cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Read") | .input.file_path' | sort | uniq -c | sort -rn
  251. echo ""
  252. echo "=== Files Edited ==="
  253. cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Edit") | .input.file_path' | sort | uniq -c | sort -rn
  254. echo ""
  255. echo "=== Files Written ==="
  256. cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Write") | .input.file_path' | sort -u
  257. fi
  258. }
  259. cmd_turns() {
  260. local f="$1" json="${2:-}"
  261. cat "$f" | jq -sc '
  262. [.[] | select(.type == "system" and .subtype == "turn_duration")] as $durations |
  263. [.[] | select(.type == "assistant")] as $assistants |
  264. [range(0; [$durations | length, $assistants | length] | min) |
  265. {
  266. turn: (. + 1),
  267. duration_s: ($durations[.].durationMs / 1000 | floor),
  268. tools: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name] | length),
  269. tool_names: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name]),
  270. has_thinking: ([$assistants[.].message.content[]? | select(.type == "thinking")] | length > 0),
  271. text_length: ([$assistants[.].message.content[]? | select(.type == "text") | .text | length] | add // 0)
  272. }
  273. ]
  274. ' 2>/dev/null | if [[ "$json" == "json" ]]; then cat; else
  275. jq -r '.[] | "Turn \(.turn): \(.duration_s)s, \(.tools) tools [\(.tool_names | join(", "))]\(if .has_thinking then " [thinking]" else "" end)"' 2>/dev/null
  276. fi
  277. }
  278. cmd_agents() {
  279. local f="$1" json="${2:-}"
  280. if [[ "$json" == "json" ]]; then
  281. cat "$f" | jq -c '
  282. select(.type == "assistant") | .message.content[]? |
  283. select(.type == "tool_use" and .name == "Agent") |
  284. {
  285. id,
  286. subagent_type: (.input.subagent_type // "general-purpose"),
  287. description: .input.description,
  288. prompt_preview: (.input.prompt | .[0:200]),
  289. background: (.input.run_in_background // false),
  290. isolation: (.input.isolation // null)
  291. }
  292. ' 2>/dev/null
  293. else
  294. cat "$f" | jq -r '
  295. select(.type == "assistant") | .message.content[]? |
  296. select(.type == "tool_use" and .name == "Agent") |
  297. "[\(.input.subagent_type // "general")] \(.input.description // "no description")\n prompt: \(.input.prompt | .[0:150] | gsub("\n"; " "))...\n"
  298. ' 2>/dev/null
  299. fi
  300. }
  301. cmd_search() {
  302. local pattern="$1"
  303. shift
  304. local search_dir="$1"
  305. rg -l "$pattern" "$search_dir"/*.jsonl 2>/dev/null | while read -r f; do
  306. local session
  307. session=$(basename "$f" .jsonl)
  308. echo "=== $session ==="
  309. cat "$f" | jq -r "
  310. if .type == \"user\" then
  311. .message.content[]? | select(.type == \"text\") | .text
  312. elif .type == \"assistant\" then
  313. .message.content[]? | select(.type == \"text\") | .text
  314. else empty end
  315. " 2>/dev/null | grep -i --color=auto -C2 "$pattern" || true
  316. echo ""
  317. done
  318. }
  319. cmd_cost() {
  320. local f="$1" json="${2:-}"
  321. cat "$f" | jq -sc '
  322. ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $user_chars |
  323. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $asst_chars |
  324. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | .thinking | length] | add // 0) as $think_chars |
  325. ($user_chars + $asst_chars + $think_chars) as $total_chars |
  326. ($total_chars / 4 | floor) as $est_tokens |
  327. {
  328. user_chars: $user_chars,
  329. assistant_chars: $asst_chars,
  330. thinking_chars: $think_chars,
  331. total_chars: $total_chars,
  332. est_tokens: $est_tokens,
  333. est_tokens_k: (($est_tokens / 1000 * 10 | floor) / 10)
  334. }
  335. ' 2>/dev/null | if [[ "$json" == "json" ]]; then cat; else
  336. jq -r '"Token estimate: \(.est_tokens_k)k tokens\n User: \(.user_chars) chars\n Assistant: \(.assistant_chars) chars\n Thinking: \(.thinking_chars) chars"' 2>/dev/null
  337. fi
  338. }
  339. cmd_timeline() {
  340. local f="$1"
  341. cat "$f" | jq -r '
  342. select(.type == "user" or .type == "assistant") |
  343. select(.timestamp) |
  344. "\(.timestamp) \(.type | . + " " * (12 - length)) \(
  345. if .type == "user" then
  346. (.message.content[]? | select(.type == "text") | .text | .[0:80] | gsub("\n"; " ")) // "[tool_result]"
  347. else
  348. (.message.content[]? |
  349. if .type == "tool_use" then "[\(.name)] \(
  350. if .name == "Bash" then (.input.command | .[0:60])
  351. elif .name == "Read" then .input.file_path
  352. elif .name == "Edit" then .input.file_path
  353. elif .name == "Write" then .input.file_path
  354. else (.input | tostring | .[0:60])
  355. end
  356. )"
  357. elif .type == "text" then .text | .[0:80] | gsub("\n"; " ")
  358. elif .type == "thinking" then "[thinking...]"
  359. else empty
  360. end
  361. ) // ""
  362. end
  363. )"
  364. ' 2>/dev/null
  365. }
  366. cmd_summary() {
  367. local f="$1"
  368. cat "$f" | jq -r '
  369. select(.type == "summary" or (.type == "system" and .subtype == "compact_boundary")) |
  370. if .type == "summary" then
  371. "=== Summary ===\n\(.summary)\n"
  372. else
  373. "--- Compaction Boundary ---"
  374. end
  375. ' 2>/dev/null
  376. }
  377. # --- Main ---
  378. [[ $# -eq 0 ]] && usage
  379. cmd="$1"
  380. shift
  381. # Parse options
  382. session_file=""
  383. project=""
  384. recent=1
  385. output="text"
  386. search_all=false
  387. dir_pattern=""
  388. while [[ $# -gt 0 ]]; do
  389. case "$1" in
  390. --project|-p) project="$2"; shift 2 ;;
  391. --dir|-d) dir_pattern="$2"; shift 2 ;;
  392. --recent) recent="$2"; shift 2 ;;
  393. --json) output="json"; shift ;;
  394. --all) search_all=true; shift ;;
  395. --help|-h) usage ;;
  396. *)
  397. if [[ -f "$1" ]]; then
  398. session_file="$1"
  399. elif [[ "$cmd" == "search" && -z "${search_pattern:-}" ]]; then
  400. search_pattern="$1"
  401. fi
  402. shift
  403. ;;
  404. esac
  405. done
  406. # Resolve session file if not given directly
  407. if [[ -z "$session_file" ]]; then
  408. project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}")
  409. session_file=$(resolve_session "$project_dir" "$recent")
  410. [[ -n "$session_file" ]] || die "No session files found in $project_dir"
  411. fi
  412. # Execute command
  413. case "$cmd" in
  414. overview) cmd_overview "$session_file" "$output" ;;
  415. tools) cmd_tools "$session_file" "$output" ;;
  416. tool-chain) cmd_tool_chain "$session_file" "$output" ;;
  417. thinking) cmd_thinking "$session_file" ;;
  418. thinking-summary) cmd_thinking_summary "$session_file" "$output" ;;
  419. errors) cmd_errors "$session_file" "$output" ;;
  420. conversation) cmd_conversation "$session_file" ;;
  421. files) cmd_files "$session_file" "$output" ;;
  422. turns) cmd_turns "$session_file" "$output" ;;
  423. agents) cmd_agents "$session_file" "$output" ;;
  424. cost) cmd_cost "$session_file" "$output" ;;
  425. timeline) cmd_timeline "$session_file" ;;
  426. summary) cmd_summary "$session_file" ;;
  427. search)
  428. [[ -n "${search_pattern:-}" ]] || die "search requires a pattern"
  429. if [[ "$search_all" == true ]]; then
  430. for d in "$PROJECTS_DIR"/*/; do
  431. echo ">>> $(basename "$d")"
  432. cmd_search "$search_pattern" "$d"
  433. done
  434. else
  435. project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}")
  436. cmd_search "$search_pattern" "$project_dir"
  437. fi
  438. ;;
  439. *)
  440. die "Unknown command: $cmd. Run cc-session --help for usage."
  441. ;;
  442. esac