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/// Kill a process by PID. Tries SIGTERM first, then SIGKILL on failure.
128pub fn kill_process(pid: u32) -> Result<(), String> {
129    let output = std::process::Command::new("kill")
130        .arg(pid.to_string())
131        .output()
132        .map_err(|e| format!("Failed to run kill: {e}"))?;
133
134    if output.status.success() {
135        return Ok(());
136    }
137
138    let output = std::process::Command::new("kill")
139        .args(["-9", &pid.to_string()])
140        .output()
141        .map_err(|e| format!("Failed to run kill -9: {e}"))?;
142
143    if output.status.success() {
144        Ok(())
145    } else {
146        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
147    }
148}
149
150/// Create a synthetic session for aggregate budget hook firing.
151/// Uses {project} = "daily"/"weekly", {cost} = total spend.
152pub fn create_aggregate_session(total_cost: f64, limit: f64, period: &str) -> ClaudeSession {
153    use crate::session::RawSession;
154    let raw = RawSession {
155        pid: 0,
156        session_id: format!("{period}-budget"),
157        cwd: String::new(),
158        started_at: 0,
159    };
160    let mut s = ClaudeSession::from_raw(raw);
161    s.project_name = format!("{period}-budget");
162    s.cost_usd = total_cost;
163    s.model = format!("limit=${limit:.2}");
164    s
165}