1use crate::ChannelMessage;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum CommandResult {
8 Response(String),
11 PassThrough,
13}
14
15#[derive(Clone)]
20pub struct CommandContext {
21 pub auto_approve: Arc<Mutex<Vec<String>>>,
22 pub config_path: Option<PathBuf>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ChatCommand {
28 Models(Option<String>),
30 Model(Option<String>),
32 New,
34 Approve(String),
36 Unapprove(String),
38 Approvals,
40 ApproveRequest(String),
42 ApproveConfirm(String),
44 ApprovePending,
46 Help,
48}
49
50pub fn parse_command(text: &str) -> Option<ChatCommand> {
53 let trimmed = text.trim();
54 if !trimmed.starts_with('/') {
55 return None;
56 }
57
58 let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
59 let cmd = parts[0].to_lowercase();
60 let arg = parts.get(1).map(|s| s.trim().to_string());
61
62 match cmd.as_str() {
63 "/models" => Some(ChatCommand::Models(arg)),
64 "/model" => Some(ChatCommand::Model(arg)),
65 "/new" => Some(ChatCommand::New),
66 "/approve" => arg.map(ChatCommand::Approve),
67 "/unapprove" => arg.map(ChatCommand::Unapprove),
68 "/approvals" => Some(ChatCommand::Approvals),
69 "/approve-request" => arg.map(ChatCommand::ApproveRequest),
70 "/approve-confirm" => arg.map(ChatCommand::ApproveConfirm),
71 "/approve-pending" => Some(ChatCommand::ApprovePending),
72 "/help" => Some(ChatCommand::Help),
73 _ => None,
74 }
75}
76
77pub fn handle_command(cmd: &ChatCommand, _msg: &ChannelMessage) -> CommandResult {
80 handle_command_with_context(cmd, _msg, None)
81}
82
83pub fn handle_command_with_context(
85 cmd: &ChatCommand,
86 _msg: &ChannelMessage,
87 ctx: Option<&CommandContext>,
88) -> CommandResult {
89 match cmd {
90 ChatCommand::Models(provider) => {
91 let response = if let Some(p) = provider {
92 format!("Listing models for provider `{p}`. (Requires runtime integration)")
93 } else {
94 "Available providers: openrouter, openai, anthropic, ollama. Use `/models <provider>` to list models.".to_string()
95 };
96 CommandResult::Response(response)
97 }
98 ChatCommand::Model(id) => {
99 let response = if let Some(model_id) = id {
100 format!("Switching model to `{model_id}` for this session.")
101 } else {
102 "Current model: (default from config). Use `/model <id>` to switch.".to_string()
103 };
104 CommandResult::Response(response)
105 }
106 ChatCommand::New => CommandResult::Response("Conversation history cleared.".to_string()),
107 ChatCommand::Approve(tool) => {
108 if let Some(ctx) = ctx {
109 approve_tool(tool, ctx)
110 } else {
111 CommandResult::Response(format!("Auto-approved tool `{tool}` for this session."))
112 }
113 }
114 ChatCommand::Unapprove(tool) => {
115 if let Some(ctx) = ctx {
116 unapprove_tool(tool, ctx)
117 } else {
118 CommandResult::Response(format!("Removed auto-approval for tool `{tool}`."))
119 }
120 }
121 ChatCommand::Approvals => {
122 if let Some(ctx) = ctx {
123 let list = ctx
124 .auto_approve
125 .lock()
126 .expect("auto_approve mutex poisoned");
127 if list.is_empty() {
128 CommandResult::Response("Current approvals: (none)".to_string())
129 } else {
130 CommandResult::Response(format!("Current approvals: {}", list.join(", ")))
131 }
132 } else {
133 CommandResult::Response("Current approvals: (none configured)".to_string())
134 }
135 }
136 ChatCommand::ApproveRequest(tool) => {
137 CommandResult::Response(format!("Approval requested for tool `{tool}`."))
138 }
139 ChatCommand::ApproveConfirm(id) => {
140 CommandResult::Response(format!("Approval confirmed for request `{id}`."))
141 }
142 ChatCommand::ApprovePending => {
143 CommandResult::Response("Pending approvals: (none)".to_string())
144 }
145 ChatCommand::Help => CommandResult::Response(
146 "Available commands:\n\
147 /models [provider] - List available models\n\
148 /model [id] - Show or switch model\n\
149 /new - Clear conversation history\n\
150 /approve <tool> - Auto-approve a tool\n\
151 /unapprove <tool> - Remove auto-approval\n\
152 /approvals - List current approvals\n\
153 /approve-request <tool> - Request tool approval\n\
154 /approve-confirm <id> - Confirm pending approval\n\
155 /approve-pending - List pending approvals\n\
156 /help - Show this help"
157 .to_string(),
158 ),
159 }
160}
161
162fn approve_tool(tool: &str, ctx: &CommandContext) -> CommandResult {
163 let mut list = ctx
164 .auto_approve
165 .lock()
166 .expect("auto_approve mutex poisoned");
167 if list.contains(&tool.to_string()) {
168 return CommandResult::Response(format!("Tool `{tool}` is already approved."));
169 }
170 list.push(tool.to_string());
171 let persist_msg = persist_approvals(&list, ctx.config_path.as_deref());
172 CommandResult::Response(format!("Auto-approved tool `{tool}`.{persist_msg}"))
173}
174
175fn unapprove_tool(tool: &str, ctx: &CommandContext) -> CommandResult {
176 let mut list = ctx
177 .auto_approve
178 .lock()
179 .expect("auto_approve mutex poisoned");
180 let before = list.len();
181 list.retain(|t| t != tool);
182 if list.len() == before {
183 return CommandResult::Response(format!("Tool `{tool}` was not in the approval list."));
184 }
185 let persist_msg = persist_approvals(&list, ctx.config_path.as_deref());
186 CommandResult::Response(format!(
187 "Removed auto-approval for tool `{tool}`.{persist_msg}"
188 ))
189}
190
191fn persist_approvals(tools: &[String], config_path: Option<&Path>) -> String {
192 let Some(path) = config_path else {
193 return String::new();
194 };
195 match agentzero_config::update_auto_approve(path, tools) {
196 Ok(()) => " Saved to config.".to_string(),
197 Err(e) => format!(" (warning: failed to persist: {e})"),
198 }
199}
200
201pub fn intercept_command(msg: &ChannelMessage) -> CommandResult {
205 match parse_command(&msg.content) {
206 Some(cmd) => handle_command(&cmd, msg),
207 None => CommandResult::PassThrough,
208 }
209}
210
211pub fn intercept_command_with_context(msg: &ChannelMessage, ctx: &CommandContext) -> CommandResult {
213 match parse_command(&msg.content) {
214 Some(cmd) => handle_command_with_context(&cmd, msg, Some(ctx)),
215 None => CommandResult::PassThrough,
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn test_msg(content: &str) -> ChannelMessage {
224 ChannelMessage {
225 id: "1".into(),
226 sender: "alice".into(),
227 reply_target: "alice".into(),
228 content: content.into(),
229 channel: "test".into(),
230 timestamp: 0,
231 thread_ts: None,
232 privacy_boundary: String::new(),
233 }
234 }
235
236 #[test]
237 fn parse_models_no_arg() {
238 assert_eq!(parse_command("/models"), Some(ChatCommand::Models(None)));
239 }
240
241 #[test]
242 fn parse_models_with_provider() {
243 assert_eq!(
244 parse_command("/models openai"),
245 Some(ChatCommand::Models(Some("openai".into())))
246 );
247 }
248
249 #[test]
250 fn parse_model_no_arg() {
251 assert_eq!(parse_command("/model"), Some(ChatCommand::Model(None)));
252 }
253
254 #[test]
255 fn parse_model_with_id() {
256 assert_eq!(
257 parse_command("/model gpt-4o"),
258 Some(ChatCommand::Model(Some("gpt-4o".into())))
259 );
260 }
261
262 #[test]
263 fn parse_new() {
264 assert_eq!(parse_command("/new"), Some(ChatCommand::New));
265 }
266
267 #[test]
268 fn parse_approve_with_tool() {
269 assert_eq!(
270 parse_command("/approve shell"),
271 Some(ChatCommand::Approve("shell".into()))
272 );
273 }
274
275 #[test]
276 fn parse_approve_without_tool_returns_none() {
277 assert_eq!(parse_command("/approve"), None);
278 }
279
280 #[test]
281 fn parse_unapprove() {
282 assert_eq!(
283 parse_command("/unapprove shell"),
284 Some(ChatCommand::Unapprove("shell".into()))
285 );
286 }
287
288 #[test]
289 fn parse_approvals() {
290 assert_eq!(parse_command("/approvals"), Some(ChatCommand::Approvals));
291 }
292
293 #[test]
294 fn parse_approve_pending() {
295 assert_eq!(
296 parse_command("/approve-pending"),
297 Some(ChatCommand::ApprovePending)
298 );
299 }
300
301 #[test]
302 fn parse_approve_confirm() {
303 assert_eq!(
304 parse_command("/approve-confirm req-123"),
305 Some(ChatCommand::ApproveConfirm("req-123".into()))
306 );
307 }
308
309 #[test]
310 fn parse_help() {
311 assert_eq!(parse_command("/help"), Some(ChatCommand::Help));
312 }
313
314 #[test]
315 fn parse_unknown_command_returns_none() {
316 assert_eq!(parse_command("/foobar"), None);
317 }
318
319 #[test]
320 fn parse_non_command_returns_none() {
321 assert_eq!(parse_command("hello world"), None);
322 assert_eq!(parse_command(""), None);
323 }
324
325 #[test]
326 fn intercept_command_handles_known_command() {
327 let msg = test_msg("/help");
328 match intercept_command(&msg) {
329 CommandResult::Response(text) => {
330 assert!(text.contains("Available commands"));
331 }
332 CommandResult::PassThrough => panic!("expected Response"),
333 }
334 }
335
336 #[test]
337 fn intercept_command_passes_through_non_command() {
338 let msg = test_msg("hello");
339 assert_eq!(intercept_command(&msg), CommandResult::PassThrough);
340 }
341
342 #[test]
343 fn case_insensitive_commands() {
344 assert_eq!(parse_command("/MODELS"), Some(ChatCommand::Models(None)));
345 assert_eq!(parse_command("/Help"), Some(ChatCommand::Help));
346 assert_eq!(parse_command("/NEW"), Some(ChatCommand::New));
347 }
348
349 #[test]
350 fn handle_model_switch_response() {
351 let cmd = ChatCommand::Model(Some("claude-3-opus".into()));
352 let msg = test_msg("/model claude-3-opus");
353 match handle_command(&cmd, &msg) {
354 CommandResult::Response(text) => {
355 assert!(text.contains("claude-3-opus"));
356 }
357 CommandResult::PassThrough => panic!("expected Response"),
358 }
359 }
360
361 #[test]
362 fn handle_new_clears_history() {
363 let msg = test_msg("/new");
364 match handle_command(&ChatCommand::New, &msg) {
365 CommandResult::Response(text) => {
366 assert!(text.contains("cleared"));
367 }
368 CommandResult::PassThrough => panic!("expected Response"),
369 }
370 }
371
372 fn test_ctx() -> CommandContext {
373 CommandContext {
374 auto_approve: Arc::new(Mutex::new(Vec::new())),
375 config_path: None,
376 }
377 }
378
379 #[test]
380 fn approve_adds_tool_to_list() {
381 let ctx = test_ctx();
382 let msg = test_msg("/approve shell");
383 match intercept_command_with_context(&msg, &ctx) {
384 CommandResult::Response(text) => {
385 assert!(text.contains("Auto-approved tool `shell`"));
386 }
387 CommandResult::PassThrough => panic!("expected Response"),
388 }
389 let list = ctx.auto_approve.lock().unwrap();
390 assert_eq!(*list, vec!["shell".to_string()]);
391 }
392
393 #[test]
394 fn approve_duplicate_rejected() {
395 let ctx = test_ctx();
396 ctx.auto_approve.lock().unwrap().push("shell".to_string());
397 let msg = test_msg("/approve shell");
398 match intercept_command_with_context(&msg, &ctx) {
399 CommandResult::Response(text) => {
400 assert!(text.contains("already approved"));
401 }
402 CommandResult::PassThrough => panic!("expected Response"),
403 }
404 }
405
406 #[test]
407 fn unapprove_removes_tool() {
408 let ctx = test_ctx();
409 ctx.auto_approve.lock().unwrap().push("shell".to_string());
410 let msg = test_msg("/unapprove shell");
411 match intercept_command_with_context(&msg, &ctx) {
412 CommandResult::Response(text) => {
413 assert!(text.contains("Removed auto-approval"));
414 }
415 CommandResult::PassThrough => panic!("expected Response"),
416 }
417 let list = ctx.auto_approve.lock().unwrap();
418 assert!(list.is_empty());
419 }
420
421 #[test]
422 fn unapprove_missing_tool_reports_not_found() {
423 let ctx = test_ctx();
424 let msg = test_msg("/unapprove shell");
425 match intercept_command_with_context(&msg, &ctx) {
426 CommandResult::Response(text) => {
427 assert!(text.contains("was not in the approval list"));
428 }
429 CommandResult::PassThrough => panic!("expected Response"),
430 }
431 }
432
433 #[test]
434 fn approvals_lists_current() {
435 let ctx = test_ctx();
436 {
437 let mut list = ctx.auto_approve.lock().unwrap();
438 list.push("shell".to_string());
439 list.push("browser".to_string());
440 }
441 let msg = test_msg("/approvals");
442 match intercept_command_with_context(&msg, &ctx) {
443 CommandResult::Response(text) => {
444 assert!(text.contains("shell"));
445 assert!(text.contains("browser"));
446 }
447 CommandResult::PassThrough => panic!("expected Response"),
448 }
449 }
450
451 #[test]
452 fn approvals_empty_shows_none() {
453 let ctx = test_ctx();
454 let msg = test_msg("/approvals");
455 match intercept_command_with_context(&msg, &ctx) {
456 CommandResult::Response(text) => {
457 assert!(text.contains("(none)"));
458 }
459 CommandResult::PassThrough => panic!("expected Response"),
460 }
461 }
462
463 #[test]
464 fn approve_persists_to_disk() {
465 let dir = std::env::temp_dir().join("agentzero-test-approve");
466 let _ = std::fs::create_dir_all(&dir);
467 let path = dir.join("config.toml");
468 std::fs::write(&path, "").unwrap();
469
470 let ctx = CommandContext {
471 auto_approve: Arc::new(Mutex::new(Vec::new())),
472 config_path: Some(path.clone()),
473 };
474
475 let msg = test_msg("/approve shell");
476 let result = intercept_command_with_context(&msg, &ctx);
477 match result {
478 CommandResult::Response(text) => {
479 assert!(text.contains("Saved to config"));
480 }
481 CommandResult::PassThrough => panic!("expected Response"),
482 }
483
484 let content = std::fs::read_to_string(&path).unwrap();
485 assert!(content.contains("auto_approve"));
486 assert!(content.contains("shell"));
487
488 let _ = std::fs::remove_dir_all(&dir);
489 }
490}