[BUG] Symlinked .md files in .claude/commands/ are silently dropped from autocomplete
Environment
- Claude Code: 2.1.126
- Platform: linux (Ubuntu 22.04, Node bundled in binary)
- Terminal: gnome-terminal / zsh
Bug
Custom slash commands stored as symlinks in .claude/commands/ (or ~/.claude/commands/) never appear in /-autocomplete. Real .md files in the same directory work normally. There is no warning or error in any log — the symlinks are silently dropped during command discovery.
The skill loader handles symlinks correctly. Only the command loader is affected.
Steps to reproduce
mkdir -p /tmp/repro/.claude/commands
cd /tmp/repro
# Real file — appears in autocomplete
cat > .claude/commands/realfile.md <<'MD'
---
description: a real file
---
hello from real file
MD
# Symlink — does NOT appear in autocomplete
echo "hello from symlinked file" > /tmp/source-cmd.md
ln -s /tmp/source-cmd.md .claude/commands/symlinked.md
claude
# type \`/\` and look for slash commands:
# \`/realfile\` → present
# \`/symlinked\` → MISSING
Expected
Both /realfile and /symlinked should appear, since both resolve to valid .md files.
Actual
Only /realfile appears. /symlinked is silently dropped.
Root cause (verified against 2.1.126 bundle)
The directory walker used by the command loader (cmH, called via _m_ with logLabel: \"commands\") iterates Dirent entries from readdir(..., { withFileTypes: true }) and filters with:
if (O.isFile() && O.name.toLowerCase().endsWith(\".md\")) return $(M, Y)
Dirent.isFile() returns false for symbolic links — even when the target is a regular .md file. So every symlinked command hits the implicit return at the end of the iterator and is skipped silently.
Empirical verification with stock Node:
$ node -e \"
const fs = require('fs');
for (const d of fs.readdirSync('.claude/commands', { withFileTypes: true }))
console.log(d.name, 'isFile=' + d.isFile(), 'isSymbolicLink=' + d.isSymbolicLink());
\"
realfile.md isFile=true isSymbolicLink=false ← passes filter
symlinked.md isFile=false isSymbolicLink=true ← dropped
Why I'm confident this is an oversight rather than intent
The neighbouring skill loader (Ym7) in the same file uses the correct check:
if (!D.isDirectory() && !D.isSymbolicLink()) return
So the codebase already knows the right pattern — cmH just didn't get it.
Suggested fix (one line)
In cmH, accept symlink dirents the same way the skill loader does. Either:
// Option A — explicit symlink branch, no extra syscalls in the common case
if (O.isFile() || O.isSymbolicLink()) {
if (O.name.toLowerCase().endsWith(\".md\")) return $(M, Y)
}
Or follow the link with stat to handle symlinked subdirectories too:
// Option B — handles symlinked directories as well
const real = O.isSymbolicLink() ? await K.stat(M).catch(() => null) : O
if (!real) return
if (real.isDirectory()) return A(M, [...Y, O.name])
if (real.isFile() && O.name.toLowerCase().endsWith(\".md\")) return $(M, Y)
Workaround for users hitting this today
Replace the symlinks with real file copies:
cd .claude/commands
for f in *.md; do
if [ -L \"\$f\" ]; then
target=\$(readlink -f \"\$f\")
rm \"\$f\" && cp \"\$target\" \"\$f\"
fi
done
Trade-off: edits to the source file no longer auto-propagate.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