Skip to main content

mars_agents/cli/
agents.rs

1//! `mars agents` — list and inspect agents from the .mars/ canonical store.
2
3use 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/// Arguments for `mars agents`.
18#[derive(Debug, clap::Args)]
19pub struct AgentsArgs {
20    /// Filter by mode (primary or subagent).
21    ///
22    /// Global so it is accepted both as `mars agents --mode ...` and on the
23    /// `list` subcommand (`mars agents list --mode ...`).
24    #[arg(long, global = true)]
25    pub mode: Option<String>,
26
27    /// Filter by source name.
28    #[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 all agents (same as bare `mars agents`).
38    List,
39    /// Show full metadata for a named agent.
40    Show {
41        /// Agent name.
42        name: String,
43    },
44}
45
46/// Run `mars agents`.
47pub 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        // source filter
67        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        // mode filter
94        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            // Compute column widths
126            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        // The `list` subcommand form is the discoverable one and must work.
279        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            // Value must actually populate (run_list reads args.mode), not merely parse.
286            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}