Skills: `!`shell`` substitution is single-pass — blocks hierarchical/composable skill authoring
Summary
The ` !<command> preprocessing in SKILL.md files runs as a single pass over the original file. Output of one substitution is never re-scanned, so a shared fragment that itself contains !... ` placeholders cannot be composed into another skill. This breaks the most natural pattern for hierarchical skill composition.
Per the skills doc, the mechanism is described as a preprocessing step but the docs are silent on whether substitution is recursive. Empirically: it is not.
Repro
Three skill files in ~/.claude/skills/:
recursion-test-base/SKILL.md
---
name: recursion-test-base
description: level 0
---
LEVEL_0_START
!`tail -n +6 /etc/hostname`
LEVEL_0_END
recursion-test-combined/SKILL.md
---
name: recursion-test-combined
description: level 1 — cats base
---
LEVEL_1_START
!`tail -n +6 /Users/me/.claude/skills/recursion-test-base/SKILL.md`
LEVEL_1_END
recursion-test-top/SKILL.md
---
name: recursion-test-top
description: level 2 — cats combined
---
LEVEL_2_START
!`tail -n +6 /Users/me/.claude/skills/recursion-test-combined/SKILL.md`
LEVEL_2_END
Invoke /recursion-test-top.
Expected
The model receives a fully resolved blob — the tail in combined's body re-runs after combined is inlined into top, and the tail in base's body re-runs after base is inlined into combined.
Actual
LEVEL_2_START
LEVEL_1_START
!`tail -n +6 /Users/me/.claude/skills/recursion-test-base/SKILL.md`
LEVEL_1_END
LEVEL_2_END
The outer tail ran. The cat'd content's own ` !tail ` came through as literal text to the model.
Use case: composable / hierarchical skill authoring
Skills are growing into a serious authoring surface, and the same authoring problems that drove #include, ES module imports, and Jinja {% include %} show up:
| Pattern | Why you want it |
|---|---|
| Shared preamble | "Always read these 3 docs first" — one fragment included by 20 skills; one place to edit. |
| Role/persona library | A personas/senior-reviewer.md fragment composed into every review-style skill. |
| Tool-context bundle | A fragment that itself dynamically lists relevant files via ` !git diff --stat , included by any skill that needs current-branch context. |language/cpp.md
| **Layered domain skills** | extends language/base.md; project/njin-cpp.md extends language/cpp.md. Each layer adds rules. |org-policy.md
| **Per-org policy fragment** | One canonical with dynamic content (e.g. !cat ~/.config/org-version `), composed into every project's skills. |
Today the only way to compose is to flatten everything into the bash command itself — fine for static cat, but it defeats the purpose for fragments that themselves want dynamic content.
Suggested behaviors (in order of preference)
- Recursively expand until fixed point, with a depth/iteration cap (e.g. 8) to bound runaway expansion. Cycles would be detectable via a visited-set of resolved fragments.
- Explicit opt-in syntax for "include this file and continue preprocessing it," e.g. `
!!cat fragment.mdor@include fragment.md`. Keeps single-pass default; opt-in to deeper resolution. - At minimum, document the current single-pass behavior in the skills page so authors know the boundary up front and can choose to flatten in their bash command.
Related observations from the same investigation
- `
!...only triggers when!is at line start (or after whitespace).FOO=!bar` is passed through as literal. This is not documented and is surprising — variables, assignments, and inline annotations all break silently. Worth either documenting or relaxing. - The kill-switch
disableSkillShellExecutionreplaces with[shell command execution disabled by policy]— but a denied/failed-to-find command appears to pass through silently as literal. Failure mode is invisible to the author.
Environment
- Claude Code: 2.1.143
- OS: macOS (Darwin 25.2.0)
- Shell:
bash(default)
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