1use std::path::Path;
2use std::path::PathBuf;
3
4use agm_core::agent::AGENTS_PIPE;
5use agm_core::agent::Agent;
6use owo_colors::OwoColorize as _;
7use rootcause::prelude::ResultExt;
8use serde_json::Value;
9
10const ZELLIJ_PLUGINS_PATH: &[&str] = &[".config", "zellij", "plugins"];
11const ZELLIJ_LAYOUTS_PATH: &[&str] = &[".config", "zellij", "layouts"];
12const WASM_FILENAME: &str = "agm-plugin.wasm";
13const INSTALL_NAME: &str = "agm.wasm";
14const LAYOUT_FILENAME: &str = "agm.kdl";
15
16pub fn install_plugin_and_hooks(is_debug: bool) -> rootcause::Result<()> {
17 let wasm_path = build_wasm(is_debug).context("failed to build wasm plugin")?;
18 install_wasm(&wasm_path).context("failed to install wasm plugin")?;
19 install_layout().context("failed to install zellij layout")?;
20 install_hooks(Agent::Claude).context("failed to install Claude hooks")?;
21 install_hooks(Agent::Cursor).context("failed to install Cursor hooks")?;
22 install_hooks(Agent::Codex).context("failed to install Codex hooks")?;
23 install_hooks(Agent::Gemini).context("failed to install Gemini hooks")?;
24 install_opencode_plugin().context("failed to install Opencode hooks")?;
25 Ok(())
26}
27
28fn build_wasm(is_debug: bool) -> rootcause::Result<PathBuf> {
29 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
30 let plugin_dir = manifest_dir.join("plugin");
31 let workspace_target = manifest_dir
32 .parent()
33 .and_then(Path::parent)
34 .ok_or_else(|| rootcause::report!("cannot resolve workspace target from CARGO_MANIFEST_DIR"))?
35 .join("target");
36 let wasm_target = workspace_target.join("wasm-plugins");
37
38 let target = "wasm32-wasip1";
39
40 ytil_cmd::silent_cmd("rustup")
41 .args(["target", "add", target])
42 .status()
43 .context("failed to spawn rustup command")
44 .attach_with(|| format!("target={target}"))?
45 .exit_ok()
46 .context("failed to add wasm32-wasip1 target")
47 .attach_with(|| format!("target={target}"))?;
48
49 let mut cmd = ytil_cmd::silent_cmd("cargo");
50 cmd.args(["build", "--target", target]);
51 cmd.current_dir(&plugin_dir);
52 cmd.env("CARGO_TARGET_DIR", &wasm_target);
53 if !is_debug {
54 cmd.arg("--release");
55 }
56 cmd.status()
57 .context("failed to spawn cargo build command")
58 .attach_with(|| format!("target={target}"))?
59 .exit_ok()
60 .context("failed to build wasm plugin")
61 .attach_with(|| format!("target={target}"))
62 .attach_with(|| format!("plugin_dir={}", plugin_dir.display()))?;
63
64 let profile = if is_debug { "debug" } else { "release" };
65 Ok(wasm_target.join("wasm32-wasip1").join(profile).join(WASM_FILENAME))
66}
67
68fn install_wasm(built: &Path) -> rootcause::Result<()> {
69 let install_dir = ytil_sys::dir::build_home_path(ZELLIJ_PLUGINS_PATH)
70 .context("failed to determine zellij plugins directory")
71 .attach_with(|| format!("plugins_path={ZELLIJ_PLUGINS_PATH:?}"))?;
72
73 std::fs::create_dir_all(&install_dir)
74 .context("failed to create install directory")
75 .attach_with(|| format!("install_dir={}", install_dir.display()))?;
76
77 let dest = install_dir.join(INSTALL_NAME);
78 ytil_sys::file::atomic_cp(built, &dest)
79 .context("failed to copy wasm plugin to install location")
80 .attach_with(|| format!("from={}", built.display()))
81 .attach_with(|| format!("to={}", dest.display()))?;
82
83 println!("{} {}", "Installed".green().bold(), dest.display());
84 Ok(())
85}
86
87fn install_layout() -> rootcause::Result<()> {
88 let install_dir = ytil_sys::dir::build_home_path(ZELLIJ_LAYOUTS_PATH)
89 .context("failed to determine zellij layouts directory")
90 .attach_with(|| format!("layouts_path={ZELLIJ_LAYOUTS_PATH:?}"))?;
91
92 std::fs::create_dir_all(&install_dir)
93 .context("failed to create zellij layouts directory")
94 .attach_with(|| format!("install_dir={}", install_dir.display()))?;
95
96 let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
97 .join("assets")
98 .join("zellij")
99 .join(LAYOUT_FILENAME);
100 let dest = install_dir.join(LAYOUT_FILENAME);
101 ytil_sys::file::atomic_cp(&source, &dest)
102 .context("failed to copy agm layout to install location")
103 .attach_with(|| format!("from={}", source.display()))
104 .attach_with(|| format!("to={}", dest.display()))?;
105
106 println!("{} {}", "Installed".green().bold(), dest.display());
107 Ok(())
108}
109
110fn read_hooks_json_or_default(path: &Path, agent: Agent) -> rootcause::Result<Value> {
112 if path.exists() {
113 let raw = std::fs::read_to_string(path).context("failed to read config file")?;
114 let doc: Value = serde_json::from_str(&raw).context("failed to parse config file")?;
115 return Ok(doc);
116 }
117
118 let Some(parent) = path.parent() else {
119 return Err(rootcause::report!(
120 "hook config path has no parent directory: {}",
121 path.display()
122 ));
123 };
124
125 std::fs::create_dir_all(parent).context("failed to create agent config directory")?;
126
127 let doc: Value = serde_json::from_str(agent.default_config()).context("failed to parse default config")?;
128
129 Ok(doc)
130}
131
132fn install_hooks(agent: Agent) -> rootcause::Result<()> {
133 let config = agent.config_path();
134 if config.is_empty() {
135 print_skipped(agent);
136 return Ok(());
137 }
138
139 let Ok(path) = ytil_sys::dir::build_home_path(config).attach_with(|| format!("agent={}", agent.name())) else {
140 print_skipped(agent);
141 return Ok(());
142 };
143
144 let mut doc = read_hooks_json_or_default(&path, agent)
145 .attach_with(|| format!("path={}", path.display()))
146 .attach_with(|| format!("agent={}", agent.name()))?;
147
148 let hooks = doc
149 .as_object_mut()
150 .ok_or_else(|| rootcause::report!("{} root is not an object", path.display()))?
151 .entry("hooks")
152 .or_insert_with(|| serde_json::json!({}))
153 .as_object_mut()
154 .ok_or_else(|| rootcause::report!("{} hooks is not an object", path.display()))?;
155
156 remove_all_agm_entries(agent, hooks);
157
158 for &(event, payload) in agent.hook_events() {
159 let cmd = agent.hook_command(payload);
160 let event_arr = hooks
161 .entry(event)
162 .or_insert_with(|| serde_json::json!([]))
163 .as_array_mut()
164 .ok_or_else(|| rootcause::report!("hooks.{event} is not an array"))?;
165
166 remove_agm_entries(agent, event_arr);
167 event_arr.push(new_hook_entry(agent, &cmd));
168 }
169
170 let out =
171 serde_json::to_string_pretty(&doc).context(format!("failed to serialize config for {}", agent.name()))? + "\n";
172 std::fs::write(&path, out)
173 .context("failed to write config file")
174 .attach_with(|| format!("path={}", path.display()))
175 .attach_with(|| format!("agent={}", agent.name()))?;
176
177 println!(
178 "{} {} hooks in {}",
179 "Installed".green().bold(),
180 agent.name(),
181 path.display()
182 );
183
184 Ok(())
185}
186
187fn install_opencode_plugin() -> rootcause::Result<()> {
188 let config_path = Agent::Opencode.config_path();
189 let Ok(path) = ytil_sys::dir::build_home_path(config_path).attach("agent=opencode") else {
190 print_skipped(Agent::Opencode);
191 return Ok(());
192 };
193
194 let Some(dir) = path.parent() else {
195 print_skipped(Agent::Opencode);
196 return Ok(());
197 };
198
199 std::fs::create_dir_all(dir)
200 .context("failed to create opencode plugins directory")
201 .attach_with(|| format!("dir={}", dir.display()))?;
202
203 let template = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
204 .join("assets")
205 .join("opencode")
206 .join("agm.ts");
207 ytil_sys::file::atomic_cp(&template, &path)
208 .context("failed to copy opencode plugin file")
209 .attach_with(|| format!("from={}", template.display()))
210 .attach_with(|| format!("path={}", path.display()))?;
211
212 println!("{} opencode plugin in {}", "Installed".green().bold(), path.display());
213 Ok(())
214}
215
216fn print_skipped(agent: Agent) {
217 println!(
218 "{} {} hooks — config not found",
219 "Skipped".yellow().bold(),
220 agent.name(),
221 );
222}
223
224fn remove_agm_entries(agent: Agent, arr: &mut Vec<Value>) {
225 match agent {
226 Agent::Claude | Agent::Codex | Agent::Gemini => arr.retain(|group| {
227 !group
228 .get("hooks")
229 .and_then(|hooks| hooks.as_array())
230 .is_some_and(|hooks| {
231 hooks.iter().any(|hook| {
232 hook.get("command")
233 .and_then(|c| c.as_str())
234 .is_some_and(|c| c.contains(AGENTS_PIPE))
235 })
236 })
237 }),
238 Agent::Cursor => arr.retain(|entry| {
239 !entry
240 .get("command")
241 .and_then(|c| c.as_str())
242 .is_some_and(|c| c.contains(AGENTS_PIPE))
243 }),
244 Agent::Opencode => {}
245 }
246}
247
248fn remove_all_agm_entries(agent: Agent, hooks: &mut serde_json::Map<String, Value>) {
249 let empty_events: Vec<String> = hooks
250 .iter_mut()
251 .filter_map(|(event, value)| {
252 let arr = value.as_array_mut()?;
253 remove_agm_entries(agent, arr);
254 arr.is_empty().then(|| event.clone())
255 })
256 .collect();
257
258 for event in empty_events {
259 hooks.remove(&event);
260 }
261}
262
263fn new_hook_entry(agent: Agent, cmd: &str) -> Value {
264 match agent {
265 Agent::Claude | Agent::Codex | Agent::Gemini => serde_json::json!({
266 "hooks": [{ "type": "command", "command": cmd }]
267 }),
268 Agent::Cursor => serde_json::json!({ "command": cmd }),
269 Agent::Opencode => serde_json::json!({}),
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use agm_core::agent::AgentEventKind;
276
277 use super::*;
278
279 #[test]
280 fn test_remove_all_agm_entries_removes_stale_codex_events() {
281 let mut hooks = serde_json::json!({
282 "PreToolUse": [
283 new_hook_entry(Agent::Codex, &Agent::Codex.hook_command(AgentEventKind::Busy)),
284 {
285 "hooks": [{ "type": "command", "command": "echo keep-me" }]
286 }
287 ],
288 "SessionEnd": [new_hook_entry(Agent::Codex, &Agent::Codex.hook_command(AgentEventKind::Exit))],
289 "UserPromptSubmit": [new_hook_entry(Agent::Codex, &Agent::Codex.hook_command(AgentEventKind::Busy))]
290 });
291
292 remove_all_agm_entries(Agent::Codex, hooks.as_object_mut().unwrap());
293
294 let expected = serde_json::json!({
295 "PreToolUse": [
296 {
297 "hooks": [{ "type": "command", "command": "echo keep-me" }]
298 }
299 ]
300 });
301
302 assert_eq!(hooks, expected);
303 }
304}