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?
- Create a valid skill directory with a valid
SKILL.mdfrontmatter file. - Replace that
SKILL.mdwith a symlink to another validSKILL.mdfile. - Start/reload Codex in that workspace.
- Open
$skill suggestions.
Observed:
- skills with regular
SKILL.mdare listed - skills with symlinked
SKILL.mdare 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_
7 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
This is by design. The packaging boundary of a skill is its directory.
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.mdfiles directly instead of only following symlinked directories. If this approach looks acceptable, please merge the branch or cherry-pick the fix commit.@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?
_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:
If the packaging boundary is the skill directory, that is a reasonable rule. But rejecting a symlinked
SKILL.mdfile is not the only way to preserve that rule.The cleaner implementation would be:
SKILL.mdfile to be discoveredThat preserves the directory boundary while still allowing a wrapper install at
~/.codex/skills/<slug>/SKILL.md.Right now the behavior is asymmetric:
core-skills/src/loader.rsperCwdExtraUserRootsis already supported by app-serverSKILL.mdfile is skippedSo 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:
So I think the most coherent options are:
SKILL.mdby canonicalizing before metadata/asset resolution, orThe current middle state is hard to reason about because the protocol surface and the loader behavior are not aligned.
@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.
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), macOSDarwin 25.5.0arm64, modelgpt-5.6-sol~/.agents/skills(the documented global root)Minimal matrix — three skills side by side, only the symlink shape differs:
Observed output:
repro-filelink(real dir + symlinkedSKILL.md) is silently missing, even though the link resolves to a perfectly readable regular file:So the inconsistency the earlier commenters described is intact: symlinked directories are followed, symlinked
SKILL.mdfiles 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 iscodex-rs/file-system/src/lib.rs(inwalk_via_directory_reads):get_metadataalready follows the link (tokio::fs::metadata), so for a symlinkedSKILL.mdit reportsis_symlink = true,is_file = true,is_directory = false. The!metadata.is_directoryterm then makes the guard true and the entry is skipped before it can become aWalkEntryKind::File, so it never reachesskill_filesinext/skills/src/loader/discovery.rs. (There's even a comment there acknowledging "file symlinks omitted by the walk.") The behavior is pinned by theignores_symlinked_skill_files_and_directory_cyclestest indiscovery_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.mdhappens 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/skillsconventionThis 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/skillsconvention (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.mdwhen it resolves to a regular file (canonicalizing before metadata/asset resolution so the directory-package rule is preserved), with the usual cycle/MAX_SCAN_DEPTHprotection 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