mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
feat: support AGENTS.md project instructions
This commit is contained in:
@@ -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<string, () => 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(<React.Suspense fallback={null}><Config context={{ options: { mcpClients: [] } } as any} onClose={value => { closed = value }} setTabsHidden={() => {}} onIsSearchModeChange={value => { ownsEsc = value }} /></React.Suspense>, {
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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<string>
|
||||
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<typeof config.getCurrentProjectConfig>),
|
||||
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'])
|
||||
})
|
||||
+132
-47
@@ -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<boolean> {
|
||||
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<boolean> => {
|
||||
for (const { dir, skipProject } of getProjectInstructionDirectories(root)) {
|
||||
if (await hasClaudeInstructions(dir, skipProject)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
async function processAgentsFiles(
|
||||
dir: string,
|
||||
processedPaths: Set<string>,
|
||||
includeExternal: boolean,
|
||||
): Promise<MemoryFileInfo[]> {
|
||||
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<string>()
|
||||
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<MemoryFileInfo[]> => {
|
||||
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<string>()
|
||||
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/<name>/ 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<string>,
|
||||
): Promise<MemoryFileInfo[]> {
|
||||
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<string>,
|
||||
): Promise<MemoryFileInfo[]> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user