Prefer AGENTS.md over CLAUDE.md for project instructions (#439)
* Prefer AGENTS.md over CLAUDE.md for project instructions * fix: preserve CLAUDE.md fallback behavior * fix: isolate onboarding tests and preserve legacy init * fix: restore full fsOperations exports in test mock and align compact cwd * Fix onboarding test isolation and init migration guidance * Tighten init prompt coverage and onboarding copy * Handle nested project instruction paths consistently * Fix NEW_INIT feature gate for Bun build --------- Co-authored-by: 赵小落 <zhaoxiaoluo@zhaoxiaoluodeMac-mini.local> Co-authored-by: zhaomo01 <zhaomo01@baidu.com>
This commit is contained in:
@@ -45,7 +45,7 @@ function getPromptContent(
|
||||
<!-- CHANGELOG:END -->`
|
||||
let slackStep = `
|
||||
|
||||
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
|
||||
5. After creating/updating the PR, check if the user's AGENTS.md or CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
|
||||
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
|
||||
prefix = getUndercoverInstructions() + '\n'
|
||||
reviewerArg = ''
|
||||
|
||||
43
src/commands/init.test.ts
Normal file
43
src/commands/init.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
|
||||
const originalClaudeCodeNewInit = process.env.CLAUDE_CODE_NEW_INIT
|
||||
|
||||
async function importInitCommand() {
|
||||
return (await import(`./init.ts?ts=${Date.now()}-${Math.random()}`)).default
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
|
||||
if (originalClaudeCodeNewInit === undefined) {
|
||||
delete process.env.CLAUDE_CODE_NEW_INIT
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_NEW_INIT = originalClaudeCodeNewInit
|
||||
}
|
||||
})
|
||||
|
||||
test('NEW_INIT prompt preserves existing root CLAUDE.md by default', async () => {
|
||||
process.env.CLAUDE_CODE_NEW_INIT = '1'
|
||||
|
||||
mock.module('../projectOnboardingState.js', () => ({
|
||||
maybeMarkProjectOnboardingComplete: () => {},
|
||||
}))
|
||||
mock.module('./initMode.js', () => ({
|
||||
isNewInitEnabled: () => true,
|
||||
}))
|
||||
|
||||
const command = await importInitCommand()
|
||||
const blocks = await command.getPromptForCommand()
|
||||
|
||||
expect(blocks).toHaveLength(1)
|
||||
expect(blocks[0]?.type).toBe('text')
|
||||
expect(String(blocks[0]?.text)).toContain(
|
||||
'checked-in root `CLAUDE.md` and does NOT already have a root `AGENTS.md`',
|
||||
)
|
||||
expect(String(blocks[0]?.text)).toContain(
|
||||
'do NOT silently create a second root instruction file',
|
||||
)
|
||||
expect(String(blocks[0]?.text)).toContain(
|
||||
'update the existing root `CLAUDE.md` in place by default',
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Command } from '../commands.js'
|
||||
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
import { isNewInitEnabled } from './initMode.js'
|
||||
|
||||
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
|
||||
|
||||
@@ -25,19 +24,19 @@ Usage notes:
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
\`\`\``
|
||||
|
||||
const NEW_INIT_PROMPT = `Set up a minimal CLAUDE.md (and optionally skills and hooks) for this repo. CLAUDE.md is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
|
||||
const NEW_INIT_PROMPT = `Set up a minimal AGENTS.md (and optionally CLAUDE.local.md, skills, and hooks) for this repo. The root project instruction file is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
|
||||
|
||||
## Phase 1: Ask what to set up
|
||||
|
||||
Use AskUserQuestion to find out what the user wants:
|
||||
|
||||
- "Which CLAUDE.md files should /init set up?"
|
||||
Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal"
|
||||
- "Which instruction files should /init set up?"
|
||||
Options: "Project AGENTS.md" | "Personal CLAUDE.local.md" | "Both project + personal"
|
||||
Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows."
|
||||
Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks."
|
||||
|
||||
- "Also set up skills and hooks?"
|
||||
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just CLAUDE.md"
|
||||
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just the instruction file(s)"
|
||||
Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge."
|
||||
Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them."
|
||||
|
||||
@@ -59,24 +58,24 @@ Note what you could NOT figure out from code alone — these become interview qu
|
||||
|
||||
## Phase 3: Fill in the gaps
|
||||
|
||||
Use AskUserQuestion to gather what you still need to write good CLAUDE.md files and skills. Ask only things the code can't answer.
|
||||
Use AskUserQuestion to gather what you still need to write good instruction files and skills. Ask only things the code can't answer.
|
||||
|
||||
If the user chose project CLAUDE.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
|
||||
If the user chose project AGENTS.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
|
||||
|
||||
If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions:
|
||||
- What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding")
|
||||
- How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth)
|
||||
- Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know?
|
||||
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file.
|
||||
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project AGENTS.md — that would check a personal reference into the team-shared file.
|
||||
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
|
||||
|
||||
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
|
||||
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, an AGENTS.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
|
||||
|
||||
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
|
||||
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
|
||||
- **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
|
||||
- **AGENTS.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
|
||||
|
||||
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or a CLAUDE.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes a CLAUDE.md note. Never propose an artifact type the user didn't opt into.
|
||||
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or an AGENTS.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes an AGENTS.md note. Never propose an artifact type the user didn't opt into.
|
||||
|
||||
**Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as:
|
||||
|
||||
@@ -86,17 +85,19 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
|
||||
|
||||
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
|
||||
• **Verification workflow** (on-demand) — \`make lint && make typecheck && make test\`
|
||||
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
|
||||
• **AGENTS.md note** (guideline) — "run lint/typecheck/test before marking done"
|
||||
|
||||
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
|
||||
|
||||
**Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue.
|
||||
|
||||
## Phase 4: Write CLAUDE.md (if user chose project or both)
|
||||
## Phase 4: Write AGENTS.md (if user chose project or both)
|
||||
|
||||
Write a minimal CLAUDE.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
|
||||
Write a minimal AGENTS.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
|
||||
|
||||
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
|
||||
If the repo already has a checked-in root \`CLAUDE.md\` and does NOT already have a root \`AGENTS.md\`, do NOT silently create a second root instruction file. In that case, update the existing root \`CLAUDE.md\` in place by default. Only create or migrate to root \`AGENTS.md\` if the user explicitly asks to migrate.
|
||||
|
||||
**Consume \`note\` entries from the Phase 3 preference queue whose target is AGENTS.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
|
||||
|
||||
Include:
|
||||
- Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences)
|
||||
@@ -111,7 +112,7 @@ Exclude:
|
||||
- File-by-file structure or component lists (Claude can discover these by reading the codebase)
|
||||
- Standard language conventions Claude already knows
|
||||
- Generic advice ("write clean code", "handle errors")
|
||||
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating CLAUDE.md
|
||||
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating AGENTS.md
|
||||
- Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version
|
||||
- Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill)
|
||||
- Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest")
|
||||
@@ -123,20 +124,20 @@ Do not repeat yourself and do not make up sections like "Common Development Task
|
||||
Prefix the file with:
|
||||
|
||||
\`\`\`
|
||||
# CLAUDE.md
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
\`\`\`
|
||||
|
||||
If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
|
||||
If AGENTS.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
|
||||
|
||||
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside CLAUDE.md and can be scoped to specific file paths using \`paths\` frontmatter.
|
||||
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside AGENTS.md and can be scoped to specific file paths using \`paths\` frontmatter.
|
||||
|
||||
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory CLAUDE.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
|
||||
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory AGENTS.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
|
||||
|
||||
## Phase 5: Write CLAUDE.local.md (if user chose personal or both)
|
||||
|
||||
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside CLAUDE.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
|
||||
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside AGENTS.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
|
||||
|
||||
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries.
|
||||
|
||||
@@ -147,7 +148,7 @@ Include:
|
||||
|
||||
Keep it short — only include what would make Claude's responses noticeably better for this user.
|
||||
|
||||
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project CLAUDE.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
|
||||
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project AGENTS.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
|
||||
|
||||
If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite.
|
||||
|
||||
@@ -183,7 +184,7 @@ Both the user (\`/<skill-name>\`) and Claude can invoke skills by default. For w
|
||||
|
||||
## Phase 7: Suggest additional optimizations
|
||||
|
||||
Tell the user you're going to suggest a few additional optimizations now that CLAUDE.md and skills (if chosen) are in place.
|
||||
Tell the user you're going to suggest a few additional optimizations now that AGENTS.md and skills (if chosen) are in place.
|
||||
|
||||
Check the environment and ask about each gap you find (use AskUserQuestion):
|
||||
|
||||
@@ -195,7 +196,7 @@ Check the environment and ask about each gap you find (use AskUserQuestion):
|
||||
|
||||
For each hook preference (from the queue or the formatter fallback):
|
||||
|
||||
1. Target file: default based on the Phase 1 CLAUDE.md choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
|
||||
1. Target file: default based on the Phase 1 instruction-file choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
|
||||
|
||||
2. Pick the event and matcher from the preference:
|
||||
- "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\`
|
||||
@@ -227,11 +228,9 @@ const command = {
|
||||
type: 'prompt',
|
||||
name: 'init',
|
||||
get description() {
|
||||
return feature('NEW_INIT') &&
|
||||
(process.env.USER_TYPE === 'ant' ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
|
||||
? 'Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation'
|
||||
: 'Initialize a new CLAUDE.md file with codebase documentation'
|
||||
return isNewInitEnabled()
|
||||
? 'Initialize new project instruction file(s) and optional skills/hooks with codebase documentation'
|
||||
: 'Initialize a new project instruction file with codebase documentation'
|
||||
},
|
||||
contentLength: 0, // Dynamic content
|
||||
progressMessage: 'analyzing your codebase',
|
||||
@@ -242,12 +241,7 @@ const command = {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
feature('NEW_INIT') &&
|
||||
(process.env.USER_TYPE === 'ant' ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
|
||||
? NEW_INIT_PROMPT
|
||||
: OLD_INIT_PROMPT,
|
||||
text: isNewInitEnabled() ? NEW_INIT_PROMPT : OLD_INIT_PROMPT,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
13
src/commands/initMode.ts
Normal file
13
src/commands/initMode.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
|
||||
export function isNewInitEnabled(): boolean {
|
||||
if (feature('NEW_INIT')) {
|
||||
return (
|
||||
process.env.USER_TYPE === 'ant' ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { c as _c } from "react-compiler-runtime";
|
||||
import { feature } from 'bun:bundle';
|
||||
import chalk from 'chalk';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { basename, join } from 'path';
|
||||
import * as React from 'react';
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { getOriginalCwd } from '../../bootstrap/state.js';
|
||||
@@ -24,6 +24,7 @@ import { projectIsInGitRepo } from '../../utils/memory/versions.js';
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js';
|
||||
import { Select } from '../CustomSelect/index.js';
|
||||
import { ListItem } from '../design-system/ListItem.js';
|
||||
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const teamMemPaths = feature('TEAMMEM') ? require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js') : null;
|
||||
@@ -48,8 +49,10 @@ export function MemoryFileSelector(t0) {
|
||||
onCancel
|
||||
} = t0;
|
||||
const existingMemoryFiles = use(getMemoryFiles());
|
||||
const originalCwd = getOriginalCwd();
|
||||
const userMemoryPath = join(getClaudeConfigHomeDir(), "CLAUDE.md");
|
||||
const projectMemoryPath = join(getOriginalCwd(), "CLAUDE.md");
|
||||
const projectMemoryPath = getProjectMemoryPathForSelector(existingMemoryFiles, originalCwd);
|
||||
const projectMemoryFileName = basename(projectMemoryPath);
|
||||
const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath);
|
||||
const hasProjectMemory = existingMemoryFiles.some(f_0 => f_0.path === projectMemoryPath);
|
||||
const allMemoryFiles = [...existingMemoryFiles.filter(_temp).map(_temp2), ...(hasUserMemory ? [] : [{
|
||||
@@ -85,12 +88,12 @@ export function MemoryFileSelector(t0) {
|
||||
}
|
||||
}
|
||||
let description;
|
||||
const isGit = projectIsInGitRepo(getOriginalCwd());
|
||||
const isGit = projectIsInGitRepo(originalCwd);
|
||||
if (file.type === "User" && !file.isNested) {
|
||||
description = "Saved in ~/.claude/CLAUDE.md";
|
||||
} else {
|
||||
if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) {
|
||||
description = `${isGit ? "Checked in at" : "Saved in"} ./CLAUDE.md`;
|
||||
description = `${isGit ? "Checked in at" : "Saved in"} ./${projectMemoryFileName}`;
|
||||
} else {
|
||||
if (file.parent) {
|
||||
description = "@-imported";
|
||||
|
||||
69
src/components/memory/memoryFileSelectorPaths.test.ts
Normal file
69
src/components/memory/memoryFileSelectorPaths.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import type { MemoryFileInfo } from '../../utils/claudemd.js'
|
||||
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js'
|
||||
|
||||
function projectFile(path: string): MemoryFileInfo {
|
||||
return {
|
||||
path,
|
||||
type: 'Project',
|
||||
content: '',
|
||||
}
|
||||
}
|
||||
|
||||
describe('getProjectMemoryPathForSelector', () => {
|
||||
test('uses the loaded repo-level AGENTS.md from a nested cwd', () => {
|
||||
const repoDir = '/repo'
|
||||
const nestedDir = join(repoDir, 'packages', 'app')
|
||||
|
||||
expect(
|
||||
getProjectMemoryPathForSelector(
|
||||
[projectFile(join(repoDir, 'AGENTS.md'))],
|
||||
nestedDir,
|
||||
),
|
||||
).toBe(join(repoDir, 'AGENTS.md'))
|
||||
})
|
||||
|
||||
test('uses the loaded repo-level CLAUDE.md fallback from a nested cwd', () => {
|
||||
const repoDir = '/repo'
|
||||
const nestedDir = join(repoDir, 'packages', 'app')
|
||||
|
||||
expect(
|
||||
getProjectMemoryPathForSelector(
|
||||
[projectFile(join(repoDir, 'CLAUDE.md'))],
|
||||
nestedDir,
|
||||
),
|
||||
).toBe(join(repoDir, 'CLAUDE.md'))
|
||||
})
|
||||
|
||||
test('prefers the closest loaded ancestor instruction file', () => {
|
||||
const repoDir = '/repo'
|
||||
const nestedProjectDir = join(repoDir, 'packages', 'app')
|
||||
|
||||
expect(
|
||||
getProjectMemoryPathForSelector(
|
||||
[
|
||||
projectFile(join(repoDir, 'AGENTS.md')),
|
||||
projectFile(join(nestedProjectDir, 'CLAUDE.md')),
|
||||
],
|
||||
join(nestedProjectDir, 'src'),
|
||||
),
|
||||
).toBe(join(nestedProjectDir, 'CLAUDE.md'))
|
||||
})
|
||||
|
||||
test('defaults to a new AGENTS.md in the current cwd when no project file is loaded', () => {
|
||||
expect(getProjectMemoryPathForSelector([], '/repo/packages/app')).toBe(
|
||||
'/repo/packages/app/AGENTS.md',
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores loaded project instruction files outside the current cwd ancestry', () => {
|
||||
expect(
|
||||
getProjectMemoryPathForSelector(
|
||||
[projectFile('/other-worktree/AGENTS.md')],
|
||||
'/repo/packages/app',
|
||||
),
|
||||
).toBe('/repo/packages/app/AGENTS.md')
|
||||
})
|
||||
})
|
||||
34
src/components/memory/memoryFileSelectorPaths.ts
Normal file
34
src/components/memory/memoryFileSelectorPaths.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { basename, join } from 'path'
|
||||
|
||||
import type { MemoryFileInfo } from '../../utils/claudemd.js'
|
||||
import {
|
||||
findProjectInstructionFilePathInAncestors,
|
||||
isProjectInstructionFileName,
|
||||
PRIMARY_PROJECT_INSTRUCTION_FILE,
|
||||
} from '../../utils/projectInstructions.js'
|
||||
|
||||
function isLoadedProjectInstructionFile(file: MemoryFileInfo): boolean {
|
||||
return (
|
||||
file.type === 'Project' &&
|
||||
file.parent === undefined &&
|
||||
isProjectInstructionFileName(basename(file.path))
|
||||
)
|
||||
}
|
||||
|
||||
export function getProjectMemoryPathForSelector(
|
||||
existingMemoryFiles: MemoryFileInfo[],
|
||||
cwd: string,
|
||||
): string {
|
||||
const loadedProjectInstructionPaths = new Set(
|
||||
existingMemoryFiles
|
||||
.filter(isLoadedProjectInstructionFile)
|
||||
.map(file => file.path),
|
||||
)
|
||||
|
||||
return (
|
||||
findProjectInstructionFilePathInAncestors(
|
||||
cwd,
|
||||
path => loadedProjectInstructionPaths.has(path),
|
||||
) ?? join(cwd, PRIMARY_PROJECT_INSTRUCTION_FILE)
|
||||
)
|
||||
}
|
||||
62
src/projectOnboardingState.test.ts
Normal file
62
src/projectOnboardingState.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
getSteps,
|
||||
isProjectOnboardingComplete,
|
||||
} from './projectOnboardingSteps.js'
|
||||
import { runWithCwdOverride } from './utils/cwd.js'
|
||||
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
describe('project onboarding completion', () => {
|
||||
test('is incomplete when neither AGENTS.md nor CLAUDE.md exists', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
|
||||
|
||||
await runWithCwdOverride(tempDir, async () => {
|
||||
expect(isProjectOnboardingComplete()).toBe(false)
|
||||
expect(getSteps()[1]?.text).toContain('/init')
|
||||
expect(getSteps()[1]?.text).toContain('AGENTS.md')
|
||||
expect(getSteps()[1]?.text).toContain('CLAUDE.md')
|
||||
})
|
||||
})
|
||||
|
||||
test('is complete when only CLAUDE.md exists', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
|
||||
await writeFile(join(tempDir, 'CLAUDE.md'), '# CLAUDE.md\n')
|
||||
|
||||
await runWithCwdOverride(tempDir, async () => {
|
||||
expect(isProjectOnboardingComplete()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('is complete when only AGENTS.md exists', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
|
||||
await writeFile(join(tempDir, 'AGENTS.md'), '# AGENTS.md\n')
|
||||
|
||||
await runWithCwdOverride(tempDir, async () => {
|
||||
expect(isProjectOnboardingComplete()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('is complete from a nested cwd when repo instructions exist in an ancestor directory', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
|
||||
const nestedDir = join(tempDir, 'packages', 'app')
|
||||
await writeFile(join(tempDir, 'AGENTS.md'), '# AGENTS.md\n')
|
||||
await mkdir(nestedDir, { recursive: true })
|
||||
await writeFile(join(nestedDir, 'index.ts'), 'export {}\n')
|
||||
|
||||
await runWithCwdOverride(nestedDir, async () => {
|
||||
expect(isProjectOnboardingComplete()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,50 +1,14 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
getCurrentProjectConfig,
|
||||
saveCurrentProjectConfig,
|
||||
} from './utils/config.js'
|
||||
import { getCwd } from './utils/cwd.js'
|
||||
import { isDirEmpty } from './utils/file.js'
|
||||
import { getFsImplementation } from './utils/fsOperations.js'
|
||||
|
||||
export type Step = {
|
||||
key: string
|
||||
text: string
|
||||
isComplete: boolean
|
||||
isCompletable: boolean
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
export function getSteps(): Step[] {
|
||||
const hasClaudeMd = getFsImplementation().existsSync(
|
||||
join(getCwd(), 'CLAUDE.md'),
|
||||
)
|
||||
const isWorkspaceDirEmpty = isDirEmpty(getCwd())
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'workspace',
|
||||
text: 'Ask Claude to create a new app or clone a repository',
|
||||
isComplete: false,
|
||||
isCompletable: true,
|
||||
isEnabled: isWorkspaceDirEmpty,
|
||||
},
|
||||
{
|
||||
key: 'claudemd',
|
||||
text: 'Run /init to create a CLAUDE.md file with instructions for Claude',
|
||||
isComplete: hasClaudeMd,
|
||||
isCompletable: true,
|
||||
isEnabled: !isWorkspaceDirEmpty,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function isProjectOnboardingComplete(): boolean {
|
||||
return getSteps()
|
||||
.filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
|
||||
.every(({ isComplete }) => isComplete)
|
||||
}
|
||||
export {
|
||||
getSteps,
|
||||
isProjectOnboardingComplete,
|
||||
type Step,
|
||||
} from './projectOnboardingSteps.js'
|
||||
import { isProjectOnboardingComplete } from './projectOnboardingSteps.js'
|
||||
|
||||
export function maybeMarkProjectOnboardingComplete(): void {
|
||||
// Short-circuit on cached config — isProjectOnboardingComplete() hits
|
||||
|
||||
44
src/projectOnboardingSteps.ts
Normal file
44
src/projectOnboardingSteps.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getCwd } from './utils/cwd.js'
|
||||
import { isDirEmpty } from './utils/file.js'
|
||||
import { getFsImplementation } from './utils/fsOperations.js'
|
||||
import { findProjectInstructionFilePathInAncestors } from './utils/projectInstructions.js'
|
||||
|
||||
export type Step = {
|
||||
key: string
|
||||
text: string
|
||||
isComplete: boolean
|
||||
isCompletable: boolean
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
export function getSteps(): Step[] {
|
||||
const hasRepoInstructions =
|
||||
findProjectInstructionFilePathInAncestors(
|
||||
getCwd(),
|
||||
getFsImplementation().existsSync,
|
||||
) !== null
|
||||
const isWorkspaceDirEmpty = isDirEmpty(getCwd())
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'workspace',
|
||||
text: 'Ask Claude to create a new app or clone a repository',
|
||||
isComplete: false,
|
||||
isCompletable: true,
|
||||
isEnabled: isWorkspaceDirEmpty,
|
||||
},
|
||||
{
|
||||
key: 'claudemd',
|
||||
text: 'Set up repo instructions (/init creates AGENTS.md or updates existing CLAUDE.md; either file counts)',
|
||||
isComplete: hasRepoInstructions,
|
||||
isCompletable: true,
|
||||
isEnabled: !isWorkspaceDirEmpty,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function isProjectOnboardingComplete(): boolean {
|
||||
return getSteps()
|
||||
.filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
|
||||
.every(({ isComplete }) => isComplete)
|
||||
}
|
||||
@@ -9,7 +9,10 @@ const sessionTranscriptModule = feature('KAIROS')
|
||||
|
||||
import { APIUserAbortError } from '@anthropic-ai/sdk'
|
||||
import { markPostCompaction } from 'src/bootstrap/state.js'
|
||||
import { getInvokedSkillsForAgent } from '../../bootstrap/state.js'
|
||||
import {
|
||||
getInvokedSkillsForAgent,
|
||||
getOriginalCwd,
|
||||
} from '../../bootstrap/state.js'
|
||||
import type { QuerySource } from '../../constants/querySource.js'
|
||||
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
|
||||
import type { Tool, ToolUseContext } from '../../Tool.js'
|
||||
@@ -68,6 +71,7 @@ import {
|
||||
} from '../../utils/messages.js'
|
||||
import { expandPath } from '../../utils/path.js'
|
||||
import { getPlan, getPlanFilePath } from '../../utils/plans.js'
|
||||
import { getProjectInstructionFilePaths } from '../../utils/projectInstructions.js'
|
||||
import {
|
||||
isSessionActivityTrackingActive,
|
||||
sendSessionActivitySignal,
|
||||
@@ -1689,8 +1693,13 @@ function shouldExcludeFromPostCompactRestore(
|
||||
// and to also match child directory memory files (.claude/rules/*.md, etc.)
|
||||
try {
|
||||
const normalizedMemoryPaths = new Set(
|
||||
MEMORY_TYPE_VALUES.map(type => expandPath(getMemoryPath(type))),
|
||||
MEMORY_TYPE_VALUES.filter(type => type !== 'Project').map(type =>
|
||||
expandPath(getMemoryPath(type)),
|
||||
),
|
||||
)
|
||||
for (const path of getProjectInstructionFilePaths(getOriginalCwd())) {
|
||||
normalizedMemoryPaths.add(expandPath(path))
|
||||
}
|
||||
|
||||
if (normalizedMemoryPaths.has(normalizedFilename)) {
|
||||
return true
|
||||
|
||||
@@ -525,7 +525,10 @@ export const FileEditTool = buildTool({
|
||||
})
|
||||
|
||||
// 7. Log events
|
||||
if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) {
|
||||
if (
|
||||
absoluteFilePath.endsWith(`${sep}AGENTS.md`) ||
|
||||
absoluteFilePath.endsWith(`${sep}CLAUDE.md`)
|
||||
) {
|
||||
logEvent('tengu_write_claudemd', {})
|
||||
}
|
||||
countLinesChanged(patch)
|
||||
|
||||
@@ -336,8 +336,11 @@ export const FileWriteTool = buildTool({
|
||||
limit: undefined,
|
||||
})
|
||||
|
||||
// Log when writing to CLAUDE.md
|
||||
if (fullFilePath.endsWith(`${sep}CLAUDE.md`)) {
|
||||
// Log when writing to the root project instruction file
|
||||
if (
|
||||
fullFilePath.endsWith(`${sep}AGENTS.md`) ||
|
||||
fullFilePath.endsWith(`${sep}CLAUDE.md`)
|
||||
) {
|
||||
logEvent('tengu_write_claudemd', {})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (AGENTS.md or fallback CLAUDE.md, plus .claude/CLAUDE.md 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
|
||||
@@ -13,7 +13,8 @@
|
||||
* - User memory is loaded from the user's home directory
|
||||
* - Project and Local files are discovered by traversing from the current directory up to root
|
||||
* - Files closer to the current directory have higher priority (loaded later)
|
||||
* - CLAUDE.md, .claude/CLAUDE.md, and all .md files in .claude/rules/ are checked in each directory for Project memory
|
||||
* - AGENTS.md is preferred for root project instructions; CLAUDE.md is only used when AGENTS.md is absent
|
||||
* - .claude/CLAUDE.md and all .md files in .claude/rules/ are checked in each directory for Project memory
|
||||
*
|
||||
* Memory @include directive:
|
||||
* - Memory files can include other files using @ notation
|
||||
@@ -75,6 +76,10 @@ import {
|
||||
import type { MemoryType } from './memory/types.js'
|
||||
import { expandPath } from './path.js'
|
||||
import { pathInWorkingPath } from './permissions/filesystem.js'
|
||||
import {
|
||||
getProjectInstructionFilePath,
|
||||
isProjectInstructionFileName,
|
||||
} from './projectInstructions.js'
|
||||
import { isSettingSourceEnabled } from './settings/constants.js'
|
||||
import { getInitialSettings } from './settings/settings.js'
|
||||
|
||||
@@ -868,7 +873,7 @@ export const getMemoryFiles = memoize(
|
||||
// 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
|
||||
// checked-in files like AGENTS.md/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
|
||||
@@ -892,9 +897,12 @@ export const getMemoryFiles = memoize(
|
||||
pathInWorkingPath(dir, canonicalRoot) &&
|
||||
!pathInWorkingPath(dir, gitRoot)
|
||||
|
||||
// Try reading CLAUDE.md (Project) - only if projectSettings is enabled
|
||||
// Try reading the root project instruction file (AGENTS.md first, otherwise CLAUDE.md)
|
||||
if (isSettingSourceEnabled('projectSettings') && !skipProject) {
|
||||
const projectPath = join(dir, 'CLAUDE.md')
|
||||
const projectPath = getProjectInstructionFilePath(
|
||||
dir,
|
||||
getFsImplementation().existsSync,
|
||||
)
|
||||
result.push(
|
||||
...(await processMemoryFile(
|
||||
projectPath,
|
||||
@@ -942,15 +950,18 @@ export const getMemoryFiles = memoize(
|
||||
}
|
||||
}
|
||||
|
||||
// Process CLAUDE.md from additional directories (--add-dir) if env var is enabled
|
||||
// Process root project instruction files 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)) {
|
||||
const additionalDirs = getAdditionalDirectoriesForClaudeMd()
|
||||
for (const dir of additionalDirs) {
|
||||
// Try reading CLAUDE.md from the additional directory
|
||||
const projectPath = join(dir, 'CLAUDE.md')
|
||||
// Try reading the root project instruction file from the additional directory
|
||||
const projectPath = getProjectInstructionFilePath(
|
||||
dir,
|
||||
getFsImplementation().existsSync,
|
||||
)
|
||||
result.push(
|
||||
...(await processMemoryFile(
|
||||
projectPath,
|
||||
@@ -1248,7 +1259,7 @@ export async function getManagedAndUserConditionalRules(
|
||||
|
||||
/**
|
||||
* Gets memory files for a single nested directory (between CWD and target).
|
||||
* Loads CLAUDE.md, unconditional rules, and conditional rules for that directory.
|
||||
* Loads the root project instruction file, unconditional rules, and conditional rules for that directory.
|
||||
*
|
||||
* @param dir The directory to process
|
||||
* @param targetPath The target file path (for conditional rule matching)
|
||||
@@ -1262,9 +1273,12 @@ export async function getMemoryFilesForNestedDirectory(
|
||||
): Promise<MemoryFileInfo[]> {
|
||||
const result: MemoryFileInfo[] = []
|
||||
|
||||
// Process project memory files (CLAUDE.md and .claude/CLAUDE.md)
|
||||
// Process project memory files (AGENTS.md first, otherwise CLAUDE.md, plus .claude/CLAUDE.md)
|
||||
if (isSettingSourceEnabled('projectSettings')) {
|
||||
const projectPath = join(dir, 'CLAUDE.md')
|
||||
const projectPath = getProjectInstructionFilePath(
|
||||
dir,
|
||||
getFsImplementation().existsSync,
|
||||
)
|
||||
result.push(
|
||||
...(await processMemoryFile(
|
||||
projectPath,
|
||||
@@ -1439,13 +1453,13 @@ export async function shouldShowClaudeMdExternalIncludesWarning(): Promise<boole
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is a memory file (CLAUDE.md, CLAUDE.local.md, or .claude/rules/*.md)
|
||||
* Check if a file path is a memory file (AGENTS.md, CLAUDE.md, CLAUDE.local.md, or .claude/rules/*.md)
|
||||
*/
|
||||
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') {
|
||||
// Root instruction files or CLAUDE.local.md anywhere
|
||||
if (isProjectInstructionFileName(name) || name === 'CLAUDE.local.md') {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { normalizePathForConfigKey } from './path.js'
|
||||
import { getEssentialTrafficOnlyReason } from './privacyLevel.js'
|
||||
import { getManagedFilePath } from './settings/managedPath.js'
|
||||
import type { ThemeSetting } from './theme.js'
|
||||
import { PRIMARY_PROJECT_INSTRUCTION_FILE } from './projectInstructions.js'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const teamMemPaths = feature('TEAMMEM')
|
||||
@@ -1823,7 +1824,7 @@ export function getMemoryPath(memoryType: MemoryType): string {
|
||||
case 'Local':
|
||||
return join(cwd, 'CLAUDE.local.md')
|
||||
case 'Project':
|
||||
return join(cwd, 'CLAUDE.md')
|
||||
return join(cwd, PRIMARY_PROJECT_INSTRUCTION_FILE)
|
||||
case 'Managed':
|
||||
return join(getManagedFilePath(), 'CLAUDE.md')
|
||||
case 'AutoMem':
|
||||
|
||||
105
src/utils/projectInstructions.test.ts
Normal file
105
src/utils/projectInstructions.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
findProjectInstructionFilePathInAncestors,
|
||||
FALLBACK_PROJECT_INSTRUCTION_FILE,
|
||||
getProjectInstructionFilePath,
|
||||
getProjectInstructionFilePaths,
|
||||
hasProjectInstructionFile,
|
||||
isProjectInstructionFileName,
|
||||
PRIMARY_PROJECT_INSTRUCTION_FILE,
|
||||
} from './projectInstructions.js'
|
||||
|
||||
describe('projectInstructions', () => {
|
||||
test('prefers AGENTS.md over CLAUDE.md for root project instructions', () => {
|
||||
const dir = '/repo'
|
||||
const existingPaths = new Set([
|
||||
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
|
||||
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
|
||||
])
|
||||
|
||||
const filePath = getProjectInstructionFilePath(
|
||||
dir,
|
||||
path => existingPaths.has(path),
|
||||
)
|
||||
|
||||
expect(filePath).toBe(join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE))
|
||||
})
|
||||
|
||||
test('falls back to CLAUDE.md when AGENTS.md is absent', () => {
|
||||
const dir = '/repo'
|
||||
const existingPaths = new Set([join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE)])
|
||||
|
||||
const filePath = getProjectInstructionFilePath(
|
||||
dir,
|
||||
path => existingPaths.has(path),
|
||||
)
|
||||
|
||||
expect(filePath).toBe(join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE))
|
||||
})
|
||||
|
||||
test('returns both candidate root instruction paths', () => {
|
||||
const dir = '/repo'
|
||||
|
||||
expect(getProjectInstructionFilePaths(dir)).toEqual([
|
||||
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
|
||||
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
|
||||
])
|
||||
})
|
||||
|
||||
test('detects whether a repo instruction file exists', () => {
|
||||
const dir = '/repo'
|
||||
const existingPaths = new Set([join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE)])
|
||||
|
||||
expect(hasProjectInstructionFile(dir, path => existingPaths.has(path))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(hasProjectInstructionFile(dir, () => false)).toBe(false)
|
||||
})
|
||||
|
||||
test('recognizes AGENTS.md as a root instruction filename', () => {
|
||||
expect(isProjectInstructionFileName(PRIMARY_PROJECT_INSTRUCTION_FILE)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isProjectInstructionFileName(FALLBACK_PROJECT_INSTRUCTION_FILE)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isProjectInstructionFileName('README.md')).toBe(false)
|
||||
})
|
||||
|
||||
test('finds repo instructions in ancestor directories', () => {
|
||||
const repoDir = '/repo'
|
||||
const nestedDir = join(repoDir, 'packages', 'app')
|
||||
const existingPaths = new Set([join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE)])
|
||||
|
||||
expect(
|
||||
findProjectInstructionFilePathInAncestors(
|
||||
nestedDir,
|
||||
path => existingPaths.has(path),
|
||||
),
|
||||
).toBe(join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE))
|
||||
})
|
||||
|
||||
test('prefers the closest ancestor project instruction file', () => {
|
||||
const repoDir = '/repo'
|
||||
const nestedProjectDir = join(repoDir, 'packages', 'app')
|
||||
const existingPaths = new Set([
|
||||
join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE),
|
||||
join(nestedProjectDir, FALLBACK_PROJECT_INSTRUCTION_FILE),
|
||||
])
|
||||
|
||||
expect(
|
||||
findProjectInstructionFilePathInAncestors(
|
||||
join(nestedProjectDir, 'src'),
|
||||
path => existingPaths.has(path),
|
||||
),
|
||||
).toBe(join(nestedProjectDir, FALLBACK_PROJECT_INSTRUCTION_FILE))
|
||||
})
|
||||
|
||||
test('returns null when no ancestor repo instruction file exists', () => {
|
||||
expect(
|
||||
findProjectInstructionFilePathInAncestors('/repo/packages/app', () => false),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
55
src/utils/projectInstructions.ts
Normal file
55
src/utils/projectInstructions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
export const PRIMARY_PROJECT_INSTRUCTION_FILE = 'AGENTS.md'
|
||||
export const FALLBACK_PROJECT_INSTRUCTION_FILE = 'CLAUDE.md'
|
||||
|
||||
export function getProjectInstructionFilePaths(dir: string): string[] {
|
||||
return [
|
||||
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
|
||||
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
|
||||
]
|
||||
}
|
||||
|
||||
export function getProjectInstructionFilePath(
|
||||
dir: string,
|
||||
existsSync: (path: string) => boolean,
|
||||
): string {
|
||||
const [primaryPath, fallbackPath] = getProjectInstructionFilePaths(dir)
|
||||
return existsSync(primaryPath)
|
||||
? primaryPath
|
||||
: fallbackPath
|
||||
}
|
||||
|
||||
export function hasProjectInstructionFile(
|
||||
dir: string,
|
||||
existsSync: (path: string) => boolean,
|
||||
): boolean {
|
||||
return getProjectInstructionFilePaths(dir).some(path => existsSync(path))
|
||||
}
|
||||
|
||||
export function findProjectInstructionFilePathInAncestors(
|
||||
startDir: string,
|
||||
existsSync: (path: string) => boolean,
|
||||
): string | null {
|
||||
let currentDir = startDir
|
||||
|
||||
while (true) {
|
||||
if (hasProjectInstructionFile(currentDir, existsSync)) {
|
||||
return getProjectInstructionFilePath(currentDir, existsSync)
|
||||
}
|
||||
|
||||
const parentDir = dirname(currentDir)
|
||||
if (parentDir === currentDir) {
|
||||
return null
|
||||
}
|
||||
|
||||
currentDir = parentDir
|
||||
}
|
||||
}
|
||||
|
||||
export function isProjectInstructionFileName(name: string): boolean {
|
||||
return (
|
||||
name === PRIMARY_PROJECT_INSTRUCTION_FILE ||
|
||||
name === FALLBACK_PROJECT_INSTRUCTION_FILE
|
||||
)
|
||||
}
|
||||
@@ -1082,10 +1082,10 @@ export const SettingsSchema = lazySchema(() =>
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Glob patterns or absolute paths of CLAUDE.md files to exclude from loading. ' +
|
||||
'Glob patterns or absolute paths of AGENTS.md/CLAUDE.md files to exclude from loading. ' +
|
||||
'Patterns are matched against absolute file paths using picomatch. ' +
|
||||
'Only applies to User, Project, and Local memory types (Managed/policy files cannot be excluded). ' +
|
||||
'Examples: "/home/user/monorepo/CLAUDE.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"',
|
||||
'Examples: "/home/user/monorepo/AGENTS.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"',
|
||||
),
|
||||
pluginTrustMessage: z
|
||||
.string()
|
||||
|
||||
Reference in New Issue
Block a user