[BUG] Symlinked .md files in .claude/commands/ are silently dropped from autocomplete

Resolved 💬 3 comments Opened May 3, 2026 by logiqum Closed May 7, 2026

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.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