Claude Code Command System: Six Sources, One Registry

4 min readAI Agents

How six command sources assemble into a memoized registry, and what REMOTE_SAFE and BRIDGE_SAFE availability flags actually control.

ai-agentsclaude-codecommandsruntimetypescript

When people first see a coding agent's slash commands, the assumption is that they are convenience aliases — shorthand for text the model could handle anyway.

That is not how Claude Code is structured.

The command system is part of the product runtime. It decides what command surface exists for the current session, enforces auth and environment gates, and — for some commands — determines whether execution stays on the main thread or forks into a sub-agent.

File structure

Files covered in this post4 files
src/
├── services/
│   ├── plugins/
│   │   └── index.ts
├── skills/
│   └── index.ts
├── types/
│   └── command.ts

src/commands.ts

Command assembly and routing layer

Critical

Builds the effective per-session command set from built-in commands, feature-gated branches, bundled skills, plugin skills, workflow commands, and dynamic skills discovered during file operations. Also defines remote/bridge safety allowlists.

Module
Product Commands

Key Exports

  • getCommands
  • builtInCommandNames
  • meetsAvailabilityRequirement
  • isBridgeSafeCommand
  • REMOTE_SAFE_COMMANDS
  • BRIDGE_SAFE_COMMANDS

Why It Matters

  • Command availability is recomputed at runtime, not frozen at startup.
  • Dynamic skills discovered during file operations can extend the command surface mid-session.
  • Bridge and remote safety are explicit allowlists — the default is blocked, not allowed.

Related Files

  • Input routingsrc/utils/processUserInput.ts
  • Skill loadingsrc/skills/index.ts
  • Plugin commandssrc/services/plugins/index.ts
  • Command typesrc/types/command.ts

Two questions, two files

The command system answers two questions, and they are handled in separate files:

  1. What commands exist right now?commands.ts
  2. What should happen with this specific user input?processUserInput.ts

That split is not accidental. Loading commands is expensive (disk I/O, dynamic imports, auth checks). Processing input happens on every keystroke and submit. Keeping them separate means the expensive loading is memoized by cwd and auth changes invalidate selectively, not globally.

Command sources: six, merged in order

getCommands() returns a flat list, but that list is assembled from six sources:

Command assembly in getCommands()

Six sources are merged into one per-session command list. The order matters — dynamic skills insert at a specific position, not at the end.

  1. 1

    Bundled skills

    bundledSkills

    Skills shipped with Claude Code as part of the binary. Always available, no discovery needed.

  2. 2

    Built-in plugin skills

    builtinPluginSkills

    Skills from first-party plugins that ship with Claude Code. Distinct from user-installed plugins.

  3. 3

    Skill directory commands

    skillDirCommands

    Skills found in .claude/commands/ in the project or user config directory. Loaded from disk, memoized by cwd.

  4. 4

    Workflow commands

    workflowCommands

    Feature-flagged workflow-type commands. Loaded via getWorkflowCommands, absent if the feature is off.

  5. 5

    Plugin commands and plugin skills

    pluginCommands / pluginSkills

    Commands and skills contributed by installed plugins. Plugins are the primary extension point for third-party tooling.

  6. 6

    Dynamic skills (mid-session insert)

    getDynamicSkills()

    Skills discovered during file operations (e.g. CLAUDE.md files found while reading the project). These insert before built-in commands, after plugin skills — not at the tail of the list.

The merge is not simply [...all]. Dynamic skills insert at the position of the first built-in command, not at the end. That insertion point is found by checking which commands are in COMMANDS() (the static built-in set):

const builtInNames = new Set(COMMANDS().map(c => c.name))
const insertIndex = baseCommands.findIndex(c => builtInNames.has(c.name))

return [
  ...baseCommands.slice(0, insertIndex),
  ...uniqueDynamicSkills,
  ...baseCommands.slice(insertIndex),
]

The position matters for UX — dynamic skills surface near the top of the command list, not buried after all the built-ins.

Availability gating

Not all commands from the merged list are returned. getCommands() filters by two predicates:

  • meetsAvailabilityRequirement(cmd) — checks whether the current user's auth context satisfies the command's declared availability
  • isCommandEnabled(cmd) — checks feature flags and other enable conditions

The availability check against auth context is what makes commands like /login appear conditionally:

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':
        // Direct Anthropic API key, not Bedrock/Vertex/3P proxy
        if (!isClaudeAISubscriber() && !isUsing3PServices() && isFirstPartyAnthropicBaseUrl())
          return true
        break
    }
  }
  return false
}

The implication: the command surface is not static. Auth state changes (like /login completing) can change which commands are available. getCommands() runs the availability and isEnabled checks on every call — only the expensive loading is memoized.

📝Why clearCommandMemoizationCaches() matters

When a dynamic skill is added mid-session, clearCommandMemoizationCaches() is called. This clears the loadAllCommands memoization and the skill index cache.

But it does NOT clear the loadAllCommands outer cache if the inner caches are cleared while the outer result is still hot. The code comment explains this directly:

"getSkillIndex in skillSearch/localSearch.ts is a separate memoization layer built ON TOP of getSkillToolCommands/getCommands. Clearing only the inner caches is a no-op for the outer."

