Skip to content

Instantly share code, notes, and snippets.

@jayjongcheolpark
Last active January 26, 2026 01:00
Show Gist options
  • Select an option

  • Save jayjongcheolpark/ec85e6cf3843784a1e13994b2891a552 to your computer and use it in GitHub Desktop.

Select an option

Save jayjongcheolpark/ec85e6cf3843784a1e13994b2891a552 to your computer and use it in GitHub Desktop.
~/.claude/hooks/claude-obsidian-sync.sh
#!/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
@jayjongcheolpark
Copy link
Copy Markdown
Author

~/Library/LaunchAgents/com.claude.obsidian-sync.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.claude.obsidian-sync</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/jaypark/.claude/hooks/claude-obsidian-sync.sh</string>
    </array>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>/Users/jaypark/.claude/logs/obsidian-sync.log</string>

    <key>StandardErrorPath</key>
    <string>/Users/jaypark/.claude/logs/obsidian-sync.error.log</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
    </dict>
</dict>
</plist>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment