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