Skip to content

Chapter 8 — The Command System

What You'll Learn

By the end of this chapter, you will be able to:

  • Distinguish the three command variants — PromptCommand, LocalCommand, and LocalJSXCommand — and explain when each one is used
  • Read CommandBase and explain every field: availability gates, alias resolution, sensitivity flags, and the immediate execution mode
  • Navigate src/commands.ts and explain why the 70+ built-in commands are wrapped in a memoize() call rather than exported as a plain array
  • Trace the full command discovery pipeline: from the memoized loadAllCommands() through getCommands() to the filtered list that reaches the REPL
  • Explain how skills and plugins merge into the command list and which priority order governs when two commands share a name
  • Follow the processUserInput() pipeline from raw keystroke to routed command or model prompt
  • Add a new slash command to the codebase, correctly wiring up type, metadata, lazy-load, and registration

Why a Dedicated Command System

Every shell has commands. What makes Claude Code's command system unusual is that it must serve two very different audiences simultaneously.

The first audience is the human user sitting at the terminal. They type /clear, /compact, or a custom skill they installed into ~/.claude/skills/. They expect immediate, predictable behavior — clear should wipe the conversation; compact should summarize it; unknown inputs should return a helpful error. For this audience, the command system is a keyboard-driven menu of local operations.

The second audience is the language model itself. The model can invoke slash commands as tools during agentic operation. It needs to know each command's description, when to use it, which tools it permits, and whether it can be called in non-interactive contexts. For this audience, the command system is an API surface with a structured schema.

These two audiences share the same command registry but consume it in completely different ways. The type system is designed to make both usages correct by construction.


8.1 Three Command Types

8.1.1 PromptCommand: Expanding into Model Context

Source: src/types/command.ts:25-57

A PromptCommand does not execute TypeScript logic. Instead it expands into a sequence of content blocks that are inserted into the model's context as if the user had typed them. Think of it as a macro: the user types /my-workflow, and the system replaces that with whatever getPromptForCommand() returns — which can include file contents, instructions, tool-use permissions, or any mixture of ContentBlockParam.

typescript
// src/types/command.ts:25-57
export type PromptCommand = {
  type: 'prompt'
  progressMessage: string
  contentLength: number        // used for token budget estimation
  argNames?: string[]
  allowedTools?: string[]
  model?: string
  source: SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
  pluginInfo?: { pluginManifest: PluginManifest; repository: string }
  disableNonInteractive?: boolean
  hooks?: HooksSettings
  skillRoot?: string
  context?: 'inline' | 'fork'  // run inline or as a sub-agent
  agent?: string
  effort?: EffortValue
  paths?: string[]             // glob patterns; command is only visible after model touches matching files
  getPromptForCommand(args: string, context: ToolUseContext): Promise<ContentBlockParam[]>
}

Several fields on this type deserve careful attention.

contentLength is not the actual expanded length of the prompt — it is a pre-computed estimate used so the system can decide whether there is room for this command in the current context window before calling the relatively expensive getPromptForCommand() at all.

source encodes provenance. The string 'builtin' means the command ships with Claude Code's source code. 'plugin' means it arrived via an installed plugin. The distinction matters in formatDescriptionWithSource() (covered in section 8.3) which annotates the description shown to users with the source label, so a user can always tell whether a command is first-party or extension-provided.

context: 'inline' | 'fork' controls execution scope. An inline command runs inside the current agent's context, sharing its state and conversation history. A fork command spawns a sub-agent with its own isolated context. Skills that perform destructive or wide-ranging operations (large refactors, multi-file generation) often use 'fork' so their intermediate states do not pollute the parent session.

paths implements visibility-gating: a command only appears in the registry after the model has already touched at least one file matching one of the patterns. This prevents commands that are only relevant in, say, a Rust project from cluttering the command menu of a Python project.

8.1.2 LocalCommand: Local TypeScript Execution

Source: src/types/command.ts:74-78

A LocalCommand executes a TypeScript module directly, without involving the model at all. It returns a Promise<{ resultText?: string }> and optionally sets shouldQuery: false to tell the REPL not to send anything to the API after the command completes.

