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::PiCodingAgent => ExtensionFile {
29 filename: "pi-coding-agent.ts".to_string(),
30 content: build_pi_extension(bin_path),
31 },
32 AgentName::Opencode => ExtensionFile {
33 filename: "opencode.ts".to_string(),
34 content: build_opencode_extension(bin_path),
35 },
36 }
37}
38
39fn build_claude_code_settings(bin_path: &str) -> String {
40 let set_notify = format!("{bin_path} set --agent claude-code notify");
41 let set_done = format!("{bin_path} set --agent claude-code done");
42 let set_working = format!("{bin_path} set --agent claude-code working");
43 let set_idle = format!("{bin_path} set --agent claude-code idle");
44 let clear = format!("{bin_path} clear --agent claude-code");
45
46 let value = serde_json::json!({
64 "hooks": {
65 "PermissionRequest": [{"hooks": [{"type": "command", "command": set_notify}]}],
66 "Stop": [{"hooks": [{"type": "command", "command": set_done}]}],
67 "UserPromptSubmit": [{"hooks": [{"type": "command", "command": &set_working}]}],
68 "PreToolUse": [{"hooks": [{"type": "command", "command": &set_working}]}],
69 "PostToolUse": [{"hooks": [{"type": "command", "command": set_working}]}],
70 "SessionStart": [{"hooks": [{"type": "command", "command": set_idle}]}],
71 "SessionEnd": [{"hooks": [{"type": "command", "command": clear}]}],
72 }
73 });
74 serde_json::to_string_pretty(&value).expect("serde_json::Value always serializes")
75}
76
77fn build_pi_extension(bin_path: &str) -> String {
78 let template = include_str!("../../extensions/pi-coding-agent.ts");
79 let serialized = serde_json::to_string(bin_path).expect("path serializes");
80 let replacement = format!("const BIN = {serialized};");
81 template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
82}
83
84fn build_opencode_extension(bin_path: &str) -> String {
85 let template = include_str!("../../extensions/opencode.ts");
86 let serialized = serde_json::to_string(bin_path).expect("path serializes");
87 let replacement = format!("const BIN = {serialized};");
88 template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
89}
90
91const TS_BIN_RESOLUTION_LINE: &str =
98 "const BIN = process.env.AGENT_STATUS_BIN ?? \"agent-status\";";
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn build_extension_returns_extension_for_claude_code() {
106 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
107 assert_eq!(ext.filename, "claude-code.json");
108 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
109 assert!(parsed.get("hooks").is_some(), "missing top-level hooks key");
110 }
111
112 #[test]
113 fn build_extension_claude_code_wires_all_hook_events() {
114 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
115 for event in [
116 "PermissionRequest",
117 "Stop",
118 "UserPromptSubmit",
119 "PreToolUse",
120 "PostToolUse",
121 "SessionStart",
122 "SessionEnd",
123 ] {
124 assert!(ext.content.contains(event), "missing hook event {event}");
125 }
126 }
127
128 #[test]
129 fn build_extension_claude_code_does_not_subscribe_to_notification() {
130 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
136 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
137 assert!(
138 parsed.pointer("/hooks/Notification").is_none(),
139 "should not subscribe to Notification; got: {}",
140 ext.content,
141 );
142 }
143
144 #[test]
145 fn build_extension_claude_code_uses_set_and_clear_correctly() {
146 let ext = build_extension("/path/to/agent-status", AgentName::ClaudeCode);
147 assert!(ext.content.contains("set --agent claude-code notify"));
148 assert!(ext.content.contains("set --agent claude-code done"));
149 assert!(ext.content.contains("clear --agent claude-code"));
150 assert!(ext.content.contains("/path/to/agent-status"));
151 }
152
153 #[test]
154 fn build_extension_escapes_unsafe_chars_in_bin_path() {
155 let ext = build_extension(
156 r#"/x/has"quote\and-backslash/agent-status"#,
157 AgentName::ClaudeCode,
158 );
159 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
160 let command = parsed
161 .pointer("/hooks/PermissionRequest/0/hooks/0/command")
162 .and_then(serde_json::Value::as_str)
163 .expect("PermissionRequest command string");
164 assert!(command.contains(r#"has"quote\and-backslash"#), "got: {command}");
165 }
166
167 #[test]
168 fn build_extension_returns_pi_coding_agent_extension() {
169 let ext = build_extension("/abs/path/agent-status", AgentName::PiCodingAgent);
170 assert_eq!(ext.filename, "pi-coding-agent.ts");
171 assert!(
172 ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
173 "missing substituted BIN; got:\n{}",
174 ext.content,
175 );
176 assert!(
177 !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
178 "env-fallback line should have been replaced",
179 );
180 assert!(ext.content.contains("export default function"));
181 }
182
183 #[test]
184 fn build_extension_pi_extension_json_escapes_bin_path() {
185 let ext = build_extension(
186 r#"/x/has"quote\and-backslash/agent-status"#,
187 AgentName::PiCodingAgent,
188 );
189 assert!(
190 ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
191 "BIN line not escaped correctly; got:\n{}",
192 ext.content,
193 );
194 }
195
196 #[test]
197 fn build_extension_returns_opencode_extension() {
198 let ext = build_extension("/abs/path/agent-status", AgentName::Opencode);
199 assert_eq!(ext.filename, "opencode.ts");
200 assert!(
201 ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
202 "missing substituted BIN; got:\n{}",
203 ext.content,
204 );
205 assert!(
206 !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
207 "env-fallback line should have been replaced",
208 );
209 assert!(ext.content.contains("AgentStatusPlugin"));
210 }
211
212 #[test]
213 fn build_extension_opencode_extension_json_escapes_bin_path() {
214 let ext = build_extension(
215 r#"/x/has"quote\and-backslash/agent-status"#,
216 AgentName::Opencode,
217 );
218 assert!(
219 ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
220 "BIN line not escaped correctly; got:\n{}",
221 ext.content,
222 );
223 }
224
225 #[test]
226 fn build_extension_claude_code_user_prompt_submit_sets_working() {
227 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
228 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
229 let cmd = parsed
230 .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
231 .and_then(serde_json::Value::as_str)
232 .expect("UserPromptSubmit command");
233 assert!(
234 cmd.contains("set --agent claude-code working"),
235 "got: {cmd}",
236 );
237 }
238
239 #[test]
240 fn build_extension_claude_code_pre_tool_use_sets_working() {
241 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
242 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
243 let cmd = parsed
244 .pointer("/hooks/PreToolUse/0/hooks/0/command")
245 .and_then(serde_json::Value::as_str)
246 .expect("PreToolUse command");
247 assert!(
248 cmd.contains("set --agent claude-code working"),
249 "got: {cmd}",
250 );
251 }
252
253 #[test]
254 fn build_extension_claude_code_post_tool_use_sets_working() {
255 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
260 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
261 let cmd = parsed
262 .pointer("/hooks/PostToolUse/0/hooks/0/command")
263 .and_then(serde_json::Value::as_str)
264 .expect("PostToolUse command");
265 assert!(
266 cmd.contains("set --agent claude-code working"),
267 "got: {cmd}",
268 );
269 }
270
271 #[test]
272 fn build_extension_claude_code_permission_request_sets_notify() {
273 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
279 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
280 let cmd = parsed
281 .pointer("/hooks/PermissionRequest/0/hooks/0/command")
282 .and_then(serde_json::Value::as_str)
283 .expect("PermissionRequest command");
284 assert!(
285 cmd.contains("set --agent claude-code notify"),
286 "got: {cmd}",
287 );
288 }
289
290 #[test]
291 fn build_extension_claude_code_session_start_sets_idle() {
292 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
298 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
299 let cmd = parsed
300 .pointer("/hooks/SessionStart/0/hooks/0/command")
301 .and_then(serde_json::Value::as_str)
302 .expect("SessionStart command");
303 assert!(
304 cmd.contains("set --agent claude-code idle"),
305 "got: {cmd}",
306 );
307 }
308
309 #[test]
310 fn build_extension_claude_code_session_end_still_clears() {
311 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
313 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
314 let cmd = parsed
315 .pointer("/hooks/SessionEnd/0/hooks/0/command")
316 .and_then(serde_json::Value::as_str)
317 .expect("SessionEnd command");
318 assert!(
319 cmd.contains("clear --agent claude-code"),
320 "SessionEnd should still clear; got: {cmd}",
321 );
322 }
323}