Claude Code Command System: Six Sources, One Registry
How six command sources assemble into a memoized registry, and what REMOTE_SAFE and BRIDGE_SAFE availability flags actually control.
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
src/
├── services/
│ ├── plugins/
│ │ └── index.ts
├── skills/
│ └── index.ts
├── types/
│ └── command.tssrc/commands.ts
Command assembly and routing layer
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.
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:
- What commands exist right now? →
commands.ts - 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
Bundled skills
bundledSkillsSkills shipped with Claude Code as part of the binary. Always available, no discovery needed.
- 2
Built-in plugin skills
builtinPluginSkillsSkills from first-party plugins that ship with Claude Code. Distinct from user-installed plugins.
- 3
Skill directory commands
skillDirCommandsSkills found in .claude/commands/ in the project or user config directory. Loaded from disk, memoized by cwd.
- 4
Workflow commands
workflowCommandsFeature-flagged workflow-type commands. Loaded via getWorkflowCommands, absent if the feature is off.
- 5
Plugin commands and plugin skills
pluginCommands / pluginSkillsCommands and skills contributed by installed plugins. Plugins are the primary extension point for third-party tooling.
- 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 availabilityisCommandEnabled(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.
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:
- Image and attachment normalization — images, file paths, and memory attachments are extracted before routing
- Hook processing —
pre_tool_useand similar hooks can intercept and transform input - Bridge origin check — input arriving over the bridge is checked against
isBridgeSafeCommandbefore the slash handler runs - 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
.mdfile in.claude/commands/and the skill is discovered on nextgetCommands()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?
mediumgetCommands() 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.