1use 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
30const MI6_EXTENSION_TS: &str = include_str!("pi-extension/mi6.ts");
32
33pub 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 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 generated
78 }
79
80 fn parse_hook_input(
81 &self,
82 _event_type: &str,
83 stdin_json: &serde_json::Value,
84 ) -> ParsedHookInput {
85 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 }
104
105 fn map_event_type(&self, framework_event: &str) -> EventType {
106 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 &["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 None
138 }
139
140 fn resume_command(&self, _session_id: &str) -> Option<String> {
141 Some("pi -r".to_string())
144 }
145
146 fn settings_path(&self, local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
151 if local {
152 Ok(self.project_config_path())
153 } else {
154 self.user_config_path()
155 .ok_or_else(|| InitError::Config("could not determine home directory".into()))
156 }
157 }
158
159 fn has_mi6_hooks(&self, local: bool, settings_local: bool) -> bool {
160 self.settings_path(local, settings_local)
161 .map(|p| p.exists())
162 .unwrap_or(false)
163 }
164
165 fn install_hooks(
166 &self,
167 path: &Path,
168 _hooks: &serde_json::Value,
169 _otel_env: Option<serde_json::Value>,
170 _remove_otel: bool,
171 ) -> Result<InstallHooksResult, InitError> {
172 if let Some(parent) = path.parent() {
174 std::fs::create_dir_all(parent).map_err(|e| {
175 InitError::Config(format!("failed to create {}: {e}", parent.display()))
176 })?;
177 }
178
179 std::fs::write(path, MI6_EXTENSION_TS)
181 .map_err(|e| InitError::Config(format!("failed to write {}: {e}", path.display())))?;
182
183 Ok(InstallHooksResult::default())
185 }
186
187 fn uninstall_hooks(
188 &self,
189 local: bool,
190 settings_local: bool,
191 ) -> Result<UninstallHooksResult, InitError> {
192 let path = self.settings_path(local, settings_local)?;
193
194 if path.exists() {
195 std::fs::remove_file(&path).map_err(|e| {
196 InitError::Config(format!("failed to remove {}: {e}", path.display()))
197 })?;
198 Ok(UninstallHooksResult {
199 hooks_removed: true,
200 commands_run: vec![],
201 })
202 } else {
203 Ok(UninstallHooksResult {
204 hooks_removed: false,
205 commands_run: vec![],
206 })
207 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn test_name() {
217 let adapter = PiAdapter;
218 assert_eq!(adapter.name(), "pi");
219 assert_eq!(adapter.display_name(), "Pi Coding Agent");
220 }
221
222 #[test]
223 fn test_project_config_path() {
224 let adapter = PiAdapter;
225 assert_eq!(
226 adapter.project_config_path(),
227 PathBuf::from(".pi/extensions/mi6.ts")
228 );
229 }
230
231 #[test]
232 fn test_user_config_path() {
233 let adapter = PiAdapter;
234 let path = adapter.user_config_path();
235 assert!(path.is_some());
236 let path = path.unwrap();
237 assert!(path.ends_with(".pi/agent/extensions/mi6.ts"));
238 }
239
240 #[test]
241 fn test_map_event_type() {
242 let adapter = PiAdapter;
243
244 assert_eq!(
246 adapter.map_event_type("SessionStart"),
247 EventType::SessionStart
248 );
249 assert_eq!(adapter.map_event_type("PreToolUse"), EventType::PreToolUse);
250 assert_eq!(
251 adapter.map_event_type("PostToolUse"),
252 EventType::PostToolUse
253 );
254 assert_eq!(
255 adapter.map_event_type("UserPromptSubmit"),
256 EventType::UserPromptSubmit
257 );
258 assert_eq!(adapter.map_event_type("Stop"), EventType::Stop);
259 assert_eq!(adapter.map_event_type("PreCompact"), EventType::PreCompact);
260
261 assert_eq!(
263 adapter.map_event_type("UnknownEvent"),
264 EventType::Custom("UnknownEvent".to_string())
265 );
266 }
267
268 #[test]
269 fn test_parse_hook_input() {
270 let adapter = PiAdapter;
271 let input = serde_json::json!({
272 "session_id": "pi-session-123",
273 "tool_use_id": "tool-456",
274 "tool_name": "Bash",
275 "cwd": "/projects/test"
276 });
277
278 let parsed = adapter.parse_hook_input("PreToolUse", &input);
279
280 assert_eq!(parsed.session_id, Some("pi-session-123".to_string()));
281 assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
282 assert_eq!(parsed.tool_name, Some("Bash".to_string()));
283 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
284 assert_eq!(parsed.permission_mode, None);
286 assert_eq!(parsed.subagent_type, None);
287 assert_eq!(parsed.spawned_agent_id, None);
288 }
289
290 #[test]
291 fn test_parse_hook_input_with_model_and_prompt() {
292 let adapter = PiAdapter;
293 let input = serde_json::json!({
294 "session_id": "pi-session-123",
295 "cwd": "/projects/test",
296 "model": "anthropic/claude-sonnet-4-20250514",
297 "user_prompt": "hello there",
298 "transcript_path": "/home/user/.pi/agent/sessions/test/session.jsonl"
299 });
300
301 let parsed = adapter.parse_hook_input("SessionStart", &input);
302
303 assert_eq!(parsed.session_id, Some("pi-session-123".to_string()));
304 assert_eq!(
305 parsed.model,
306 Some("anthropic/claude-sonnet-4-20250514".to_string())
307 );
308 assert_eq!(parsed.prompt, Some("hello there".to_string()));
309 assert_eq!(
310 parsed.transcript_path,
311 Some("/home/user/.pi/agent/sessions/test/session.jsonl".to_string())
312 );
313 }
314
315 #[test]
316 fn test_parse_hook_input_with_trigger() {
317 let adapter = PiAdapter;
318 let input = serde_json::json!({
319 "session_id": "pi-123",
320 "trigger": "auto"
321 });
322
323 let parsed = adapter.parse_hook_input("PreCompact", &input);
324
325 assert_eq!(parsed.session_id, Some("pi-123".to_string()));
326 assert_eq!(parsed.compact_trigger, Some("auto".to_string()));
327 }
328
329 #[test]
330 fn test_parse_hook_input_with_tokens() {
331 let adapter = PiAdapter;
332 let input = serde_json::json!({
333 "session_id": "pi-123",
334 "model": "gpt-4.1",
335 "tokens_input": 942,
336 "tokens_output": 64,
337 "tokens_cache_read": 0,
338 "tokens_cache_write": 0,
339 "cost_usd": 0.00239
340 });
341
342 let parsed = adapter.parse_hook_input("ApiRequest", &input);
343
344 assert_eq!(parsed.session_id, Some("pi-123".to_string()));
345 assert_eq!(parsed.model, Some("gpt-4.1".to_string()));
346 assert_eq!(parsed.tokens_input, Some(942));
347 assert_eq!(parsed.tokens_output, Some(64));
348 assert_eq!(parsed.tokens_cache_read, Some(0));
349 assert_eq!(parsed.tokens_cache_write, Some(0));
350 assert!((parsed.cost_usd.unwrap() - 0.00239).abs() < 0.0001);
351 }
352
353 #[test]
354 fn test_supported_events() {
355 let adapter = PiAdapter;
356 let events = adapter.supported_events();
357
358 assert!(events.contains(&"SessionStart"));
359 assert!(events.contains(&"SessionEnd"));
360 assert!(events.contains(&"PreToolUse"));
361 assert!(events.contains(&"PostToolUse"));
362 assert!(events.contains(&"UserPromptSubmit"));
363 assert!(events.contains(&"Stop"));
364 assert!(events.contains(&"PreCompact"));
365 assert!(events.contains(&"ApiRequest"));
366 assert_eq!(events.len(), 8);
367 }
368
369 #[test]
370 fn test_detection_env_vars() {
371 let adapter = PiAdapter;
372 let vars = adapter.detection_env_vars();
373
374 assert!(vars.contains(&"PI_SESSION_ID"));
375 assert_eq!(vars.len(), 1);
376 }
377
378 #[test]
379 fn test_embedded_extension() {
380 assert!(MI6_EXTENSION_TS.contains("mi6"));
382 assert!(MI6_EXTENSION_TS.contains("ingestEvent"));
383 assert!(MI6_EXTENSION_TS.contains("session_start"));
385 assert!(MI6_EXTENSION_TS.contains("session_shutdown"));
386 assert!(MI6_EXTENSION_TS.contains("tool_call"));
387 assert!(MI6_EXTENSION_TS.contains("tool_result"));
388 assert!(MI6_EXTENSION_TS.contains("turn_start"));
389 assert!(MI6_EXTENSION_TS.contains("turn_end"));
390 assert!(MI6_EXTENSION_TS.contains("session_before_compact"));
391 assert!(MI6_EXTENSION_TS.contains("@mariozechner/pi-coding-agent"));
393 assert!(MI6_EXTENSION_TS.contains("export default function"));
395 assert!(MI6_EXTENSION_TS.contains("ExtensionContext"));
397 }
398
399 #[test]
400 fn test_generate_hooks_config() {
401 let adapter = PiAdapter;
402 let events = vec![EventType::SessionStart, EventType::PreToolUse];
403
404 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
405
406 assert!(config.get("_note").is_some());
408 assert!(config.get("extension_path").is_some());
409 }
410}