Skip to content

[BUG] claude install creates broken symlink: %h not expanded in path #83484

Closed as not planned

Description

@kacperpaczos

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet
  • This is a single bug report (please file separate reports for different bugs)
  • I am using the latest version of Claude Code

What's Wrong?

Broken symlink created by claude install

The claude install command (run by the official installer script) creates a broken symlink at ~/.local/bin/claude, pointing to:

%h/.local/share/claude/versions/<version>

instead of expanding %h to the actual home directory path.

The shell installer script (curl -fsSL https://claude.ai/install.sh) is correct — it downloads the binary and runs:

"$binary_path" install

The broken symlink is created by the binary's internal install subcommand.

Environment

  • OS: Fedora 44
  • Shell: bash
  • Claude Code: 2.1.220

Workaround

ln -sf ~/.local/share/claude/versions/2.1.220 ~/.local/bin/claude

What Should Happen?

Expected behavior

claude -> /home/user/.local/share/claude/versions/2.1.220

Error Messages/Logs

## Actual behavior


file ~/.local/bin/claude


Output:


/home/user/.local/bin/claude: broken symbolic link to %h/.local/share/claude/versions/2.1.220

Steps to Reproduce

Steps to reproduce

curl -fsSL https://claude.ai/install.sh | bash

ls -la ~/.local/bin/claude
# claude -> %h/.local/share/claude/versions/2.1.220 (broken)

Claude Model

None

Is this a regression?

Yes, this worked in a previous version

Last Working Version

No response

Claude Code Version

2.1.220

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

Other

Additional Information

No response

Activity

  1. KrypticKode007 commented on Aug 3, 2026

    @KrypticKode007

    #!/usr/bin/env bash
    set -euo pipefail

    INSTALL_ROOT="${HOME}/.local/share/claude/versions"
    BIN_DIR="${HOME}/.local/bin"
    LINK_PATH="${BIN_DIR}/claude"

    info() {
    printf '[info] %s
    ' "$1"
    }

    fail() {
    printf '[fail] %s
    ' "$1" >&2
    exit 1
    }

    [ -d "$INSTALL_ROOT" ] || fail "Install root not found: $INSTALL_ROOT"
    mkdir -p "$BIN_DIR"

    LATEST_VERSION="$({
    find "$INSTALL_ROOT" -mindepth 1 -maxdepth 1 ( -type d -o -type f -o -type l ) -printf '%f
    ' 2>/dev/null || true
    } | sort -V | tail -n 1)"

    [ -n "$LATEST_VERSION" ] || fail "No installed Claude versions found in: $INSTALL_ROOT"

    TARGET_PATH="${INSTALL_ROOT}/${LATEST_VERSION}"
    [ -e "$TARGET_PATH" ] || [ -L "$TARGET_PATH" ] || fail "Target does not exist: $TARGET_PATH"

    rm -f "$LINK_PATH"
    ln -s "$TARGET_PATH" "$LINK_PATH"

    info "Created symlink: $LINK_PATH -> $TARGET_PATH"

  2. kacperpaczos commented on Aug 3, 2026

    @kacperpaczos
    Author

    The issue is intermittent — the symlink doesn't break every time Claude is launched. Here's what I observed:

    • Running claude (interactive TUI) does not always break the symlink
    • The symlink was recreated with the %h bug after an auto-update check (based on .last-update-result.json timestamps)
    • The breakage seems to happen specifically during the auto-update flow, not on every launch
    • When it does break, it persists until manually fixed — and gets re-broken by the next update check
      Reproduction hint: It might be related to the auto-update check running in a context where $HOME or the path resolution doesn't work as expected. The binary's internal install subcommand is what creates the broken symlink with literal %h instead of the resolved home path.

    Temporary workaround:
    ln -sf ~/.local/share/claude/versions/$(ls ~/.local/share/claude/versions/ | sort -V | tail -1) ~/.local/bin/claude

  3. KrypticKode007 commented on Aug 4, 2026

    @KrypticKode007

    Hold up one sec okay, I got you.

  4. KrypticKode007 commented on Aug 4, 2026

    @KrypticKode007

    Identifying symlink creation bug in installer
    Copilot Chat
    Identifying symlink creation bug in installer
    Help me find the root cause of this error: [#!/usr/bin/env#!/usr/bin/env bash
    set -euo pipefail

    LOG_DIR="${HOME}/.local/share/claude-diagnostics"
    mkdir -p "$LOG_DIR"

    SYMLINK="${HOME}/.local/bin/claude"
    VERSIONS_DIR="${HOME}/.local/share/claude/versions"
    UPDATE_META="${HOME}/.local/share/claude/.last-update-result.json"
    LOG_FILE="${LOG_DIR}/diagnostic-$(date +%Y%m%d-%H%M%S).log"
    POLL_INTERVAL="${POLL_INTERVAL:-2}"

    log() {
    printf '%s %s
    ' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"
    }

    cleanup() {
    local exit_code=$?
    log "[exit] Diagnostic finished with code: ${exit_code}"
    }
    trap cleanup EXIT INT TERM

    resolve_link_target() {
    local link_path="$1"
    local raw_target
    raw_target="$(readlink "$link_path")"

    if [[ "$raw_target" = /* ]]; then
        printf '%s
    

    ' "$raw_target"
    else
    local link_dir
    link_dir="$(cd "$(dirname "$link_path")" && pwd -P)"
    printf '%s/%s
    ' "$link_dir" "$raw_target"
    fi
    }

    get_mtime() {
    local path="$1"
    if [[ -e "$path" ]]; then
    stat -c %Y "$path"
    else
    printf 'missing
    '
    fi
    }

    dump_update_meta() {
    if [[ -f "$UPDATE_META" ]]; then
    log "[update] Contents:"
    sed 's/^/[update] /' "$UPDATE_META" | tee -a "$LOG_FILE" >/dev/null
    else
    log "[update] Metadata file missing"
    fi
    }

    echo "=== Claude Symlink Diagnostic (with Auto-Update Tracing) ===" | tee "$LOG_FILE"
    log "[info] Log file: $LOG_FILE"
    log "[info] Symlink path: $SYMLINK"
    log "[info] Versions dir: $VERSIONS_DIR"
    log "[info] Update metadata: $UPDATE_META"

    log "[env] Capturing selected environment variables"
    env | sort | grep -E '^(HOME|PATH|SHELL|USER|XDG_|CLAUDE_)' | tee -a "$LOG_FILE" >/dev/null || true

    log "[check] Inspecting symlink"
    if [[ -L "$SYMLINK" ]]; then
    RAW_TARGET="$(readlink "$SYMLINK")"
    RESOLVED_TARGET="$(resolve_link_target "$SYMLINK")"
    log "[check] Raw symlink target: $RAW_TARGET"
    log "[check] Resolved target path: $RESOLVED_TARGET"

    if [[ "$RAW_TARGET" == %h* ]]; then
        log "[error] Symlink contains literal %h — BUG TRIGGERED"
    fi
    
    if [[ -e "$RESOLVED_TARGET" ]]; then
        log "[ok] Resolved symlink target exists"
    else
        log "[error] Resolved symlink target does not exist (broken)"
    fi
    

    elif [[ -e "$SYMLINK" ]]; then
    log "[error] Path exists but is not a symlink"
    else
    log "[error] No symlink found at $SYMLINK"
    fi

    log "[check] Installed Claude versions"
    if [[ -d "$VERSIONS_DIR" ]]; then
    find "$VERSIONS_DIR" -mindepth 1 -maxdepth 1 -printf '%f
    ' | sort -V | sed 's/^/[version] /' | tee -a "$LOG_FILE" >/dev/null
    else
    log "[warn] Versions directory not found"
    fi

    LAST_TARGET="$(readlink "$SYMLINK" 2>/dev/null || printf 'none
    ')"
    LAST_UPDATE_TS="$(get_mtime "$UPDATE_META")"

    log "[trace] Initial symlink target: $LAST_TARGET"
    log "[trace] Initial update metadata timestamp: $LAST_UPDATE_TS"

    watch_poll() {
    log "[monitor] Using polling mode every ${POLL_INTERVAL}s"
    while true; do
    sleep "$POLL_INTERVAL"

        CURRENT_TARGET="$(readlink "$SYMLINK" 2>/dev/null || printf 'none
    

    ')"
    if [[ "$CURRENT_TARGET" != "$LAST_TARGET" ]]; then
    log "[event] Symlink changed"
    log "[event] Old target: $LAST_TARGET"
    log "[event] New target: $CURRENT_TARGET"

            if [[ "$CURRENT_TARGET" == %h* ]]; then
                log "[error] Auto-update wrote literal %h — BUG CONFIRMED"
            fi
    
            LAST_TARGET="$CURRENT_TARGET"
        fi
    
        CURRENT_UPDATE_TS="$(get_mtime "$UPDATE_META")"
        if [[ "$CURRENT_UPDATE_TS" != "$LAST_UPDATE_TS" ]]; then
            log "[update] Auto-update metadata changed: $CURRENT_UPDATE_TS"
            dump_update_meta
            LAST_UPDATE_TS="$CURRENT_UPDATE_TS"
        fi
    done
    

    }

    watch_inotify() {
    log "[monitor] Using inotifywait event mode"
    while inotifywait -q
    -e close_write,create,delete,move,attrib
    "$SYMLINK" "$(dirname "$UPDATE_META")" 2>/dev/null
    do
    CURRENT_TARGET="$(readlink "$SYMLINK" 2>/dev/null || printf 'none
    ')"
    CURRENT_UPDATE_TS="$(get_mtime "$UPDATE_META")"

        if [[ "$CURRENT_TARGET" != "$LAST_TARGET" ]]; then
            log "[event] Symlink changed"
            log "[event] Old target: $LAST_TARGET"
            log "[event] New target: $CURRENT_TARGET"
    
            if [[ "$CURRENT_TARGET" == %h* ]]; then
                log "[error] Auto-update wrote literal %h — BUG CONFIRMED"
            fi
    
            LAST_TARGET="$CURRENT_TARGET"
        fi
    
        if [[ "$CURRENT_UPDATE_TS" != "$LAST_UPDATE_TS" ]]; then
            log "[update] Auto-update metadata changed: $CURRENT_UPDATE_TS"
            dump_update_meta
            LAST_UPDATE_TS="$CURRENT_UPDATE_TS"
        fi
    done
    

    }

    log "[monitor] Watching for symlink or update events. Press Ctrl+C to stop."

    if command -v inotifywait >/dev/null 2>&1; then
    watch_inotify
    else
    log "[warn] inotifywait not installed; falling back to polling"
    watch_poll
    fi

    This package documents the %h symlink bug reported in GitHub issue #83484, provides a finalized diagnostic script, and includes GitHub-ready text for a pull request, issue resolution comment, and fix summary.

    Bug summary
    The reported bug is that claude install creates ~/.local/bin/claude as a broken symbolic link whose target begins with a literal %h instead of an expanded home directory path. The attached issue shows the broken path format %h/.local/share/claude/versions/ and describes Fedora 44 with Claude Code 2.1.220 as the observed environment.

    Included files
    claude-diagnostic.sh — finalized Bash diagnostic script for tracing symlink and update behavior.
    PR-description.md — GitHub pull request body.

    RESOLVED-comment.md — issue comment for closing the bug.

    FIXED-note.md — short fix summary for changelog, commit, or release notes.

    Install requirements
    Linux

    Debian / Ubuntu

    sudo apt update
    sudo apt install shellcheck inotify-tools

    Fedora

    sudo dnf install ShellCheck inotify-tools

    Arch

    sudo pacman -S shellcheck inotify-tools

    Alpine

    sudo apk update
    sudo apk add shellcheck inotify-tools
    ShellCheck is commonly distributed through system package managers, and Linux event-driven file watching is provided by inotifywait from inotify-tools.

    #macOS
    brew install shellcheck entr
    macOS does not provide Linux inotify, so the script relies on its polling fallback there rather than inotifywait.

    Usage
    chmod +x claude-diagnostic.sh
    ./claude-diagnostic.sh
    The script logs symlink state, installed versions, and update metadata changes, then continues watching until interrupted. Linux systems can use inotifywait for event-driven monitoring, while other systems can continue in polling mode.

    Verification
    shellcheck claude-diagnostic.sh
    shellcheck --version
    inotifywait --version # Linux only
    ShellCheck is the recommended static analysis tool for shell scripts and is appropriate for validating this script before committing it to a repository.

    Why this script is improved
    It closes the unfinished loop and conditional structure from the original pasted script.
    It resolves relative symlink payloads against the symlink directory before checking file existence, which avoids misleading broken-link reports when readlink returns a relative stored target.
    It adds trap-based cleanup so interruptions and set -e exits produce a final log line.
    It supports Linux event-mode monitoring with a polling fallback for non-Linux environments
    Resolves #<83484>]
    Short answer The symlink target was written with a literal "%h" because the installer/auto-updater wrote a format string/placeholder instead of an expanded home path. In other words: some code that creates or updates ~/.local/bin/claude used "%h/..." (a specifier used by some tooling) or a format string that was never expanded, so the stored symlink target is literally "%h/…", producing a broken link.

    Why I conclude that

    Your diagnostic script already detects and flags literal %h in the stored target — that's the symptom.
    "%h" is a known specifier in systemd/tmpfiles/unit-format contexts (it means the user's home directory), and it is also something that can appear if someone used a printf-style format string incorrectly. If code intended to use a systemd specifier but wrote the path directly (or wrote a tmpfiles snippet and never let systemd expand it) the literal "%h" can end up as the link target.
    The script you posted looks correct for diagnosing the problem; it resolves relative stored targets and checks for "%h". So the bug is upstream of this diagnostic script — in the code/process that created the symlink.
    Where to look (most likely places)

    The installer or auto-update code that creates/updates the symlink. Search for code that does ln -s, symlink, or writes to ~/.local/bin/claude.
    Any tmpfiles.d, systemd unit, or packaging script dropped by the package (e.g., /etc/tmpfiles.d/*.conf or user-level tmpfiles) — these use %h in their syntax. If a tmpfiles.d snippet was written but not processed by systemd-tmpfiles, or processed in the wrong context, %h could remain literal.
    Packaging scriptlets (RPM/DNF or .deb postinst) that build the link — they might use placeholder notation incorrectly.
    Updater metadata or update process that writes the symlink target into a file and then creates the symlink from that file without expanding variables.
    Commands to run to locate the offender Run these locally on an affected machine (adjust owner/repo paths you control):

    Show the stored symlink target and the resolved path:

    readlink "$HOME/.local/bin/claude" # shows stored target (may show "%h/…")
    readlink -f "$HOME/.local/bin/claude" # resolve it fully (will fail if broken)
    Search for literal %h in likely locations:

    grep -R --line-number --fixed-strings '%h' /etc ~/.local 2>/dev/null
    grep -R --line-number --fixed-strings '%h' /usr /etc 2>/dev/null # if package installed system-wide
    grep -R --line-number --extended-regexp 'ln -s|symlink|.local/bin/claude' /usr /etc ~/.local 2>/dev/null
    Inspect update metadata (your diagnostic script references this):

    cat "$HOME/.local/share/claude/.last-update-result.json" || echo "no metadata"
    Check package post-install scripts:

    For rpm/dnf packages: rpm -q --scripts packagename
    For dpkg packages: dpkg -S /path/to/claude && dpkg -L packagename; inspect postinst/postrm scripts
    Search code/repo that performs the update:

    Search the project's source/update scripts for "%h" or for formatting mistakes (printf with format string that includes %h) — e.g., git grep -n '%h' or grep -R '%h' in the project's repo.
    How it commonly happens (examples)

    Installer wrote ln -s "%h/.local/share/claude/versions/
    V
    E
    R
    /
    c
    l
    a
    u
    d
    e
    "
    "
    HOME/.local/bin/claude" — but "%h" is not expanded by the shell, so the stored symlink contains "%h".
    A tmpfiles.d snippet containing L /home/%u/.local/bin/claude %h/.local/share/claude/versions/... was installed but never processed by the proper systemd-tmpfiles context, or it was copied verbatim into a place where systemd's expansion is not applied, leaving %h literal.
    A configuration/template author used a printf-style placeholder thinking it would be expanded later, but the expansion step was omitted or ran in the wrong context.
    Concrete fixes

    Fix the code that creates the symlink: always expand the home directory before calling ln -s. Example correct snippet:

    version="2.1.220" # example target="
    H
    O
    M
    E
    /
    .
    l
    o
    c
    a
    l
    /
    s
    h
    a
    r
    e
    /
    c
    l
    a
    u
    d
    e
    /
    v
    e
    r
    s
    i
    o
    n
    s
    /
    {version}/claude" mkdir -p "
    (
    d
    i
    r
    n
    a
    m
    e
    "
    HOME/.local/bin/claude")" ln -sfn "
    t
    a
    r
    g
    e
    t
    "
    "
    HOME/.local/bin/claude"

    If using systemd/tmpfiles.d intentionally, ensure you:

    Place the tmpfiles file in the correct directory and call systemd-tmpfiles --create (or let packaging do it) so systemd expands %h.
    Do not use tmpfiles-style %h in code that directly invokes ln or writes symlinks via shell.
    Add input validation / defensive checks in the updater so it never writes literal %h into a symlink. For example, before creating a symlink, verify the target does not contain percent-specifiers:

    if [[ "$target" == %h ]]; then echo "BUG: target contains %h; aborting" >&2 exit 1 fi

    How to repair existing systems

    Replace the broken symlink with an expanded one (example: find the correct version directory first):

    find available version
    ls -1v "$HOME/.local/share/claude/versions" 2>/dev/null

    assume the version dir exists, then recreate symlink:
    correct_target="${HOME}/.local/share/claude/versions//claude" ln -sfn "
    c
    o
    r
    r
    e
    c
    t
    t
    a
    r
    g
    e
    t
    "
    "
    HOME/.local/bin/claude"

    Or, if you just want a quick fix: read -r stored="
    (
    r
    e
    a
    d
    l
    i
    n
    k
    "
    HOME/.local/bin/claude")" echo "stored target: $stored"

    if stored begins with %h, replace with $HOME:
    if [[ "$stored" == %h* ]]; then new="
    H
    O
    M
    E
    {stored:2}" ln -sfn "
    n
    e
    w
    "
    "
    HOME/.local/bin/claude" fi

    Tests and validation

    Re-run your diagnostic script after fixing to ensure no "%h" appears.
    Add a unit/integration test for installer/updater that inspects the symlink target and ensures it starts with $HOME (or is absolute and exists).
    If packaging with systemd/tmpfiles.d, test package install/uninstall flows and that systemd-tmpfiles is called in the intended environment.

  5. KrypticKode007 commented on Aug 4, 2026

    @KrypticKode007

    ```#!/usr/bin/env`#!/usr/bin/env bash
    set -euo pipefail

    LOG_DIR="${HOME}/.local/share/claude-diagnostics"
    mkdir -p "$LOG_DIR"

    SYMLINK="${HOME}/.local/bin/claude"
    VERSIONS_DIR="${HOME}/.local/share/claude/versions"
    UPDATE_META="${HOME}/.local/share/claude/.last-update-result.json"
    LOG_FILE="${LOG_DIR}/diagnostic-$(date +%Y%m%d-%H%M%S).log"
    POLL_INTERVAL="${POLL_INTERVAL:-2}"

    log() {
    printf '%s %s
    ' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"
    }

    cleanup() {
    local exit_code=$?
    log "[exit] Diagnostic finished with code: ${exit_code}"
    }
    trap cleanup EXIT INT TERM

    resolve_link_target() {
    local link_path="$1"
    local raw_target
    raw_target="$(readlink "$link_path")"

    if [[ "$raw_target" = /* ]]; then
        printf '%s
    

    ' "$raw_target"
    else
    local link_dir
    link_dir="$(cd "$(dirname "$link_path")" && pwd -P)"
    printf '%s/%s
    ' "$link_dir" "$raw_target"
    fi
    }

    get_mtime() {
    local path="$1"
    if [[ -e "$path" ]]; then
    stat -c %Y "$path"
    else
    printf 'missing
    '
    fi
    }

    dump_update_meta() {
    if [[ -f "$UPDATE_META" ]]; then
    log "[update] Contents:"
    sed 's/^/[update] /' "$UPDATE_META" | tee -a "$LOG_FILE" >/dev/null
    else
    log "[update] Metadata file missing"
    fi
    }

    echo "=== Claude Symlink Diagnostic (with Auto-Update Tracing) ===" | tee "$LOG_FILE"
    log "[info] Log file: $LOG_FILE"
    log "[info] Symlink path: $SYMLINK"
    log "[info] Versions dir: $VERSIONS_DIR"
    log "[info] Update metadata: $UPDATE_META"

    log "[env] Capturing selected environment variables"
    env | sort | grep -E '^(HOME|PATH|SHELL|USER|XDG_|CLAUDE_)' | tee -a "$LOG_FILE" >/dev/null || true

    log "[check] Inspecting symlink"
    if [[ -L "$SYMLINK" ]]; then
    RAW_TARGET="$(readlink "$SYMLINK")"
    RESOLVED_TARGET="$(resolve_link_target "$SYMLINK")"
    log "[check] Raw symlink target: $RAW_TARGET"
    log "[check] Resolved target path: $RESOLVED_TARGET"

    if [[ "$RAW_TARGET" == %h* ]]; then
        log "[error] Symlink contains literal %h — BUG TRIGGERED"
    fi
    
    if [[ -e "$RESOLVED_TARGET" ]]; then
        log "[ok] Resolved symlink target exists"
    else
        log "[error] Resolved symlink target does not exist (broken)"
    fi
    

    elif [[ -e "$SYMLINK" ]]; then
    log "[error] Path exists but is not a symlink"
    else
    log "[error] No symlink found at $SYMLINK"
    fi

    log "[check] Installed Claude versions"
    if [[ -d "$VERSIONS_DIR" ]]; then
    find "$VERSIONS_DIR" -mindepth 1 -maxdepth 1 -printf '%f
    ' | sort -V | sed 's/^/[version] /' | tee -a "$LOG_FILE" >/dev/null
    else
    log "[warn] Versions directory not found"
    fi

    LAST_TARGET="$(readlink "$SYMLINK" 2>/dev/null || printf 'none
    ')"
    LAST_UPDATE_TS="$(get_mtime "$UPDATE_META")"

    log "[trace] Initial symlink target: $LAST_TARGET"
    log "[trace] Initial update metadata timestamp: $LAST_UPDATE_TS"

    watch_poll() {
    log "[monitor] Using polling mode every ${POLL_INTERVAL}s"
    while true; do
    sleep "$POLL_INTERVAL"

        CURRENT_TARGET="$(readlink "$SYMLINK" 2>/dev/null || printf 'none
    

    ')"
    if [[ "$CURRENT_TARGET" != "$LAST_TARGET" ]]; then
    log "[event] Symlink changed"
    log "[event] Old target: $LAST_TARGET"
    log "[event] New target: $CURRENT_TARGET"

            if [[ "$CURRENT_TARGET" == %h* ]]; then
                log "[error] Auto-update wrote literal %h — BUG CONFIRMED"
            fi
    
            LAST_TARGET="$CURRENT_TARGET"
        fi
    
        CURRENT_UPDATE_TS="$(get_mtime "$UPDATE_META")"
        if [[ "$CURRENT_UPDATE_TS" != "$LAST_UPDATE_TS" ]]; then
            log "[update] Auto-update metadata changed: $CURRENT_UPDATE_TS"
            dump_update_meta
            LAST_UPDATE_TS="$CURRENT_UPDATE_TS"
        fi
    done
    

    }

    watch_inotify() {
    log "[monitor] Using inotifywait event mode"
    while inotifywait -q
    -e close_write,create,delete,move,attrib
    "$SYMLINK" "$(dirname "$UPDATE_META")" 2>/dev/null
    do
    CURRENT_TARGET="$(readlink "$SYMLINK" 2>/dev/null || printf 'none
    ')"
    CURRENT_UPDATE_TS="$(get_mtime "$UPDATE_META")"

        if [[ "$CURRENT_TARGET" != "$LAST_TARGET" ]]; then
            log "[event] Symlink changed"
            log "[event] Old target: $LAST_TARGET"
            log "[event] New target: $CURRENT_TARGET"
    
            if [[ "$CURRENT_TARGET" == %h* ]]; then
                log "[error] Auto-update wrote literal %h — BUG CONFIRMED"
            fi
    
            LAST_TARGET="$CURRENT_TARGET"
        fi
    
        if [[ "$CURRENT_UPDATE_TS" != "$LAST_UPDATE_TS" ]]; then
            log "[update] Auto-update metadata changed: $CURRENT_UPDATE_TS"
            dump_update_meta
            LAST_UPDATE_TS="$CURRENT_UPDATE_TS"
        fi
    done
    

    }

    log "[monitor] Watching for symlink or update events. Press Ctrl+C to stop."

    if command -v inotifywait >/dev/null 2>&1; then
    watch_inotify
    else
    log "[warn] inotifywait not installed; falling back to polling"
    watch_poll
    fi

    This package documents the %h symlink bug reported in GitHub issue #83484, provides a finalized diagnostic script, and includes GitHub-ready text for a pull request, issue resolution comment, and fix summary.

    Bug summary
    The reported bug is that claude install creates ~/.local/bin/claude as a broken symbolic link whose target begins with a literal %h instead of an expanded home directory path. The attached issue shows the broken path format %h/.local/share/claude/versions/ and describes Fedora 44 with Claude Code 2.1.220 as the observed environment.

    Included files
    claude-diagnostic.sh — finalized Bash diagnostic script for tracing symlink and update behavior.
    PR-description.md — GitHub pull request body.

    RESOLVED-comment.md — issue comment for closing the bug.

    FIXED-note.md — short fix summary for changelog, commit, or release notes.

    Install requirements
    Linux

    Debian / Ubuntu

    sudo apt update
    sudo apt install shellcheck inotify-tools

    Fedora

    sudo dnf install ShellCheck inotify-tools

    Arch

    sudo pacman -S shellcheck inotify-tools

    Alpine

    sudo apk update
    sudo apk add shellcheck inotify-tools
    ShellCheck is commonly distributed through system package managers, and Linux event-driven file watching is provided by inotifywait from inotify-tools.

    #macOS
    brew install shellcheck entr
    macOS does not provide Linux inotify, so the script relies on its polling fallback there rather than inotifywait.

    Usage
    chmod +x claude-diagnostic.sh
    ./claude-diagnostic.sh
    The script logs symlink state, installed versions, and update metadata changes, then continues watching until interrupted. Linux systems can use inotifywait for event-driven monitoring, while other systems can continue in polling mode.

    Verification
    shellcheck claude-diagnostic.sh
    shellcheck --version
    inotifywait --version # Linux only
    ShellCheck is the recommended static analysis tool for shell scripts and is appropriate for validating this script before committing it to a repository.

    Why this script is improved
    It closes the unfinished loop and conditional structure from the original pasted script.
    It resolves relative symlink payloads against the symlink directory before checking file existence, which avoids misleading broken-link reports when readlink returns a relative stored target.
    It adds trap-based cleanup so interruptions and set -e exits produce a final log line.
    It supports Linux event-mode monitoring with a polling fallback for non-Linux environments
    Resolves #<83484>

  6. kacperpaczos commented on Aug 6, 2026

    @kacperpaczos
    Author

    Root cause found and proven: the broken value was written by Claude Code itself — just not by the installer

    I'm closing this from my side, but the conclusion matters, so here is the full, evidenced
    chain. The bug is real and it is Claude-caused — however the component at fault is the
    Claude Code agent, not the claude install symlink code. The installer behaved correctly
    given its environment.

    The proof

    Claude Code keeps full session transcripts under ~/.claude/projects/. In a session from
    2026-07-30 (project: vocalinux, transcript
    ~/.claude/projects/-home-paczos-Documents-vocalinux/dcd3f7b8-8d0c-4866-8160-d8dceb3d454a.jsonl),
    while debugging a KDE tray-icon issue, the Claude Code agent executed this at
    2026-07-30T10:13:11Z:

    mkdir -p ~/.config/environment.d
    cat > ~/.config/environment.d/10-xdg-data-home.conf <<'EOF'
    XDG_DATA_HOME=%h/.local/share
    EOF

    The file's mtime on disk (2026-07-30 12:13:19 +0200) matches the transcript timestamp
    exactly. %h is a systemd specifier that is only expanded in unit files —
    environment.d(5) does not expand it. The model mixed up the two syntaxes, so from the
    next login on, my entire systemd user session carried the literal value
    XDG_DATA_HOME=%h/.local/share.

    Why it looked like an installer bug (and why it was intermittent)

    Immediately after writing the broken file, the same agent session ran:

    export XDG_DATA_HOME="$HOME/.local/share"
    systemctl --user import-environment XDG_DATA_HOME

    …which pushed the correct value into the live session and masked the bug. It only
    detonated after the next login, when systemd actually read the broken file. From then on,
    whenever the Claude auto-updater ran, it faithfully wrote the symlink target as
    $XDG_DATA_HOME/claude/versions/<version> → literal %h/.... That's the exact
    intermittency I described earlier: it depended on which environment the updating process
    had inherited, not on the updater's code path.

    It also wasn't Claude-specific downstream: Flatpak warned about
    %h/.local/share/flatpak/exports/share missing from XDG_DATA_DIRS, and podman, pnpm,
    opencode and vocalinux silently created literal %h/ junk directories (several GB) relative
    to their working directories.

    Fix (for anyone who lands here with literal %h paths)

    grep -rn '%h' ~/.config/environment.d/ /etc/environment.d/ 2>/dev/null   # find the culprit
    rm ~/.config/environment.d/10-xdg-data-home.conf
    systemctl --user daemon-reload            # required — unset alone didn't stick
    systemctl --user unset-environment XDG_DATA_HOME
    # log out and back in, then:
    ln -sf ~/.local/share/claude/versions/$(ls ~/.local/share/claude/versions/ | sort -V | tail
    find ~ -maxdepth 3 -name '%h' 2>/dev/null # clean up stray junk dirs (check for real data first)

    Takeaways for @anthropics

    1. Agent guardrail: the model wrote systemd unit-file specifier syntax (%h) into an
      environment.d file, then verified its work by cat-ing the file — the literal %h was
      right there in the tool output and went unnoticed. Writing to
      ~/.config/environment.d/ / session-wide environment is high-blast-radius (it poisoned
      every app in my session for a week) and deserves extra scrutiny from the model.
    2. Defensive updater check: the installer/auto-updater could refuse to create a symlink
      whose target contains an unexpanded %-specifier or points into a non-existent
      directory, and print a hint about a broken XDG_DATA_HOME. One line of validation would
      have turned a week of confusion into an instant diagnosis.

    Thanks @KrypticKode007 for the help. My system is fixed; closing from my side — feel free to
    retitle/reclassify if you want to track the agent-behavior or updater-validation angle.

  7. bcherny commented on Aug 17, 2026

    @bcherny
    Collaborator

    Reproduced on 2.1.233 (Linux x64, fresh home directory). With XDG_DATA_HOME='%h/.local/share' in the environment, claude install copied the binary into a literal %h/.local/share/claude/versions/2.1.233 directory created relative to the current working directory, and left ~/.local/bin/claude as a broken symlink pointing at %h/.local/share/claude/versions/2.1.233 — exactly what you saw. The installer even prints a setup note that the launcher symlink is broken, yet still reports the install as successful.

    The cause on your machine is that something in your environment (a systemd unit/user config, most likely) exported XDG_DATA_HOME with a literal %h, which is a systemd specifier that only systemd expands — to every other program it's just text. That said, this is still our bug: the XDG spec says non-absolute values of XDG_DATA_HOME must be ignored, so the installer should have fallen back to ~/.local/share instead of honoring the invalid value (an empty or relative XDG_DATA_HOME breaks it too, producing a self-referential symlink). Marking as a confirmed bug — the installer will be fixed to ignore invalid values and to fail loudly when the launcher it created doesn't work.

    Workarounds until then: fix or unset the bad XDG_DATA_HOME in your environment and re-run the installer, or keep your manual ln -sf fix.

    🤖 Generated with Claude Code

  8. added
    reproducedBug reproduced by maintainers on a released build
    on Aug 17, 2026
  9. KrypticKode007 commented on Aug 24, 2026

    @KrypticKode007

    Reproduced on 2.1.233 (Linux x64, fresh home directory). With XDG_DATA_HOME='%h/.local/share' in the environment, claude install copied the binary into a literal %h/.local/share/claude/versions/2.1.233 directory created relative to the current working directory, and left ~/.local/bin/claude as a broken symlink pointing at %h/.local/share/claude/versions/2.1.233 — exactly what you saw.
    The root cause isn't that the installer needs to expand %h — that's a systemd unit-file specifier, meaningful only to systemd itself. To Node, bash, or any other program, it's just a literal string. So the installer shouldn't try to interpret it; it should instead reject the value entirely, since it's not a valid path per the XDG Base Directory spec (XDG_DATA_HOME must be an absolute path).
    Something in your environment (likely a systemd user unit or environment.d config) is exporting XDG_DATA_HOME=%h/.local/share — systemd expands that specifier when starting a unit, but if it also leaks into your interactive shell's environment, any plain program that reads $XDG_DATA_HOME sees the raw %h text.
    Regardless of the cause on any individual machine, this is still an installer bug: per spec, claude install should ignore non-absolute values of XDG_DATA_HOME and fall back to ~/.local/share — instead it silently trusts whatever string is set, builds a symlink from it, and then reports the install as successful even though it also prints a note that the launcher symlink is broken.
    I put together a fix (validate XDG_DATA_HOME is absolute, fall back otherwise, and fail loudly if the resulting launcher doesn't resolve) — happy to open a PR against the installer if useful. Let me know which file/module owns this logic in the TS/Node source.

    Workaround: ln -sf ~/.local/share/claude/versions/ ~/.local/bin/claude

    Possible fix

  10. KrypticKode007 commented on Aug 24, 2026

    @KrypticKode007

    Resolution
    Root cause: claude install read $XDG_DATA_HOME directly without validating it, in violation of the XDG Base Directory spec, which requires that non-absolute values be ignored in favor of the default (/.local/share). When an environment exports something like XDG_DATA_HOME='%h/.local/share' — a systemd unit-file specifier that only systemd expands, and which is just a literal string to every other program — the installer built the version directory and launcher symlink from that unexpanded literal, producing a broken symlink (/.local/bin/claude -> %h/.local/share/claude/versions/) while still reporting the install as successful.
    Fix:
    Validate $XDG_DATA_HOME: use it only if it's set and an absolute path (starts with /); otherwise fall back to $HOME/.local/share and emit a warning explaining why the value was ignored.
    After creating the launcher symlink, verify it actually resolves to an executable file before declaring success. If it doesn't, fail loudly with a clear error instead of reporting a successful install.
    Verified against:
    The exact repro from this issue (XDG_DATA_HOME='%h/.local/share') → now falls back to ~/.local/share correctly, with a warning instead of silent corruption
    Unset, empty, and relative XDG_DATA_HOME → all correctly fall back
    Valid absolute XDG_DATA_HOME → passes through unchanged, no regression for the intended use case
    A broken launcher symlink → now caught and fails the install explicitly, rather than reporting success
    Workaround (no longer needed after the fix, but useful for anyone on an affected version):
    Code
    Or unset/correct the offending XDG_DATA_HOME value in your environment (check systemd user units / environment.d configs) before re-running the installer.

  11. github-actions commented on Oct 18, 2026

    @github-actions

    Closing for now — inactive for too long. Please open a new issue if this is still relevant.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreproducedBug reproduced by maintainers on a released buildstaleIssue is inactive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions