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 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 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 std::fs::write(path, MI6_EXTENSION_TS)
175 .map_err(|e| InitError::Config(format!("failed to write {}: {e}", path.display())))?;
176
177 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 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 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 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 assert!(MI6_EXTENSION_TS.contains("mi6"));
376 assert!(MI6_EXTENSION_TS.contains("ingestEvent"));
377 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 assert!(MI6_EXTENSION_TS.contains("@mariozechner/pi-coding-agent"));
387 assert!(MI6_EXTENSION_TS.contains("export default function"));
389 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 assert!(config.get("_note").is_some());
402 assert!(config.get("extension_path").is_some());
403 }
404}