1use crate::session::ClaudeSession;
2
3pub fn fire_webhook(url: &str, session: &ClaudeSession, old_status: String) {
6 let payload = serde_json::json!({
7 "event": "status_change",
8 "session": {
9 "pid": session.pid,
10 "project": session.display_name(),
11 "old_status": old_status,
12 "new_status": session.status.to_string(),
13 "telemetry": session.telemetry_label(),
14 "cost_usd": if session.has_usage_metrics() { serde_json::json!((session.cost_usd * 100.0).round() / 100.0) } else { serde_json::Value::Null },
15 "context_pct": if session.has_usage_metrics() { serde_json::json!((session.context_percent() * 100.0).round() / 100.0) } else { serde_json::Value::Null },
16 "elapsed_secs": session.elapsed.as_secs(),
17 "estimate_verified": !session.cost_estimate_unverified,
18 "profile_source": session.model_profile_source,
19 },
20 "timestamp": chrono_now_iso(),
21 });
22
23 let body = serde_json::to_string(&payload).unwrap_or_default();
24 let url = url.to_string();
25
26 std::thread::spawn(move || {
28 let _ = std::process::Command::new("curl")
29 .args([
30 "-s",
31 "-X",
32 "POST",
33 "-H",
34 "Content-Type: application/json",
35 "-d",
36 &body,
37 "--max-time",
38 "5",
39 &url,
40 ])
41 .output();
42 });
43}
44
45pub fn chrono_now_iso() -> String {
47 let now = std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .unwrap_or_default();
50 let secs = now.as_secs();
51 let days_since_epoch = secs / 86400;
53 let time_of_day = secs % 86400;
54 let hours = time_of_day / 3600;
55 let minutes = (time_of_day % 3600) / 60;
56 let seconds = time_of_day % 60;
57
58 let mut y = 1970;
60 let mut remaining_days = days_since_epoch;
61 loop {
62 let days_in_year = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) {
63 366
64 } else {
65 365
66 };
67 if remaining_days < days_in_year {
68 break;
69 }
70 remaining_days -= days_in_year;
71 y += 1;
72 }
73 let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
74 let month_days = [
75 31,
76 if leap { 29 } else { 28 },
77 31,
78 30,
79 31,
80 30,
81 31,
82 31,
83 30,
84 31,
85 30,
86 31,
87 ];
88 let mut m = 0;
89 for &md in &month_days {
90 if remaining_days < md {
91 break;
92 }
93 remaining_days -= md;
94 m += 1;
95 }
96 let d = remaining_days + 1;
97 m += 1;
98
99 format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
100}
101
102pub fn fire_notification(message: &str) {
106 let safe = message.replace('"', "'").replace('\\', "");
107 #[cfg(target_os = "macos")]
108 let _ = std::process::Command::new("osascript")
109 .args([
110 "-e",
111 &format!("display notification \"{safe}\" with title \"claudectl\""),
112 ])
113 .spawn();
114 #[cfg(target_os = "linux")]
115 let _ = std::process::Command::new("notify-send")
116 .args(["claudectl", &safe])
117 .spawn();
118}
119
120pub fn dirs_home() -> std::path::PathBuf {
122 std::env::var_os("HOME")
123 .map(std::path::PathBuf::from)
124 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
125}
126
127fn fallback_tool_dirs(home: &std::path::Path) -> Vec<std::path::PathBuf> {
131 vec![
132 home.join(".local/bin"),
133 home.join(".cargo/bin"),
134 std::path::PathBuf::from("/opt/homebrew/bin"),
135 std::path::PathBuf::from("/usr/local/bin"),
136 home.join(".claude/local"),
137 ]
138}
139
140fn is_executable(path: &std::path::Path) -> bool {
141 use std::os::unix::fs::PermissionsExt;
142 std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
143}
144
145pub fn find_tool(name: &str) -> Option<std::path::PathBuf> {
149 find_tool_in(name, std::env::var_os("PATH").as_deref(), &dirs_home())
150}
151
152pub fn find_tool_in(
154 name: &str,
155 path_var: Option<&std::ffi::OsStr>,
156 home: &std::path::Path,
157) -> Option<std::path::PathBuf> {
158 let from_path = path_var
159 .map(|p| std::env::split_paths(p).collect::<Vec<_>>())
160 .unwrap_or_default();
161 from_path
162 .into_iter()
163 .chain(fallback_tool_dirs(home))
164 .map(|dir| dir.join(name))
165 .find(|candidate| is_executable(candidate))
166}
167
168pub fn claude_bin() -> std::path::PathBuf {
172 if let Some(explicit) = std::env::var_os("CLAUDECTL_CLAUDE_BIN").filter(|v| !v.is_empty()) {
173 return std::path::PathBuf::from(explicit);
174 }
175 find_tool("claude").unwrap_or_else(|| std::path::PathBuf::from("claude"))
176}
177
178pub fn kill_process(pid: u32) -> Result<(), String> {
180 let output = std::process::Command::new("kill")
181 .arg(pid.to_string())
182 .output()
183 .map_err(|e| format!("Failed to run kill: {e}"))?;
184
185 if output.status.success() {
186 return Ok(());
187 }
188
189 let output = std::process::Command::new("kill")
190 .args(["-9", &pid.to_string()])
191 .output()
192 .map_err(|e| format!("Failed to run kill -9: {e}"))?;
193
194 if output.status.success() {
195 Ok(())
196 } else {
197 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
198 }
199}
200
201pub fn create_aggregate_session(total_cost: f64, limit: f64, period: &str) -> ClaudeSession {
204 use crate::session::RawSession;
205 let raw = RawSession {
206 pid: 0,
207 session_id: format!("{period}-budget"),
208 cwd: String::new(),
209 started_at: 0,
210 };
211 let mut s = ClaudeSession::from_raw(raw);
212 s.project_name = format!("{period}-budget");
213 s.cost_usd = total_cost;
214 s.model = format!("limit=${limit:.2}");
215 s
216}
217
218pub fn truncate_cell(value: &str, width: usize) -> String {
221 if value.chars().count() <= width {
222 return value.to_string();
223 }
224 let keep = width.saturating_sub(1);
225 format!("{}…", value.chars().take(keep).collect::<String>())
226}
227
228pub fn is_exposed_bind(addr: &std::net::SocketAddr) -> bool {
235 !addr.ip().is_loopback()
236}
237
238pub fn fmt_ms_at(ms: u64, now_ms: u64) -> String {
248 let (delta_ms, future) = if ms >= now_ms {
249 (ms - now_ms, true)
250 } else {
251 (now_ms - ms, false)
252 };
253 let secs = delta_ms / 1000;
254 let nearest = |unit: u64| (secs + unit / 2) / unit;
256 let span = match secs {
257 0..=59 => return "just now".into(),
258 60..=3599 => format!("{}m", nearest(60)),
259 3600..=86_399 => format!("{}h", nearest(3600)),
260 _ => format!("{}d", nearest(86_400)),
261 };
262 if future {
263 format!("in {span}")
264 } else {
265 format!("{span} ago")
266 }
267}
268
269#[cfg(test)]
270mod find_tool_tests {
271 use super::*;
272 use std::os::unix::fs::PermissionsExt;
273
274 fn scratch(tag: &str) -> std::path::PathBuf {
275 let d = std::env::temp_dir().join(format!("cctl-findtool-{tag}-{}", std::process::id()));
276 let _ = std::fs::remove_dir_all(&d);
277 std::fs::create_dir_all(&d).unwrap();
278 d
279 }
280
281 fn tool(dir: &std::path::Path, name: &str, exec: bool) {
282 std::fs::create_dir_all(dir).unwrap();
283 let p = dir.join(name);
284 std::fs::write(&p, "#!/bin/sh\n").unwrap();
285 let mode = if exec { 0o755 } else { 0o644 };
286 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode)).unwrap();
287 }
288
289 #[test]
291 fn a_tool_outside_a_bare_path_is_found_in_the_usual_dirs() {
292 let home = scratch("fallback");
293 tool(&home.join(".local/bin"), "claude", true);
294 let bare = std::ffi::OsString::from("/usr/bin:/bin:/usr/sbin:/sbin");
295 assert_eq!(
296 find_tool_in("claude", Some(&bare), &home),
297 Some(home.join(".local/bin/claude"))
298 );
299 }
300
301 #[test]
302 fn path_wins_over_the_fallback_dirs() {
303 let home = scratch("pathwins");
304 tool(&home.join("onpath"), "claude", true);
305 tool(&home.join(".local/bin"), "claude", true);
306 let path = std::env::join_paths([home.join("onpath")]).unwrap();
307 assert_eq!(
308 find_tool_in("claude", Some(&path), &home),
309 Some(home.join("onpath/claude"))
310 );
311 }
312
313 #[test]
314 fn a_non_executable_file_is_not_a_tool() {
315 let name = "cctl-test-tool-not-installed";
318 let home = scratch("noexec");
319 tool(&home.join(".local/bin"), name, false);
320 assert_eq!(find_tool_in(name, None, &home), None);
321 }
322}
323
324#[cfg(test)]
325mod exposed_bind_tests {
326 use super::is_exposed_bind;
327 use std::net::SocketAddr;
328
329 fn addr(s: &str) -> SocketAddr {
330 s.parse().expect("test address should parse")
331 }
332
333 #[test]
334 fn loopback_is_not_exposed() {
335 assert!(!is_exposed_bind(&addr("127.0.0.1:9876")));
336 assert!(!is_exposed_bind(&addr("[::1]:9876")));
337 }
338
339 #[test]
340 fn unspecified_addresses_are_exposed() {
341 assert!(is_exposed_bind(&addr("0.0.0.0:9876")));
344 assert!(is_exposed_bind(&addr("[::]:9876")));
345 }
346
347 #[test]
348 fn specific_lan_address_is_exposed() {
349 assert!(is_exposed_bind(&addr("192.168.1.5:9876")));
350 }
351}
352
353#[cfg(test)]
354mod truncate_tests {
355 use super::truncate_cell;
356
357 #[test]
358 fn short_values_pass_through() {
359 assert_eq!(truncate_cell("claudectl", 16), "claudectl");
360 }
361
362 #[test]
363 fn long_values_are_ellipsized_to_width() {
364 let out = truncate_cell("[mac-mini-9f2a1b] nightly-bench", 16);
365 assert_eq!(out.chars().count(), 16);
366 assert!(out.ends_with('…'));
367 }
368
369 #[test]
370 fn multibyte_values_do_not_split_codepoints() {
371 let out = truncate_cell("日本語プロジェクト名前", 5);
372 assert_eq!(out.chars().count(), 5);
373 }
374}