1use std::path::PathBuf;
16
17use crate::agents::planning as agent_planning;
18use crate::error::AgentConfigError;
19use crate::integration::{
20 InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
21};
22use crate::paths;
23use crate::plan::{InstallPlan, PlanTarget, RefusalReason, UninstallPlan};
24use crate::scope::{Scope, ScopeKind};
25use crate::spec::{HookSpec, InstructionSpec, McpSpec, ScriptTemplate, SkillSpec};
26use crate::status::StatusReport;
27use crate::util::{
28 file_lock, fs_atomic, instructions_dir, mcp_json_map, md_block, ownership, planning, safe_fs,
29 skills_dir,
30};
31
32#[derive(Debug, Clone, Copy, Default)]
34pub struct OpenCodeAgent {
35 _private: (),
36}
37
38impl OpenCodeAgent {
39 pub const fn new() -> Self {
41 Self { _private: () }
42 }
43
44 fn plugin_path(scope: &Scope, tag: &str) -> Result<PathBuf, AgentConfigError> {
45 Ok(match scope {
46 Scope::Global => paths::opencode_plugins_dir()?.join(format!("{tag}.ts")),
47 Scope::Local(p) => p
48 .join(".opencode")
49 .join("plugins")
50 .join(format!("{tag}.ts")),
51 })
52 }
53
54 fn agents_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
55 Ok(match scope {
56 Scope::Global => paths::home_dir()?
57 .join(".config")
58 .join("opencode")
59 .join("AGENTS.md"),
60 Scope::Local(p) => p.join("AGENTS.md"),
61 })
62 }
63
64 fn config_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
68 Ok(match scope {
69 Scope::Global => paths::opencode_config_file()?,
70 Scope::Local(p) => p.join("opencode.json"),
71 })
72 }
73
74 fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
75 Ok(match scope {
76 Scope::Global => paths::home_dir()?
77 .join(".config")
78 .join("opencode")
79 .join("skills"),
80 Scope::Local(p) => p.join(".opencode").join("skills"),
81 })
82 }
83
84 fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
85 Ok(match scope {
86 Scope::Global => paths::home_dir()?.join(".config").join("opencode"),
87 Scope::Local(p) => p.join(".opencode"),
88 })
89 }
90
91 fn inline_layout(
92 &self,
93 scope: &Scope,
94 ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
95 Ok(instructions_dir::InlineLayout {
96 config_dir: Self::instruction_config_dir(scope)?,
97 host_file: Self::agents_path(scope)?,
98 })
99 }
100}
101
102impl Integration for OpenCodeAgent {
103 fn id(&self) -> &'static str {
104 "opencode"
105 }
106
107 fn display_name(&self) -> &'static str {
108 "OpenCode"
109 }
110
111 fn supported_scopes(&self) -> &'static [ScopeKind] {
112 &[ScopeKind::Global, ScopeKind::Local]
113 }
114
115 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
116 HookSpec::validate_tag(tag)?;
117 let p = Self::plugin_path(scope, tag)?;
118 Ok(StatusReport::for_file_hook(tag, p))
119 }
120
121 fn plan_install(
122 &self,
123 scope: &Scope,
124 spec: &HookSpec,
125 ) -> Result<InstallPlan, AgentConfigError> {
126 HookSpec::validate_tag(&spec.tag)?;
127 let target = PlanTarget::Hook {
128 integration_id: Integration::id(self),
129 scope: scope.clone(),
130 tag: spec.tag.clone(),
131 };
132 let p = Self::plugin_path(scope, &spec.tag)?;
133 let body = match &spec.script {
134 Some(ScriptTemplate::TypeScript(s)) => s.clone(),
135 Some(ScriptTemplate::Shell(_)) => {
136 return Ok(InstallPlan::refused(
137 target,
138 None,
139 RefusalReason::MissingRequiredSpecField,
140 ));
141 }
142 None => default_plugin_body(&spec.command.render_shell()),
143 };
144 let body = fs_atomic::ensure_trailing_newline(&body);
145 let mut changes = Vec::new();
146 planning::plan_write_file(&mut changes, &p, body.as_bytes(), true)?;
147 if let Some(rules) = &spec.rules {
148 planning::plan_markdown_upsert(
149 &mut changes,
150 &Self::agents_path(scope)?,
151 &spec.tag,
152 &rules.content,
153 )?;
154 }
155 Ok(InstallPlan::from_changes(target, changes))
156 }
157
158 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
159 HookSpec::validate_tag(tag)?;
160 let target = PlanTarget::Hook {
161 integration_id: Integration::id(self),
162 scope: scope.clone(),
163 tag: tag.to_string(),
164 };
165 let p = Self::plugin_path(scope, tag)?;
166 let mut changes = Vec::new();
167 planning::plan_remove_file(&mut changes, &p);
168 planning::plan_markdown_remove(&mut changes, &Self::agents_path(scope)?, tag)?;
169 Ok(UninstallPlan::from_changes(target, changes))
170 }
171
172 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
173 HookSpec::validate_tag(&spec.tag)?;
174 let p = Self::plugin_path(scope, &spec.tag)?;
175
176 let body = match &spec.script {
177 Some(ScriptTemplate::TypeScript(s)) => s.clone(),
178 Some(ScriptTemplate::Shell(_)) => {
179 return Err(AgentConfigError::MissingSpecField {
180 id: "opencode",
181 field: "script (TypeScript)",
182 });
183 }
184 None => default_plugin_body(&spec.command.render_shell()),
185 };
186 let body = fs_atomic::ensure_trailing_newline(&body);
187
188 scope.ensure_contained(&p)?;
189 let outcome = safe_fs::write(scope, &p, body.as_bytes(), true)?;
190 let mut report = InstallReport::default();
191 if outcome.no_change {
192 report.already_installed = true;
193 } else if outcome.existed {
194 report.patched.push(outcome.path.clone());
195 } else {
196 report.created.push(outcome.path.clone());
197 }
198 if let Some(b) = outcome.backup {
199 report.backed_up.push(b);
200 }
201 if let Some(rules) = &spec.rules {
202 let agents = Self::agents_path(scope)?;
203 scope.ensure_contained(&agents)?;
204 file_lock::with_lock(&agents, || {
205 let host = fs_atomic::read_to_string_or_empty(&agents)?;
206 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
207 let outcome = safe_fs::write(scope, &agents, new_host.as_bytes(), true)?;
208 if outcome.existed && !outcome.no_change {
209 report.patched.push(outcome.path.clone());
210 report.already_installed = false;
211 } else if !outcome.existed {
212 report.created.push(outcome.path.clone());
213 report.already_installed = false;
214 }
215 if let Some(b) = outcome.backup {
216 report.backed_up.push(b);
217 }
218 Ok::<(), AgentConfigError>(())
219 })?;
220 }
221 Ok(report)
222 }
223
224 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
225 HookSpec::validate_tag(tag)?;
226 let mut report = UninstallReport::default();
227 let p = Self::plugin_path(scope, tag)?;
228 scope.ensure_contained(&p)?;
229 let mut removed_any = false;
230 if p.exists() {
231 safe_fs::remove_file(scope, &p)?;
232 report.removed.push(p.clone());
233 removed_any = true;
234
235 if let Some(parent) = p.parent() {
237 if std::fs::read_dir(parent)
238 .map(|mut it| it.next().is_none())
239 .unwrap_or(false)
240 {
241 let _ = safe_fs::remove_empty_dir(scope, parent);
242 }
243 }
244 }
245
246 let agents = Self::agents_path(scope)?;
247 scope.ensure_contained(&agents)?;
248 file_lock::with_lock(&agents, || {
249 let host = fs_atomic::read_to_string_or_empty(&agents)?;
250 let (stripped, removed) = md_block::remove(&host, tag);
251 if removed {
252 if stripped.trim().is_empty() {
253 if safe_fs::restore_backup_if_matches(scope, &agents, stripped.as_bytes())? {
254 report.restored.push(agents.clone());
255 removed_any = true;
256 } else {
257 safe_fs::remove_file(scope, &agents)?;
258 report.removed.push(agents.clone());
259 removed_any = true;
260 }
261 } else {
262 safe_fs::write(scope, &agents, stripped.as_bytes(), false)?;
263 report.patched.push(agents.clone());
264 removed_any = true;
265 }
266 }
267 Ok::<(), AgentConfigError>(())
268 })?;
269 report.not_installed = !removed_any;
270 Ok(report)
271 }
272}
273
274impl McpSurface for OpenCodeAgent {
275 fn id(&self) -> &'static str {
276 "opencode"
277 }
278
279 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
280 &[ScopeKind::Global, ScopeKind::Local]
281 }
282
283 fn mcp_status(
284 &self,
285 scope: &Scope,
286 name: &str,
287 expected_owner: &str,
288 ) -> Result<StatusReport, AgentConfigError> {
289 McpSpec::validate_name(name)?;
290 let cfg = Self::config_path(scope)?;
291 let ledger = ownership::mcp_ledger_for(&cfg);
292 let presence =
293 mcp_json_map::config_presence(&cfg, &["mcp"], name, mcp_json_map::ConfigFormat::Jsonc)?;
294 let recorded = ownership::owner_of(&ledger, name)?;
295 Ok(StatusReport::for_mcp(
296 name,
297 cfg,
298 ledger,
299 presence,
300 expected_owner,
301 recorded,
302 ))
303 }
304
305 fn plan_install_mcp(
306 &self,
307 scope: &Scope,
308 spec: &McpSpec,
309 ) -> Result<InstallPlan, AgentConfigError> {
310 agent_planning::mcp_json_map_install(
311 McpSurface::id(self),
312 scope,
313 spec,
314 Self::config_path(scope),
315 &["mcp"],
316 mcp_json_map::command_array_value,
317 mcp_json_map::ConfigFormat::Jsonc,
318 )
319 }
320
321 fn plan_uninstall_mcp(
322 &self,
323 scope: &Scope,
324 name: &str,
325 owner_tag: &str,
326 ) -> Result<UninstallPlan, AgentConfigError> {
327 agent_planning::mcp_json_map_uninstall(
328 McpSurface::id(self),
329 scope,
330 name,
331 owner_tag,
332 Self::config_path(scope),
333 &["mcp"],
334 mcp_json_map::ConfigFormat::Jsonc,
335 )
336 }
337
338 fn install_mcp(
339 &self,
340 scope: &Scope,
341 spec: &McpSpec,
342 ) -> Result<InstallReport, AgentConfigError> {
343 spec.validate()?;
344 let cfg = Self::config_path(scope)?;
345 spec.validate_local_secret_policy(scope)?;
346 scope.ensure_contained(&cfg)?;
347 let ledger = ownership::mcp_ledger_for(&cfg);
348 mcp_json_map::install(
349 &cfg,
350 &ledger,
351 spec,
352 &["mcp"],
353 mcp_json_map::command_array_value,
354 mcp_json_map::ConfigFormat::Jsonc,
355 )
356 }
357
358 fn uninstall_mcp(
359 &self,
360 scope: &Scope,
361 name: &str,
362 owner_tag: &str,
363 ) -> Result<UninstallReport, AgentConfigError> {
364 McpSpec::validate_name(name)?;
365 HookSpec::validate_tag(owner_tag)?;
366 let cfg = Self::config_path(scope)?;
367 scope.ensure_contained(&cfg)?;
368 let ledger = ownership::mcp_ledger_for(&cfg);
369 mcp_json_map::uninstall(
370 &cfg,
371 &ledger,
372 name,
373 owner_tag,
374 "mcp server",
375 &["mcp"],
376 mcp_json_map::ConfigFormat::Jsonc,
377 )
378 }
379}
380
381impl SkillSurface for OpenCodeAgent {
382 fn id(&self) -> &'static str {
383 "opencode"
384 }
385
386 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
387 &[ScopeKind::Global, ScopeKind::Local]
388 }
389
390 fn skill_status(
391 &self,
392 scope: &Scope,
393 name: &str,
394 expected_owner: &str,
395 ) -> Result<StatusReport, AgentConfigError> {
396 SkillSpec::validate_name(name)?;
397 let root = Self::skills_root(scope)?;
398 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
399 let recorded = ownership::owner_of(&ledger, name)?;
400 Ok(StatusReport::for_skill(
401 name,
402 dir,
403 manifest,
404 ledger,
405 expected_owner,
406 recorded,
407 ))
408 }
409
410 fn plan_install_skill(
411 &self,
412 scope: &Scope,
413 spec: &SkillSpec,
414 ) -> Result<InstallPlan, AgentConfigError> {
415 agent_planning::skill_install(
416 SkillSurface::id(self),
417 scope,
418 spec,
419 Self::skills_root(scope),
420 )
421 }
422
423 fn plan_uninstall_skill(
424 &self,
425 scope: &Scope,
426 name: &str,
427 owner_tag: &str,
428 ) -> Result<UninstallPlan, AgentConfigError> {
429 agent_planning::skill_uninstall(
430 SkillSurface::id(self),
431 scope,
432 name,
433 owner_tag,
434 Self::skills_root(scope),
435 )
436 }
437
438 fn install_skill(
439 &self,
440 scope: &Scope,
441 spec: &SkillSpec,
442 ) -> Result<InstallReport, AgentConfigError> {
443 let root = Self::skills_root(scope)?;
444 scope.ensure_contained(&root)?;
445 skills_dir::install(&root, spec)
446 }
447
448 fn uninstall_skill(
449 &self,
450 scope: &Scope,
451 name: &str,
452 owner_tag: &str,
453 ) -> Result<UninstallReport, AgentConfigError> {
454 let root = Self::skills_root(scope)?;
455 scope.ensure_contained(&root)?;
456 skills_dir::uninstall(&root, name, owner_tag)
457 }
458}
459
460impl InstructionSurface for OpenCodeAgent {
461 fn id(&self) -> &'static str {
462 "opencode"
463 }
464
465 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
466 &[ScopeKind::Global, ScopeKind::Local]
467 }
468
469 fn instruction_status(
470 &self,
471 scope: &Scope,
472 name: &str,
473 expected_owner: &str,
474 ) -> Result<StatusReport, AgentConfigError> {
475 instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
476 }
477
478 fn plan_install_instruction(
479 &self,
480 scope: &Scope,
481 spec: &InstructionSpec,
482 ) -> Result<InstallPlan, AgentConfigError> {
483 instructions_dir::inline_plan_install(
484 InstructionSurface::id(self),
485 scope,
486 self.inline_layout(scope),
487 spec,
488 )
489 }
490
491 fn plan_uninstall_instruction(
492 &self,
493 scope: &Scope,
494 name: &str,
495 owner_tag: &str,
496 ) -> Result<UninstallPlan, AgentConfigError> {
497 instructions_dir::inline_plan_uninstall(
498 InstructionSurface::id(self),
499 scope,
500 self.inline_layout(scope),
501 name,
502 owner_tag,
503 )
504 }
505
506 fn install_instruction(
507 &self,
508 scope: &Scope,
509 spec: &InstructionSpec,
510 ) -> Result<InstallReport, AgentConfigError> {
511 instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
512 }
513
514 fn uninstall_instruction(
515 &self,
516 scope: &Scope,
517 name: &str,
518 owner_tag: &str,
519 ) -> Result<UninstallReport, AgentConfigError> {
520 instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
521 }
522}
523
524fn default_plugin_body(command: &str) -> String {
529 let escaped = escape_js_template_literal(command);
530 format!(
531 r#"// Generated by agent-config. Edit at your own risk.
532// Re-running install will overwrite this file.
533
534import type {{ Plugin }} from "@opencode-ai/plugin";
535
536export const Hook: Plugin = async ({{ $ }}) => ({{
537 "tool.execute.before": async (input, output) => {{
538 if (input.tool !== "bash") return;
539 const payload = JSON.stringify({{ tool: input.tool, args: output.args }});
540 await $`echo ${{payload}} | {escaped}`;
541 }},
542}});
543"#
544 )
545}
546
547fn escape_js_template_literal(s: &str) -> String {
548 s.replace('\\', "\\\\")
549 .replace('`', "\\`")
550 .replace("${", "\\${")
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::spec::{Event, Matcher};
557 use tempfile::tempdir;
558
559 fn spec_with_script(tag: &str, ts: &str) -> HookSpec {
560 HookSpec::builder(tag)
561 .command_program("noop", [] as [&str; 0])
562 .matcher(Matcher::Bash)
563 .event(Event::PreToolUse)
564 .script(ScriptTemplate::TypeScript(ts.into()))
565 .build()
566 }
567
568 #[test]
569 fn install_writes_typescript_plugin_file() {
570 let dir = tempdir().unwrap();
571 let agent = OpenCodeAgent::new();
572 let scope = Scope::Local(dir.path().to_path_buf());
573 let custom = "export const X = 1;";
574 agent
575 .install(&scope, &spec_with_script("alpha", custom))
576 .unwrap();
577 let p = dir.path().join(".opencode/plugins/alpha.ts");
578 let body = std::fs::read_to_string(&p).unwrap();
579 assert!(body.contains("export const X = 1;"));
580 }
581
582 #[test]
583 fn install_without_script_uses_default_template() {
584 let dir = tempdir().unwrap();
585 let agent = OpenCodeAgent::new();
586 let scope = Scope::Local(dir.path().to_path_buf());
587 let s = HookSpec::builder("alpha")
588 .command_program("myapp", ["hook", "opencode"])
589 .build();
590 agent.install(&scope, &s).unwrap();
591 let body = std::fs::read_to_string(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
592 assert!(body.contains("myapp hook opencode"));
593 assert!(body.contains("tool.execute.before"));
594 assert!(body.contains("async (input, output)"));
595 assert!(body.contains("input.tool"));
596 assert!(body.contains("output.args"));
597 }
598
599 #[test]
600 fn install_without_script_quotes_program_arguments() {
601 let dir = tempdir().unwrap();
602 let agent = OpenCodeAgent::new();
603 let scope = Scope::Local(dir.path().to_path_buf());
604 let s = HookSpec::builder("alpha")
605 .command_program(
606 "my hook",
607 ["repo path", "semi;$(not run)", "`tick`", "quote's"],
608 )
609 .build();
610
611 agent.install(&scope, &s).unwrap();
612
613 let body = std::fs::read_to_string(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
614 assert!(body.contains("'my hook' 'repo path' 'semi;$(not run)'"));
615 assert!(body.contains("'\\`tick\\`'"));
616 assert!(body.contains("tool.execute.before"));
617 }
618
619 #[test]
620 fn install_with_rules_writes_agents_md() {
621 let dir = tempdir().unwrap();
622 let agent = OpenCodeAgent::new();
623 let scope = Scope::Local(dir.path().to_path_buf());
624 let s = HookSpec::builder("alpha")
625 .command_program("myapp", ["hook"])
626 .rules("Use OpenCode project rules.")
627 .build();
628
629 agent.install(&scope, &s).unwrap();
630
631 let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
632 assert!(agents.contains("BEGIN AGENT-CONFIG:alpha"));
633 assert!(agents.contains("Use OpenCode project rules."));
634 }
635
636 #[test]
637 fn uninstall_removes_rules_even_when_plugin_file_missing() {
638 let dir = tempdir().unwrap();
639 let agent = OpenCodeAgent::new();
640 let scope = Scope::Local(dir.path().to_path_buf());
641 let s = HookSpec::builder("alpha")
642 .command_program("myapp", ["hook"])
643 .rules("Use OpenCode project rules.")
644 .build();
645
646 agent.install(&scope, &s).unwrap();
647 std::fs::remove_file(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
648 let report = agent.uninstall(&scope, "alpha").unwrap();
649
650 assert!(!report.not_installed);
651 assert!(!dir.path().join("AGENTS.md").exists());
652 }
653
654 #[test]
655 fn instruction_surface_round_trip_uses_agents_md() {
656 let dir = tempdir().unwrap();
657 let agent = OpenCodeAgent::new();
658 let scope = Scope::Local(dir.path().to_path_buf());
659 let spec = InstructionSpec::builder("guide")
660 .owner("myapp")
661 .placement(crate::spec::InstructionPlacement::InlineBlock)
662 .body("# Guide\n\nUse OpenCode instructions.\n")
663 .try_build()
664 .unwrap();
665
666 agent.install_instruction(&scope, &spec).unwrap();
667 let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
668 assert!(agents.contains("BEGIN AGENT-CONFIG-INSTR:guide"));
669 assert!(agent.is_instruction_installed(&scope, "guide").unwrap());
670
671 agent
672 .uninstall_instruction(&scope, "guide", "myapp")
673 .unwrap();
674 assert!(!agent.is_instruction_installed(&scope, "guide").unwrap());
675 }
676
677 #[test]
678 fn install_uninstall_round_trip() {
679 let dir = tempdir().unwrap();
680 let agent = OpenCodeAgent::new();
681 let scope = Scope::Local(dir.path().to_path_buf());
682 agent
683 .install(&scope, &spec_with_script("alpha", "// x"))
684 .unwrap();
685 agent.uninstall(&scope, "alpha").unwrap();
686 assert!(!dir.path().join(".opencode/plugins/alpha.ts").exists());
687 assert!(!dir.path().join(".opencode/plugins").exists());
689 }
690
691 #[test]
692 fn install_with_shell_script_returns_typed_error() {
693 let dir = tempdir().unwrap();
694 let agent = OpenCodeAgent::new();
695 let scope = Scope::Local(dir.path().to_path_buf());
696 let s = HookSpec::builder("alpha")
697 .command_program("noop", [] as [&str; 0])
698 .script(ScriptTemplate::Shell("#!/bin/sh\nexit 0".into()))
699 .build();
700 let err = agent.install(&scope, &s).unwrap_err();
701 assert!(matches!(err, AgentConfigError::MissingSpecField { .. }));
702 }
703
704 fn read_json(p: &std::path::Path) -> serde_json::Value {
705 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
706 }
707
708 fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
709 McpSpec::builder(name)
710 .owner(owner)
711 .stdio("npx", ["-y", "@example/server"])
712 .build()
713 }
714
715 #[test]
716 fn install_mcp_writes_object_based_mcp() {
717 let dir = tempdir().unwrap();
718 let agent = OpenCodeAgent::new();
719 let scope = Scope::Local(dir.path().to_path_buf());
720 agent
721 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
722 .unwrap();
723 let cfg = dir.path().join("opencode.json");
724 assert!(cfg.exists());
725 let v = read_json(&cfg);
726 assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
727 assert_eq!(
728 v["mcp"]["github"]["command"],
729 serde_json::json!(["npx", "-y", "@example/server"])
730 );
731 }
732
733 #[test]
734 fn install_mcp_coexists_with_user_mcp_entries() {
735 let dir = tempdir().unwrap();
736 let cfg = dir.path().join("opencode.json");
737 std::fs::write(
738 &cfg,
739 r#"{ "mcp": { "user": { "type": "local", "command": ["user-cmd"] } } }"#,
740 )
741 .unwrap();
742 let agent = OpenCodeAgent::new();
743 let scope = Scope::Local(dir.path().to_path_buf());
744 agent
745 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
746 .unwrap();
747 let v = read_json(&cfg);
748 assert_eq!(v["mcp"]["user"]["command"], serde_json::json!(["user-cmd"]));
749 assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
750 }
751
752 #[test]
753 fn install_mcp_reads_jsonc_with_comments_and_trailing_commas() {
754 let dir = tempdir().unwrap();
755 let cfg = dir.path().join("opencode.json");
756 std::fs::write(
757 &cfg,
758 r#"{
759 // existing OpenCode config
760 "mcp": {
761 "user": {
762 "type": "remote",
763 "url": "https://example.com/mcp",
764 },
765 },
766}
767"#,
768 )
769 .unwrap();
770 let agent = OpenCodeAgent::new();
771 let scope = Scope::Local(dir.path().to_path_buf());
772 agent
773 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
774 .unwrap();
775 let v = read_json(&cfg);
776 assert_eq!(
777 v["mcp"]["user"]["url"],
778 serde_json::json!("https://example.com/mcp")
779 );
780 assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
781 }
782
783 #[test]
784 fn install_mcp_idempotent() {
785 let dir = tempdir().unwrap();
786 let agent = OpenCodeAgent::new();
787 let scope = Scope::Local(dir.path().to_path_buf());
788 let s = local_mcp_spec("github", "myapp");
789 agent.install_mcp(&scope, &s).unwrap();
790 let r = agent.install_mcp(&scope, &s).unwrap();
791 assert!(r.already_installed);
792 }
793
794 #[test]
795 fn install_mcp_does_not_collide_with_plugin_install() {
796 let dir = tempdir().unwrap();
797 let agent = OpenCodeAgent::new();
798 let scope = Scope::Local(dir.path().to_path_buf());
799 let plugin_spec = HookSpec::builder("alpha")
800 .command_program("noop", [] as [&str; 0])
801 .build();
802 agent.install(&scope, &plugin_spec).unwrap();
803 agent
804 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
805 .unwrap();
806 assert!(dir.path().join(".opencode/plugins/alpha.ts").exists());
808 assert!(dir.path().join("opencode.json").exists());
809 }
810
811 #[test]
812 fn uninstall_mcp_owner_mismatch_refused() {
813 let dir = tempdir().unwrap();
814 let agent = OpenCodeAgent::new();
815 let scope = Scope::Local(dir.path().to_path_buf());
816 agent
817 .install_mcp(&scope, &local_mcp_spec("github", "appA"))
818 .unwrap();
819 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
820 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
821 }
822
823 #[test]
824 fn uninstall_mcp_round_trip() {
825 let dir = tempdir().unwrap();
826 let agent = OpenCodeAgent::new();
827 let scope = Scope::Local(dir.path().to_path_buf());
828 agent
829 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
830 .unwrap();
831 agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
832 assert!(!dir.path().join("opencode.json").exists());
834 }
835}