1use std::path::PathBuf;
8
9const LABEL: &str = "com.naridon.agent-relay-daemon";
10#[cfg(target_os = "linux")]
11const SERVICE_NAME: &str = "agent-relay-daemon";
12
13#[derive(Clone)]
14pub struct DaemonConfig {
15 pub server: String,
16 pub session: String,
17 pub agent: String,
18 pub interval: u64,
19 pub exec_cmd: String,
20 pub daily_cap: u32,
21 pub cooldown: u64,
22}
23
24impl DaemonConfig {
25 pub fn default_exec(server: &str, session: &str) -> String {
26 let claude_bin = find_claude_binary();
28 let relay_bin = std::env::current_exe()
29 .map(|p| p.display().to_string())
30 .unwrap_or_else(|_| "agent-relay".to_string());
31
32 format!(
33 "{claude} --dangerously-skip-permissions -p \"\
34 You have new messages on the agent-relay. \
35 Read them with: {relay} -S {server} inbox --session {session} \
36 Then respond with: {relay} -S {server} send -f {session} -a claude \\\"your reply\\\" \
37 Be concise. Read all messages, respond to each, then exit.\"",
38 claude = claude_bin,
39 relay = relay_bin,
40 server = server,
41 session = session,
42 )
43 }
44}
45
46fn find_claude_binary() -> String {
48 let candidates = [
49 which_claude(),
51 Some(format!(
53 "{}/.local/bin/claude",
54 std::env::var("HOME").unwrap_or_default()
55 )),
56 Some(format!(
57 "{}/.cargo/bin/claude",
58 std::env::var("HOME").unwrap_or_default()
59 )),
60 Some("/usr/local/bin/claude".to_string()),
61 ];
62
63 for candidate in candidates.into_iter().flatten() {
64 if std::path::Path::new(&candidate).exists() {
65 return candidate;
66 }
67 }
68
69 "claude".to_string()
71}
72
73fn which_claude() -> Option<String> {
74 std::process::Command::new("which")
75 .arg("claude")
76 .output()
77 .ok()
78 .filter(|o| o.status.success())
79 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
80}
81
82pub fn install(config: &DaemonConfig) -> Result<String, String> {
84 let binary = std::env::current_exe().map_err(|e| format!("Cannot find binary path: {}", e))?;
85
86 #[cfg(target_os = "macos")]
87 {
88 install_launchagent(&binary, config)
89 }
90
91 #[cfg(target_os = "linux")]
92 {
93 install_systemd(&binary, config)
94 }
95
96 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
97 {
98 Err(
99 "Daemon install not supported on this platform. Use 'agent-relay watch' manually."
100 .to_string(),
101 )
102 }
103}
104
105pub fn uninstall() -> Result<String, String> {
107 #[cfg(target_os = "macos")]
108 {
109 uninstall_launchagent()
110 }
111
112 #[cfg(target_os = "linux")]
113 {
114 uninstall_systemd()
115 }
116
117 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
118 {
119 Err("Daemon uninstall not supported on this platform.".to_string())
120 }
121}
122
123pub fn status() -> Result<String, String> {
125 #[cfg(target_os = "macos")]
126 {
127 status_launchagent()
128 }
129
130 #[cfg(target_os = "linux")]
131 {
132 status_systemd()
133 }
134
135 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
136 {
137 Err("Daemon status not supported on this platform.".to_string())
138 }
139}
140
141#[cfg(target_os = "macos")]
144fn plist_path() -> PathBuf {
145 let home = std::env::var("HOME").unwrap_or_default();
146 PathBuf::from(home)
147 .join("Library/LaunchAgents")
148 .join(format!("{}.plist", LABEL))
149}
150
151#[cfg(target_os = "macos")]
152fn install_launchagent(binary: &std::path::Path, config: &DaemonConfig) -> Result<String, String> {
153 let plist_dir = plist_path().parent().unwrap().to_path_buf();
154 let _ = std::fs::create_dir_all(&plist_dir);
155
156 let log_dir = PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".agent-relay");
157 let _ = std::fs::create_dir_all(&log_dir);
158
159 let plist = format!(
160 r#"<?xml version="1.0" encoding="UTF-8"?>
161<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
162<plist version="1.0">
163<dict>
164 <key>Label</key>
165 <string>{label}</string>
166 <key>ProgramArguments</key>
167 <array>
168 <string>{binary}</string>
169 <string>--server</string>
170 <string>{server}</string>
171 <string>watch</string>
172 <string>--session</string>
173 <string>{session}</string>
174 <string>--interval</string>
175 <string>{interval}</string>
176 <string>--exec</string>
177 <string>{exec_cmd}</string>
178 </array>
179 <key>RunAtLoad</key>
180 <true/>
181 <key>KeepAlive</key>
182 <true/>
183 <key>StandardOutPath</key>
184 <string>{log_dir}/daemon.log</string>
185 <key>StandardErrorPath</key>
186 <string>{log_dir}/daemon.err</string>
187 <key>ThrottleInterval</key>
188 <integer>{cooldown}</integer>
189</dict>
190</plist>"#,
191 label = LABEL,
192 binary = binary.display(),
193 server = config.server,
194 session = config.session,
195 interval = config.interval,
196 exec_cmd = config.exec_cmd.replace('"', """),
197 log_dir = log_dir.display(),
198 cooldown = config.cooldown,
199 );
200
201 let path = plist_path();
202 std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {}", e))?;
203
204 let _ = std::process::Command::new("launchctl")
206 .args(["unload", &path.to_string_lossy()])
207 .output();
208
209 let output = std::process::Command::new("launchctl")
210 .args(["load", &path.to_string_lossy()])
211 .output()
212 .map_err(|e| format!("launchctl load failed: {}", e))?;
213
214 if !output.status.success() {
215 return Err(format!(
216 "launchctl load failed: {}",
217 String::from_utf8_lossy(&output.stderr)
218 ));
219 }
220
221 Ok(format!(
222 "Daemon installed at {}\nLogs: {}/daemon.log\nPolling {} every {}s",
223 path.display(),
224 log_dir.display(),
225 config.server,
226 config.interval
227 ))
228}
229
230#[cfg(target_os = "macos")]
231fn uninstall_launchagent() -> Result<String, String> {
232 let path = plist_path();
233 if path.exists() {
234 let _ = std::process::Command::new("launchctl")
235 .args(["unload", &path.to_string_lossy()])
236 .output();
237 let _ = std::fs::remove_file(&path);
238 Ok(format!("Daemon uninstalled ({})", path.display()))
239 } else {
240 Ok("No daemon installed.".to_string())
241 }
242}
243
244#[cfg(target_os = "macos")]
245fn status_launchagent() -> Result<String, String> {
246 let output = std::process::Command::new("launchctl")
247 .args(["list", LABEL])
248 .output()
249 .map_err(|e| format!("launchctl list failed: {}", e))?;
250
251 if output.status.success() {
252 let stdout = String::from_utf8_lossy(&output.stdout);
253 Ok(format!("Daemon is running:\n{}", stdout))
254 } else {
255 Ok("Daemon is not running.".to_string())
256 }
257}
258
259#[cfg(target_os = "linux")]
262fn service_path() -> PathBuf {
263 let home = std::env::var("HOME").unwrap_or_default();
264 PathBuf::from(home)
265 .join(".config/systemd/user")
266 .join(format!("{}.service", SERVICE_NAME))
267}
268
269#[cfg(target_os = "linux")]
270fn install_systemd(binary: &PathBuf, config: &DaemonConfig) -> Result<String, String> {
271 let svc_dir = service_path().parent().unwrap().to_path_buf();
272 let _ = std::fs::create_dir_all(&svc_dir);
273
274 let unit = format!(
275 r#"[Unit]
276Description=agent-relay daemon — auto-respond to AI agent messages
277After=network.target
278
279[Service]
280Type=simple
281ExecStart={binary} --server {server} watch --session {session} --interval {interval} --exec "{exec_cmd}"
282Restart=always
283RestartSec={cooldown}
284
285[Install]
286WantedBy=default.target
287"#,
288 binary = binary.display(),
289 server = config.server,
290 session = config.session,
291 interval = config.interval,
292 exec_cmd = config.exec_cmd.replace('"', "\\\""),
293 cooldown = config.cooldown,
294 );
295
296 let path = service_path();
297 std::fs::write(&path, unit).map_err(|e| format!("Failed to write service: {}", e))?;
298
299 let _ = std::process::Command::new("systemctl")
300 .args(["--user", "daemon-reload"])
301 .output();
302
303 let output = std::process::Command::new("systemctl")
304 .args(["--user", "enable", "--now", SERVICE_NAME])
305 .output()
306 .map_err(|e| format!("systemctl enable failed: {}", e))?;
307
308 if !output.status.success() {
309 return Err(format!(
310 "systemctl enable failed: {}",
311 String::from_utf8_lossy(&output.stderr)
312 ));
313 }
314
315 Ok(format!(
316 "Daemon installed at {}\nPolling {} every {}s",
317 path.display(),
318 config.server,
319 config.interval
320 ))
321}
322
323#[cfg(target_os = "linux")]
324fn uninstall_systemd() -> Result<String, String> {
325 let _ = std::process::Command::new("systemctl")
326 .args(["--user", "stop", SERVICE_NAME])
327 .output();
328 let _ = std::process::Command::new("systemctl")
329 .args(["--user", "disable", SERVICE_NAME])
330 .output();
331
332 let path = service_path();
333 if path.exists() {
334 let _ = std::fs::remove_file(&path);
335 let _ = std::process::Command::new("systemctl")
336 .args(["--user", "daemon-reload"])
337 .output();
338 Ok(format!("Daemon uninstalled ({})", path.display()))
339 } else {
340 Ok("No daemon installed.".to_string())
341 }
342}
343
344#[cfg(target_os = "linux")]
345fn status_systemd() -> Result<String, String> {
346 let output = std::process::Command::new("systemctl")
347 .args(["--user", "status", SERVICE_NAME])
348 .output()
349 .map_err(|e| format!("systemctl status failed: {}", e))?;
350
351 Ok(String::from_utf8_lossy(&output.stdout).to_string())
352}