Skip to main content

agent_config/agents/
opencode.rs

1//! OpenCode integration (sst/opencode).
2//!
3//! OpenCode loads plugins from `~/.config/opencode/plugins/*.{ts,js}` (Global)
4//! or `<project>/.opencode/plugins/*.{ts,js}` (Local). We write a single TS
5//! file per consumer (`<tag>.ts`) whose body is supplied by the caller via
6//! [`ScriptTemplate::TypeScript`].
7//!
8//! Optional prompt surface: `~/.config/opencode/AGENTS.md` (Global) or
9//! `<project>/AGENTS.md` (Local). If the caller does not supply a script,
10//! this integration falls back to a generic plugin that intercepts
11//! `tool.execute.before` for the `bash` tool and execs the rendered hook
12//! command, passing the call's args via stdin (JSON). Safe program commands
13//! are shell-quoted before rendering.
14
15use 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/// OpenCode plugin installer.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct OpenCodeAgent {
35    _private: (),
36}
37
38impl OpenCodeAgent {
39    /// Construct an instance. Stateless.
40    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    /// `~/.config/opencode/opencode.json` (Global) or
65    /// `<root>/opencode.json` (Local). MCP servers live in the object-based
66    /// `mcp` key.
67    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            // Tidy: prune empty plugins dir.
236            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
524/// A minimal TS plugin body that runs `command` before every `bash`-tool call,
525/// piping the call's args (JSON) on stdin.
526///
527/// Callers who need richer behavior should pass their own [`ScriptTemplate::TypeScript`].
528fn 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        // Empty plugins dir was pruned.
688        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        // Plugin file and MCP config are separate.
807        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        // Empty config gets removed.
833        assert!(!dir.path().join("opencode.json").exists());
834    }
835}