mi6_core/framework/
pi.rs

1//! Pi Coding Agent framework adapter.
2//!
3//! This adapter handles integration with the Pi Coding Agent
4//! (<https://github.com/badlogic/pi-mono>).
5//!
6//! Pi uses a TypeScript extension system rather than JSON/TOML hooks.
7//! mi6 integrates by installing a TypeScript extension that subscribes
8//! to Pi's event bus and forwards events to `mi6 ingest event`.
9//!
10//! # Installation Flow
11//!
12//! 1. `mi6 enable pi` writes the embedded TypeScript extension to
13//!    `~/.pi/agent/extensions/mi6.ts` (user scope) or
14//!    `.pi/extensions/mi6.ts` (project scope)
15//! 2. Pi automatically discovers and loads extensions from these directories
16//! 3. The extension sets `PI_SESSION_ID` env var for framework detection
17//!
18//! # Uninstallation
19//!
20//! `mi6 disable pi` removes the extension file.
21
22use super::{
23    FrameworkAdapter, InstallHooksResult, ParsedHookInput, ParsedHookInputBuilder,
24    UninstallHooksResult, common,
25};
26use crate::model::EventType;
27use crate::model::error::InitError;
28use std::path::{Path, PathBuf};
29
30/// Embedded mi6.ts extension content.
31const MI6_EXTENSION_TS: &str = include_str!("pi-extension/mi6.ts");
32
33/// Pi Coding Agent framework adapter.
34///
35/// Handles extension installation and event parsing for Pi.
36pub struct PiAdapter;
37
38impl FrameworkAdapter for PiAdapter {
39    fn name(&self) -> &'static str {
40        "pi"
41    }
42
43    fn display_name(&self) -> &'static str {
44        "Pi Coding Agent"
45    }
46
47    fn project_config_path(&self) -> PathBuf {
48        PathBuf::from(".pi/extensions/mi6.ts")
49    }
50
51    fn user_config_path(&self) -> Option<PathBuf> {
52        dirs::home_dir().map(|h| h.join(".pi/agent/extensions/mi6.ts"))
53    }
54
55    fn generate_hooks_config(
56        &self,
57        _enabled_events: &[EventType],
58        _mi6_bin: &str,
59        _otel_enabled: bool,
60        _otel_port: u16,
61    ) -> serde_json::Value {
62        // Pi uses TypeScript extensions, not JSON config.
63        // Return a placeholder that describes what will be installed.
64        serde_json::json!({
65            "_note": "Pi uses TypeScript extensions, not JSON hooks",
66            "extension_path": "~/.pi/agent/extensions/mi6.ts",
67            "extension_content": "See embedded mi6.ts"
68        })
69    }
70
71    fn merge_config(
72        &self,
73        generated: serde_json::Value,
74        _existing: Option<serde_json::Value>,
75    ) -> serde_json::Value {
76        // No merging needed - we write a standalone file
77        generated
78    }
79
80    fn parse_hook_input(
81        &self,
82        _event_type: &str,
83        stdin_json: &serde_json::Value,
84    ) -> ParsedHookInput {
85        // Pi extension sends JSON with canonical field names
86        ParsedHookInputBuilder::new(stdin_json)
87            .session_id("session_id")
88            .tool_use_id("tool_use_id")
89            .tool_name("tool_name")
90            .cwd("cwd")
91            .compact_trigger("trigger")
92            .model("model")
93            .transcript_path("transcript_path")
94            .prompt("user_prompt")
95            .tokens_input("tokens_input")
96            .tokens_output("tokens_output")
97            .tokens_cache_read("tokens_cache_read")
98            .tokens_cache_write("tokens_cache_write")
99            .cost_usd("cost_usd")
100            .build()
101        // Note: Pi doesn't provide subagent_type, spawned_agent_id, permission_mode,
102        // session_source, agent_id, agent_transcript_path, or duration_ms via hooks
103    }
104
105    fn map_event_type(&self, framework_event: &str) -> EventType {
106        // Pi extension sends canonical event names
107        framework_event
108            .parse()
109            .unwrap_or_else(|_| EventType::Custom(framework_event.to_string()))
110    }
111
112    fn supported_events(&self) -> Vec<&'static str> {
113        vec![
114            "SessionStart",
115            "SessionEnd",
116            "PreToolUse",
117            "PostToolUse",
118            "UserPromptSubmit",
119            "Stop",
120            "PreCompact",
121            "ApiRequest",
122        ]
123    }
124
125    fn detection_env_vars(&self) -> &[&'static str] {
126        // The mi6 extension sets this when Pi starts
127        &["PI_SESSION_ID"]
128    }
129
130    fn is_installed(&self) -> bool {
131        common::is_framework_installed(dirs::home_dir().map(|h| h.join(".pi/agent")), "pi")
132    }
133
134    fn remove_hooks(&self, _existing: serde_json::Value) -> Option<serde_json::Value> {
135        // Pi uses extension files, not JSON hooks
136        // Removal is handled by uninstall_hooks
137        None
138    }
139
140    // ========================================================================
141    // Extension-based installation
142    // ========================================================================
143
144    fn settings_path(&self, local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
145        if local {
146            Ok(self.project_config_path())
147        } else {
148            self.user_config_path()
149                .ok_or_else(|| InitError::Config("could not determine home directory".into()))
150        }
151    }
152
153    fn has_mi6_hooks(&self, local: bool, settings_local: bool) -> bool {
154        self.settings_path(local, settings_local)
155            .map(|p| p.exists())
156            .unwrap_or(false)
157    }
158
159    fn install_hooks(
160        &self,
161        path: &Path,
162        _hooks: &serde_json::Value,
163        _otel_env: Option<serde_json::Value>,
164        _remove_otel: bool,
165    ) -> Result<InstallHooksResult, InitError> {
166        // Create parent directory if needed
167        if let Some(parent) = path.parent() {
168            std::fs::create_dir_all(parent).map_err(|e| {
169                InitError::Config(format!("failed to create {}: {e}", parent.display()))
170            })?;
171        }
172
173        // Write the TypeScript extension
174        std::fs::write(path, MI6_EXTENSION_TS)
175            .map_err(|e| InitError::Config(format!("failed to write {}: {e}", path.display())))?;
176
177        // Extension-based installation doesn't run any shell commands
178        Ok(InstallHooksResult::default())
179    }
180
181    fn uninstall_hooks(
182        &self,
183        local: bool,
184        settings_local: bool,
185    ) -> Result<UninstallHooksResult, InitError> {
186        let path = self.settings_path(local, settings_local)?;
187
188        if path.exists() {
189            std::fs::remove_file(&path).map_err(|e| {
190                InitError::Config(format!("failed to remove {}: {e}", path.display()))
191            })?;
192            Ok(UninstallHooksResult {
193                hooks_removed: true,
194                commands_run: vec![],
195            })
196        } else {
197            Ok(UninstallHooksResult {
198                hooks_removed: false,
199                commands_run: vec![],
200            })
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_name() {
211        let adapter = PiAdapter;
212        assert_eq!(adapter.name(), "pi");
213        assert_eq!(adapter.display_name(), "Pi Coding Agent");
214    }
215
216    #[test]
217    fn test_project_config_path() {
218        let adapter = PiAdapter;
219        assert_eq!(
220            adapter.project_config_path(),
221            PathBuf::from(".pi/extensions/mi6.ts")
222        );
223    }
224
225    #[test]
226    fn test_user_config_path() {
227        let adapter = PiAdapter;
228        let path = adapter.user_config_path();
229        assert!(path.is_some());
230        let path = path.unwrap();
231        assert!(path.ends_with(".pi/agent/extensions/mi6.ts"));
232    }
233
234    #[test]
235    fn test_map_event_type() {
236        let adapter = PiAdapter;
237
238        // Canonical events should parse correctly
239        assert_eq!(
240            adapter.map_event_type("SessionStart"),
241            EventType::SessionStart
242        );
243        assert_eq!(adapter.map_event_type("PreToolUse"), EventType::PreToolUse);
244        assert_eq!(
245            adapter.map_event_type("PostToolUse"),
246            EventType::PostToolUse
247        );
248        assert_eq!(
249            adapter.map_event_type("UserPromptSubmit"),
250            EventType::UserPromptSubmit
251        );
252        assert_eq!(adapter.map_event_type("Stop"), EventType::Stop);
253        assert_eq!(adapter.map_event_type("PreCompact"), EventType::PreCompact);
254
255        // Unknown events become Custom
256        assert_eq!(
257            adapter.map_event_type("UnknownEvent"),
258            EventType::Custom("UnknownEvent".to_string())
259        );
260    }
261
262    #[test]
263    fn test_parse_hook_input() {
264        let adapter = PiAdapter;
265        let input = serde_json::json!({
266            "session_id": "pi-session-123",
267            "tool_use_id": "tool-456",
268            "tool_name": "Bash",
269            "cwd": "/projects/test"
270        });
271
272        let parsed = adapter.parse_hook_input("PreToolUse", &input);
273
274        assert_eq!(parsed.session_id, Some("pi-session-123".to_string()));
275        assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
276        assert_eq!(parsed.tool_name, Some("Bash".to_string()));
277        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
278        // Pi doesn't provide Claude-specific fields
279        assert_eq!(parsed.permission_mode, None);
280        assert_eq!(parsed.subagent_type, None);
281        assert_eq!(parsed.spawned_agent_id, None);
282    }
283
284    #[test]
285    fn test_parse_hook_input_with_model_and_prompt() {
286        let adapter = PiAdapter;
287        let input = serde_json::json!({
288            "session_id": "pi-session-123",
289            "cwd": "/projects/test",
290            "model": "anthropic/claude-sonnet-4-20250514",
291            "user_prompt": "hello there",
292            "transcript_path": "/home/user/.pi/agent/sessions/test/session.jsonl"
293        });
294
295        let parsed = adapter.parse_hook_input("SessionStart", &input);
296
297        assert_eq!(parsed.session_id, Some("pi-session-123".to_string()));
298        assert_eq!(
299            parsed.model,
300            Some("anthropic/claude-sonnet-4-20250514".to_string())
301        );
302        assert_eq!(parsed.prompt, Some("hello there".to_string()));
303        assert_eq!(
304            parsed.transcript_path,
305            Some("/home/user/.pi/agent/sessions/test/session.jsonl".to_string())
306        );
307    }
308
309    #[test]
310    fn test_parse_hook_input_with_trigger() {
311        let adapter = PiAdapter;
312        let input = serde_json::json!({
313            "session_id": "pi-123",
314            "trigger": "auto"
315        });
316
317        let parsed = adapter.parse_hook_input("PreCompact", &input);
318
319        assert_eq!(parsed.session_id, Some("pi-123".to_string()));
320        assert_eq!(parsed.compact_trigger, Some("auto".to_string()));
321    }
322
323    #[test]
324    fn test_parse_hook_input_with_tokens() {
325        let adapter = PiAdapter;
326        let input = serde_json::json!({
327            "session_id": "pi-123",
328            "model": "gpt-4.1",
329            "tokens_input": 942,
330            "tokens_output": 64,
331            "tokens_cache_read": 0,
332            "tokens_cache_write": 0,
333            "cost_usd": 0.00239
334        });
335
336        let parsed = adapter.parse_hook_input("ApiRequest", &input);
337
338        assert_eq!(parsed.session_id, Some("pi-123".to_string()));
339        assert_eq!(parsed.model, Some("gpt-4.1".to_string()));
340        assert_eq!(parsed.tokens_input, Some(942));
341        assert_eq!(parsed.tokens_output, Some(64));
342        assert_eq!(parsed.tokens_cache_read, Some(0));
343        assert_eq!(parsed.tokens_cache_write, Some(0));
344        assert!((parsed.cost_usd.unwrap() - 0.00239).abs() < 0.0001);
345    }
346
347    #[test]
348    fn test_supported_events() {
349        let adapter = PiAdapter;
350        let events = adapter.supported_events();
351
352        assert!(events.contains(&"SessionStart"));
353        assert!(events.contains(&"SessionEnd"));
354        assert!(events.contains(&"PreToolUse"));
355        assert!(events.contains(&"PostToolUse"));
356        assert!(events.contains(&"UserPromptSubmit"));
357        assert!(events.contains(&"Stop"));
358        assert!(events.contains(&"PreCompact"));
359        assert!(events.contains(&"ApiRequest"));
360        assert_eq!(events.len(), 8);
361    }
362
363    #[test]
364    fn test_detection_env_vars() {
365        let adapter = PiAdapter;
366        let vars = adapter.detection_env_vars();
367
368        assert!(vars.contains(&"PI_SESSION_ID"));
369        assert_eq!(vars.len(), 1);
370    }
371
372    #[test]
373    fn test_embedded_extension() {
374        // Verify the embedded extension uses correct Pi API patterns
375        assert!(MI6_EXTENSION_TS.contains("mi6"));
376        assert!(MI6_EXTENSION_TS.contains("ingestEvent"));
377        // Uses Pi's snake_case event names
378        assert!(MI6_EXTENSION_TS.contains("session_start"));
379        assert!(MI6_EXTENSION_TS.contains("session_shutdown"));
380        assert!(MI6_EXTENSION_TS.contains("tool_call"));
381        assert!(MI6_EXTENSION_TS.contains("tool_result"));
382        assert!(MI6_EXTENSION_TS.contains("turn_start"));
383        assert!(MI6_EXTENSION_TS.contains("turn_end"));
384        assert!(MI6_EXTENSION_TS.contains("session_before_compact"));
385        // Uses correct Pi import
386        assert!(MI6_EXTENSION_TS.contains("@mariozechner/pi-coding-agent"));
387        // Uses factory function pattern
388        assert!(MI6_EXTENSION_TS.contains("export default function"));
389        // Uses correct event handler signature (event, ctx)
390        assert!(MI6_EXTENSION_TS.contains("ExtensionContext"));
391    }
392
393    #[test]
394    fn test_generate_hooks_config() {
395        let adapter = PiAdapter;
396        let events = vec![EventType::SessionStart, EventType::PreToolUse];
397
398        let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
399
400        // Should return a placeholder JSON since Pi uses TypeScript
401        assert!(config.get("_note").is_some());
402        assert!(config.get("extension_path").is_some());
403    }
404}