typescript
// src/types/command.ts:74-78
type LocalCommand = {
  type: 'local'
  supportsNonInteractive: boolean
  load: () => Promise<LocalCommandModule>  // lazy-loaded
}

The load field is a dynamic import thunk. The module is not loaded at startup; it is fetched on first use. This keeps the startup bundle small — a user who never types /bug does not pay the parse-and-compile cost for the bug-reporting module.

supportsNonInteractive flags whether this command can be used when Claude Code is invoked with the -p (print) flag in non-interactive mode. Commands that read a file, format output, and exit can set this to true. Commands that open an interactive selector or depend on terminal focus must set it to false.

The clear command is the canonical minimal example of a LocalCommand:

typescript
// src/commands/clear/index.ts
const clear = {
  type: 'local',
  name: 'clear',
  description: 'Clear conversation history and free up context',
  aliases: ['reset', 'new'],          // /reset and /new are also valid
  supportsNonInteractive: false,
  load: () => import('./clear.js'),   // lazy-load the implementation module
} satisfies Command

export default clear

Notice the satisfies Command constraint rather than an explicit type annotation. This idiom lets TypeScript verify that the literal object satisfies the full Command union without widening the type, which preserves the narrowed type: 'local' literal in the object's inferred type. It is the same pattern used throughout the codebase for all command and tool definitions.

8.1.3 LocalJSXCommand: Rendering Ink UI

Source: src/types/command.ts:144-152

A LocalJSXCommand is identical in concept to a LocalCommand, except the module it loads returns a React component rather than a plain function. Claude Code uses Ink to render React trees into the terminal, so a LocalJSXCommand can display interactive UI elements — selection lists, text inputs, progress bars — that are not possible with plain text output.

typescript
// src/types/command.ts:144-152
type LocalJSXCommand = {
  type: 'local-jsx'
  load: () => Promise<LocalJSXCommandModule>  // lazy-loaded
}

The interface is intentionally minimal. All the behavioral knobs — whether it is hidden, what its description is, whether it has aliases — are carried by CommandBase, which all three variants share. The type field is the only differentiator that the command executor uses to decide how to render the output.


8.2 CommandBase: The Shared Foundation

Source: src/types/command.ts:175-203

All three command variants are intersected with CommandBase to form the final Command type:

typescript
// src/types/command.ts:175-203
export type CommandBase = {
  availability?: CommandAvailability[]
  description: string
  hasUserSpecifiedDescription?: boolean
  isEnabled?: () => boolean
  isHidden?: boolean
  name: string
  aliases?: string[]
  isMcp?: boolean
  argumentHint?: string
  whenToUse?: string
  version?: string
  disableModelInvocation?: boolean
  userInvocable?: boolean
  loadedFrom?: 'commands_DEPRECATED' | 'skills' | 'plugin' | 'managed' | 'bundled' | 'mcp'
  kind?: 'workflow'
  immediate?: boolean
  isSensitive?: boolean
  userFacingName?: () => string
}

export type Command = CommandBase & (PromptCommand | LocalCommand | LocalJSXCommand)

This is a discriminated union via intersection rather than a traditional | union. Every Command value is guaranteed to have all of CommandBase's fields plus exactly one of the three variant shapes. TypeScript narrows correctly based on the type literal.

The fields in CommandBase divide naturally into three responsibilities.

Identity and discoverability. name is the canonical identifier used in the registry. aliases is an array of alternative names that resolve to the same command — clear declares aliases ['reset', 'new'], so /reset and /new are both valid. userFacingName is an optional function (not a string) because it can be computed dynamically: a command loaded from a plugin might prepend the plugin's namespace prefix at read time.

Availability and enablement. availability is an array of CommandAvailability values — currently 'claude-ai' (Claude.ai subscription required) or 'console' (internal Anthropic console). If the array is absent the command is available to everyone. isEnabled is a zero-argument function that is called at command listing time; it allows feature flags to gate a command at runtime rather than at build time. isHidden excludes the command from the /help listing while keeping it functional — useful for internal or experimental commands that should not be advertised.

