Skills Ate Commands: What Changed in Claude Code

Tooling
Agents
Claude Code
Author

Ravi Kalia

Published

July 24, 2026

Skills Ate Commands

Claude Code stores user prompts as markdown under .claude/commands/ (slash commands) or .claude/skills/ (skills). Custom commands merged into skills; the commands reference now points to skills for new additions.

1 Invocation modes

Slash commands — one entry point: user types /name.

Skills — two entry points:

  1. Direct/skill-name plus arguments ($ARGUMENTS, $N, or named placeholders from frontmatter arguments).
  2. Implicit — Claude matches user phrasing against each skill’s description and invokes without a slash (invocation control).

Same skill, both doors:

# Direct: you point at it
/contrast-audit styles/main.css
# Implicit: you describe the need
Is the button text readable
against that new background color?

The left substitutes styles/main.css into the skill body. The right matches the skill description (“audit CSS color contrast”) and invokes with inferred arguments.

2 Skill directory layout

Commands were single markdown files. Skills are directories with required SKILL.md:

my-skill/
├── SKILL.md         # required: YAML frontmatter + markdown instructions
├── reference.md     # optional: detailed docs, loaded only when needed
├── examples.md      # optional: sample outputs showing expected format
├── template.md      # optional: fill-in-the-blank templates
└── scripts/
    └── helper.py    # optional: executable — run, never loaded into context

SKILL.md structure:

  1. YAML frontmatter — metadata for invocation timing; only description is loaded into context upfront.
  2. Markdown body — instructions; on invoke, rendered body is inserted as a message for the session.

Supporting files load on demand; scripts execute without entering context. Dynamic lines like !`git diff HEAD` run at invoke time (dynamic context injection).

Install locations (directory name → command name): enterprise managed settings, ~/.claude/skills/, .claude/skills/, plugins.

3 Frontmatter fields

Frontmatter controls permission and timing. description is recommended — drives auto-invocation; write what it does and when to use it (combined with when_to_use, truncated at 1,536 characters in listings).

Field What it does
name Display name in skill listings. Defaults to the directory name (which is what names the /command for personal and project skills).
description What the skill does and when to use it. Drives Claude’s decision to auto-invoke. Falls back to the body’s first paragraph if omitted.
when_to_use Extra trigger context (phrasings, example requests). Appended to description in the listing.
argument-hint Autocomplete hint for expected arguments, e.g. [css-file].
arguments Names for positional arguments, enabling $name substitution in the body.
disable-model-invocation true = only you can invoke it. Use for side-effectful workflows (/deploy, /commit) where Claude shouldn’t decide the timing. Also removes the description from context.
user-invocable false = hidden from the / menu; only Claude can invoke it. For background knowledge that isn’t a meaningful user action.
allowed-tools Tools Claude may use without a permission prompt during the turn that invokes the skill. The grant clears on your next message.
disallowed-tools Tools removed from Claude’s pool while the skill is active.
model Model override while the skill runs (rest of the current turn).
effort Effort-level override (lowmax) while the skill is active.
context fork = run the skill in an isolated subagent instead of inline. See next section.
agent Which subagent type executes a context: fork skill (default general-purpose).
background With context: fork: false waits for the result in the invoking turn instead of running in the background (default true).
hooks Hooks scoped to this skill’s lifecycle.
paths Glob patterns; the skill only auto-activates when Claude is working with matching files.
shell bash (default) or powershell for !`command` dynamic-context lines.

4 Context window model

Analogy: index (name, description) always loaded; body read on invoke; supporting files and scripts loaded or executed only when referenced. Commands loaded the full method on every invoke.

5 Skills vs subagents

Skill (default) — instructions inserted inline; shared session context; content persists after run.

Subagent — separate actor with own system prompt, tools, and isolated context (sub-agents docs).

context: fork — skill body becomes subagent prompt; agent selects type; default background execution.

Subagents can preload skills via skills field.

Three combinations, then, differing in whose system prompt is in charge, where the task text comes from, and whether the work happens in your context or somewhere else:

