1use aether_project::{AetherSettings, AgentConfig};
2use crossterm::style::Stylize;
3use std::fs;
4
5use crate::agent::ListArgs;
6use crate::error::CliError;
7
8pub fn run_list(args: ListArgs) -> Result<(), CliError> {
9 let project_root = args.path.canonicalize().unwrap_or(args.path);
10 let settings_path = project_root.join(".aether/settings.json");
11
12 let content = match fs::read_to_string(&settings_path) {
13 Ok(c) => c,
14 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
15 println!("No agents found. Run `aether agent new` to create one.");
16 return Ok(());
17 }
18 Err(e) => return Err(CliError::IoError(e)),
19 };
20
21 let config: AetherSettings =
22 serde_json::from_str(&content).map_err(|e| CliError::AgentError(format!("Failed to parse settings: {e}")))?;
23
24 if config.agents.is_empty() {
25 println!("No agents found. Run `aether agent new` to create one.");
26 return Ok(());
27 }
28
29 let mut sorted: Vec<&AgentConfig> = config.agents.iter().collect();
30 sorted.sort_by(|a, b| a.name.cmp(&b.name));
31
32 for (i, agent) in sorted.iter().enumerate() {
33 if i > 0 {
34 println!();
35 }
36 print_agent(agent, &config.prompts, &config.mcps);
37 }
38
39 Ok(())
40}
41
42fn print_agent(
43 agent: &AgentConfig,
44 default_prompts: &[aether_project::PromptSource],
45 default_mcps: &[aether_project::McpSourceSpec],
46) {
47 println!("{}", agent.name.as_str().bold().cyan());
48 println!(" {} {}", "model:".dim(), agent.model);
49
50 let reasoning = agent.reasoning_effort.as_ref().map_or("none".to_string(), std::string::ToString::to_string);
51 println!(" {} {reasoning}", "reasoning:".dim());
52
53 println!(" {} {}", "description:".dim(), agent.description);
54
55 let mut surfaces = Vec::new();
56 if agent.user_invocable {
57 surfaces.push("user");
58 }
59 if agent.agent_invocable {
60 surfaces.push("agent");
61 }
62 println!(" {} {}", "invocable:".dim(), surfaces.join(", "));
63
64 let prompts = if agent.prompts.is_empty() { default_prompts } else { &agent.prompts };
65 if !prompts.is_empty() {
66 println!(
67 " {} {}",
68 "prompts:".dim(),
69 prompts.iter().filter_map(aether_project::PromptSource::path).collect::<Vec<_>>().join(", ")
70 );
71 }
72
73 let mcps = if agent.mcps.is_empty() { default_mcps } else { &agent.mcps };
74 if !mcps.is_empty() {
75 println!(
76 " {} {}",
77 "mcp servers:".dim(),
78 mcps.iter().filter_map(aether_project::McpSourceSpec::path).collect::<Vec<_>>().join(", ")
79 );
80 }
81
82 if !agent.tools.allow.is_empty() {
83 println!(" {} {}", "tools allow:".dim(), agent.tools.allow.join(", "));
84 }
85 if !agent.tools.deny.is_empty() {
86 println!(" {} {}", "tools deny:".dim(), agent.tools.deny.join(", "));
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::agent::new_agent_wizard::{DraftAgentEntry, build_system_md, scaffold};
94
95 #[test]
96 fn list_empty_project() {
97 let dir = tempfile::tempdir().unwrap();
98 let args = super::super::ListArgs { path: dir.path().to_path_buf() };
99 run_list(args).unwrap();
100 }
101
102 #[test]
103 fn list_project_with_agents() {
104 let dir = tempfile::tempdir().unwrap();
105 scaffold(dir.path(), &test_draft()).unwrap();
106 let args = super::super::ListArgs { path: dir.path().to_path_buf() };
107 run_list(args).unwrap();
108 }
109
110 fn test_draft() -> DraftAgentEntry {
111 let mut draft = DraftAgentEntry {
112 entry: AgentConfig {
113 name: "Coder".to_string(),
114 description: "A coding agent".to_string(),
115 user_invocable: true,
116 agent_invocable: true,
117 model: "anthropic:claude-sonnet-4-5".to_string(),
118 prompts: vec![aether_project::PromptSource::file("AGENTS.md")],
119 ..AgentConfig::default()
120 },
121 system_md_content: String::new(),
122 system_md_edited: false,
123 selected_mcp_servers: vec!["coding".into()],
124 workspace_mcp_configs: vec![],
125 };
126 draft.system_md_content = build_system_md(&draft);
127 draft
128 }
129}