Skip to main content

claudectl_core/
helpers.rs

1use crate::session::ClaudeSession;
2
3/// Fire a webhook POST with session status change payload.
4/// Runs in a background thread to avoid blocking the TUI loop.
5pub 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    // Non-blocking: spawn a thread to POST
27    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
45/// Simple ISO-8601 timestamp without pulling in the chrono crate.
46pub 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    // Simple ISO-8601 without pulling in chrono crate
52    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    // Approximate date calculation (doesn't handle leap years perfectly but good enough for timestamps)
59    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
102/// Fire a desktop notification (macOS via osascript, Linux via notify-send).
103/// `message` is shown verbatim as the notification body — callers pass the full
104/// text (e.g. "myproject needs input", "myproject budget at 80%").
105pub 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
120/// Resolve the user's home directory, falling back to /tmp.
121pub 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
127/// Where command-line tools are usually installed when PATH doesn't say.
128/// launchd starts the relay with `/usr/bin:/bin:/usr/sbin:/sbin`, which has
129/// none of `claude` (`~/.local/bin` or Homebrew), `node` or `cargo`.
130fn 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
145/// Find a tool on PATH, then in the usual install directories. `None` if it
146/// isn't anywhere. Used for everything claudectl runs from the relay or the
147/// supervisor, which may be started with launchd's bare PATH.
148pub 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
152/// [`find_tool`] with the search inputs explicit, for tests.
153pub 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
168/// The `claude` binary to run. `CLAUDECTL_CLAUDE_BIN` overrides the search,
169/// which is also how tests substitute a stand-in. Falls back to the bare name,
170/// so the error, if any, comes from the spawn and names `claude`.
171pub 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
178/// Kill a process by PID. Tries SIGTERM first, then SIGKILL on failure.
179pub 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
201/// Create a synthetic session for aggregate budget hook firing.
202/// Uses {project} = "daily"/"weekly", {cost} = total spend.
203pub 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
218/// Fit a value into a fixed-width table column, with an ellipsis when it has to
219/// be cut. Counts characters, so a multi-byte name can't split mid-codepoint.
220pub 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
228/// True when a listener's bind address is reachable from off the machine.
229///
230/// `0.0.0.0` and `::` are *unspecified*, not loopback, so a plain
231/// `is_loopback()` check correctly flags them along with any specific LAN
232/// address. Callers use this to warn at startup when a plaintext or
233/// unauthenticated HTTP surface has been exposed to the network (#426).
234pub fn is_exposed_bind(addr: &std::net::SocketAddr) -> bool {
235    !addr.ip().is_loopback()
236}
237
238/// Render an epoch-millisecond timestamp as a coarse relative span.
239///
240/// `"just now"`, `"5m ago"`, `"in 30d"`. Rounds to the nearest unit rather than
241/// truncating, so a grant issued with `--expires 30d` does not read back as
242/// `in 29d` a millisecond later.
243///
244/// Lives in core because two feature-gated callers need it — `access` (behind
245/// `relay`) and `hive` (not) — and a display helper duplicated across a feature
246/// boundary is a display helper that drifts.
247pub 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    // Round to nearest unit rather than truncating.
255    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    /// The mini's case: launchd's PATH, `claude` in `~/.local/bin`.
290    #[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        // A name no real machine has, since the fallback dirs include the
316        // system's own /opt/homebrew/bin and /usr/local/bin.
317        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        // The case that matters: 0.0.0.0 binds every interface but is not
342        // loopback, so it must be flagged.
343        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}