How slash commands are typed, registered, assembled into a pipeline, processed at input, and dispatched inside the REPL
Every /something you type in Claude Code is a Command — a TypeScript object that
carries metadata (name, description, availability) plus one of three execution strategies (local, local-jsx, or prompt).
Commands live in src/commands.ts and the src/commands/ directory tree.
The top-level type is a discriminated union:
export type Command = CommandBase & (PromptCommand | LocalCommand | LocalJSXCommand)
The discriminant is the type field — every command object must declare exactly one of the three string literals: "local", "local-jsx", or "prompt".
COMMANDS(), plus an arbitrary number of dynamic ones loaded from skills directories, plugins, workflows, and MCP servers — all resolved at runtime via getCommands(cwd).
Pure TypeScript function. Runs synchronously in the current process. Returns a LocalCommandResult — either {type:'text'}, {type:'compact'}, or {type:'skip'}.
Renders a React/Ink component into the terminal TUI. Returns a ReactNode via the call(onDone, context, args) signature. Blocked from bridge/remote mode.
Expands to text content blocks sent to the model. Declares getPromptForCommand(args, context) which returns ContentBlockParam[]. Powers skills, workflows, and built-in agentic flows.
local — the /compact commandRegistration in src/commands/compact/index.ts:
const compact = { type: 'local', name: 'compact', description: 'Clear conversation history but keep a summary in context', isEnabled: () => !isEnvTruthy(process.env.DISABLE_COMPACT), supportsNonInteractive: true, argumentHint: '<optional custom summarization instructions>', load: () => import('./compact.js'), } satisfies Command
The load() pattern is universal for local commands — it defers the heavy implementation module until the command is actually invoked, keeping startup fast. The module must export { call: LocalCommandCall }:
export const call: LocalCommandCall = async (args, context) => { // args = trimmed string after "/compact" // context = ToolUseContext + REPL state const customInstructions = args.trim() // ... runs compaction, returns: return { type: 'compact', compactionResult, displayText } }
The LocalCommandCall signature always receives (args: string, context: LocalJSXCommandContext) — a single raw argument string (everything after the command name) and a rich context object that includes messages, setMessages, options, and the abort controller.
local-jsx — the /help commandRegistration in src/commands/help/index.ts:
const help = { type: 'local-jsx', name: 'help', description: 'Show help and available commands', load: () => import('./help.js'), } satisfies Command
The loaded module exports { call: LocalJSXCommandCall }:
export const call: LocalJSXCommandCall = async ( onDone, { options: { commands } }, ) => { return <HelpV2 commands={commands} onClose={onDone} /> }
Note the reversed argument order compared to local commands: (onDone, context, args). The onDone callback accepts an optional string result plus options like shouldQuery, display, and nextInput. When onDone() fires, the REPL tears down the component and resumes normal input.
The /model command uses the same pattern but adds a computed getter for its description so it always reflects the currently selected model name:
export default { type: 'local-jsx', name: 'model', get description() { return `Set the AI model (currently ${renderModelName(getMainLoopModel())})` }, argumentHint: '[model]', get immediate() { return shouldInferenceConfigCommandBeImmediate() }, load: () => import('./model.js'), }
prompt — the /commit commandPrompt commands define getPromptForCommand instead of load. They return an array of ContentBlockParam objects that become the first user turn when the command fires:
const command = { type: 'prompt', name: 'commit', description: 'Create a git commit', allowedTools: ['Bash(git add:*)', 'Bash(git status:*)', 'Bash(git commit:*)'], contentLength: 0, // 0 = dynamic (computed at call time) progressMessage: 'creating commit', source: 'builtin', async getPromptForCommand(_args, context) { const promptContent = getPromptContent() const finalContent = await executeShellCommandsInPrompt( promptContent, context, '/commit' ) return [{ type: 'text', text: finalContent }] }, } satisfies Command
The executeShellCommandsInPrompt helper scans the prompt for !`shell cmd` backtick patterns and replaces them with live shell output before the text reaches the model. This is how /commit inlines the current git status, git diff HEAD, and recent log into the prompt automatically.
allowedTools restricts which tools the model may call during this command's execution — a security layer that prevents the model from invoking anything outside the declared list.
All three types share a common base. The most important fields:
| Field | Type | Description |
|---|---|---|
name | string | The slash command name, e.g. "compact". Users type /compact. |
description | string | Shown in typeahead and /help. Can be a getter for dynamic descriptions. |
aliases | string[]? | Alternative names. /clear also responds to /reset and /new. |
isEnabled | () => boolean | Feature-flag or env-var guard. Called fresh on every getCommands() pass. |
isHidden | boolean? | Hides from typeahead and help UI while still allowing invocation. |
availability | CommandAvailability[]? | Auth gate: 'claude-ai' (subscriber) or 'console' (API key user). |
argumentHint | string? | Grayed hint shown after the command name in typeahead, e.g. [model]. |
immediate | boolean? | If true, command runs without waiting for any in-flight AI request to stop. |
loadedFrom | string? | Origin tag: 'skills', 'plugin', 'bundled', 'commands_DEPRECATED', 'mcp'. |
whenToUse | string? | Model-facing usage hint (from SKILL.md frontmatter). Controls SkillTool visibility. |
isSensitive | boolean? | Redacts command args from conversation history if true. |
availability is a static auth-provider check run before feature flags — it controls who can see the command. isEnabled() is a runtime check for feature flags and env vars — it controls whether the command is active right now. Both must pass for a command to appear in getCommands().
Commands reach the REPL through a multi-stage assembly pipeline in src/commands.ts:
A memoized function returns the canonical list of ~80 built-in commands — /help, /model, /clear, /compact, /commit, /review, etc. It is declared as a function (not a constant) so that it executes after config is readable at startup:
const COMMANDS = memoize((): Command[] => [ addDir, advisor, agents, branch, btw, clear, compact, config, cost, diff, ... // feature-flagged commands are spread conditionally: ...(ultraplan ? [ultraplan] : []), ...(!isUsing3PServices() ? [logout, login()] : []), ])
All non-static sources are loaded in parallel and merged. The merge order is deterministic and matters for deduplication:
const loadAllCommands = memoize(async (cwd: string) => { const [ { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills }, pluginCommands, workflowCommands, ] = await Promise.all([ getSkills(cwd), getPluginCommands(), getWorkflowCommands ? getWorkflowCommands(cwd) : Promise.resolve([]), ]) return [ ...bundledSkills, // lowest index → highest priority in dedup ...builtinPluginSkills, ...skillDirCommands, ...workflowCommands, ...pluginCommands, ...pluginSkills, ...COMMANDS(), // built-ins last ] })
Every call to getCommands(cwd) re-runs availability and isEnabled checks against the memoized command pool. Dynamic skills (discovered during file operations) are inserted just before the built-in commands block:
export async function getCommands(cwd: string): Promise<Command[]> { const allCommands = await loadAllCommands(cwd) const dynamicSkills = getDynamicSkills() const baseCommands = allCommands.filter( _ => meetsAvailabilityRequirement(_) && isCommandEnabled(_) ) // Insert dynamic skills at the right position... }
Skills (prompt-type commands loaded from markdown files) arrive from four places via getSkills(cwd):
.claude/skills/ in the project or user home. These are the SKILL.md files you or your team write./plugin install frontend-design@claude-plugins-official).getBundledSkills()).getBuiltinPluginSkillCommands().If any source fails to load, the error is caught and logged but the rest continue — skill loading is intentionally non-fatal.
A subset of commands only exists in Anthropic-internal builds. They are declared in INTERNAL_ONLY_COMMANDS and only appended to COMMANDS() when process.env.USER_TYPE === 'ant':
export const INTERNAL_ONLY_COMMANDS = [ backfillSessions, breakCache, bughunter, commit, commitPushPr, mockLimits, bridgeKick, // ...many more ].filter(Boolean) // Inside COMMANDS(): ...(!process.env.IS_DEMO && process.env.USER_TYPE === 'ant' ? INTERNAL_ONLY_COMMANDS : [])
This means commands like /commit and /bughunter are literally absent from the public binary — they are dead-code-eliminated by Bun's bundler.
When the user submits text starting with /, the REPL runs lookup and dispatch before anything touches the model. The key helpers live at the bottom of src/commands.ts:
// Returns the first Command matching by name, userFacingName, or alias export function findCommand(commandName: string, commands: Command[]): Command | undefined // Presence check (wraps findCommand) export function hasCommand(commandName: string, commands: Command[]): boolean // Throws ReferenceError listing all available commands if not found export function getCommand(commandName: string, commands: Command[]): Command
The findCommand matcher checks three things in order: _.name === commandName, getCommandName(_) === commandName (the user-facing override), and _.aliases?.includes(commandName). This is why /reset and /new both trigger the clear command.
Everything after the command name (trimmed) is the args string. There is no framework-level argument parsing — each command is responsible for interpreting its own args. /compact focus on the database layer delivers "focus on the database layer" to the compact handler, which passes it to the summarization model as customInstructions.
Prompt commands often embed shell output inline. The executeShellCommandsInPrompt utility scans prompt text for !`shell command` patterns and replaces them with live output before the prompt reaches the model. /commit uses this to automatically inject git status, git diff HEAD, and recent log output:
const PROMPT = `## Context - Current git status: !\`git status\` - Current git diff: !\`git diff HEAD\` - Current branch: !\`git branch --show-current\` - Recent commits: !\`git log --oneline -10\` ## Your task Based on the above changes, create a single git commit...`
The formatDescriptionWithSource(cmd) utility adds a provenance annotation for user-facing surfaces (typeahead, help screen) without polluting the model-facing description. A skill from a plugin named "frontend-design" displays as "(frontend-design) Polish and refine UI components" in autocomplete, but the model only sees "Polish and refine UI components".
The REPL (Read-Eval-Print Loop) is the heart of the interactive session. It renders via launchRepl in src/replLauncher.tsx, which lazy-loads the App and REPL Ink components. Commands flow through the REPL in three distinct dispatch paths depending on their type:
Inside the REPL, slash command handling follows this sequence:
The immediate flag on a command bypasses the normal "wait for any in-flight AI request to stop" behavior. /status sets immediate: true so you can check connection status even while a long generation is running.
| Type | Dispatch | Post-dispatch |
|---|---|---|
local |
await cmd.load() then call(args, ctx) |
If result is {type:'compact'}, REPL replaces messages. If {type:'text'}, adds system message. If {type:'skip'}, does nothing. |
local-jsx |
await cmd.load() then renders returned ReactNode |
REPL mounts the component. When onDone(result, opts) fires: unmounts, optionally appends result, optionally calls model if opts.shouldQuery = true. |
prompt |
await cmd.getPromptForCommand(args, ctx) |
Returned ContentBlockParam[] become the first user message. REPL enters query mode as if the user typed that text. progressMessage shows in the status line. |
The LocalJSXCommandOnDone callback is richer than it looks. The full signature:
type LocalJSXCommandOnDone = ( result?: string, options?: { display?: 'skip' | 'system' | 'user' // default: 'user' shouldQuery?: boolean // send messages to model after done metaMessages?: string[] // model-visible but hidden from UI nextInput?: string // pre-fill the input box submitNextInput?: boolean // auto-submit nextInput } ) => void
This means a JSX command can, on close, inject a pre-written message into the model pipeline automatically — e.g. the model picker can auto-submit a confirmation after model selection.
When Claude Code runs in non-interactive (headless) mode (e.g. claude -p "..."), local commands can declare supportsNonInteractive: true to remain usable. /compact and /cost both do this. JSX commands are always blocked in headless mode since there is no terminal TUI.
Prompt commands declare disableNonInteractive: true when they depend on interactive state (e.g. active session messages). Built-in prompt commands that do heavy model work generally work in both modes.
Commands have two independent gate layers that both must pass before the command appears in the REPL:
Checked by meetsAvailabilityRequirement(cmd). Two possible values: 'claude-ai' (requires OAuth subscriber) or 'console' (requires direct API key user). Commands without this field pass unconditionally. Re-evaluated on every getCommands() call so auth changes after /login take effect immediately.
A function () => boolean. Can read GrowthBook flags via feature('FLAG_NAME'), env vars, platform checks, or any other runtime condition. Called fresh on every filter pass. Commands without isEnabled default to true.
export function meetsAvailabilityRequirement(cmd: Command): boolean { if (!cmd.availability) return true for (const a of cmd.availability) { switch (a) { case 'claude-ai': if (isClaudeAISubscriber()) return true; break case 'console': if (!isClaudeAISubscriber() && !isUsing3PServices() && isFirstPartyAnthropicBaseUrl()) return true; break } } return false }
Several commands only exist when a Bun bundle feature flag is active. At build time, Bun's dead-code elimination drops the entire require chain if the feature is off:
const ultraplan = feature('ULTRAPLAN') ? require('./commands/ultraplan.js').default : null const voiceCommand = feature('VOICE_MODE') ? require('./commands/voice/index.js').default : null // Then in COMMANDS(): ...(ultraplan ? [ultraplan] : []), ...(voiceCommand ? [voiceCommand] : []),
Command loading is expensive — it involves disk I/O, YAML parsing, dynamic imports, and MCP round-trips. Three layers of memoization keep repeated calls fast:
| Cache | Key | What it stores |
|---|---|---|
COMMANDS() |
none (singleton) | The static built-in command list. Cleared by clearCommandMemoizationCaches(). |
loadAllCommands |
cwd string |
Merged command pool from all sources for a given working directory. |
getSkillToolCommands |
cwd string |
Filtered prompt commands eligible for SkillTool model invocation. |
Two granular clear functions exist for different invalidation scenarios:
// Clears command memoization only — does NOT clear skill file caches. // Use when dynamic skills are added mid-session. export function clearCommandMemoizationCaches(): void { loadAllCommands.cache?.clear?.() getSkillToolCommands.cache?.clear?.() getSlashCommandToolSkills.cache?.clear?.() clearSkillIndexCache?.() // outer search index must also be cleared } // Full reset: command + plugin + skill file caches. // Use when plugins are installed/removed or skills dir changes. export function clearCommandsCache(): void { clearCommandMemoizationCaches() clearPluginCommandCache() clearPluginSkillsCache() clearSkillCaches() }
meetsAvailabilityRequirement and isCommandEnabled are deliberately not memoized — they are re-evaluated on every getCommands() call. This ensures that /login or a GrowthBook flag flip takes effect immediately without requiring a cache bust.
Claude Code can run in a remote mode (accessed via browser/mobile) and can receive commands over a bridge (the Remote Control protocol). Both modes restrict which commands are available.
When Claude Code starts with --remote, only commands in REMOTE_SAFE_COMMANDS are made available before the CCR init message arrives. These are commands that only affect local TUI state and do not touch the filesystem, git, shell, or IDE:
export const REMOTE_SAFE_COMMANDS: Set<Command> = new Set([ session, // Shows QR code / URL for remote session exit, // Exit the TUI clear, // Clear screen help, // Show help theme, // Change terminal theme cost, // Show session cost plan, // Plan mode toggle // ...more ])
The bridge (mobile/web client) sends slash commands over a Remote Control connection. The isBridgeSafeCommand predicate determines which ones execute rather than getting silently dropped:
export function isBridgeSafeCommand(cmd: Command): boolean { if (cmd.type === 'local-jsx') return false // always blocked (renders Ink UI) if (cmd.type === 'prompt') return true // always safe (expands to text) return BRIDGE_SAFE_COMMANDS.has(cmd) // 'local' needs explicit opt-in }
The rule is intuitive: local-jsx commands render terminal UI that the bridge client can't display, so they are always blocked. prompt commands just generate text, so they are always safe. Plain local commands must be explicitly listed in BRIDGE_SAFE_COMMANDS — by default they are blocked. The allowlist includes /compact, /clear, /cost, /files, and a few others.
Command object — a CommandBase discriminated union of three execution types: local (pure TS function), local-jsx (Ink component), and prompt (text injected into model context).local and local-jsx commands use lazy loading via load: () => import('./cmd.js') — the implementation module is not imported until the command is actually invoked, keeping startup time fast.loadAllCommands(cwd), then filters by availability and isEnabled on every getCommands() call.availability (static auth-provider check, re-evaluated per call) and isEnabled() (runtime feature-flag check). Both must pass. Neither is memoized, so auth changes take effect immediately.local-jsx commands are always blocked over the bridge (they render terminal UI). prompt commands are always allowed. local commands require explicit opt-in via BRIDGE_SAFE_COMMANDS.!`cmd` patterns to embed live shell output into the prompt before it reaches the model — this is how /commit auto-injects git status, git diff HEAD, and recent log without you needing to paste them./commit, /bughunter, etc.) are wrapped in INTERNAL_ONLY_COMMANDS and dead-code-eliminated by Bun at build time in the public binary — they do not exist at all in the version users install.Q1. You want to add a new slash command that opens an interactive menu to select a GitHub PR for review. Which type should you use?
Q2. A user installs a new plugin and immediately types /plugin-command. The command is not found. What is the most likely cause?
Q3. Which statement about REMOTE_SAFE_COMMANDS vs BRIDGE_SAFE_COMMANDS is correct?
Q4. A prompt command sets allowedTools: ['Bash(git add:*)', 'Bash(git commit:*)']. What does this do?
Q5. What is the purpose of the !`shell command` pattern inside a prompt command's template string?