markdownConfigLoader.ts
utils/markdownConfigLoader.ts
No strong subsystem tag
601
Lines
21312
Bytes
8
Exports
20
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 601 lines, 20 detected imports, and 8 detected exports.
Important relationships
Detected exports
CLAUDE_CONFIG_DIRECTORIESClaudeConfigDirectoryMarkdownFileextractDescriptionFromMarkdownparseAgentToolsFromFrontmatterparseSlashCommandToolsFromFrontmattergetProjectDirsUpToHomeloadMarkdownFilesForSubdir
Keywords
filefilessubdirdirectoryclaudetoolsvaluestatsripgrepprojectfilepath
Detected imports
bun:bundlefsfs/promiseslodash-es/memoize.jsospathsrc/services/analytics/index.js../bootstrap/state.js./debug.js./envUtils.js./errors.js./file.js./frontmatterParser.js./frontmatterParser.js./git.js./permissions/permissionSetup.js./ripgrep.js./settings/constants.js./settings/managedPath.js./settings/pluginOnlyPolicy.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
import { feature } from 'bun:bundle'
import { statSync } from 'fs'
import { lstat, readdir, readFile, realpath, stat } from 'fs/promises'
import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os'
import { dirname, join, resolve, sep } from 'path'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { getProjectRoot } from '../bootstrap/state.js'
import { logForDebugging } from './debug.js'
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
import { isFsInaccessible } from './errors.js'
import { normalizePathForComparison } from './file.js'
import type { FrontmatterData } from './frontmatterParser.js'
import { parseFrontmatter } from './frontmatterParser.js'
import { findCanonicalGitRoot, findGitRoot } from './git.js'
import { parseToolListFromCLI } from './permissions/permissionSetup.js'
import { ripGrep } from './ripgrep.js'
import {
isSettingSourceEnabled,
type SettingSource,
} from './settings/constants.js'
import { getManagedFilePath } from './settings/managedPath.js'
import { isRestrictedToPluginOnly } from './settings/pluginOnlyPolicy.js'
// Claude configuration directory names
export const CLAUDE_CONFIG_DIRECTORIES = [
'commands',
'agents',
'output-styles',
'skills',
'workflows',
...(feature('TEMPLATES') ? (['templates'] as const) : []),
] as const
export type ClaudeConfigDirectory = (typeof CLAUDE_CONFIG_DIRECTORIES)[number]
export type MarkdownFile = {
filePath: string
baseDir: string
frontmatter: FrontmatterData
content: string
source: SettingSource
}
/**
* Extracts a description from markdown content
* Uses the first non-empty line as the description, or falls back to a default
*/
export function extractDescriptionFromMarkdown(
content: string,
defaultDescription: string = 'Custom item',
): string {
const lines = content.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (trimmed) {
// If it's a header, strip the header prefix
const headerMatch = trimmed.match(/^#+\s+(.+)$/)
const text = headerMatch?.[1] ?? trimmed
// Return the text, limited to reasonable length
return text.length > 100 ? text.substring(0, 97) + '...' : text
}
}
return defaultDescription
}
/**
* Parses tools from frontmatter, supporting both string and array formats
* Always returns a string array for consistency
* @param toolsValue The value from frontmatter
* @returns Parsed tool list as string[]
*/
function parseToolListString(toolsValue: unknown): string[] | null {
// Return null for missing/null - let caller decide the default
if (toolsValue === undefined || toolsValue === null) {
return null
}
// Empty string or other falsy values mean no tools
if (!toolsValue) {
return []
}
let toolsArray: string[] = []
if (typeof toolsValue === 'string') {
toolsArray = [toolsValue]
} else if (Array.isArray(toolsValue)) {
toolsArray = toolsValue.filter(
(item): item is string => typeof item === 'string',
)
}
if (toolsArray.length === 0) {
return []
}
const parsedTools = parseToolListFromCLI(toolsArray)
if (parsedTools.includes('*')) {
return ['*']
}
return parsedTools
}
/**
* Parse tools from agent frontmatter
* Missing field = undefined (all tools)
* Empty field = [] (no tools)
*/
export function parseAgentToolsFromFrontmatter(
toolsValue: unknown,
): string[] | undefined {
const parsed = parseToolListString(toolsValue)
if (parsed === null) {
// For agents: undefined = all tools (undefined), null = no tools ([])
return toolsValue === undefined ? undefined : []
}
// If parsed contains '*', return undefined (all tools)
if (parsed.includes('*')) {
return undefined
}
return parsed
}
/**
* Parse allowed-tools from slash command frontmatter
* Missing or empty field = no tools ([])
*/
export function parseSlashCommandToolsFromFrontmatter(
toolsValue: unknown,
): string[] {
const parsed = parseToolListString(toolsValue)
if (parsed === null) {
return []
}
return parsed
}
/**
* Gets a unique identifier for a file based on its device ID and inode.
* This allows detection of duplicate files accessed through different paths
* (e.g., via symlinks). Returns null if the file doesn't exist or can't be stat'd.
*
* Note: On Windows, dev and ino may not be reliable for all file systems.
* The code handles this gracefully by returning null on error (fail open),
* meaning deduplication may not work on some Windows configurations.
*
* Uses bigint: true to handle filesystems with large inodes (e.g., ExFAT)
* that exceed JavaScript's Number precision (53 bits). Without bigint, different
* large inodes can round to the same Number, causing false duplicate detection.
* See: https://github.com/anthropics/claude-code/issues/13893
*
* @param filePath - Path to the file
* @returns A string identifier "device:inode" or null if file can't be identified
*/
async function getFileIdentity(filePath: string): Promise<string | null> {
try {
const stats = await lstat(filePath, { bigint: true })
// Some filesystems (NFS, FUSE, network mounts) report dev=0 and ino=0
// for all files, which would cause every file to look like a duplicate.
// Return null to skip deduplication for these unreliable identities.
if (stats.dev === 0n && stats.ino === 0n) {
return null
}
return `${stats.dev}:${stats.ino}`
} catch {
return null
}
}
/**
* Compute the stop boundary for getProjectDirsUpToHome's upward walk.
*
* Normally the walk stops at the nearest `.git` above `cwd`. But if the Bash
* tool has cd'd into a nested git repo inside the session's project (submodule,
* vendored dep with its own `.git`), that nested root isn't the right boundary —
* stopping there makes the parent project's `.claude/` unreachable (#31905).
*
* The boundary is widened to the session's git root only when BOTH:
* - the nearest `.git` from cwd belongs to a *different* canonical repo
* (submodule/vendored clone — not a worktree, which resolves back to main)
* - that nearest `.git` sits *inside* the session's project tree
*
* Worktrees (under `.claude/worktrees/`) stay on the old behavior: their `.git`
* file is the stop, and loadMarkdownFilesForSubdir's fallback adds the main-repo
* copy only when the worktree lacks one.
*/
function resolveStopBoundary(cwd: string): string | null {
const cwdGitRoot = findGitRoot(cwd)
const sessionGitRoot = findGitRoot(getProjectRoot())
if (!cwdGitRoot || !sessionGitRoot) {
return cwdGitRoot
}
// findCanonicalGitRoot resolves worktree `.git` files to the main repo.
// Submodules (no commondir) and standalone clones fall through unchanged.
const cwdCanonical = findCanonicalGitRoot(cwd)
if (
cwdCanonical &&
normalizePathForComparison(cwdCanonical) ===
normalizePathForComparison(sessionGitRoot)
) {
// Same canonical repo (main, or a worktree of main). Stop at nearest .git.
return cwdGitRoot
}
// Different canonical repo. Is it nested *inside* the session's project?
const nCwdGitRoot = normalizePathForComparison(cwdGitRoot)
const nSessionRoot = normalizePathForComparison(sessionGitRoot)
if (
nCwdGitRoot !== nSessionRoot &&
nCwdGitRoot.startsWith(nSessionRoot + sep)
) {
// Nested repo inside the project — skip past it, stop at the project's root.
return sessionGitRoot
}
// Sibling repo or elsewhere. Stop at nearest .git (old behavior).
return cwdGitRoot
}
/**
* Traverses from the current directory up to the git root (or home directory if not in a git repo),
* collecting all .claude directories along the way.
*
* Stopping at git root prevents commands/skills from parent directories outside the repository
* from leaking into projects. For example, if ~/projects/.claude/commands/ exists, it won't
* appear in ~/projects/my-repo/ if my-repo is a git repository.
*
* @param subdir Subdirectory (eg. "commands", "agents")
* @param cwd Current working directory to start from
* @returns Array of directory paths containing .claude/subdir, from most specific (cwd) to least specific
*/
export function getProjectDirsUpToHome(
subdir: ClaudeConfigDirectory,
cwd: string,
): string[] {
const home = resolve(homedir()).normalize('NFC')
const gitRoot = resolveStopBoundary(cwd)
let current = resolve(cwd)
const dirs: string[] = []
// Traverse from current directory up to git root (or home if not in a git repo)
while (true) {
// Stop if we've reached the home directory (don't check it, as it's loaded separately as userDir)
// Use normalized comparison to handle Windows drive letter casing (C:\ vs c:\)
if (
normalizePathForComparison(current) === normalizePathForComparison(home)
) {
break
}
const claudeSubdir = join(current, '.claude', subdir)
// Filter to existing dirs. This is a perf filter (avoids spawning
// ripgrep on non-existent dirs downstream) and the worktree fallback
// in loadMarkdownFilesForSubdir relies on it. statSync + explicit error
// handling instead of existsSync — re-throws unexpected errors rather
// than silently swallowing them. Downstream loadMarkdownFiles handles
// the TOCTOU window (dir disappearing before read) gracefully.
try {
statSync(claudeSubdir)
dirs.push(claudeSubdir)
} catch (e: unknown) {
if (!isFsInaccessible(e)) throw e
}
// Stop after processing the git root directory - this prevents commands from parent
// directories outside the repository from appearing in the project
if (
gitRoot &&
normalizePathForComparison(current) ===
normalizePathForComparison(gitRoot)
) {
break
}
// Move to parent directory
const parent = dirname(current)
// Safety check: if parent is the same as current, we've reached the root
if (parent === current) {
break
}
current = parent
}
return dirs
}
/**
* Loads markdown files from managed, user, and project directories
* @param subdir Subdirectory (eg. "agents" or "commands")
* @param cwd Current working directory for project directory traversal
* @returns Array of parsed markdown files with metadata
*/
export const loadMarkdownFilesForSubdir = memoize(
async function (
subdir: ClaudeConfigDirectory,
cwd: string,
): Promise<MarkdownFile[]> {
const searchStartTime = Date.now()
const userDir = join(getClaudeConfigHomeDir(), subdir)
const managedDir = join(getManagedFilePath(), '.claude', subdir)
const projectDirs = getProjectDirsUpToHome(subdir, cwd)
// For git worktrees where the worktree does NOT have .claude/<subdir> checked
// out (e.g. sparse-checkout), fall back to the main repository's copy.
// getProjectDirsUpToHome stops at the worktree root (where the .git file is),
// so it never sees the main repo on its own.
//
// Only add the main repo's copy when the worktree root's .claude/<subdir>
// is absent. A standard `git worktree add` checks out the full tree, so the
// worktree already has identical .claude/<subdir> content — loading the main
// repo's copy too would duplicate every command/agent/skill
// (anthropics/claude-code#29599, #28182, #26992).
//
// projectDirs already reflects existence (getProjectDirsUpToHome checked
// each dir), so we compare against that instead of stat'ing again.
const gitRoot = findGitRoot(cwd)
const canonicalRoot = findCanonicalGitRoot(cwd)
if (gitRoot && canonicalRoot && canonicalRoot !== gitRoot) {
const worktreeSubdir = normalizePathForComparison(
join(gitRoot, '.claude', subdir),
)
const worktreeHasSubdir = projectDirs.some(
dir => normalizePathForComparison(dir) === worktreeSubdir,
)
if (!worktreeHasSubdir) {
const mainClaudeSubdir = join(canonicalRoot, '.claude', subdir)
if (!projectDirs.includes(mainClaudeSubdir)) {
projectDirs.push(mainClaudeSubdir)
}
}
}
const [managedFiles, userFiles, projectFilesNested] = await Promise.all([
// Always load managed (policy settings)
loadMarkdownFiles(managedDir).then(_ =>
_.map(file => ({
...file,
baseDir: managedDir,
source: 'policySettings' as const,
})),
),
// Conditionally load user files
isSettingSourceEnabled('userSettings') &&
!(subdir === 'agents' && isRestrictedToPluginOnly('agents'))
? loadMarkdownFiles(userDir).then(_ =>
_.map(file => ({
...file,
baseDir: userDir,
source: 'userSettings' as const,
})),
)
: Promise.resolve([]),
// Conditionally load project files from all directories up to home
isSettingSourceEnabled('projectSettings') &&
!(subdir === 'agents' && isRestrictedToPluginOnly('agents'))
? Promise.all(
projectDirs.map(projectDir =>
loadMarkdownFiles(projectDir).then(_ =>
_.map(file => ({
...file,
baseDir: projectDir,
source: 'projectSettings' as const,
})),
),
),
)
: Promise.resolve([]),
])
// Flatten nested project files array
const projectFiles = projectFilesNested.flat()
// Combine all files with priority: managed > user > project
const allFiles = [...managedFiles, ...userFiles, ...projectFiles]
// Deduplicate files that resolve to the same physical file (same inode).
// This prevents the same file from appearing multiple times when ~/.claude is
// symlinked to a directory within the project hierarchy, causing the same
// physical file to be discovered through different paths.
const fileIdentities = await Promise.all(
allFiles.map(file => getFileIdentity(file.filePath)),
)
const seenFileIds = new Map<string, SettingSource>()
const deduplicatedFiles: MarkdownFile[] = []
for (const [i, file] of allFiles.entries()) {
const fileId = fileIdentities[i] ?? null
if (fileId === null) {
// If we can't identify the file, include it (fail open)
deduplicatedFiles.push(file)
continue
}
const existingSource = seenFileIds.get(fileId)
if (existingSource !== undefined) {
logForDebugging(
`Skipping duplicate file '${file.filePath}' from ${file.source} (same inode already loaded from ${existingSource})`,
)
continue
}
seenFileIds.set(fileId, file.source)
deduplicatedFiles.push(file)
}
const duplicatesRemoved = allFiles.length - deduplicatedFiles.length
if (duplicatesRemoved > 0) {
logForDebugging(
`Deduplicated ${duplicatesRemoved} files in ${subdir} (same inode via symlinks or hard links)`,
)
}
logEvent(`tengu_dir_search`, {
durationMs: Date.now() - searchStartTime,
managedFilesFound: managedFiles.length,
userFilesFound: userFiles.length,
projectFilesFound: projectFiles.length,
projectDirsSearched: projectDirs.length,
subdir:
subdir as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return deduplicatedFiles
},
// Custom resolver creates cache key from both subdir and cwd parameters
(subdir: ClaudeConfigDirectory, cwd: string) => `${subdir}:${cwd}`,
)
/**
* Native implementation to find markdown files using Node.js fs APIs
*
* This implementation exists alongside ripgrep for the following reasons:
* 1. Ripgrep has poor startup performance in native builds (noticeable on app startup)
* 2. Provides a fallback when ripgrep is unavailable
* 3. Can be explicitly enabled via CLAUDE_CODE_USE_NATIVE_FILE_SEARCH env var
*
* Symlink handling:
* - Follows symlinks (equivalent to ripgrep's --follow flag)
* - Uses device+inode tracking to detect cycles (same as ripgrep's same_file library)
* - Falls back to realpath on systems without inode support
*
* Does not respect .gitignore (matches ripgrep with --no-ignore flag)
*
* @param dir Directory to search
* @param signal AbortSignal for timeout
* @returns Array of file paths
*/
async function findMarkdownFilesNative(
dir: string,
signal: AbortSignal,
): Promise<string[]> {
const files: string[] = []
const visitedDirs = new Set<string>()
async function walk(currentDir: string): Promise<void> {
if (signal.aborted) {
return
}
// Cycle detection: track visited directories by device+inode
// Uses bigint: true to handle filesystems with large inodes (e.g., ExFAT)
// that exceed JavaScript's Number precision (53 bits).
// See: https://github.com/anthropics/claude-code/issues/13893
try {
const stats = await stat(currentDir, { bigint: true })
if (stats.isDirectory()) {
const dirKey =
stats.dev !== undefined && stats.ino !== undefined
? `${stats.dev}:${stats.ino}` // Unix/Linux: device + inode
: await realpath(currentDir) // Windows: canonical path
if (visitedDirs.has(dirKey)) {
logForDebugging(
`Skipping already visited directory (circular symlink): ${currentDir}`,
)
return
}
visitedDirs.add(dirKey)
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(`Failed to stat directory ${currentDir}: ${errorMessage}`)
return
}
try {
const entries = await readdir(currentDir, { withFileTypes: true })
for (const entry of entries) {
if (signal.aborted) {
break
}
const fullPath = join(currentDir, entry.name)
try {
// Handle symlinks: isFile() and isDirectory() return false for symlinks
if (entry.isSymbolicLink()) {
try {
const stats = await stat(fullPath) // stat() follows symlinks
if (stats.isDirectory()) {
await walk(fullPath)
} else if (stats.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath)
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(
`Failed to follow symlink ${fullPath}: ${errorMessage}`,
)
}
} else if (entry.isDirectory()) {
await walk(fullPath)
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath)
}
} catch (error) {
// Skip files/directories we can't access
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(`Failed to access ${fullPath}: ${errorMessage}`)
}
}
} catch (error) {
// If readdir fails (e.g., permission denied), log and continue
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(`Failed to read directory ${currentDir}: ${errorMessage}`)
}
}
await walk(dir)
return files
}
/**
* Generic function to load markdown files from specified directories
* @param dir Directory (eg. "~/.claude/commands")
* @returns Array of parsed markdown files with metadata
*/
async function loadMarkdownFiles(dir: string): Promise<
{
filePath: string
frontmatter: FrontmatterData
content: string
}[]
> {
// File search strategy:
// - Default: ripgrep (faster, battle-tested)
// - Fallback: native Node.js (when CLAUDE_CODE_USE_NATIVE_FILE_SEARCH is set)
//
// Why both? Ripgrep has poor startup performance in native builds.
const useNative = isEnvTruthy(process.env.CLAUDE_CODE_USE_NATIVE_FILE_SEARCH)
const signal = AbortSignal.timeout(3000)
let files: string[]
try {
files = useNative
? await findMarkdownFilesNative(dir, signal)
: await ripGrep(
['--files', '--hidden', '--follow', '--no-ignore', '--glob', '*.md'],
dir,
signal,
)
} catch (e: unknown) {
// Handle missing/inaccessible dir directly instead of pre-checking
// existence (TOCTOU). findMarkdownFilesNative already catches internally;
// ripGrep rejects on inaccessible target paths.
if (isFsInaccessible(e)) return []
throw e
}
const results = await Promise.all(
files.map(async filePath => {
try {
const rawContent = await readFile(filePath, { encoding: 'utf-8' })
const { frontmatter, content } = parseFrontmatter(rawContent, filePath)
return {
filePath,
frontmatter,
content,
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(
`Failed to read/parse markdown file: ${filePath}: ${errorMessage}`,
)
return null
}
}),
)
return results.filter(_ => _ !== null)
}