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!({
55 "hooks": {
56 "PermissionRequest": [{"hooks": [{"type": "command", "command": set_notify}]}],
57 "Stop": [{"hooks": [{"type": "command", "command": set_done}]}],
58 "UserPromptSubmit": [{"hooks": [{"type": "command", "command": &set_working}]}],
59 "PreToolUse": [{"hooks": [{"type": "command", "command": set_working}]}],
60 "SessionStart": [{"hooks": [{"type": "command", "command": set_idle}]}],
61 "SessionEnd": [{"hooks": [{"type": "command", "command": clear}]}],
62 }
63 });
64 serde_json::to_string_pretty(&value).expect("serde_json::Value always serializes")
65}
66
67fn build_pi_extension(bin_path: &str) -> String {
68 let template = include_str!("../../extensions/pi-coding-agent.ts");
69 let serialized = serde_json::to_string(bin_path).expect("path serializes");
70 let replacement = format!("const BIN = {serialized};");
71 template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
72}
73
74fn build_opencode_extension(bin_path: &str) -> String {
75 let template = include_str!("../../extensions/opencode.ts");
76 let serialized = serde_json::to_string(bin_path).expect("path serializes");
77 let replacement = format!("const BIN = {serialized};");
78 template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
79}
80
81const TS_BIN_RESOLUTION_LINE: &str =
88 "const BIN = process.env.AGENT_STATUS_BIN ?? \"agent-status\";";
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn build_extension_returns_extension_for_claude_code() {
96 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
97 assert_eq!(ext.filename, "claude-code.json");
98 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
99 assert!(parsed.get("hooks").is_some(), "missing top-level hooks key");
100 }
101
102 #[test]
103 fn build_extension_claude_code_wires_all_hook_events() {
104 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
105 for event in [
106 "PermissionRequest",
107 "Stop",
108 "UserPromptSubmit",
109 "PreToolUse",
110 "SessionStart",
111 "SessionEnd",
112 ] {
113 assert!(ext.content.contains(event), "missing hook event {event}");
114 }
115 }
116
117 #[test]
118 fn build_extension_claude_code_does_not_subscribe_to_notification() {
119 let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
125 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
126 assert!(
127 parsed.pointer("/hooks/Notification").is_none(),
128 "should not subscribe to Notification; got: {}",
129 ext.content,
130 );
131 }
132
133 #[test]
134 fn build_extension_claude_code_uses_set_and_clear_correctly() {
135 let ext = build_extension("/path/to/agent-status", AgentName::ClaudeCode);
136 assert!(ext.content.contains("set --agent claude-code notify"));
137 assert!(ext.content.contains("set --agent claude-code done"));
138 assert!(ext.content.contains("clear --agent claude-code"));
139 assert!(ext.content.contains("/path/to/agent-status"));
140 }
141
142 #[test]
143 fn build_extension_escapes_unsafe_chars_in_bin_path() {
144 let ext = build_extension(
145 r#"/x/has"quote\and-backslash/agent-status"#,
146 AgentName::ClaudeCode,
147 );
148 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
149 let command = parsed
150 .pointer("/hooks/PermissionRequest/0/hooks/0/command")
151 .and_then(serde_json::Value::as_str)
152 .expect("PermissionRequest command string");
153 assert!(command.contains(r#"has"quote\and-backslash"#), "got: {command}");
154 }
155
156 #[test]
157 fn build_extension_returns_pi_coding_agent_extension() {
158 let ext = build_extension("/abs/path/agent-status", AgentName::PiCodingAgent);
159 assert_eq!(ext.filename, "pi-coding-agent.ts");
160 assert!(
161 ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
162 "missing substituted BIN; got:\n{}",
163 ext.content,
164 );
165 assert!(
166 !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
167 "env-fallback line should have been replaced",
168 );
169 assert!(ext.content.contains("export default function"));
170 }
171
172 #[test]
173 fn build_extension_pi_extension_json_escapes_bin_path() {
174 let ext = build_extension(
175 r#"/x/has"quote\and-backslash/agent-status"#,
176 AgentName::PiCodingAgent,
177 );
178 assert!(
179 ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
180 "BIN line not escaped correctly; got:\n{}",
181 ext.content,
182 );
183 }
184
185 #[test]
186 fn build_extension_returns_opencode_extension() {
187 let ext = build_extension("/abs/path/agent-status", AgentName::Opencode);
188 assert_eq!(ext.filename, "opencode.ts");
189 assert!(
190 ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
191 "missing substituted BIN; got:\n{}",
192 ext.content,
193 );
194 assert!(
195 !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
196 "env-fallback line should have been replaced",
197 );
198 assert!(ext.content.contains("AgentStatusPlugin"));
199 }
200
201 #[test]
202 fn build_extension_opencode_extension_json_escapes_bin_path() {
203 let ext = build_extension(
204 r#"/x/has"quote\and-backslash/agent-status"#,
205 AgentName::Opencode,
206 );
207 assert!(
208 ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
209 "BIN line not escaped correctly; got:\n{}",
210 ext.content,
211 );
212 }
213
214 #[test]
215 fn build_extension_claude_code_user_prompt_submit_sets_working() {
216 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
217 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
218 let cmd = parsed
219 .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
220 .and_then(serde_json::Value::as_str)
221 .expect("UserPromptSubmit command");
222 assert!(
223 cmd.contains("set --agent claude-code working"),
224 "got: {cmd}",
225 );
226 }
227
228 #[test]
229 fn build_extension_claude_code_pre_tool_use_sets_working() {
230 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
231 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
232 let cmd = parsed
233 .pointer("/hooks/PreToolUse/0/hooks/0/command")
234 .and_then(serde_json::Value::as_str)
235 .expect("PreToolUse command");
236 assert!(
237 cmd.contains("set --agent claude-code working"),
238 "got: {cmd}",
239 );
240 }
241
242 #[test]
243 fn build_extension_claude_code_permission_request_sets_notify() {
244 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
250 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
251 let cmd = parsed
252 .pointer("/hooks/PermissionRequest/0/hooks/0/command")
253 .and_then(serde_json::Value::as_str)
254 .expect("PermissionRequest command");
255 assert!(
256 cmd.contains("set --agent claude-code notify"),
257 "got: {cmd}",
258 );
259 }
260
261 #[test]
262 fn build_extension_claude_code_session_start_sets_idle() {
263 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
269 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
270 let cmd = parsed
271 .pointer("/hooks/SessionStart/0/hooks/0/command")
272 .and_then(serde_json::Value::as_str)
273 .expect("SessionStart command");
274 assert!(
275 cmd.contains("set --agent claude-code idle"),
276 "got: {cmd}",
277 );
278 }
279
280 #[test]
281 fn build_extension_claude_code_session_end_still_clears() {
282 let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
284 let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
285 let cmd = parsed
286 .pointer("/hooks/SessionEnd/0/hooks/0/command")
287 .and_then(serde_json::Value::as_str)
288 .expect("SessionEnd command");
289 assert!(
290 cmd.contains("clear --agent claude-code"),
291 "SessionEnd should still clear; got: {cmd}",
292 );
293 }
294}