1use 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#[derive(Debug, Clone, Copy, Default)]
27pub struct OpenCodeAgent {
28 _private: (),
29}
30
31impl OpenCodeAgent {
32 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 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 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
372fn 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 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 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 assert!(!dir.path().join("opencode.json").exists());
621 }
622}