#!/usr/bin/env bash # # Deterministic project-root resolver for all humanize hooks and scripts. # # Resolution priority: # 1. CLAUDE_PROJECT_DIR (set by Claude Code, stable across `cd` within a session) # 2. git rev-parse --show-toplevel (nearest enclosing repo) # 3. Non-zero return. # # pwd is intentionally NOT used as a fallback: it drifts with `cd` # invocations during a session and silently causes state.md lookups # under .humanize/rlcr/ to miss the active loop directory. # # The resolved path is passed through realpath so symlinked prefixes # (e.g. /Users/x vs /private/Users/x on macOS, or /var vs /private/var) # do not diverge between setup-time and hook-time resolution. # # Path-comparison sites in validators must mirror this by canonicalizing # the user-provided side as well; use the companion `canonicalize_path` # helper below. # if [[ -n "${_HUMANIZE_PROJECT_ROOT_SOURCED:-}" ]]; then return 0 2>/dev/null || true fi _HUMANIZE_PROJECT_ROOT_SOURCED=1 # resolve_project_root # # Prints the resolved project root to stdout. Returns 0 on success, # 1 when neither CLAUDE_PROJECT_DIR nor a git toplevel is available. # # Callers that must have a project root should handle the failure: # # PROJECT_ROOT="$(resolve_project_root)" || exit 0 # hook: allow natural stop # PROJECT_ROOT="$(resolve_project_root)" || { # setup: hard error # echo "Error: cannot determine humanize project root" >&2 # exit 1 # } # resolve_project_root() { local root="${CLAUDE_PROJECT_DIR:-}" if [[ -z "$root" ]]; then root="$(git rev-parse --show-toplevel 2>/dev/null || true)" fi if [[ -z "$root" ]]; then return 1 fi local canonical canonical=$(canonicalize_path "$root") printf '%s\n' "${canonical:-$root}" } # canonicalize_path_prefix # # Resolves symlinks ONLY in the parent directory and reattaches the # original basename verbatim. This is the right helper for comparing # user-supplied filenames against an expected path inside a known # directory: a symlink at /tmp/alias pointing at /real/loop/state.md # MUST NOT canonicalize to /real/loop/state.md for comparison purposes, # because `mv` operates on the link path itself. Resolving only the # parent still lets a symlinked project prefix (e.g. /var vs /private/var # on macOS) match a canonical expected path. # # If realpath on the parent fails, falls back to returning the input # path unchanged (prefix cannot be canonicalized -> caller's comparison # will correctly fail against a canonical expected path). # # Empty input prints nothing and returns 0. # canonicalize_path_prefix() { local path="$1" if [[ -z "$path" ]]; then return 0 fi local parent base parent_real parent=$(dirname -- "$path") base=$(basename -- "$path") if parent_real=$(realpath "$parent" 2>/dev/null) && [[ -n "$parent_real" ]]; then printf '%s/%s\n' "${parent_real%/}" "$base" return 0 fi if command -v python3 >/dev/null 2>&1; then parent_real=$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$parent" 2>/dev/null || true) if [[ -n "$parent_real" ]]; then printf '%s/%s\n' "${parent_real%/}" "$base" return 0 fi fi printf '%s\n' "$path" } # canonicalize_path # # Prints the realpath of the input path. If the path itself does not # exist yet (common for write validation before the file is created), # canonicalizes the parent directory and reattaches the basename. # If realpath is unavailable and python3 is missing, prints the input # path verbatim. # # SECURITY NOTE: This helper dereferences symlinks at the leaf when # the leaf exists. Do NOT use it to authorize a user-supplied path # against an expected filename -- use canonicalize_path_prefix instead, # which only resolves the parent. # # Empty input prints nothing and returns 0. # canonicalize_path() { local path="$1" if [[ -z "$path" ]]; then return 0 fi local canonical="" if canonical=$(realpath "$path" 2>/dev/null) && [[ -n "$canonical" ]]; then printf '%s\n' "$canonical" return 0 fi # Path does not exist: canonicalize parent, reattach basename. local parent base parent=$(dirname -- "$path") base=$(basename -- "$path") if canonical=$(realpath "$parent" 2>/dev/null) && [[ -n "$canonical" ]]; then printf '%s/%s\n' "${canonical%/}" "$base" return 0 fi if command -v python3 >/dev/null 2>&1; then canonical=$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$path" 2>/dev/null || true) if [[ -n "$canonical" ]]; then printf '%s\n' "$canonical" return 0 fi fi printf '%s\n' "$path" }