Execution metadata. immediate marks commands that should execute without waiting for the model to reach a natural stopping point. isSensitive causes the command's arguments to be redacted from the stored conversation history — relevant for commands that accept tokens, passwords, or other secrets as arguments. disableModelInvocation prevents the model from invoking this command as a tool, reserving it for human-only use. kind: 'workflow' tags commands sourced from workflow scripts so formatDescriptionWithSource() can apply the correct label.

The two utility functions exported from the same file make the optional fields safe to call without null checks:

typescript
// src/types/command.ts:205-210
export function getCommandName(cmd: CommandBase): string {
  return cmd.userFacingName?.() ?? cmd.name
}

export function isCommandEnabled(cmd: CommandBase): boolean {
  return cmd.isEnabled?.() ?? true
}

8.3 The Command Registry: commands.ts

Source: src/commands.ts:258-346

The built-in command list is defined inside a memoize() call, not as a top-level array:

typescript
// src/commands.ts:258-346
const COMMANDS = memoize((): Command[] => [
  addDir, advisor, agents, branch, clear, compact, config,
  // ... 70+ built-in commands
  ...(proactive ? [proactive] : []),
  ...(voiceCommand ? [voiceCommand] : []),
  ...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO
    ? INTERNAL_ONLY_COMMANDS
    : []),
])

The reason for memoization is subtle but important. Several commands call configuration-reading functions at construction time — reading the config file, checking environment variables, evaluating feature flags. If COMMANDS were a module-level constant, all of that evaluation would happen the moment the module was imported, which happens very early in the startup sequence before the config system is fully initialized. Wrapping it in memoize() defers all initialization to the first call of COMMANDS(), which happens only after the config and feature-flag systems are ready.

The conditional spreads at the end of the array demonstrate feature-flag-driven inclusion. proactive is a reference to the proactive features command module; if the module is null (because the feature flag is disabled at compile time) the spread adds nothing. voiceCommand follows the same pattern. The USER_TYPE === 'ant' block gates a set of internal-only commands behind an employee check combined with a demo-mode exclusion — employees running a demo should not accidentally expose internal tooling to an audience.

The internal-only commands are exported separately so they can be inspected and tested:

typescript
// src/commands.ts:225-254
export const INTERNAL_ONLY_COMMANDS = [
  backfillSessions, breakCache, bughunter, commit, commitPushPr,
  // ...
].filter(Boolean)

The .filter(Boolean) removes any null or undefined entries that arise when a command module is conditionally compiled out, without requiring every entry to be guarded by a ternary.

The formatDescriptionWithSource() function applies source-specific labels to each command's description string for display in help output and model tool listings:

typescript
// src/commands.ts:728-754
export function formatDescriptionWithSource(cmd: Command): string {
  if (cmd.type !== 'prompt') return cmd.description
  if (cmd.kind === 'workflow') return `${cmd.description} (workflow)`
  if (cmd.source === 'plugin') { /* include plugin name */ }
  if (cmd.source === 'bundled') return `${cmd.description} (bundled)`
  return `${cmd.description} (${getSettingSourceName(cmd.source)})`
}

The early return for non-prompt commands reflects the fact that only PromptCommand carries a source field. LocalCommand and LocalJSXCommand are always built-in, so they have no need for a provenance annotation.


8.4 Command Discovery: From Raw List to Available Commands

The path from the raw command definitions to the filtered list that the REPL actually presents involves three distinct layers, each with its own caching strategy.

The memoized full load

Source: src/commands.ts:449-469

The first layer is loadAllCommands, which is memoized by working directory:

typescript
// src/commands.ts:449-469
const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
  const [
    { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills },
    pluginCommands,
    workflowCommands,
  ] = await Promise.all([
    getSkills(cwd),
    getPluginCommands(),
    getWorkflowCommands ? getWorkflowCommands(cwd) : Promise.resolve([]),
  ])
  return [
    ...bundledSkills,        // pre-bundled skills shipped with the binary
    ...builtinPluginSkills,  // skills provided by built-in plugins
    ...skillDirCommands,     // user's ~/.claude/skills/ and project's .claude/skills/
    ...workflowCommands,     // workflow scripts
    ...pluginCommands,       // commands from installed plugins
    ...pluginSkills,         // skills from installed plugins
    ...COMMANDS(),           // built-in commands (lowest priority)
  ]
})

