167 lines
5.1 KiB
Bash
Executable File
167 lines
5.1 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# PostToolUse Hook: Atuin Integration
|
|
# Captures Bash commands executed by Claude Code and adds them to:
|
|
# - Atuin history (with metadata tags)
|
|
# - Zsh history (fallback)
|
|
#
|
|
# Enable debug logging: export CLAUDE_ATUIN_DEBUG=1
|
|
|
|
set -euo pipefail
|
|
|
|
# Check for jq dependency
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
echo "Warning: 'jq' is not installed. Atuin hook skipping." >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Configuration
|
|
DEBUG="${CLAUDE_ATUIN_DEBUG:-0}"
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
LOG_FILE="${SCRIPT_DIR}/atuin-hook.log"
|
|
# Respect HISTFILE or fallback to standard locations
|
|
HISTORY_FILE="${HISTFILE:-${HOME}/.zsh_history}"
|
|
if [[ ! -f "$HISTORY_FILE" && -f "${HOME}/.bash_history" ]]; then
|
|
HISTORY_FILE="${HOME}/.bash_history"
|
|
fi
|
|
# Context log for git branch and session tracking
|
|
CONTEXT_FILE="${HOME}/.claude/atuin-context.jsonl"
|
|
|
|
# Helper: Log debug messages
|
|
debug_log() {
|
|
if [[ "$DEBUG" == "1" ]]; then
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
|
|
fi
|
|
}
|
|
|
|
# Helper: Log errors (always logged)
|
|
error_log() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >> "$LOG_FILE"
|
|
echo "[Atuin Hook Error] $*" >&2
|
|
}
|
|
|
|
# Helper: Write context to JSONL for later searching by git branch/session
|
|
write_context() {
|
|
local cmd="$1"
|
|
local session="$2"
|
|
local cwd="$3"
|
|
|
|
# Capture git branch (fast, fails gracefully outside git repos)
|
|
local git_branch=""
|
|
git_branch=$(git -C "$cwd" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
|
|
# Ensure context directory exists
|
|
mkdir -p "$(dirname "$CONTEXT_FILE")"
|
|
|
|
# Get ISO timestamp
|
|
local timestamp
|
|
timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
|
|
# Escape command for JSON (replace quotes and newlines)
|
|
local escaped_cmd
|
|
escaped_cmd=$(printf '%s' "$cmd" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ')
|
|
|
|
# Write JSONL entry (lightweight, no jq dependency for writing)
|
|
printf '{"ts":"%s","cmd":"%s","branch":"%s","session":"%s","cwd":"%s"}\n' \
|
|
"$timestamp" \
|
|
"$escaped_cmd" \
|
|
"$git_branch" \
|
|
"$session" \
|
|
"$cwd" \
|
|
>> "$CONTEXT_FILE"
|
|
|
|
debug_log "Wrote context: branch=$git_branch, session=$session"
|
|
}
|
|
|
|
# Main execution
|
|
main() {
|
|
debug_log "=== Hook started ==="
|
|
|
|
# Read JSON input from stdin
|
|
INPUT=$(cat)
|
|
debug_log "Received input: $INPUT"
|
|
|
|
# Parse JSON fields
|
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
|
|
EXIT_CODE=$(echo "$INPUT" | jq -r '.tool_response.exit_code // 0')
|
|
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
|
|
# Get working directory from tool input, or fall back to current directory
|
|
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
|
|
if [[ -z "$CWD" ]]; then
|
|
CWD="$(pwd)"
|
|
fi
|
|
|
|
debug_log "Tool: $TOOL_NAME, Exit: $EXIT_CODE, CWD: $CWD"
|
|
|
|
# Filter: Only process Bash tool calls
|
|
if [[ "$TOOL_NAME" != "Bash" ]]; then
|
|
debug_log "Skipping non-Bash tool: $TOOL_NAME"
|
|
exit 0
|
|
fi
|
|
|
|
# Append to history file if it exists
|
|
if [[ -f "$HISTORY_FILE" ]]; then
|
|
# Format depends on shell, simple append for now
|
|
echo "$COMMAND" >> "$HISTORY_FILE"
|
|
fi
|
|
|
|
# Validate command exists
|
|
if [[ -z "$COMMAND" ]]; then
|
|
debug_log "No command found in tool_input"
|
|
exit 0
|
|
fi
|
|
|
|
debug_log "Processing command: $COMMAND"
|
|
|
|
# Use atuin's native history tracking for rich metadata
|
|
# This captures: command, exit code, duration, timestamp, cwd, hostname
|
|
|
|
# Helper function to add to shell history as fallback
|
|
add_to_shell_history() {
|
|
local timestamp
|
|
timestamp=$(date +%s)
|
|
if [[ -f "$HISTORY_FILE" ]]; then
|
|
local escaped_cmd
|
|
escaped_cmd=$(echo "$COMMAND" | tr '\n' ';' | sed 's/\\/\\\\/g')
|
|
echo ": ${timestamp}:0;${escaped_cmd}" >> "$HISTORY_FILE"
|
|
debug_log "Fallback: Added to shell history"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Step 1: Start history entry and capture the ID
|
|
# Wrap in conditional to handle atuin not being available
|
|
HISTORY_ID=""
|
|
if ! HISTORY_ID=$(atuin history start -- "$COMMAND" 2>&1); then
|
|
debug_log "Atuin history start failed, using fallback"
|
|
add_to_shell_history
|
|
# Still write context even when using fallback
|
|
write_context "$COMMAND" "$SESSION_ID" "$CWD"
|
|
exit 0
|
|
fi
|
|
debug_log "Started atuin history entry: $HISTORY_ID"
|
|
|
|
# Step 2: End history entry with exit code and duration
|
|
# Duration is set to 0 since we don't track execution time in post-hook
|
|
if atuin history end --exit "$EXIT_CODE" --duration 0 "$HISTORY_ID" 2>&1; then
|
|
debug_log "Added to atuin with exit code: $EXIT_CODE"
|
|
else
|
|
error_log "Failed to end atuin history entry"
|
|
add_to_shell_history
|
|
fi
|
|
|
|
# Step 3: Write context (git branch, session ID) for later searching
|
|
write_context "$COMMAND" "$SESSION_ID" "$CWD"
|
|
|
|
debug_log "=== Hook completed successfully ==="
|
|
exit 0
|
|
}
|
|
|
|
# Run main function with error handling
|
|
if ! main; then
|
|
error_log "Hook execution failed"
|
|
exit 0 # Always exit 0 to not block Claude Code
|
|
fi
|