markdown.engineering
Lesson 13

The Commands System

How slash commands are typed, registered, assembled into a pipeline, processed at input, and dispatched inside the REPL

1. What Is a Command?

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:

src/types/command.ts — core 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".

There are over 80 built-in commands registered in COMMANDS(), plus an arbitrary number of dynamic ones loaded from skills directories, plugins, workflows, and MCP servers — all resolved at runtime via getCommands(cwd).

2. Three Command Types

local

Local

Pure TypeScript function. Runs synchronously in the current process. Returns a LocalCommandResult — either {type:'text'}, {type:'compact'}, or {type:'skip'}.

/clear, /compact, /cost
local-jsx

Local JSX

Renders a React/Ink component into the terminal TUI. Returns a ReactNode via the call(onDone, context, args) signature. Blocked from bridge/remote mode.

/help, /model, /config, /memory
prompt

Prompt

Expands to text content blocks sent to the model. Declares getPromptForCommand(args, context) which returns ContentBlockParam[]. Powers skills, workflows, and built-in agentic flows.

/commit, /review, /init, /security-review

Type decision tree

flowchart TD A[User types /cmd] --> B{Does it need\nto render UI?} B -- Yes --> C[local-jsx\ne.g. /model, /help] B -- No --> D{Does it call\nthe AI model?} D -- Yes --> E[prompt\ne.g. /commit, /review] D -- No --> F[local\ne.g. /clear, /compact, /cost] style C fill:#1f1249,stroke:#8e82ad,color:#b8b0a4 style E fill:#2a1900,stroke:#c47a50,color:#b8b0a4 style F fill:#0a2a16,stroke:#6e9468,color:#b8b0a4
Deep dive: local — the /compact command

Registration 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.

Deep dive: local-jsx — the /help command

Registration 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'),
}
Deep dive: prompt — the /commit command

Prompt 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.

3. The CommandBase Contract

All three types share a common base. The most important fields:

Field Type Description
namestringThe slash command name, e.g. "compact". Users type /compact.
descriptionstringShown in typeahead and /help. Can be a getter for dynamic descriptions.
aliasesstring[]?Alternative names. /clear also responds to /reset and /new.
isEnabled() => booleanFeature-flag or env-var guard. Called fresh on every getCommands() pass.
isHiddenboolean?Hides from typeahead and help UI while still allowing invocation.
availabilityCommandAvailability[]?Auth gate: 'claude-ai' (subscriber) or 'console' (API key user).
argumentHintstring?Grayed hint shown after the command name in typeahead, e.g. [model].
immediateboolean?If true, command runs without waiting for any in-flight AI request to stop.
loadedFromstring?Origin tag: 'skills', 'plugin', 'bundled', 'commands_DEPRECATED', 'mcp'.
whenToUsestring?Model-facing usage hint (from SKILL.md frontmatter). Controls SkillTool visibility.
isSensitiveboolean?Redacts command args from conversation history if true.
isEnabled vs availability: 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().

4. Registration Pipeline

Commands reach the REPL through a multi-stage assembly pipeline in src/commands.ts:

Static COMMANDS()
+
Skills dirs
+
Plugins
+
Workflows
loadAllCommands(cwd)
filter(availability + isEnabled)
getCommands(cwd)

Stage 1: Static core commands (COMMANDS())

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:

src/commands.ts
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()] : []),
])

Stage 2: Dynamic command sources (loadAllCommands)

All non-static sources are loaded in parallel and merged. The merge order is deterministic and matters for deduplication:

src/commands.ts — loadAllCommands
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
  ]
})

Stage 3: Filtering and dynamic skill insertion (getCommands)

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:

src/commands.ts — getCommands
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...
}
Four skill sources and their origins

Skills (prompt-type commands loaded from markdown files) arrive from four places via getSkills(cwd):

  • skillDirCommands — loaded from .claude/skills/ in the project or user home. These are the SKILL.md files you or your team write.
  • pluginSkills — skills bundled inside installed plugins (e.g. /plugin install frontend-design@claude-plugins-official).
  • bundledSkills — skills compiled into the Claude Code binary itself (registered synchronously at startup via getBundledSkills()).
  • builtinPluginSkills — skills from always-enabled built-in plugins, via getBuiltinPluginSkillCommands().

If any source fails to load, the error is caught and logged but the rest continue — skill loading is intentionally non-fatal.

INTERNAL_ONLY_COMMANDS — the internal build gate

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.

5. Input Processing

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:

Lookup functions

src/commands.ts — find / has / get
// 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.

Argument passing

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.

Shell command substitution in prompts

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:

