aether_cli/agent/
remove.rs1use aether_project::Settings;
2use crossterm::style::Stylize;
3use std::fs;
4use std::path::Path;
5
6use crate::agent::RemoveArgs;
7use crate::error::CliError;
8
9pub fn run_remove(args: RemoveArgs) -> Result<(), CliError> {
10 let project_root = args.path.canonicalize().unwrap_or(args.path);
11 let settings_path = project_root.join(".aether/settings.json");
12
13 let content = fs::read_to_string(&settings_path).map_err(CliError::IoError)?;
14 let mut settings: Settings =
15 serde_json::from_str(&content).map_err(|e| CliError::AgentError(format!("Failed to parse settings: {e}")))?;
16
17 let index = settings
18 .agents
19 .iter()
20 .position(|a| a.name == args.name)
21 .ok_or_else(|| CliError::AgentError(format!("Agent '{}' not found", args.name)))?;
22
23 let entry = settings.agents.remove(index);
24 let slug = entry.name.to_lowercase().replace(' ', "-");
25
26 cleanup_agent_files(&project_root, &slug, &entry);
27
28 let json = serde_json::to_string_pretty(&settings).expect("settings serialization cannot fail");
29 fs::write(&settings_path, json).map_err(CliError::IoError)?;
30
31 println!("{} Removed agent '{}'", "✓".green().bold(), entry.name);
32 Ok(())
33}
34
35fn cleanup_agent_files(project_root: &Path, slug: &str, entry: &aether_project::AgentEntry) {
36 let per_agent_dir = project_root.join(".aether/agents").join(slug);
37 if per_agent_dir.is_dir() {
38 let _ = fs::remove_dir_all(&per_agent_dir);
39 }
40
41 for prompt in &entry.prompts {
42 let path = project_root.join(prompt);
43 if path.starts_with(project_root.join(".aether")) {
44 let _ = fs::remove_file(&path);
45 }
46 }
47
48 for mcp in &entry.mcp_servers {
49 let path = project_root.join(mcp);
50 if path.starts_with(project_root.join(".aether")) {
51 let _ = fs::remove_file(&path);
52 }
53 }
54}
55
56#[cfg(test)]
57mod tests {
58 use super::*;
59 use crate::agent::new_agent_wizard::{DraftAgentEntry, add_agent, build_system_md, scaffold};
60 use aether_project::AgentEntry;
61
62 #[test]
63 fn remove_only_agent() {
64 let dir = tempfile::tempdir().unwrap();
65 scaffold(dir.path(), &default_draft()).unwrap();
66
67 let args = super::super::RemoveArgs { name: "Default".to_string(), path: dir.path().to_path_buf() };
68 run_remove(args).unwrap();
69
70 let content = fs::read_to_string(dir.path().join(".aether/settings.json")).unwrap();
71 let settings: Settings = serde_json::from_str(&content).unwrap();
72 assert!(settings.agents.is_empty());
73
74 assert!(!dir.path().join(".aether/DEFAULT.md").exists());
75 }
76
77 #[test]
78 fn remove_second_agent_keeps_first() {
79 let dir = tempfile::tempdir().unwrap();
80 scaffold(dir.path(), &default_draft()).unwrap();
81
82 let settings_path = dir.path().join(".aether/settings.json");
83 add_agent(&settings_path, &researcher_draft()).unwrap();
84
85 let args = super::super::RemoveArgs { name: "Researcher".to_string(), path: dir.path().to_path_buf() };
86 run_remove(args).unwrap();
87
88 let content = fs::read_to_string(&settings_path).unwrap();
89 let settings: Settings = serde_json::from_str(&content).unwrap();
90 assert_eq!(settings.agents.len(), 1);
91 assert_eq!(settings.agents[0].name, "Default");
92
93 assert!(!dir.path().join(".aether/agents/researcher").exists());
94 assert!(dir.path().join(".aether/DEFAULT.md").exists());
95 }
96
97 #[test]
98 fn remove_nonexistent_agent_returns_error() {
99 let dir = tempfile::tempdir().unwrap();
100 scaffold(dir.path(), &default_draft()).unwrap();
101
102 let args = super::super::RemoveArgs { name: "Ghost".to_string(), path: dir.path().to_path_buf() };
103 let result = run_remove(args);
104 assert!(result.is_err());
105 }
106
107 #[test]
108 fn remove_no_settings_file_returns_error() {
109 let dir = tempfile::tempdir().unwrap();
110 let args = super::super::RemoveArgs { name: "Default".to_string(), path: dir.path().to_path_buf() };
111 let result = run_remove(args);
112 assert!(result.is_err());
113 }
114
115 fn default_draft() -> DraftAgentEntry {
116 let mut draft = DraftAgentEntry {
117 entry: AgentEntry {
118 name: "Default".to_string(),
119 description: "Default coding agent".to_string(),
120 user_invocable: true,
121 agent_invocable: true,
122 model: "anthropic:claude-sonnet-4-5".to_string(),
123 prompts: vec!["AGENTS.md".to_string()],
124 mcp_servers: vec!["coding".to_string()],
125 ..AgentEntry::default()
126 },
127 system_md_content: String::new(),
128 system_md_edited: false,
129 workspace_mcp_configs: vec![],
130 };
131 draft.system_md_content = build_system_md(&draft);
132 draft
133 }
134
135 fn researcher_draft() -> DraftAgentEntry {
136 let mut draft = default_draft();
137 draft.entry.name = "Researcher".to_string();
138 draft.entry.description = "Research agent".to_string();
139 draft.entry.mcp_servers = vec![];
140 draft.workspace_mcp_configs = vec![];
141 draft.system_md_content = build_system_md(&draft);
142 draft
143 }
144}