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` (`mcp` 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::compiler::mcp::{HeaderValue, McpTransport};
11use crate::error::MarsError;
12use crate::lock::ItemKind;
13use crate::types::DestPath;
14
15use super::{ConfigEntry, HookEntry, McpServerEntry, TargetAdapter, hook_command};
16
17#[derive(Debug)]
18pub struct OpencodeAdapter;
19
20impl TargetAdapter for OpencodeAdapter {
21    fn name(&self) -> &str {
22        ".opencode"
23    }
24
25    fn skill_variant_key(&self) -> Option<&str> {
26        Some("opencode")
27    }
28
29    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
30        match kind {
31            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
32            _ => None,
33        }
34    }
35
36    fn write_config_entries(
37        &self,
38        entries: &[ConfigEntry],
39        target_dir: &Path,
40    ) -> Result<Vec<PathBuf>, MarsError> {
41        let mcp_servers: Vec<&McpServerEntry> = entries
42            .iter()
43            .filter_map(|e| {
44                if let ConfigEntry::McpServer(s) = e {
45                    Some(s)
46                } else {
47                    None
48                }
49            })
50            .collect();
51
52        let hooks: Vec<&HookEntry> = entries
53            .iter()
54            .filter_map(|e| {
55                if let ConfigEntry::Hook(h) = e {
56                    Some(h)
57                } else {
58                    None
59                }
60            })
61            .collect();
62
63        if mcp_servers.is_empty() && hooks.is_empty() {
64            return Ok(Vec::new());
65        }
66
67        // OpenCode merges both into a single config file.
68        let path = write_opencode_config(target_dir, &mcp_servers, &hooks)?;
69        Ok(vec![path])
70    }
71
72    fn remove_config_entries(
73        &self,
74        entry_keys: &[String],
75        target_dir: &Path,
76    ) -> Result<(), MarsError> {
77        remove_opencode_entries(entry_keys, target_dir)
78    }
79}
80
81// ---------------------------------------------------------------------------
82// OpenCode config — `opencode.json` format
83// ---------------------------------------------------------------------------
84//
85// OpenCode uses a single config file with both MCP and hooks:
86// {
87//   "mcp": {
88//     "server-name": {
89//       "type": "local",
90//       "command": ["npx", "-y", "server-package"],
91//       "environment": { "KEY": "VAR_NAME" }   ← plain var name, no interpolation
92//     }
93//   },
94//   "hooks": {
95//     "session:start": ["bash /path/to/script.sh"],
96//     "tool:before": [...]
97//   }
98// }
99
100fn write_opencode_config(
101    target_dir: &Path,
102    servers: &[&McpServerEntry],
103    hooks: &[&HookEntry],
104) -> Result<PathBuf, MarsError> {
105    let path = target_dir.join("opencode.json");
106
107    let mut root: serde_json::Value = if path.is_file() {
108        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
109        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
110    } else {
111        serde_json::json!({})
112    };
113
114    let root_obj = root.as_object_mut().ok_or_else(|| {
115        MarsError::Config(crate::error::ConfigError::Invalid {
116            message: format!("{} is not a JSON object", path.display()),
117        })
118    })?;
119
120    migrate_legacy_mcp_servers(root_obj);
121
122    // MCP servers
123    if !servers.is_empty() {
124        let mcp_obj = root_obj
125            .entry("mcp")
126            .or_insert_with(|| serde_json::json!({}));
127        let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
128            MarsError::Config(crate::error::ConfigError::Invalid {
129                message: format!("{}: mcp is not an object", path.display()),
130            })
131        })?;
132
133        for server in servers {
134            let mut entry = match server.transport {
135                McpTransport::Stdio => {
136                    let mut command = Vec::with_capacity(server.args.len() + 1);
137                    if let Some(command_name) = server.command.as_ref() {
138                        command.push(serde_json::Value::String(command_name.clone()));
139                    }
140                    command.extend(server.args.iter().cloned().map(serde_json::Value::String));
141                    serde_json::json!({
142                        "type": "local",
143                        "command": command,
144                    })
145                }
146                McpTransport::Http => serde_json::json!({
147                    "type": "remote",
148                    "url": server.url,
149                }),
150            };
151
152            // OpenCode: env as plain name map (no interpolation)
153            if !server.env.is_empty() {
154                let env_obj: serde_json::Map<String, serde_json::Value> = server
155                    .env
156                    .iter()
157                    .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
158                    .collect();
159                entry["environment"] = serde_json::Value::Object(env_obj);
160            }
161
162            if !server.headers.is_empty() {
163                let headers_obj: serde_json::Map<String, serde_json::Value> = server
164                    .headers
165                    .iter()
166                    .map(|(k, v)| {
167                        let value = match v {
168                            HeaderValue::EnvRef(env_ref) => {
169                                serde_json::Value::String(env_ref.var_name().to_string())
170                            }
171                            HeaderValue::Plain(plain) => serde_json::Value::String(plain.clone()),
172                        };
173                        (k.clone(), value)
174                    })
175                    .collect();
176                entry["headers"] = serde_json::Value::Object(headers_obj);
177            }
178
179            mcp_map.insert(server.name.clone(), entry);
180        }
181    }
182
183    // Hooks
184    if !hooks.is_empty() {
185        let hooks_obj = root_obj
186            .entry("hooks")
187            .or_insert_with(|| serde_json::json!({}));
188        let hooks_map = hooks_obj.as_object_mut().ok_or_else(|| {
189            MarsError::Config(crate::error::ConfigError::Invalid {
190                message: format!("{}: hooks is not an object", path.display()),
191            })
192        })?;
193
194        for hook in hooks {
195            let command = hook_command(&hook.script_path);
196            let native_event = hook.native_event.clone();
197            let event_hooks = hooks_map
198                .entry(native_event.clone())
199                .or_insert_with(|| serde_json::json!([]))
200                .as_array_mut()
201                .ok_or_else(|| {
202                    MarsError::Config(crate::error::ConfigError::Invalid {
203                        message: format!(
204                            "{}: hooks.{native_event} is not an array",
205                            path.display()
206                        ),
207                    })
208                })?;
209            remove_managed_hook_commands(event_hooks, &hook.name);
210            event_hooks.push(serde_json::Value::String(command));
211        }
212    }
213
214    let content = serde_json::to_string_pretty(&root).map_err(|e| {
215        MarsError::Config(crate::error::ConfigError::Invalid {
216            message: format!("failed to serialize {}: {e}", path.display()),
217        })
218    })?;
219    crate::fs::atomic_write(&path, content.as_bytes())?;
220
221    Ok(path)
222}
223
224fn remove_managed_hook_commands(commands: &mut Vec<serde_json::Value>, hook_name: &str) {
225    commands.retain(|cmd| {
226        cmd.as_str()
227            .map(|cmd| !is_managed_hook_command_for(cmd, hook_name))
228            .unwrap_or(true)
229    });
230}
231
232fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
233    let normalized = command.replace('\\', "/").replace("//", "/");
234    normalized.contains(&format!("/hooks/{hook_name}/"))
235}
236
237fn remove_opencode_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
238    let path = target_dir.join("opencode.json");
239    if !path.is_file() {
240        return Ok(());
241    }
242
243    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
244    let mut root: serde_json::Value =
245        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
246
247    let root_obj = match root.as_object_mut() {
248        Some(o) => o,
249        None => return Ok(()),
250    };
251    migrate_legacy_mcp_servers(root_obj);
252
253    // Remove MCP entries
254    if let Some(mcp_map) = root_obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
255        for key in entry_keys {
256            if let Some(name) = key.strip_prefix("mcp:") {
257                mcp_map.remove(name);
258            }
259        }
260    }
261
262    // Remove hook entries
263    let hook_keys: Vec<(String, &str)> = entry_keys
264        .iter()
265        .filter_map(|k| {
266            let rest = k.strip_prefix("hook:")?;
267            let (event, name) = rest.split_once(':')?;
268            Some((opencode_hook_event(event)?.to_string(), name))
269        })
270        .collect();
271
272    if !hook_keys.is_empty()
273        && let Some(hooks_map) = root_obj.get_mut("hooks").and_then(|v| v.as_object_mut())
274    {
275        for (event, name) in &hook_keys {
276            if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
277                arr.retain(|cmd| {
278                    let cmd_str = cmd.as_str().unwrap_or("");
279                    // Exact path-segment match to avoid partial name collisions.
280                    !is_managed_hook_command_for(cmd_str, name)
281                });
282            }
283        }
284    }
285
286    let content = serde_json::to_string_pretty(&root).map_err(|e| {
287        MarsError::Config(crate::error::ConfigError::Invalid {
288            message: format!("failed to serialize {}: {e}", path.display()),
289        })
290    })?;
291    crate::fs::atomic_write(&path, content.as_bytes())?;
292    Ok(())
293}
294
295fn migrate_legacy_mcp_servers(root_obj: &mut serde_json::Map<String, serde_json::Value>) {
296    if root_obj.contains_key("mcp") {
297        return;
298    }
299
300    let Some(serde_json::Value::Object(legacy_mcp)) = root_obj.remove("mcpServers") else {
301        return;
302    };
303
304    let migrated = legacy_mcp
305        .iter()
306        .map(|(name, entry)| (name.clone(), migrate_legacy_server_entry(entry)))
307        .collect();
308    root_obj.insert("mcp".to_string(), serde_json::Value::Object(migrated));
309}
310
311fn migrate_legacy_server_entry(entry: &serde_json::Value) -> serde_json::Value {
312    let Some(obj) = entry.as_object() else {
313        return serde_json::json!({
314            "type": "local",
315            "command": [],
316        });
317    };
318
319    let mut command = Vec::new();
320    if let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) {
321        command.push(serde_json::Value::String(cmd.to_string()));
322    }
323    if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
324        command.extend(
325            args.iter()
326                .filter_map(|v| v.as_str().map(|s| serde_json::Value::String(s.to_string()))),
327        );
328    }
329
330    let mut migrated = serde_json::Map::new();
331    migrated.insert(
332        "type".to_string(),
333        serde_json::Value::String("local".to_string()),
334    );
335    migrated.insert("command".to_string(), serde_json::Value::Array(command));
336
337    if let Some(env_obj) = obj.get("env").and_then(|v| v.as_object()) {
338        migrated.insert(
339            "environment".to_string(),
340            serde_json::Value::Object(env_obj.clone()),
341        );
342    }
343
344    serde_json::Value::Object(migrated)
345}
346
347fn opencode_hook_event(event: &str) -> Option<&'static str> {
348    match event {
349        "session.start" => Some("session:start"),
350        "session.end" => Some("session:end"),
351        "tool.pre" => Some("tool:before"),
352        "tool.post" => Some("tool:after"),
353        _ => None,
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Tests
359// ---------------------------------------------------------------------------
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use indexmap::IndexMap;
365    use tempfile::TempDir;
366
367    fn make_stdio_mcp_entry(name: &str) -> ConfigEntry {
368        let mut env = IndexMap::new();
369        env.insert("TOKEN".to_string(), "MY_TOKEN".to_string());
370        ConfigEntry::McpServer(McpServerEntry {
371            name: name.to_string(),
372            transport: McpTransport::Stdio,
373            command: Some("node".to_string()),
374            args: vec!["server.js".to_string()],
375            env,
376            url: None,
377            headers: IndexMap::new(),
378        })
379    }
380
381    fn make_http_mcp_entry(name: &str) -> ConfigEntry {
382        let mut headers = IndexMap::new();
383        headers.insert(
384            "Authorization".to_string(),
385            HeaderValue::EnvRef(crate::compiler::mcp::EnvRef::Env {
386                var: "API_TOKEN".to_string(),
387            }),
388        );
389        headers.insert(
390            "X-Custom".to_string(),
391            HeaderValue::Plain("static-value".to_string()),
392        );
393        ConfigEntry::McpServer(McpServerEntry {
394            name: name.to_string(),
395            transport: McpTransport::Http,
396            command: None,
397            args: vec![],
398            env: IndexMap::new(),
399            url: Some("https://api.example.com/mcp".to_string()),
400            headers,
401        })
402    }
403
404    fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
405        ConfigEntry::Hook(HookEntry {
406            name: name.to_string(),
407            event: "tool.pre".to_string(),
408            native_event: native.to_string(),
409            script_path: script_path.to_string(),
410            order: 0,
411        })
412    }
413
414    #[test]
415    fn write_config_entries_merges_mcp_and_hooks_into_single_file() {
416        let tmp = TempDir::new().unwrap();
417        let adapter = OpencodeAdapter;
418        let written = adapter
419            .write_config_entries(
420                &[
421                    make_stdio_mcp_entry("local-server"),
422                    make_http_mcp_entry("remote-server"),
423                    make_hook_entry_with_path("audit", "tool:before", "/hooks/audit/run.sh"),
424                ],
425                tmp.path(),
426            )
427            .unwrap();
428
429        assert_eq!(written.len(), 1);
430        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
431        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
432
433        let local = &json["mcp"]["local-server"];
434        assert_eq!(local["type"], "local");
435        assert_eq!(local["command"][0], "node");
436        assert_eq!(local["command"][1], "server.js");
437        assert_eq!(local["environment"]["TOKEN"], "MY_TOKEN");
438
439        let remote = &json["mcp"]["remote-server"];
440        assert_eq!(remote["type"], "remote");
441        assert_eq!(remote["url"], "https://api.example.com/mcp");
442        assert_eq!(remote["headers"]["Authorization"], "API_TOKEN");
443        assert_eq!(remote["headers"]["X-Custom"], "static-value");
444        assert!(remote["command"].is_null());
445
446        assert!(json["hooks"]["tool:before"].is_array());
447    }
448
449    #[test]
450    fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
451        let tmp = TempDir::new().unwrap();
452        let adapter = OpencodeAdapter;
453        adapter
454            .write_config_entries(
455                &[make_hook_entry_with_path(
456                    "audit",
457                    "tool:before",
458                    "/old/hooks/audit/run.sh",
459                )],
460                tmp.path(),
461            )
462            .unwrap();
463        adapter
464            .write_config_entries(
465                &[make_hook_entry_with_path(
466                    "audit",
467                    "tool:before",
468                    "/new/hooks/audit/run.sh",
469                )],
470                tmp.path(),
471            )
472            .unwrap();
473
474        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
475        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
476        let hooks = json["hooks"]["tool:before"].as_array().unwrap();
477        assert_eq!(hooks.len(), 1);
478        assert!(hooks[0].as_str().unwrap().contains("/new/hooks/audit/"));
479    }
480
481    #[test]
482    fn remove_entries_removes_selected_mcp_and_hook_entries() {
483        let tmp = TempDir::new().unwrap();
484        let adapter = OpencodeAdapter;
485        adapter
486            .write_config_entries(
487                &[
488                    make_stdio_mcp_entry("to-remove"),
489                    make_stdio_mcp_entry("to-keep"),
490                    make_hook_entry_with_path("audit", "tool:before", "/hooks/audit/run.sh"),
491                    make_hook_entry_with_path("audit", "tool:after", "/hooks/audit/run.sh"),
492                ],
493                tmp.path(),
494            )
495            .unwrap();
496
497        adapter
498            .remove_config_entries(
499                &[
500                    "mcp:to-remove".to_string(),
501                    "hook:tool.pre:audit".to_string(),
502                ],
503                tmp.path(),
504            )
505            .unwrap();
506
507        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
508        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
509        assert!(json["mcp"]["to-remove"].is_null());
510        assert!(json["mcp"]["to-keep"].is_object());
511        assert!(json["hooks"]["tool:before"].as_array().unwrap().is_empty());
512        assert_eq!(json["hooks"]["tool:after"].as_array().unwrap().len(), 1);
513    }
514
515    #[test]
516    fn remove_entries_migrates_legacy_mcp_servers_before_cleanup() {
517        let tmp = TempDir::new().unwrap();
518        let legacy = serde_json::json!({
519            "mcpServers": {
520                "to-remove": {
521                    "command": "npx",
522                    "args": ["-y", "legacy-mcp@latest"]
523                },
524                "to-keep": {
525                    "command": "npx",
526                    "args": ["-y", "keep-mcp@latest"]
527                }
528            }
529        });
530        std::fs::write(
531            tmp.path().join("opencode.json"),
532            serde_json::to_string_pretty(&legacy).unwrap(),
533        )
534        .unwrap();
535
536        let adapter = OpencodeAdapter;
537        adapter
538            .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
539            .unwrap();
540
541        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
542        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
543        assert!(json["mcpServers"].is_null());
544        assert!(json["mcp"]["to-remove"].is_null());
545        assert!(json["mcp"]["to-keep"].is_object());
546    }
547
548    #[test]
549    fn write_migrates_legacy_mcp_servers_when_mcp_missing() {
550        let tmp = TempDir::new().unwrap();
551        let existing = serde_json::json!({
552            "mcpServers": {
553                "legacy": {
554                    "command": "npx",
555                    "args": ["-y", "legacy-mcp@latest"],
556                    "env": { "TOKEN": "LEGACY_TOKEN" }
557                }
558            },
559            "hooks": {
560                "tool:before": [r#"bash "/hooks/audit/run.sh""#]
561            }
562        });
563        std::fs::write(
564            tmp.path().join("opencode.json"),
565            serde_json::to_string_pretty(&existing).unwrap(),
566        )
567        .unwrap();
568
569        let adapter = OpencodeAdapter;
570        adapter
571            .write_config_entries(
572                &[make_hook_entry_with_path(
573                    "audit",
574                    "tool:before",
575                    "/hooks/audit/run.sh",
576                )],
577                tmp.path(),
578            )
579            .unwrap();
580
581        let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
582        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
583        assert!(json["mcpServers"].is_null());
584        assert_eq!(json["mcp"]["legacy"]["type"], "local");
585        assert_eq!(json["mcp"]["legacy"]["command"][0], "npx");
586        assert_eq!(json["mcp"]["legacy"]["command"][1], "-y");
587        assert_eq!(
588            json["mcp"]["legacy"]["environment"]["TOKEN"],
589            "LEGACY_TOKEN"
590        );
591    }
592}