spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / mods / agents-md / hooks / register.ts
305 lines · 9.3 KBRaw
1import type {
2 EngineInterface,
3 FsAncestor,
4 InstructionFile,
5 On,
6 PluginOptions,
7} from 'claude-code'
8
9import Files from './files'
10import Frames from './frames'
11import Modes from './modes'
12import Names from './names'
13import Switches from './switches'
14import Telemetry from './telemetry'
15
16/**
17 * No files: what a walk not taken, or one that failed, found.
18 */
19const NONE: readonly FsAncestor[] = []
20
21/**
22 * Registers the plugin's hooks for the mode `instructionFiles` names
23 * (`claude-md-or-agents-md` when unset; the manifest lists the four, nothing
24 * else arrives).
25 *
26 * `claude-md`: nothing beyond the usage row, the engine's CLAUDE.md walk
27 * standing alone. `managed-only`: the project's and the person's instruction
28 * files dropped, the organization's kept. `claude-md-or-agents-md` (a project
29 * with none of its own) and `claude-md-and-agents-md`: AGENTS.md files joined
30 * to the engine's instruction files, nested ones on a Read except in a run
31 * where the engine attaches nothing to a turn (--bare, which sets
32 * CLAUDE_CODE_SIMPLE, or CLAUDE_CODE_DISABLE_ATTACHMENTS). Every mode sends
33 * its usage rows through `$.telemetry` where that noun is seated and drops
34 * them where it is not.
35 *
36 * @param on the engine's registrar
37 * @param options the plugin's options; `instructionFiles` is `claude-md`,
38 * `claude-md-or-agents-md`, `claude-md-and-agents-md` or `managed-only`
39 */
40export function register(on: On, options: PluginOptions): void {
41 const named = Modes.modeOf(options.instructionFiles)
42 // COMPAT_BREAK(agents-md-project-instructions): drop the projectInstructions
43 // mapping once stored settings have migrated. The host fills in the default,
44 // so an instructionFiles set to it cannot be told from one left unset.
45 const legacy = Modes.legacyModeOf(options.projectInstructions)
46 const isLegacyRead = legacy !== undefined && named === Modes.DEFAULT_MODE
47 const mode = isLegacyRead ? legacy : named
48 let isRenameTold = legacy === undefined
49
50 on('session.start', ($, e, next) => {
51 Telemetry.quietly(() =>
52 $.telemetry.log(Telemetry.modeRowOf(mode, e.isInteractive)),
53 )
54
55 if (!isRenameTold) {
56 isRenameTold = true
57 $.ui.log(
58 isLegacyRead
59 ? 'option projectInstructions in settings is honoured for now, ' +
60 `read as instructionFiles ${mode}; set instructionFiles to ` +
61 `${mode} and remove projectInstructions`
62 : `option projectInstructions in settings is not read: ` +
63 `instructionFiles ${mode} is set; remove projectInstructions`,
64 )
65 }
66
67 return next(e)
68 })
69
70 if (mode === 'claude-md') {
71 return
72 }
73
74 if (mode === 'managed-only') {
75 on(
76 'prompt.context',
77 { instructionFiles: { kind: Files.DROPPED_KINDS } },
78 ($, e, next) =>
79 next({
80 ...e,
81 instructionFiles: e.instructionFiles?.filter(
82 Files.isKeptWithoutInstructions,
83 ),
84 }),
85 )
86
87 return
88 }
89
90 const isFallback = mode === 'claude-md-or-agents-md'
91 const given = new Map<string, Set<string>>()
92 let inContext: readonly InstructionFile[] = []
93 let home: string | undefined
94 let isClaudeProject: boolean | undefined
95 let rootSeen: string | undefined
96 let rootLogged: string | undefined
97 let isCounted = false
98
99 on('prompt.context', async ($, e, next) => {
100 const handed = e.instructionFiles
101
102 if (handed === undefined) {
103 return next(e).finally(() => {
104 given.clear()
105 })
106 }
107
108 const root = isFallback ? await $.session.root() : undefined
109 rootSeen = root ?? rootSeen
110 let isWalkFailed = false
111 // The walk, not only what was handed: the engine can withhold a project
112 // CLAUDE.md it loaded, and the project still has one.
113 isClaudeProject =
114 root !== undefined &&
115 (handed.some(file => Files.isClaudeFileOnWalk(file, root)) ||
116 (await $.fs.ancestors({ names: Names.CLAUDE_NAMES }).then(
117 files => files.length > 0,
118 () => {
119 isWalkFailed = true
120
121 return true
122 },
123 )))
124 const found = isClaudeProject
125 ? NONE
126 : await $.fs.ancestors({ names: Names.AGENTS_NAMES }).catch(() => {
127 isWalkFailed = true
128
129 return NONE
130 })
131 const added = Files.unseenFiles(Files.filesOf(found), handed)
132
133 if (!isCounted) {
134 isCounted = true
135 const counts = Telemetry.loadCountsOf(
136 added,
137 isClaudeProject && !isWalkFailed,
138 isWalkFailed,
139 )
140 Telemetry.quietly(() =>
141 $.telemetry.log(Telemetry.loadRowOf(mode, counts)),
142 )
143 Telemetry.quietly(() => $.telemetry.mark(Telemetry.loadMarkOf(counts)))
144 }
145
146 const isFirstLoad =
147 root !== undefined && root !== rootLogged && added.length > 0
148
149 if (isFirstLoad) {
150 rootLogged = root
151 $.ui.log(
152 'no CLAUDE.md found; AGENTS.md loaded: ' +
153 added
154 .filter(file => file.parent === undefined)
155 .map(file => file.path)
156 .join(', '),
157 { to: 'debug' },
158 )
159 }
160
161 const instructionFiles = Files.withProjectFiles(handed, added)
162
163 return next({ ...e, instructionFiles }).finally(() => {
164 given.clear()
165 inContext = instructionFiles
166 })
167 })
168
169 on('agent.spawn', { fork: true }, async ($, e, next) => {
170 const result = await next(e)
171
172 if (result.agentId !== undefined) {
173 const parent = given.get(e.parentAgentId ?? Names.MAIN_LOOP)
174 given.set(result.agentId, new Set(parent))
175 }
176
177 return result
178 })
179
180 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
181 const result = await next(e)
182 const isSettledElsewhere =
183 e.tool !== 'Read' || result.deny !== undefined || result.isError
184
185 if (isSettledElsewhere) {
186 return result
187 }
188
189 if (!(await attachesOnRead($))) {
190 return result
191 }
192
193 const [root, cwd] = await Promise.all([$.session.root(), $.session.cwd()])
194 home ??= await homeOf($, cwd)
195 const read = Frames.absoluteOf(e.file_path, cwd, home)
196
197 if (root !== rootSeen) {
198 rootSeen = root
199 isClaudeProject = undefined
200 given.clear()
201 }
202
203 isClaudeProject ??=
204 isFallback &&
205 (await $.fs.ancestors({ names: Names.CLAUDE_NAMES })).length > 0
206 const isOutOfReach = isClaudeProject || !Frames.isBelow(read, root)
207
208 if (isOutOfReach) {
209 return result
210 }
211
212 const [stack, claude] = await Promise.all([
213 $.fs.ancestors({ names: Names.AGENTS_NAMES, of: read, below: root }),
214 $.fs.ancestors({ names: Names.CLAUDE_NAMES, of: read, below: root }),
215 ]).catch((): [typeof NONE, typeof NONE] => [NONE, NONE])
216 const loop = e.agentId ?? Names.MAIN_LOOP
217 const sent = given.get(loop) ?? new Set<string>()
218 given.set(loop, sent)
219 const nested = (
220 isFallback ? Frames.outsideClaudeDirs(stack, claude) : stack
221 ).filter(file => Frames.isBelow(file.dir, root))
222 const fresh = Files.unseenFiles(Files.filesOf(nested), [
223 ...inContext,
224 ...Files.filesOf(claude),
225 ]).filter(file => !sent.has(file.path))
226 const attached = fresh.filter(file => !Frames.isFileAt(file, read))
227 const isWholeRead = e.offset === undefined && e.limit === undefined
228
229 for (const file of fresh) {
230 const isSent =
231 attached.includes(file) || (Frames.isFileAt(file, read) && isWholeRead)
232
233 if (isSent) {
234 sent.add(file.path)
235 }
236 }
237
238 const hasAttached = attached.length > 0
239
240 if (hasAttached) {
241 Telemetry.quietly(() =>
242 $.telemetry.log(Telemetry.nestedRowOf(mode, attached.length)),
243 )
244 }
245
246 return hasAttached
247 ? {
248 ...result,
249 context: [
250 ...(result.context ?? []),
251 ...attached.map(Frames.nestedFrame),
252 ],
253 }
254 : result
255 })
256}
257
258/**
259 * Whether a Read attaches nested AGENTS.md files in this run: not where the
260 * engine attaches nothing to a turn, a nested CLAUDE.md included, which is a
261 * --bare run (it sets CLAUDE_CODE_SIMPLE) or one with
262 * CLAUDE_CODE_DISABLE_ATTACHMENTS on.
263 *
264 * Read on every Read, as the engine reads them on every turn: a settings
265 * `env` block or a managed delivery can flip either mid-session. The files of
266 * the walk itself need no such check: where the engine loads no instruction
267 * files `$.fs.ancestors` finds none.
268 *
269 * @param $ the engine, as the `tool.call` hook holds it
270 * @returns whether nested files ride a Read's result here
271 */
272async function attachesOnRead($: EngineInterface): Promise<boolean> {
273 const [simple, attachmentsOff] = await Promise.all([
274 $.env.get('CLAUDE_CODE_SIMPLE'),
275 $.env.get('CLAUDE_CODE_DISABLE_ATTACHMENTS'),
276 ])
277
278 return (
279 !Switches.isSwitchedOn(simple) && !Switches.isSwitchedOn(attachmentsOff)
280 )
281}
282
283/**
284 * The home directory a `~` in a Read's path stands for, read the way the
285 * Read tool reads it.
286 *
287 * The profile directory on a Windows spelling of the working directory,
288 * else `HOME`, each falling back to the other.
289 *
290 * @param $ the engine, as the `tool.call` hook holds it
291 * @param cwd the session's working directory, whose spelling names the platform
292 * @returns the home directory, or undefined when the environment names none
293 */
294async function homeOf(
295 $: EngineInterface,
296 cwd: string,
297): Promise<string | undefined> {
298 const [home, profile] = await Promise.all([
299 $.env.get('HOME'),
300 $.env.get('USERPROFILE'),
301 ])
302 const isWindowsSpelling = cwd.includes('\\')
303
304 return isWindowsSpelling ? (profile ?? home) : (home ?? profile)
305}