1use super::{
43 FrameworkAdapter, InstallHooksResult, OtelSupport, 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
53fn strip_json_comments(input: &str) -> String {
58 let mut result = String::with_capacity(input.len());
59 let mut chars = input.chars().peekable();
60 let mut in_string = false;
61 let mut escape_next = false;
62
63 while let Some(c) = chars.next() {
64 if escape_next {
65 result.push(c);
66 escape_next = false;
67 continue;
68 }
69
70 if in_string {
71 result.push(c);
72 if c == '\\' {
73 escape_next = true;
74 } else if c == '"' {
75 in_string = false;
76 }
77 continue;
78 }
79
80 match c {
81 '"' => {
82 in_string = true;
83 result.push(c);
84 }
85 '/' => {
86 if chars.peek() == Some(&'/') {
87 chars.next();
89 while let Some(&next) = chars.peek() {
90 if next == '\n' {
91 break;
92 }
93 chars.next();
94 }
95 } else if chars.peek() == Some(&'*') {
96 chars.next();
98 while let Some(next) = chars.next() {
99 if next == '*' && chars.peek() == Some(&'/') {
100 chars.next();
101 break;
102 }
103 }
104 } else {
105 result.push(c);
106 }
107 }
108 _ => result.push(c),
109 }
110 }
111
112 result
113}
114
115const PLUGIN_FILENAME: &str = "mi6.ts";
117
118pub struct OpenCodeAdapter;
122
123impl OpenCodeAdapter {
124 fn config_dir() -> Option<PathBuf> {
130 dirs::home_dir().map(|h| h.join(".config/opencode"))
131 }
132
133 fn plugin_dir() -> Option<PathBuf> {
135 Self::config_dir().map(|d| d.join("plugin"))
136 }
137
138 fn plugin_path() -> Option<PathBuf> {
140 Self::plugin_dir().map(|d| d.join(PLUGIN_FILENAME))
141 }
142
143 fn is_plugin_installed() -> bool {
145 Self::plugin_path().is_some_and(|p| p.exists())
146 }
147
148 fn opencode_config_path() -> Option<PathBuf> {
152 Self::config_dir().map(|d| d.join("opencode.json"))
153 }
154}
155
156impl FrameworkAdapter for OpenCodeAdapter {
157 fn name(&self) -> &'static str {
158 "opencode"
159 }
160
161 fn display_name(&self) -> &'static str {
162 "OpenCode"
163 }
164
165 fn project_config_path(&self) -> PathBuf {
166 PathBuf::from(".opencode/plugin/mi6.ts")
168 }
169
170 fn user_config_path(&self) -> Option<PathBuf> {
171 Self::plugin_path()
172 }
173
174 fn generate_hooks_config(
175 &self,
176 _enabled_events: &[EventType],
177 _mi6_bin: &str,
178 otel_enabled: bool,
179 otel_port: u16,
180 ) -> serde_json::Value {
181 let mut config = serde_json::json!({
183 "plugin": {
184 "path": Self::plugin_path().map(|p| p.to_string_lossy().to_string()),
185 "events": self.supported_events()
186 }
187 });
188
189 if otel_enabled {
191 config["otel"] = serde_json::json!({
192 "enabled": true,
193 "port": otel_port,
194 "env_var": "MI6_OTEL_PORT",
195 "note": "Set MI6_OTEL_PORT env var to configure. Plugin starts OTEL server on session start."
196 });
197 }
198
199 config
200 }
201
202 fn merge_config(
203 &self,
204 generated: serde_json::Value,
205 _existing: Option<serde_json::Value>,
206 ) -> serde_json::Value {
207 generated
209 }
210
211 fn parse_hook_input(
212 &self,
213 event_type: &str,
214 stdin_json: &serde_json::Value,
215 ) -> ParsedHookInput {
216 let mut parsed = ParsedHookInputBuilder::new(stdin_json)
220 .session_id_or(&["sessionId", "session_id", "id"])
221 .cwd_or(&["cwd", "workingDirectory", "directory"])
222 .tool_use_id_or(&["toolUseId", "tool_use_id", "executionId"])
223 .model_or(&["model", "modelID"])
224 .tokens_input("tokens_input")
225 .tokens_output("tokens_output")
226 .tokens_cache_read("tokens_cache_read")
227 .tokens_cache_write("tokens_cache_write")
228 .cost_usd("cost_usd")
229 .prompt("prompt")
230 .build();
231
232 if event_type.contains("tool") {
235 parsed.tool_name = ParsedHookInputBuilder::new(stdin_json)
236 .tool_name_or(&["toolName", "tool_name", "name"])
237 .build()
238 .tool_name;
239 }
240
241 parsed
242 }
246
247 fn map_event_type(&self, framework_event: &str) -> EventType {
248 match framework_event {
249 "SessionStart" => EventType::SessionStart,
251 "SessionEnd" => EventType::SessionEnd,
252 "Stop" => EventType::Stop,
253 "UserPromptSubmit" => EventType::UserPromptSubmit,
254 "ApiRequest" => EventType::ApiRequest,
255 "PreToolUse" => EventType::PreToolUse,
256 "PostToolUse" => EventType::PostToolUse,
257 "PreCompact" => EventType::PreCompact,
258 "PermissionRequest" => EventType::PermissionRequest,
259
260 "FileEdited" => EventType::Custom("FileEdited".to_string()),
262 "CommandExecuted" => EventType::Custom("CommandExecuted".to_string()),
263 "SessionError" => EventType::Custom("SessionError".to_string()),
264
265 "session_completed" => EventType::SessionEnd,
267 "file_edited" => EventType::Custom("FileEdited".to_string()),
268
269 other => other
271 .parse()
272 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
273 }
274 }
275
276 fn supported_events(&self) -> Vec<&'static str> {
277 vec![
279 "SessionStart",
280 "SessionEnd",
281 "Stop",
282 "UserPromptSubmit",
283 "ApiRequest",
284 "PreToolUse",
285 "PostToolUse",
286 "PreCompact",
287 "PermissionRequest",
288 "FileEdited",
289 "CommandExecuted",
290 "SessionError",
291 ]
292 }
293
294 fn framework_specific_events(&self) -> Vec<&'static str> {
295 vec!["FileEdited", "CommandExecuted", "SessionError"]
297 }
298
299 fn detection_env_vars(&self) -> &[&'static str] {
300 &["OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR"]
304 }
305
306 fn is_installed(&self) -> bool {
307 common::is_framework_installed(
309 dirs::home_dir().map(|h| h.join(".config/opencode")),
310 "opencode",
311 )
312 }
313
314 fn otel_support(&self) -> OtelSupport {
315 let Some(config_path) = Self::opencode_config_path() else {
318 return OtelSupport::Disabled;
319 };
320 let Ok(contents) = std::fs::read_to_string(&config_path) else {
321 return OtelSupport::Disabled;
322 };
323 let contents = strip_json_comments(&contents);
325 let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
326 return OtelSupport::Disabled;
327 };
328
329 if let Some(experimental) = json.get("experimental")
331 && let Some(otel) = experimental.get("openTelemetry")
332 && otel.as_bool() == Some(true)
333 {
334 return OtelSupport::Enabled;
335 }
336 OtelSupport::Disabled
337 }
338
339 fn remove_hooks(&self, _existing: serde_json::Value) -> Option<serde_json::Value> {
340 None
342 }
343
344 fn resume_command(&self, _session_id: &str) -> Option<String> {
345 None
347 }
348
349 fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
354 Self::plugin_path()
356 .ok_or_else(|| InitError::Config("could not determine plugin path".to_string()))
357 }
358
359 fn has_mi6_hooks(&self, _local: bool, _settings_local: bool) -> bool {
360 Self::is_plugin_installed()
361 }
362
363 fn install_hooks(
364 &self,
365 _path: &Path,
366 _hooks: &serde_json::Value,
367 _otel_env: Option<serde_json::Value>,
368 _remove_otel: bool,
369 ) -> Result<InstallHooksResult, InitError> {
370 let plugin_dir = Self::plugin_dir()
372 .ok_or_else(|| InitError::Config("could not determine plugin directory".to_string()))?;
373
374 if !plugin_dir.exists() {
376 std::fs::create_dir_all(&plugin_dir).map_err(|e| {
377 InitError::Config(format!(
378 "failed to create plugin directory {}: {}",
379 plugin_dir.display(),
380 e
381 ))
382 })?;
383 }
384
385 let plugin_path = plugin_dir.join(PLUGIN_FILENAME);
387 std::fs::write(&plugin_path, PLUGIN_TS).map_err(|e| {
388 InitError::Config(format!(
389 "failed to write plugin file {}: {}",
390 plugin_path.display(),
391 e
392 ))
393 })?;
394
395 Ok(InstallHooksResult::default())
397 }
398
399 fn uninstall_hooks(
400 &self,
401 _local: bool,
402 _settings_local: bool,
403 ) -> Result<UninstallHooksResult, InitError> {
404 let Some(plugin_path) = Self::plugin_path() else {
405 return Ok(UninstallHooksResult {
406 hooks_removed: false,
407 commands_run: vec![],
408 });
409 };
410
411 if !plugin_path.exists() {
412 return Ok(UninstallHooksResult {
413 hooks_removed: false,
414 commands_run: vec![],
415 });
416 }
417
418 std::fs::remove_file(&plugin_path).map_err(|e| {
420 InitError::Config(format!(
421 "failed to remove plugin file {}: {}",
422 plugin_path.display(),
423 e
424 ))
425 })?;
426
427 Ok(UninstallHooksResult {
428 hooks_removed: true,
429 commands_run: vec![],
430 })
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn test_name() {
440 let adapter = OpenCodeAdapter;
441 assert_eq!(adapter.name(), "opencode");
442 assert_eq!(adapter.display_name(), "OpenCode");
443 }
444
445 #[test]
446 fn test_project_config_path() {
447 let adapter = OpenCodeAdapter;
448 assert_eq!(
449 adapter.project_config_path(),
450 PathBuf::from(".opencode/plugin/mi6.ts")
451 );
452 }
453
454 #[test]
455 fn test_map_event_type() {
456 let adapter = OpenCodeAdapter;
457
458 assert_eq!(
460 adapter.map_event_type("SessionStart"),
461 EventType::SessionStart
462 );
463 assert_eq!(adapter.map_event_type("SessionEnd"), EventType::SessionEnd);
464 assert_eq!(adapter.map_event_type("Stop"), EventType::Stop);
465 assert_eq!(adapter.map_event_type("PreToolUse"), EventType::PreToolUse);
466 assert_eq!(
467 adapter.map_event_type("PostToolUse"),
468 EventType::PostToolUse
469 );
470 assert_eq!(adapter.map_event_type("PreCompact"), EventType::PreCompact);
471 assert_eq!(
472 adapter.map_event_type("PermissionRequest"),
473 EventType::PermissionRequest
474 );
475
476 assert_eq!(
478 adapter.map_event_type("FileEdited"),
479 EventType::Custom("FileEdited".to_string())
480 );
481 assert_eq!(
482 adapter.map_event_type("CommandExecuted"),
483 EventType::Custom("CommandExecuted".to_string())
484 );
485 assert_eq!(
486 adapter.map_event_type("SessionError"),
487 EventType::Custom("SessionError".to_string())
488 );
489
490 assert_eq!(
492 adapter.map_event_type("session_completed"),
493 EventType::SessionEnd
494 );
495 assert_eq!(
496 adapter.map_event_type("file_edited"),
497 EventType::Custom("FileEdited".to_string())
498 );
499
500 assert_eq!(
502 adapter.map_event_type("unknown_event"),
503 EventType::Custom("unknown_event".to_string())
504 );
505 }
506
507 #[test]
508 fn test_parse_hook_input_session_event() {
509 let adapter = OpenCodeAdapter;
510 let input = serde_json::json!({
511 "sessionId": "opencode-session-123",
512 "cwd": "/projects/test"
513 });
514
515 let parsed = adapter.parse_hook_input("SessionStart", &input);
516
517 assert_eq!(parsed.session_id, Some("opencode-session-123".to_string()));
518 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
519 assert_eq!(parsed.tool_name, None);
520 assert_eq!(parsed.tool_use_id, None);
521 }
522
523 #[test]
524 fn test_parse_hook_input_tool_event() {
525 let adapter = OpenCodeAdapter;
526 let input = serde_json::json!({
527 "sessionId": "opencode-session-123",
528 "toolName": "shell",
529 "executionId": "exec-456",
530 "cwd": "/projects/test"
531 });
532
533 let parsed = adapter.parse_hook_input("tool.execute.before", &input);
534
535 assert_eq!(parsed.session_id, Some("opencode-session-123".to_string()));
536 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
537 assert_eq!(parsed.tool_name, Some("shell".to_string()));
538 assert_eq!(parsed.tool_use_id, Some("exec-456".to_string()));
539 }
540
541 #[test]
542 fn test_parse_hook_input_alternative_keys() {
543 let adapter = OpenCodeAdapter;
544 let input = serde_json::json!({
545 "session_id": "opencode-456",
546 "workingDirectory": "/home/user/project",
547 "tool_name": "read",
548 "tool_use_id": "tool-789"
549 });
550
551 let parsed = adapter.parse_hook_input("tool.execute.after", &input);
552
553 assert_eq!(parsed.session_id, Some("opencode-456".to_string()));
554 assert_eq!(parsed.cwd, Some("/home/user/project".to_string()));
555 assert_eq!(parsed.tool_name, Some("read".to_string()));
556 assert_eq!(parsed.tool_use_id, Some("tool-789".to_string()));
557 }
558
559 #[test]
560 fn test_supported_events() {
561 let adapter = OpenCodeAdapter;
562 let events = adapter.supported_events();
563
564 assert!(events.contains(&"SessionStart"));
565 assert!(events.contains(&"SessionEnd"));
566 assert!(events.contains(&"Stop"));
567 assert!(events.contains(&"PreToolUse"));
568 assert!(events.contains(&"PostToolUse"));
569 assert!(events.contains(&"UserPromptSubmit"));
570 assert!(events.contains(&"PreCompact"));
571 assert!(events.contains(&"PermissionRequest"));
572 assert!(events.contains(&"FileEdited"));
573 assert!(events.contains(&"CommandExecuted"));
574 assert!(events.contains(&"SessionError"));
575 assert!(events.contains(&"ApiRequest"));
576 assert_eq!(events.len(), 12);
577 }
578
579 #[test]
580 fn test_framework_specific_events() {
581 let adapter = OpenCodeAdapter;
582 let events = adapter.framework_specific_events();
583
584 assert!(events.contains(&"FileEdited"));
585 assert!(events.contains(&"CommandExecuted"));
586 assert!(events.contains(&"SessionError"));
587 assert_eq!(events.len(), 3);
588 }
589
590 #[test]
591 fn test_detection_env_vars() {
592 let adapter = OpenCodeAdapter;
593 let vars = adapter.detection_env_vars();
594
595 assert!(vars.contains(&"OPENCODE_CONFIG"));
596 assert!(vars.contains(&"OPENCODE_CONFIG_DIR"));
597 }
598
599 #[test]
600 fn test_embedded_plugin() {
601 assert!(!PLUGIN_TS.is_empty());
603 assert!(PLUGIN_TS.contains("export const Mi6Plugin"));
605 assert!(PLUGIN_TS.contains("event: async"));
607 assert!(PLUGIN_TS.contains("session.created"));
608 assert!(PLUGIN_TS.contains("tool.execute.before"));
609 assert!(PLUGIN_TS.contains("ingest event"));
610 assert!(PLUGIN_TS.contains("SessionStart"));
611 assert!(PLUGIN_TS.contains("--framework opencode"));
612 assert!(PLUGIN_TS.contains("MI6_OTEL_PORT"));
614 assert!(PLUGIN_TS.contains("otel start"));
615 }
616
617 #[test]
618 fn test_plugin_path() {
619 if let Some(path) = OpenCodeAdapter::plugin_path() {
621 assert!(path.to_string_lossy().contains(".config/opencode"));
622 assert!(path.to_string_lossy().contains("plugin"));
623 assert!(path.to_string_lossy().ends_with("mi6.ts"));
624 }
625 }
626
627 #[test]
628 fn test_generate_hooks_config_shows_plugin_info() {
629 let adapter = OpenCodeAdapter;
630 let config = adapter.generate_hooks_config(&[], "mi6", false, 4318);
631
632 assert!(config.get("plugin").is_some());
633 assert!(config["plugin"].get("events").is_some());
634 assert!(config.get("otel").is_none());
636 }
637
638 #[test]
639 fn test_generate_hooks_config_with_otel() {
640 let adapter = OpenCodeAdapter;
641 let config = adapter.generate_hooks_config(&[], "mi6", true, 9999);
642
643 assert!(config.get("plugin").is_some());
645
646 let otel = config.get("otel").expect("otel section should exist");
648 assert_eq!(otel["enabled"], true);
649 assert_eq!(otel["port"], 9999);
650 assert_eq!(otel["env_var"], "MI6_OTEL_PORT");
651 }
652
653 #[test]
654 fn test_strip_json_comments_single_line() {
655 let input = r#"{
656 // This is a comment
657 "key": "value"
658 }"#;
659 let result = strip_json_comments(input);
660 assert!(!result.contains("//"));
661 assert!(result.contains(r#""key": "value""#));
662 }
663
664 #[test]
665 fn test_strip_json_comments_multi_line() {
666 let input = r#"{
667 /* This is a
668 multi-line comment */
669 "key": "value"
670 }"#;
671 let result = strip_json_comments(input);
672 assert!(!result.contains("/*"));
673 assert!(!result.contains("*/"));
674 assert!(result.contains(r#""key": "value""#));
675 }
676
677 #[test]
678 fn test_strip_json_comments_preserves_strings() {
679 let input = r#"{"url": "http://example.com/path"}"#;
681 let result = strip_json_comments(input);
682 assert_eq!(result, input);
683 }
684
685 #[test]
686 fn test_strip_json_comments_preserves_escaped_quotes() {
687 let input = r#"{"msg": "He said \"hello\" // not a comment"}"#;
688 let result = strip_json_comments(input);
689 assert!(result.contains("// not a comment"));
690 }
691
692 #[test]
693 fn test_config_path() {
694 if let Some(path) = OpenCodeAdapter::opencode_config_path() {
696 assert!(path.to_string_lossy().contains(".config/opencode"));
697 assert!(path.to_string_lossy().ends_with("opencode.json"));
698 }
699 }
700}