How to write a Claude Code slash command (with $ARGUMENTS)
Write a custom Claude Code slash command in .claude/commands: frontmatter, $ARGUMENTS and positional args, bash context, and four real examples.
9 min read
A Claude Code slash command is a Markdown file. Put fix-issue.md in .claude/commands/, write the prompt you keep retyping, and /fix-issue shows up in the command menu. Whatever you type after the command name gets substituted wherever the file says $ARGUMENTS.
That's the whole trick. The rest covers what makes a command reliable, plus four complete commands you can copy.
Where slash commands live
There are two locations:
| Location | Path | Scope |
|---|---|---|
| Project | .claude/commands/ | This repo. Commit it and your team gets the same commands. |
| Personal | ~/.claude/commands/ | Every project on your machine. |
The file name is the command name. .claude/commands/review.md becomes /review. Use lowercase and hyphens, and give the file a .md extension or it won't load.
Subdirectories organize commands. In the current docs, a file at .claude/commands/frontend/component.md is invoked as /frontend:component, with the folder path turned into a prefix. How namespaced commands display in the menu has changed between versions, so if you rely on folders, check how yours renders them in /.
A note on naming: recent versions of Claude Code have merged custom commands into skills. A skill at .claude/skills/deploy/SKILL.md and a command at .claude/commands/deploy.md both give you /deploy, and .claude/commands/ keeps working. Everything below applies to both. If you're deciding which format to use, I wrote up the trade-offs in skills vs subagents vs slash commands.
The minimal command
No frontmatter required. This is a valid command:
Explain how this project is structured in plain language. Start with the
entry points, then how data flows between modules, then anything surprising.Save it as .claude/commands/explain.md and run /explain. When Claude Code runs a command, the file's contents become your prompt. That's it.
Frontmatter fields
Frontmatter goes between --- lines at the top. All fields are optional.
---
description: Fix a GitHub issue by number and open a branch
argument-hint: [issue-number]
allowed-tools: Bash(gh issue view *), Bash(git checkout *)
model: sonnet
---What each one does:
descriptionis the text shown next to the command in the/menu. Make it say what the command does, not what it is. "Fix a GitHub issue by number" beats "Issue helper".argument-hintshows up during autocomplete so you remember what to pass.[issue-number]or[file] [format]are typical.allowed-toolspre-approves tools for the turn that runs the command, so Claude doesn't stop to ask. It's required in practice if the command injects bash output (more on that below). Patterns likeBash(git diff *)scope the grant to specific commands. Older versions documented aBash(git diff:*)form, so match whatever your version's docs show.modeloverrides the model for that turn. Use it when a cheap model is fine (formatting a commit message) or when you want the heavier one (planning a refactor).
$ARGUMENTS and positional arguments
$ARGUMENTS expands to everything you typed after the command name, as one string.
Fix GitHub issue $ARGUMENTS. Read the issue, find the relevant code,
make the fix, and add a test that would have caught it.| You type | Claude receives |
|---|---|
/fix-issue 482 | Fix GitHub issue 482. Read the issue... |
/fix-issue 482 but don't touch the API | Fix GitHub issue 482 but don't touch the API. Read the issue... |
/fix-issue | Fix GitHub issue . Read the issue... |
That last row matters. An empty argument doesn't fail, it just leaves a hole in the sentence. Handle it in the prompt (see the tips section).
Positional arguments
When a command takes several distinct inputs, use positions instead of one string. Here's the catch: the numbering has changed. Older Claude Code docs used $1 for the first argument and $2 for the second. The current docs index from zero: $ARGUMENTS[0] (or the shorthand $0) is the first argument, $1 is the second.
So with the current behavior:
Migrate the $0 component from $1 to $2. Preserve existing behavior and tests.| You type | $0 | $1 | $2 |
|---|---|---|---|
/migrate SearchBar JavaScript TypeScript | SearchBar | JavaScript | TypeScript |
/migrate "Search Bar" JS TS | Search Bar | JS | TS |
Quotes group a multi-word value into one argument. A placeholder with no matching argument stays in the text literally.
If you share commands across machines with different versions, sidestep the numbering question with named arguments. Recent versions support an arguments frontmatter field:
---
description: Migrate a component between languages
argument-hint: [component] [from] [to]
arguments: component from to
---
Migrate the $component component from $from to $to.
Preserve existing behavior and tests.Names map to positions in order, and a missing named argument expands to an empty string. Check that your version supports the field before relying on it.
Injecting live context with !
A line starting with ! followed by a command in backticks runs that command before Claude sees the prompt, and the output replaces the line. This is what turns a command from a canned prompt into something grounded in your actual working tree.
---
description: Summarize uncommitted changes
allowed-tools: Bash(git status *), Bash(git diff *)
---
## Status
!`git status --short`
## Diff
!`git diff HEAD`
Summarize these changes in three bullets, then list anything risky.Two things trip people up:
- Permissions. Injected commands don't prompt you. If a command isn't allowed by your permission rules or by
allowed-tools, the whole invocation fails instead. Put every injected command inallowed-tools. - Exit codes. A command that exits non-zero aborts the entire invocation, and Claude never sees the prompt. If a check script exits 1 when it finds problems, append
|| true.
Referencing files with @
@path includes a file's contents in the prompt, the same way it works when you type it in chat:
Review @src/auth/session.ts against the rules in @docs/security.md.
List violations with line numbers.For a command that takes a path as an argument, I'd rather tell Claude to read $ARGUMENTS itself, so a typo produces a clear "file not found".
Four complete commands
These are complete files. Drop them into .claude/commands/ and adjust.
/fix-issue
---
description: Fix a GitHub issue by number
argument-hint: [issue-number]
allowed-tools: Bash(gh issue view *)
---
If no issue number was given ("$ARGUMENTS" is empty), stop and ask for one.
!`gh issue view $ARGUMENTS`
Fix the issue above.
1. Find the code involved. Quote the file paths before changing anything.
2. Make the smallest change that fixes it.
3. Add or update a test that fails without the fix.
4. Run the tests for the files you touched.
Finish with: files changed, test added, and anything you were unsure about.One caveat: with no argument, the injected gh issue view line has nothing to look up and should exit with an error, which aborts the invocation before Claude reads the "stop and ask" line. That's a fine outcome, just know it's the shell failing, not Claude.
/review
---
description: Review the current diff before I commit
allowed-tools: Bash(git diff *), Bash(git status *)
---
## Changes
!`git status --short`
!`git diff HEAD`
Review this diff as a strict senior engineer. Focus on: $ARGUMENTS
Report in this format:
- **Blocking**: bugs, security problems, broken behavior. File and line.
- **Should fix**: missing tests, unclear naming, dead code.
- **Fine as is**: one line on what looks good.
Do not edit files. If the diff is empty, say so and stop.$ARGUMENTS here is optional focus. /review alone does a general pass, /review error handling in the retry loop narrows it.
/commit
---
description: Write a commit message for staged changes and commit
argument-hint: [optional context]
allowed-tools: Bash(git diff *), Bash(git commit *)
model: haiku
---
!`git diff --cached`
Write a commit message for the staged diff above.
- Subject line: imperative, under 60 characters, no trailing period.
- Body: what changed and why, wrapped at 72 characters. Skip it for trivial changes.
- Extra context from me: $ARGUMENTS
If nothing is staged, say so and stop. Otherwise show me the message,
then run git commit with it.A smaller model is plenty here. Use whatever your setup has, or drop the line.
/ship
This one hands off to a release-gate subagent: an agent defined in .claude/agents/ whose only job is to run your checks and return SHIP or BLOCK.
---
description: Run the release gate before merging
argument-hint: [what this change is]
allowed-tools: Bash(git status *), Bash(git log *)
---
## Working tree
!`git status --short`
## Recent commits
!`git log --oneline -10`
The change being shipped: $ARGUMENTS
Use the release-gate agent to check this change. It should run tests,
typecheck, lint, and build, and return SHIP or BLOCK with the exact
failing command and output for any BLOCK.
If BLOCK: list what failed and stop. Do not try to fix it in this turn.
If SHIP: summarize what was verified in three lines.The command runs in the main conversation, so it can call the subagent. That matters because subagents can't spawn other subagents, so the main thread has to orchestrate the hand-off. The full setup for the agent itself is in adding a release gate to Claude Code. SlashKits ships its own /ship command and shipper agent built on the same pattern, if you'd rather not write them.
Tips that make commands hold up
Write workflows, not one-liners. If a command is one sentence, you could have typed it. Commands earn their place when they encode steps you'd otherwise forget: read first, change the minimum, test, report.
Be explicit about the output. "Review this" gets you an essay. A fixed format (Blocking / Should fix / Fine) gets you something you can scan in five seconds, and the same shape every time.
Say what to do with bad input. Every command that takes arguments should have a line like "If no issue number was given, stop and ask." Otherwise Claude guesses.
Check them into git. Project commands in .claude/commands/ are code. Review changes to them like code, especially allowed-tools, since a command can grant itself shell access.
Test with edge-case arguments. Run each command with no arguments, with a quoted multi-word argument, and with something wrong (an issue number that doesn't exist) before you trust it.
Troubleshooting
The command doesn't show up
- Check the path. It must be
.claude/commands/in the project root (or~/.claude/commands/), spelled exactly, not.claude/command/orclaude/commands/. - Check the extension.
review.md, notreview.txtorreview. - Restart the session. Claude Code may only pick up new command files at startup, depending on version and location.
- Look for a name clash. If a skill and a command share a name, the skill wins, so an old skill can hide a new command.
- If it's in a subfolder, type the prefix:
/frontend:component, not/component.
The arguments come through empty
- Make sure the placeholder is spelled
$ARGUMENTS, all caps, with the dollar sign. - If you use positional arguments, check the numbering for your version. On a current build, a command written with
$1as the first argument will receive the second one, and a single-argument call leaves$1unfilled. - If the file has no placeholder at all, current versions append your input at the end as
ARGUMENTS: <value>. The text arrives, just not where you expected it.
The command fails before Claude responds
That's almost always an injected ! command. Either it isn't covered by allowed-tools, or it exited non-zero. Run the same shell command yourself in the project directory to see which.