|
#!/usr/bin/env bash |
|
# ============================================================================== |
|
# claude_mem_search.sh |
|
# ============================================================================== |
|
# Dynamic interactive search across Claude memory markdown files using fzf + ripgrep. |
|
# |
|
# Features: |
|
# 1. Automatic discovery of memory directories (~/.claude/**/memory/). |
|
# 2. Dynamic ripgrep search triggered only when query length >= 3 characters. |
|
# 3. Instant clean screen when query length < 3 characters. |
|
# 4. Rich preview pane with syntax highlighting (`bat` with line highlighting). |
|
# 5. Extensible output / selection hook (defaults to printing to stdout). |
|
# |
|
# Usage: |
|
# ./claude_mem_search.sh # Launch interactive fzf search |
|
# ./claude_mem_search.sh --help # Show usage & configuration |
|
# ============================================================================== |
|
# Customize minimum query length to 2 characters |
|
# ./claude_mem_search.sh --min-len 2 |
|
# |
|
# Enable regex search mode instead of literal fixed-strings |
|
# ./claude_mem_search.sh --mode regex |
|
# |
|
# See usage options |
|
# ./claude_mem_search.sh --help |
|
# ============================================================================== |
|
|
|
set -euo pipefail |
|
|
|
# ------------------------------------------------------------------------------ |
|
# Configuration & Defaults |
|
# ------------------------------------------------------------------------------ |
|
SCRIPT_PATH="$(readlink -f "$0")" |
|
SEARCH_MIN_LEN="${SEARCH_MIN_LEN:-3}" # Minimum query length to start search |
|
CLAUDE_DIR="${CLAUDE_DIR:-"$HOME/.claude"}" # Target base directory |
|
SEARCH_MODE="${SEARCH_MODE:-fixed}" # 'fixed' (literal) or 'regex' |
|
CASE_MODE="${CASE_MODE:-smart}" # 'smart', 'ignore', or 'sensitive' |
|
|
|
# ------------------------------------------------------------------------------ |
|
# Helper Functions |
|
# ------------------------------------------------------------------------------ |
|
|
|
# Display usage help |
|
show_help() { |
|
cat << EOF |
|
Usage: $(basename "$0") [OPTIONS] |
|
|
|
Interactive search across Claude memory markdown files (~/.claude/**/memory/*.md). |
|
|
|
Options: |
|
-m, --min-len NUM Minimum characters required to trigger search (default: $SEARCH_MIN_LEN) |
|
--mode MODE Search mode: 'fixed' (literal text, default) or 'regex' |
|
--case CASE Case sensitivity: 'smart' (default), 'ignore', or 'sensitive' |
|
-h, --help Display this help message and exit |
|
|
|
Environment Variables: |
|
SEARCH_MIN_LEN Default minimum query length ($SEARCH_MIN_LEN) |
|
CLAUDE_DIR Target Claude directory ($CLAUDE_DIR) |
|
EDITOR / VISUAL Default editor if extending selection action |
|
|
|
EOF |
|
} |
|
|
|
# Discover memory directories under ~/.claude |
|
discover_memory_dirs() { |
|
if [[ ! -d "$CLAUDE_DIR" ]]; then |
|
echo "Error: Directory '$CLAUDE_DIR' does not exist." >&2 |
|
return 1 |
|
fi |
|
|
|
mapfile -t MEM_DIRS < <(fd --hidden --no-ignore --ignore-case --type=directory memory "$CLAUDE_DIR" 2>/dev/null || true) |
|
|
|
if [[ ${#MEM_DIRS[@]} -eq 0 ]]; then |
|
echo "Error: No 'memory' directories found under '$CLAUDE_DIR'." >&2 |
|
return 1 |
|
fi |
|
} |
|
|
|
# Perform ripgrep search across discovered memory directories |
|
search_memories() { |
|
local query="$1" |
|
|
|
# Stop search and clear list if query length is less than SEARCH_MIN_LEN |
|
if [[ ${#query} -lt "$SEARCH_MIN_LEN" ]]; then |
|
return 0 |
|
fi |
|
|
|
# Build ripgrep options |
|
local rg_opts=( |
|
-g '*.md' |
|
--line-number |
|
--column |
|
--max-columns 150 |
|
--max-columns-preview |
|
--color=always |
|
) |
|
|
|
# Case sensitivity flags |
|
case "$CASE_MODE" in |
|
ignore) rg_opts+=(--ignore-case) ;; |
|
sensitive) rg_opts+=(--case-sensitive) ;; |
|
smart|*) rg_opts+=(--smart-case) ;; |
|
esac |
|
|
|
# Search mode flags |
|
if [[ "$SEARCH_MODE" == "fixed" ]]; then |
|
rg_opts+=(--fixed-strings) |
|
fi |
|
|
|
# Execute ripgrep across discovered memory directories |
|
rg "${rg_opts[@]}" -- "$query" "${MEM_DIRS[@]}" 2>/dev/null || true |
|
} |
|
|
|
# Render file preview in fzf |
|
preview_match() { |
|
local file_path raw_line |
|
# Strip ANSI color codes if present |
|
file_path=$(echo "${1:-}" | sed -r 's/\x1b\[[0-9;]*[a-zA-Z]//g') |
|
raw_line=$(echo "${2:-1}" | sed -r 's/\x1b\[[0-9;]*[a-zA-Z]//g') |
|
local line_number="${raw_line:-1}" |
|
|
|
if [[ -z "$file_path" || ! -f "$file_path" ]]; then |
|
echo "No file selected." |
|
return 0 |
|
fi |
|
|
|
# Use `bat` for syntax-highlighted preview if available |
|
if command -v bat &>/dev/null; then |
|
local start_line=$(( line_number > 10 ? line_number - 10 : 1 )) |
|
local end_line=$(( line_number + 20 )) |
|
bat --style=full \ |
|
--color=always \ |
|
--highlight-line "$line_number" \ |
|
--line-range "${start_line}:${end_line}" \ |
|
"$file_path" |
|
else |
|
# Fallback to cat with line context |
|
echo "=== $file_path (Line $line_number) ===" |
|
sed -n "$(( line_number > 10 ? line_number - 10 : 1 )),$(( line_number + 20 ))p" "$file_path" |
|
fi |
|
} |
|
|
|
# Selection action hook: executed when user selects a match in fzf and presses Enter |
|
on_select_match() { |
|
local selection="${1:-}" |
|
|
|
if [[ -z "$selection" ]]; then |
|
return 0 |
|
fi |
|
|
|
# DEFAULT ACTION: Print selected line to STDOUT |
|
echo "$selection" |
|
|
|
# -------------------------------------------------------------------------- |
|
# CUSTOMIZATION HOOK: |
|
# To open the selected match directly in an editor, uncomment one of below: |
|
# -------------------------------------------------------------------------- |
|
# IFS=':' read -r file line col _ <<< "$selection" |
|
# ${EDITOR:-nvim} "+${line}" "$file" |
|
# code -g "${file}:${line}:${col}" |
|
} |
|
|
|
# Main interactive fzf loop |
|
run_interactive_fzf() { |
|
discover_memory_dirs |
|
|
|
# Export functions and variables for fzf callbacks |
|
export SCRIPT_PATH CLAUDE_DIR SEARCH_MIN_LEN SEARCH_MODE CASE_MODE |
|
export -f discover_memory_dirs search_memories preview_match |
|
export MEM_DIRS_STR="${MEM_DIRS[*]}" |
|
|
|
# Launch fzf in dynamic mode |
|
local selected |
|
selected=$(fzf \ |
|
--disabled \ |
|
--ansi \ |
|
--delimiter : \ |
|
--header "🔍 Minimum $SEARCH_MIN_LEN characters required to search. [Esc: Exit | Enter: Select]" \ |
|
--prompt "Claude Memory Search > " \ |
|
--pointer "▶ " \ |
|
--bind "start:reload:$SCRIPT_PATH --internal-search {q}" \ |
|
--bind "change:reload:$SCRIPT_PATH --internal-search {q}" \ |
|
--preview "$SCRIPT_PATH --internal-preview {1} {2}" \ |
|
--preview-window "right:55%:wrap" \ |
|
--color "hl:green,hl+:green:bold,fg+:white:bold,bg+:black" \ |
|
--layout=reverse \ |
|
|| true) |
|
|
|
if [[ -n "$selected" ]]; then |
|
on_select_match "$selected" |
|
fi |
|
} |
|
|
|
# ------------------------------------------------------------------------------ |
|
# Internal Callbacks & Subcommand Dispatcher |
|
# ------------------------------------------------------------------------------ |
|
|
|
# Callback for fzf --bind change:reload |
|
if [[ "${1:-}" == "--internal-search" ]]; then |
|
shift |
|
discover_memory_dirs |
|
search_memories "${1:-}" |
|
exit 0 |
|
fi |
|
|
|
# Callback for fzf --preview |
|
if [[ "${1:-}" == "--internal-preview" ]]; then |
|
shift |
|
preview_match "${1:-}" "${2:-1}" |
|
exit 0 |
|
fi |
|
|
|
# ------------------------------------------------------------------------------ |
|
# CLI Argument Parsing |
|
# ------------------------------------------------------------------------------ |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
-m|--min-len) |
|
SEARCH_MIN_LEN="$2" |
|
shift 2 |
|
;; |
|
--mode) |
|
SEARCH_MODE="$2" |
|
shift 2 |
|
;; |
|
--case) |
|
CASE_MODE="$2" |
|
shift 2 |
|
;; |
|
-h|--help) |
|
show_help |
|
exit 0 |
|
;; |
|
*) |
|
echo "Unknown option: $1" >&2 |
|
show_help |
|
exit 1 |
|
;; |
|
esac |
|
done |
|
|
|
# ------------------------------------------------------------------------------ |
|
# Entry Point |
|
# ------------------------------------------------------------------------------ |
|
run_interactive_fzf |