Approach System prompt Task comes from Context
Skill (default) your session’s SKILL.md body, inline shared with your conversation, persists
Skill with context: fork from the agent type SKILL.md body isolated subagent
Subagent with skills: the subagent’s own body Claude’s delegation message isolated, skills preloaded as reference

Rule: knowledge/procedures → skill; isolated heavy work → subagent; authored task off to the side → context: fork.

6 Example: contrast-audit skill

WCAG 2.1 requires contrast ratio ≥ 4.5:1 (AA) for normal text (WCAG 2.1 contrast minimum). AAA: 7:1.

Skill scans CSS color pairs, computes ratios, outputs chart and HTML report. Code below executes at render time.

The skill directory is two files:

contrast-audit/
├── SKILL.md
└── scripts/
    └── audit.py

${CLAUDE_SKILL_DIR} in body and allowed-tools pre-approves the script command.

---
name: contrast-audit
description: Audit a CSS file for WCAG color-contrast failures. Use when the
  user asks to check contrast, color accessibility, or whether text is
  readable against its background.
argument-hint: "[css-file]"
allowed-tools: Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/audit.py *)
---

Audit the CSS file the user named (if none given, find `*.css` in the
project and ask which one):

```bash
python3 ${CLAUDE_SKILL_DIR}/scripts/audit.py $ARGUMENTS
```

The script prints one line per color pair, writes `contrast-chart.png` and
`contrast-report.html` next to the CSS file, and exits non-zero if any pair
fails AA.

After it runs:
1. Summarize the failures, worst ratio first.
2. For each failure, propose a darkened/lightened replacement hex that
   clears 4.5:1 while staying close to the original hue.
3. Point the user at `contrast-report.html` for the visual version.

6.1 audit.py

Plain Python: regex for CSS color pairs, WCAG relative luminance, matplotlib chart, HTML report. Script output — not model arithmetic — enters context.

scripts/audit.py — the full script (click to expand)
"""contrast-audit: WCAG contrast checker for CSS color pairs."""
import re
import sys
from dataclasses import dataclass
from pathlib import Path

import matplotlib.pyplot as plt

HEX = r"#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b"
AA, AAA = 4.5, 7.0

# Status colors for the chart: good / warning / critical.
GRADE_COLOR = {"AAA": "#0ca30c", "AA": "#fab219", "FAIL": "#d03b3b"}
GRADE_LABEL = {"AAA": "AAA ✓", "AA": "AA only", "FAIL": "FAIL ✗"}


@dataclass
class Pair:
    selector: str
    fg: str
    bg: str
    ratio: float
    grade: str


def expand_hex(h: str) -> str:
    h = h.lstrip("#").lower()
    if len(h) == 3:
        h = "".join(c * 2 for c in h)
    return f"#{h}"


def relative_luminance(hex_color: str) -> float:
    """WCAG 2.1 relative luminance of an sRGB color."""
    h = expand_hex(hex_color).lstrip("#")
    channels = [int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)]
    lin = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
    return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]


def contrast_ratio(fg: str, bg: str) -> float:
    lighter, darker = sorted((relative_luminance(fg), relative_luminance(bg)), reverse=True)
    return (lighter + 0.05) / (darker + 0.05)


def grade(ratio: float) -> str:
    return "AAA" if ratio >= AAA else "AA" if ratio >= AA else "FAIL"


def parse_pairs(css: str) -> list[Pair]:
    """Find rules declaring both a text color and a background color (hex only)."""
    pairs = []
    for m in re.finditer(r"([^{}]+)\{([^}]*)\}", css):
        selector, body = m.group(1).strip(), m.group(2)
        fg = re.search(rf"(?<![-\w])color\s*:\s*({HEX})", body)
        bg = re.search(rf"background(?:-color)?\s*:\s*({HEX})", body)
        if fg and bg:
            f, b = expand_hex(fg.group(1)), expand_hex(bg.group(1))
            r = contrast_ratio(f, b)
            pairs.append(Pair(selector, f, b, r, grade(r)))
    return pairs


