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(SkillSurface::id(self), scope, spec, Ok(root))
432    }
433
434    fn plan_uninstall_skill(
435        &self,
436        scope: &Scope,
437        name: &str,
438        owner_tag: &str,
439    ) -> Result<UninstallPlan, AgentConfigError> {
440        let root = Self::resolve_skills_root(scope, name)?;
441        agent_planning::skill_uninstall(SkillSurface::id(self), scope, name, owner_tag, Ok(root))
442    }
443
444    fn install_skill(
445        &self,
446        scope: &Scope,
447        spec: &SkillSpec,
448    ) -> Result<InstallReport, AgentConfigError> {
449        let root = Self::resolve_skills_root(scope, &spec.name)?;
450        scope.ensure_contained(&root)?;
451        skills_dir::install(&root, spec)
452    }
453
454    fn uninstall_skill(
455        &self,
456        scope: &Scope,
457        name: &str,
458        owner_tag: &str,
459    ) -> Result<UninstallReport, AgentConfigError> {
460        let root = Self::resolve_skills_root(scope, name)?;
461        scope.ensure_contained(&root)?;
462        skills_dir::uninstall(&root, name, owner_tag)
463    }
464}
465
466impl InstructionSurface for OpenCodeAgent {
467    fn id(&self) -> &'static str {
468        "opencode"
469    }
470
471    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
472        &[ScopeKind::Global, ScopeKind::Local]
473    }
474
475    fn instruction_status(
476        &self,
477        scope: &Scope,
478        name: &str,
479        expected_owner: &str,
480    ) -> Result<StatusReport, AgentConfigError> {
481        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
482    }
483
484    fn plan_install_instruction(
485        &self,
486        scope: &Scope,
487        spec: &InstructionSpec,
488    ) -> Result<InstallPlan, AgentConfigError> {
489        instructions_dir::inline_plan_install(
490            InstructionSurface::id(self),
491            scope,
492            self.inline_layout(scope),
493            spec,
494        )
495    }
496
497    fn plan_uninstall_instruction(
498        &self,
499        scope: &Scope,
500        name: &str,
501        owner_tag: &str,
502    ) -> Result<UninstallPlan, AgentConfigError> {
503        instructions_dir::inline_plan_uninstall(
504            InstructionSurface::id(self),
505            scope,
506            self.inline_layout(scope),
507            name,
508            owner_tag,
509        )
510    }
511
512    fn install_instruction(
513        &self,
514        scope: &Scope,
515        spec: &InstructionSpec,
516    ) -> Result<InstallReport, AgentConfigError> {
517        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
518    }
519
520    fn uninstall_instruction(
521        &self,
522        scope: &Scope,
523        name: &str,
524        owner_tag: &str,
525    ) -> Result<UninstallReport, AgentConfigError> {
526        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
527    }
528}
529
530/// A dynamically generated TS plugin body based on the event and matcher of HookSpec.
531fn generate_plugin_body(spec: &HookSpec) -> String {
532    let command = spec.command.render_shell();
533    let escaped = escape_js_template_literal(&command);
534
535    let hook_name = match &spec.event {
536        Event::PreToolUse => "tool.execute.before",
537        Event::PostToolUse => "tool.execute.after",
538        Event::Custom(name) => name.as_str(),
539        other => other.as_str(),
540    };
541
542    let is_tool_event = hook_name == "tool.execute.before" || hook_name == "tool.execute.after";
543
544    let guard = if is_tool_event {
545        match &spec.matcher {
546            Matcher::All => "".to_string(),
547            Matcher::Bash => "    if (input.tool !== \"bash\") return;\n".to_string(),
548            Matcher::Exact(tool) => format!("    if (input.tool !== {:?}) return;\n", tool),
549            Matcher::AnyOf(tools) => {
550                let list = tools
551                    .iter()
552                    .map(|t| format!("{:?}", t))
553                    .collect::<Vec<_>>()
554                    .join(", ");
555                format!("    if (![{}].includes(input.tool)) return;\n", list)
556            }
557            Matcher::Regex(pattern) => {
558                let escaped_pat = pattern.replace('\\', "\\\\").replace('"', "\\\"");
559                format!(
560                    "    if (!new RegExp(\"{}\").test(input.tool)) return;\n",
561                    escaped_pat
562                )
563            }
564        }
565    } else {
566        "".to_string()
567    };
568
569    let payload_js = if is_tool_event {
570        "    const payload = JSON.stringify({ tool: input.tool, args: output.args });"
571    } else {
572        "    const payload = JSON.stringify({ event: input });"
573    };
574
575    format!(
576        r#"// Generated by agent-config. Edit at your own risk.
577// Re-running install will overwrite this file.
578
579import type {{ Plugin }} from "@opencode-ai/plugin";
580
581export const Hook: Plugin = async ({{ $ }}) => ({{
582  {:?}: async (input, output) => {{
583{}{}
584    await $`echo ${{payload}} | {escaped}`;
585  }},
586}});
587"#,
588        hook_name, guard, payload_js
589    )
590}
591
592fn escape_js_template_literal(s: &str) -> String {
593    s.replace('\\', "\\\\")
594        .replace('`', "\\`")
595        .replace("${", "\\${")
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::spec::{Event, Matcher};
602    use tempfile::tempdir;
603
604    fn spec_with_script(tag: &str, ts: &str) -> HookSpec {
605        HookSpec::builder(tag)
606            .command_program("noop", [] as [&str; 0])
607            .matcher(Matcher::Bash)
608            .event(Event::PreToolUse)
609            .script(ScriptTemplate::TypeScript(ts.into()))
610            .build()
611    }
612
613    #[test]
614    fn generate_plugin_body_with_various_matchers_and_events() {
615        // Test PostToolUse with Matcher::All
616        let s1 = HookSpec::builder("all_post")
617            .command_program("test", [] as [&str; 0])
618            .matcher(Matcher::All)
619            .event(Event::PostToolUse)
620            .build();
621        let body1 = generate_plugin_body(&s1);
622        assert!(body1.contains("\"tool.execute.after\""));
623        assert!(!body1.contains("if (input.tool"));
624        assert!(body1
625            .contains("const payload = JSON.stringify({ tool: input.tool, args: output.args });"));
626
627        // Test Custom event with Matcher::Exact
628        let s2 = HookSpec::builder("custom")
629            .command_program("test", [] as [&str; 0])
630            .matcher(Matcher::Exact("git".into()))
631            .event(Event::Custom("session.idle".into()))
632            .build();
633        let body2 = generate_plugin_body(&s2);
634        assert!(body2.contains("\"session.idle\""));
635        // Custom non-tool event should not generate matcher guard since input.tool might not exist
636        assert!(!body2.contains("if (input.tool !== \"git\")"));
637        assert!(body2.contains("const payload = JSON.stringify({ event: input });"));
638
639        // Test PreToolUse with Matcher::AnyOf
640        let s3 = HookSpec::builder("any_of")
641            .command_program("test", [] as [&str; 0])
642            .matcher(Matcher::AnyOf(vec!["git".into(), "bash".into()]))
643            .event(Event::PreToolUse)
644            .build();
645        let body3 = generate_plugin_body(&s3);
646        assert!(body3.contains("if (![\"git\", \"bash\"].includes(input.tool))"));
647
648        // Test PreToolUse with Matcher::Regex
649        let s4 = HookSpec::builder("regex")
650            .command_program("test", [] as [&str; 0])
651            .matcher(Matcher::Regex("g.t".into()))
652            .event(Event::PreToolUse)
653            .build();
654        let body4 = generate_plugin_body(&s4);
655        assert!(body4.contains("if (!new RegExp(\"g.t\").test(input.tool))"));
656    }
657
658    #[test]
659    fn install_writes_typescript_plugin_file() {
660        let dir = tempdir().unwrap();
661        let agent = OpenCodeAgent::new();
662        let scope = Scope::Local(dir.path().to_path_buf());
663        let custom = "export const X = 1;";
664        agent
665            .install(&scope, &spec_with_script("alpha", custom))
666            .unwrap();
667        let p = dir.path().join(".opencode/plugins/alpha.ts");
668        let body = std::fs::read_to_string(&p).unwrap();
669        assert!(body.contains("export const X = 1;"));
670    }
671
672    #[test]
673    fn install_without_script_uses_default_template() {
674        let dir = tempdir().unwrap();
675        let agent = OpenCodeAgent::new();
676        let scope = Scope::Local(dir.path().to_path_buf());
677        let s = HookSpec::builder("alpha")
678            .command_program("myapp", ["hook", "opencode"])
679            .build();
680        agent.install(&scope, &s).unwrap();
681        let body = std::fs::read_to_string(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
682        assert!(body.contains("myapp hook opencode"));
683        assert!(body.contains("tool.execute.before"));
684        assert!(body.contains("async (input, output)"));
685        assert!(body.contains("input.tool"));
686        assert!(body.contains("output.args"));
687    }
688
689    #[test]
690    fn install_without_script_quotes_program_arguments() {
691        let dir = tempdir().unwrap();
692        let agent = OpenCodeAgent::new();
693        let scope = Scope::Local(dir.path().to_path_buf());
694        let s = HookSpec::builder("alpha")
695            .command_program(
696                "my hook",
697                ["repo path", "semi;$(not run)", "`tick`", "quote's"],
698            )
699            .build();
700
701        agent.install(&scope, &s).unwrap();
702
703        let body = std::fs::read_to_string(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
704        assert!(body.contains("'my hook' 'repo path' 'semi;$(not run)'"));
705        assert!(body.contains("'\\`tick\\`'"));
706        assert!(body.contains("tool.execute.before"));
707    }
708
709    #[test]
710    fn install_with_rules_writes_agents_md() {
711        let dir = tempdir().unwrap();
712        let agent = OpenCodeAgent::new();
713        let scope = Scope::Local(dir.path().to_path_buf());
714        let s = HookSpec::builder("alpha")
715            .command_program("myapp", ["hook"])
716            .rules("Use OpenCode project rules.")
717            .build();
718
719        agent.install(&scope, &s).unwrap();
720
721        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
722        assert!(agents.contains("BEGIN AGENT-CONFIG:alpha"));
723        assert!(agents.contains("Use OpenCode project rules."));
724    }
725
726    #[test]
727    fn uninstall_removes_rules_even_when_plugin_file_missing() {
728        let dir = tempdir().unwrap();
729        let agent = OpenCodeAgent::new();
730        let scope = Scope::Local(dir.path().to_path_buf());
731        let s = HookSpec::builder("alpha")
732            .command_program("myapp", ["hook"])
733            .rules("Use OpenCode project rules.")
734            .build();
735
736        agent.install(&scope, &s).unwrap();
737        std::fs::remove_file(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
738        let report = agent.uninstall(&scope, "alpha").unwrap();
739
740        assert!(!report.not_installed);
741        assert!(!dir.path().join("AGENTS.md").exists());
742    }
743
744    #[test]
745    fn instruction_surface_round_trip_uses_agents_md() {
746        let dir = tempdir().unwrap();
747        let agent = OpenCodeAgent::new();
748        let scope = Scope::Local(dir.path().to_path_buf());
749        let spec = InstructionSpec::builder("guide")
750            .owner("myapp")
751            .placement(crate::spec::InstructionPlacement::InlineBlock)
752            .body("# Guide\n\nUse OpenCode instructions.\n")
753            .try_build()
754            .unwrap();
755
756        agent.install_instruction(&scope, &spec).unwrap();
757        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
758        assert!(agents.contains("BEGIN AGENT-CONFIG-INSTR:guide"));
759        assert!(agent.is_instruction_installed(&scope, "guide").unwrap());
760
761        agent
762            .uninstall_instruction(&scope, "guide", "myapp")
763            .unwrap();
764        assert!(!agent.is_instruction_installed(&scope, "guide").unwrap());
765    }
766
767    #[test]
768    fn install_uninstall_round_trip() {
769        let dir = tempdir().unwrap();
770        let agent = OpenCodeAgent::new();
771        let scope = Scope::Local(dir.path().to_path_buf());
772        agent
773            .install(&scope, &spec_with_script("alpha", "// x"))
774            .unwrap();
775        agent.uninstall(&scope, "alpha").unwrap();
776        assert!(!dir.path().join(".opencode/plugins/alpha.ts").exists());
777        // Empty plugins dir was pruned.
778        assert!(!dir.path().join(".opencode/plugins").exists());
779    }
780
781    #[test]
782    fn install_with_shell_script_returns_typed_error() {
783        let dir = tempdir().unwrap();
784        let agent = OpenCodeAgent::new();
785        let scope = Scope::Local(dir.path().to_path_buf());
786        let s = HookSpec::builder("alpha")
787            .command_program("noop", [] as [&str; 0])
788            .script(ScriptTemplate::Shell("#!/bin/sh\nexit 0".into()))
789            .build();
790        let err = agent.install(&scope, &s).unwrap_err();
791        assert!(matches!(err, AgentConfigError::MissingSpecField { .. }));
792    }
793
794    fn read_json(p: &std::path::Path) -> serde_json::Value {
795        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
796    }
797
798    fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
799        McpSpec::builder(name)
800            .owner(owner)
801            .stdio("npx", ["-y", "@example/server"])
802            .build()
803    }
804
805    #[test]
806    fn install_mcp_writes_object_based_mcp() {
807        let dir = tempdir().unwrap();
808        let agent = OpenCodeAgent::new();
809        let scope = Scope::Local(dir.path().to_path_buf());
810        agent
811            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
812            .unwrap();
813        let cfg = dir.path().join("opencode.json");
814        assert!(cfg.exists());
815        let v = read_json(&cfg);
816        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
817        assert_eq!(
818            v["mcp"]["github"]["command"],
819            serde_json::json!(["npx", "-y", "@example/server"])
820        );
821    }
822
823    #[test]
824    fn install_mcp_coexists_with_user_mcp_entries() {
825        let dir = tempdir().unwrap();
826        let cfg = dir.path().join("opencode.json");
827        std::fs::write(
828            &cfg,
829            r#"{ "mcp": { "user": { "type": "local", "command": ["user-cmd"] } } }"#,
830        )
831        .unwrap();
832        let agent = OpenCodeAgent::new();
833        let scope = Scope::Local(dir.path().to_path_buf());
834        agent
835            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
836            .unwrap();
837        let v = read_json(&cfg);
838        assert_eq!(v["mcp"]["user"]["command"], serde_json::json!(["user-cmd"]));
839        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
840    }
841
842    #[test]
843    fn install_mcp_reads_jsonc_with_comments_and_trailing_commas() {
844        let dir = tempdir().unwrap();
845        let cfg = dir.path().join("opencode.json");
846        std::fs::write(
847            &cfg,
848            r#"{
849  // existing OpenCode config
850  "mcp": {
851    "user": {
852      "type": "remote",
853      "url": "https://example.com/mcp",
854    },
855  },
856}
857"#,
858        )
859        .unwrap();
860        let agent = OpenCodeAgent::new();
861        let scope = Scope::Local(dir.path().to_path_buf());
862        agent
863            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
864            .unwrap();
865        let v = read_json(&cfg);
866        assert_eq!(
867            v["mcp"]["user"]["url"],
868            serde_json::json!("https://example.com/mcp")
869        );
870        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
871    }
872
873    #[test]
874    fn install_mcp_idempotent() {
875        let dir = tempdir().unwrap();
876        let agent = OpenCodeAgent::new();
877        let scope = Scope::Local(dir.path().to_path_buf());
878        let s = local_mcp_spec("github", "myapp");
879        agent.install_mcp(&scope, &s).unwrap();
880        let r = agent.install_mcp(&scope, &s).unwrap();
881        assert!(r.already_installed);
882    }
883
884    #[test]
885    fn install_mcp_does_not_collide_with_plugin_install() {
886        let dir = tempdir().unwrap();
887        let agent = OpenCodeAgent::new();
888        let scope = Scope::Local(dir.path().to_path_buf());
889        let plugin_spec = HookSpec::builder("alpha")
890            .command_program("noop", [] as [&str; 0])
891            .build();
892        agent.install(&scope, &plugin_spec).unwrap();
893        agent
894            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
895            .unwrap();
896        // Plugin file and MCP config are separate.
897        assert!(dir.path().join(".opencode/plugins/alpha.ts").exists());
898        assert!(dir.path().join("opencode.json").exists());
899    }
900
901    #[test]
902    fn uninstall_mcp_owner_mismatch_refused() {
903        let dir = tempdir().unwrap();
904        let agent = OpenCodeAgent::new();
905        let scope = Scope::Local(dir.path().to_path_buf());
906        agent
907            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
908            .unwrap();
909        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
910        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
911    }
912
913    #[test]
914    fn uninstall_mcp_round_trip() {
915        let dir = tempdir().unwrap();
916        let agent = OpenCodeAgent::new();
917        let scope = Scope::Local(dir.path().to_path_buf());
918        agent
919            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
920            .unwrap();
921        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
922        // Empty config gets removed.
923        assert!(!dir.path().join("opencode.json").exists());
924    }
925
926    #[test]
927    fn skills_resolve_multiple_roots() {
928        let dir = tempdir().unwrap();
929        let scope = Scope::Local(dir.path().to_path_buf());
930
931        // 1. Initially, should resolve to .opencode/skills (primary)
932        let root = OpenCodeAgent::resolve_skills_root(&scope, "my-skill").unwrap();
933        assert_eq!(root, dir.path().join(".opencode").join("skills"));
934
935        // 2. Install manually in .claude/skills and check if resolved root updates
936        let claude_skills = dir.path().join(".claude").join("skills");
937        std::fs::create_dir_all(&claude_skills).unwrap();
938        std::fs::create_dir(claude_skills.join("my-skill")).unwrap();
939        let root = OpenCodeAgent::resolve_skills_root(&scope, "my-skill").unwrap();
940        assert_eq!(root, claude_skills);
941
942        // 3. Let's install to .agents/skills/my-skill manually and check
943        std::fs::remove_dir(claude_skills.join("my-skill")).unwrap();
944        let agents_skills = dir.path().join(".agents").join("skills");
945        std::fs::create_dir_all(&agents_skills).unwrap();
946        std::fs::create_dir(agents_skills.join("my-skill")).unwrap();
947        let root = OpenCodeAgent::resolve_skills_root(&scope, "my-skill").unwrap();
948        assert_eq!(root, agents_skills);
949    }
950}