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
311 lines · 9.5 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 output = result.result
227 const isPartialText =
228 output?.type === 'text' &&
229 (output.file.truncatedByTokenCap ||
230 output.file.startLine !== 1 ||
231 output.file.numLines < output.file.totalLines)
232 const isWholeRead =
233 e.offset === undefined && e.limit === undefined && !isPartialText
234
235 for (const file of fresh) {
236 const isSent =
237 attached.includes(file) || (Frames.isFileAt(file, read) && isWholeRead)
238
239 if (isSent) {
240 sent.add(file.path)
241 }
242 }
243
244 const hasAttached = attached.length > 0
245
246 if (hasAttached) {
247 Telemetry.quietly(() =>
248 $.telemetry.log(Telemetry.nestedRowOf(mode, attached.length)),
249 )
250 }
251
252 return hasAttached
253 ? {
254 ...result,
255 context: [
256 ...(result.context ?? []),
257 ...attached.map(Frames.nestedFrame),
258 ],
259 }
260 : result
261 })
262}
263
264/**
265 * Whether a Read attaches nested AGENTS.md files in this run: not where the
266 * engine attaches nothing to a turn, a nested CLAUDE.md included, which is a
267 * --bare run (it sets CLAUDE_CODE_SIMPLE) or one with
268 * CLAUDE_CODE_DISABLE_ATTACHMENTS on.
269 *
270 * Read on every Read, as the engine reads them on every turn: a settings
271 * `env` block or a managed delivery can flip either mid-session. The files of
272 * the walk itself need no such check: where the engine loads no instruction
273 * files `$.fs.ancestors` finds none.
274 *
275 * @param $ the engine, as the `tool.call` hook holds it
276 * @returns whether nested files ride a Read's result here
277 */
278async function attachesOnRead($: EngineInterface): Promise<boolean> {
279 const [simple, attachmentsOff] = await Promise.all([
280 $.env.get('CLAUDE_CODE_SIMPLE'),
281 $.env.get('CLAUDE_CODE_DISABLE_ATTACHMENTS'),
282 ])
283
284 return (
285 !Switches.isSwitchedOn(simple) && !Switches.isSwitchedOn(attachmentsOff)
286 )
287}
288
289/**
290 * The home directory a `~` in a Read's path stands for, read the way the
291 * Read tool reads it.
292 *
293 * The profile directory on a Windows spelling of the working directory,
294 * else `HOME`, each falling back to the other.
295 *
296 * @param $ the engine, as the `tool.call` hook holds it
297 * @param cwd the session's working directory, whose spelling names the platform
298 * @returns the home directory, or undefined when the environment names none
299 */
300async function homeOf(
301 $: EngineInterface,
302 cwd: string,
303): Promise<string | undefined> {
304 const [home, profile] = await Promise.all([
305 $.env.get('HOME'),
306 $.env.get('USERPROFILE'),
307 ])
308 const isWindowsSpelling = cwd.includes('\\')
309
310 return isWindowsSpelling ? (profile ?? home) : (home ?? profile)
311}