1use 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 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
81fn 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 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 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 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 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 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 !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#[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}