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