Skip to main content

mars_agents/target/
opencode.rs

1/// `.opencode` target adapter.
2///
3/// Handles MCP server registration and hook binding for the OpenCode harness.
4///
5/// OpenCode-native lowering:
6/// - MCP: writes to `opencode.json` (mcpServers section), env vars as plain name map
7/// - Hooks: writes to `opencode.json` (hooks section with plugin hook format)
8use std::path::{Path, PathBuf};
9
10use crate::error::MarsError;
11use crate::lock::ItemKind;
12use crate::types::DestPath;
13
14use super::{ConfigEntry, HookEntry, McpServerEntry, TargetAdapter, hook_command};
15
16#[derive(Debug)]
17pub struct OpencodeAdapter;
18
19impl TargetAdapter for OpencodeAdapter {
20    fn name(&self) -> &str {
21        ".opencode"
22    }
23
24    fn skill_variant_key(&self) -> Option<&str> {
25        Some("opencode")
26    }
27
28    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
29        match kind {
30            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
31            _ => None,
32        }
33    }
34
35    fn write_config_entries(
36        &self,
37        entries: &[ConfigEntry],
38        target_dir: &Path,
39    ) -> Result<Vec<PathBuf>, MarsError> {
40        let mcp_servers: Vec<&McpServerEntry> = entries
41            .iter()
42            .filter_map(|e| {
43                if let ConfigEntry::McpServer(s) = e {
44                    Some(s)
45                } else {
46                    None
47                }
48            })
49            .collect();
50
51        let hooks: Vec<&HookEntry> = entries
52            .iter()
53            .filter_map(|e| {
54                if let ConfigEntry::Hook(h) = e {
55                    Some(h)
56                } else {
57                    None
58                }
59            })
60            .collect();
61
62        if mcp_servers.is_empty() && hooks.is_empty() {
63            return Ok(Vec::new());
64        }
65
66        // OpenCode merges both into a single config file.
67        let path = write_opencode_config(target_dir, &mcp_servers, &hooks)?;
68        Ok(vec![path])
69    }
70
71    fn remove_config_entries(
72        &self,
73        entry_keys: &[String],
74        target_dir: &Path,
75    ) -> Result<(), MarsError> {
76        remove_opencode_entries(entry_keys, target_dir)
77    }
78}
79
80// ---------------------------------------------------------------------------
81// OpenCode config — `opencode.json` format
82// ---------------------------------------------------------------------------
83//
84// OpenCode uses a single config file with both MCP and hooks:
85// {
86//   "mcpServers": {
87//     "server-name": {
88//       "command": "...",
89//       "args": [...],
90//       "env": { "KEY": "VAR_NAME" }   ← plain var name, no interpolation
91//     }
92//   },
93//   "hooks": {
94//     "session:start": ["bash /path/to/script.sh"],
95//     "tool:before": [...]
96//   }
97// }
98
99fn write_opencode_config(
100    target_dir: &Path,
101    servers: &[&McpServerEntry],
102    hooks: &[&HookEntry],
103) -> Result<PathBuf, MarsError> {
104    let path = target_dir.join("opencode.json");
105
106    let mut root: serde_json::Value = if path.is_file() {
107        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
108        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
109    } else {
110        serde_json::json!({})
111    };
112
113    let root_obj = root.as_object_mut().ok_or_else(|| {
114        MarsError::Config(crate::error::ConfigError::Invalid {
115            message: format!("{} is not a JSON object", path.display()),
116        })
117    })?;
118
119    // MCP servers
120    if !servers.is_empty() {
121        let mcp_obj = root_obj
122            .entry("mcpServers")
123            .or_insert_with(|| serde_json::json!({}));
124        let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
125            MarsError::Config(crate::error::ConfigError::Invalid {
126                message: format!("{}: mcpServers is not an object", path.display()),
127            })
128        })?;
129
130        for server in servers {
131            let mut entry = serde_json::json!({
132                "command": server.command,
133                "args": server.args,
134            });
135
136            // OpenCode: env as plain name map (no interpolation)
137            if !server.env.is_empty() {
138                let env_obj: serde_json::Map<String, serde_json::Value> = server
139                    .env
140                    .iter()
141                    .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
142                    .collect();
143                entry["env"] = serde_json::Value::Object(env_obj);
144            }
145
146            mcp_map.insert(server.name.clone(), entry);
147        }
148    }
149
150    // Hooks
151    if !hooks.is_empty() {
152        let hooks_obj = root_obj
153            .entry("hooks")
154            .or_insert_with(|| serde_json::json!({}));
155        let hooks_map = hooks_obj.as_object_mut().ok_or_else(|| {
156            MarsError::Config(crate::error::ConfigError::Invalid {
157                message: format!("{}: hooks is not an object", path.display()),
158            })
159        })?;
160
161        for hook in hooks {
162            let command = hook_command(&hook.script_path);
163            let native_event = hook.native_event.clone();
164            let event_hooks = hooks_map
165                .entry(native_event.clone())
166                .or_insert_with(|| serde_json::json!([]))
167                .as_array_mut()
168                .ok_or_else(|| {
169                    MarsError::Config(crate::error::ConfigError::Invalid {
170                        message: format!(
171                            "{}: hooks.{native_event} is not an array",
172                            path.display()
173                        ),
174                    })
175                })?;
176            remove_managed_hook_commands(event_hooks, &hook.name);
177            event_hooks.push(serde_json::Value::String(command));
178        }
179    }
180
181    let content = serde_json::to_string_pretty(&root).map_err(|e| {
182        MarsError::Config(crate::error::ConfigError::Invalid {
183            message: format!("failed to serialize {}: {e}", path.display()),
184        })
185    })?;
186    crate::fs::atomic_write(&path, content.as_bytes())?;
187
188    Ok(path)
189}
190
191fn remove_managed_hook_commands(commands: &mut Vec<serde_json::Value>, hook_name: &str) {
192    commands.retain(|cmd| {
193        cmd.as_str()
194            .map(|cmd| !is_managed_hook_command_for(cmd, hook_name))
195            .unwrap_or(true)
196    });
197}
198
199fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
200    let normalized = command.replace('\\', "/").replace("//", "/");
201    normalized.contains(&format!("/hooks/{hook_name}/"))
202}
203
204fn remove_opencode_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
205    let path = target_dir.join("opencode.json");
206    if !path.is_file() {
207        return Ok(());
208    }
209
210    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
211    let mut root: serde_json::Value =
212        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
213
214    let root_obj = match root.as_object_mut() {
215        Some(o) => o,
216        None => return Ok(()),
217    };
218
219    // Remove MCP entries
220    if let Some(mcp_map) = root_obj
221        .get_mut("mcpServers")
222        .and_then(|v| v.as_object_mut())
223    {
224        for key in entry_keys {
225            if let Some(name) = key.strip_prefix("mcp:") {
226                mcp_map.remove(name);
227            }
228        }
229    }
230
231    // Remove hook entries
232    let hook_keys: Vec<(String, &str)> = entry_keys
233        .iter()
234        .filter_map(|k| {
235            let rest = k.strip_prefix("hook:")?;
236            let (event, name) = rest.split_once(':')?;
237            Some((opencode_hook_event(event)?.to_string(), name))
238        })
239        .collect();
240
241    if !hook_keys.is_empty()
242        && let Some(hooks_map) = root_obj.get_mut("hooks").and_then(|v| v.as_object_mut())
243    {
244        for (event, name) in &hook_keys {
245            if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
246                arr.retain(|cmd| {
247                    let cmd_str = cmd.as_str().unwrap_or("");
248                    // Exact path-segment match to avoid partial name collisions.
249                    !is_managed_hook_command_for(cmd_str, name)
250                });
251            }
252        }
253    }
254
255    let content = serde_json::to_string_pretty(&root).map_err(|e| {
256        MarsError::Config(crate::error::ConfigError::Invalid {
257            message: format!("failed to serialize {}: {e}", path.display()),
258        })
259    })?;
260    crate::fs::atomic_write(&path, content.as_bytes())?;
261    Ok(())
262}
263
264fn opencode_hook_event(event: &str) -> Option<&'static str> {
265    match event {
266        "session.start" => Some("session:start"),
267        "session.end" => Some("session:end"),
268        "tool.pre" => Some("tool:before"),
269        "tool.post" => Some("tool:after"),
270        _ => None,
271    }
272}
273
274// ---------------------------------------------------------------------------
275// Tests
276// ---------------------------------------------------------------------------
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use indexmap::IndexMap;
282    use tempfile::TempDir;
283
284    fn make_mcp_entry(name: &str) -> ConfigEntry {
285        let mut env = IndexMap::new();
286        env.insert("TOKEN".to_string(), "MY_TOKEN".to_string());
287        ConfigEntry::McpServer(McpServerEntry {
288            name: name.to_string(),
289            command: "node".to_string(),
290            args: vec![],
291            env,
292        })
293    }
294
295    fn make_hook_entry(name: &str, native: &str) -> ConfigEntry {
296        ConfigEntry::Hook(HookEntry {
297            name: name.to_string(),
298            event: "tool.pre".to_string(),
299            native_event: native.to_string(),
300            script_path: format!("/hooks/{name}/run.sh"),
301            order: 0,
302        })
303    }
304
305    fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
306        ConfigEntry::Hook(HookEntry {
307            name: name.to_string(),
308            event: "tool.pre".to_string(),
309            native_event: native.to_string(),
310            script_path: script_path.to_string(),
311            order: 0,
312        })
313    }
314
315    #[test]
316    fn write_config_entries_creates_opencode_json() {
317        let tmp = TempDir::new().unwrap();
318        let adapter = OpencodeAdapter;
319        let entries = vec![make_mcp_entry("context7")];
320        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
321        assert_eq!(written.len(), 1);
322        assert!(tmp.path().join("opencode.json").exists());
323
324        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
325        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
326        assert!(json["mcpServers"]["context7"].is_object());
327    }
328
329    #[test]
330    fn write_mcp_env_as_plain_name_map() {
331        let tmp = TempDir::new().unwrap();
332        let adapter = OpencodeAdapter;
333        let entries = vec![make_mcp_entry("server")];
334        adapter.write_config_entries(&entries, tmp.path()).unwrap();
335
336        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
337        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
338        // OpenCode: env is a plain name map (not interpolated)
339        assert_eq!(json["mcpServers"]["server"]["env"]["TOKEN"], "MY_TOKEN");
340    }
341
342    #[test]
343    fn write_hooks_into_same_file() {
344        let tmp = TempDir::new().unwrap();
345        let adapter = OpencodeAdapter;
346        let entries = vec![
347            make_mcp_entry("ctx"),
348            make_hook_entry("audit", "tool:before"),
349        ];
350        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
351        // Both written to a single file.
352        assert_eq!(written.len(), 1);
353
354        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
355        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
356        assert!(json["mcpServers"]["ctx"].is_object());
357        assert!(json["hooks"]["tool:before"].is_array());
358    }
359
360    #[test]
361    fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
362        let tmp = TempDir::new().unwrap();
363        let adapter = OpencodeAdapter;
364        adapter
365            .write_config_entries(
366                &[make_hook_entry_with_path(
367                    "audit",
368                    "tool:before",
369                    "/old/hooks/audit/run.sh",
370                )],
371                tmp.path(),
372            )
373            .unwrap();
374        adapter
375            .write_config_entries(
376                &[make_hook_entry_with_path(
377                    "audit",
378                    "tool:before",
379                    "/new/hooks/audit/run.sh",
380                )],
381                tmp.path(),
382            )
383            .unwrap();
384
385        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
386        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
387        let hooks = json["hooks"]["tool:before"].as_array().unwrap();
388        assert_eq!(hooks.len(), 1);
389        assert!(hooks[0].as_str().unwrap().contains("/new/hooks/audit/"));
390    }
391
392    #[test]
393    fn remove_entries_removes_mcp_and_hooks() {
394        let tmp = TempDir::new().unwrap();
395        let adapter = OpencodeAdapter;
396        let entries = vec![make_mcp_entry("to-remove"), make_mcp_entry("to-keep")];
397        adapter.write_config_entries(&entries, tmp.path()).unwrap();
398
399        adapter
400            .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
401            .unwrap();
402
403        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
404        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
405        assert!(json["mcpServers"]["to-remove"].is_null());
406        assert!(json["mcpServers"]["to-keep"].is_object());
407    }
408}