Three sources are fetched concurrently via Promise.all. The ordering of the resulting array is the priority order for name conflicts: if a user skill and a built-in command share the same name, the user skill wins because it appears earlier in the array. findCommand() uses Array.find(), which returns the first match.

Memoization by cwd means Claude Code pays the cost of scanning skill directories and loading plugin manifests exactly once per working directory per process lifetime. If the user switches projects (or opens a second REPL in the same process), the new working directory triggers a fresh load.

The per-call filter

Source: src/commands.ts:476-516

The second layer is getCommands, which is called on every REPL prompt and every model turn that needs the current command list:

typescript
// src/commands.ts:476-516
export async function getCommands(cwd: string): Promise<Command[]> {
  const allCommands = await loadAllCommands(cwd)
  const dynamicSkills = getDynamicSkills()  // discovered during file operations
  const baseCommands = allCommands.filter(
    _ => meetsAvailabilityRequirement(_) && isCommandEnabled(_),
  )
  // Insert dynamic skills before built-in commands, deduped
}

getDynamicSkills() returns skills that were discovered at runtime — for example, a skill whose paths glob matched a file the model just read. These are not present in the static loadAllCommands result, so they are inserted at the correct priority position during the per-call filter pass rather than cached.

After the dynamic skills are merged in, the filter applies two predicates to every command:

meetsAvailabilityRequirement checks whether the current session satisfies the command's availability constraints:

typescript
// src/commands.ts:417-443
export function meetsAvailabilityRequirement(cmd: Command): boolean {
  if (!cmd.availability) return true  // no restriction = visible to all
  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
}

The function iterates over the availability array and returns true on the first matching condition. A command can list multiple availability modes — if it lists both 'claude-ai' and 'console', it is visible to both Claude.ai subscribers and internal console users.

isCommandEnabled calls the command's optional isEnabled() function. For most commands this returns true unconditionally. Feature-flagged commands supply a function that reads the current flag state each time it is called, so a flag toggle at runtime takes effect on the very next REPL prompt.


8.5 Command Lookup: findCommand()

Source: src/commands.ts:688-698

Once the filtered command list is in hand, looking up a command by name uses a single Array.find:

typescript
// src/commands.ts:688-698
export function findCommand(commandName: string, commands: Command[]): Command | undefined {
  return commands.find(
    _ => _.name === commandName
      || getCommandName(_) === commandName
      || _.aliases?.includes(commandName),
  )
}

The three conditions handle the three ways a command can be referenced.

_.name === commandName is the canonical match against the internal registry key.

getCommandName(_) === commandName handles commands that override their display name via userFacingName(). A plugin might register a command with name: 'myplugin__do-thing' for uniqueness in the registry, but expose it to users as do-thing by returning that shorter string from userFacingName. Both forms resolve to the same command object.

_.aliases?.includes(commandName) handles the alias array. The /clear command is also reachable as /reset and /new because its aliases array contains both strings. Alias resolution happens at lookup time rather than registration time, so there is no need to create separate registry entries.

The function returns undefined if no command matches. The callers in processUserInput treat this as a "pass-through" condition and route the input to the model as plain text rather than treating it as an error.


8.6 Skills and Plugins: Dynamic Extension

Claude Code's command list is not fixed at compile time. Three extension mechanisms add commands at runtime.

User skills are Markdown or script files placed in ~/.claude/skills/ (global) or .claude/skills/ (project-local). The getSkills(cwd) call in loadAllCommands scans both directories and converts each skill file into a PromptCommand with source: 'local' or source: 'project' and a loadedFrom: 'skills' annotation. The skill's filename becomes the command name; its YAML front-matter provides the description and other metadata.

Plugins are npm packages declared in the Claude Code configuration. getPluginCommands() loads each plugin's manifest, reads its declared command exports, and wraps them as Command objects with source: 'plugin' and isMcp: false. Plugin skills work identically except their loadedFrom is 'plugin' rather than 'skills'.

