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 format!(
27 "claude -p \"You have new messages on the agent-relay. \
28 Read them with: agent-relay -S {} inbox --session {} \
29 Then respond with: agent-relay -S {} send -f {} -a claude \\\"your reply\\\" \
30 Be concise. Read all messages, respond to each, then exit.\"",
31 server, session, server, session
32 )
33 }
34}
35
36pub fn install(config: &DaemonConfig) -> Result<String, String> {
38 let binary = std::env::current_exe().map_err(|e| format!("Cannot find binary path: {}", e))?;
39
40 #[cfg(target_os = "macos")]
41 {
42 install_launchagent(&binary, config)
43 }
44
45 #[cfg(target_os = "linux")]
46 {
47 install_systemd(&binary, config)
48 }
49
50 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
51 {
52 Err(
53 "Daemon install not supported on this platform. Use 'agent-relay watch' manually."
54 .to_string(),
55 )
56 }
57}
58
59pub fn uninstall() -> Result<String, String> {
61 #[cfg(target_os = "macos")]
62 {
63 uninstall_launchagent()
64 }
65
66 #[cfg(target_os = "linux")]
67 {
68 uninstall_systemd()
69 }
70
71 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
72 {
73 Err("Daemon uninstall not supported on this platform.".to_string())
74 }
75}
76
77pub fn status() -> Result<String, String> {
79 #[cfg(target_os = "macos")]
80 {
81 status_launchagent()
82 }
83
84 #[cfg(target_os = "linux")]
85 {
86 status_systemd()
87 }
88
89 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
90 {
91 Err("Daemon status not supported on this platform.".to_string())
92 }
93}
94
95#[cfg(target_os = "macos")]
98fn plist_path() -> PathBuf {
99 let home = std::env::var("HOME").unwrap_or_default();
100 PathBuf::from(home)
101 .join("Library/LaunchAgents")
102 .join(format!("{}.plist", LABEL))
103}
104
105#[cfg(target_os = "macos")]
106fn install_launchagent(binary: &std::path::Path, config: &DaemonConfig) -> Result<String, String> {
107 let plist_dir = plist_path().parent().unwrap().to_path_buf();
108 let _ = std::fs::create_dir_all(&plist_dir);
109
110 let log_dir = PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".agent-relay");
111 let _ = std::fs::create_dir_all(&log_dir);
112
113 let plist = format!(
114 r#"<?xml version="1.0" encoding="UTF-8"?>
115<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
116<plist version="1.0">
117<dict>
118 <key>Label</key>
119 <string>{label}</string>
120 <key>ProgramArguments</key>
121 <array>
122 <string>{binary}</string>
123 <string>--server</string>
124 <string>{server}</string>
125 <string>watch</string>
126 <string>--session</string>
127 <string>{session}</string>
128 <string>--interval</string>
129 <string>{interval}</string>
130 <string>--exec</string>
131 <string>{exec_cmd}</string>
132 </array>
133 <key>RunAtLoad</key>
134 <true/>
135 <key>KeepAlive</key>
136 <true/>
137 <key>StandardOutPath</key>
138 <string>{log_dir}/daemon.log</string>
139 <key>StandardErrorPath</key>
140 <string>{log_dir}/daemon.err</string>
141 <key>ThrottleInterval</key>
142 <integer>{cooldown}</integer>
143</dict>
144</plist>"#,
145 label = LABEL,
146 binary = binary.display(),
147 server = config.server,
148 session = config.session,
149 interval = config.interval,
150 exec_cmd = config.exec_cmd.replace('"', """),
151 log_dir = log_dir.display(),
152 cooldown = config.cooldown,
153 );
154
155 let path = plist_path();
156 std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {}", e))?;
157
158 let _ = std::process::Command::new("launchctl")
160 .args(["unload", &path.to_string_lossy()])
161 .output();
162
163 let output = std::process::Command::new("launchctl")
164 .args(["load", &path.to_string_lossy()])
165 .output()
166 .map_err(|e| format!("launchctl load failed: {}", e))?;
167
168 if !output.status.success() {
169 return Err(format!(
170 "launchctl load failed: {}",
171 String::from_utf8_lossy(&output.stderr)
172 ));
173 }
174
175 Ok(format!(
176 "Daemon installed at {}\nLogs: {}/daemon.log\nPolling {} every {}s",
177 path.display(),
178 log_dir.display(),
179 config.server,
180 config.interval
181 ))
182}
183
184#[cfg(target_os = "macos")]
185fn uninstall_launchagent() -> Result<String, String> {
186 let path = plist_path();
187 if path.exists() {
188 let _ = std::process::Command::new("launchctl")
189 .args(["unload", &path.to_string_lossy()])
190 .output();
191 let _ = std::fs::remove_file(&path);
192 Ok(format!("Daemon uninstalled ({})", path.display()))
193 } else {
194 Ok("No daemon installed.".to_string())
195 }
196}
197
198#[cfg(target_os = "macos")]
199fn status_launchagent() -> Result<String, String> {
200 let output = std::process::Command::new("launchctl")
201 .args(["list", LABEL])
202 .output()
203 .map_err(|e| format!("launchctl list failed: {}", e))?;
204
205 if output.status.success() {
206 let stdout = String::from_utf8_lossy(&output.stdout);
207 Ok(format!("Daemon is running:\n{}", stdout))
208 } else {
209 Ok("Daemon is not running.".to_string())
210 }
211}
212
213#[cfg(target_os = "linux")]
216fn service_path() -> PathBuf {
217 let home = std::env::var("HOME").unwrap_or_default();
218 PathBuf::from(home)
219 .join(".config/systemd/user")
220 .join(format!("{}.service", SERVICE_NAME))
221}
222
223#[cfg(target_os = "linux")]
224fn install_systemd(binary: &PathBuf, config: &DaemonConfig) -> Result<String, String> {
225 let svc_dir = service_path().parent().unwrap().to_path_buf();
226 let _ = std::fs::create_dir_all(&svc_dir);
227
228 let unit = format!(
229 r#"[Unit]
230Description=agent-relay daemon — auto-respond to AI agent messages
231After=network.target
232
233[Service]
234Type=simple
235ExecStart={binary} --server {server} watch --session {session} --interval {interval} --exec "{exec_cmd}"
236Restart=always
237RestartSec={cooldown}
238
239[Install]
240WantedBy=default.target
241"#,
242 binary = binary.display(),
243 server = config.server,
244 session = config.session,
245 interval = config.interval,
246 exec_cmd = config.exec_cmd.replace('"', "\\\""),
247 cooldown = config.cooldown,
248 );
249
250 let path = service_path();
251 std::fs::write(&path, unit).map_err(|e| format!("Failed to write service: {}", e))?;
252
253 let _ = std::process::Command::new("systemctl")
254 .args(["--user", "daemon-reload"])
255 .output();
256
257 let output = std::process::Command::new("systemctl")
258 .args(["--user", "enable", "--now", SERVICE_NAME])
259 .output()
260 .map_err(|e| format!("systemctl enable failed: {}", e))?;
261
262 if !output.status.success() {
263 return Err(format!(
264 "systemctl enable failed: {}",
265 String::from_utf8_lossy(&output.stderr)
266 ));
267 }
268
269 Ok(format!(
270 "Daemon installed at {}\nPolling {} every {}s",
271 path.display(),
272 config.server,
273 config.interval
274 ))
275}
276
277#[cfg(target_os = "linux")]
278fn uninstall_systemd() -> Result<String, String> {
279 let _ = std::process::Command::new("systemctl")
280 .args(["--user", "stop", SERVICE_NAME])
281 .output();
282 let _ = std::process::Command::new("systemctl")
283 .args(["--user", "disable", SERVICE_NAME])
284 .output();
285
286 let path = service_path();
287 if path.exists() {
288 let _ = std::fs::remove_file(&path);
289 let _ = std::process::Command::new("systemctl")
290 .args(["--user", "daemon-reload"])
291 .output();
292 Ok(format!("Daemon uninstalled ({})", path.display()))
293 } else {
294 Ok("No daemon installed.".to_string())
295 }
296}
297
298#[cfg(target_os = "linux")]
299fn status_systemd() -> Result<String, String> {
300 let output = std::process::Command::new("systemctl")
301 .args(["--user", "status", SERVICE_NAME])
302 .output()
303 .map_err(|e| format!("systemctl status failed: {}", e))?;
304
305 Ok(String::from_utf8_lossy(&output.stdout).to_string())
306}