The docs say it in one line: “Custom commands have been merged into skills.” A file at .claude/commands/deploy.md and a skill at .claude/skills/deploy/SKILL.md both create /deploy and work the same way. The old slash-commands documentation page no longer exists on its own; that URL now serves the Skills page.
So a command file is the legacy form of a surface you are going to meet again in the next module. It is still supported, and your existing .claude/commands/ files keep working. One file, no directory, no decisions about what else belongs in the folder. Everything you learn here about arguments and shell injection carries into skills unchanged.
What a command file is
A markdown file with a body and, above it, an optional settings block fenced by two lines of three dashes. That block is the frontmatter from module 3, written in YAML, which is the indented key: value format you can see in the examples below. The body is a prompt. When you type /deploy, the body is rendered and enters the conversation.
Two rules govern what counts as running a command. A command is only recognized at the start of your message. Text that follows the command name becomes its arguments.
The name comes from the file name without its extension. deploy.md gives you /deploy. Rename the file and you rename the command. Frontmatter name: does nothing here, and neither does paths:; both fields are ignored in a .claude/commands/ file. Every other frontmatter field works.
Command files live in two places, and both behave the same way:
| Path | Applies to |
|---|---|
~/.claude/commands/*.md | all your projects |
.claude/commands/*.md | this project |
The smallest thing that works
Two lines of frontmatter and one line of body.
---
description: Deploy to production
---
Deploy $ARGUMENTS to production.
Save that as .claude/commands/deploy.md, type /deploy staging, and $ARGUMENTS becomes staging.
description is the only field worth recommending. It is what Claude reads to decide whether the command is relevant. If you leave it out, the first paragraph of the body is used instead.
.claude/commands/deploy.md The documented minimum: a description and a body.
---
description: Deploy to production
---
Deploy $ARGUMENTS to production. .claude/commands/changed.md ships to your plugin What you write in the Build step. This is the file that ends up in your plugin.
---
description: Summarize uncommitted changes and flag anything risky
argument-hint: [path]
---
## Diff
```!
git diff --stat -- $0 || true
```
## Instructions
Summarize the changes above in two or three bullets, then
list anything risky: missing error handling, hardcoded
values, tests that will now fail. If the diff is empty,
say there are no uncommitted changes. Read more
In module 12 this file moves to commands/ inside your plugin directory and ships to anyone who installs it. Nothing about its contents changes.
Arguments
Everything after the command name is one string, and you can reach it four ways.
| Form | Meaning |
|---|---|
$ARGUMENTS | the full argument string, exactly as typed |
$ARGUMENTS[N] | one argument by 0-based index |
$N | shorthand for the same thing |
$name | a named argument declared in the arguments frontmatter field |
The shorthand is where people get caught. $0 is the first argument. $1 is the second. This is not shell-style, where $0 is the program name and $1 is the first argument. Read it as an array index, because that is what it is.
/my-skill "hello world" second gives $0 = hello world and $1 = second. Quotes hold a phrase together as one argument, the way they do in a terminal, and that is all shell-style quoting means here. An index with nothing behind it stays in the text literally, so an unmatched $2 renders as the characters $2. An unmatched named placeholder expands to an empty string instead. To write a literal dollar amount, escape it with one backslash: \$1.00. Doubling the backslash does not work.
If $ARGUMENTS never appears in the body, whatever the user typed is appended to the end as ARGUMENTS: <value>, so nothing is silently dropped.
Two frontmatter fields shape the argument experience. argument-hint supplies the autocomplete hint, such as [issue-number] or [filename] [format]. arguments declares names that map to positions in order, which is how $issue works instead of $0.
Running shell commands inside the prompt
A command body can execute shell and paste the output into itself before Claude sees anything.
Inline, backtick form:
Current branch: !`git branch --show-current`
Fenced form, for anything multi-line: open the fence with three backticks followed by !, and put the commands inside.
The behaviour:
!is recognized only at the start of a line or immediately after whitespace. Something likeKEY=!`cmd`stays literal text.- Swapping the command for its output is called substitution, and it runs once, over the original file. Output is not rescanned, so a command that prints
!does not trigger another execution. - Commands run in the session shell’s working directory, which moves when you
cd. - A program writes its ordinary output to a stream called stdout and its complaints to a separate one called stderr. Under bash, stderr is merged into stdout, so Claude sees both.
- The default timeout is two minutes. Output past the inline ceiling arrives as a file path plus a preview rather than the whole payload, meaning the full text the command printed.
shell: bashis the default. Setshell: powershellto switch. On Windows without Git Bash a bash skill fails outright, reporting that the skill requires bash but Git Bash was not found.
Write .claude/commands/changed.md in a repo that has uncommitted work. Give it a description, an argument-hint of [path], a fenced ! block running git diff --stat -- $0, and instructions telling Claude what to do with the output. Run /changed with no argument, then /changed src. The second run should scope the diff. This file ships to commands/ in your plugin at the end of the course.
Every shell command finishes by handing back a number, its exit code. Zero means the command believes it worked, and anything else means it failed. An injected command that exits with any number other than zero aborts the entire invocation. Claude never sees the body, not even the parts that rendered fine. You get an error of this shape, with the command’s error output collected under [stderr]:
Shell command failed for pattern "git diff --stat -- src"
git diff --stat on a path that matches nothing is the everyday version of this. Any non-zero exit fails, with one carve-out: exit code 1 from search and comparison commands is tolerated, because that is how grep and diff report “no match” rather than “broken”. Everything else needs || true appended if a failure should be survivable.
Permissions produce the same abort through a different door. Injected commands never prompt. Anything other than an outright allow, an ask rule included, ends the invocation with Shell command permission check failed for pattern "...". Pre-approve the command in allowed-tools if it needs to run unattended.
A .claude/commands/foo.md is a markdown file with a prompt in it, so the content moves further than the format does. Copilot CLI reads that exact directory, unmodified, since version 0.0.399, and frontmatter became optional there in 0.0.412. Gemini CLI wants TOML rather than markdown: ~/.gemini/commands/name.toml with a required prompt field, {{args}} for arguments and !{...} for shell, which makes it a rewrite rather than a move. Codex CLI reads ~/.codex/prompts/*.md and invokes them as /prompts:name, and its docs are explicit that these are “not shared through your repository”, so a command you write there does not travel with the repo the way a checked-in .claude/commands/ file does.
Check yourself
Before module 5, you should be able to answer these without looking:
- You rename
.claude/commands/ship.mdtorelease.mdbut leavename: shipin the frontmatter. What do you type to invoke it? /report "last week" summaryruns a command whose body contains$1. What does$1render as?- Your command injects
grep -c TODO srcand the repo has no TODOs left, sogrepexits 1. Does the invocation survive?