You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG] claude install creates broken symlink: %h not expanded in path #83484
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.
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() {
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
}
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
')"
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.
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:
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.
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
}
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
')"
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.
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>
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:
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:
…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 | tailfind ~ -maxdepth 3 -name '%h'2>/dev/null # clean up stray junk dirs (check for real data first)
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.
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.
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.
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.
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.
Activity
KrypticKode007 commentedon Aug 3, 2026
#!/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"
kacperpaczos commentedon Aug 3, 2026
The issue is intermittent — the symlink doesn't break every time Claude is launched. Here's what I observed:
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
KrypticKode007 commentedon Aug 4, 2026
Hold up one sec okay, I got you.
KrypticKode007 commentedon Aug 4, 2026
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 bashset -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")"
' "$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"
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"
')"
if [[ "$CURRENT_TARGET" != "$LAST_TARGET" ]]; then
log "[event] Symlink changed"
log "[event] Old target: $LAST_TARGET"
log "[event] New target: $CURRENT_TARGET"
}
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")"
}
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.
KrypticKode007 commentedon Aug 4, 2026
```#!/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")"
' "$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"
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"
')"
if [[ "$CURRENT_TARGET" != "$LAST_TARGET" ]]; then
log "[event] Symlink changed"
log "[event] Old target: $LAST_TARGET"
log "[event] New target: $CURRENT_TARGET"
}
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")"
}
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>
Document issue anthropics#83484 root cause and fix strategy
Fix: Expand home directory in symlink path (Issue anthropics#83484)
Add resolution summary for issue anthropics#83484
kacperpaczos commentedon Aug 6, 2026
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 installsymlink code. The installer behaved correctlygiven its environment.
The proof
Claude Code keeps full session transcripts under
~/.claude/projects/. In a session from2026-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:The file's mtime on disk (
2026-07-30 12:13:19 +0200) matches the transcript timestampexactly.
%his 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 thenext 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:
…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 exactintermittency 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/sharemissing fromXDG_DATA_DIRS, and podman, pnpm,opencode and vocalinux silently created literal
%h/junk directories (several GB) relativeto their working directories.
Fix (for anyone who lands here with literal
%hpaths)Takeaways for @anthropics
%h) into anenvironment.dfile, then verified its work bycat-ing the file — the literal%hwasright there in the tool output and went unnoticed. Writing to
~/.config/environment.d// session-wide environment is high-blast-radius (it poisonedevery app in my session for a week) and deserves extra scrutiny from the model.
whose target contains an unexpanded
%-specifier or points into a non-existentdirectory, and print a hint about a broken
XDG_DATA_HOME. One line of validation wouldhave 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.
bcherny commentedon Aug 17, 2026
Reproduced on 2.1.233 (Linux x64, fresh home directory). With
XDG_DATA_HOME='%h/.local/share'in the environment,claude installcopied the binary into a literal%h/.local/share/claude/versions/2.1.233directory created relative to the current working directory, and left~/.local/bin/claudeas 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_HOMEwith 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 ofXDG_DATA_HOMEmust be ignored, so the installer should have fallen back to~/.local/shareinstead of honoring the invalid value (an empty or relativeXDG_DATA_HOMEbreaks 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_HOMEin your environment and re-run the installer, or keep your manualln -sffix.🤖 Generated with Claude Code
KrypticKode007 commentedon Aug 24, 2026
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
KrypticKode007 commentedon Aug 24, 2026
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.
github-actions commentedon Oct 18, 2026
Closing for now — inactive for too long. Please open a new issue if this is still relevant.