Dynamic skills are the most unusual of the three. Certain PromptCommand definitions declare a paths array of glob patterns. When the model reads or modifies a file that matches one of those patterns, the skill is promoted from inactive to active via getDynamicSkills(). This implements context-aware commands: a Rust refactoring skill with paths: ['**/*.rs'] only appears in the command menu after the model has touched a Rust source file.

The priority order in loadAllCommands — bundled skills, built-in plugin skills, skill-dir commands, workflow commands, plugin commands, plugin skills, built-in commands — ensures that the most specific extension always wins over the most general built-in. A user who creates a project-local skill named clear gets exactly that behavior and overrides the built-in clear command for that project.


8.7 The User Input Pipeline: processUserInput()

Source: src/utils/processUserInput/processUserInput.ts

Every character the user types in the REPL, and every message that arrives via a bridge (the mobile app, the web interface), passes through processUserInput. Its job is to classify the input and route it to the correct handler.

The function signature exposes the routing surface:

typescript
// src/utils/processUserInput/processUserInput.ts:85-100
export async function processUserInput({
  input,              // string or ContentBlockParam[] (may include images)
  mode,               // 'prompt' | 'bash' | ...
  setToolJSX,
  context,
  pastedContents,
  skipSlashCommands,  // true: treat /xxx as plain text (used by bridge)
  bridgeOrigin,       // identifies a remote bridge as the input source
  ...
}): Promise<ProcessUserInputBaseResult>

The return type carries everything the REPL needs to take the next step:

typescript
// src/utils/processUserInput/processUserInput.ts:64-83
export type ProcessUserInputBaseResult = {
  messages: (UserMessage | AssistantMessage | AttachmentMessage | SystemMessage | ProgressMessage)[]
  shouldQuery: boolean      // false = handled locally, do not call the model
  allowedTools?: string[]
  model?: string
  effort?: EffortValue
  resultText?: string       // text output for -p (non-interactive) mode
  nextInput?: string        // pre-fill the next input after the command completes
  submitNextInput?: boolean
}

shouldQuery: false is the key signal. When a LocalCommand handles the input completely — /clear wipes the conversation and returns — there is nothing to send to the model, so shouldQuery is false and the REPL skips the API call entirely.

The internal routing tree inside processUserInputBase() follows a fixed priority order:

The bridge path (skipSlashCommands + bridgeOrigin) deserves special mention. When input arrives from the mobile app or web interface, certain slash commands may not make sense — /clear on a remote bridge session has ambiguous semantics because the bridge and the local REPL may disagree on what "clear" means. Setting skipSlashCommands: true bypasses the entire processSlashCommand branch, ensuring the text reaches the model unchanged. The bridge can then issue its own post-processing based on the model's response.

The ULTRAPLAN keyword path is a feature-flag-gated override that restructures the input into a multi-step planning prompt before it reaches the model. It is processed before the slash command check, so it takes precedence even if the input happens to start with a /.


8.8 Practical Guide: Adding a New Slash Command

This section walks through adding a hypothetical /summarize command that calls a local TypeScript function to print a word count summary of the current conversation. It is a LocalCommand because it executes logic locally without involving the model.

Step 1: Create the command directory.

By convention each command lives in its own directory under src/commands/. Create src/commands/summarize/.

Step 2: Write the implementation module.

The implementation module exports a LocalCommandModule — an object with a call method that receives the command arguments and the current context, and returns a result object.

typescript
// src/commands/summarize/summarize.ts
import type { LocalCommandModule } from '../../types/command.js'
import type { ToolUseContext } from '../../context/ToolUseContext.js'

const summarizeModule: LocalCommandModule = {
  async call(args: string, context: ToolUseContext) {
    // Read the message history from context
    const messages = context.getAppState().messages ?? []
    const wordCount = messages
      .flatMap(m => (typeof m.content === 'string' ? [m.content] : []))
      .join(' ')
      .split(/\s+/)
      .filter(Boolean).length

    return {
      resultText: `Conversation word count: ${wordCount}`,
      shouldQuery: false,
    }
  },
}

