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::{Event, HookSpec, InstructionSpec, Matcher, 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 resolve_skills_root(scope: &Scope, name: &str) -> Result<PathBuf, AgentConfigError> {
75        let roots = match scope {
76            Scope::Global => vec![
77                paths::home_dir()?
78                    .join(".config")
79                    .join("opencode")
80                    .join("skills"),
81                paths::home_dir()?.join(".claude").join("skills"),
82                paths::home_dir()?.join(".agents").join("skills"),
83            ],
84            Scope::Local(p) => vec![
85                p.join(".opencode").join("skills"),
86                p.join(".claude").join("skills"),
87                p.join(".agents").join("skills"),
88            ],
89        };
90        for root in &roots {
91            let (dir, _, ledger) = skills_dir::paths_for_status(root, name);
92            if ownership::contains(&ledger, name).unwrap_or(false) || dir.exists() {
93                return Ok(root.clone());
94            }
95        }
96        Ok(roots[0].clone())
97    }
98
99    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
100        Ok(match scope {
101            Scope::Global => paths::home_dir()?.join(".config").join("opencode"),
102            Scope::Local(p) => p.join(".opencode"),
103        })
104    }
105
106    fn inline_layout(
107        &self,
108        scope: &Scope,
109    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
110        Ok(instructions_dir::InlineLayout {
111            config_dir: Self::instruction_config_dir(scope)?,
112            host_file: Self::agents_path(scope)?,
113        })
114    }
115}
116
117impl Integration for OpenCodeAgent {
118    fn id(&self) -> &'static str {
119        "opencode"
120    }
121
122    fn display_name(&self) -> &'static str {
123        "OpenCode"
124    }
125
126    fn supported_scopes(&self) -> &'static [ScopeKind] {
127        &[ScopeKind::Global, ScopeKind::Local]
128    }
129
130    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
131        HookSpec::validate_tag(tag)?;
132        let p = Self::plugin_path(scope, tag)?;
133        Ok(StatusReport::for_file_hook(tag, p))
134    }
135
136    fn plan_install(
137        &self,
138        scope: &Scope,
139        spec: &HookSpec,
140    ) -> Result<InstallPlan, AgentConfigError> {
141        HookSpec::validate_tag(&spec.tag)?;
142        let target = PlanTarget::Hook {
143            integration_id: Integration::id(self),
144            scope: scope.clone(),
145            tag: spec.tag.clone(),
146        };
147        let p = Self::plugin_path(scope, &spec.tag)?;
148        let body = match &spec.script {
149            Some(ScriptTemplate::TypeScript(s)) => s.clone(),
150            Some(ScriptTemplate::Shell(_)) => {
151                return Ok(InstallPlan::refused(
152                    target,
153                    None,
154                    RefusalReason::MissingRequiredSpecField,
155                ));
156            }
157            None => generate_plugin_body(spec),
158        };
159        let body = fs_atomic::ensure_trailing_newline(&body);
160        let mut changes = Vec::new();
161        planning::plan_write_file(&mut changes, &p, body.as_bytes(), true)?;
162        if let Some(rules) = &spec.rules {
163            planning::plan_markdown_upsert(
164                &mut changes,
165                &Self::agents_path(scope)?,
166                &spec.tag,
167                &rules.content,
168            )?;
169        }
170        Ok(InstallPlan::from_changes(target, changes))
171    }
172
173    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
174        HookSpec::validate_tag(tag)?;
175        let target = PlanTarget::Hook {
176            integration_id: Integration::id(self),
177            scope: scope.clone(),
178            tag: tag.to_string(),
179        };
180        let p = Self::plugin_path(scope, tag)?;
181        let mut changes = Vec::new();
182        planning::plan_remove_file(&mut changes, &p);
183        planning::plan_markdown_remove(&mut changes, &Self::agents_path(scope)?, tag)?;
184        Ok(UninstallPlan::from_changes(target, changes))
185    }
186
187    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
188        HookSpec::validate_tag(&spec.tag)?;
189        let p = Self::plugin_path(scope, &spec.tag)?;
190
191        let body = match &spec.script {
192            Some(ScriptTemplate::TypeScript(s)) => s.clone(),
193            Some(ScriptTemplate::Shell(_)) => {
194                return Err(AgentConfigError::MissingSpecField {
195                    id: "opencode",
196                    field: "script (TypeScript)",
197                });
198            }
199            None => generate_plugin_body(spec),
200        };
201        let body = fs_atomic::ensure_trailing_newline(&body);
202
203        scope.ensure_contained(&p)?;
204        let outcome = safe_fs::write(scope, &p, body.as_bytes(), true)?;
205        let mut report = InstallReport::default();
206        if outcome.no_change {
207            report.already_installed = true;
208        } else if outcome.existed {
209            report.patched.push(outcome.path.clone());
210        } else {
211            report.created.push(outcome.path.clone());
212        }
213        if let Some(b) = outcome.backup {
214            report.backed_up.push(b);
215        }
216        if let Some(rules) = &spec.rules {
217            let agents = Self::agents_path(scope)?;
218            scope.ensure_contained(&agents)?;
219            file_lock::with_lock(&agents, || {
220                let host = fs_atomic::read_to_string_or_empty(&agents)?;
221                let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
222                let outcome = safe_fs::write(scope, &agents, new_host.as_bytes(), true)?;
223                if outcome.existed && !outcome.no_change {
224                    report.patched.push(outcome.path.clone());
225                    report.already_installed = false;
226                } else if !outcome.existed {
227                    report.created.push(outcome.path.clone());
228                    report.already_installed = false;
229                }
230                if let Some(b) = outcome.backup {
231                    report.backed_up.push(b);
232                }
233                Ok::<(), AgentConfigError>(())
234            })?;
235        }
236        Ok(report)
237    }
238
239    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
240        HookSpec::validate_tag(tag)?;
241        let mut report = UninstallReport::default();
242        let p = Self::plugin_path(scope, tag)?;
243        scope.ensure_contained(&p)?;
244        let mut removed_any = false;
245        if p.exists() {
246            safe_fs::remove_file(scope, &p)?;
247            report.removed.push(p.clone());
248            removed_any = true;
249
250            // Tidy: prune empty plugins dir.
251            if let Some(parent) = p.parent() {
252                if std::fs::read_dir(parent)
253                    .map(|mut it| it.next().is_none())
254                    .unwrap_or(false)
255                {
256                    let _ = safe_fs::remove_empty_dir(scope, parent);
257                }
258            }
259        }
260
261        let agents = Self::agents_path(scope)?;
262        scope.ensure_contained(&agents)?;
263        file_lock::with_lock(&agents, || {
264            let host = fs_atomic::read_to_string_or_empty(&agents)?;
265            let (stripped, removed) = md_block::remove(&host, tag);
266            if removed {
267                if stripped.trim().is_empty() {
268                    if safe_fs::restore_backup_if_matches(scope, &agents, stripped.as_bytes())? {
269                        report.restored.push(agents.clone());
270                        removed_any = true;
271                    } else {
272                        safe_fs::remove_file(scope, &agents)?;
273                        report.removed.push(agents.clone());
274                        removed_any = true;
275                    }
276                } else {
277                    safe_fs::write(scope, &agents, stripped.as_bytes(), false)?;
278                    report.patched.push(agents.clone());
279                    removed_any = true;
280                }
281            }
282            Ok::<(), AgentConfigError>(())
283        })?;
284        report.not_installed = !removed_any;
285        Ok(report)
286    }
287}
288
289impl McpSurface for OpenCodeAgent {
290    fn id(&self) -> &'static str {
291        "opencode"
292    }
293
294    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
295        &[ScopeKind::Global, ScopeKind::Local]
296    }
297
298    fn mcp_status(
299        &self,
300        scope: &Scope,
301        name: &str,
302        expected_owner: &str,
303    ) -> Result<StatusReport, AgentConfigError> {
304        McpSpec::validate_name(name)?;
305        let cfg = Self::config_path(scope)?;
306        let ledger = ownership::mcp_ledger_for(&cfg);
307        let presence =
308            mcp_json_map::config_presence(&cfg, &["mcp"], name, mcp_json_map::ConfigFormat::Jsonc)?;
309        let recorded = ownership::owner_of(&ledger, name)?;
310        Ok(StatusReport::for_mcp(
311            name,
312            cfg,
313            ledger,
314            presence,
315            expected_owner,
316            recorded,
317        ))
318    }
319
320    fn plan_install_mcp(
321        &self,
322        scope: &Scope,
323        spec: &McpSpec,
324    ) -> Result<InstallPlan, AgentConfigError> {
325        agent_planning::mcp_json_map_install(
326            McpSurface::id(self),
327            scope,
328            spec,
329            Self::config_path(scope),
330            &["mcp"],
331            mcp_json_map::command_array_value,
332            mcp_json_map::ConfigFormat::Jsonc,
333        )
334    }
335
336    fn plan_uninstall_mcp(
337        &self,
338        scope: &Scope,
339        name: &str,
340        owner_tag: &str,
341    ) -> Result<UninstallPlan, AgentConfigError> {
342        agent_planning::mcp_json_map_uninstall(
343            McpSurface::id(self),
344            scope,
345            name,
346            owner_tag,
347            Self::config_path(scope),
348            &["mcp"],
349            mcp_json_map::ConfigFormat::Jsonc,
350        )
351    }
352
353    fn install_mcp(
354        &self,
355        scope: &Scope,
356        spec: &McpSpec,
357    ) -> Result<InstallReport, AgentConfigError> {
358        spec.validate()?;
359        let cfg = Self::config_path(scope)?;
360        spec.validate_local_secret_policy(scope)?;
361        scope.ensure_contained(&cfg)?;
362        let ledger = ownership::mcp_ledger_for(&cfg);
363        mcp_json_map::install(
364            &cfg,
365            &ledger,
366            spec,
367            &["mcp"],
368            mcp_json_map::command_array_value,
369            mcp_json_map::ConfigFormat::Jsonc,
370        )
371    }
372
373    fn uninstall_mcp(
374        &self,
375        scope: &Scope,
376        name: &str,
377        owner_tag: &str,
378    ) -> Result<UninstallReport, AgentConfigError> {
379        McpSpec::validate_name(name)?;
380        HookSpec::validate_tag(owner_tag)?;
381        let cfg = Self::config_path(scope)?;
382        scope.ensure_contained(&cfg)?;
383        let ledger = ownership::mcp_ledger_for(&cfg);
384        mcp_json_map::uninstall(
385            &cfg,
386            &ledger,
387            name,
388            owner_tag,
389            "mcp server",
390            &["mcp"],
391            mcp_json_map::ConfigFormat::Jsonc,
392        )
393    }
394}
395
396impl SkillSurface for OpenCodeAgent {
397    fn id(&self) -> &'static str {
398        "opencode"
399    }
400
401    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
402        &[ScopeKind::Global, ScopeKind::Local]
403    }
404
405    fn skill_status(
406        &self,
407        scope: &Scope,
408        name: &str,
409        expected_owner: &str,
410    ) -> Result<StatusReport, AgentConfigError> {
411        SkillSpec::validate_name(name)?;
412        let root = Self::resolve_skills_root(scope, name)?;
413        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
414        let recorded = ownership::owner_of(&ledger, name)?;
415        Ok(StatusReport::for_skill(
416            name,
417            dir,
418            manifest,
419            ledger,
420            expected_owner,
421            recorded,
422        ))
423    }
424
425    fn plan_install_skill(
426        &self,
427        scope: &Scope,
428        spec: &SkillSpec,
429    ) -> Result<InstallPlan, AgentConfigError> {
430        let root = Self::resolve_skills_root(scope, &spec.name)?;
431        agent_planning::skill_install(
432            SkillSurface::id(self),
433            scope,
434            spec,
435            Ok(root),
436        )
437    }
438
439    fn plan_uninstall_skill(
440        &self,
441        scope: &Scope,
442        name: &str,
443        owner_tag: &str,
444    ) -> Result<UninstallPlan, AgentConfigError> {
445        let root = Self::resolve_skills_root(scope, name)?;
446        agent_planning::skill_uninstall(
447            SkillSurface::id(self),
448            scope,
449            name,
450            owner_tag,
451            Ok(root),
452        )
453    }
454
455    fn install_skill(
456        &self,
457        scope: &Scope,
458        spec: &SkillSpec,
459    ) -> Result<InstallReport, AgentConfigError> {
460        let root = Self::resolve_skills_root(scope, &spec.name)?;
461        scope.ensure_contained(&root)?;
462        skills_dir::install(&root, spec)
463    }
464
465    fn uninstall_skill(
466        &self,
467        scope: &Scope,
468        name: &str,
469        owner_tag: &str,
470    ) -> Result<UninstallReport, AgentConfigError> {
471        let root = Self::resolve_skills_root(scope, name)?;
472        scope.ensure_contained(&root)?;
473        skills_dir::uninstall(&root, name, owner_tag)
474    }
475}
476
477impl InstructionSurface for OpenCodeAgent {
478    fn id(&self) -> &'static str {
479        "opencode"
480    }
481
482    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
483        &[ScopeKind::Global, ScopeKind::Local]
484    }
485
486    fn instruction_status(
487        &self,
488        scope: &Scope,
489        name: &str,
490        expected_owner: &str,
491    ) -> Result<StatusReport, AgentConfigError> {
492        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
493    }
494
495    fn plan_install_instruction(
496        &self,
497        scope: &Scope,
498        spec: &InstructionSpec,
499    ) -> Result<InstallPlan, AgentConfigError> {
500        instructions_dir::inline_plan_install(
501            InstructionSurface::id(self),
502            scope,
503            self.inline_layout(scope),
504            spec,
505        )
506    }
507
508    fn plan_uninstall_instruction(
509        &self,
510        scope: &Scope,
511        name: &str,
512        owner_tag: &str,
513    ) -> Result<UninstallPlan, AgentConfigError> {
514        instructions_dir::inline_plan_uninstall(
515            InstructionSurface::id(self),
516            scope,
517            self.inline_layout(scope),
518            name,
519            owner_tag,
520        )
521    }
522
523    fn install_instruction(
524        &self,
525        scope: &Scope,
526        spec: &InstructionSpec,
527    ) -> Result<InstallReport, AgentConfigError> {
528        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
529    }
530
531    fn uninstall_instruction(
532        &self,
533        scope: &Scope,
534        name: &str,
535        owner_tag: &str,
536    ) -> Result<UninstallReport, AgentConfigError> {
537        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
538    }
539}
540
541/// A dynamically generated TS plugin body based on the event and matcher of HookSpec.
542fn generate_plugin_body(spec: &HookSpec) -> String {
543    let command = spec.command.render_shell();
544    let escaped = escape_js_template_literal(&command);
545
546    let hook_name = match &spec.event {
547        Event::PreToolUse => "tool.execute.before",
548        Event::PostToolUse => "tool.execute.after",
549        Event::Custom(name) => name.as_str(),
550    };
551
552    let is_tool_event = hook_name == "tool.execute.before" || hook_name == "tool.execute.after";
553
554    let guard = if is_tool_event {
555        match &spec.matcher {
556            Matcher::All => "".to_string(),
557            Matcher::Bash => "    if (input.tool !== \"bash\") return;\n".to_string(),
558            Matcher::Exact(tool) => format!("    if (input.tool !== {:?}) return;\n", tool),
559            Matcher::AnyOf(tools) => {
560                let list = tools
561                    .iter()
562                    .map(|t| format!("{:?}", t))
563                    .collect::<Vec<_>>()
564                    .join(", ");
565                format!("    if (![{}].includes(input.tool)) return;\n", list)
566            }
567            Matcher::Regex(pattern) => {
568                let escaped_pat = pattern.replace('\\', "\\\\").replace('"', "\\\"");
569                format!(
570                    "    if (!new RegExp(\"{}\").test(input.tool)) return;\n",
571                    escaped_pat
572                )
573            }
574        }
575    } else {
576        "".to_string()
577    };
578
579    let payload_js = if is_tool_event {
580        "    const payload = JSON.stringify({ tool: input.tool, args: output.args });"
581    } else {
582        "    const payload = JSON.stringify({ event: input });"
583    };
584
585    format!(
586        r#"// Generated by agent-config. Edit at your own risk.
587// Re-running install will overwrite this file.
588
589import type {{ Plugin }} from "@opencode-ai/plugin";
590
591export const Hook: Plugin = async ({{ $ }}) => ({{
592  {:?}: async (input, output) => {{
593{}{}
594    await $`echo ${{payload}} | {escaped}`;
595  }},
596}});
597"#,
598        hook_name, guard, payload_js
599    )
600}
601
602fn escape_js_template_literal(s: &str) -> String {
603    s.replace('\\', "\\\\")
604        .replace('`', "\\`")
605        .replace("${", "\\${")
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::spec::{Event, Matcher};
612    use tempfile::tempdir;
613
614    fn spec_with_script(tag: &str, ts: &str) -> HookSpec {
615        HookSpec::builder(tag)
616            .command_program("noop", [] as [&str; 0])
617            .matcher(Matcher::Bash)
618            .event(Event::PreToolUse)
619            .script(ScriptTemplate::TypeScript(ts.into()))
620            .build()
621    }
622
623    #[test]
624    fn generate_plugin_body_with_various_matchers_and_events() {
625        // Test PostToolUse with Matcher::All
626        let s1 = HookSpec::builder("all_post")
627            .command_program("test", [] as [&str; 0])
628            .matcher(Matcher::All)
629            .event(Event::PostToolUse)
630            .build();
631        let body1 = generate_plugin_body(&s1);
632        assert!(body1.contains("\"tool.execute.after\""));
633        assert!(!body1.contains("if (input.tool"));
634        assert!(body1.contains("const payload = JSON.stringify({ tool: input.tool, args: output.args });"));
635
636        // Test Custom event with Matcher::Exact
637        let s2 = HookSpec::builder("custom")
638            .command_program("test", [] as [&str; 0])
639            .matcher(Matcher::Exact("git".into()))
640            .event(Event::Custom("session.idle".into()))
641            .build();
642        let body2 = generate_plugin_body(&s2);
643        assert!(body2.contains("\"session.idle\""));
644        // Custom non-tool event should not generate matcher guard since input.tool might not exist
645        assert!(!body2.contains("if (input.tool !== \"git\")"));
646        assert!(body2.contains("const payload = JSON.stringify({ event: input });"));
647
648        // Test PreToolUse with Matcher::AnyOf
649        let s3 = HookSpec::builder("any_of")
650            .command_program("test", [] as [&str; 0])
651            .matcher(Matcher::AnyOf(vec!["git".into(), "bash".into()]))
652            .event(Event::PreToolUse)
653            .build();
654        let body3 = generate_plugin_body(&s3);
655        assert!(body3.contains("if (![\"git\", \"bash\"].includes(input.tool))"));
656
657        // Test PreToolUse with Matcher::Regex
658        let s4 = HookSpec::builder("regex")
659            .command_program("test", [] as [&str; 0])
660            .matcher(Matcher::Regex("g.t".into()))
661            .event(Event::PreToolUse)
662            .build();
663        let body4 = generate_plugin_body(&s4);
664        assert!(body4.contains("if (!new RegExp(\"g.t\").test(input.tool))"));
665    }
666
667    #[test]
668    fn install_writes_typescript_plugin_file() {
669        let dir = tempdir().unwrap();
670        let agent = OpenCodeAgent::new();
671        let scope = Scope::Local(dir.path().to_path_buf());
672        let custom = "export const X = 1;";
673        agent
674            .install(&scope, &spec_with_script("alpha", custom))
675            .unwrap();
676        let p = dir.path().join(".opencode/plugins/alpha.ts");
677        let body = std::fs::read_to_string(&p).unwrap();
678        assert!(body.contains("export const X = 1;"));
679    }
680
681    #[test]
682    fn install_without_script_uses_default_template() {
683        let dir = tempdir().unwrap();
684        let agent = OpenCodeAgent::new();
685        let scope = Scope::Local(dir.path().to_path_buf());
686        let s = HookSpec::builder("alpha")
687            .command_program("myapp", ["hook", "opencode"])
688            .build();
689        agent.install(&scope, &s).unwrap();
690        let body = std::fs::read_to_string(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
691        assert!(body.contains("myapp hook opencode"));
692        assert!(body.contains("tool.execute.before"));
693        assert!(body.contains("async (input, output)"));
694        assert!(body.contains("input.tool"));
695        assert!(body.contains("output.args"));
696    }
697
698    #[test]
699    fn install_without_script_quotes_program_arguments() {
700        let dir = tempdir().unwrap();
701        let agent = OpenCodeAgent::new();
702        let scope = Scope::Local(dir.path().to_path_buf());
703        let s = HookSpec::builder("alpha")
704            .command_program(
705                "my hook",
706                ["repo path", "semi;$(not run)", "`tick`", "quote's"],
707            )
708            .build();
709
710        agent.install(&scope, &s).unwrap();
711
712        let body = std::fs::read_to_string(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
713        assert!(body.contains("'my hook' 'repo path' 'semi;$(not run)'"));
714        assert!(body.contains("'\\`tick\\`'"));
715        assert!(body.contains("tool.execute.before"));
716    }
717
718    #[test]
719    fn install_with_rules_writes_agents_md() {
720        let dir = tempdir().unwrap();
721        let agent = OpenCodeAgent::new();
722        let scope = Scope::Local(dir.path().to_path_buf());
723        let s = HookSpec::builder("alpha")
724            .command_program("myapp", ["hook"])
725            .rules("Use OpenCode project rules.")
726            .build();
727
728        agent.install(&scope, &s).unwrap();
729
730        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
731        assert!(agents.contains("BEGIN AGENT-CONFIG:alpha"));
732        assert!(agents.contains("Use OpenCode project rules."));
733    }
734
735    #[test]
736    fn uninstall_removes_rules_even_when_plugin_file_missing() {
737        let dir = tempdir().unwrap();
738        let agent = OpenCodeAgent::new();
739        let scope = Scope::Local(dir.path().to_path_buf());
740        let s = HookSpec::builder("alpha")
741            .command_program("myapp", ["hook"])
742            .rules("Use OpenCode project rules.")
743            .build();
744
745        agent.install(&scope, &s).unwrap();
746        std::fs::remove_file(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
747        let report = agent.uninstall(&scope, "alpha").unwrap();
748
749        assert!(!report.not_installed);
750        assert!(!dir.path().join("AGENTS.md").exists());
751    }
752
753    #[test]
754    fn instruction_surface_round_trip_uses_agents_md() {
755        let dir = tempdir().unwrap();
756        let agent = OpenCodeAgent::new();
757        let scope = Scope::Local(dir.path().to_path_buf());
758        let spec = InstructionSpec::builder("guide")
759            .owner("myapp")
760            .placement(crate::spec::InstructionPlacement::InlineBlock)
761            .body("# Guide\n\nUse OpenCode instructions.\n")
762            .try_build()
763            .unwrap();
764
765        agent.install_instruction(&scope, &spec).unwrap();
766        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
767        assert!(agents.contains("BEGIN AGENT-CONFIG-INSTR:guide"));
768        assert!(agent.is_instruction_installed(&scope, "guide").unwrap());
769
770        agent
771            .uninstall_instruction(&scope, "guide", "myapp")
772            .unwrap();
773        assert!(!agent.is_instruction_installed(&scope, "guide").unwrap());
774    }
775
776    #[test]
777    fn install_uninstall_round_trip() {
778        let dir = tempdir().unwrap();
779        let agent = OpenCodeAgent::new();
780        let scope = Scope::Local(dir.path().to_path_buf());
781        agent
782            .install(&scope, &spec_with_script("alpha", "// x"))
783            .unwrap();
784        agent.uninstall(&scope, "alpha").unwrap();
785        assert!(!dir.path().join(".opencode/plugins/alpha.ts").exists());
786        // Empty plugins dir was pruned.
787        assert!(!dir.path().join(".opencode/plugins").exists());
788    }
789
790    #[test]
791    fn install_with_shell_script_returns_typed_error() {
792        let dir = tempdir().unwrap();
793        let agent = OpenCodeAgent::new();
794        let scope = Scope::Local(dir.path().to_path_buf());
795        let s = HookSpec::builder("alpha")
796            .command_program("noop", [] as [&str; 0])
797            .script(ScriptTemplate::Shell("#!/bin/sh\nexit 0".into()))
798            .build();
799        let err = agent.install(&scope, &s).unwrap_err();
800        assert!(matches!(err, AgentConfigError::MissingSpecField { .. }));
801    }
802
803    fn read_json(p: &std::path::Path) -> serde_json::Value {
804        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
805    }
806
807    fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
808        McpSpec::builder(name)
809            .owner(owner)
810            .stdio("npx", ["-y", "@example/server"])
811            .build()
812    }
813
814    #[test]
815    fn install_mcp_writes_object_based_mcp() {
816        let dir = tempdir().unwrap();
817        let agent = OpenCodeAgent::new();
818        let scope = Scope::Local(dir.path().to_path_buf());
819        agent
820            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
821            .unwrap();
822        let cfg = dir.path().join("opencode.json");
823        assert!(cfg.exists());
824        let v = read_json(&cfg);
825        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
826        assert_eq!(
827            v["mcp"]["github"]["command"],
828            serde_json::json!(["npx", "-y", "@example/server"])
829        );
830    }
831
832    #[test]
833    fn install_mcp_coexists_with_user_mcp_entries() {
834        let dir = tempdir().unwrap();
835        let cfg = dir.path().join("opencode.json");
836        std::fs::write(
837            &cfg,
838            r#"{ "mcp": { "user": { "type": "local", "command": ["user-cmd"] } } }"#,
839        )
840        .unwrap();
841        let agent = OpenCodeAgent::new();
842        let scope = Scope::Local(dir.path().to_path_buf());
843        agent
844            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
845            .unwrap();
846        let v = read_json(&cfg);
847        assert_eq!(v["mcp"]["user"]["command"], serde_json::json!(["user-cmd"]));
848        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
849    }
850
851    #[test]
852    fn install_mcp_reads_jsonc_with_comments_and_trailing_commas() {
853        let dir = tempdir().unwrap();
854        let cfg = dir.path().join("opencode.json");
855        std::fs::write(
856            &cfg,
857            r#"{
858  // existing OpenCode config
859  "mcp": {
860    "user": {
861      "type": "remote",
862      "url": "https://example.com/mcp",
863    },
864  },
865}
866"#,
867        )
868        .unwrap();
869        let agent = OpenCodeAgent::new();
870        let scope = Scope::Local(dir.path().to_path_buf());
871        agent
872            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
873            .unwrap();
874        let v = read_json(&cfg);
875        assert_eq!(
876            v["mcp"]["user"]["url"],
877            serde_json::json!("https://example.com/mcp")
878        );
879        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
880    }
881
882    #[test]
883    fn install_mcp_idempotent() {
884        let dir = tempdir().unwrap();
885        let agent = OpenCodeAgent::new();
886        let scope = Scope::Local(dir.path().to_path_buf());
887        let s = local_mcp_spec("github", "myapp");
888        agent.install_mcp(&scope, &s).unwrap();
889        let r = agent.install_mcp(&scope, &s).unwrap();
890        assert!(r.already_installed);
891    }
892
893    #[test]
894    fn install_mcp_does_not_collide_with_plugin_install() {
895        let dir = tempdir().unwrap();
896        let agent = OpenCodeAgent::new();
897        let scope = Scope::Local(dir.path().to_path_buf());
898        let plugin_spec = HookSpec::builder("alpha")
899            .command_program("noop", [] as [&str; 0])
900            .build();
901        agent.install(&scope, &plugin_spec).unwrap();
902        agent
903            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
904            .unwrap();
905        // Plugin file and MCP config are separate.
906        assert!(dir.path().join(".opencode/plugins/alpha.ts").exists());
907        assert!(dir.path().join("opencode.json").exists());
908    }
909
910    #[test]
911    fn uninstall_mcp_owner_mismatch_refused() {
912        let dir = tempdir().unwrap();
913        let agent = OpenCodeAgent::new();
914        let scope = Scope::Local(dir.path().to_path_buf());
915        agent
916            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
917            .unwrap();
918        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
919        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
920    }
921
922    #[test]
923    fn uninstall_mcp_round_trip() {
924        let dir = tempdir().unwrap();
925        let agent = OpenCodeAgent::new();
926        let scope = Scope::Local(dir.path().to_path_buf());
927        agent
928            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
929            .unwrap();
930        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
931        // Empty config gets removed.
932        assert!(!dir.path().join("opencode.json").exists());
933    }
934
935    #[test]
936    fn skills_resolve_multiple_roots() {
937        let dir = tempdir().unwrap();
938        let scope = Scope::Local(dir.path().to_path_buf());
939
940        // 1. Initially, should resolve to .opencode/skills (primary)
941        let root = OpenCodeAgent::resolve_skills_root(&scope, "my-skill").unwrap();
942        assert_eq!(root, dir.path().join(".opencode").join("skills"));
943
944        // 2. Install manually in .claude/skills and check if resolved root updates
945        let claude_skills = dir.path().join(".claude").join("skills");
946        std::fs::create_dir_all(&claude_skills).unwrap();
947        std::fs::create_dir(claude_skills.join("my-skill")).unwrap();
948        let root = OpenCodeAgent::resolve_skills_root(&scope, "my-skill").unwrap();
949        assert_eq!(root, claude_skills);
950
951        // 3. Let's install to .agents/skills/my-skill manually and check
952        std::fs::remove_dir(claude_skills.join("my-skill")).unwrap();
953        let agents_skills = dir.path().join(".agents").join("skills");
954        std::fs::create_dir_all(&agents_skills).unwrap();
955        std::fs::create_dir(agents_skills.join("my-skill")).unwrap();
956        let root = OpenCodeAgent::resolve_skills_root(&scope, "my-skill").unwrap();
957        assert_eq!(root, agents_skills);
958    }
959}