Skills: `!`shell`` substitution is single-pass — blocks hierarchical/composable skill authoring

Resolved 💬 2 comments Opened May 16, 2026 by mannewalis Closed May 17, 2026

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. |
| **Layered domain skills** |
language/cpp.md extends language/base.md; project/njin-cpp.md extends language/cpp.md. Each layer adds rules. |
| **Per-org policy fragment** | One canonical
org-policy.md 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)

  1. 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.
  2. Explicit opt-in syntax for "include this file and continue preprocessing it," e.g. ` !!cat fragment.md or @include fragment.md`. Keeps single-pass default; opt-in to deeper resolution.
  3. 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 disableSkillShellExecution replaces 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)

View original on GitHub ↗

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