1use super::{FrameworkAdapter, ParsedHookInput, common};
23use crate::model::EventType;
24use crate::model::error::InitError;
25use std::path::{Path, PathBuf};
26
27const MI6_EXTENSION_TS: &str = include_str!("pi-extension/mi6.ts");
29
30pub struct PiAdapter;
34
35impl FrameworkAdapter for PiAdapter {
36 fn name(&self) -> &'static str {
37 "pi"
38 }
39
40 fn display_name(&self) -> &'static str {
41 "Pi Coding Agent"
42 }
43
44 fn project_config_path(&self) -> PathBuf {
45 PathBuf::from(".pi/extensions/mi6.ts")
46 }
47
48 fn user_config_path(&self) -> Option<PathBuf> {
49 dirs::home_dir().map(|h| h.join(".pi/agent/extensions/mi6.ts"))
50 }
51
52 fn generate_hooks_config(
53 &self,
54 _enabled_events: &[EventType],
55 _mi6_bin: &str,
56 _otel_enabled: bool,
57 _otel_port: u16,
58 ) -> serde_json::Value {
59 serde_json::json!({
62 "_note": "Pi uses TypeScript extensions, not JSON hooks",
63 "extension_path": "~/.pi/agent/extensions/mi6.ts",
64 "extension_content": "See embedded mi6.ts"
65 })
66 }
67
68 fn merge_config(
69 &self,
70 generated: serde_json::Value,
71 _existing: Option<serde_json::Value>,
72 ) -> serde_json::Value {
73 generated
75 }
76
77 fn parse_hook_input(
78 &self,
79 _event_type: &str,
80 stdin_json: &serde_json::Value,
81 ) -> ParsedHookInput {
82 ParsedHookInput {
94 session_id: stdin_json
95 .get("session_id")
96 .and_then(|v| v.as_str())
97 .map(String::from),
98 tool_use_id: stdin_json
99 .get("tool_use_id")
100 .and_then(|v| v.as_str())
101 .map(String::from),
102 tool_name: stdin_json
103 .get("tool_name")
104 .and_then(|v| v.as_str())
105 .map(String::from),
106 cwd: stdin_json
107 .get("cwd")
108 .and_then(|v| v.as_str())
109 .map(String::from),
110 compact_trigger: stdin_json
111 .get("trigger")
112 .and_then(|v| v.as_str())
113 .map(String::from),
114 model: stdin_json
115 .get("model")
116 .and_then(|v| v.as_str())
117 .map(String::from),
118 transcript_path: stdin_json
119 .get("transcript_path")
120 .and_then(|v| v.as_str())
121 .map(String::from),
122 prompt: stdin_json
123 .get("user_prompt")
124 .and_then(|v| v.as_str())
125 .map(String::from),
126 tokens_input: stdin_json.get("tokens_input").and_then(|v| v.as_i64()),
128 tokens_output: stdin_json.get("tokens_output").and_then(|v| v.as_i64()),
129 tokens_cache_read: stdin_json.get("tokens_cache_read").and_then(|v| v.as_i64()),
130 tokens_cache_write: stdin_json
131 .get("tokens_cache_write")
132 .and_then(|v| v.as_i64()),
133 cost_usd: stdin_json.get("cost_usd").and_then(|v| v.as_f64()),
134 subagent_type: None,
136 spawned_agent_id: None,
137 permission_mode: None,
138 session_source: None,
139 agent_id: None,
140 agent_transcript_path: None,
141 duration_ms: None,
142 }
143 }
144
145 fn map_event_type(&self, framework_event: &str) -> EventType {
146 framework_event
148 .parse()
149 .unwrap_or_else(|_| EventType::Custom(framework_event.to_string()))
150 }
151
152 fn supported_events(&self) -> Vec<&'static str> {
153 vec![
154 "SessionStart",
155 "SessionEnd",
156 "PreToolUse",
157 "PostToolUse",
158 "UserPromptSubmit",
159 "Stop",
160 "PreCompact",
161 "ApiRequest",
162 ]
163 }
164
165 fn detection_env_vars(&self) -> &[&'static str] {
166 &["PI_SESSION_ID"]
168 }
169
170 fn is_installed(&self) -> bool {
171 common::is_framework_installed(dirs::home_dir().map(|h| h.join(".pi/agent")), "pi")
172 }
173
174 fn remove_hooks(&self, _existing: serde_json::Value) -> Option<serde_json::Value> {
175 None
178 }
179
180 fn settings_path(&self, local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
185 if local {
186 Ok(self.project_config_path())
187 } else {
188 self.user_config_path()
189 .ok_or_else(|| InitError::Config("could not determine home directory".into()))
190 }
191 }
192
193 fn has_mi6_hooks(&self, local: bool, settings_local: bool) -> bool {
194 self.settings_path(local, settings_local)
195 .map(|p| p.exists())
196 .unwrap_or(false)
197 }
198
199 fn install_hooks(
200 &self,
201 path: &Path,
202 _hooks: &serde_json::Value,
203 _otel_env: Option<serde_json::Value>,
204 _remove_otel: bool,
205 ) -> Result<(), InitError> {
206 if let Some(parent) = path.parent() {
208 std::fs::create_dir_all(parent).map_err(|e| {
209 InitError::Config(format!("failed to create {}: {e}", parent.display()))
210 })?;
211 }
212
213 std::fs::write(path, MI6_EXTENSION_TS)
215 .map_err(|e| InitError::Config(format!("failed to write {}: {e}", path.display())))?;
216
217 Ok(())
218 }
219
220 fn uninstall_hooks(&self, local: bool, settings_local: bool) -> Result<bool, InitError> {
221 let path = self.settings_path(local, settings_local)?;
222
223 if path.exists() {
224 std::fs::remove_file(&path).map_err(|e| {
225 InitError::Config(format!("failed to remove {}: {e}", path.display()))
226 })?;
227 Ok(true)
228 } else {
229 Ok(false)
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_name() {
240 let adapter = PiAdapter;
241 assert_eq!(adapter.name(), "pi");
242 assert_eq!(adapter.display_name(), "Pi Coding Agent");
243 }
244
245 #[test]
246 fn test_project_config_path() {
247 let adapter = PiAdapter;
248 assert_eq!(
249 adapter.project_config_path(),
250 PathBuf::from(".pi/extensions/mi6.ts")
251 );
252 }
253
254 #[test]
255 fn test_user_config_path() {
256 let adapter = PiAdapter;
257 let path = adapter.user_config_path();
258 assert!(path.is_some());
259 let path = path.unwrap();
260 assert!(path.ends_with(".pi/agent/extensions/mi6.ts"));
261 }
262
263 #[test]
264 fn test_map_event_type() {
265 let adapter = PiAdapter;
266
267 assert_eq!(
269 adapter.map_event_type("SessionStart"),
270 EventType::SessionStart
271 );
272 assert_eq!(adapter.map_event_type("PreToolUse"), EventType::PreToolUse);
273 assert_eq!(
274 adapter.map_event_type("PostToolUse"),
275 EventType::PostToolUse
276 );
277 assert_eq!(
278 adapter.map_event_type("UserPromptSubmit"),
279 EventType::UserPromptSubmit
280 );
281 assert_eq!(adapter.map_event_type("Stop"), EventType::Stop);
282 assert_eq!(adapter.map_event_type("PreCompact"), EventType::PreCompact);
283
284 assert_eq!(
286 adapter.map_event_type("UnknownEvent"),
287 EventType::Custom("UnknownEvent".to_string())
288 );
289 }
290
291 #[test]
292 fn test_parse_hook_input() {
293 let adapter = PiAdapter;
294 let input = serde_json::json!({
295 "session_id": "pi-session-123",
296 "tool_use_id": "tool-456",
297 "tool_name": "Bash",
298 "cwd": "/projects/test"
299 });
300
301 let parsed = adapter.parse_hook_input("PreToolUse", &input);
302
303 assert_eq!(parsed.session_id, Some("pi-session-123".to_string()));
304 assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
305 assert_eq!(parsed.tool_name, Some("Bash".to_string()));
306 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
307 assert_eq!(parsed.permission_mode, None);
309 assert_eq!(parsed.subagent_type, None);
310 assert_eq!(parsed.spawned_agent_id, None);
311 }
312
313 #[test]
314 fn test_parse_hook_input_with_model_and_prompt() {
315 let adapter = PiAdapter;
316 let input = serde_json::json!({
317 "session_id": "pi-session-123",
318 "cwd": "/projects/test",
319 "model": "anthropic/claude-sonnet-4-20250514",
320 "user_prompt": "hello there",
321 "transcript_path": "/home/user/.pi/agent/sessions/test/session.jsonl"
322 });
323
324 let parsed = adapter.parse_hook_input("SessionStart", &input);
325
326 assert_eq!(parsed.session_id, Some("pi-session-123".to_string()));
327 assert_eq!(
328 parsed.model,
329 Some("anthropic/claude-sonnet-4-20250514".to_string())
330 );
331 assert_eq!(parsed.prompt, Some("hello there".to_string()));
332 assert_eq!(
333 parsed.transcript_path,
334 Some("/home/user/.pi/agent/sessions/test/session.jsonl".to_string())
335 );
336 }
337
338 #[test]
339 fn test_parse_hook_input_with_trigger() {
340 let adapter = PiAdapter;
341 let input = serde_json::json!({
342 "session_id": "pi-123",
343 "trigger": "auto"
344 });
345
346 let parsed = adapter.parse_hook_input("PreCompact", &input);
347
348 assert_eq!(parsed.session_id, Some("pi-123".to_string()));
349 assert_eq!(parsed.compact_trigger, Some("auto".to_string()));
350 }
351
352 #[test]
353 fn test_parse_hook_input_with_tokens() {
354 let adapter = PiAdapter;
355 let input = serde_json::json!({
356 "session_id": "pi-123",
357 "model": "gpt-4.1",
358 "tokens_input": 942,
359 "tokens_output": 64,
360 "tokens_cache_read": 0,
361 "tokens_cache_write": 0,
362 "cost_usd": 0.00239
363 });
364
365 let parsed = adapter.parse_hook_input("ApiRequest", &input);
366
367 assert_eq!(parsed.session_id, Some("pi-123".to_string()));
368 assert_eq!(parsed.model, Some("gpt-4.1".to_string()));
369 assert_eq!(parsed.tokens_input, Some(942));
370 assert_eq!(parsed.tokens_output, Some(64));
371 assert_eq!(parsed.tokens_cache_read, Some(0));
372 assert_eq!(parsed.tokens_cache_write, Some(0));
373 assert!((parsed.cost_usd.unwrap() - 0.00239).abs() < 0.0001);
374 }
375
376 #[test]
377 fn test_supported_events() {
378 let adapter = PiAdapter;
379 let events = adapter.supported_events();
380
381 assert!(events.contains(&"SessionStart"));
382 assert!(events.contains(&"SessionEnd"));
383 assert!(events.contains(&"PreToolUse"));
384 assert!(events.contains(&"PostToolUse"));
385 assert!(events.contains(&"UserPromptSubmit"));
386 assert!(events.contains(&"Stop"));
387 assert!(events.contains(&"PreCompact"));
388 assert!(events.contains(&"ApiRequest"));
389 assert_eq!(events.len(), 8);
390 }
391
392 #[test]
393 fn test_detection_env_vars() {
394 let adapter = PiAdapter;
395 let vars = adapter.detection_env_vars();
396
397 assert!(vars.contains(&"PI_SESSION_ID"));
398 assert_eq!(vars.len(), 1);
399 }
400
401 #[test]
402 fn test_embedded_extension() {
403 assert!(MI6_EXTENSION_TS.contains("mi6"));
405 assert!(MI6_EXTENSION_TS.contains("ingestEvent"));
406 assert!(MI6_EXTENSION_TS.contains("session_start"));
408 assert!(MI6_EXTENSION_TS.contains("session_shutdown"));
409 assert!(MI6_EXTENSION_TS.contains("tool_call"));
410 assert!(MI6_EXTENSION_TS.contains("tool_result"));
411 assert!(MI6_EXTENSION_TS.contains("turn_start"));
412 assert!(MI6_EXTENSION_TS.contains("turn_end"));
413 assert!(MI6_EXTENSION_TS.contains("session_before_compact"));
414 assert!(MI6_EXTENSION_TS.contains("@mariozechner/pi-coding-agent"));
416 assert!(MI6_EXTENSION_TS.contains("export default function"));
418 assert!(MI6_EXTENSION_TS.contains("ExtensionContext"));
420 }
421
422 #[test]
423 fn test_generate_hooks_config() {
424 let adapter = PiAdapter;
425 let events = vec![EventType::SessionStart, EventType::PreToolUse];
426
427 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
428
429 assert!(config.get("_note").is_some());
431 assert!(config.get("extension_path").is_some());
432 }
433}