diff --git a/src/components/Settings/Config.test.tsx b/src/components/Settings/Config.test.tsx new file mode 100644 index 00000000..0adaa858 --- /dev/null +++ b/src/components/Settings/Config.test.tsx @@ -0,0 +1,98 @@ +import { expect, spyOn, test } from 'bun:test' +import React from 'react' +import { render } from 'ink' +import { PassThrough } from 'node:stream' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import * as ink from '../../ink.js' +import * as appState from '../../state/AppState.js' +import * as config from '../../utils/config.js' +import * as settings from '../../utils/settings/settings.js' +import * as memory from '../../utils/claudemd.js' +import * as tabs from '../design-system/Tabs.js' +import * as bindings from '../../keybindings/useKeybinding.js' +import * as search from '../../hooks/useSearchInput.js' +import * as terminal from '../../hooks/useTerminalSize.js' +import * as instructions from '../../utils/instructionFiles.js' +import { Config } from './Config.js' + +const tick = () => new Promise(resolve => setTimeout(resolve, 20)) + +test('list Escape belongs to Config and restores project instructions', async () => { + const dir = mkdtempSync(join(tmpdir(), 'config-cancel-')) + const oldHome = process.env.HOME + const oldConfig = process.env.CLAUDE_CONFIG_DIR + process.env.HOME = dir + process.env.CLAUDE_CONFIG_DIR = dir + const spies: Array<{ mockRestore(): void }> = [] + let keyDown: (event: any) => void = () => {} + const handlers = new Map void>() + let ownsEsc = false + let closed: string | undefined + let state = appState.getDefaultAppState() + const initial = { pluginConfigs: { 'agents-md@builtin': { options: { instructionFiles: 'claude-md-or-agents-md', sibling: true } } } } + let user = structuredClone(initial) + const memoryPromise = Promise.resolve([]) + const store = { getState: () => state } + spies.push( + spyOn(ink, 'Box').mockImplementation((props: any) => { if (props.onKeyDown) keyDown = props.onKeyDown; return null }), + spyOn(ink, 'useTheme').mockReturnValue([{}, () => {}] as any), + spyOn(ink, 'useThemeSetting').mockReturnValue('dark' as any), + spyOn(ink, 'useTerminalFocus').mockReturnValue(true), + spyOn(appState, 'useAppState').mockImplementation((selector: any) => selector(state)), + spyOn(appState, 'useSetAppState').mockReturnValue((updater: any) => { state = updater(state) }), + spyOn(appState, 'useAppStateStore').mockReturnValue(store as any), + spyOn(config, 'getGlobalConfig').mockReturnValue({} as any), + spyOn(config, 'saveGlobalConfig').mockImplementation(() => {}), + spyOn(config, 'getCurrentProjectConfig').mockReturnValue({} as any), + spyOn(settings, 'getInitialSettings').mockImplementation(() => user), + spyOn(settings, 'getSettingsForSource').mockImplementation(source => source === 'userSettings' ? user : {}), + spyOn(settings, 'updateSettingsForSource').mockImplementation((source, patch) => { + if (source === 'userSettings' && patch.pluginConfigs) { + user = { ...user, pluginConfigs: { ...user.pluginConfigs, 'agents-md@builtin': { options: { ...user.pluginConfigs['agents-md@builtin'].options, ...patch.pluginConfigs['agents-md@builtin']?.options } } } } as typeof user + } + return { error: null } + }), + spyOn(memory, 'getMemoryFiles').mockReturnValue(memoryPromise), + spyOn(memory, 'clearMemoryFileCaches').mockImplementation(() => {}), + spyOn(tabs, 'useTabHeaderFocus').mockReturnValue({ headerFocused: false, focusHeader: () => {} } as any), + spyOn(search, 'useSearchInput').mockReturnValue({ query: 'instructions', setQuery: () => {}, cursorOffset: 12 } as any), + spyOn(terminal, 'useTerminalSize').mockReturnValue({ rows: 30, columns: 100 } as any), + spyOn(bindings, 'useKeybinding').mockImplementation((action, handler, options) => { + if (options?.isActive) handlers.set(action, handler as () => void) + else handlers.delete(action) + }), + spyOn(bindings, 'useKeybindings').mockImplementation((actions, options) => { + for (const [action, handler] of Object.entries(actions)) { + if (options?.isActive) handlers.set(action, handler as () => void) + else handlers.delete(action) + } + }), + ) + const app = render( { closed = value }} setTabsHidden={() => {}} onIsSearchModeChange={value => { ownsEsc = value }} />, { + stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough(), exitOnCtrlC: false, patchConsole: false, + }) + try { + await new Promise(resolve => setTimeout(resolve, 350)) + keyDown({ key: 'return', preventDefault() {} }) + await tick() + expect(ownsEsc).toBe(true) + handlers.get('select:accept')?.() + await tick() + expect(instructions.getInstructionFilesModeFromSettings(user)).toBe('claude-md-and-agents-md') + handlers.get('confirm:no')?.() + await tick() + expect(closed).toBe('Config dialog dismissed') + expect(instructions.getInstructionFilesModeFromSettings(user)).toBe('claude-md-or-agents-md') + expect(user.pluginConfigs['agents-md@builtin'].options.sibling).toBe(true) + } finally { + app.unmount() + for (const spy of spies.reverse()) spy.mockRestore() + if (oldHome === undefined) delete process.env.HOME + else process.env.HOME = oldHome + if (oldConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = oldConfig + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index 3657481f..ffbd2869 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -27,7 +27,7 @@ import { Dialog } from '../design-system/Dialog.js'; import { Select } from '../CustomSelect/index.js'; import { OutputStylePicker } from '../OutputStylePicker.js'; import { LanguagePicker } from '../LanguagePicker.js'; -import { getExternalClaudeMdIncludes, getMemoryFiles, hasExternalClaudeMdIncludes } from 'src/utils/claudemd.js'; +import { clearMemoryFileCaches, getExternalClaudeMdIncludes, getMemoryFiles, hasExternalClaudeMdIncludes } from 'src/utils/claudemd.js'; import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; import { Byline } from '../design-system/Byline.js'; @@ -48,6 +48,8 @@ import { useSearchInput } from '../../hooks/useSearchInput.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeEnabled, getFastModeModel, isFastModeSupportedByModel } from '../../utils/fastMode.js'; import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; +import { getUserContext } from '../../context.js'; +import { getInstructionFilesMode, INSTRUCTION_FILE_MODES, INSTRUCTION_FILES_PLUGIN, instructionFilesSettingsPatch } from '../../utils/instructionFiles.js'; type Props = { onClose: (result?: string, options?: { display?: CommandResultDisplay; @@ -189,9 +191,10 @@ export function Config({ }); // Tell the parent when Config's own Esc handler is active so Settings cedes - // confirm:no. Only true when search mode owns the keyboard — not when the - // tab header is focused (then Settings must handle Esc-to-close). - const ownsEsc = isSearchMode && !headerFocused; + // confirm:no. Config owns both search and list input: list Escape must + // revert immediate settings writes before closing. The tab header still + // delegates Escape to Settings. + const ownsEsc = !headerFocused; React.useEffect(() => { onIsSearchModeChange?.(ownsEsc); }, [ownsEsc, onIsSearchModeChange]); @@ -432,6 +435,22 @@ export function Config({ }); } }] : []), { + id: 'instructionFiles', + label: 'Project instructions', + value: getInstructionFilesMode(), + options: [...INSTRUCTION_FILE_MODES], + type: 'enum' as const, + onChange(value: string) { + const result = updateSettingsForSource('userSettings', instructionFilesSettingsPatch(value)); + if (result.error) { + logError(result.error); + return; + } + clearMemoryFileCaches(); + getUserContext.cache.clear?.(); + setSettingsData(getInitialSettings()); + } + }, { id: 'workflows', label: 'Dynamic workflows', value: settingsData?.disableWorkflows === true ? false : settingsData?.enableWorkflows ?? true, @@ -1249,6 +1268,9 @@ export function Config({ outputStyle: il?.outputStyle }); const iu = initialUserSettings; + updateSettingsForSource('userSettings', instructionFilesSettingsPatch(iu?.pluginConfigs?.[INSTRUCTION_FILES_PLUGIN]?.options?.instructionFiles as string | undefined, iu?.pluginConfigs?.[INSTRUCTION_FILES_PLUGIN]?.options?.projectInstructions as string | undefined)); + clearMemoryFileCaches(); + getUserContext.cache.clear?.(); updateSettingsForSource('userSettings', { alwaysThinkingEnabled: iu?.alwaysThinkingEnabled, fastMode: iu?.fastMode, diff --git a/src/context.ts b/src/context.ts index 423414d1..ce944e94 100644 --- a/src/context.ts +++ b/src/context.ts @@ -16,6 +16,7 @@ import { execFileNoThrow } from './utils/execFileNoThrow.js' import { getBranch, getDefaultBranch, getIsGit, gitExe } from './utils/git.js' import { shouldIncludeGitInstructions } from './utils/gitSettings.js' import { logError } from './utils/log.js' +import { getInstructionFilesMode, type InstructionFilesMode } from './utils/instructionFiles.js' const MAX_STATUS_CHARS = 2000 @@ -152,6 +153,8 @@ export const getSystemContext = memoize( /** * This context is prepended to each conversation, and cached for the duration of the conversation. */ +let cachedUserContextMode: InstructionFilesMode | undefined + export const getUserContext = memoize( async (): Promise<{ [k: string]: string @@ -186,4 +189,14 @@ export const getUserContext = memoize( currentDate: `Today's date is ${getLocalISODate()}.`, } }, + () => { + const mode = getInstructionFilesMode() + if (mode !== cachedUserContextMode) { + // Keep only the current mode so returning to an earlier mode also + // refreshes the classifier's cached instruction content. + getUserContext.cache.clear?.() + cachedUserContextMode = mode + } + return mode + }, ) diff --git a/src/utils/claudemd.test.ts b/src/utils/claudemd.test.ts new file mode 100644 index 00000000..b620b4ee --- /dev/null +++ b/src/utils/claudemd.test.ts @@ -0,0 +1,282 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, spyOn, test } from 'bun:test' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +let root: string +let cwd: string +let mode: string | undefined +let excludes: string[] +let additional: string[] +let disabledSources: Set +let memory: typeof import('./claudemd.js') +let config: typeof import('./config.js') +let state: typeof import('../bootstrap/state.js') +let settings: typeof import('./settings/settings.js') +let sources: typeof import('./settings/constants.js') +let fsOperations: typeof import('./fsOperations.js') +let autoMem: typeof import('../memdir/paths.js') +let hooks: typeof import('./hooks.js') +const spies: Array<{ mockRestore(): void }> = [] +const envKeys = ['HOME', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD'] as const +const originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])) + +function put(path: string, content: string) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +beforeAll(async () => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'agents-md-memory-'))) + process.env.HOME = join(root, 'home') + process.env.CLAUDE_CONFIG_DIR = join(root, 'config') + ;[config, state, settings, sources, fsOperations, autoMem, hooks] = await Promise.all([ + import('./config.js'), import('../bootstrap/state.js'), import('./settings/settings.js'), + import('./settings/constants.js'), import('./fsOperations.js'), import('../memdir/paths.js'), import('./hooks.js'), + ]) + memory = await import('./claudemd.js') +}) + +beforeEach(() => { + cwd = join(root, 'project', 'child') + rmSync(join(root, 'project'), { recursive: true, force: true }) + mkdirSync(cwd, { recursive: true }) + mode = undefined + excludes = [] + additional = [] + disabledSources = new Set() + delete process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD + const userSettings = () => ({ pluginConfigs: { 'agents-md@builtin': { options: { instructionFiles: mode } } } }) + spies.push( + spyOn(state, 'getOriginalCwd').mockImplementation(() => cwd), + spyOn(state, 'getAdditionalDirectoriesForClaudeMd').mockImplementation(() => additional), + spyOn(config, 'getCurrentProjectConfig').mockReturnValue({} as ReturnType), + spyOn(config, 'getMemoryPath').mockImplementation(type => join(root, 'policy', type, 'CLAUDE.md')), + spyOn(config, 'getManagedClaudeRulesDir').mockReturnValue(join(root, 'policy', 'managed-rules')), + spyOn(config, 'getUserClaudeRulesDir').mockReturnValue(join(root, 'policy', 'user-rules')), + spyOn(settings, 'getInitialSettings').mockImplementation(() => ({ ...userSettings(), claudeMdExcludes: excludes })), + spyOn(settings, 'getSettingsForSource').mockImplementation(source => source === 'userSettings' ? userSettings() : {}), + spyOn(sources, 'isSettingSourceEnabled').mockImplementation(source => !disabledSources.has(source)), + spyOn(autoMem, 'isAutoMemoryEnabled').mockReturnValue(false), + spyOn(hooks, 'hasInstructionsLoadedHook').mockReturnValue(false), + ) + // Real fixture reads, but never inspect instructions from host ancestors. + const fs = fsOperations.getFsImplementation() + spies.push(spyOn(fsOperations, 'getFsImplementation').mockReturnValue({ + ...fs, + readFile: async (path, options) => { + if (!path.startsWith(root + '/')) throw Object.assign(new Error('Outside fixture'), { code: 'ENOENT' }) + return fs.readFile(path, options) + }, + readdir: async path => path.startsWith(root + '/') ? fs.readdir(path) : [], + })) + memory.clearMemoryFileCaches() +}) + +afterEach(() => { + memory.clearMemoryFileCaches() + for (const spy of spies.splice(0).reverse()) spy.mockRestore() + rmSync(join(root, 'policy'), { recursive: true, force: true }) +}) + +afterAll(() => { + for (const key of envKeys) { + const value = originalEnv[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + rmSync(root, { recursive: true, force: true }) +}) + +const contents = async () => (await memory.getMemoryFiles()).map(file => file.content) +const nestedContents = async (dir = join(cwd, 'nested')) => + (await memory.getMemoryFilesForNestedDirectory(dir, join(dir, 'file.ts'), new Set())).map(file => file.content) + +test('loads ancestor and dot-directory AGENTS instructions without CLAUDE instructions', async () => { + put(join(dirname(cwd), 'AGENTS.md'), 'ancestor') + put(join(cwd, 'AGENTS.md'), 'project') + put(join(cwd, '.claude', 'AGENTS.md'), 'dot directory') + expect(await contents()).toEqual(['ancestor', 'project', 'dot directory']) +}) + +test.each(['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md'])('loaded ancestor %s suppresses all startup fallback', async name => { + put(join(dirname(cwd), name), 'claude') + put(join(cwd, 'AGENTS.md'), 'agents') + expect(await contents()).toEqual(['claude']) +}) + +test('empty CLAUDE file does not suppress AGENTS fallback', async () => { + put(join(cwd, 'CLAUDE.md'), '') + put(join(cwd, 'AGENTS.md'), 'agents') + expect(await contents()).toEqual(['agents']) +}) + +test('both mode deduplicates imported paths and equal contents', async () => { + mode = 'claude-md-and-agents-md' + put(join(cwd, 'CLAUDE.md'), '@./AGENTS.md\nclaude') + put(join(cwd, 'AGENTS.md'), 'agents') + put(join(cwd, '.claude', 'AGENTS.md'), 'additional agents') + put(join(dirname(cwd), 'AGENTS.md'), 'agents') + const loaded = await contents() + expect(loaded.filter(content => content === 'agents')).toHaveLength(1) + expect(loaded).toContain('additional agents') + expect(loaded).toHaveLength(3) +}) + +test('claude-only mode ignores AGENTS files', async () => { + mode = 'claude-md' + put(join(cwd, 'AGENTS.md'), 'agents') + expect(await contents()).toEqual([]) +}) + +test('managed-only preserves managed memory and excludes project, local, and nested rules', async () => { + mode = 'managed-only' + put(join(root, 'policy', 'Managed', 'CLAUDE.md'), 'managed') + const autoPath = join(root, 'policy', 'MEMORY.md') + put(autoPath, 'auto memory') + spies.push( + spyOn(autoMem, 'isAutoMemoryEnabled').mockReturnValue(true), + spyOn(autoMem, 'getAutoMemEntrypoint').mockReturnValue(autoPath), + ) + put(join(root, 'policy', 'User', 'CLAUDE.md'), 'user') + for (const dir of [cwd, join(cwd, 'nested')]) { + for (const file of ['CLAUDE.md', 'AGENTS.md', 'CLAUDE.local.md', '.claude/rules/test.md']) put(join(dir, file), file) + } + expect(await contents()).toEqual(['managed', 'auto memory']) + expect(await nestedContents()).toEqual([]) +}) + +test('nested AGENTS loads unless session or directory CLAUDE instructions apply', async () => { + put(join(cwd, 'nested', 'AGENTS.md'), 'nested agents') + expect(await nestedContents()).toEqual(['nested agents']) + put(join(cwd, 'CLAUDE.md'), 'session claude') + memory.clearMemoryFileCaches() + expect(await nestedContents()).toEqual([]) + rmSync(join(cwd, 'CLAUDE.md')) + put(join(cwd, 'nested', 'CLAUDE.md'), 'nested claude') + memory.clearMemoryFileCaches() + expect(await nestedContents()).toEqual(['nested claude']) +}) + +test('AGENTS imports retain excludes and project source gating', async () => { + put(join(cwd, 'AGENTS.md'), '@./detail.md\nproject') + put(join(cwd, 'detail.md'), 'included') + expect(await contents()).toEqual(['@./detail.md\nproject', 'included']) + excludes = ['**/AGENTS.md'] + memory.clearMemoryFileCaches() + expect(await contents()).toEqual([]) + excludes = [] + disabledSources.add('projectSettings') + memory.clearMemoryFileCaches() + expect(await contents()).toEqual([]) +}) + +test('explicit additional directories discover AGENTS when project sources are disabled', async () => { + additional = [join(cwd, 'extra')] + put(join(additional[0]!, 'AGENTS.md'), 'additional agents') + process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD = '1' + disabledSources.add('projectSettings') + expect((await memory.getMemoryFiles(true)).map(file => file.content)).toEqual(['additional agents']) +}) + +test('recognizes AGENTS files for memory change tracking without broadening unrelated paths', () => { + expect(memory.isMemoryFilePath(join(cwd, 'AGENTS.md'))).toBe(true) + expect(memory.isMemoryFilePath(join(cwd, '.claude', 'AGENTS.md'))).toBe(true) + expect(memory.isMemoryFilePath(join(cwd, 'agents.md'))).toBe(false) + expect(memory.isMemoryFilePath(join(cwd, 'AGENTS.local.md'))).toBe(false) +}) + + +test('user CLAUDE memory does not disable project AGENTS fallback', async () => { + put(join(root, 'policy', 'User', 'CLAUDE.md'), 'user') + put(join(cwd, 'AGENTS.md'), 'project agents') + expect(await contents()).toEqual(['user', 'project agents']) +}) + +test('excluded CLAUDE instructions do not suppress fallback', async () => { + put(join(cwd, 'CLAUDE.md'), 'excluded claude') + put(join(cwd, 'AGENTS.md'), 'agents') + excludes = ['**/CLAUDE.md'] + expect(await contents()).toEqual(['agents']) +}) + +test('AGENTS external includes require the existing approval gate', async () => { + const external = join(root, 'policy', 'external.md') + put(external, 'external') + const text = `@${external}\nproject` + put(join(cwd, 'AGENTS.md'), text) + expect(await contents()).toEqual([text]) + expect((await memory.getMemoryFiles(true)).map(file => file.content)).toEqual([text, 'external']) +}) + +test('disabled project source excludes nested AGENTS and project rules', async () => { + const dir = join(cwd, 'nested') + put(join(dir, 'AGENTS.md'), 'nested agents') + put(join(dir, '.claude', 'rules', 'test.md'), 'nested rule') + disabledSources.add('projectSettings') + expect(await nestedContents()).toEqual([]) +}) + +test('user context follows instruction settings changes without clearing prompt caches', async () => { + const { getUserContext } = await import('../context.js') + getUserContext.cache.clear?.() + put(join(cwd, 'CLAUDE.md'), 'Claude context fixture') + put(join(cwd, 'AGENTS.md'), 'Agents context fixture') + try { + mode = 'claude-md' + expect((await getUserContext()).claudeMd).toContain('Claude context fixture') + expect((await getUserContext()).claudeMd).not.toContain('Agents context fixture') + mode = 'claude-md-and-agents-md' + expect((await getUserContext()).claudeMd).toContain('Agents context fixture') + mode = 'managed-only' + expect((await getUserContext()).claudeMd).toBeUndefined() + expect(state.getCachedClaudeMdContent()).toBeNull() + mode = 'claude-md' + expect((await getUserContext()).claudeMd).toContain('Claude context fixture') + expect(state.getCachedClaudeMdContent()).toContain('Claude context fixture') + expect(state.getCachedClaudeMdContent()).not.toContain('Agents context fixture') + } finally { + getUserContext.cache.clear?.() + } +}) + +test('additional directories make independent fallback decisions', async () => { + additional = [join(cwd, 'extra')] + process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD = '1' + put(join(cwd, 'AGENTS.md'), 'project agents') + put(join(additional[0]!, 'CLAUDE.md'), 'additional claude') + put(join(additional[0]!, 'AGENTS.md'), 'additional agents') + expect(await contents()).toEqual(['project agents', 'additional claude']) + mode = 'claude-md-and-agents-md' + expect(await contents()).toEqual(['project agents', 'additional claude', 'additional agents']) +}) + +test('both mode inserts AGENTS after same-directory instructions and before descendants', async () => { + mode = 'claude-md-and-agents-md' + put(join(dirname(cwd), 'CLAUDE.md'), 'parent claude') + put(join(dirname(cwd), 'CLAUDE.local.md'), 'parent local') + put(join(dirname(cwd), '.claude/rules/rule.md'), 'parent rule') + put(join(dirname(cwd), 'AGENTS.md'), 'parent agents') + put(join(cwd, 'CLAUDE.md'), 'child claude') + expect(await contents()).toEqual(['parent claude', 'parent rule', 'parent local', 'parent agents', 'child claude']) +}) + +test('nested AGENTS does not repeat instructions already in initial context', async () => { + put(join(cwd, 'AGENTS.md'), 'shared instructions') + put(join(cwd, 'nested', 'AGENTS.md'), 'shared instructions') + expect(await contents()).toEqual(['shared instructions']) + expect(await nestedContents()).toEqual([]) +}) + +test('nested worktrees skip checked-in parent instructions when deciding fallback', async () => { + const git = await import('./git.js') + const mainRoot = join(root, 'project') + cwd = join(mainRoot, '.claude', 'worktrees', 'fixture') + put(join(mainRoot, 'CLAUDE.md'), 'main checkout only') + put(join(cwd, 'AGENTS.md'), 'worktree instructions') + spies.push( + spyOn(git, 'findGitRoot').mockReturnValue(cwd), + spyOn(git, 'findCanonicalGitRoot').mockReturnValue(mainRoot), + ) + expect(await contents()).toEqual(['worktree instructions']) +}) diff --git a/src/utils/claudemd.ts b/src/utils/claudemd.ts index 5ea8ab6d..dc027ce7 100644 --- a/src/utils/claudemd.ts +++ b/src/utils/claudemd.ts @@ -3,7 +3,7 @@ * * 1. Managed memory (eg. /etc/claude-code/CLAUDE.md) - Global instructions for all users * 2. User memory (~/.claude/CLAUDE.md) - Private global instructions for all projects - * 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, and .claude/rules/*.md in project roots) - Instructions checked into the codebase + * 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, AGENTS.md fallback, and .claude/rules/*.md in project roots) - Instructions checked into the codebase * 4. Local memory (CLAUDE.local.md in project roots) - Private project-specific instructions * * Files are loaded in reverse order of priority, i.e. the latest files are highest priority @@ -77,6 +77,7 @@ import { expandPath } from './path.js' import { pathInWorkingPath } from './permissions/filesystem.js' import { isSettingSourceEnabled } from './settings/constants.js' import { getInitialSettings } from './settings/settings.js' +import { getInstructionFilesMode, type InstructionFilesMode } from './instructionFiles.js' /* eslint-disable @typescript-eslint/no-require-imports */ const teamMemPaths = feature('TEAMMEM') @@ -787,12 +788,100 @@ export async function processMdRules({ } } +// Keep the walk shared by initial loading and AGENTS fallback detection. A nested +// worktree must not inherit checked-in instructions from its main checkout. +function getProjectInstructionDirectories(root: string) { + const dirs: { dir: string; skipProject: boolean }[] = [] + const gitRoot = findGitRoot(root) + const canonicalRoot = findCanonicalGitRoot(root) + const nested = gitRoot !== null && canonicalRoot !== null && + normalizePathForComparison(gitRoot) !== normalizePathForComparison(canonicalRoot) && + pathInWorkingPath(gitRoot, canonicalRoot) + for (let dir = root; dir !== parse(dir).root; dir = dirname(dir)) { + dirs.push({ + dir, + skipProject: Boolean(nested && pathInWorkingPath(dir, canonicalRoot!) && + !pathInWorkingPath(dir, gitRoot!)), + }) + } + return dirs.reverse() +} + +async function hasClaudeInstructions(dir: string, skipProject = false, explicitProject = false): Promise { + const candidates: [string, MemoryType][] = [] + if (!skipProject && (explicitProject || isSettingSourceEnabled('projectSettings'))) { + candidates.push([join(dir, 'CLAUDE.md'), 'Project'], [join(dir, '.claude', 'CLAUDE.md'), 'Project']) + } + if (!explicitProject && isSettingSourceEnabled('localSettings')) { + candidates.push([join(dir, 'CLAUDE.local.md'), 'Local']) + } + for (const [file, type] of candidates) { + if ((await processMemoryFile(file, type, new Set(), false)).length > 0) return true + } + return false +} + +// This is a project-wide decision, not a per-file fallback. User and managed +// CLAUDE.md files, rules, and --add-dir files do not disable AGENTS.md. +const canUseAgentsFallback = memoize(async (root: string): Promise => { + for (const { dir, skipProject } of getProjectInstructionDirectories(root)) { + if (await hasClaudeInstructions(dir, skipProject)) return false + } + return true +}) + +async function processAgentsFiles( + dir: string, + processedPaths: Set, + includeExternal: boolean, +): Promise { + const files: MemoryFileInfo[] = [] + for (const name of ['AGENTS.md', join('.claude', 'AGENTS.md')]) { + files.push(...await processMemoryFile(join(dir, name), 'Project', processedPaths, includeExternal)) + } + return files +} + +// CLAUDE.md may already import (or symlink to) AGENTS.md. Path deduplication +// happens in processMemoryFile; content deduplication also handles copied files. +function deduplicateAgentsFiles( + files: MemoryFileInfo[], + existing: MemoryFileInfo[] = [], +): MemoryFileInfo[] { + const byPath = new Map([...existing, ...files].map(file => [file.path, file])) + const fromAgents = (file: MemoryFileInfo) => { + const seen = new Set() + while (file.parent && byPath.has(file.parent) && !seen.has(file.path)) { + seen.add(file.path) + file = byPath.get(file.parent)! + } + return file.type === 'Project' && basename(file.path) === 'AGENTS.md' + } + const contents = new Set([ + ...existing.filter(file => file.type === 'Project' || file.type === 'Local'), + ...files.filter(file => !fromAgents(file) && (file.type === 'Project' || file.type === 'Local')), + ].map(file => file.content.trim())) + return files.filter(file => { + if (!fromAgents(file)) return true + const content = file.content.trim() + if (content && contents.has(content)) return false + if (content) contents.add(content) + return true + }) +} + +let cachedInstructionMode: InstructionFilesMode | undefined + export const getMemoryFiles = memoize( async (forceIncludeExternal: boolean = false): Promise => { const startTime = Date.now() logForDiagnosticsNoPII('info', 'memory_files_started') - const result: MemoryFileInfo[] = [] + let result: MemoryFileInfo[] = [] + const mode = getInstructionFilesMode() + const managedOnly = mode === 'managed-only' + const loadAgents = mode === 'claude-md-and-agents-md' || + (mode === 'claude-md-or-agents-md' && await canUseAgentsFallback(getOriginalCwd())) const processedPaths = new Set() const config = getCurrentProjectConfig() const includeExternal = @@ -823,7 +912,7 @@ export const getMemoryFiles = memoize( ) // Process User file (only if userSettings is enabled) - if (isSettingSourceEnabled('userSettings')) { + if (getInstructionFilesMode() !== 'managed-only' && isSettingSourceEnabled('userSettings')) { const userClaudeMd = getMemoryPath('User') result.push( ...(await processMemoryFile( @@ -846,45 +935,10 @@ export const getMemoryFiles = memoize( ) } - // Then process Project and Local files - const dirs: string[] = [] - const originalCwd = getOriginalCwd() - let currentDir = originalCwd - - while (currentDir !== parse(currentDir).root) { - dirs.push(currentDir) - currentDir = dirname(currentDir) - } - - // When running from a git worktree nested inside its main repo (e.g., - // .claude/worktrees// from `claude -w`), the upward walk passes - // through both the worktree root and the main repo root. Both contain - // checked-in files like CLAUDE.md and .claude/rules/*.md, so the same - // content gets loaded twice. Skip Project-type (checked-in) files from - // directories above the worktree but within the main repo — the worktree - // already has its own checkout. CLAUDE.local.md is gitignored so it only - // exists in the main repo and is still loaded. - // See: https://github.com/anthropics/claude-code/issues/29599 - const gitRoot = findGitRoot(originalCwd) - const canonicalRoot = findCanonicalGitRoot(originalCwd) - const isNestedWorktree = - gitRoot !== null && - canonicalRoot !== null && - normalizePathForComparison(gitRoot) !== - normalizePathForComparison(canonicalRoot) && - pathInWorkingPath(gitRoot, canonicalRoot) - - // Process from root downward to CWD - for (const dir of dirs.reverse()) { - // In a nested worktree, skip checked-in files from the main repo's - // working tree (dirs inside canonicalRoot but outside the worktree). - const skipProject = - isNestedWorktree && - pathInWorkingPath(dir, canonicalRoot) && - !pathInWorkingPath(dir, gitRoot) - + // Process ancestors before descendants, preserving local worktree rules. + for (const { dir, skipProject } of getProjectInstructionDirectories(getOriginalCwd())) { // Try reading CLAUDE.md (Project) - only if projectSettings is enabled - if (isSettingSourceEnabled('projectSettings') && !skipProject) { + if (!managedOnly && isSettingSourceEnabled('projectSettings') && !skipProject) { const projectPath = join(dir, 'CLAUDE.md') result.push( ...(await processMemoryFile( @@ -920,7 +974,7 @@ export const getMemoryFiles = memoize( } // Try reading CLAUDE.local.md (Local) - only if localSettings is enabled - if (isSettingSourceEnabled('localSettings')) { + if (getInstructionFilesMode() !== 'managed-only' && isSettingSourceEnabled('localSettings')) { const localPath = join(dir, 'CLAUDE.local.md') result.push( ...(await processMemoryFile( @@ -931,13 +985,16 @@ export const getMemoryFiles = memoize( )), ) } + if (!managedOnly && !skipProject && isSettingSourceEnabled('projectSettings') && loadAgents) { + result.push(...await processAgentsFiles(dir, processedPaths, includeExternal)) + } } // Process CLAUDE.md from additional directories (--add-dir) if env var is enabled // This is controlled by CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD and defaults to off // Note: we don't check isSettingSourceEnabled('projectSettings') here because --add-dir // is an explicit user action and the SDK defaults settingSources to [] when not specified - if (isEnvTruthy(process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD)) { + if (!managedOnly && isEnvTruthy(process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD)) { const additionalDirs = getAdditionalDirectoriesForClaudeMd() for (const dir of additionalDirs) { // Try reading CLAUDE.md from the additional directory @@ -973,6 +1030,11 @@ export const getMemoryFiles = memoize( conditionalRule: false, })), ) + // Explicit add-dir discovery uses that directory's own fallback decision. + if (mode === 'claude-md-and-agents-md' || + (mode === 'claude-md-or-agents-md' && !await hasClaudeInstructions(dir, false, true))) { + result.push(...await processAgentsFiles(dir, processedPaths, includeExternal)) + } } } @@ -1006,6 +1068,8 @@ export const getMemoryFiles = memoize( } } + result = deduplicateAgentsFiles(result) + const totalContentLength = result.reduce( (sum, f) => sum + f.content.length, 0, @@ -1072,6 +1136,14 @@ export const getMemoryFiles = memoize( return result }, + (forceIncludeExternal = false) => { + const mode = getInstructionFilesMode() + if (cachedInstructionMode !== mode) { + cachedInstructionMode = mode + clearMemoryFileCaches() + } + return `${mode}:${forceIncludeExternal}` + }, ) function isInstructionsMemoryType( @@ -1119,6 +1191,7 @@ function consumeNextEagerLoadReason(): InstructionsLoadReason | undefined { export function clearMemoryFileCaches(): void { // ?.cache because tests spyOn this, which replaces the memoize wrapper. getMemoryFiles.cache?.clear?.() + canUseAgentsFallback.cache.clear() } export function resetGetMemoryFilesCache( @@ -1220,7 +1293,7 @@ export async function getManagedAndUserConditionalRules( )), ) - if (isSettingSourceEnabled('userSettings')) { + if (getInstructionFilesMode() !== 'managed-only' && isSettingSourceEnabled('userSettings')) { // Process User conditional .claude/rules/*.md files const userClaudeRulesDir = getUserClaudeRulesDir() result.push( @@ -1251,6 +1324,7 @@ export async function getMemoryFilesForNestedDirectory( targetPath: string, processedPaths: Set, ): Promise { + if (getInstructionFilesMode() === 'managed-only') return [] const result: MemoryFileInfo[] = [] // Process project memory files (CLAUDE.md and .claude/CLAUDE.md) @@ -1276,13 +1350,15 @@ export async function getMemoryFilesForNestedDirectory( } // Process local memory file (CLAUDE.local.md) - if (isSettingSourceEnabled('localSettings')) { + if (getInstructionFilesMode() !== 'managed-only' && isSettingSourceEnabled('localSettings')) { const localPath = join(dir, 'CLAUDE.local.md') result.push( ...(await processMemoryFile(localPath, 'Local', processedPaths, false)), ) } + if (!isSettingSourceEnabled('projectSettings')) return result + const rulesDir = join(dir, '.claude', 'rules') // Process project unconditional .claude/rules/*.md files, which were not eagerly loaded @@ -1314,7 +1390,15 @@ export async function getMemoryFilesForNestedDirectory( processedPaths.add(path) } - return result + const mode = getInstructionFilesMode() + if (isSettingSourceEnabled('projectSettings') && + (mode === 'claude-md-and-agents-md' || + (mode === 'claude-md-or-agents-md' && + await canUseAgentsFallback(getOriginalCwd()) && !await hasClaudeInstructions(dir)))) { + result.push(...await processAgentsFiles(dir, processedPaths, false)) + } + + return deduplicateAgentsFiles(result, await getMemoryFiles()) } /** @@ -1331,6 +1415,7 @@ export async function getConditionalRulesForCwdLevelDirectory( targetPath: string, processedPaths: Set, ): Promise { + if (getInstructionFilesMode() === 'managed-only' || !isSettingSourceEnabled('projectSettings')) return [] const rulesDir = join(dir, '.claude', 'rules') return processConditionedMdRules( targetPath, @@ -1436,7 +1521,7 @@ export function isMemoryFilePath(filePath: string): boolean { const name = basename(filePath) // CLAUDE.md or CLAUDE.local.md anywhere - if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md') { + if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md' || name === 'AGENTS.md') { return true } diff --git a/src/utils/instructionFiles.ts b/src/utils/instructionFiles.ts new file mode 100644 index 00000000..9f943650 --- /dev/null +++ b/src/utils/instructionFiles.ts @@ -0,0 +1,58 @@ +import { getSettingsForSource } from './settings/settings.js' +import type { SettingsJson } from './settings/types.js' + +export const INSTRUCTION_FILE_MODES = [ + 'claude-md', + 'claude-md-or-agents-md', + 'claude-md-and-agents-md', + 'managed-only', +] as const + +export type InstructionFilesMode = (typeof INSTRUCTION_FILE_MODES)[number] +export const INSTRUCTION_FILES_PLUGIN = 'agents-md@builtin' + +export function normalizeInstructionFilesMode(value: unknown): InstructionFilesMode { + if (INSTRUCTION_FILE_MODES.includes(value as InstructionFilesMode)) { + return value as InstructionFilesMode + } + return 'claude-md-or-agents-md' +} + +/** Sources are supplied in descending precedence. Each option inherits separately. */ +export function getInstructionFilesModeFromSettings( + ...sources: (SettingsJson | null | undefined)[] +): InstructionFilesMode { + const options = Object.fromEntries(sources.toReversed().flatMap(settings => + Object.entries(settings?.pluginConfigs?.[INSTRUCTION_FILES_PLUGIN]?.options ?? {}) + .filter(([, value]) => value !== undefined), + )) + const mode = normalizeInstructionFilesMode(options.instructionFiles) + if (mode !== 'claude-md-or-agents-md' || options.projectInstructions === undefined) { + return mode + } + // Read-time migration matches the builtin plugin without rewriting user files. + switch (options.projectInstructions) { + case 'none': return 'managed-only' + case 'both': return 'claude-md-and-agents-md' + case 'agents-fallback': return 'claude-md-or-agents-md' + default: return 'claude-md' + } +} + +export function getInstructionFilesMode(): InstructionFilesMode { + // Repository settings must not be able to switch off instructions themselves. + return getInstructionFilesModeFromSettings( + getSettingsForSource('policySettings'), + getSettingsForSource('flagSettings'), + getSettingsForSource('userSettings'), + ) +} + +/** Patch only this option; undefined deletes the key via settings' merge protocol. */ +export function instructionFilesSettingsPatch(value: string | undefined, legacy?: string): SettingsJson { + return { + pluginConfigs: { + [INSTRUCTION_FILES_PLUGIN]: { options: { instructionFiles: value as string, projectInstructions: legacy as string } }, + }, + } +} diff --git a/src/utils/settings/__tests__/instructionFiles.test.ts b/src/utils/settings/__tests__/instructionFiles.test.ts new file mode 100644 index 00000000..21398ec5 --- /dev/null +++ b/src/utils/settings/__tests__/instructionFiles.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + getInstructionFilesModeFromSettings, + INSTRUCTION_FILE_MODES, + instructionFilesSettingsPatch, +} from '../../instructionFiles.js' +import { parseSettingsFile, updateSettingsForSource } from '../settings.js' + +const directories: string[] = [] +afterEach(async () => { + await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +async function readFixture(value: unknown) { + const dir = await mkdtemp(join(tmpdir(), 'instruction-files-')) + directories.push(dir) + await mkdir(join(dir, '.claude')) + const path = join(dir, '.claude', 'settings.json') + await writeFile(path, JSON.stringify(value)) + return { settings: parseSettingsFile(path).settings, path, projectRoot: dir } +} + +describe('project instruction settings compatibility', () => { + it('migrates an old settings file at read time without modifying user fields', async () => { + const fixture = { model: 'sonnet', customFutureField: { retained: true } } + const { settings, path } = await readFixture(fixture) + expect(getInstructionFilesModeFromSettings(settings)).toBe('claude-md-or-agents-md') + expect(settings).toMatchObject(fixture) + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual(fixture) + }) + + for (const mode of INSTRUCTION_FILE_MODES) { + it(`reads official builtin plugin option ${mode}`, async () => { + const { settings } = await readFixture(instructionFilesSettingsPatch(mode)) + expect(getInstructionFilesModeFromSettings(settings)).toBe(mode) + }) + } + + for (const [legacy, expected] of [ + ['none', 'managed-only'], ['claude', 'claude-md'], + ['agents-fallback', 'claude-md-or-agents-md'], ['both', 'claude-md-and-agents-md'], + ]) { + it(`migrates legacy projectInstructions ${legacy}`, async () => { + const { settings } = await readFixture({ pluginConfigs: { 'agents-md@builtin': { options: { projectInstructions: legacy } } }, customFutureField: true }) + expect(getInstructionFilesModeFromSettings(settings)).toBe(expected) + expect(settings?.customFutureField).toBe(true) + }) + } + + it('writes and cancels one option while retaining sibling plugin settings', async () => { + const fixture = { + customFutureField: 'retained', + pluginConfigs: { + 'agents-md@builtin': { options: { sibling: true } }, + 'other@plugin': { options: { option: 'preserved' } }, + }, + } + const { path, projectRoot } = await readFixture(fixture) + expect(updateSettingsForSource('projectSettings', instructionFilesSettingsPatch('claude-md'), projectRoot).error).toBeNull() + expect(parseSettingsFile(path).settings?.pluginConfigs?.['agents-md@builtin']?.options).toEqual({ sibling: true, instructionFiles: 'claude-md' }) + expect(updateSettingsForSource('projectSettings', instructionFilesSettingsPatch(undefined), projectRoot).error).toBeNull() + expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject(fixture) + expect(parseSettingsFile(path).settings?.pluginConfigs?.['agents-md@builtin']?.options?.instructionFiles).toBeUndefined() + }) + + it('clears legacy options on selection and restores them on cancellation', async () => { + const fixture = { pluginConfigs: { 'agents-md@builtin': { options: { projectInstructions: 'both', sibling: true } } } } + const { path, projectRoot } = await readFixture(fixture) + expect(updateSettingsForSource('projectSettings', instructionFilesSettingsPatch('claude-md-or-agents-md'), projectRoot).error).toBeNull() + expect(getInstructionFilesModeFromSettings(parseSettingsFile(path).settings)).toBe('claude-md-or-agents-md') + expect(updateSettingsForSource('projectSettings', instructionFilesSettingsPatch(undefined, 'both'), projectRoot).error).toBeNull() + expect(getInstructionFilesModeFromSettings(parseSettingsFile(path).settings)).toBe('claude-md-and-agents-md') + expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject(fixture) + }) + + it('migrates legacy options after independently merging source precedence', () => { + expect(getInstructionFilesModeFromSettings( + instructionFilesSettingsPatch('claude-md-or-agents-md'), + { pluginConfigs: { 'agents-md@builtin': { options: { projectInstructions: 'both' } } } }, + )).toBe('claude-md-and-agents-md') + expect(getInstructionFilesModeFromSettings(instructionFilesSettingsPatch('unknown', 'unknown'))).toBe('claude-md') + expect(getInstructionFilesModeFromSettings(instructionFilesSettingsPatch('both'))).toBe('claude-md-or-agents-md') + expect(getInstructionFilesModeFromSettings({ projectInstructions: 'none' })).toBe('claude-md-or-agents-md') + }) + + it('inherits absent options and honors descending source precedence', () => { + expect(getInstructionFilesModeFromSettings({}, instructionFilesSettingsPatch('claude-md'))).toBe('claude-md') + expect(getInstructionFilesModeFromSettings( + instructionFilesSettingsPatch('managed-only'), instructionFilesSettingsPatch('claude-md'), + )).toBe('managed-only') + expect(getInstructionFilesModeFromSettings({ + ...instructionFilesSettingsPatch('claude-md', 'both'), + })).toBe('claude-md') + }) +})