Skip to main content

agent_status/commands/
agent_extension.rs

1use crate::agents::AgentName;
2
3/// One generated extension/settings file: the filename to write it as and the
4/// content to fill it with. Returned by [`build_extension`] for agents that
5/// support a per-launch file-loaded integration (Claude Code's `--settings`,
6/// pi's `-e <path>`).
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ExtensionFile {
9    pub filename: String,
10    pub content: String,
11}
12
13/// Build the extension/settings file an alias-installed agent loads at launch.
14///
15/// Every [`AgentName`] variant has a branch (the match is exhaustive), so the
16/// caller always gets an [`ExtensionFile`]. `claude-code` uses `--settings
17/// <file>`, `pi-coding-agent` uses `-e <file>`, and `opencode`'s in-process
18/// plugin file can be copied once. The `filename` member is the basename to
19/// write as (`claude-code.json`, `pi-coding-agent.ts`, `opencode.ts`); the
20/// `content` member is the file body.
21#[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
74/// The exact BIN-resolution line shared by `extensions/pi-coding-agent.ts`
75/// and `extensions/opencode.ts`. Matched verbatim by `str::replacen` so the
76/// embedded template can be specialized with an absolute path. If this line
77/// drifts in the .ts source, the substitution silently no-ops and the file
78/// keeps its env-fallback resolution at runtime — still functional, just
79/// not alias-optimized.
80const 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        // PermissionRequest fires when Claude Code shows a tool-permission dialog
223        // (after PreToolUse, before the user clicks Yes/No). Without this hook the
224        // PreToolUse-emitted `working` state stays until the user resolves the
225        // dialog — so the tmux indicator and agent-switcher would silently miss
226        // the "needs you now" transition.
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/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        // SessionStart registers the session as `idle` so every Claude session
242        // appears in the switcher from the moment it starts — even before the
243        // user has typed their first prompt. Clearing on SessionStart (the
244        // previous behavior) made the row invisible until UserPromptSubmit or
245        // PreToolUse fired.
246        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        // SessionEnd is the only lifecycle event that should remove the row.
261        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}