mi6_core/framework/
opencode.rs

1//! OpenCode framework adapter.
2//!
3//! This adapter handles integration with OpenCode, an open-source AI coding agent.
4//! mi6 integrates via OpenCode's plugin system, which provides rich event coverage.
5//!
6//! # Installation
7//!
8//! `mi6 enable opencode` installs the mi6 plugin to `~/.config/opencode/plugin/mi6.ts`.
9//! The plugin captures various events and forwards them to mi6 via the CLI.
10//!
11//! # OpenTelemetry Support
12//!
13//! The plugin automatically starts the mi6 OTEL server on session start to receive
14//! telemetry data from OpenCode's experimental OpenTelemetry support.
15//!
16//! Environment variables:
17//! - `MI6_OTEL_PORT`: OTEL server port (default: 4318)
18//!
19//! To enable OTEL in OpenCode, add to your OpenCode config:
20//! ```json
21//! { "experimental": { "openTelemetry": true } }
22//! ```
23//!
24//! # Supported Events
25//!
26//! The plugin captures these events:
27//! - `session.created` → SessionStart
28//! - `session.idle` → Stop
29//! - `session.compacted` → PreCompact
30//! - `session.deleted` → SessionEnd
31//! - `tool.execute.before` → PreToolUse
32//! - `tool.execute.after` → PostToolUse
33//! - `permission.updated` → PermissionRequest
34//! - `file.edited` → FileEdited (custom)
35//! - `command.executed` → CommandExecuted (custom)
36//! - `session.error` → SessionError (custom)
37//!
38//! # Uninstallation
39//!
40//! `mi6 disable opencode` removes the plugin file.
41
42use 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
50/// Embedded mi6 plugin TypeScript source.
51const PLUGIN_TS: &str = include_str!("opencode-plugin/mi6.ts");
52
53/// Strip JSON comments (// and /* */) from a string.
54///
55/// OpenCode uses .jsonc format which allows comments. This function
56/// removes them so the JSON can be parsed by serde_json.
57fn 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                    // Single-line comment: skip until newline
88                    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                    // Multi-line comment: skip until */
97                    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
115/// Plugin filename.
116const PLUGIN_FILENAME: &str = "mi6.ts";
117
118/// OpenCode framework adapter.
119///
120/// Handles plugin installation and event parsing for OpenCode.
121pub struct OpenCodeAdapter;
122
123impl OpenCodeAdapter {
124    /// Get the path to the OpenCode config directory.
125    ///
126    /// OpenCode follows XDG conventions and uses `~/.config/opencode/`
127    /// regardless of platform. We use `dirs::home_dir()` instead of `dirs::config_dir()`
128    /// because the latter returns `~/Library/Application Support/` on macOS.
129    fn config_dir() -> Option<PathBuf> {
130        dirs::home_dir().map(|h| h.join(".config/opencode"))
131    }
132
133    /// Get the path to the OpenCode plugin directory.
134    fn plugin_dir() -> Option<PathBuf> {
135        Self::config_dir().map(|d| d.join("plugin"))
136    }
137
138    /// Get the path to the mi6 plugin file.
139    fn plugin_path() -> Option<PathBuf> {
140        Self::plugin_dir().map(|d| d.join(PLUGIN_FILENAME))
141    }
142
143    /// Check if the mi6 plugin is installed.
144    fn is_plugin_installed() -> bool {
145        Self::plugin_path().is_some_and(|p| p.exists())
146    }
147
148    /// Get the path to OpenCode's config file.
149    ///
150    /// OpenCode uses `opencode.json` in the config directory.
151    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        // For plugin-based installation, return the plugin path
167        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        // For --print mode, show information about the plugin
182        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        // Include OTEL info if enabled
190        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        // Plugin-based: no config merging needed
208        generated
209    }
210
211    fn parse_hook_input(
212        &self,
213        event_type: &str,
214        stdin_json: &serde_json::Value,
215    ) -> ParsedHookInput {
216        // OpenCode plugin event payloads vary by event type.
217        // We extract common fields using fallback paths since the plugin may
218        // pass different field names depending on OpenCode's internal event structure.
219        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        // Only extract tool_name for tool-related events to avoid false positives
233        // from generic "name" fields in non-tool event payloads
234        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        // Note: OpenCode doesn't provide subagent_type, spawned_agent_id, permission_mode,
243        // transcript_path, session_source, agent_id, agent_transcript_path, compact_trigger,
244        // or duration_ms via hooks
245    }
246
247    fn map_event_type(&self, framework_event: &str) -> EventType {
248        match framework_event {
249            // Canonical event mappings (from plugin)
250            "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            // Custom events (OpenCode-specific)
261            "FileEdited" => EventType::Custom("FileEdited".to_string()),
262            "CommandExecuted" => EventType::Custom("CommandExecuted".to_string()),
263            "SessionError" => EventType::Custom("SessionError".to_string()),
264
265            // Legacy experimental hook events (for backward compatibility)
266            "session_completed" => EventType::SessionEnd,
267            "file_edited" => EventType::Custom("FileEdited".to_string()),
268
269            // Try canonical parsing, fall back to custom
270            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        // All events captured by the plugin
278        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        // OpenCode-specific events with no canonical equivalent
296        vec!["FileEdited", "CommandExecuted", "SessionError"]
297    }
298
299    fn detection_env_vars(&self) -> &[&'static str] {
300        // Environment variables that OpenCode may set during plugin execution.
301        // Note: These are inferred and may not be set. If detection doesn't work,
302        // users should specify --framework opencode explicitly.
303        &["OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR"]
304    }
305
306    fn is_installed(&self) -> bool {
307        // OpenCode uses ~/.config/opencode/ following XDG conventions
308        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        // OpenCode has experimental OTel support via opencode.json:
316        // { "experimental": { "openTelemetry": true } }
317        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        // OpenCode uses .jsonc (JSON with comments), so we strip comments before parsing
324        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        // Check for experimental.openTelemetry = true
330        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        // Plugin-based: no config modification needed
341        None
342    }
343
344    fn resume_command(&self, _session_id: &str) -> Option<String> {
345        // OpenCode resume support is unverified
346        None
347    }
348
349    // ========================================================================
350    // Plugin-based installation
351    // ========================================================================
352
353    fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
354        // Return the plugin path
355        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        // Get the plugin directory
371        let plugin_dir = Self::plugin_dir()
372            .ok_or_else(|| InitError::Config("could not determine plugin directory".to_string()))?;
373
374        // Create the plugin directory if it doesn't exist
375        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        // Write the plugin file
386        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        // Plugin-based installation doesn't run any shell commands
396        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        // Remove the plugin file
419        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        // Canonical events from plugin
459        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        // OpenCode-specific events
477        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        // Legacy experimental hook events
491        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        // Unknown events become Custom
501        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        // Verify the embedded plugin is valid TypeScript (basic check)
602        assert!(!PLUGIN_TS.is_empty());
603        // Uses named export per OpenCode plugin API
604        assert!(PLUGIN_TS.contains("export const Mi6Plugin"));
605        // Uses unified event handler pattern
606        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        // Verify OTEL support
613        assert!(PLUGIN_TS.contains("MI6_OTEL_PORT"));
614        assert!(PLUGIN_TS.contains("otel start"));
615    }
616
617    #[test]
618    fn test_plugin_path() {
619        // Plugin path should be under ~/.config/opencode/plugin/ (XDG convention)
620        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        // No OTEL section when disabled
635        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        // Plugin info should still be present
644        assert!(config.get("plugin").is_some());
645
646        // OTEL section should be present when enabled
647        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        // Comments inside strings should be preserved
680        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        // Config path should be under ~/.config/opencode/ (XDG convention)
695        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}