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