def contrast_chart(pairs: list[Pair], path: str | None = None):
    """Horizontal bars of contrast ratio, color-coded by grade, thresholds marked."""
    pairs = sorted(pairs, key=lambda p: p.ratio)
    fig, ax = plt.subplots(figsize=(8, 0.55 * len(pairs) + 1.2), dpi=150)
    fig.patch.set_facecolor("white")
    ys = range(len(pairs))
    ax.barh(ys, [p.ratio for p in pairs], height=0.55,
            color=[GRADE_COLOR[p.grade] for p in pairs], zorder=3)
    ax.set_ylim(-0.55, len(pairs) + 0.75)
    for thr, name in ((AA, "AA 4.5:1"), (AAA, "AAA 7:1")):
        ax.axvline(thr, color="#9aa0a6", lw=1, ls="--", zorder=2)
        ax.text(thr, len(pairs) + 0.05, f" {name}", color="#6b7280",
                fontsize=8, va="bottom")
    for y, p in zip(ys, pairs):
        ax.text(p.ratio + 0.15, y, f"{p.ratio:.2f}:1 · {GRADE_LABEL[p.grade]}",
                va="center", fontsize=8.5, color="#374151")
    ax.set_yticks(list(ys), [p.selector for p in pairs], fontsize=9)
    ax.set_xlim(0, max(p.ratio for p in pairs) + 4)
    ax.set_xlabel("contrast ratio", fontsize=9, color="#6b7280")
    ax.tick_params(colors="#6b7280", length=0)
    for spine in ax.spines.values():
        spine.set_visible(False)
    ax.grid(axis="x", color="#e5e7eb", lw=0.6, zorder=0)
    ax.set_title("WCAG contrast ratios by selector", fontsize=11,
                 color="#111827", loc="left")
    fig.tight_layout()
    if path:
        fig.savefig(path, bbox_inches="tight")
    return fig


def render_report(pairs: list[Pair]) -> str:
    """Self-contained HTML fragment: swatch preview per pair with pass/fail badges."""
    badge = lambda ok, label: (
        f'<span style="font:600 11px system-ui;padding:2px 8px;border-radius:10px;'
        f'color:#fff;background:{"#0ca30c" if ok else "#d03b3b"}">'
        f'{"✓" if ok else "✗"} {label}</span>'
    )
    rows = ""
    for p in sorted(pairs, key=lambda p: p.ratio):
        rows += f'''
        <div style="display:flex;align-items:center;gap:14px;padding:10px 12px;
                    border:1px solid #e5e7eb;border-radius:8px;margin:6px 0;
                    background:#fff">
          <div style="width:72px;height:44px;border-radius:6px;flex:none;
                      display:flex;align-items:center;justify-content:center;
                      font:700 18px system-ui;color:{p.fg};background:{p.bg};
                      border:1px solid #d1d5db">Aa</div>
          <div style="flex:1;font:13px ui-monospace,monospace;color:#111827">
            {p.selector}<br>
            <span style="color:#6b7280">{p.fg} on {p.bg}</span>
          </div>
          <div style="font:600 14px system-ui;color:#111827;width:64px">
            {p.ratio:.2f}:1</div>
          {badge(p.ratio >= AA, "AA")} {badge(p.ratio >= AAA, "AAA")}
        </div>'''
    return f'<div style="max-width:640px;font-family:system-ui">{rows}</div>'


def main(argv: list[str]) -> int:
    css_path = Path(argv[0]) if argv else Path("styles.css")
    pairs = parse_pairs(css_path.read_text())
    for p in sorted(pairs, key=lambda p: p.ratio):
        print(f"{p.selector:<14} {p.fg} on {p.bg}  {p.ratio:5.2f}:1  {p.grade}")
    contrast_chart(pairs, str(css_path.with_name("contrast-chart.png")))
    report = "<!DOCTYPE html><meta charset='utf-8'><title>Contrast audit</title>" \
             f"<body style='background:#f9fafb;padding:24px'>{render_report(pairs)}</body>"
    css_path.with_name("contrast-report.html").write_text(report)
    return 1 if any(p.grade == "FAIL" for p in pairs) else 0


