Skip to main content

seher/agent/
mod.rs

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, // no models map → pass-through, accepts any model key
162            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 models map is not set, pass --model <value> through as-is
189        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}