claudectl_core/
helpers.rs1use 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
127pub 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
150pub 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}