1use crate::agents::AgentName;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ExtensionFile {
9 pub filename: String,
10 pub content: String,
11}
12
13#[must_use]
22pub fn build_extension(bin_path: &str, agent: AgentName) -> ExtensionFile {
23 match agent {
24 AgentName::ClaudeCode => ExtensionFile {
25 filename: "claude-code.json".to_string(),
26 content: build_claude_code_settings(bin_path),
27 },
28 AgentName::OhMyPi => ExtensionFile {
29 filename: "oh-my-pi.ts".to_string(),
30 content: build_oh_my_pi_extension(bin_path),
31 },
32 AgentName::Pi => ExtensionFile {
33 filename: "pi.ts".to_string(),
34 content: build_pi_extension(bin_path),
35 },
36 AgentName::Opencode => ExtensionFile {
37 filename: "opencode.ts".to_string(),
38 content: build_opencode_extension(bin_path),
39 },
40 }
41}
42
43fn build_claude_code_settings(bin_path: &str) -> String {
44 let set_notify = format!("{bin_path} set --agent claude-code notify");
45 let set_done = format!("{bin_path} set --agent claude-code done");
46 let set_working = format!("{bin_path} set --agent claude-code working");
47 let set_idle = format!("{bin_path} set --agent claude-code idle");
48 let clear = format!("{bin_path} clear --agent claude-code");
49
50 let value = serde_json::json!({
68 "hooks": {
69 "PermissionRequest": [{"hooks": [{"type": "command", "command": set_notify}]}],
70 "Stop": [{"hooks": [{"type": "command", "command": set_done}]}],
71 "UserPromptSubmit": [{"hooks": [{"type": "command", "command": &set_working}]}],
72 "PreToolUse": [{"hooks": [{"type": "command", "command": &set_working}]}],
73 "PostToolUse": [{"hooks": [{"type": "command", "command": set_working}]}],
74 "SessionStart": [{"hooks": [{"type": "command", "command": set_idle}]}],
75 "SessionEnd": [{"hooks": [{"type": "command", "command": clear}]}],
76 }
77 });
78 serde_json::to_string_pretty(&value).expect("serde_json::Value always serializes")
79}
80
81fn build_oh_my_pi_extension(bin_path: &str) -> String {
82 let template = include_str!("../../extensions/oh-my-pi.ts");
83 let serialized = serde_json::to_string(bin_path).expect("path serializes");
84 let replacement = format!("const BIN = {serialized};");
85 template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
86}
87
88fn build_pi_extension(bin_path: &str) -> String {
89 let template = include_str!("../../extensions/pi.ts");
90 let serialized = serde_json::to_string(bin_path).expect("path serializes");
91 let replacement = format!("const BIN = {serialized};");
92 template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
93}
94
95fn build_opencode_extension(bin_path: &str) -> String {
96 let template = include_str!("../../extensions/opencode.ts");
97 let serialized = serde_json::to_string(bin_path).expect("path serializes");
98 let replacement = format!("const BIN = {serialized};");
99 template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
100}
101
102const TS_BIN_RESOLUTION_LINE: &str =
109 "const BIN = process.env.AGENT_STATUS_BIN ?? \"agent-status\";";
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn build_extension_returns_extension_for_claude_code() {
117 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
118 assert_eq!(ext.filename, "claude-code.json");
119 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
120 assert!(parsed.get("hooks").is_some(), "missing top-level hooks key");
121 }
122
123 #[test]
124 fn build_extension_claude_code_wires_all_hook_events() {
125 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
126 for event in [
127 "PermissionRequest",
128 "Stop",
129 "UserPromptSubmit",
130 "PreToolUse",
131 "PostToolUse",
132 "SessionStart",
133 "SessionEnd",
134 ] {
135 assert!(ext.content.contains(event), "missing hook event {event}");
136 }
137 }
138
139 #[test]
140 fn build_extension_claude_code_does_not_subscribe_to_notification() {
141 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
147 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
148 assert!(
149 parsed.pointer("/hooks/Notification").is_none(),
150 "should not subscribe to Notification; got: {}",
151 ext.content,
152 );
153 }
154
155 #[test]
156 fn build_extension_claude_code_uses_set_and_clear_correctly() {
157 let ext = build_extension("/path/to/agent-status", AgentName::ClaudeCode);
158 assert!(ext.content.contains("set --agent claude-code notify"));
159 assert!(ext.content.contains("set --agent claude-code done"));
160 assert!(ext.content.contains("clear --agent claude-code"));
161 assert!(ext.content.contains("/path/to/agent-status"));
162 }
163
164 #[test]
165 fn build_extension_escapes_unsafe_chars_in_bin_path() {
166 let ext = build_extension(
167 r#"/x/has"quote\and-backslash/agent-status"#,
168 AgentName::ClaudeCode,
169 );
170 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
171 let command = parsed
172 .pointer("/hooks/PermissionRequest/0/hooks/0/command")
173 .and_then(serde_json::Value::as_str)
174 .expect("PermissionRequest command string");
175 assert!(command.contains(r#"has"quote\and-backslash"#), "got: {command}");
176 }
177
178 #[test]
179 fn build_extension_returns_pi_extension() {
180 let ext = build_extension("/abs/path/agent-status", AgentName::Pi);
181 assert_eq!(ext.filename, "pi.ts");
182 assert!(
183 ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
184 "missing substituted BIN; got:\n{}",
185 ext.content,
186 );
187 assert!(
188 !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
189 "env-fallback line should have been replaced",
190 );
191 assert!(ext.content.contains("export default function"));
192 }
193
194 #[test]
195 fn build_extension_pi_extension_json_escapes_bin_path() {
196 let ext = build_extension(
197 r#"/x/has"quote\and-backslash/agent-status"#,
198 AgentName::Pi,
199 );
200 assert!(
201 ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
202 "BIN line not escaped correctly; got:\n{}",
203 ext.content,
204 );
205 }
206
207 #[test]
208 fn build_extension_pi_extension_wires_all_parity_events() {
209 let ext = build_extension("/x/agent-status", AgentName::Pi);
214 for event in [
215 "session_start",
216 "session_shutdown",
217 "session_switch",
218 "session_branch",
219 "before_agent_start",
220 "tool_execution_start",
221 "tool_call",
222 "tool_execution_end",
223 "agent_end",
224 ] {
225 assert!(
226 ext.content.contains(&format!("pi.on(\"{event}\"")),
227 "missing pi event subscription {event}",
228 );
229 }
230 assert!(
231 ext.content.contains("\"notify\""),
232 "bridge never emits notify",
233 );
234 }
235
236 #[test]
237 fn build_extension_returns_opencode_extension() {
238 let ext = build_extension("/abs/path/agent-status", AgentName::Opencode);
239 assert_eq!(ext.filename, "opencode.ts");
240 assert!(
241 ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
242 "missing substituted BIN; got:\n{}",
243 ext.content,
244 );
245 assert!(
246 !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
247 "env-fallback line should have been replaced",
248 );
249 assert!(ext.content.contains("AgentStatusPlugin"));
250 }
251
252 #[test]
253 fn build_extension_opencode_extension_json_escapes_bin_path() {
254 let ext = build_extension(
255 r#"/x/has"quote\and-backslash/agent-status"#,
256 AgentName::Opencode,
257 );
258 assert!(
259 ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
260 "BIN line not escaped correctly; got:\n{}",
261 ext.content,
262 );
263 }
264
265 #[test]
266 fn build_extension_claude_code_user_prompt_submit_sets_working() {
267 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
268 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
269 let cmd = parsed
270 .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
271 .and_then(serde_json::Value::as_str)
272 .expect("UserPromptSubmit command");
273 assert!(
274 cmd.contains("set --agent claude-code working"),
275 "got: {cmd}",
276 );
277 }
278
279 #[test]
280 fn build_extension_claude_code_pre_tool_use_sets_working() {
281 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
282 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
283 let cmd = parsed
284 .pointer("/hooks/PreToolUse/0/hooks/0/command")
285 .and_then(serde_json::Value::as_str)
286 .expect("PreToolUse command");
287 assert!(
288 cmd.contains("set --agent claude-code working"),
289 "got: {cmd}",
290 );
291 }
292
293 #[test]
294 fn build_extension_claude_code_post_tool_use_sets_working() {
295 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
300 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
301 let cmd = parsed
302 .pointer("/hooks/PostToolUse/0/hooks/0/command")
303 .and_then(serde_json::Value::as_str)
304 .expect("PostToolUse command");
305 assert!(
306 cmd.contains("set --agent claude-code working"),
307 "got: {cmd}",
308 );
309 }
310
311 #[test]
312 fn build_extension_claude_code_permission_request_sets_notify() {
313 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
319 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
320 let cmd = parsed
321 .pointer("/hooks/PermissionRequest/0/hooks/0/command")
322 .and_then(serde_json::Value::as_str)
323 .expect("PermissionRequest command");
324 assert!(
325 cmd.contains("set --agent claude-code notify"),
326 "got: {cmd}",
327 );
328 }
329
330 #[test]
331 fn build_extension_claude_code_session_start_sets_idle() {
332 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
338 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
339 let cmd = parsed
340 .pointer("/hooks/SessionStart/0/hooks/0/command")
341 .and_then(serde_json::Value::as_str)
342 .expect("SessionStart command");
343 assert!(
344 cmd.contains("set --agent claude-code idle"),
345 "got: {cmd}",
346 );
347 }
348
349 #[test]
350 fn build_extension_claude_code_session_end_still_clears() {
351 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
353 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
354 let cmd = parsed
355 .pointer("/hooks/SessionEnd/0/hooks/0/command")
356 .and_then(serde_json::Value::as_str)
357 .expect("SessionEnd command");
358 assert!(
359 cmd.contains("clear --agent claude-code"),
360 "SessionEnd should still clear; got: {cmd}",
361 );
362 }
363
364 #[test]
365 fn build_extension_returns_oh_my_pi_extension() {
366 let ext = build_extension("/abs/path/agent-status", AgentName::OhMyPi);
367 assert_eq!(ext.filename, "oh-my-pi.ts");
368 assert!(
369 ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
370 "missing substituted BIN; got:\n{}",
371 ext.content,
372 );
373 assert!(
374 !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
375 "env-fallback line should have been replaced",
376 );
377 assert!(ext.content.contains("export default function"));
378 }
379
380 #[test]
381 fn build_extension_oh_my_pi_extension_json_escapes_bin_path() {
382 let ext = build_extension(
383 r#"/x/has"quote\and-backslash/agent-status"#,
384 AgentName::OhMyPi,
385 );
386 assert!(
387 ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
388 "BIN line not escaped correctly; got:\n{}",
389 ext.content,
390 );
391 }
392
393 #[test]
394 fn build_extension_oh_my_pi_wires_claude_code_parity_events() {
395 let ext = build_extension("/x/agent-status", AgentName::OhMyPi);
398 for event in [
399 "session_start",
400 "session_switch",
401 "session_branch",
402 "session_shutdown",
403 "before_agent_start",
404 "tool_execution_start",
405 "tool_execution_end",
406 "agent_end",
407 ] {
408 assert!(
409 ext.content.contains(&format!("omp.on(\"{event}\"")),
410 "bridge missing subscription for {event}",
411 );
412 }
413 assert!(
414 ext.content.contains(r#""set", "notify""#),
415 "bridge never sets notify (ask-tool mapping missing)",
416 );
417 }
418}