Symlinked SKILL.md files are not discovered in skills loader (only symlinked directories are followed)

Resolved 💬 7 comments Opened Mar 25, 2026 by miking38 Closed Mar 25, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.116.0

What subscription do you have?

Enterprise

Which model were you using?

gpt-5.3-codex

What platform is your computer?

Darwin 25.3.0 arm64 arm

What terminal emulator and version are you using (if applicable)?

iTerm2 3.6.6

What issue are you seeing?

Symlinked SKILL.md files are not discovered by the skills loader, so those skills do not appear in $ command suggestions.

From codex-rs/core/src/skills/loader.rs, the likely cause is:

  • symlink entries are handled in a separate branch
  • that branch only follows symlinks that point to directories
  • symlink-to-file entries are skipped
  • the parse path only runs when file_type.is_file() && file_name == "SKILL.md"

So a SKILL.md symlink never reaches parsing.

What steps can reproduce the bug?

  1. Create a valid skill directory with a valid SKILL.md frontmatter file.
  2. Replace that SKILL.md with a symlink to another valid SKILL.md file.
  3. Start/reload Codex in that workspace.
  4. Open $ skill suggestions.

Observed:

  • skills with regular SKILL.md are listed
  • skills with symlinked SKILL.md are not listed

What is the expected behavior?

A SKILL.md symlink that resolves to a regular file should be discovered and loaded the same as a regular SKILL.md.

Potential fix idea in loader.rs:

  • allow file candidates with:

file_type.is_file() || file_type.is_symlink()

  • and for symlinks, verify the target is a file (e.g. fs::metadata(&path)?.is_file()) before parsing.

Additional information

_No response_

View original on GitHub ↗

7 Comments

github-actions[bot] contributor · 5 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #15345
  • #14767

Powered by Codex Action

etraut-openai contributor · 5 months ago

This is by design. The packaging boundary of a skill is its directory.

vegerot · 4 months ago

I have already fixed this locally and pushed the fix to my fork.

Branch: https://github.com/vegerot/codex/tree/mchcopl/bugfixes-skills-symlink-source-build
Relevant commit in that branch: a99d20f4a9c3 (fix(skills): Load symlinked skill files)

This branch contains a change that loads symlinked SKILL.md files directly instead of only following symlinked directories. If this approach looks acceptable, please merge the branch or cherry-pick the fix commit.

vegerot · 4 months ago

@etraut-openai May I ask _why_ this is the design? I understand that "the packaging boundary of a skill is its directory", but why block symlinks inside the skill itself?

light-merlin-dark · 4 months ago

_I've been struggling with this issue as well. Even GPT 5.4 xHigh noticed serious room for improvement here. I present to you this argument below:_

I think the current framing conflates two different concerns:

  1. what defines the packaging boundary of a skill
  2. what install mechanism is allowed to point at that package

If the packaging boundary is the skill directory, that is a reasonable rule. But rejecting a symlinked SKILL.md file is not the only way to preserve that rule.

The cleaner implementation would be:

  • allow a symlinked SKILL.md file to be discovered
  • canonicalize it to the target path
  • treat the target file’s parent directory as the skill directory for any sibling metadata/assets/scripts resolution

That preserves the directory boundary while still allowing a wrapper install at ~/.codex/skills/<slug>/SKILL.md.

Right now the behavior is asymmetric:

  • symlinked directories are followed in core-skills/src/loader.rs
  • perCwdExtraUserRoots is already supported by app-server
  • but a symlinked SKILL.md file is skipped

So Codex already accepts external skill locations in other forms. Disallowing only the file-level entry point creates a narrow implementation boundary rather than a consistent packaging rule.

This matters in practice because wrapper installs are useful for keeping canonical skills in versioned repos while avoiding top-level directory symlink pollution in harness roots. That is a legitimate workflow, especially for users syncing skills across multiple harnesses.

If the intended product decision is “full directory installs only,” that should probably come with first-class support for durable extra skill roots in the desktop/TUI flow. Otherwise the current behavior leaves a gap:

  • full-directory external roots are conceptually allowed
  • but the most surgical wrapper install is rejected
  • and the desktop client does not expose a durable extra-root workflow

So I think the most coherent options are:

  • support symlinked SKILL.md by canonicalizing before metadata/asset resolution, or
  • formally require full-directory installs and add first-class persistent extra skill roots in the client

The current middle state is hard to reason about because the protocol surface and the loader behavior are not aligned.

mrtomatwo · 2 months ago

@etraut-openai having a folder as package boundary is well and good.
The symlink is me explicitly adding another file into this very folder, quite the same as to me copying another file into it.

My use case is that I do not want to scatter multiple copies of my skills across my system but symlink a single source of truth.

Treating symlinks just like files is a well established pattern in the Unix world and codex treating them the same seems like an obvious choice.

