If you find yourself typing the same elaborate instruction to Claude Code over and over — "review this diff for security issues, checking auth, input validation, and secrets, and format the findings as a list" — you're doing manual work the tool is built to eliminate. You can capture that instruction once as a custom slash command and invoke it forever with /security-review. Here's how, and the modern shape of the feature.
Commands are now skills
First, an important clarification, because the docs have evolved: custom slash commands have merged into Skills. The legacy form — a single markdown file at .claude/commands/deploy.md — still works and still creates /deploy. But the modern, recommended form is a skill: a directory .claude/skills/deploy/ containing a SKILL.md file. Both produce a /deploy command; the skill version just does more. So everything below is written in the skill form, which is where the feature is actively developed.
Creating one
A skill is a markdown file with YAML frontmatter and a body:
---
description: Review the current diff for security issues
argument-hint: [severity]
allowed-tools: Bash(git diff:*) Read
---
Review the staged diff for security problems. Check authentication,
input validation, and hardcoded secrets. Report findings as a list
ordered by severity.
Save that as .claude/skills/security-review/SKILL.md and you have /security-review. Two things worth noting immediately:
- The command name comes from the directory, not the frontmatter.
.claude/skills/security-review/SKILL.md→/security-review. Thename/descriptionfields control how it's labeled and when Claude might auto-invoke it, not the command string. - The body becomes the prompt. Everything after the frontmatter is what Claude receives when you invoke the command.
Skills live at two scopes: .claude/skills/ (project, shared with your team via git) and ~/.claude/skills/ (personal, available across all your projects).
Arguments
Commands get more useful when they take input. Claude Code substitutes several variables into the body before running it:
$ARGUMENTS— everything you passed, as one string.$1,$2, … — positional arguments.- Named arguments, if you declare them in frontmatter.
So a skill body containing Migrate the $1 component from $2 to $3 invoked as /migrate SearchBar React Vue becomes "Migrate the SearchBar component from React to Vue." Multi-word arguments use shell-style quoting: /my-cmd "hello world" second puts hello world in $1.
Injecting live context
This is the feature that turns a static prompt into a dynamic tool. Inside a skill body, !`<command>` runs a shell command before the content reaches Claude, and substitutes the output in place. So:
## Current diff
!`git diff --staged`
## Task
Review the diff above for security issues.
When you run this, Claude Code executes git diff --staged first and Claude receives the actual diff text, not the command. Your slash command now always operates on live data. (One gotcha: this injection runs once — the output isn't re-scanned for further !` placeholders.)
Two ways to invoke — and controlling that
Because commands are now skills, there are two invocation paths:
- You type it —
/security-review, from the/menu. - Claude auto-invokes it — if a skill's
descriptionmatches what you're asking for, Claude can reach for it on its own. That's powerful for knowledge-type skills but not what you want for something with side effects.
For anything that acts on the world (deploys, commits, destructive operations), set disable-model-invocation: true so only you can trigger it — Claude won't fire it unprompted. Conversely, user-invocable: false hides a skill from the / menu so it only serves as background knowledge Claude pulls in automatically. This split — model-invoked vs user-invoked — is the key mental model that distinguishes skills from the old commands.
Organizing them
Subdirectories create namespaced commands: .claude/skills/ops/deploy/SKILL.md becomes /ops:deploy. This keeps a large collection tidy and avoids name clashes. If a nested skill shares a name with a root one, both stay available (/deploy and /apps/web:deploy), and typing the bare name loads the root version.
The payoff
The reframe worth internalizing: a slash command is a prompt you refuse to retype. Any instruction detailed enough that you've written it more than twice — a review checklist, a release procedure, a specific refactoring pattern, a report format — is a candidate to become a command. You write it carefully once, with the exact tools it's allowed to use and the exact context it should pull in, and from then on it's a single slash away, executed consistently every time instead of approximated from memory. That consistency is the real win: the command doesn't just save keystrokes, it removes the variance of you remembering to ask for everything you wanted.