1use 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#[derive(Debug, Clone, Copy, Default)]
34pub struct OpenCodeAgent {
35 _private: (),
36}
37
38impl OpenCodeAgent {
39 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 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 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
530fn 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 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 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 assert!(!body2.contains("if (input.tool !== \"git\")"));
637 assert!(body2.contains("const payload = JSON.stringify({ event: input });"));
638
639 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 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 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 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 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 let root = OpenCodeAgent::resolve_skills_root(&scope, "my-skill").unwrap();
933 assert_eq!(root, dir.path().join(".opencode").join("skills"));
934
935 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 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}