export default summarizeModule

Step 3: Write the command metadata module.

This is the thin index file that registers the command in the registry. It must not import from the implementation directly — the load thunk handles that lazily.

typescript
// src/commands/summarize/index.ts
import type { Command } from '../../types/command.js'

const summarize = {
  type: 'local',
  name: 'summarize',
  description: 'Print a word count summary of the current conversation',
  supportsNonInteractive: true,
  load: () => import('./summarize.js'),
} satisfies Command

export default summarize

Step 4: Register the command in commands.ts.

Import the metadata module at the top of src/commands.ts with the other local command imports, then add it to the COMMANDS() array:

typescript
// src/commands.ts — import section
import summarize from './commands/summarize/index.js'

// src/commands.ts:258 — inside the memoize() call
const COMMANDS = memoize((): Command[] => [
  addDir, advisor, agents, branch, clear, compact, config,
  summarize,    // add here
  // ... rest of the list
])

Step 5: Verify the command appears in the list.

Run the development server and type /summarize at the REPL prompt. The output should print the word count. To confirm the command shows in /help, check that isHidden is absent (or explicitly false) in the index module.

Step 6: Add a unit test.

Write a test that imports the summarizeModule directly and calls .call('', fakeContext), asserting that resultText contains the expected word count and shouldQuery is false. Because the implementation module is separated from the metadata module, the test does not need to mock the load thunk.

typescript
// src/commands/summarize/__tests__/summarize.test.ts
import summarizeModule from '../summarize.js'
import { makeTestContext } from '../../../test-utils/makeTestContext.js'

test('returns word count of conversation messages', async () => {
  const ctx = makeTestContext({
    messages: [{ role: 'user', content: 'hello world' }],
  })
  const result = await summarizeModule.call('', ctx)
  expect(result.shouldQuery).toBe(false)
  expect(result.resultText).toContain('2')
})

The complete flow from keystroke to result is: user types /summarizeprocessUserInput detects the leading /processSlashCommand calls findCommand('summarize', commands) → the metadata object is returned → load() is invoked for the first time → the implementation module is imported and its call method is executed → ProcessUserInputBaseResult is returned with shouldQuery: false → the REPL prints resultText and does not make an API call.


Key Takeaways

The command system's design reflects a consistent set of choices made throughout Claude Code's architecture: defer expensive initialization, separate metadata from implementation, use the type system as the primary correctness guarantee, and make extension easy without making the core complex.

The three command types form a complete coverage of output destinations. PromptCommand feeds the model's context; LocalCommand produces text; LocalJSXCommand renders interactive terminal UI. Any new command fits cleanly into one of these three shapes.

CommandBase collects all the behavioral knobs that are independent of output destination — availability gates, feature-flag hooks, alias resolution, sensitivity flags — into one shared contract. This means the command executor never needs to check the type field to decide whether to apply an availability rule or redact arguments.

The layered loading pipeline — loadAllCommands memoized by cwd, then getCommands filtering per call — separates the expensive I/O (scanning skill directories, reading plugin manifests) from the cheap evaluation (checking flag states, checking subscription status). The expensive part runs once; the cheap part runs on every prompt.

findCommand is intentionally simple: a single Array.find over a flat list that checks three match conditions. This simplicity is possible because the priority order was resolved at load time. By the time findCommand is called, the array is already ordered so the correct command is always first.

The processUserInput pipeline makes the routing logic explicit and linear. Each branch — image processing, bridge bypass, ultraplan expansion, bash mode, slash command dispatch, plain text — is handled in a fixed order with a clear fallthrough. Adding a new routing branch means inserting a new conditional at the correct position in this sequence, not modifying a dispatch table or event bus.

The practical guide in section 8.8 demonstrates that adding a command is genuinely a three-file operation: implementation module, metadata module, and one line in the registry array. The separation between metadata and implementation is not boilerplate — it is what makes startup fast, testing straightforward, and lazy loading free.

Built for learners who want to read Claude Code like a real system.