Skip to main content

agm/cmd/
install.rs

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
110/// Load the JSON hook config at `path`, or create it from [`Agent::default_config`] when missing.
111fn 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}