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