1use super::{
43 FrameworkAdapter, InstallHooksResult, ParsedHookInput, ParsedHookInputBuilder,
44 UninstallHooksResult, common,
45};
46use crate::model::EventType;
47use crate::model::error::InitError;
48use std::path::{Path, PathBuf};
49
50const PLUGIN_TS: &str = include_str!("opencode-plugin/mi6.ts");
52
53const PLUGIN_FILENAME: &str = "mi6.ts";
55
56pub struct OpenCodeAdapter;
60
61impl OpenCodeAdapter {
62 fn plugin_dir() -> Option<PathBuf> {
68 dirs::home_dir().map(|h| h.join(".config/opencode/plugin"))
69 }
70
71 fn plugin_path() -> Option<PathBuf> {
73 Self::plugin_dir().map(|d| d.join(PLUGIN_FILENAME))
74 }
75
76 fn is_plugin_installed() -> bool {
78 Self::plugin_path().is_some_and(|p| p.exists())
79 }
80}
81
82impl FrameworkAdapter for OpenCodeAdapter {
83 fn name(&self) -> &'static str {
84 "opencode"
85 }
86
87 fn display_name(&self) -> &'static str {
88 "OpenCode"
89 }
90
91 fn project_config_path(&self) -> PathBuf {
92 PathBuf::from(".opencode/plugin/mi6.ts")
94 }
95
96 fn user_config_path(&self) -> Option<PathBuf> {
97 Self::plugin_path()
98 }
99
100 fn generate_hooks_config(
101 &self,
102 _enabled_events: &[EventType],
103 _mi6_bin: &str,
104 otel_enabled: bool,
105 otel_port: u16,
106 ) -> serde_json::Value {
107 let mut config = serde_json::json!({
109 "plugin": {
110 "path": Self::plugin_path().map(|p| p.to_string_lossy().to_string()),
111 "events": self.supported_events()
112 }
113 });
114
115 if otel_enabled {
117 config["otel"] = serde_json::json!({
118 "enabled": true,
119 "port": otel_port,
120 "env_var": "MI6_OTEL_PORT",
121 "note": "Set MI6_OTEL_PORT env var to configure. Plugin starts OTEL server on session start."
122 });
123 }
124
125 config
126 }
127
128 fn merge_config(
129 &self,
130 generated: serde_json::Value,
131 _existing: Option<serde_json::Value>,
132 ) -> serde_json::Value {
133 generated
135 }
136
137 fn parse_hook_input(
138 &self,
139 event_type: &str,
140 stdin_json: &serde_json::Value,
141 ) -> ParsedHookInput {
142 let mut parsed = ParsedHookInputBuilder::new(stdin_json)
146 .session_id_or(&["sessionId", "session_id", "id"])
147 .cwd_or(&["cwd", "workingDirectory", "directory"])
148 .tool_use_id_or(&["toolUseId", "tool_use_id", "executionId"])
149 .model_or(&["model", "modelID"])
150 .tokens_input("tokens_input")
151 .tokens_output("tokens_output")
152 .tokens_cache_read("tokens_cache_read")
153 .tokens_cache_write("tokens_cache_write")
154 .cost_usd("cost_usd")
155 .prompt("prompt")
156 .build();
157
158 if event_type.contains("tool") {
161 parsed.tool_name = ParsedHookInputBuilder::new(stdin_json)
162 .tool_name_or(&["toolName", "tool_name", "name"])
163 .build()
164 .tool_name;
165 }
166
167 parsed
168 }
172
173 fn map_event_type(&self, framework_event: &str) -> EventType {
174 match framework_event {
175 "SessionStart" => EventType::SessionStart,
177 "SessionEnd" => EventType::SessionEnd,
178 "Stop" => EventType::Stop,
179 "UserPromptSubmit" => EventType::UserPromptSubmit,
180 "ApiRequest" => EventType::ApiRequest,
181 "PreToolUse" => EventType::PreToolUse,
182 "PostToolUse" => EventType::PostToolUse,
183 "PreCompact" => EventType::PreCompact,
184 "PermissionRequest" => EventType::PermissionRequest,
185
186 "FileEdited" => EventType::Custom("FileEdited".to_string()),
188 "CommandExecuted" => EventType::Custom("CommandExecuted".to_string()),
189 "SessionError" => EventType::Custom("SessionError".to_string()),
190
191 "session_completed" => EventType::SessionEnd,
193 "file_edited" => EventType::Custom("FileEdited".to_string()),
194
195 other => other
197 .parse()
198 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
199 }
200 }
201
202 fn supported_events(&self) -> Vec<&'static str> {
203 vec![
205 "SessionStart",
206 "SessionEnd",
207 "Stop",
208 "UserPromptSubmit",
209 "ApiRequest",
210 "PreToolUse",
211 "PostToolUse",
212 "PreCompact",
213 "PermissionRequest",
214 "FileEdited",
215 "CommandExecuted",
216 "SessionError",
217 ]
218 }
219
220 fn framework_specific_events(&self) -> Vec<&'static str> {
221 vec!["FileEdited", "CommandExecuted", "SessionError"]
223 }
224
225 fn detection_env_vars(&self) -> &[&'static str] {
226 &["OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR"]
230 }
231
232 fn is_installed(&self) -> bool {
233 common::is_framework_installed(
235 dirs::home_dir().map(|h| h.join(".config/opencode")),
236 "opencode",
237 )
238 }
239
240 fn remove_hooks(&self, _existing: serde_json::Value) -> Option<serde_json::Value> {
241 None
243 }
244
245 fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
250 Self::plugin_path()
252 .ok_or_else(|| InitError::Config("could not determine plugin path".to_string()))
253 }
254
255 fn has_mi6_hooks(&self, _local: bool, _settings_local: bool) -> bool {
256 Self::is_plugin_installed()
257 }
258
259 fn install_hooks(
260 &self,
261 _path: &Path,
262 _hooks: &serde_json::Value,
263 _otel_env: Option<serde_json::Value>,
264 _remove_otel: bool,
265 ) -> Result<InstallHooksResult, InitError> {
266 let plugin_dir = Self::plugin_dir()
268 .ok_or_else(|| InitError::Config("could not determine plugin directory".to_string()))?;
269
270 if !plugin_dir.exists() {
272 std::fs::create_dir_all(&plugin_dir).map_err(|e| {
273 InitError::Config(format!(
274 "failed to create plugin directory {}: {}",
275 plugin_dir.display(),
276 e
277 ))
278 })?;
279 }
280
281 let plugin_path = plugin_dir.join(PLUGIN_FILENAME);
283 std::fs::write(&plugin_path, PLUGIN_TS).map_err(|e| {
284 InitError::Config(format!(
285 "failed to write plugin file {}: {}",
286 plugin_path.display(),
287 e
288 ))
289 })?;
290
291 Ok(InstallHooksResult::default())
293 }
294
295 fn uninstall_hooks(
296 &self,
297 _local: bool,
298 _settings_local: bool,
299 ) -> Result<UninstallHooksResult, InitError> {
300 let Some(plugin_path) = Self::plugin_path() else {
301 return Ok(UninstallHooksResult {
302 hooks_removed: false,
303 commands_run: vec![],
304 });
305 };
306
307 if !plugin_path.exists() {
308 return Ok(UninstallHooksResult {
309 hooks_removed: false,
310 commands_run: vec![],
311 });
312 }
313
314 std::fs::remove_file(&plugin_path).map_err(|e| {
316 InitError::Config(format!(
317 "failed to remove plugin file {}: {}",
318 plugin_path.display(),
319 e
320 ))
321 })?;
322
323 Ok(UninstallHooksResult {
324 hooks_removed: true,
325 commands_run: vec![],
326 })
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn test_name() {
336 let adapter = OpenCodeAdapter;
337 assert_eq!(adapter.name(), "opencode");
338 assert_eq!(adapter.display_name(), "OpenCode");
339 }
340
341 #[test]
342 fn test_project_config_path() {
343 let adapter = OpenCodeAdapter;
344 assert_eq!(
345 adapter.project_config_path(),
346 PathBuf::from(".opencode/plugin/mi6.ts")
347 );
348 }
349
350 #[test]
351 fn test_map_event_type() {
352 let adapter = OpenCodeAdapter;
353
354 assert_eq!(
356 adapter.map_event_type("SessionStart"),
357 EventType::SessionStart
358 );
359 assert_eq!(adapter.map_event_type("SessionEnd"), EventType::SessionEnd);
360 assert_eq!(adapter.map_event_type("Stop"), EventType::Stop);
361 assert_eq!(adapter.map_event_type("PreToolUse"), EventType::PreToolUse);
362 assert_eq!(
363 adapter.map_event_type("PostToolUse"),
364 EventType::PostToolUse
365 );
366 assert_eq!(adapter.map_event_type("PreCompact"), EventType::PreCompact);
367 assert_eq!(
368 adapter.map_event_type("PermissionRequest"),
369 EventType::PermissionRequest
370 );
371
372 assert_eq!(
374 adapter.map_event_type("FileEdited"),
375 EventType::Custom("FileEdited".to_string())
376 );
377 assert_eq!(
378 adapter.map_event_type("CommandExecuted"),
379 EventType::Custom("CommandExecuted".to_string())
380 );
381 assert_eq!(
382 adapter.map_event_type("SessionError"),
383 EventType::Custom("SessionError".to_string())
384 );
385
386 assert_eq!(
388 adapter.map_event_type("session_completed"),
389 EventType::SessionEnd
390 );
391 assert_eq!(
392 adapter.map_event_type("file_edited"),
393 EventType::Custom("FileEdited".to_string())
394 );
395
396 assert_eq!(
398 adapter.map_event_type("unknown_event"),
399 EventType::Custom("unknown_event".to_string())
400 );
401 }
402
403 #[test]
404 fn test_parse_hook_input_session_event() {
405 let adapter = OpenCodeAdapter;
406 let input = serde_json::json!({
407 "sessionId": "opencode-session-123",
408 "cwd": "/projects/test"
409 });
410
411 let parsed = adapter.parse_hook_input("SessionStart", &input);
412
413 assert_eq!(parsed.session_id, Some("opencode-session-123".to_string()));
414 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
415 assert_eq!(parsed.tool_name, None);
416 assert_eq!(parsed.tool_use_id, None);
417 }
418
419 #[test]
420 fn test_parse_hook_input_tool_event() {
421 let adapter = OpenCodeAdapter;
422 let input = serde_json::json!({
423 "sessionId": "opencode-session-123",
424 "toolName": "shell",
425 "executionId": "exec-456",
426 "cwd": "/projects/test"
427 });
428
429 let parsed = adapter.parse_hook_input("tool.execute.before", &input);
430
431 assert_eq!(parsed.session_id, Some("opencode-session-123".to_string()));
432 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
433 assert_eq!(parsed.tool_name, Some("shell".to_string()));
434 assert_eq!(parsed.tool_use_id, Some("exec-456".to_string()));
435 }
436
437 #[test]
438 fn test_parse_hook_input_alternative_keys() {
439 let adapter = OpenCodeAdapter;
440 let input = serde_json::json!({
441 "session_id": "opencode-456",
442 "workingDirectory": "/home/user/project",
443 "tool_name": "read",
444 "tool_use_id": "tool-789"
445 });
446
447 let parsed = adapter.parse_hook_input("tool.execute.after", &input);
448
449 assert_eq!(parsed.session_id, Some("opencode-456".to_string()));
450 assert_eq!(parsed.cwd, Some("/home/user/project".to_string()));
451 assert_eq!(parsed.tool_name, Some("read".to_string()));
452 assert_eq!(parsed.tool_use_id, Some("tool-789".to_string()));
453 }
454
455 #[test]
456 fn test_supported_events() {
457 let adapter = OpenCodeAdapter;
458 let events = adapter.supported_events();
459
460 assert!(events.contains(&"SessionStart"));
461 assert!(events.contains(&"SessionEnd"));
462 assert!(events.contains(&"Stop"));
463 assert!(events.contains(&"PreToolUse"));
464 assert!(events.contains(&"PostToolUse"));
465 assert!(events.contains(&"UserPromptSubmit"));
466 assert!(events.contains(&"PreCompact"));
467 assert!(events.contains(&"PermissionRequest"));
468 assert!(events.contains(&"FileEdited"));
469 assert!(events.contains(&"CommandExecuted"));
470 assert!(events.contains(&"SessionError"));
471 assert!(events.contains(&"ApiRequest"));
472 assert_eq!(events.len(), 12);
473 }
474
475 #[test]
476 fn test_framework_specific_events() {
477 let adapter = OpenCodeAdapter;
478 let events = adapter.framework_specific_events();
479
480 assert!(events.contains(&"FileEdited"));
481 assert!(events.contains(&"CommandExecuted"));
482 assert!(events.contains(&"SessionError"));
483 assert_eq!(events.len(), 3);
484 }
485
486 #[test]
487 fn test_detection_env_vars() {
488 let adapter = OpenCodeAdapter;
489 let vars = adapter.detection_env_vars();
490
491 assert!(vars.contains(&"OPENCODE_CONFIG"));
492 assert!(vars.contains(&"OPENCODE_CONFIG_DIR"));
493 }
494
495 #[test]
496 fn test_embedded_plugin() {
497 assert!(!PLUGIN_TS.is_empty());
499 assert!(PLUGIN_TS.contains("export const Mi6Plugin"));
501 assert!(PLUGIN_TS.contains("event: async"));
503 assert!(PLUGIN_TS.contains("session.created"));
504 assert!(PLUGIN_TS.contains("tool.execute.before"));
505 assert!(PLUGIN_TS.contains("ingest event"));
506 assert!(PLUGIN_TS.contains("SessionStart"));
507 assert!(PLUGIN_TS.contains("--framework opencode"));
508 assert!(PLUGIN_TS.contains("MI6_OTEL_PORT"));
510 assert!(PLUGIN_TS.contains("otel start"));
511 }
512
513 #[test]
514 fn test_plugin_path() {
515 if let Some(path) = OpenCodeAdapter::plugin_path() {
517 assert!(path.to_string_lossy().contains(".config/opencode"));
518 assert!(path.to_string_lossy().contains("plugin"));
519 assert!(path.to_string_lossy().ends_with("mi6.ts"));
520 }
521 }
522
523 #[test]
524 fn test_generate_hooks_config_shows_plugin_info() {
525 let adapter = OpenCodeAdapter;
526 let config = adapter.generate_hooks_config(&[], "mi6", false, 4318);
527
528 assert!(config.get("plugin").is_some());
529 assert!(config["plugin"].get("events").is_some());
530 assert!(config.get("otel").is_none());
532 }
533
534 #[test]
535 fn test_generate_hooks_config_with_otel() {
536 let adapter = OpenCodeAdapter;
537 let config = adapter.generate_hooks_config(&[], "mi6", true, 9999);
538
539 assert!(config.get("plugin").is_some());
541
542 let otel = config.get("otel").expect("otel section should exist");
544 assert_eq!(otel["enabled"], true);
545 assert_eq!(otel["port"], 9999);
546 assert_eq!(otel["env_var"], "MI6_OTEL_PORT");
547 }
548}