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//! If the caller does not supply a script, this integration falls back to a
9//! generic plugin that intercepts `tool.execute.before` for the `bash` tool
10//! and execs the rendered hook command, passing the call's args via stdin
11//! (JSON). Safe program commands are shell-quoted before rendering.
12
13use std::path::PathBuf;
14
15use crate::agents::planning as agent_planning;
16use crate::error::AgentConfigError;
17use crate::integration::{InstallReport, Integration, McpSurface, SkillSurface, UninstallReport};
18use crate::paths;
19use crate::plan::{InstallPlan, PlanTarget, RefusalReason, UninstallPlan};
20use crate::scope::{Scope, ScopeKind};
21use crate::spec::{HookSpec, McpSpec, ScriptTemplate, SkillSpec};
22use crate::status::StatusReport;
23use crate::util::{fs_atomic, mcp_json_map, ownership, planning, safe_fs, skills_dir};
24
25/// OpenCode plugin installer.
26#[derive(Debug, Clone, Copy, Default)]
27pub struct OpenCodeAgent {
28    _private: (),
29}
30
31impl OpenCodeAgent {
32    /// Construct an instance. Stateless.
33    pub const fn new() -> Self {
34        Self { _private: () }
35    }
36
37    fn plugin_path(scope: &Scope, tag: &str) -> Result<PathBuf, AgentConfigError> {
38        Ok(match scope {
39            Scope::Global => paths::opencode_plugins_dir()?.join(format!("{tag}.ts")),
40            Scope::Local(p) => p
41                .join(".opencode")
42                .join("plugins")
43                .join(format!("{tag}.ts")),
44        })
45    }
46
47    /// `~/.config/opencode/opencode.json` (Global) or
48    /// `<root>/opencode.json` (Local). MCP servers live in the object-based
49    /// `mcp` key.
50    fn config_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
51        Ok(match scope {
52            Scope::Global => paths::opencode_config_file()?,
53            Scope::Local(p) => p.join("opencode.json"),
54        })
55    }
56
57    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
58        Ok(match scope {
59            Scope::Global => paths::home_dir()?
60                .join(".config")
61                .join("opencode")
62                .join("skills"),
63            Scope::Local(p) => p.join(".opencode").join("skills"),
64        })
65    }
66}
67
68impl Integration for OpenCodeAgent {
69    fn id(&self) -> &'static str {
70        "opencode"
71    }
72
73    fn display_name(&self) -> &'static str {
74        "OpenCode"
75    }
76
77    fn supported_scopes(&self) -> &'static [ScopeKind] {
78        &[ScopeKind::Global, ScopeKind::Local]
79    }
80
81    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
82        HookSpec::validate_tag(tag)?;
83        let p = Self::plugin_path(scope, tag)?;
84        Ok(StatusReport::for_file_hook(tag, p))
85    }
86
87    fn plan_install(
88        &self,
89        scope: &Scope,
90        spec: &HookSpec,
91    ) -> Result<InstallPlan, AgentConfigError> {
92        HookSpec::validate_tag(&spec.tag)?;
93        let target = PlanTarget::Hook {
94            integration_id: Integration::id(self),
95            scope: scope.clone(),
96            tag: spec.tag.clone(),
97        };
98        let p = Self::plugin_path(scope, &spec.tag)?;
99        let body = match &spec.script {
100            Some(ScriptTemplate::TypeScript(s)) => s.clone(),
101            Some(ScriptTemplate::Shell(_)) => {
102                return Ok(InstallPlan::refused(
103                    target,
104                    None,
105                    RefusalReason::MissingRequiredSpecField,
106                ));
107            }
108            None => default_plugin_body(&spec.command.render_shell()),
109        };
110        let body = fs_atomic::ensure_trailing_newline(&body);
111        let mut changes = Vec::new();
112        planning::plan_write_file(&mut changes, &p, body.as_bytes(), true)?;
113        Ok(InstallPlan::from_changes(target, changes))
114    }
115
116    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
117        HookSpec::validate_tag(tag)?;
118        let target = PlanTarget::Hook {
119            integration_id: Integration::id(self),
120            scope: scope.clone(),
121            tag: tag.to_string(),
122        };
123        let p = Self::plugin_path(scope, tag)?;
124        let mut changes = Vec::new();
125        planning::plan_remove_file(&mut changes, &p);
126        Ok(UninstallPlan::from_changes(target, changes))
127    }
128
129    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
130        HookSpec::validate_tag(&spec.tag)?;
131        let p = Self::plugin_path(scope, &spec.tag)?;
132
133        let body = match &spec.script {
134            Some(ScriptTemplate::TypeScript(s)) => s.clone(),
135            Some(ScriptTemplate::Shell(_)) => {
136                return Err(AgentConfigError::MissingSpecField {
137                    id: "opencode",
138                    field: "script (TypeScript)",
139                });
140            }
141            None => default_plugin_body(&spec.command.render_shell()),
142        };
143        let body = fs_atomic::ensure_trailing_newline(&body);
144
145        scope.ensure_contained(&p)?;
146        let outcome = safe_fs::write(scope, &p, body.as_bytes(), true)?;
147        let mut report = InstallReport::default();
148        if outcome.no_change {
149            report.already_installed = true;
150        } else if outcome.existed {
151            report.patched.push(outcome.path.clone());
152        } else {
153            report.created.push(outcome.path.clone());
154        }
155        if let Some(b) = outcome.backup {
156            report.backed_up.push(b);
157        }
158        Ok(report)
159    }
160
161    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
162        HookSpec::validate_tag(tag)?;
163        let mut report = UninstallReport::default();
164        let p = Self::plugin_path(scope, tag)?;
165        scope.ensure_contained(&p)?;
166        if !p.exists() {
167            report.not_installed = true;
168            return Ok(report);
169        }
170        safe_fs::remove_file(scope, &p)?;
171        report.removed.push(p.clone());
172
173        // Tidy: prune empty plugins dir.
174        if let Some(parent) = p.parent() {
175            if std::fs::read_dir(parent)
176                .map(|mut it| it.next().is_none())
177                .unwrap_or(false)
178            {
179                let _ = safe_fs::remove_empty_dir(scope, parent);
180            }
181        }
182        Ok(report)
183    }
184}
185
186impl McpSurface for OpenCodeAgent {
187    fn id(&self) -> &'static str {
188        "opencode"
189    }
190
191    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
192        &[ScopeKind::Global, ScopeKind::Local]
193    }
194
195    fn mcp_status(
196        &self,
197        scope: &Scope,
198        name: &str,
199        expected_owner: &str,
200    ) -> Result<StatusReport, AgentConfigError> {
201        McpSpec::validate_name(name)?;
202        let cfg = Self::config_path(scope)?;
203        let ledger = ownership::mcp_ledger_for(&cfg);
204        let presence =
205            mcp_json_map::config_presence(&cfg, &["mcp"], name, mcp_json_map::ConfigFormat::Jsonc)?;
206        let recorded = ownership::owner_of(&ledger, name)?;
207        Ok(StatusReport::for_mcp(
208            name,
209            cfg,
210            ledger,
211            presence,
212            expected_owner,
213            recorded,
214        ))
215    }
216
217    fn plan_install_mcp(
218        &self,
219        scope: &Scope,
220        spec: &McpSpec,
221    ) -> Result<InstallPlan, AgentConfigError> {
222        agent_planning::mcp_json_map_install(
223            McpSurface::id(self),
224            scope,
225            spec,
226            Self::config_path(scope),
227            &["mcp"],
228            mcp_json_map::command_array_value,
229            mcp_json_map::ConfigFormat::Jsonc,
230        )
231    }
232
233    fn plan_uninstall_mcp(
234        &self,
235        scope: &Scope,
236        name: &str,
237        owner_tag: &str,
238    ) -> Result<UninstallPlan, AgentConfigError> {
239        agent_planning::mcp_json_map_uninstall(
240            McpSurface::id(self),
241            scope,
242            name,
243            owner_tag,
244            Self::config_path(scope),
245            &["mcp"],
246            mcp_json_map::ConfigFormat::Jsonc,
247        )
248    }
249
250    fn install_mcp(
251        &self,
252        scope: &Scope,
253        spec: &McpSpec,
254    ) -> Result<InstallReport, AgentConfigError> {
255        spec.validate()?;
256        let cfg = Self::config_path(scope)?;
257        spec.validate_local_secret_policy(scope)?;
258        scope.ensure_contained(&cfg)?;
259        let ledger = ownership::mcp_ledger_for(&cfg);
260        mcp_json_map::install(
261            &cfg,
262            &ledger,
263            spec,
264            &["mcp"],
265            mcp_json_map::command_array_value,
266            mcp_json_map::ConfigFormat::Jsonc,
267        )
268    }
269
270    fn uninstall_mcp(
271        &self,
272        scope: &Scope,
273        name: &str,
274        owner_tag: &str,
275    ) -> Result<UninstallReport, AgentConfigError> {
276        McpSpec::validate_name(name)?;
277        HookSpec::validate_tag(owner_tag)?;
278        let cfg = Self::config_path(scope)?;
279        scope.ensure_contained(&cfg)?;
280        let ledger = ownership::mcp_ledger_for(&cfg);
281        mcp_json_map::uninstall(
282            &cfg,
283            &ledger,
284            name,
285            owner_tag,
286            "mcp server",
287            &["mcp"],
288            mcp_json_map::ConfigFormat::Jsonc,
289        )
290    }
291}
292
293impl SkillSurface for OpenCodeAgent {
294    fn id(&self) -> &'static str {
295        "opencode"
296    }
297
298    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
299        &[ScopeKind::Global, ScopeKind::Local]
300    }
301
302    fn skill_status(
303        &self,
304        scope: &Scope,
305        name: &str,
306        expected_owner: &str,
307    ) -> Result<StatusReport, AgentConfigError> {
308        SkillSpec::validate_name(name)?;
309        let root = Self::skills_root(scope)?;
310        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
311        let recorded = ownership::owner_of(&ledger, name)?;
312        Ok(StatusReport::for_skill(
313            name,
314            dir,
315            manifest,
316            ledger,
317            expected_owner,
318            recorded,
319        ))
320    }
321
322    fn plan_install_skill(
323        &self,
324        scope: &Scope,
325        spec: &SkillSpec,
326    ) -> Result<InstallPlan, AgentConfigError> {
327        agent_planning::skill_install(
328            SkillSurface::id(self),
329            scope,
330            spec,
331            Self::skills_root(scope),
332        )
333    }
334
335    fn plan_uninstall_skill(
336        &self,
337        scope: &Scope,
338        name: &str,
339        owner_tag: &str,
340    ) -> Result<UninstallPlan, AgentConfigError> {
341        agent_planning::skill_uninstall(
342            SkillSurface::id(self),
343            scope,
344            name,
345            owner_tag,
346            Self::skills_root(scope),
347        )
348    }
349
350    fn install_skill(
351        &self,
352        scope: &Scope,
353        spec: &SkillSpec,
354    ) -> Result<InstallReport, AgentConfigError> {
355        let root = Self::skills_root(scope)?;
356        scope.ensure_contained(&root)?;
357        skills_dir::install(&root, spec)
358    }
359
360    fn uninstall_skill(
361        &self,
362        scope: &Scope,
363        name: &str,
364        owner_tag: &str,
365    ) -> Result<UninstallReport, AgentConfigError> {
366        let root = Self::skills_root(scope)?;
367        scope.ensure_contained(&root)?;
368        skills_dir::uninstall(&root, name, owner_tag)
369    }
370}
371
372/// A minimal TS plugin body that runs `command` before every `bash`-tool call,
373/// piping the call's args (JSON) on stdin.
374///
375/// Callers who need richer behavior should pass their own [`ScriptTemplate::TypeScript`].
376fn default_plugin_body(command: &str) -> String {
377    let escaped = escape_js_template_literal(command);
378    format!(
379        r#"// Generated by agent-config. Edit at your own risk.
380// Re-running install will overwrite this file.
381
382import type {{ Plugin }} from "@opencode-ai/plugin";
383
384export const Hook: Plugin = async ({{ $ }}) => ({{
385  "tool.execute.before": async ({{ tool }}, {{ args }}) => {{
386    if (tool !== "bash") return;
387    const payload = JSON.stringify({{ tool, args }});
388    await $`echo ${{payload}} | {escaped}`;
389  }},
390}});
391"#
392    )
393}
394
395fn escape_js_template_literal(s: &str) -> String {
396    s.replace('\\', "\\\\")
397        .replace('`', "\\`")
398        .replace("${", "\\${")
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::spec::{Event, Matcher};
405    use tempfile::tempdir;
406
407    fn spec_with_script(tag: &str, ts: &str) -> HookSpec {
408        HookSpec::builder(tag)
409            .command_program("noop", [] as [&str; 0])
410            .matcher(Matcher::Bash)
411            .event(Event::PreToolUse)
412            .script(ScriptTemplate::TypeScript(ts.into()))
413            .build()
414    }
415
416    #[test]
417    fn install_writes_typescript_plugin_file() {
418        let dir = tempdir().unwrap();
419        let agent = OpenCodeAgent::new();
420        let scope = Scope::Local(dir.path().to_path_buf());
421        let custom = "export const X = 1;";
422        agent
423            .install(&scope, &spec_with_script("alpha", custom))
424            .unwrap();
425        let p = dir.path().join(".opencode/plugins/alpha.ts");
426        let body = std::fs::read_to_string(&p).unwrap();
427        assert!(body.contains("export const X = 1;"));
428    }
429
430    #[test]
431    fn install_without_script_uses_default_template() {
432        let dir = tempdir().unwrap();
433        let agent = OpenCodeAgent::new();
434        let scope = Scope::Local(dir.path().to_path_buf());
435        let s = HookSpec::builder("alpha")
436            .command_program("myapp", ["hook", "opencode"])
437            .build();
438        agent.install(&scope, &s).unwrap();
439        let body = std::fs::read_to_string(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
440        assert!(body.contains("myapp hook opencode"));
441        assert!(body.contains("tool.execute.before"));
442    }
443
444    #[test]
445    fn install_without_script_quotes_program_arguments() {
446        let dir = tempdir().unwrap();
447        let agent = OpenCodeAgent::new();
448        let scope = Scope::Local(dir.path().to_path_buf());
449        let s = HookSpec::builder("alpha")
450            .command_program(
451                "my hook",
452                ["repo path", "semi;$(not run)", "`tick`", "quote's"],
453            )
454            .build();
455
456        agent.install(&scope, &s).unwrap();
457
458        let body = std::fs::read_to_string(dir.path().join(".opencode/plugins/alpha.ts")).unwrap();
459        assert!(body.contains("'my hook' 'repo path' 'semi;$(not run)'"));
460        assert!(body.contains("'\\`tick\\`'"));
461        assert!(body.contains("tool.execute.before"));
462    }
463
464    #[test]
465    fn install_uninstall_round_trip() {
466        let dir = tempdir().unwrap();
467        let agent = OpenCodeAgent::new();
468        let scope = Scope::Local(dir.path().to_path_buf());
469        agent
470            .install(&scope, &spec_with_script("alpha", "// x"))
471            .unwrap();
472        agent.uninstall(&scope, "alpha").unwrap();
473        assert!(!dir.path().join(".opencode/plugins/alpha.ts").exists());
474        // Empty plugins dir was pruned.
475        assert!(!dir.path().join(".opencode/plugins").exists());
476    }
477
478    #[test]
479    fn install_with_shell_script_returns_typed_error() {
480        let dir = tempdir().unwrap();
481        let agent = OpenCodeAgent::new();
482        let scope = Scope::Local(dir.path().to_path_buf());
483        let s = HookSpec::builder("alpha")
484            .command_program("noop", [] as [&str; 0])
485            .script(ScriptTemplate::Shell("#!/bin/sh\nexit 0".into()))
486            .build();
487        let err = agent.install(&scope, &s).unwrap_err();
488        assert!(matches!(err, AgentConfigError::MissingSpecField { .. }));
489    }
490
491    fn read_json(p: &std::path::Path) -> serde_json::Value {
492        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
493    }
494
495    fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
496        McpSpec::builder(name)
497            .owner(owner)
498            .stdio("npx", ["-y", "@example/server"])
499            .build()
500    }
501
502    #[test]
503    fn install_mcp_writes_object_based_mcp() {
504        let dir = tempdir().unwrap();
505        let agent = OpenCodeAgent::new();
506        let scope = Scope::Local(dir.path().to_path_buf());
507        agent
508            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
509            .unwrap();
510        let cfg = dir.path().join("opencode.json");
511        assert!(cfg.exists());
512        let v = read_json(&cfg);
513        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
514        assert_eq!(
515            v["mcp"]["github"]["command"],
516            serde_json::json!(["npx", "-y", "@example/server"])
517        );
518    }
519
520    #[test]
521    fn install_mcp_coexists_with_user_mcp_entries() {
522        let dir = tempdir().unwrap();
523        let cfg = dir.path().join("opencode.json");
524        std::fs::write(
525            &cfg,
526            r#"{ "mcp": { "user": { "type": "local", "command": ["user-cmd"] } } }"#,
527        )
528        .unwrap();
529        let agent = OpenCodeAgent::new();
530        let scope = Scope::Local(dir.path().to_path_buf());
531        agent
532            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
533            .unwrap();
534        let v = read_json(&cfg);
535        assert_eq!(v["mcp"]["user"]["command"], serde_json::json!(["user-cmd"]));
536        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
537    }
538
539    #[test]
540    fn install_mcp_reads_jsonc_with_comments_and_trailing_commas() {
541        let dir = tempdir().unwrap();
542        let cfg = dir.path().join("opencode.json");
543        std::fs::write(
544            &cfg,
545            r#"{
546  // existing OpenCode config
547  "mcp": {
548    "user": {
549      "type": "remote",
550      "url": "https://example.com/mcp",
551    },
552  },
553}
554"#,
555        )
556        .unwrap();
557        let agent = OpenCodeAgent::new();
558        let scope = Scope::Local(dir.path().to_path_buf());
559        agent
560            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
561            .unwrap();
562        let v = read_json(&cfg);
563        assert_eq!(
564            v["mcp"]["user"]["url"],
565            serde_json::json!("https://example.com/mcp")
566        );
567        assert_eq!(v["mcp"]["github"]["type"], serde_json::json!("local"));
568    }
569
570    #[test]
571    fn install_mcp_idempotent() {
572        let dir = tempdir().unwrap();
573        let agent = OpenCodeAgent::new();
574        let scope = Scope::Local(dir.path().to_path_buf());
575        let s = local_mcp_spec("github", "myapp");
576        agent.install_mcp(&scope, &s).unwrap();
577        let r = agent.install_mcp(&scope, &s).unwrap();
578        assert!(r.already_installed);
579    }
580
581    #[test]
582    fn install_mcp_does_not_collide_with_plugin_install() {
583        let dir = tempdir().unwrap();
584        let agent = OpenCodeAgent::new();
585        let scope = Scope::Local(dir.path().to_path_buf());
586        let plugin_spec = HookSpec::builder("alpha")
587            .command_program("noop", [] as [&str; 0])
588            .build();
589        agent.install(&scope, &plugin_spec).unwrap();
590        agent
591            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
592            .unwrap();
593        // Plugin file and MCP config are separate.
594        assert!(dir.path().join(".opencode/plugins/alpha.ts").exists());
595        assert!(dir.path().join("opencode.json").exists());
596    }
597
598    #[test]
599    fn uninstall_mcp_owner_mismatch_refused() {
600        let dir = tempdir().unwrap();
601        let agent = OpenCodeAgent::new();
602        let scope = Scope::Local(dir.path().to_path_buf());
603        agent
604            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
605            .unwrap();
606        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
607        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
608    }
609
610    #[test]
611    fn uninstall_mcp_round_trip() {
612        let dir = tempdir().unwrap();
613        let agent = OpenCodeAgent::new();
614        let scope = Scope::Local(dir.path().to_path_buf());
615        agent
616            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
617            .unwrap();
618        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
619        // Empty config gets removed.
620        assert!(!dir.path().join("opencode.json").exists());
621    }
622}