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
304 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 )
158 }
159
160 const instructionFiles = Files.withProjectFiles(handed, added)
161
162 return next({ ...e, instructionFiles }).finally(() => {
163 given.clear()
164 inContext = instructionFiles
165 })
166 })
167
168 on('agent.spawn', { fork: true }, async ($, e, next) => {
169 const result = await next(e)
170
171 if (result.agentId !== undefined) {
172 const parent = given.get(e.parentAgentId ?? Names.MAIN_LOOP)
173 given.set(result.agentId, new Set(parent))
174 }
175
176 return result
177 })
178
179 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
180 const result = await next(e)
181 const isSettledElsewhere =
182 e.tool !== 'Read' || result.deny !== undefined || result.isError
183
184 if (isSettledElsewhere) {
185 return result
186 }
187
188 if (!(await attachesOnRead($))) {
189 return result
190 }
191
192 const [root, cwd] = await Promise.all([$.session.root(), $.session.cwd()])
193 home ??= await homeOf($, cwd)
194 const read = Frames.absoluteOf(e.file_path, cwd, home)
195
196 if (root !== rootSeen) {
197 rootSeen = root
198 isClaudeProject = undefined
199 given.clear()
200 }
201
202 isClaudeProject ??=
203 isFallback &&
204 (await $.fs.ancestors({ names: Names.CLAUDE_NAMES })).length > 0
205 const isOutOfReach = isClaudeProject || !Frames.isBelow(read, root)
206
207 if (isOutOfReach) {
208 return result
209 }
210
211 const [stack, claude] = await Promise.all([
212 $.fs.ancestors({ names: Names.AGENTS_NAMES, of: read, below: root }),
213 $.fs.ancestors({ names: Names.CLAUDE_NAMES, of: read, below: root }),
214 ]).catch((): [typeof NONE, typeof NONE] => [NONE, NONE])
215 const loop = e.agentId ?? Names.MAIN_LOOP
216 const sent = given.get(loop) ?? new Set<string>()
217 given.set(loop, sent)
218 const nested = (
219 isFallback ? Frames.outsideClaudeDirs(stack, claude) : stack
220 ).filter(file => Frames.isBelow(file.dir, root))
221 const fresh = Files.unseenFiles(Files.filesOf(nested), [
222 ...inContext,
223 ...Files.filesOf(claude),
224 ]).filter(file => !sent.has(file.path))
225 const attached = fresh.filter(file => !Frames.isFileAt(file, read))
226 const isWholeRead = e.offset === undefined && e.limit === undefined
227
228 for (const file of fresh) {
229 const isSent =
230 attached.includes(file) || (Frames.isFileAt(file, read) && isWholeRead)
231
232 if (isSent) {
233 sent.add(file.path)
234 }
235 }
236
237 const hasAttached = attached.length > 0
238
239 if (hasAttached) {
240 Telemetry.quietly(() =>
241 $.telemetry.log(Telemetry.nestedRowOf(mode, attached.length)),
242 )
243 }
244
245 return hasAttached
246 ? {
247 ...result,
248 context: [
249 ...(result.context ?? []),
250 ...attached.map(Frames.nestedFrame),
251 ],
252 }
253 : result
254 })
255}
256
257/**
258 * Whether a Read attaches nested AGENTS.md files in this run: not where the
259 * engine attaches nothing to a turn, a nested CLAUDE.md included, which is a
260 * --bare run (it sets CLAUDE_CODE_SIMPLE) or one with
261 * CLAUDE_CODE_DISABLE_ATTACHMENTS on.
262 *
263 * Read on every Read, as the engine reads them on every turn: a settings
264 * `env` block or a managed delivery can flip either mid-session. The files of
265 * the walk itself need no such check: where the engine loads no instruction
266 * files `$.fs.ancestors` finds none.
267 *
268 * @param $ the engine, as the `tool.call` hook holds it
269 * @returns whether nested files ride a Read's result here
270 */
271async function attachesOnRead($: EngineInterface): Promise<boolean> {
272 const [simple, attachmentsOff] = await Promise.all([
273 $.env.get('CLAUDE_CODE_SIMPLE'),
274 $.env.get('CLAUDE_CODE_DISABLE_ATTACHMENTS'),
275 ])
276
277 return (
278 !Switches.isSwitchedOn(simple) && !Switches.isSwitchedOn(attachmentsOff)
279 )
280}
281
282/**
283 * The home directory a `~` in a Read's path stands for, read the way the
284 * Read tool reads it.
285 *
286 * The profile directory on a Windows spelling of the working directory,
287 * else `HOME`, each falling back to the other.
288 *
289 * @param $ the engine, as the `tool.call` hook holds it
290 * @param cwd the session's working directory, whose spelling names the platform
291 * @returns the home directory, or undefined when the environment names none
292 */
293async function homeOf(
294 $: EngineInterface,
295 cwd: string,
296): Promise<string | undefined> {
297 const [home, profile] = await Promise.all([
298 $.env.get('HOME'),
299 $.env.get('USERPROFILE'),
300 ])
301 const isWindowsSpelling = cwd.includes('\\')
302
303 return isWindowsSpelling ? (profile ?? home) : (home ?? profile)
304}