src/commands/commit.ts — prompt template excerpt
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...`

Description formatting for UI vs model

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".

6. REPL Integration

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:

sequenceDiagram participant U as User Input participant R as REPL participant LC as LocalCommand participant LJ as LocalJSX participant PC as PromptCommand participant M as Model U->>R: /compact "focus on DB" R->>R: findCommand("compact") R->>LC: load() then call(args, ctx) LC-->>R: {type:'compact', ...} R->>R: update messages state U->>R: /model R->>LJ: load() then call(onDone, ctx, args) LJ-->>R: ReactNode (renders picker UI) R->>R: onDone() → resume input U->>R: /commit R->>PC: getPromptForCommand(args, ctx) PC-->>R: ContentBlockParam[] R->>M: send as user turn M-->>R: streaming response

The processSlashCommand flow

Inside the REPL, slash command handling follows this sequence:

1. parse input → commandName + args 2. findCommand(commandName, commands) 3. check immediate flag 4. dispatch by type 5. handle result / teardown

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.

Result handling by type

TypeDispatchPost-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 onDone callback contract (local-jsx)

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.

Non-interactive mode (headless CLI)

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.

7. Availability & Feature Gating

Commands have two independent gate layers that both must pass before the command appears in the REPL:

availability

Auth-provider gate

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.

isEnabled

Runtime feature gate

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.

src/commands.ts — meetsAvailabilityRequirement
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
}

Feature-flagged commands

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:

src/commands.ts — conditional require pattern
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] : []),

8. Cache Management

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:

CacheKeyWhat 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:

src/commands.ts
// 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()
}
Important: 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.

9. Bridge & Remote Mode

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.

Remote-safe commands (--remote flag)

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:

src/commands.ts — REMOTE_SAFE_COMMANDS (subset)
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
])

Bridge-safe commands

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:

src/commands.ts — isBridgeSafeCommand
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.

10. Key Takeaways

  • 🔵
    Every slash command is a 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).
  • Both 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.
  • 🔀
    The registration pipeline merges bundled skills, plugin skills, skill dir commands, workflows, plugin commands, and static built-ins via loadAllCommands(cwd), then filters by availability and isEnabled on every getCommands() call.
  • 🛡️
    Two independent gates control visibility: 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.
  • 🌐
    Bridge and remote mode apply a third filter. 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.
  • 📝
    Prompt commands use shell substitution via !`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.
  • 🔒
    Internal-only commands (/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.

11. Quiz

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?

A
local — it's not sending anything to the model
B
local-jsx — it needs to render an interactive UI component
C
prompt — it needs to use the model to summarize PR diffs
D
Any type works; type only affects startup performance
Correct. local-jsx is the right choice whenever the command needs to render a React/Ink component for interactive terminal UI.
Not quite. local-jsx is the right choice — it's designed for commands that render interactive terminal UI components.

Q2. A user installs a new plugin and immediately types /plugin-command. The command is not found. What is the most likely cause?

A
The command is in INTERNAL_ONLY_COMMANDS
B
The command's isEnabled() returned false
C
loadAllCommands is memoized per cwd — the old cache has no entry for it
D
Plugin commands use a different lookup path than built-in commands
Correct. loadAllCommands is memoized per cwd, so newly installed plugins won't appear until clearCommandsCache() is called (e.g. via /reload-plugins).
Not quite. The memoized loadAllCommands is the culprit — freshly installed plugins won't appear until the cache is cleared and commands are re-loaded.

Q3. Which statement about REMOTE_SAFE_COMMANDS vs BRIDGE_SAFE_COMMANDS is correct?

A
REMOTE_SAFE_COMMANDS filters the initial command list before CCR init; BRIDGE_SAFE_COMMANDS gates commands arriving over the bridge connection after init
B
They are aliases for the same set — both control the same filter pass
C
REMOTE_SAFE_COMMANDS includes all prompt-type commands; BRIDGE_SAFE_COMMANDS includes all local-type commands
D
BRIDGE_SAFE_COMMANDS is only enforced on mobile clients, not desktop remote mode
Correct. The two sets serve different moments in the remote session lifecycle: REMOTE_SAFE_COMMANDS are what's available before CCR initialization, BRIDGE_SAFE_COMMANDS govern what can be triggered over the bridge at any time.
Not quite. They are distinct sets with distinct roles — REMOTE_SAFE_COMMANDS for pre-CCR-init mode, BRIDGE_SAFE_COMMANDS for ongoing bridge-triggered commands.

Q4. A prompt command sets allowedTools: ['Bash(git add:*)', 'Bash(git commit:*)']. What does this do?

A
These are the only tools the command's getPromptForCommand can use to build the prompt
B
These tools are automatically pre-approved for the user's permission context globally
C
The model is shown only these tools in its system prompt during this command
D
During execution of this command, the model may only invoke tools matching these patterns — a scoped permission list
Correct. allowedTools scopes which tools the model is permitted to call during the command's execution turn — it's a security boundary that prevents the model from reaching beyond the declared list.
Not quite. allowedTools is a scoped permission list that restricts which tools the model may invoke during this command's execution turn specifically.

Q5. What is the purpose of the !`shell command` pattern inside a prompt command's template string?

A
It instructs the model to run that shell command and include its output
B
It is processed by executeShellCommandsInPrompt before the text reaches the model, replacing the pattern with actual shell output
C
It is a syntax sugar for adding the command to allowedTools
D
It marks those lines as comments that are stripped before sending to the model
Correct. executeShellCommandsInPrompt scans the prompt at call time, runs the shell commands, and substitutes their output into the prompt string before the model ever sees it.
Not quite. The !`cmd` patterns are resolved client-side by executeShellCommandsInPrompt — the model receives the substituted output, not the pattern itself.