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::{FrameworkAdapter, ParsedHookInput, common};
43use crate::model::EventType;
44use crate::model::error::InitError;
45use std::path::{Path, PathBuf};
46
47/// Embedded mi6 plugin TypeScript source.
48const PLUGIN_TS: &str = include_str!("opencode-plugin/mi6.ts");
49
50/// Plugin filename.
51const PLUGIN_FILENAME: &str = "mi6.ts";
52
53/// OpenCode framework adapter.
54///
55/// Handles plugin installation and event parsing for OpenCode.
56pub struct OpenCodeAdapter;
57
58impl OpenCodeAdapter {
59    /// Get the path to the OpenCode plugin directory.
60    ///
61    /// OpenCode follows XDG conventions and expects plugins at `~/.config/opencode/plugin/`
62    /// regardless of platform. We use `dirs::home_dir()` instead of `dirs::config_dir()`
63    /// because the latter returns `~/Library/Application Support/` on macOS.
64    fn plugin_dir() -> Option<PathBuf> {
65        dirs::home_dir().map(|h| h.join(".config/opencode/plugin"))
66    }
67
68    /// Get the path to the mi6 plugin file.
69    fn plugin_path() -> Option<PathBuf> {
70        Self::plugin_dir().map(|d| d.join(PLUGIN_FILENAME))
71    }
72
73    /// Check if the mi6 plugin is installed.
74    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        // For plugin-based installation, return the plugin path
90        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        // For --print mode, show information about the plugin
105        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        // Include OTEL info if enabled
113        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        // Plugin-based: no config merging needed
131        generated
132    }
133
134    fn parse_hook_input(
135        &self,
136        event_type: &str,
137        stdin_json: &serde_json::Value,
138    ) -> ParsedHookInput {
139        // OpenCode plugin event payloads vary by event type.
140        // We extract common fields and event-specific fields.
141        //
142        // The plugin passes the full event object from OpenCode to mi6,
143        // so we look for various possible field names.
144
145        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        // Tool execution events have tool information
160        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        // Model is extracted by the plugin from info.model.modelID or info.modelID
179        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        // Token usage fields (extracted by plugin from info.tokens)
186        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        // Cost in USD (extracted by plugin from info.cost)
194        let cost_usd = stdin_json.get("cost_usd").and_then(|v| v.as_f64());
195
196        // User prompt text (for UserPromptSubmit events)
197        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            // OpenCode doesn't provide these Claude-specific fields
215            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            // Canonical event mappings (from plugin)
230            "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            // Custom events (OpenCode-specific)
241            "FileEdited" => EventType::Custom("FileEdited".to_string()),
242            "CommandExecuted" => EventType::Custom("CommandExecuted".to_string()),
243            "SessionError" => EventType::Custom("SessionError".to_string()),
244
245            // Legacy experimental hook events (for backward compatibility)
246            "session_completed" => EventType::SessionEnd,
247            "file_edited" => EventType::Custom("FileEdited".to_string()),
248
249            // Try canonical parsing, fall back to custom
250            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        // All events captured by the plugin
258        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        // OpenCode-specific events with no canonical equivalent
276        vec!["FileEdited", "CommandExecuted", "SessionError"]
277    }
278
279    fn detection_env_vars(&self) -> &[&'static str] {
280        // Environment variables that OpenCode may set during plugin execution.
281        // Note: These are inferred and may not be set. If detection doesn't work,
282        // users should specify --framework opencode explicitly.
283        &["OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR"]
284    }
285
286    fn is_installed(&self) -> bool {
287        // OpenCode uses ~/.config/opencode/ following XDG conventions
288        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        // Plugin-based: no config modification needed
296        None
297    }
298
299    // ========================================================================
300    // Plugin-based installation
301    // ========================================================================
302
303    fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
304        // Return the plugin path
305        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        // Get the plugin directory
321        let plugin_dir = Self::plugin_dir()
322            .ok_or_else(|| InitError::Config("could not determine plugin directory".to_string()))?;
323
324        // Create the plugin directory if it doesn't exist
325        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        // Write the plugin file
336        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        // Remove the plugin file
358        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        // Canonical events from plugin
395        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        // OpenCode-specific events
413        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        // Legacy experimental hook events
427        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        // Unknown events become Custom
437        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        // Verify the embedded plugin is valid TypeScript (basic check)
538        assert!(!PLUGIN_TS.is_empty());
539        // Uses named export per OpenCode plugin API
540        assert!(PLUGIN_TS.contains("export const Mi6Plugin"));
541        // Uses unified event handler pattern
542        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        // Verify OTEL support
549        assert!(PLUGIN_TS.contains("MI6_OTEL_PORT"));
550        assert!(PLUGIN_TS.contains("otel start"));
551    }
552
553    #[test]
554    fn test_plugin_path() {
555        // Plugin path should be under ~/.config/opencode/plugin/ (XDG convention)
556        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        // No OTEL section when disabled
571        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        // Plugin info should still be present
580        assert!(config.get("plugin").is_some());
581
582        // OTEL section should be present when enabled
583        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}