Skip to main content

aether_cli/agent/new_agent_wizard/
new_agent_step.rs

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