1use crate::Cookie;
2use crate::config::AgentConfig;
3use chrono::{DateTime, Utc};
4use serde::Serialize;
5
6pub struct Agent {
7 pub config: AgentConfig,
8 pub cookies: Vec<Cookie>,
9}
10
11#[derive(Debug)]
12pub enum AgentLimit {
13 NotLimited,
14 Limited { reset_time: Option<DateTime<Utc>> },
15}
16
17#[derive(Debug, Serialize)]
18pub struct UsageEntry {
19 #[serde(rename = "type")]
20 pub entry_type: String,
21 pub limited: bool,
22 pub utilization: f64,
23 pub resets_at: Option<DateTime<Utc>>,
24}
25
26#[derive(Debug, Serialize)]
27pub struct AgentStatus {
28 pub command: String,
29 pub usage: Vec<UsageEntry>,
30}
31
32impl Agent {
33 pub fn new(config: AgentConfig, cookies: Vec<Cookie>) -> Self {
34 Self { config, cookies }
35 }
36
37 pub fn command(&self) -> &str {
38 &self.config.command
39 }
40
41 pub fn args(&self) -> &[String] {
42 &self.config.args
43 }
44
45 pub async fn check_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
46 match self.config.resolve_domain() {
47 Some("claude.ai") => self.check_claude_limit().await,
48 Some("github.com") => self.check_copilot_limit().await,
49 None => Ok(AgentLimit::NotLimited),
50 Some(d) => Err(format!("Unknown domain: {}", d).into()),
51 }
52 }
53
54 pub async fn fetch_status(&self) -> Result<AgentStatus, Box<dyn std::error::Error>> {
55 match self.config.resolve_domain() {
56 None => Ok(AgentStatus {
57 command: self.config.command.clone(),
58 usage: vec![],
59 }),
60 Some("claude.ai") => {
61 let usage = crate::claude::ClaudeClient::fetch_usage(&self.cookies).await?;
62 let windows = [
63 ("five_hour", &usage.five_hour),
64 ("seven_day", &usage.seven_day),
65 ("seven_day_sonnet", &usage.seven_day_sonnet),
66 ];
67 let entries = windows
68 .into_iter()
69 .filter_map(|(name, w)| {
70 w.as_ref().map(|w| UsageEntry {
71 entry_type: name.to_string(),
72 limited: w.utilization >= 100.0,
73 utilization: w.utilization,
74 resets_at: w.resets_at,
75 })
76 })
77 .collect();
78 Ok(AgentStatus {
79 command: self.config.command.clone(),
80 usage: entries,
81 })
82 }
83 Some("github.com") => {
84 let quota = crate::copilot::CopilotClient::fetch_quota(&self.cookies).await?;
85 let entries = vec![
86 UsageEntry {
87 entry_type: "chat_utilization".to_string(),
88 limited: quota.chat_utilization >= 100.0,
89 utilization: quota.chat_utilization,
90 resets_at: quota.reset_time,
91 },
92 UsageEntry {
93 entry_type: "premium_utilization".to_string(),
94 limited: quota.premium_utilization >= 100.0,
95 utilization: quota.premium_utilization,
96 resets_at: quota.reset_time,
97 },
98 ];
99 Ok(AgentStatus {
100 command: self.config.command.clone(),
101 usage: entries,
102 })
103 }
104 Some(d) => Err(format!("Unknown domain: {}", d).into()),
105 }
106 }
107
108 async fn check_claude_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
109 let usage = crate::claude::ClaudeClient::fetch_usage(&self.cookies).await?;
110
111 if let Some(reset_time) = usage.next_reset_time() {
112 Ok(AgentLimit::Limited {
113 reset_time: Some(reset_time),
114 })
115 } else {
116 let is_limited = [
117 usage.five_hour.as_ref(),
118 usage.seven_day.as_ref(),
119 usage.seven_day_sonnet.as_ref(),
120 ]
121 .into_iter()
122 .flatten()
123 .any(|w| w.utilization >= 100.0);
124
125 if is_limited {
126 Ok(AgentLimit::Limited { reset_time: None })
127 } else {
128 Ok(AgentLimit::NotLimited)
129 }
130 }
131 }
132
133 async fn check_copilot_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
134 let quota = crate::copilot::CopilotClient::fetch_quota(&self.cookies).await?;
135
136 if quota.is_limited() {
137 Ok(AgentLimit::Limited {
138 reset_time: quota.reset_time,
139 })
140 } else {
141 Ok(AgentLimit::NotLimited)
142 }
143 }
144
145 pub fn execute(
146 &self,
147 resolved_args: &[String],
148 extra_args: &[String],
149 ) -> std::io::Result<std::process::ExitStatus> {
150 let mut cmd = std::process::Command::new(self.command());
151 cmd.args(resolved_args);
152 cmd.args(extra_args);
153 if let Some(env) = &self.config.env {
154 cmd.envs(env);
155 }
156 cmd.status()
157 }
158
159 pub fn has_model(&self, model_key: &str) -> bool {
160 match &self.config.models {
161 None => true, Some(m) => m.contains_key(model_key),
163 }
164 }
165
166 pub fn resolved_args(&self, model: Option<&str>) -> Vec<String> {
167 const MODEL_PLACEHOLDER: &str = "{model}";
168 let mut args: Vec<String> = self
169 .config
170 .args
171 .iter()
172 .filter_map(|arg| {
173 if arg.contains(MODEL_PLACEHOLDER) {
174 let model_key = model?;
175 let replacement = self
176 .config
177 .models
178 .as_ref()
179 .and_then(|m| m.get(model_key))
180 .map_or(model_key, |s| s.as_str());
181 Some(arg.replace(MODEL_PLACEHOLDER, replacement))
182 } else {
183 Some(arg.clone())
184 }
185 })
186 .collect();
187
188 if self.config.models.is_none()
190 && let Some(model_key) = model
191 {
192 args.push("--model".to_string());
193 args.push(model_key.to_string());
194 }
195
196 args
197 }
198
199 pub fn mapped_args(&self, args: &[String]) -> Vec<String> {
200 args.iter()
201 .flat_map(|arg| {
202 self.config
203 .arg_maps
204 .get(arg.as_str())
205 .map_or_else(|| std::slice::from_ref(arg), Vec::as_slice)
206 })
207 .cloned()
208 .collect()
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use std::collections::HashMap;
215
216 use super::*;
217 use crate::config::AgentConfig;
218
219 fn make_agent(
220 models: Option<HashMap<String, String>>,
221 arg_maps: HashMap<String, Vec<String>>,
222 ) -> Agent {
223 Agent::new(
224 AgentConfig {
225 command: "claude".to_string(),
226 args: vec![],
227 models,
228 arg_maps,
229 env: None,
230 provider: None,
231 },
232 vec![],
233 )
234 }
235
236 #[test]
237 fn has_model_returns_true_when_models_is_none() {
238 let agent = make_agent(None, HashMap::new());
239 assert!(agent.has_model("high"));
240 assert!(agent.has_model("anything"));
241 }
242
243 #[test]
244 fn resolved_args_passthrough_when_models_is_none_with_model() {
245 let agent = make_agent(None, HashMap::new());
246 let args = agent.resolved_args(Some("high"));
247 assert_eq!(args, vec!["--model", "high"]);
248 }
249
250 #[test]
251 fn resolved_args_no_model_flag_when_models_is_none_without_model() {
252 let agent = make_agent(None, HashMap::new());
253 let args = agent.resolved_args(None);
254 assert!(!args.contains(&"--model".to_string()));
255 }
256
257 #[test]
258 fn mapped_args_passthrough_when_arg_maps_is_empty() {
259 let agent = make_agent(None, HashMap::new());
260 let args = vec!["--danger".to_string(), "fix bugs".to_string()];
261
262 assert_eq!(agent.mapped_args(&args), args);
263 }
264
265 #[test]
266 fn mapped_args_replaces_matching_tokens() {
267 let mut arg_maps = HashMap::new();
268 arg_maps.insert("--danger".to_string(), vec!["--yolo".to_string()]);
269 let agent = make_agent(None, arg_maps);
270
271 assert_eq!(
272 agent.mapped_args(&["--danger".to_string(), "fix bugs".to_string()]),
273 vec!["--yolo".to_string(), "fix bugs".to_string()]
274 );
275 }
276
277 #[test]
278 fn mapped_args_can_expand_to_multiple_tokens() {
279 let mut arg_maps = HashMap::new();
280 arg_maps.insert(
281 "--danger".to_string(),
282 vec![
283 "--permission-mode".to_string(),
284 "bypassPermissions".to_string(),
285 ],
286 );
287 let agent = make_agent(None, arg_maps);
288
289 assert_eq!(
290 agent.mapped_args(&["--danger".to_string(), "fix bugs".to_string()]),
291 vec![
292 "--permission-mode".to_string(),
293 "bypassPermissions".to_string(),
294 "fix bugs".to_string(),
295 ]
296 );
297 }
298}