1use core::fmt::Display;
2
3use strum::EnumIter;
4
5use crate::ParseError;
6
7pub mod session;
8#[cfg(not(target_arch = "wasm32"))]
9pub mod session_loader;
10pub mod session_parser;
11
12pub const AGENTS_PIPE: &str = "agm-agent";
13
14#[derive(Clone, Copy, Debug, EnumIter, Eq, PartialEq)]
15pub enum Agent {
16 Claude,
17 Codex,
18 Cursor,
19 Gemini,
20 Opencode,
21}
22
23impl Agent {
24 pub const fn name(self) -> &'static str {
25 match self {
26 Self::Claude => "claude",
27 Self::Codex => "codex",
28 Self::Cursor => "cursor",
29 Self::Gemini => "gemini",
30 Self::Opencode => "opencode",
31 }
32 }
33
34 pub const fn default_config(self) -> &'static str {
35 match self {
36 Self::Claude => r#"{"hooks":{}}"#,
37 Self::Cursor => r#"{"version":1,"hooks":{}}"#,
38 Self::Codex => r#"{"hooks":{}}"#,
39 Self::Gemini => r#"{"hooks":{}}"#,
40 Self::Opencode => "{}",
41 }
42 }
43
44 pub const fn root_path(self) -> &'static [&'static str] {
45 match self {
46 Self::Claude => &[".claude"],
47 Self::Cursor => &[".cursor"],
48 Self::Codex => &[".codex"],
49 Self::Gemini => &[".gemini"],
50 Self::Opencode => &[".config", "opencode"],
51 }
52 }
53
54 pub const fn sessions_root_path(self) -> &'static [&'static str] {
55 match self {
56 Self::Claude => &[".claude", "projects"],
57 Self::Cursor => &[".cursor", "chats"],
58 Self::Codex => &[".codex", "sessions"],
59 Self::Gemini => Self::root_path(self),
60 Self::Opencode => Self::root_path(self),
61 }
62 }
63
64 pub const fn config_path(self) -> &'static [&'static str] {
65 match self {
66 Self::Claude => &[".claude", "settings.json"],
67 Self::Cursor => &[".cursor", "hooks.json"],
68 Self::Codex => &[".codex", "hooks.json"],
69 Self::Gemini => &[".gemini", "settings.json"],
70 Self::Opencode => &[".config", "opencode", "plugins", "agm.ts"],
71 }
72 }
73
74 pub const fn hook_events(self) -> &'static [(&'static str, AgentEventKind)] {
75 match self {
76 Self::Claude => &[
77 ("SessionStart", AgentEventKind::Start),
78 ("UserPromptSubmit", AgentEventKind::Busy),
79 ("Stop", AgentEventKind::Idle),
80 ("SessionEnd", AgentEventKind::Exit),
81 ],
82 Self::Cursor => &[
83 ("sessionStart", AgentEventKind::Start),
84 ("beforeSubmitPrompt", AgentEventKind::Busy),
85 ("stop", AgentEventKind::Idle),
86 ("sessionEnd", AgentEventKind::Exit),
87 ],
88 Self::Codex => &[
89 ("SessionStart", AgentEventKind::Start),
96 ("UserPromptSubmit", AgentEventKind::Busy),
97 ("PreToolUse", AgentEventKind::Busy),
98 ("Stop", AgentEventKind::Idle),
99 ],
100 Self::Gemini => &[
101 ("SessionStart", AgentEventKind::Start),
102 ("BeforeAgent", AgentEventKind::Busy),
103 ("BeforeModel", AgentEventKind::Busy),
104 ("BeforeTool", AgentEventKind::Busy),
105 ("AfterAgent", AgentEventKind::Idle),
106 ("SessionEnd", AgentEventKind::Exit),
107 ],
108 Self::Opencode => &[],
109 }
110 }
111
112 pub fn from_name(s: &str) -> Result<Self, ParseError> {
113 match s {
114 "claude" => Ok(Self::Claude),
115 "cursor" => Ok(Self::Cursor),
116 "codex" => Ok(Self::Codex),
117 "gemini" => Ok(Self::Gemini),
118 "opencode" => Ok(Self::Opencode),
119 _ => Err(ParseError::Invalid {
120 field: "agent",
121 value: format!("{s:?}"),
122 }),
123 }
124 }
125
126 pub fn hook_command(self, kind: AgentEventKind) -> String {
127 let pipe = format!(
128 "zellij pipe --name {AGENTS_PIPE} --args \"pane_id=$ZELLIJ_PANE_ID,agent={}\" -- {} >/dev/null 2>&1 || true",
129 self.name(),
130 kind.as_str()
131 );
132 let echo = if matches!(self, Self::Gemini) {
133 "; echo '{}'"
134 } else {
135 ""
136 };
137 format!("cat >/dev/null 2>&1 || true; {pipe}{echo}")
138 }
139
140 pub const fn priority(self) -> u8 {
141 match self {
142 Self::Claude => 0,
143 Self::Codex => 1,
144 Self::Cursor => 2,
145 Self::Gemini => 3,
146 Self::Opencode => 4,
147 }
148 }
149
150 pub fn detect(name: &str) -> Option<Self> {
151 let lower = name.to_ascii_lowercase();
152 if lower.contains("claude") {
153 Some(Self::Claude)
154 } else if lower.contains("cursor") {
155 Some(Self::Cursor)
156 } else if lower.contains("codex") {
157 Some(Self::Codex)
158 } else if lower.contains("gemini") {
159 Some(Self::Gemini)
160 } else if lower.contains("opencode") {
161 Some(Self::Opencode)
162 } else {
163 None
164 }
165 }
166}
167
168impl Display for Agent {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 let repr = match self {
171 Self::Claude => "Claude",
172 Self::Codex => "Codex",
173 Self::Cursor => "Cursor",
174 Self::Gemini => "Gemini",
175 Self::Opencode => "Opencode",
176 };
177 write!(f, "{}", repr)
178 }
179}
180
181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
182pub enum AgentEventKind {
183 Start,
184 Busy,
185 Idle,
186 Exit,
187}
188
189impl AgentEventKind {
190 pub const fn as_str(self) -> &'static str {
191 match self {
192 Self::Start => "start",
193 Self::Busy => "busy",
194 Self::Idle => "idle",
195 Self::Exit => "exit",
196 }
197 }
198
199 pub fn parse(s: &str) -> Result<Self, ParseError> {
200 match s.trim() {
201 "start" => Ok(Self::Start),
202 "busy" => Ok(Self::Busy),
203 "idle" => Ok(Self::Idle),
204 "exit" => Ok(Self::Exit),
205 _ => Err(ParseError::Invalid {
206 field: "event kind",
207 value: format!("{s:?}"),
208 }),
209 }
210 }
211}
212
213#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct AgentEventPayload {
215 pub pane_id: u32,
216 pub agent: Agent,
217 pub kind: AgentEventKind,
218}
219
220impl AgentEventPayload {
221 pub fn parse(pane_id: &str, agent: &str, payload: &str) -> Result<Self, ParseError> {
222 let pane_id = pane_id.trim().parse().map_err(|_| ParseError::Invalid {
223 field: "pane_id",
224 value: format!("{pane_id:?}"),
225 })?;
226 let agent = Agent::from_name(agent.trim())?;
227 let kind = AgentEventKind::parse(payload)?;
228 Ok(Self { pane_id, agent, kind })
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use rstest::rstest;
235
236 use super::*;
237
238 #[rstest]
239 #[case("claude", Ok(Agent::Claude))]
240 #[case("cursor", Ok(Agent::Cursor))]
241 #[case("codex", Ok(Agent::Codex))]
242 #[case("gemini", Ok(Agent::Gemini))]
243 #[case("opencode", Ok(Agent::Opencode))]
244 #[case("unknown", Err("invalid agent: \"unknown\"".to_string()))]
245 fn test_agent_from_name_works_as_expected(#[case] name: &str, #[case] expected: Result<Agent, String>) {
246 let actual = Agent::from_name(name).map_err(|e| e.to_string());
247 pretty_assertions::assert_eq!(actual, expected);
248 }
249
250 #[rstest]
251 #[case("Claude-3.5-Sonnet", Some(Agent::Claude))]
252 #[case("Cursor-IDE", Some(Agent::Cursor))]
253 #[case("GitHub-Codex", Some(Agent::Codex))]
254 #[case("Gemini-1.5-Pro", Some(Agent::Gemini))]
255 #[case("OpenCode-Agent", Some(Agent::Opencode))]
256 #[case("Vim", None)]
257 fn test_agent_detect_works_as_expected(#[case] name: &str, #[case] expected: Option<Agent>) {
258 pretty_assertions::assert_eq!(Agent::detect(name), expected);
259 }
260
261 #[rstest]
262 #[case(Agent::Claude)]
263 #[case(Agent::Cursor)]
264 #[case(Agent::Codex)]
265 #[case(Agent::Gemini)]
266 #[case(Agent::Opencode)]
267 fn test_hook_command_never_fails_when_zellij_unavailable(#[case] agent: Agent) {
268 let cmd = agent.hook_command(AgentEventKind::Busy);
269 assert2::assert!(cmd.contains("cat >/dev/null 2>&1 || true;"));
270 assert2::assert!(cmd.contains("zellij pipe --name agm-agent"));
271 assert2::assert!(cmd.contains(">/dev/null 2>&1 || true"));
272 }
273}