Last active
January 26, 2026 01:00
-
-
Save jayjongcheolpark/ec85e6cf3843784a1e13994b2891a552 to your computer and use it in GitHub Desktop.
~/.claude/hooks/claude-obsidian-sync.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Watch Claude Code session and sync to Obsidian in real-time (append mode) | |
| # Names files by project folder name | |
| # ============================================================================== | |
| # CONFIGURATION - Customize these values for your environment | |
| # ============================================================================== | |
| # Obsidian vault path (iCloud) | |
| OBSIDIAN_DIR="$HOME/Library/Mobile Documents/com~apple~CloudDocs/Obsidian/vault/claude" | |
| # Claude projects directory | |
| CLAUDE_PROJECTS_DIR="$HOME/.claude/projects" | |
| # Track last synced position per session | |
| LAST_LINE_DIR="$HOME/.claude/sync-state" | |
| # Base directory for project path resolution (e.g., $HOME/Developer) | |
| PROJECT_BASE_DIR="$HOME/Developer" | |
| # Timezone offset for "today" calculation (hours) | |
| # Auto-detected from system timezone, or set manually if needed (e.g., "-5" for EST, "-4" for EDT) | |
| # To override auto-detection, uncomment and set: TIMEZONE_OFFSET_HOURS="-5" | |
| # TIMEZONE_OFFSET_HOURS="-5" | |
| # Session detection settings | |
| SESSION_MAX_AGE_MINUTES=60 # Only consider sessions modified within this time | |
| SESSION_MIN_SIZE_BYTES=1000 # Minimum file size to consider | |
| # Watch loop interval (seconds) | |
| WATCH_INTERVAL=5 | |
| # ============================================================================== | |
| # END CONFIGURATION | |
| # -============================================================================= | |
| # Auto-detect timezone offset if not manually set | |
| if [ -z "${TIMEZONE_OFFSET_HOURS:-}" ]; then | |
| # Get timezone offset in format +0900 or -0500 | |
| local_tz_offset=$(date +%z) | |
| # Extract sign and hours (e.g., +0900 -> +09, -0500 -> -05) | |
| tz_sign="${local_tz_offset:0:1}" | |
| tz_hours="${local_tz_offset:1:2}" | |
| # For date -v command, we need opposite sign (UTC to local conversion) | |
| if [ "$tz_sign" = "+" ]; then | |
| TIMEZONE_OFFSET_HOURS="-$tz_hours" | |
| else | |
| # Remove leading zero and add + sign | |
| tz_hours_clean="${tz_hours#0}" | |
| TIMEZONE_OFFSET_HOURS="+${tz_hours_clean:-0}" | |
| fi | |
| fi | |
| mkdir -p "$OBSIDIAN_DIR" | |
| mkdir -p "$LAST_LINE_DIR" | |
| # Find actual folder name by intelligently parsing Claude project name | |
| # e.g., "-Users-<username>-Developer-<myproject>-frontend-20260122" | |
| # -> "frontend-20260122" | |
| get_project_folder_name() { | |
| local project_name="$1" | |
| # Extract base directory name from PROJECT_BASE_DIR (e.g., "Developer" from "$HOME/Developer") | |
| local base_dir_name=$(basename "$PROJECT_BASE_DIR") | |
| # Extract the part after the base directory name (e.g., -Developer-) | |
| local after_base=$(echo "$project_name" | sed -E "s/^.*-${base_dir_name}-//") | |
| # If base directory name not found in path, try after username | |
| if [ "$after_base" = "$project_name" ]; then | |
| after_base=$(echo "$project_name" | sed -E 's/^-Users-[^-]+-//') | |
| fi | |
| # Split into segments | |
| local IFS='-' | |
| local segments=($after_base) | |
| local base="$PROJECT_BASE_DIR" | |
| local current_path="$base" | |
| local i=0 | |
| local num_segments=${#segments[@]} | |
| # Walk through segments, trying to build the path | |
| while [ $i -lt $num_segments ]; do | |
| local found=0 | |
| # Try progressively longer combinations starting at position i | |
| local j=$i | |
| local accumulated="${segments[$j]}" | |
| while [ $j -lt $num_segments ]; do | |
| local try_path="$current_path/$accumulated" | |
| if [ -d "$try_path" ]; then | |
| current_path="$try_path" | |
| i=$((j + 1)) | |
| found=1 | |
| break | |
| fi | |
| # Try adding next segment with dash | |
| j=$((j + 1)) | |
| if [ $j -lt $num_segments ]; then | |
| accumulated="$accumulated-${segments[$j]}" | |
| fi | |
| done | |
| # If no match found at all, we've gone as far as we can | |
| if [ $found -eq 0 ]; then | |
| break | |
| fi | |
| done | |
| # Return the basename of the deepest directory we found | |
| if [ "$current_path" != "$base" ] && [ -d "$current_path" ]; then | |
| basename "$current_path" | |
| else | |
| # Fallback: use last 2 dash-separated segments | |
| echo "$after_base" | rev | cut -d'-' -f1-2 | rev | |
| fi | |
| } | |
| sync_session() { | |
| local session_file="$1" | |
| local project_name="$2" | |
| local TODAY=$(date +%Y-%m-%d) | |
| local TODAY_START_UTC=$(TZ=UTC date -v${TIMEZONE_OFFSET_HOURS}H -j -f "%Y-%m-%d %H:%M:%S" "$(date +%Y-%m-%d) 00:00:00" +%Y-%m-%dT%H:%M:%S 2>/dev/null) | |
| # Get project folder name for filename | |
| local folder_name=$(get_project_folder_name "$project_name") | |
| local OUTPUT_FILE="${OBSIDIAN_DIR}/${TODAY}-${folder_name}.md" | |
| # Use session file path hash for tracking state (use shasum for compatibility) | |
| local SESSION_HASH=$(echo "$session_file" | /usr/bin/shasum | cut -d' ' -f1) | |
| local LAST_LINE_FILE="${LAST_LINE_DIR}/${SESSION_HASH}" | |
| # Get last synced line number | |
| local last_line=0 | |
| if [ -f "$LAST_LINE_FILE" ]; then | |
| last_line=$(cat "$LAST_LINE_FILE" 2>/dev/null || echo 0) | |
| fi | |
| # Count current lines in session file | |
| local current_lines=$(wc -l < "$session_file" | tr -d ' ') | |
| # Only process new lines (append mode - no overwriting) | |
| if [ "$current_lines" -gt "$last_line" ]; then | |
| local new_content=$(tail -n +$((last_line + 1)) "$session_file" | jq -r --arg today_start "$TODAY_START_UTC" ' | |
| select(.type == "user" or .type == "assistant") | | |
| select((.timestamp // "9999") >= $today_start) | | |
| if .type == "user" then | |
| (.message.content // .content // "") as $content | | |
| if ($content | type) == "string" then | |
| if ($content | test("<local-command|<command-name>|<system-reminder>|<task-notification>"; "i")) then | |
| empty | |
| else | |
| "**User**: " + $content | |
| end | |
| else | |
| empty | |
| end | |
| elif .type == "assistant" then | |
| if (.message.content | type) == "array" then | |
| (.message.content[] | select(.type == "text") | | |
| if (.text | test("^No response requested"; "i")) then | |
| empty | |
| else | |
| "**Claude**: " + .text | |
| end | |
| ) | |
| else | |
| empty | |
| end | |
| else | |
| empty | |
| end | |
| ' 2>/dev/null) | |
| # Only create/append file if there's actual content | |
| if [ -n "$new_content" ]; then | |
| echo "$new_content" >> "$OUTPUT_FILE" | |
| echo "" >> "$OUTPUT_FILE" | |
| # Update last synced line only when content was written | |
| echo "$current_lines" > "$LAST_LINE_FILE" | |
| fi | |
| fi | |
| } | |
| # Find most recent active session | |
| find_session() { | |
| local latest_session="" | |
| local latest_project="" | |
| local latest_mtime=0 | |
| for project_dir in "$CLAUDE_PROJECTS_DIR"/-*; do | |
| [ -d "$project_dir" ] || continue | |
| local project_name=$(basename "$project_dir") | |
| # Find most recent session file in this project (exclude subagents) | |
| local session=$(find "$project_dir" -path "*/subagents/*" -prune -o \ | |
| -name "*.jsonl" -type f -mmin -${SESSION_MAX_AGE_MINUTES} -size +${SESSION_MIN_SIZE_BYTES}c -print \ | |
| 2>/dev/null | xargs ls -t 2>/dev/null | head -1) | |
| if [ -n "$session" ]; then | |
| local mtime=$(stat -f %m "$session" 2>/dev/null || echo 0) | |
| if [ "$mtime" -gt "$latest_mtime" ]; then | |
| latest_mtime=$mtime | |
| latest_session=$session | |
| latest_project=$project_name | |
| fi | |
| fi | |
| done | |
| # Return both session and project name (pipe-separated) | |
| if [ -n "$latest_session" ]; then | |
| echo "$latest_session|$latest_project" | |
| fi | |
| } | |
| echo "Watching for Claude session changes..." | |
| echo "Obsidian: $OBSIDIAN_DIR" | |
| while true; do | |
| result=$(find_session) | |
| if [ -n "$result" ]; then | |
| SESSION=$(echo "$result" | cut -d'|' -f1) | |
| PROJECT=$(echo "$result" | cut -d'|' -f2) | |
| sync_session "$SESSION" "$PROJECT" | |
| fi | |
| sleep $WATCH_INTERVAL | |
| done |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
~/Library/LaunchAgents/com.claude.obsidian-sync.plist