Skip to main content

aether_cli/agent/new_agent_wizard/
new_agent_step.rs

1use std::path::Path;
2
3use tui::SelectOption;
4
5pub enum NewAgentMode {
6    ScaffoldProject,
7    AddAgentToExistingProject,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum PromptFile {
12    Agents,
13    Claude,
14    Gemini,
15}
16
17impl PromptFile {
18    pub fn all() -> &'static [PromptFile] {
19        &[PromptFile::Agents, PromptFile::Claude, PromptFile::Gemini]
20    }
21
22    pub fn filename(self) -> &'static str {
23        match self {
24            PromptFile::Agents => "AGENTS.md",
25            PromptFile::Claude => "CLAUDE.md",
26            PromptFile::Gemini => "GEMINI.md",
27        }
28    }
29
30    pub fn description(self) -> &'static str {
31        match self {
32            PromptFile::Agents => "Project-level instructions shared across agents",
33            PromptFile::Claude => "Claude Code prompt file",
34            PromptFile::Gemini => "Gemini CLI prompt file",
35        }
36    }
37}
38
39pub fn detect_prompt_files(project_root: &Path) -> Vec<PromptFile> {
40    PromptFile::all().iter().copied().filter(|d| project_root.join(d.filename()).is_file()).collect()
41}
42
43/// Returns the prompt files the wizard should offer on the Prompts step.
44///
45/// Scaffold mode always offers `AGENTS.md` even when it's absent from disk,
46/// because scaffolding creates it. Add-agent mode only offers files that
47/// actually exist — aether doesn't author `CLAUDE.md`/`GEMINI.md` and there's
48/// no useful default `AGENTS.md` to append to.
49pub fn available_prompt_files(mode: &NewAgentMode, project_root: &Path) -> Vec<PromptFile> {
50    let mut detected = detect_prompt_files(project_root);
51    if matches!(mode, NewAgentMode::ScaffoldProject) && !detected.contains(&PromptFile::Agents) {
52        detected.insert(0, PromptFile::Agents);
53    }
54    detected
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum NewAgentOutcome {
59    Applied,
60    Cancelled,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum NewAgentStep {
65    Identity,
66    Model,
67    Prompts,
68    Tools,
69}
70
71impl NewAgentStep {
72    pub fn all() -> &'static [NewAgentStep] {
73        &[NewAgentStep::Identity, NewAgentStep::Model, NewAgentStep::Prompts, NewAgentStep::Tools]
74    }
75
76    pub fn title(self) -> &'static str {
77        match self {
78            NewAgentStep::Identity => "Identity",
79            NewAgentStep::Model => "Model",
80            NewAgentStep::Prompts => "Prompts",
81            NewAgentStep::Tools => "Tools",
82        }
83    }
84
85    pub fn heading(self) -> &'static str {
86        match self {
87            NewAgentStep::Identity => "Name your agent",
88            NewAgentStep::Model => "Select one or more models",
89            NewAgentStep::Prompts => "Select System Prompt Files",
90            NewAgentStep::Tools => "Select Tools",
91        }
92    }
93
94    pub fn next(self) -> Option<NewAgentStep> {
95        match self {
96            NewAgentStep::Identity => Some(NewAgentStep::Model),
97            NewAgentStep::Model => Some(NewAgentStep::Prompts),
98            NewAgentStep::Prompts => Some(NewAgentStep::Tools),
99            NewAgentStep::Tools => None,
100        }
101    }
102
103    pub fn prev(self) -> Option<NewAgentStep> {
104        match self {
105            NewAgentStep::Identity => None,
106            NewAgentStep::Model => Some(NewAgentStep::Identity),
107            NewAgentStep::Prompts => Some(NewAgentStep::Model),
108            NewAgentStep::Tools => Some(NewAgentStep::Prompts),
109        }
110    }
111}
112
113pub fn should_run_onboarding(dir: &Path) -> bool {
114    !dir.join(".aether/settings.json").exists()
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum McpConfigFile {
119    McpJson,
120    DotMcpJson,
121}
122
123impl McpConfigFile {
124    pub fn all() -> &'static [McpConfigFile] {
125        &[McpConfigFile::McpJson, McpConfigFile::DotMcpJson]
126    }
127
128    pub fn filename(self) -> &'static str {
129        match self {
130            McpConfigFile::McpJson => "mcp.json",
131            McpConfigFile::DotMcpJson => ".mcp.json",
132        }
133    }
134
135    pub fn description(self) -> &'static str {
136        match self {
137            McpConfigFile::McpJson => "MCP server configuration",
138            McpConfigFile::DotMcpJson => "MCP server configuration (dotfile)",
139        }
140    }
141}
142
143pub fn detect_mcp_configs(project_root: &Path) -> Vec<McpConfigFile> {
144    McpConfigFile::all().iter().copied().filter(|c| project_root.join(c.filename()).is_file()).collect()
145}
146
147pub fn server_options() -> Vec<tui::SelectOption> {
148    vec![
149        tui::SelectOption {
150            value: "coding".to_string(),
151            title: "Coding".to_string(),
152            description: Some("Filesystem, search, and bash tools".to_string()),
153        },
154        tui::SelectOption {
155            value: "lsp".to_string(),
156            title: "Lsp".to_string(),
157            description: Some("Language Server Protocol integration".to_string()),
158        },
159        tui::SelectOption {
160            value: "skills".to_string(),
161            title: "Skills".to_string(),
162            description: Some("Skills and slash-commands".to_string()),
163        },
164        tui::SelectOption {
165            value: "subagents".to_string(),
166            title: "Subagents".to_string(),
167            description: Some("Spawn sub-agents in parallel".to_string()),
168        },
169        tui::SelectOption {
170            value: "tasks".to_string(),
171            title: "Tasks".to_string(),
172            description: Some("Task management tools, backed by JSONL files".to_string()),
173        },
174        tui::SelectOption {
175            value: "survey".to_string(),
176            title: "Survey".to_string(),
177            description: Some("Allow your agent to ask you structured questions".to_string()),
178        },
179        SelectOption {
180            value: "plan".to_string(),
181            title: "Plan".to_string(),
182            description: Some("Plan-mode prompt and plan review via elicitation".to_string()),
183        },
184    ]
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    #[test]
191    fn should_run_onboarding_when_settings_missing() {
192        let dir = tempfile::tempdir().unwrap();
193        assert!(should_run_onboarding(dir.path()));
194    }
195
196    #[test]
197    fn does_not_run_onboarding_when_settings_exists() {
198        let dir = tempfile::tempdir().unwrap();
199        std::fs::create_dir_all(dir.path().join(".aether")).unwrap();
200        std::fs::write(dir.path().join(".aether/settings.json"), "{}").unwrap();
201        assert!(!should_run_onboarding(dir.path()));
202    }
203
204    #[test]
205    fn wizard_step_ordering() {
206        assert_eq!(NewAgentStep::Identity.next(), Some(NewAgentStep::Model));
207        assert_eq!(NewAgentStep::Model.next(), Some(NewAgentStep::Prompts));
208        assert_eq!(NewAgentStep::Prompts.next(), Some(NewAgentStep::Tools));
209        assert_eq!(NewAgentStep::Tools.next(), None);
210
211        assert_eq!(NewAgentStep::Identity.prev(), None);
212        assert_eq!(NewAgentStep::Model.prev(), Some(NewAgentStep::Identity));
213        assert_eq!(NewAgentStep::Tools.prev(), Some(NewAgentStep::Prompts));
214    }
215
216    #[test]
217    fn detect_prompt_files_returns_only_existing() {
218        let dir = tempfile::tempdir().unwrap();
219        std::fs::write(dir.path().join("AGENTS.md"), "a").unwrap();
220        std::fs::write(dir.path().join("GEMINI.md"), "g").unwrap();
221
222        let detected = detect_prompt_files(dir.path());
223        assert_eq!(detected, vec![PromptFile::Agents, PromptFile::Gemini]);
224    }
225
226    #[test]
227    fn available_prompt_files_scaffold_always_includes_agents_md() {
228        let dir = tempfile::tempdir().unwrap();
229        let prompt_files = available_prompt_files(&NewAgentMode::ScaffoldProject, dir.path());
230        assert_eq!(prompt_files, vec![PromptFile::Agents]);
231    }
232
233    #[test]
234    fn available_prompt_files_scaffold_preserves_order_when_all_present() {
235        let dir = tempfile::tempdir().unwrap();
236        std::fs::write(dir.path().join("AGENTS.md"), "a").unwrap();
237        std::fs::write(dir.path().join("CLAUDE.md"), "c").unwrap();
238        std::fs::write(dir.path().join("GEMINI.md"), "g").unwrap();
239
240        let prompt_files = available_prompt_files(&NewAgentMode::ScaffoldProject, dir.path());
241        assert_eq!(prompt_files, vec![PromptFile::Agents, PromptFile::Claude, PromptFile::Gemini]);
242    }
243
244    #[test]
245    fn available_prompt_files_scaffold_prepends_agents_md_when_only_others_exist() {
246        let dir = tempfile::tempdir().unwrap();
247        std::fs::write(dir.path().join("CLAUDE.md"), "c").unwrap();
248
249        let prompt_files = available_prompt_files(&NewAgentMode::ScaffoldProject, dir.path());
250        assert_eq!(prompt_files, vec![PromptFile::Agents, PromptFile::Claude]);
251    }
252
253    #[test]
254    fn detect_mcp_configs_returns_only_existing() {
255        let dir = tempfile::tempdir().unwrap();
256        std::fs::write(dir.path().join("mcp.json"), r#"{"servers":{}}"#).unwrap();
257
258        let detected = detect_mcp_configs(dir.path());
259        assert_eq!(detected, vec![McpConfigFile::McpJson]);
260    }
261
262    #[test]
263    fn detect_mcp_configs_finds_both() {
264        let dir = tempfile::tempdir().unwrap();
265        std::fs::write(dir.path().join("mcp.json"), r#"{"servers":{}}"#).unwrap();
266        std::fs::write(dir.path().join(".mcp.json"), r#"{"servers":{}}"#).unwrap();
267
268        let detected = detect_mcp_configs(dir.path());
269        assert_eq!(detected, vec![McpConfigFile::McpJson, McpConfigFile::DotMcpJson]);
270    }
271
272    #[test]
273    fn detect_mcp_configs_returns_empty_when_none() {
274        let dir = tempfile::tempdir().unwrap();
275        let detected = detect_mcp_configs(dir.path());
276        assert!(detected.is_empty());
277    }
278
279    #[test]
280    fn available_prompt_files_add_agent_is_detection_only() {
281        let dir = tempfile::tempdir().unwrap();
282        let prompt_files = available_prompt_files(&NewAgentMode::AddAgentToExistingProject, dir.path());
283        assert!(prompt_files.is_empty());
284    }
285}