use super::typescript::{self, Harness};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Event {
pub name: &'static str,
pub matcher: Option<&'static str>,
pub default_timeout_ms: u32,
}
const DEFAULT_TIMEOUT_MS: u32 = 600_000;
impl Event {
const fn timeout_ms(self, default_timeout_ms: u32) -> Self {
Self {
default_timeout_ms,
..self
}
}
pub fn is_tool_event(&self) -> bool {
self.matcher == Some("tool_name")
}
}
const fn event(name: &'static str) -> Event {
Event {
name,
matcher: None,
default_timeout_ms: DEFAULT_TIMEOUT_MS,
}
}
const fn matched(name: &'static str, field: &'static str) -> Event {
Event {
name,
matcher: Some(field),
default_timeout_ms: DEFAULT_TIMEOUT_MS,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MergeFile {
pub file: &'static str,
pub top_level: &'static [(&'static str, i64)],
pub trust_gated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Placement {
Merge(MergeFile),
File { dir: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rules {
Claude,
Codex,
TypeScript(&'static Harness),
Withheld { reason: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Profile {
pub events: &'static [Event],
pub verified_against: Option<&'static str>,
pub rules: Rules,
}
impl Profile {
pub fn event(&self, name: &str) -> Option<&'static Event> {
self.events.iter().find(|e| e.name == name)
}
pub fn renders(&self) -> bool {
!matches!(self.rules, Rules::Withheld { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target {
pub root: &'static str,
pub placement: Placement,
pub profile: Profile,
pub other_events: &'static [&'static str],
}
const GENERATED_SUFFIX: &str = super::RESERVED_NAME_SUFFIX;
impl Target {
pub fn reads(&self, event: &str) -> bool {
self.profile.event(event).is_some() || self.other_events.contains(&event)
}
pub fn merge_file(&self) -> Option<&MergeFile> {
match &self.placement {
Placement::Merge(merge) => Some(merge),
Placement::File { .. } => None,
}
}
pub fn fragment_problem(&self, content: &str) -> Option<&'static str> {
match self.profile.rules {
Rules::TypeScript(harness) => harness.fragment_problem(content),
_ => None,
}
}
pub fn is_file(&self) -> bool {
matches!(self.placement, Placement::File { .. })
}
pub fn fragment_path(&self, hook_name: &str) -> Option<String> {
let Placement::File { dir } = self.placement else {
return None;
};
Some(format!("{dir}/mars-{hook_name}.ts"))
}
pub fn generated_path(&self, hook_name: &str) -> Option<String> {
let Placement::File { dir } = self.placement else {
return None;
};
Some(format!("{dir}/mars-{hook_name}{GENERATED_SUFFIX}.ts"))
}
}
const CLAUDE_EVENTS: &[Event] = &[
matched("SessionStart", "source"),
event("Setup"),
event("UserPromptSubmit").timeout_ms(30_000),
event("UserPromptExpansion"),
matched("PreToolUse", "tool_name"),
matched("PermissionRequest", "tool_name"),
matched("PermissionDenied", "tool_name"),
matched("PostToolUse", "tool_name"),
matched("PostToolUseFailure", "tool_name"),
event("PostToolBatch"),
matched("SubagentStart", "agent_type"),
matched("SubagentStop", "agent_type"),
event("TaskCreated"),
event("TaskCompleted"),
event("Stop"),
event("StopFailure"),
event("TeammateIdle"),
matched("PreCompact", "trigger"),
matched("PostCompact", "trigger"),
event("Elicitation"),
event("ElicitationResult"),
matched("Notification", "notification_type"),
event("ConfigChange"),
event("InstructionsLoaded"),
event("CwdChanged"),
event("FileChanged"),
event("WorktreeCreate"),
event("WorktreeRemove"),
matched("SessionEnd", "reason").timeout_ms(1_500),
];
const CODEX_EVENTS: &[Event] = &[
matched("SessionStart", "source"),
event("UserPromptSubmit"),
matched("PreToolUse", "tool_name"),
matched("PermissionRequest", "tool_name"),
matched("PostToolUse", "tool_name"),
matched("PreCompact", "trigger"),
matched("PostCompact", "trigger"),
matched("SubagentStart", "agent_type"),
matched("SubagentStop", "agent_type"),
event("Stop"),
matched("SessionEnd", "reason"),
];
const CURSOR_EVENTS: &[Event] = &[
event("beforeShellExecution"),
event("beforeMCPExecution"),
event("afterShellExecution"),
event("afterMCPExecution"),
event("beforeReadFile"),
event("afterFileEdit"),
event("beforeTabFileRead"),
event("afterTabFileEdit"),
event("stop"),
event("beforeSubmitPrompt"),
event("afterAgentResponse"),
event("afterAgentThought"),
event("sessionStart"),
event("sessionEnd"),
event("preCompact"),
event("subagentStart"),
event("subagentStop"),
event("preToolUse"),
event("postToolUse"),
event("postToolUseFailure"),
event("workspaceOpen"),
];
const OPENCODE_EVENTS: &[Event] = &[
matched("PreToolUse", "tool_name"),
matched("PostToolUse", "tool_name"),
matched("PostToolUseFailure", "tool_name"),
event("UserPromptSubmit"),
event("PreCompact"),
matched("PermissionRequest", "tool_name"),
];
const PI_EVENTS: &[Event] = &[
matched("PreToolUse", "tool_name"),
matched("PostToolUse", "tool_name"),
matched("PostToolUseFailure", "tool_name"),
event("UserPromptSubmit"),
matched("SessionStart", "source"),
event("Stop"),
matched("PreCompact", "trigger"),
matched("PostCompact", "trigger"),
matched("SessionEnd", "reason"),
];
const CLAUDE_VERSION: &str = "Claude Code 2.1.312";
pub const PLUGIN_CLAUDE_TREE: Profile = Profile {
events: CLAUDE_EVENTS,
verified_against: Some(CLAUDE_VERSION),
rules: Rules::Codex,
};
const TARGETS: &[Target] = &[
Target {
root: ".claude",
placement: Placement::Merge(MergeFile {
file: "settings.local.json",
top_level: &[],
trust_gated: false,
}),
profile: Profile {
events: CLAUDE_EVENTS,
verified_against: Some(CLAUDE_VERSION),
rules: Rules::Claude,
},
other_events: &[],
},
Target {
root: ".codex",
placement: Placement::Merge(MergeFile {
file: "hooks.json",
top_level: &[],
trust_gated: true,
}),
profile: Profile {
events: CODEX_EVENTS,
verified_against: Some("codex-cli 0.164.0"),
rules: Rules::Codex,
},
other_events: &["PreModelRequest", "PostModelRequest", "Interrupt"],
},
Target {
root: ".cursor",
placement: Placement::Merge(MergeFile {
file: "hooks.json",
top_level: &[("version", 1)],
trust_gated: false,
}),
profile: Profile {
events: CURSOR_EVENTS,
verified_against: Some("cursor-agent 2026.07.16"),
rules: Rules::Withheld {
reason: "mars renders no generated hooks for Cursor before 0.26",
},
},
other_events: &[],
},
Target {
root: ".opencode",
placement: Placement::File { dir: "plugins" },
profile: Profile {
events: OPENCODE_EVENTS,
verified_against: Some("OpenCode 2.2.0"),
rules: Rules::TypeScript(&typescript::OPENCODE),
},
other_events: &[],
},
Target {
root: ".pi",
placement: Placement::File { dir: "extensions" },
profile: Profile {
events: PI_EVENTS,
verified_against: Some("Pi 1.4.0"),
rules: Rules::TypeScript(&typescript::PI),
},
other_events: &[],
},
];
pub fn target(root: &str) -> Option<&'static Target> {
TARGETS.iter().find(|target| target.root == root)
}
pub fn all() -> &'static [Target] {
TARGETS
}
pub fn claude_event(name: &str) -> Option<&'static Event> {
CLAUDE_EVENTS.iter().find(|event| event.name == name)
}
pub fn claude_event_names() -> String {
CLAUDE_EVENTS
.iter()
.map(|event| event.name)
.collect::<Vec<_>>()
.join(", ")
}