The fix is to clear both layers explicitly. This is a real footgun if you are building a similar layered memoization system.

Remote and bridge safety: explicit allowlists

When Claude Code runs in remote mode (someone connects from a phone or web client), not all commands are safe to execute. The command system encodes this with two explicit allowlists:

REMOTE_SAFE_COMMANDS — commands safe to show in the REPL when --remote mode is active. These are UI and session-management commands that have no dangerous local side effects.

BRIDGE_SAFE_COMMANDS — commands safe to execute when the input arrived over the Remote Control bridge (mobile app sending slash commands to a desktop session). The distinction from REMOTE_SAFE_COMMANDS is the execution origin: bridge commands are triggered by a remote client, not a local user.

The safety classification follows a type rule:

export function isBridgeSafeCommand(cmd: Command): boolean {
  if (cmd.type === 'local-jsx') return false   // renders Ink UI — never safe remotely
  if (cmd.type === 'prompt') return true        // expands to text — safe by construction
  return BRIDGE_SAFE_COMMANDS.has(cmd)          // 'local' type — explicit opt-in required
}

local-jsx commands pop Ink UI pickers on the terminal. Sending /model from iOS and watching a picker open on the desktop was the real incident that motivated this allowlist (referenced in the source comment as PR #19134).

Command assembly across runtimes

The core pattern: load once (memoized), filter on every call (auth-sensitive). JS matches the actual source; Python and Go show the same shape.

javascriptcommands.ts (simplified)

The actual getCommands() pattern from learning-claude-code

// loadAllCommands is memoized by cwd — expensive loading happens once
const loadAllCommands = memoize(async (cwd) => {
const [{ skillDirCommands, pluginSkills, bundledSkills }, pluginCommands, workflowCommands] =
  await Promise.all([getSkills(cwd), getPluginCommands(), getWorkflowCommands(cwd)])

return [...bundledSkills, ...skillDirCommands, ...workflowCommands, ...pluginCommands, ...pluginSkills, ...COMMANDS()]
})

// getCommands runs availability/enabled checks fresh every call
export async function getCommands(cwd) {
const allCommands = await loadAllCommands(cwd)
const dynamicSkills = getDynamicSkills()

const baseCommands = allCommands.filter(
  cmd => meetsAvailabilityRequirement(cmd) && isCommandEnabled(cmd)
)

// Insert dynamic skills at the right position, not the tail
const builtInNames = new Set(COMMANDS().map(c => c.name))
const insertIndex = baseCommands.findIndex(c => builtInNames.has(c.name))
if (insertIndex === -1) return [...baseCommands, ...uniqueDynamicSkills]

return [
  ...baseCommands.slice(0, insertIndex),
  ...uniqueDynamicSkills,
  ...baseCommands.slice(insertIndex),
]
}

The input routing layer

commands.ts builds the command list. processUserInput.ts decides what to do with a specific message. The pipeline there handles:

  1. Image and attachment normalization — images, file paths, and memory attachments are extracted before routing
  2. Hook processingpre_tool_use and similar hooks can intercept and transform input
  3. Bridge origin check — input arriving over the bridge is checked against isBridgeSafeCommand before the slash handler runs
  4. Slash dispatch — if the trimmed message starts with /, the runtime checks if it matches a command and executes it; if not, it is treated as plain text

The bridge safety check is where the /model on iOS → desktop Ink picker problem was fixed. The fix was not to detect the mobile client and skip the command — it was to make the allowlist explicit and default-deny for local-jsx commands regardless of origin.

What this means for extensibility

The command system is designed for extension at three levels:

  • Project level: drop a .md file in .claude/commands/ and the skill is discovered on next getCommands() call
  • Plugin level: install a plugin and its commands appear in the filtered list
  • Runtime level: skills discovered during file operations (CLAUDE.md parsing) insert mid-session without requiring a restart

The key design decision is that extension points are additive, not overriding. Skills and plugins can add commands, but they cannot replace built-in commands. The priority order (bundled first, built-ins last) enforces that.

In Claude Code's command system, why does getCommands() run availability checks on every call while loadAllCommands() is memoized?

medium

getCommands() calls loadAllCommands(cwd) which is memoized, then filters the result with meetsAvailabilityRequirement and isCommandEnabled.

  • APerformance: availability checks are cheaper than disk I/O
    Incorrect.Performance is part of it, but not the complete reason. The design intent is about correctness after auth state changes.
  • BSo that auth state changes (like /login completing) take effect immediately without requiring a restart
    Correct!Correct. The expensive loading (disk, dynamic imports) is cached, but availability and enabled checks must run fresh so that auth changes — login, logout, subscription changes — are reflected in the command list on the very next call.
  • CBecause isCommandEnabled checks feature flags that can change at any time
    Incorrect.Feature flag changes don't happen at runtime in normal usage. The more important reason is auth state changes from user actions like /login.
  • DTo avoid stale dynamic skills after clearCommandMemoizationCaches() is called
    Incorrect.clearCommandMemoizationCaches() invalidates the load cache entirely. The separation is about auth freshness, not dynamic skill freshness.