Your argument does not address this issue.
Please reconsider re-opening and fixing this.

atsaplin · 19 days ago

Adding a fresh data point in support of reconsidering this, since the discussion above stalled on the "by design" framing.

Still reproduces on the latest release

  • codex-cli 0.147.0 (Homebrew cask), macOS Darwin 25.5.0 arm64, model gpt-5.6-sol
  • User-level skill root ~/.agents/skills (the documented global root)

Minimal matrix — three skills side by side, only the symlink shape differs:

TMP=$(mktemp -d)
SK=~/.agents/skills

# real source files (outside the skills root)
mkdir -p "$TMP/src"; printf '%s\n' '---' 'name: repro-filelink' 'description: filelink probe' '---' > "$TMP/src/SKILL.md"
mkdir -p "$TMP/dir";  printf '%s\n' '---' 'name: repro-dirlink'  'description: dirlink probe'  '---' > "$TMP/dir/SKILL.md"

# A) real dir, SYMLINKED SKILL.md
mkdir -p "$SK/repro-filelink"; ln -s "$TMP/src/SKILL.md" "$SK/repro-filelink/SKILL.md"
# B) real dir + real SKILL.md (control)
mkdir -p "$SK/repro-realdir";  printf '%s\n' '---' 'name: repro-realdir' 'description: realdir probe' '---' > "$SK/repro-realdir/SKILL.md"
# C) dir-symlink -> real dir with real SKILL.md
ln -s "$TMP/dir" "$SK/repro-dirlink"

codex exec --sandbox read-only --skip-git-repo-check \
  'List every skill available to you whose name starts with "repro-". Output ONLY the exact skill names, one per line.'

Observed output:

repro-dirlink
repro-realdir

repro-filelink (real dir + symlinked SKILL.md) is silently missing, even though the link resolves to a perfectly readable regular file:

$ stat -f '%N -> %HT' -L ~/.agents/skills/repro-filelink/SKILL.md
.../repro-filelink/SKILL.md -> Regular File

So the inconsistency the earlier commenters described is intact: symlinked directories are followed, symlinked SKILL.md files are dropped.

Where it happens now (post-refactor to the walk-based loader)

The loader no longer lives in core-skills/src/loader.rs; discovery now goes through a generic filesystem walk. The single line that drops the entry is codex-rs/file-system/src/lib.rs (in walk_via_directory_reads):

if metadata.is_symlink && (!options.follow_directory_symlinks || !metadata.is_directory) {
    continue;
}

get_metadata already follows the link (tokio::fs::metadata), so for a symlinked SKILL.md it reports is_symlink = true, is_file = true, is_directory = false. The !metadata.is_directory term then makes the guard true and the entry is skipped before it can become a WalkEntryKind::File, so it never reaches skill_files in ext/skills/src/loader/discovery.rs. (There's even a comment there acknowledging "file symlinks omitted by the walk.") The behavior is pinned by the ignores_symlinked_skill_files_and_directory_cycles test in discovery_tests.rs.

Why "the package boundary is the directory" doesn't settle it

I agree the directory is the package boundary, and I'm fine with that rule. But this bug is specifically about the entry-point file inside a directory I already control. Case (A) above is a real skill directory in the skills root; the only difference from the control is that its SKILL.md happens to be a symlink. Following it and canonicalizing to the target's parent for sibling asset/script resolution (as @light-merlin-dark proposed above) preserves the directory-package rule exactly, while fixing the asymmetry.

Real-world impact: dotfiles managers + the .agents/skills convention

This isn't an exotic edge case. Dotfiles/config managers (GNU Stow, chezmoi, Nix/Home Manager) deploy config by symlinking files — that's their whole model. With the .agents/skills convention (agentskills.io), a shared skills library composed across machines/tools lands as ~/.agents/skills/<name>/SKILL.md -> <store>/..., which Codex then can't see.

Concretely: in my dotfiles I compose a global skills library with optional per-workspace packages. Because Codex drops symlinked SKILL.md, I had to hand-write a "compose" step that materializes each skill as a real directory instead of letting the manager symlink it — pure workaround code that exists only for this one behavior. Claude Code, opencode, and Mistral Vibe all discover symlinked skill files here (as @lhohan noted on #10470), so cross-tool setups behave inconsistently for Codex alone.

Ask

Would the team reconsider following a symlinked SKILL.md when it resolves to a regular file (canonicalizing before metadata/asset resolution so the directory-package rule is preserved), with the usual cycle/MAX_SCAN_DEPTH protection that already guards directory symlinks? If it's intentionally staying as-is, could that be documented as an explicit limitation on the skills page, with the recommended dotfiles pattern (symlink the directory, not the file) called out — so people stop rediscovering this via silent non-discovery?

Happy to test any fix against the matrix above on 0.147.0.

🤖 Generated with Claude Code