[BUG] Settings writer mis-resolves relative symlinked `~/.claude/settings.json` (readlink + logical-dirname join) — `/effort` fails with ENOENT
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Changing the effort level via /effort fails with ENOENT when ~/.claude/settings.json is a relative symlink and ~/.claude itself is also a symlink (a standard GNU stow layout). Settings reads work fine — sessions load the file normally — so the failure looks mysterious.
I have only reproduced /effort directly. The failure sits in the shared settings-write helper (see Additional Information), so other surfaces that persist user settings through it (/model, /config, permission grants, …) are expected to be affected as well — untested, inference from the code path only.
What Should Happen?
/effort should persist the chosen level into the real settings file. The kernel resolves the symlink chain correctly (reads through it work; readlink -f returns the real file), so the writer should resolve it the same way — canonicalize the path (e.g. fs.realpathSync) — and write to the target.
Error Messages/Logs
Failed to set effort level: Failed to read raw settings from /home/username/.claude/settings.json:
Error: ENOENT: no such file or directory, open '/path/to/config-repo/settings.json'
Note the mangled path in the inner error: `/home/username` is missing from the front. (The message also says "read", but the failing operation is the write — see Additional Information.)
Steps to Reproduce
Minimal example, no stow needed. Back up your real ~/.claude first:
mv ~/.claude ~/.claude.bakmkdir -p ~/.dotfiles/claude/.claude ~/path/to/config-repoecho '{}' > ~/path/to/config-repo/settings.json— this is the real settings fileln -s .dotfiles/claude/.claude ~/.claude—~/.claudeis now a symlink, as a stow fold would createln -s ../../../path/to/config-repo/settings.json ~/.dotfiles/claude/.claude/settings.json—settings.jsonis a relative symlink: 3 levels up from its physical dir (~/.dotfiles/claude/.claude/, 3 components under$HOME) lands back in$HOME, so the kernel resolves it to~/path/to/config-repo/settings.json- (If you use file-based credentials:
cp ~/.claude.bak/.credentials.json ~/.dotfiles/claude/.claude/to stay logged in) - Sanity-check reads:
readlink -f ~/.claude/settings.jsonprints/home/<you>/path/to/config-repo/settings.json, andcat ~/.claude/settings.jsonprints{} - Start
claude— settings load fine (reads resolve through the kernel) - Run
/effortand pick any level - → the ENOENT error above, with the mangled path; the real settings file is unchanged
Restore afterwards: rm ~/.claude && mv ~/.claude.bak ~/.claude (and remove the scratch dirs from steps 2–3).
Claude Model
Other
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.170 (Claude Code)
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
Other
Additional Information
Root cause (read from the 2.1.170 binary). The symlink-aware atomic write helper (sync b3$ / async Ry1 in the minified bundle) resolves the link target manually:
if (q.allowSymlink) try {
let O = K.readlinkSync(H);
f = FA.isAbsolute(O) ? O : FA.resolve(FA.dirname(H), O) // ← bug
...
Two problems:
- **Relative targets are resolved against the logical parent (
dirname("/home/username/.claude/settings.json")=/home/username/.claude, 3 components from root), but POSIX resolves a relative symlink target against the physical** directory containing the link (/home/username/.dotfiles/claude/.claude/). With the layout above,resolve("/home/username/.claude", "../../../path/to/config-repo/settings.json")walks up to the filesystem root and yields/path/to/config-repo/settings.json— while the kernel correctly resolves the same link to/home/username/path/to/config-repo/settings.json. - Single hop —
readlinkSyncis called once; a chained symlink isn't followed.
The bad path f then fails the atomic temp-file write (openSync(f + ".tmp...", O_CREAT|O_EXCL) → ENOENT), the non-atomic fallback openSync(f, O_CREAT) fails the same way, and the error surfaces wrapped as "Failed to read raw settings" (the message says read, but the write path is what failed).
Suggested fix. Replace the manual readlinkSync + resolve(dirname(...)) dance with fs.realpathSync(H) — one call handles relative targets, symlink chains, and logical/physical parent divergence. (Bonus: reword the catch-all error, which currently blames "read" for write failures.)
Workaround. Make the inner symlink absolute — the isAbsolute(O) branch bypasses the broken join. But relative links are the default for stow-like dotfiles tooling, so this bites real setups out of the box.
Environment.
- Platform: Arch Linux
- Version: 2.1.170 (first observed on 2.1.166/167; code inspection confirms the logic is present in 2.1.170)
~/.claudemanaged by GNU stow;settings.jsonsymlinked to a dotfiles repo
Related issues (not duplicates).
- #55485 — symlinked settings via NixOS/home-manager, but the failure there is EACCES on a read-only nix-store target (the writer opens the resolved target read-write); this report is ENOENT from mis-resolution of a relative link. Same
allowSymlinkcode path, different bug. Closed as stale without triage. - #52525 — sandbox/bwrap failure with symlinked settings (sandbox adapter, not the settings writer). Closed as stale.
- #48668 — a permissions key ignored; symlinked settings incidental. Closed as stale.