if __name__ == "__main__" and "ipykernel" not in sys.modules:
    sys.exit(main(sys.argv[1:]))

6.2 Sample CSS

Hand-written sample.css for this post: eight rules, mix of pass/marginal/fail. #0d6efd (Bootstrap link blue) on white: 4.5008:1 — tuned to AA threshold.

from pathlib import Path

Path("sample.css").write_text("""\
.body-text { color: #212529; background-color: #ffffff; }
.hero      { color: #ffffff; background-color: #4a3aa7; }
.alert     { color: #721c24; background-color: #f8d7da; }
.footer    { color: #adb5bd; background: #212529; }
.btn       { color: #ffffff; background-color: #6c757d; }
.link      { color: #0d6efd; background-color: #ffffff; }
.muted     { color: #999999; background-color: #ffffff; }
.badge     { color: #ffffff; background-color: #ffc107; }
""")

pairs = parse_pairs(Path("sample.css").read_text())
for p in sorted(pairs, key=lambda p: p.ratio):
    print(f"{p.selector:<12} {p.fg} on {p.bg}  {p.ratio:5.2f}:1  {p.grade}")
.badge       #ffffff on #ffc107   1.63:1  FAIL
.muted       #999999 on #ffffff   2.85:1  FAIL
.link        #0d6efd on #ffffff   4.50:1  AA
.btn         #ffffff on #6c757d   4.69:1  AA
.footer      #adb5bd on #212529   7.43:1  AAA
.alert       #721c24 on #f8d7da   8.25:1  AAA
.hero        #ffffff on #4a3aa7   8.56:1  AAA
.body-text   #212529 on #ffffff  15.43:1  AAA

Printed lines = script stdout (what Claude reads). Chart: bars sorted worst-first with AA/AAA thresholds.

Two notable failures: white on #ffc107 (1.63:1); #0d6efd on white (4.5008:1, no margin).

Pattern: thin SKILL.md + deterministic script + model for orchestration and fix suggestions.

fig = contrast_chart(pairs, "contrast-chart.png")

Horizontal bar chart of WCAG contrast ratios per CSS selector, color-coded green for AAA, amber for AA-only, red for fail, with dashed threshold lines at 4.5:1 and 7:1

from IPython.display import HTML

report_fragment = render_report(pairs)
Path("contrast-report.html").write_text(
    "<!DOCTYPE html><meta charset='utf-8'><title>Contrast audit</title>"
    f"<body style='background:#f9fafb;padding:24px'>{report_fragment}</body>"
)
HTML(report_fragment)
Aa
.badge
#ffffff on #ffc107
1.63:1
✗ AA ✗ AAA
Aa
.muted
#999999 on #ffffff
2.85:1
✗ AA ✗ AAA
Aa
.link
#0d6efd on #ffffff
4.50:1
✓ AA ✗ AAA
Aa
.btn
#ffffff on #6c757d
4.69:1
✓ AA ✗ AAA
Aa
.footer
#adb5bd on #212529
7.43:1
✓ AA ✓ AAA
Aa
.alert
#721c24 on #f8d7da
8.25:1
✓ AA ✓ AAA
Aa
.hero
#ffffff on #4a3aa7
8.56:1
✓ AA ✓ AAA
Aa
.body-text
#212529 on #ffffff
15.43:1
✓ AA ✓ AAA

7 Migration

Legacy .claude/commands/ files still work. Migration is mechanical — suitable for agent-assisted bulk conversion.

Migrate every file in .claude/commands/ to .claude/skills/<name>/SKILL.md.
Keep each command's body verbatim. Add a description in the frontmatter
saying what it does and when to use it, phrased the way I'd ask for it.
Set disable-model-invocation: true on anything side-effectful (deploy,
commit, release). Delete the old command files and open a PR.

Review PR for side-effectful skills: without disable-model-invocation: true, Claude can auto-invoke /deploy-class workflows.

Write new prompts as skills when they need helper scripts, reference docs, allowed-tools, or implicit invocation.

8 References