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