1use crate::compiler::agents::{parse_agent_content, parse_agent_profile};
4use crate::error::MarsError;
5use crate::frontmatter;
6use crate::lock::ItemKind;
7
8use super::output;
9
10#[derive(serde::Serialize)]
11struct AgentEntry {
12 name: String,
13 description: String,
14 mode: String,
15}
16
17#[derive(Debug, clap::Args)]
19pub struct AgentsArgs {
20 #[arg(long, global = true)]
25 pub mode: Option<String>,
26
27 #[arg(long, global = true)]
29 pub source: Option<String>,
30
31 #[command(subcommand)]
32 pub command: Option<AgentsCommand>,
33}
34
35#[derive(Debug, clap::Subcommand)]
36pub enum AgentsCommand {
37 List,
39 Show {
41 name: String,
43 },
44}
45
46pub fn run(args: &AgentsArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
48 match &args.command {
49 Some(AgentsCommand::List) => run_list(args, ctx, json),
50 Some(AgentsCommand::Show { name }) => run_show(name, ctx, json),
51 None => run_list(args, ctx, json),
52 }
53}
54
55fn run_list(args: &AgentsArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
56 let lock = crate::lock::load(&ctx.project_root)?;
57 let mars_dir = ctx.project_root.join(".mars");
58
59 let mut entries: Vec<AgentEntry> = Vec::new();
60
61 for (dest_path, item) in lock.canonical_flat_items() {
62 if item.kind != ItemKind::Agent {
63 continue;
64 }
65
66 if let Some(ref filter_source) = args.source
68 && item.source != *filter_source
69 {
70 continue;
71 }
72
73 let disk_path = dest_path.resolve(&mars_dir);
74 let content = match std::fs::read_to_string(&disk_path) {
75 Ok(c) => c,
76 Err(err) => {
77 eprintln!("warning: skipping {}: {err}", disk_path.display());
78 continue;
79 }
80 };
81
82 let fm = match frontmatter::parse(&content) {
83 Ok(fm) => fm,
84 Err(err) => {
85 eprintln!("warning: skipping {}: {err}", disk_path.display());
86 continue;
87 }
88 };
89
90 let mut diags = Vec::new();
91 let profile = parse_agent_profile(&fm, &mut diags);
92
93 let mode_str = match &profile.mode {
95 Some(m) => m.as_str().to_string(),
96 None => String::new(),
97 };
98 if let Some(ref filter_mode) = args.mode
99 && mode_str != *filter_mode
100 {
101 continue;
102 }
103
104 let name = profile
105 .name
106 .clone()
107 .unwrap_or_else(|| path_stem(&disk_path));
108 let description = profile.description.clone().unwrap_or_default();
109
110 entries.push(AgentEntry {
111 name,
112 description,
113 mode: mode_str,
114 });
115 }
116
117 entries.sort_by(|a, b| a.name.cmp(&b.name));
118
119 if json {
120 output::print_json(&serde_json::json!({ "agents": entries }));
121 } else {
122 if entries.is_empty() {
123 println!(" no agents");
124 } else {
125 let name_w = entries
127 .iter()
128 .map(|e| e.name.len())
129 .max()
130 .unwrap_or(4)
131 .max(4);
132 let mode_w = entries
133 .iter()
134 .map(|e| e.mode.len())
135 .max()
136 .unwrap_or(4)
137 .max(4);
138 println!("{:<name_w$} {:<mode_w$} DESCRIPTION", "NAME", "MODE");
139 for e in &entries {
140 println!(
141 "{:<name_w$} {:<mode_w$} {}",
142 e.name, e.mode, e.description
143 );
144 }
145 }
146 }
147
148 Ok(0)
149}
150
151fn run_show(name: &str, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
152 let lock = crate::lock::load(&ctx.project_root)?;
153 let mars_dir = ctx.project_root.join(".mars");
154
155 for (dest_path, item) in lock.canonical_flat_items() {
156 if item.kind != ItemKind::Agent {
157 continue;
158 }
159
160 let disk_path = dest_path.resolve(&mars_dir);
161 let content = match std::fs::read_to_string(&disk_path) {
162 Ok(c) => c,
163 Err(err) => {
164 eprintln!("warning: skipping {}: {err}", disk_path.display());
165 continue;
166 }
167 };
168
169 let mut diags = Vec::new();
170 let (profile, _fm) = match parse_agent_content(&content, &mut diags) {
171 Ok(p) => p,
172 Err(err) => {
173 eprintln!("warning: skipping {}: {err}", disk_path.display());
174 continue;
175 }
176 };
177
178 let stem = path_stem(&disk_path);
179 let agent_name = profile.name.as_deref().unwrap_or(stem.as_str());
180 if !agent_name.eq_ignore_ascii_case(name) {
181 continue;
182 }
183
184 let mode_str = profile.mode.as_ref().map(|m| m.as_str()).unwrap_or("");
185 let harness_str = profile
186 .harness
187 .as_ref()
188 .map(|h| h.to_harness_id().as_str())
189 .unwrap_or("");
190 let model_str = profile.model.as_deref().unwrap_or("");
191 let approval_str = profile
192 .approval
193 .as_ref()
194 .map(|a| a.as_str())
195 .unwrap_or_default();
196 let sandbox_str = profile
197 .sandbox
198 .as_ref()
199 .map(|s| s.as_str())
200 .unwrap_or_default();
201 let effort_str = profile
202 .effort
203 .as_ref()
204 .map(|e| e.as_str())
205 .unwrap_or_default();
206 let description_str = profile.description.as_deref().unwrap_or("");
207
208 if json {
209 output::print_json(&serde_json::json!({
210 "name": agent_name,
211 "description": description_str,
212 "mode": mode_str,
213 "harness": harness_str,
214 "model": model_str,
215 "skills": profile.skills.all(),
216 "skills_structured": profile.skills,
217 "subagents": profile.subagents,
218 "approval": approval_str,
219 "sandbox": sandbox_str,
220 "effort": effort_str,
221 "tools": profile.tools,
222 "disallowed-tools": profile.disallowed_tools,
223 "tools-denied": profile.tools_denied,
224 }));
225 } else {
226 println!("name: {agent_name}");
227 println!("description: {description_str}");
228 println!("mode: {mode_str}");
229 println!("harness: {harness_str}");
230 println!("model: {model_str}");
231 println!("approval: {approval_str}");
232 println!("sandbox: {sandbox_str}");
233 println!("effort: {effort_str}");
234 print_str_list("skills.load", &profile.skills.load);
235 print_str_list("skills.available", &profile.skills.available);
236 print_str_list("subagents", &profile.subagents);
237 print_str_list("tools", &profile.tools);
238 print_str_list("disallowed-tools", &profile.disallowed_tools);
239 print_str_list("tools-denied", &profile.tools_denied);
240 }
241
242 return Ok(0);
243 }
244
245 eprintln!("error: agent `{name}` not found");
246 Ok(1)
247}
248
249fn print_str_list(label: &str, items: &[String]) {
250 if items.is_empty() {
251 println!("{label}: (none)");
252 } else {
253 println!("{label}: {}", items.join(", "));
254 }
255}
256
257fn path_stem(path: &std::path::Path) -> String {
258 path.file_stem()
259 .and_then(|s| s.to_str())
260 .unwrap_or("unknown")
261 .to_string()
262}
263
264#[cfg(test)]
265mod filter_flag_tests {
266 use crate::cli::{Cli, Command};
267 use clap::Parser;
268
269 fn agents_args(args: &[&str]) -> super::AgentsArgs {
270 match Cli::try_parse_from(args).expect("should parse").command {
271 Command::Agents(a) => a,
272 other => panic!("expected agents command, got {other:?}"),
273 }
274 }
275
276 #[test]
277 fn mode_filter_populates_on_both_bare_and_list_forms() {
278 for args in [
280 ["mars", "agents", "list", "--mode", "subagent"].as_slice(),
281 ["mars", "agents", "--mode", "subagent", "list"].as_slice(),
282 ["mars", "agents", "--mode", "subagent"].as_slice(),
283 ] {
284 let parsed = agents_args(args);
285 assert_eq!(parsed.mode.as_deref(), Some("subagent"), "args: {args:?}");
287 }
288 }
289
290 #[test]
291 fn source_filter_populates_on_list_form() {
292 let parsed = agents_args(&["mars", "agents", "list", "--source", "core"]);
293 assert_eq!(parsed.source.as_deref(), Some("core"));
294 }
295}