Why this matters
commands.ts is the runtime layer that turns the command registry into the
visible slash-command list. loadAllCommands() assembles bundled skills,
built-in plugin skills, skill dir commands, workflows, plugin commands, and
plugin skills before it appends the built-in commands from COMMANDS(). Then
getCommands() filters that combined list for the current account and provider
before it inserts any dynamic skills.
The path is:
bundled skills -> built-in plugin skills -> skill dir commands -> workflows -> plugin commands -> plugin skills -> built-ins -> availability filter -> dynamic skill insertion
Memoized loading keeps the expensive filesystem and import work from repeating,
while availability checks run fresh on every call so a login or provider change
can show up immediately. Dynamic skills are kept in the current in-memory map
from file operations, so getCommands() reads that map before it inserts
anything into the final list.
The helper functions below live in skills/loadSkillsDir.ts, but they still
belong in this chapter because they define how skill directories are found,
measured, and parsed before commands.ts can assemble the final command
surface.
The registry, the filters, and the late inserts
Assembly order
commands.ts keeps a special internal-only tail for commands that should not be
shipped in the external build. The loading function then assembles the sources
in a fixed order, with the built-in commands appended last.
export const INTERNAL_ONLY_COMMANDS = [
backfillSessions,
breakCache,
bughunter,
commit,
commitPushPr,
ctx_viz,
goodClaude,
issue,
initVerifiers,
...(forceSnip ? [forceSnip] : []),
mockLimits,
bridgeKick,
version,
...(ultraplan ? [ultraplan] : []),
...(subscribePr ? [subscribePr] : []),
resetLimits,
resetLimitsNonInteractive,
onboarding,
share,
summary,
teleport,
antTrace,
perfIssue,
env,
oauthRefresh,
debugToolCall,
agentsPlatform,
autofixPr,
].filter(Boolean)
export const builtInCommandNames = memoize(
(): Set<string> =>
new Set(COMMANDS().flatMap(_ => [_.name, ...(_.aliases ?? [])])),
)
/**
* Loads all command sources (skills, plugins, workflows). Memoized by cwd
* because loading is expensive (disk I/O, dynamic imports).
*/
async function getSkills(cwd: string): Promise<{
skillDirCommands: Command[]
pluginSkills: Command[]
bundledSkills: Command[]
builtinPluginSkills: Command[]
}> {
try {
const [skillDirCommands, pluginSkills] = await Promise.all([
getSkillDirCommands(cwd).catch(err => {
logError(toError(err))
logForDebugging(
'Skill directory commands failed to load, continuing without them',
)
return []
}),
getPluginSkills().catch(err => {
logError(toError(err))
logForDebugging('Plugin skills failed to load, continuing without them')
return []
}),
])
// Bundled skills are registered synchronously at startup
const bundledSkills = getBundledSkills()
// Built-in plugin skills come from enabled built-in plugins
const builtinPluginSkills = getBuiltinPluginSkillCommands()
logForDebugging(
`getSkills returning: ${skillDirCommands.length} skill dir commands, ${pluginSkills.length} plugin skills, ${bundledSkills.length} bundled skills, ${builtinPluginSkills.length} builtin plugin skills`,
)
return {
skillDirCommands,
pluginSkills,
bundledSkills,
builtinPluginSkills,
}
Availability is checked every time
The availability filter is the part that changes with the session. It is not memoized because auth and provider state can change mid-run, so this check has to stay fresh.
/**
* Filters commands by their declared `availability` (auth/provider requirement).
* Commands without `availability` are treated as universal.
* This runs before `isEnabled()` so that provider-gated commands are hidden
* regardless of feature-flag state.
*
* Not memoized — auth state can change mid-session (e.g. after /login),
* so this must be re-evaluated on every getCommands() call.
*/
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':
// Console API key user = direct 1P API customer (not 3P, not claude.ai).
// Excludes 3P (Bedrock/Vertex/Foundry) who don't set ANTHROPIC_BASE_URL
// and gateway users who proxy through a custom base URL.
if (
!isClaudeAISubscriber() &&
!isUsing3PServices() &&
isFirstPartyAnthropicBaseUrl()
)
return true
break
default: {
const _exhaustive: never = a
void _exhaustive
break
}
}
}
return false
/**
* Loads all command sources (skills, plugins, workflows). Memoized by cwd
* because loading is expensive (disk I/O, dynamic imports).
*/
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,
...builtinPluginSkills,
...skillDirCommands,
...workflowCommands,
...pluginCommands,
...pluginSkills,
...COMMANDS(),
]
})
/**
* Returns commands available to the current user. The expensive loading is
* memoized, but availability and isEnabled checks run fresh every call so
* auth changes (e.g. /login) take effect immediately.
*/
Dynamic skills are inserted late
getCommands() is where the whole list becomes what a user actually sees. It
loads the memoized sources, checks availability again, folds in dynamic skills
that were discovered after file operations, and then inserts those dynamic
skills before the built-in commands.
export async function getCommands(cwd: string): Promise<Command[]> {
const allCommands = await loadAllCommands(cwd)
// Get dynamic skills discovered during file operations
const dynamicSkills = getDynamicSkills()
const baseCommands = allCommands.filter(
_ => meetsAvailabilityRequirement(_) && isCommandEnabled(_),
)
if (dynamicSkills.length === 0) {
return baseCommands
}
const baseCommandNames = new Set(baseCommands.map(c => c.name))
const uniqueDynamicSkills = dynamicSkills.filter(
s =>
!baseCommandNames.has(s.name) &&
meetsAvailabilityRequirement(s) &&
isCommandEnabled(s),
)
if (uniqueDynamicSkills.length === 0) {
return baseCommands
}
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),
]
}
/**
* Clears only the memoization caches for commands, WITHOUT clearing skill caches.
* Use this when dynamic skills are added to invalidate cached command lists.
*/
Why the skill helpers still matter
The command registry depends on the skill loader because skills are not just a folder of markdown files. The loader has to know where each directory lives, how expensive a skill looks before it is opened, and how frontmatter becomes command metadata.
/**
* Returns a claude config directory path for a given source.
*/
export function getSkillsPath(
source: SettingSource | 'plugin',
dir: 'skills' | 'commands',
): string {
switch (source) {
case 'policySettings':
return join(getManagedFilePath(), '.claude', dir)
case 'userSettings':
return join(getClaudeConfigHomeDir(), dir)
case 'projectSettings':
return `.claude/${dir}`
case 'plugin':
return 'plugin'
default:
return ''
}
}
/**
* Estimates token count for a skill based on frontmatter only
* (name, description, whenToUse) since full content is only loaded on invocation.
*/
/**
* Estimates token count for a skill based on frontmatter only
* (name, description, whenToUse) since full content is only loaded on invocation.
*/
export function estimateSkillFrontmatterTokens(skill: Command): number {
const frontmatterText = [skill.name, skill.description, skill.whenToUse]
.filter(Boolean)
.join(' ')
return roughTokenCountEstimation(frontmatterText)
}
/**
* Gets a unique identifier for a file by resolving symlinks to a canonical path.
* This allows detection of duplicate files accessed through different paths
* (e.g., via symlinks or overlapping parent directories).
* Returns null if the file doesn't exist or can't be resolved.
*
* Uses realpath to resolve symlinks, which is filesystem-agnostic and avoids
* issues with filesystems that report unreliable inode values (e.g., inode 0 on
* some virtual/container/NFS filesystems, or precision loss on ExFAT).
* See: https://github.com/anthropics/claude-code/issues/13893
*/
/**
* Parses all skill frontmatter fields that are shared between file-based and
* MCP skill loading. Caller supplies the resolved skill name and the
* source/loadedFrom/baseDir/paths fields separately.
*/
export function parseSkillFrontmatterFields(
frontmatter: FrontmatterData,
markdownContent: string,
resolvedName: string,
descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill',
): {
displayName: string | undefined
description: string
hasUserSpecifiedDescription: boolean
allowedTools: string[]
argumentHint: string | undefined
argumentNames: string[]
whenToUse: string | undefined
version: string | undefined
model: ReturnType<typeof parseUserSpecifiedModel> | undefined
disableModelInvocation: boolean
userInvocable: boolean
hooks: HooksSettings | undefined
executionContext: 'fork' | undefined
agent: string | undefined
effort: EffortValue | undefined
shell: FrontmatterShell | undefined
} {
const validatedDescription = coerceDescriptionToString(
frontmatter.description,
resolvedName,
)
const description =
validatedDescription ??
extractDescriptionFromMarkdown(markdownContent, descriptionFallbackLabel)
const userInvocable =
frontmatter['user-invocable'] === undefined
? true
: parseBooleanFrontmatter(frontmatter['user-invocable'])
const model =
frontmatter.model === 'inherit'
? undefined
: frontmatter.model
? parseUserSpecifiedModel(frontmatter.model as string)
: undefined
const effortRaw = frontmatter['effort']
const effort =
effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
if (effortRaw !== undefined && effort === undefined) {
logForDebugging(
`Skill ${resolvedName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
)
}
return {
displayName:
frontmatter.name != null ? String(frontmatter.name) : undefined,
description,
hasUserSpecifiedDescription: validatedDescription !== null,
allowedTools: parseSlashCommandToolsFromFrontmatter(
frontmatter['allowed-tools'],
),
argumentHint:
frontmatter['argument-hint'] != null
? String(frontmatter['argument-hint'])
: undefined,
argumentNames: parseArgumentNames(
frontmatter.arguments as string | string[] | undefined,
),
whenToUse: frontmatter.when_to_use as string | undefined,
version: frontmatter.version as string | undefined,
model,
disableModelInvocation: parseBooleanFrontmatter(
frontmatter['disable-model-invocation'],
),
userInvocable,
hooks: parseHooksFromFrontmatter(frontmatter, resolvedName),
executionContext: frontmatter.context === 'fork' ? 'fork' : undefined,
agent: frontmatter.agent as string | undefined,
effort,
shell: parseShellFrontmatter(frontmatter.shell, resolvedName),
}
}
The important split is simple: expensive loading is memoized, visibility checks are fresh, and dynamic skills are inserted after the runtime has already seen the current filesystem state. That is why this layer has to sit between the registry and the final command list.