Skip to main content

mars_schema/hooks/
targets.rs

1//! What each target root takes of a hook: one [`Target`] row per root, holding where its
2//! hooks are placed (the merge file and its trust gate, or the file directory), the events it
3//! accepts, the harness version that table was last checked against, how `[hooks]` is
4//! rendered for it (for a TypeScript harness, its glue, limits and native hook contract), and
5//! what its generated files are called. A new target is one row here; the binary only places
6//! what the row describes. Nothing about a target is kept anywhere else.
7use super::typescript::{self, Harness};
8
9/// A hook event of one target and the input field its `matcher` tests.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct Event {
12    pub name: &'static str,
13    /// The input field the matcher tests (`tool_name`, `source`, …). `None` means no field is
14    /// recorded for this event; only the events a generated adapter renders need one.
15    pub matcher: Option<&'static str>,
16    /// Claude Code's default `timeout` for a `command` handler on this event, in
17    /// milliseconds. A generated TypeScript hook applies the Claude row's value so it times
18    /// out as it would on Claude; the other targets' rows carry the common default unread.
19    pub default_timeout_ms: u32,
20}
21
22/// The default of every event but the two below. Claude Code docs, "Hook handler fields",
23/// `timeout`: https://code.claude.com/docs/en/hooks (read 2026-10-28).
24const DEFAULT_TIMEOUT_MS: u32 = 600_000;
25
26impl Event {
27    /// The row with a shorter Claude default. The docs lower it to 30 s on
28    /// `UserPromptSubmit` (and on the `PreModelSwitch`/`PostModelSwitch`, 10 s on
29    /// `MessageDisplay`, which the Claude table below does not list) and give `SessionEnd`
30    /// a 1.5 s budget.
31    const fn timeout_ms(self, default_timeout_ms: u32) -> Self {
32        Self {
33            default_timeout_ms,
34            ..self
35        }
36    }
37
38    /// A tool event: its handlers can carry Claude's `if` filter.
39    pub fn is_tool_event(&self) -> bool {
40        self.matcher == Some("tool_name")
41    }
42}
43
44const fn event(name: &'static str) -> Event {
45    Event {
46        name,
47        matcher: None,
48        default_timeout_ms: DEFAULT_TIMEOUT_MS,
49    }
50}
51
52const fn matched(name: &'static str, field: &'static str) -> Event {
53    Event {
54        name,
55        matcher: Some(field),
56        default_timeout_ms: DEFAULT_TIMEOUT_MS,
57    }
58}
59
60/// The hook config file of a merge target, where its entries are appended.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct MergeFile {
63    /// Under the target root.
64    pub file: &'static str,
65    /// Keys set at the top of the file before appending (Cursor's `version`).
66    pub top_level: &'static [(&'static str, i64)],
67    /// The harness skips hooks it has not been told to trust, keyed on their position in
68    /// `file` (Codex): a sync that moves or adds one says so.
69    pub trust_gated: bool,
70}
71
72/// How a target root takes a hook.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Placement {
75    /// The hook's events are merged into the target's hook config file.
76    Merge(MergeFile),
77    /// The hook is installed as whole files in `dir`, under the target root.
78    File { dir: &'static str },
79}
80
81/// How `[hooks]` becomes a target's entries.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Rules {
84    /// Claude takes the table as written.
85    Claude,
86    /// Codex's parser: handlers and matchers it cannot run are left out.
87    Codex,
88    /// Generated TypeScript for the harness (`hooks::typescript`).
89    TypeScript(&'static Harness),
90    /// Nothing is rendered for the target, as a whole; `reason` says why.
91    Withheld { reason: &'static str },
92}
93
94/// What a renderer needs of a target: the events it accepts and the rules its parser applies.
95/// The two vary independently: the plugin tree's `hooks.json` is Claude's events under
96/// Codex's rules.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct Profile {
99    /// Every event the target accepts.
100    pub events: &'static [Event],
101    /// The harness version `events` was last checked against; `None` while the rows are
102    /// unchecked.
103    pub verified_against: Option<&'static str>,
104    pub rules: Rules,
105}
106
107impl Profile {
108    /// The profile's row for `name`.
109    pub fn event(&self, name: &str) -> Option<&'static Event> {
110        self.events.iter().find(|e| e.name == name)
111    }
112
113    /// Whether the rules render `[hooks]` at all.
114    pub fn renders(&self) -> bool {
115        !matches!(self.rules, Rules::Withheld { .. })
116    }
117}
118
119/// One target root's hook rules.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct Target {
122    pub root: &'static str,
123    pub placement: Placement,
124    pub profile: Profile,
125    /// Events the harness reads that `profile.events` has no row for: they travel only in a
126    /// native hook marked `unchecked`.
127    pub other_events: &'static [&'static str],
128}
129
130/// Where a file-placement target's generated file gets its suffix: the hook name ends in
131/// neither, so a generated file never collides with an authored one.
132const GENERATED_SUFFIX: &str = super::RESERVED_NAME_SUFFIX;
133
134impl Target {
135    /// Whether the harness reads `event` from its hook file; it ignores any other key.
136    pub fn reads(&self, event: &str) -> bool {
137        self.profile.event(event).is_some() || self.other_events.contains(&event)
138    }
139
140    /// The file a merge target appends entries to; `None` for a file target.
141    pub fn merge_file(&self) -> Option<&MergeFile> {
142        match &self.placement {
143            Placement::Merge(merge) => Some(merge),
144            Placement::File { .. } => None,
145        }
146    }
147
148    /// What is wrong with a file target's native hook, if anything. Warning-only: a file
149    /// native hook is otherwise opaque, and the caller words the diagnostic.
150    pub fn native_hook_problem(&self, content: &str) -> Option<&'static str> {
151        match self.profile.rules {
152            Rules::TypeScript(harness) => harness.native_hook_problem(content),
153            _ => None,
154        }
155    }
156
157    /// Whether the target installs whole files rather than merging entries.
158    pub fn is_file(&self) -> bool {
159        matches!(self.placement, Placement::File { .. })
160    }
161
162    /// The path, relative to the target root, of the hook's own native hook file; `None` for a
163    /// merge target, which has no per-hook file.
164    pub fn native_hook_path(&self, hook_name: &str) -> Option<String> {
165        let Placement::File { dir } = self.placement else {
166            return None;
167        };
168        Some(format!("{dir}/mars-{hook_name}.ts"))
169    }
170
171    /// The path, relative to the target root, of the file generated from the hook's
172    /// `[hooks]`; `None` for a merge target.
173    pub fn generated_path(&self, hook_name: &str) -> Option<String> {
174        let Placement::File { dir } = self.placement else {
175            return None;
176        };
177        Some(format!("{dir}/mars-{hook_name}{GENERATED_SUFFIX}.ts"))
178    }
179}
180
181const CLAUDE_EVENTS: &[Event] = &[
182    matched("SessionStart", "source"),
183    event("Setup"),
184    event("UserPromptSubmit").timeout_ms(30_000),
185    event("UserPromptExpansion"),
186    matched("PreToolUse", "tool_name"),
187    matched("PermissionRequest", "tool_name"),
188    matched("PermissionDenied", "tool_name"),
189    matched("PostToolUse", "tool_name"),
190    matched("PostToolUseFailure", "tool_name"),
191    event("PostToolBatch"),
192    matched("SubagentStart", "agent_type"),
193    matched("SubagentStop", "agent_type"),
194    event("TaskCreated"),
195    event("TaskCompleted"),
196    event("Stop"),
197    event("StopFailure"),
198    event("TeammateIdle"),
199    matched("PreCompact", "trigger"),
200    matched("PostCompact", "trigger"),
201    event("Elicitation"),
202    event("ElicitationResult"),
203    matched("Notification", "notification_type"),
204    event("ConfigChange"),
205    event("InstructionsLoaded"),
206    event("CwdChanged"),
207    event("FileChanged"),
208    event("WorktreeCreate"),
209    event("WorktreeRemove"),
210    matched("SessionEnd", "reason").timeout_ms(1_500),
211];
212
213// Codex reports `SessionEnd` reason as `other`.
214const CODEX_EVENTS: &[Event] = &[
215    matched("SessionStart", "source"),
216    event("UserPromptSubmit"),
217    matched("PreToolUse", "tool_name"),
218    matched("PermissionRequest", "tool_name"),
219    matched("PostToolUse", "tool_name"),
220    matched("PreCompact", "trigger"),
221    matched("PostCompact", "trigger"),
222    matched("SubagentStart", "agent_type"),
223    matched("SubagentStop", "agent_type"),
224    event("Stop"),
225    matched("SessionEnd", "reason"),
226];
227
228const CURSOR_EVENTS: &[Event] = &[
229    event("beforeShellExecution"),
230    event("beforeMCPExecution"),
231    event("afterShellExecution"),
232    event("afterMCPExecution"),
233    event("beforeReadFile"),
234    event("afterFileEdit"),
235    event("beforeTabFileRead"),
236    event("afterTabFileEdit"),
237    event("stop"),
238    event("beforeSubmitPrompt"),
239    event("afterAgentResponse"),
240    event("afterAgentThought"),
241    event("sessionStart"),
242    event("sessionEnd"),
243    event("preCompact"),
244    event("subagentStart"),
245    event("subagentStop"),
246    event("preToolUse"),
247    event("postToolUse"),
248    event("postToolUseFailure"),
249    event("workspaceOpen"),
250];
251
252// OpenCode's compaction callback does not identify its trigger.
253const OPENCODE_EVENTS: &[Event] = &[
254    matched("PreToolUse", "tool_name"),
255    matched("PostToolUse", "tool_name"),
256    matched("PostToolUseFailure", "tool_name"),
257    event("UserPromptSubmit"),
258    event("PreCompact"),
259    matched("PermissionRequest", "tool_name"),
260];
261
262const PI_EVENTS: &[Event] = &[
263    matched("PreToolUse", "tool_name"),
264    matched("PostToolUse", "tool_name"),
265    matched("PostToolUseFailure", "tool_name"),
266    event("UserPromptSubmit"),
267    matched("SessionStart", "source"),
268    event("Stop"),
269    matched("PreCompact", "trigger"),
270    matched("PostCompact", "trigger"),
271    matched("SessionEnd", "reason"),
272];
273
274const CLAUDE_VERSION: &str = "Claude Code 2.1.312";
275
276/// What the Claude plugin tree's `hooks/hooks.json` must satisfy: Claude's events under
277/// Codex's handler rules, because Claude, Codex and Grok all read that one file.
278pub const PLUGIN_CLAUDE_TREE: Profile = Profile {
279    events: CLAUDE_EVENTS,
280    verified_against: Some(CLAUDE_VERSION),
281    rules: Rules::Codex,
282};
283
284const TARGETS: &[Target] = &[
285    Target {
286        root: ".claude",
287        // Hook commands carry machine-local cache paths, so they belong in the gitignored
288        // `settings.local.json` rather than the committed `settings.json`.
289        placement: Placement::Merge(MergeFile {
290            file: "settings.local.json",
291            top_level: &[],
292            trust_gated: false,
293        }),
294        profile: Profile {
295            events: CLAUDE_EVENTS,
296            verified_against: Some(CLAUDE_VERSION),
297            rules: Rules::Claude,
298        },
299        other_events: &[],
300    },
301    Target {
302        root: ".codex",
303        placement: Placement::Merge(MergeFile {
304            file: "hooks.json",
305            top_level: &[],
306            trust_gated: true,
307        }),
308        profile: Profile {
309            events: CODEX_EVENTS,
310            verified_against: Some("codex-cli 0.164.0"),
311            rules: Rules::Codex,
312        },
313        other_events: &["PreModelRequest", "PostModelRequest", "Interrupt"],
314    },
315    Target {
316        root: ".cursor",
317        // Cursor's `hooks.json` is a flat entry array and requires the version wrapper.
318        placement: Placement::Merge(MergeFile {
319            file: "hooks.json",
320            top_level: &[("version", 1)],
321            trust_gated: false,
322        }),
323        profile: Profile {
324            events: CURSOR_EVENTS,
325            verified_against: Some("cursor-agent 2026.07.16"),
326            rules: Rules::Withheld {
327                reason: "mars renders no generated hooks for Cursor before 0.26",
328            },
329        },
330        other_events: &[],
331    },
332    Target {
333        root: ".opencode",
334        placement: Placement::File { dir: "plugins" },
335        profile: Profile {
336            events: OPENCODE_EVENTS,
337            verified_against: Some("OpenCode 2.2.0"),
338            rules: Rules::TypeScript(&typescript::OPENCODE),
339        },
340        other_events: &[],
341    },
342    Target {
343        root: ".pi",
344        placement: Placement::File { dir: "extensions" },
345        profile: Profile {
346            events: PI_EVENTS,
347            verified_against: Some("Pi 1.4.0"),
348            rules: Rules::TypeScript(&typescript::PI),
349        },
350        other_events: &[],
351    },
352];
353
354/// The hook rules of `root`; `None` when it has no hook mechanism.
355pub fn target(root: &str) -> Option<&'static Target> {
356    TARGETS.iter().find(|target| target.root == root)
357}
358
359/// Every target that takes hooks.
360pub fn all() -> &'static [Target] {
361    TARGETS
362}
363
364/// Claude's event of that name: the set a `[hooks]` table may declare.
365pub fn claude_event(name: &str) -> Option<&'static Event> {
366    CLAUDE_EVENTS.iter().find(|event| event.name == name)
367}
368
369/// Claude's event names, for a message.
370pub fn claude_event_names() -> String {
371    CLAUDE_EVENTS
372        .iter()
373        .map(|event| event.name)
374        .collect::<Vec<_>>()
375        .join(", ")
376}