|
#!/bin/bash |
|
|
|
# claude-tail - Show assistant messages from a Claude Code session |
|
# Usage: claude-tail [uuid] [options] |
|
# uuid: session UUID (or partial match), can also be piped via stdin |
|
# |
|
# By default, follows the session (like tail -f). Use Ctrl-C to stop. |
|
|
|
uuid="" |
|
follow=true |
|
lines="" |
|
show_tools=false |
|
only_tools=false |
|
show_tools_output=false |
|
use_glow=false |
|
show_timestamps=false |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case $1 in |
|
-f|--follow) |
|
follow=true |
|
shift |
|
;; |
|
--no-follow) |
|
follow=false |
|
shift |
|
;; |
|
-n|--lines) |
|
lines="$2" |
|
shift 2 |
|
;; |
|
-t|--show-tools) |
|
show_tools=true |
|
shift |
|
;; |
|
-T|--only-tools) |
|
only_tools=true |
|
show_tools=true |
|
shift |
|
;; |
|
-o|--show-tools-output) |
|
show_tools_output=true |
|
shift |
|
;; |
|
--glow) |
|
use_glow=true |
|
shift |
|
;; |
|
--show-timestamps) |
|
show_timestamps=true |
|
shift |
|
;; |
|
-h|--help) |
|
echo "Usage: claude-tail [uuid] [options]" |
|
echo "" |
|
echo "Options:" |
|
echo " -f, --follow Follow output (default)" |
|
echo " --no-follow Don't follow, just output once" |
|
echo " -n, --lines N Only show last N lines" |
|
echo " -t, --show-tools Show tool/command calls" |
|
echo " -T, --only-tools Only show tool calls (no text)" |
|
echo " -o, --show-tools-output Show tool output/results" |
|
echo " --glow Render output through glow (markdown)" |
|
echo " --show-timestamps Show 12-hour time before messages" |
|
echo " -h, --help Show this help" |
|
echo "" |
|
echo "Examples:" |
|
echo " claude-tail c72bd55a # Follow session (partial UUID)" |
|
echo " claude-tail --no-follow # One-shot output" |
|
echo " claude-active | claude-tail # Pick session then tail" |
|
echo " claude-tail c72bd55a -n 20 # Last 20 lines, then follow" |
|
echo " claude-tail c72bd55a -t # Include tool calls" |
|
echo " claude-tail c72bd55a -T # Only tool calls" |
|
exit 0 |
|
;; |
|
*) |
|
if [[ -z "$uuid" ]]; then |
|
uuid="$1" |
|
fi |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
# Read UUID from stdin if not provided and stdin is not a terminal |
|
if [[ -z "$uuid" ]] && [[ ! -t 0 ]]; then |
|
read -r input |
|
# Extract UUID from piped input (handles both raw UUID and formatted line) |
|
# Format: "11:28 | c9ef7a33-... | cli | message" |
|
if [[ "$input" == *"|"* ]]; then |
|
uuid=$(echo "$input" | awk -F'|' '{print $2}' | tr -d ' ') |
|
else |
|
uuid=$(echo "$input" | tr -d ' ') |
|
fi |
|
fi |
|
|
|
if [[ -z "$uuid" ]]; then |
|
echo "Usage: claude-tail <uuid> [options]" |
|
echo "" |
|
echo "Find active sessions with: claude-active" |
|
exit 1 |
|
fi |
|
|
|
# Find session file (supports partial UUID match) |
|
find_session() { |
|
find ~/.claude/projects -name "${uuid}*.jsonl" 2>/dev/null | grep -v subagents | head -1 |
|
} |
|
|
|
session=$(find_session) |
|
|
|
if [[ -z "$session" ]]; then |
|
echo "Session not found: $uuid" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo "Tailing session: $(basename "$session" .jsonl)" >&2 |
|
echo "Press Ctrl-C to stop" >&2 |
|
echo "---" >&2 |
|
|
|
# Function to extract assistant text (with blank line between messages) |
|
extract_messages() { |
|
local sep=$'\n' |
|
local username=$(whoami) |
|
|
|
local time_fmt=' |
|
def format_time: |
|
if . then |
|
(sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601 | localtime) as $t | |
|
($t[3]) as $h | |
|
($t[4] | tostring | if length == 1 then "0" + . else . end) as $m | |
|
(if $h == 0 then 12 elif $h > 12 then $h - 12 else $h end) as $h12 | |
|
"\($h12):\($m) " |
|
else "" end; |
|
' |
|
|
|
local tool_fmt=' |
|
"→ " + .name + ( |
|
if .name == "Bash" then ":\n " + (.input.command // "" | gsub("\n"; "\n ")) |
|
elif .name == "Read" then ": " + (.input.file_path // "") |
|
elif .name == "Edit" then ": " + (.input.file_path // "") |
|
elif .name == "Write" then ": " + (.input.file_path // "") |
|
elif .name == "Grep" then "(" + (["pattern: \(.input.pattern // "" | tojson)", (if .input.path then "path: \(.input.path | tojson)" else null end), (if .input.output_mode then "output_mode: \(.input.output_mode | tojson)" else null end)] | map(select(. != null)) | join(", ")) + ")" |
|
elif .name == "Glob" then "(" + (["pattern: \(.input.pattern // "" | tojson)", (if .input.path then "path: \(.input.path | tojson)" else null end)] | map(select(. != null)) | join(", ")) + ")" |
|
elif .name == "Task" then ": " + (.input.description // "") + " [" + (.input.subagent_type // "agent") + "]" |
|
elif .name == "WebSearch" then "(" + (.input.query // "" | tojson) + ")" |
|
elif .name == "WebFetch" then ": " + (.input.url // "") |
|
elif .name == "LSP" then "(" + (.input.operation // "") + " at " + (.input.filePath // "") + ":" + ((.input.line // 0) | tostring) + ")" |
|
elif .name == "AskUserQuestion" then "" |
|
else ": " + (.input | tostring | .[0:100]) |
|
end |
|
) |
|
' |
|
local result_fmt='" ⎿ " + (if (.content | type) == "string" then .content else (.content[]? | select(.type == "text") | .text) // "" end | gsub("\n"; "\n "))' |
|
|
|
if [[ "$show_tools_output" == true ]] && [[ "$only_tools" == true ]]; then |
|
# Only tools + their output |
|
jq -r " |
|
if .type == \"assistant\" then |
|
.message.content[] | if .type == \"tool_use\" then $tool_fmt else empty end |
|
elif .type == \"user\" then |
|
.message.content[] | if .type == \"tool_result\" then $result_fmt else empty end |
|
else empty end |
|
" "$session" 2>/dev/null |
|
elif [[ "$show_tools_output" == true ]] && [[ "$show_tools" == true ]]; then |
|
# Text + tools + output |
|
jq -r --arg sep "$sep" " |
|
if .type == \"assistant\" then |
|
.message.content[] | |
|
if .type == \"text\" then .text |
|
elif .type == \"tool_use\" then $tool_fmt |
|
else empty end |
|
elif .type == \"user\" then |
|
.message.content[] | if .type == \"tool_result\" then $result_fmt else empty end |
|
else empty end |
|
" "$session" 2>/dev/null |
|
elif [[ "$show_tools_output" == true ]]; then |
|
# Only tool output (no tool calls shown) |
|
jq -r --arg sep "$sep" ' |
|
select(.type == "user") | .message.content[] | |
|
if .type == "tool_result" then |
|
if (.content | type) == "string" then .content |
|
else (.content[]? | select(.type == "text") | .text) // "" end |
|
else empty end |
|
' "$session" 2>/dev/null |
|
elif [[ "$only_tools" == true ]]; then |
|
jq -r "select(.type == \"assistant\") | .message.content[] | |
|
if .type == \"tool_use\" then $tool_fmt |
|
else empty end" "$session" 2>/dev/null |
|
elif [[ "$show_tools" == true ]]; then |
|
local ts_prefix="" |
|
local ts_prefix_asst="" |
|
if [[ "$show_timestamps" == true ]]; then |
|
ts_prefix="(.timestamp | format_time) + " |
|
ts_prefix_asst="(\$ts | format_time) + " |
|
fi |
|
|
|
jq -r --arg sep "$sep" --arg user "$username" " |
|
$time_fmt |
|
if .type == \"user\" then |
|
if (.message.content | type) == \"string\" then |
|
(if \$sep != \"\" then \$sep + \"\n\n\" else \"\" end) + ${ts_prefix}\"❯ \" + .message.content |
|
elif (.message.content | map(select(.type == \"text\")) | length) > 0 then |
|
(if \$sep != \"\" then \$sep + \"\n\n\" else \"\" end) + ${ts_prefix}\"❯ \" + (.message.content[] | select(.type == \"text\") | .text) |
|
else empty end |
|
elif .type == \"assistant\" then |
|
.timestamp as \$ts | |
|
.message.content[] | |
|
if .type == \"text\" then (if \$sep != \"\" then \$sep + \"\n\n\" else \"\" end) + ${ts_prefix_asst}.text |
|
elif .type == \"tool_use\" then $tool_fmt |
|
else empty end |
|
else empty end" "$session" 2>/dev/null |
|
else |
|
local ts_prefix="" |
|
local ts_prefix_asst="" |
|
if [[ "$show_timestamps" == true ]]; then |
|
ts_prefix="(.timestamp | format_time) + " |
|
ts_prefix_asst="(\$ts | format_time) + " |
|
fi |
|
|
|
jq -r --arg sep "$sep" --arg user "$username" " |
|
$time_fmt |
|
if .type == \"user\" then |
|
if (.message.content | type) == \"string\" then |
|
(if \$sep != \"\" then \$sep + \"\n\n\" else \"\" end) + ${ts_prefix}\"❯ \" + .message.content |
|
elif (.message.content | map(select(.type == \"text\")) | length) > 0 then |
|
(if \$sep != \"\" then \$sep + \"\n\n\" else \"\" end) + ${ts_prefix}\"❯ \" + (.message.content[] | select(.type == \"text\") | .text) |
|
else empty end |
|
elif .type == \"assistant\" then |
|
.timestamp as \$ts | |
|
.message.content[] | select(.type == \"text\") | |
|
(if \$sep != \"\" then \$sep + \"\n\n\" else \"\" end) + ${ts_prefix_asst}.text |
|
else empty end |
|
" "$session" 2>/dev/null |
|
fi |
|
} |
|
|
|
if [[ "$use_glow" == true ]] && ! command -v glow &>/dev/null; then |
|
echo "Error: glow is not installed. Install with: brew install glow" >&2 |
|
exit 1 |
|
fi |
|
|
|
if [[ "$follow" == true ]]; then |
|
# Track what we've already shown |
|
last_count=0 |
|
|
|
while true; do |
|
current=$(extract_messages) |
|
current_count=$(echo "$current" | wc -l) |
|
|
|
if [[ "$current_count" -gt "$last_count" ]]; then |
|
if [[ "$use_glow" == true ]]; then |
|
# Clear screen and re-render full output through glow |
|
clear |
|
if [[ -n "$lines" ]]; then |
|
echo "$current" | tail -"$lines" | cat -s | glow -w 0 - |
|
else |
|
echo "$current" | cat -s | glow -w 0 - |
|
fi |
|
else |
|
if [[ "$last_count" -eq 0 ]]; then |
|
if [[ -n "$lines" ]]; then |
|
echo "$current" | tail -"$lines" |
|
else |
|
echo "$current" |
|
fi |
|
else |
|
# Show only new lines |
|
echo "$current" | tail -n +$((last_count + 1)) |
|
fi |
|
fi |
|
last_count=$current_count |
|
fi |
|
|
|
sleep 1 |
|
done |
|
else |
|
output=$(extract_messages) |
|
if [[ -n "$lines" ]]; then |
|
if [[ "$use_glow" == true ]]; then |
|
echo "$output" | tail -"$lines" | cat -s | glow -w 0 - |
|
else |
|
echo "$output" | tail -"$lines" |
|
fi |
|
else |
|
if [[ "$use_glow" == true ]]; then |
|
echo "$output" | cat -s | glow -w 0 - |
|
else |
|
echo "$output" |
|
fi |
|
fi |
|
fi |