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, 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/// Plugin filename.
54const PLUGIN_FILENAME: &str = "mi6.ts";
55
56/// OpenCode framework adapter.
57///
58/// Handles plugin installation and event parsing for OpenCode.
59pub struct OpenCodeAdapter;
60
61impl OpenCodeAdapter {
62    /// Get the path to the OpenCode plugin directory.
63    ///
64    /// OpenCode follows XDG conventions and expects plugins at `~/.config/opencode/plugin/`
65    /// regardless of platform. We use `dirs::home_dir()` instead of `dirs::config_dir()`
66    /// because the latter returns `~/Library/Application Support/` on macOS.
67    fn plugin_dir() -> Option<PathBuf> {
68        dirs::home_dir().map(|h| h.join(".config/opencode/plugin"))
69    }
70
71    /// Get the path to the mi6 plugin file.
72    fn plugin_path() -> Option<PathBuf> {
73        Self::plugin_dir().map(|d| d.join(PLUGIN_FILENAME))
74    }
75
76    /// Check if the mi6 plugin is installed.
77    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        // For plugin-based installation, return the plugin path
93        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        // For --print mode, show information about the plugin
108        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        // Include OTEL info if enabled
116        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        // Plugin-based: no config merging needed
134        generated
135    }
136
137    fn parse_hook_input(
138        &self,
139        event_type: &str,
140        stdin_json: &serde_json::Value,
141    ) -> ParsedHookInput {
142        // OpenCode plugin event payloads vary by event type.
143        // We extract common fields using fallback paths since the plugin may
144        // pass different field names depending on OpenCode's internal event structure.
145        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        // Only extract tool_name for tool-related events to avoid false positives
159        // from generic "name" fields in non-tool event payloads
160        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        // Note: OpenCode doesn't provide subagent_type, spawned_agent_id, permission_mode,
169        // transcript_path, session_source, agent_id, agent_transcript_path, compact_trigger,
170        // or duration_ms via hooks
171    }
172
173    fn map_event_type(&self, framework_event: &str) -> EventType {
174        match framework_event {
175            // Canonical event mappings (from plugin)
176            "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            // Custom events (OpenCode-specific)
187            "FileEdited" => EventType::Custom("FileEdited".to_string()),
188            "CommandExecuted" => EventType::Custom("CommandExecuted".to_string()),
189            "SessionError" => EventType::Custom("SessionError".to_string()),
190
191            // Legacy experimental hook events (for backward compatibility)
192            "session_completed" => EventType::SessionEnd,
193            "file_edited" => EventType::Custom("FileEdited".to_string()),
194
195            // Try canonical parsing, fall back to custom
196            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        // All events captured by the plugin
204        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        // OpenCode-specific events with no canonical equivalent
222        vec!["FileEdited", "CommandExecuted", "SessionError"]
223    }
224
225    fn detection_env_vars(&self) -> &[&'static str] {
226        // Environment variables that OpenCode may set during plugin execution.
227        // Note: These are inferred and may not be set. If detection doesn't work,
228        // users should specify --framework opencode explicitly.
229        &["OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR"]
230    }
231
232    fn is_installed(&self) -> bool {
233        // OpenCode uses ~/.config/opencode/ following XDG conventions
234        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        // Plugin-based: no config modification needed
242        None
243    }
244
245    // ========================================================================
246    // Plugin-based installation
247    // ========================================================================
248
249    fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
250        // Return the plugin path
251        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        // Get the plugin directory
267        let plugin_dir = Self::plugin_dir()
268            .ok_or_else(|| InitError::Config("could not determine plugin directory".to_string()))?;
269
270        // Create the plugin directory if it doesn't exist
271        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        // Write the plugin file
282        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        // Plugin-based installation doesn't run any shell commands
292        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        // Remove the plugin file
315        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        // Canonical events from plugin
355        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        // OpenCode-specific events
373        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        // Legacy experimental hook events
387        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        // Unknown events become Custom
397        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        // Verify the embedded plugin is valid TypeScript (basic check)
498        assert!(!PLUGIN_TS.is_empty());
499        // Uses named export per OpenCode plugin API
500        assert!(PLUGIN_TS.contains("export const Mi6Plugin"));
501        // Uses unified event handler pattern
502        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        // Verify OTEL support
509        assert!(PLUGIN_TS.contains("MI6_OTEL_PORT"));
510        assert!(PLUGIN_TS.contains("otel start"));
511    }
512
513    #[test]
514    fn test_plugin_path() {
515        // Plugin path should be under ~/.config/opencode/plugin/ (XDG convention)
516        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        // No OTEL section when disabled
531        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        // Plugin info should still be present
540        assert!(config.get("plugin").is_some());
541
542        // OTEL section should be present when enabled
543        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}