Skip to main content

mars_agents/target/
opencode.rs

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