Skip to main content

mars_agents/cli/
list.rs

1//! `mars list` — show available agents and skills.
2
3use std::path::Path;
4
5use crate::error::MarsError;
6use crate::frontmatter;
7use crate::hash;
8use crate::lock::ItemKind;
9
10use super::output::{self, CatalogEntry, ListEntry};
11
12/// Arguments for `mars list`.
13#[derive(Debug, clap::Args)]
14pub struct ListArgs {
15    /// Filter by source name.
16    #[arg(long)]
17    pub source: Option<String>,
18
19    /// Filter by item kind (agents, skills).
20    #[arg(long)]
21    pub kind: Option<String>,
22
23    /// Show detailed status (source, version, hash check).
24    #[arg(long)]
25    pub status: bool,
26}
27
28/// Run `mars list`.
29pub fn run(args: &ListArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
30    let lock = crate::lock::load(&ctx.project_root)?;
31
32    let mars_dir = ctx.project_root.join(".mars");
33    if args.status {
34        return run_status(args, &mars_dir, &lock, json);
35    }
36
37    // Default: catalog view (name + description from frontmatter)
38    let mut agents = Vec::new();
39    let mut skills = Vec::new();
40    let mut bootstrap = Vec::new();
41
42    for (dest_path, item) in lock.flat_items() {
43        // Filter by source
44        if let Some(ref filter_source) = args.source
45            && item.source != *filter_source
46        {
47            continue;
48        }
49
50        // Filter by kind
51        if let Some(ref filter_kind) = args.kind {
52            let kind_str = match item.kind {
53                ItemKind::Agent => "agents",
54                ItemKind::Skill => "skills",
55                ItemKind::Hook => "hooks",
56                ItemKind::McpServer => "mcp",
57                ItemKind::BootstrapDoc => "bootstrap",
58            };
59            if kind_str != filter_kind && &item.kind.to_string() != filter_kind {
60                continue;
61            }
62        }
63
64        // Read frontmatter for name + description from .mars/ canonical store
65        let disk_path = dest_path.resolve(&mars_dir);
66        let content_path = match item.kind {
67            ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
68                disk_path.clone()
69            }
70            ItemKind::Skill => disk_path.join("SKILL.md"),
71        };
72
73        let fallback_name = match item.kind {
74            ItemKind::BootstrapDoc => dest_path.item_name(item.kind),
75            _ => path_to_name(&disk_path),
76        };
77        let (name, description) = read_name_description(&content_path, &fallback_name);
78        let variants = match item.kind {
79            ItemKind::Skill => {
80                let (index, _warnings) =
81                    crate::compiler::variants::index_skill_variants(&disk_path);
82                index.annotation()
83            }
84            _ => None,
85        };
86
87        let entry = CatalogEntry {
88            name,
89            description,
90            kind: item.kind.to_string(),
91            variants,
92        };
93
94        match item.kind {
95            ItemKind::Agent => agents.push(entry),
96            ItemKind::Skill => skills.push(entry),
97            ItemKind::BootstrapDoc => bootstrap.push(entry),
98            // Config kinds are not shown in the default catalog view.
99            ItemKind::Hook | ItemKind::McpServer => {}
100        }
101    }
102
103    agents.sort_by(|a, b| a.name.cmp(&b.name));
104    skills.sort_by(|a, b| a.name.cmp(&b.name));
105    bootstrap.sort_by(|a, b| a.name.cmp(&b.name));
106
107    if json {
108        output::print_json(&serde_json::json!({
109            "agents": agents,
110            "skills": skills,
111            "bootstrap": bootstrap,
112        }));
113    } else {
114        output::print_catalog(&agents, &skills, &bootstrap, args.kind.as_deref());
115    }
116
117    Ok(0)
118}
119
120/// Read name and description from a file's frontmatter.
121fn read_name_description(path: &Path, fallback_name: &str) -> (String, String) {
122    let content = match std::fs::read_to_string(path) {
123        Ok(c) => c,
124        Err(_) => return (fallback_name.to_string(), String::new()),
125    };
126    match frontmatter::parse(&content) {
127        Ok(fm) => {
128            let name = fm
129                .name()
130                .map(str::to_string)
131                .unwrap_or_else(|| fallback_name.to_string());
132            let description = fm
133                .get("description")
134                .and_then(|v| v.as_str())
135                .unwrap_or_default()
136                .to_string();
137            (name, description)
138        }
139        Err(_) => (fallback_name.to_string(), String::new()),
140    }
141}
142
143/// Derive a display name from a file path.
144fn path_to_name(path: &Path) -> String {
145    path.file_stem()
146        .or_else(|| path.parent().and_then(|p| p.file_name()))
147        .and_then(|n| n.to_str())
148        .unwrap_or("unknown")
149        .to_string()
150}
151
152/// Status view (original table with source, version, hash check).
153fn run_status(
154    args: &ListArgs,
155    root: &Path,
156    lock: &crate::lock::LockFile,
157    json: bool,
158) -> Result<i32, MarsError> {
159    let mut entries = Vec::new();
160
161    for (dest_path, item) in lock.flat_items() {
162        if let Some(ref filter_source) = args.source
163            && item.source != *filter_source
164        {
165            continue;
166        }
167
168        if let Some(ref filter_kind) = args.kind {
169            let kind_str = match item.kind {
170                ItemKind::Agent => "agents",
171                ItemKind::Skill => "skills",
172                ItemKind::Hook => "hooks",
173                ItemKind::McpServer => "mcp",
174                ItemKind::BootstrapDoc => "bootstrap",
175            };
176            if kind_str != filter_kind && &item.kind.to_string() != filter_kind {
177                continue;
178            }
179        }
180
181        let disk_path = dest_path.resolve(root);
182        let hash_path = hash_path_for_kind(&disk_path, item.kind);
183        let status = if !hash_path.exists() {
184            "missing".to_string()
185        } else if crate::merge::file_has_conflict_markers(&disk_path) {
186            "conflicted".to_string()
187        } else {
188            let disk_hash = hash::compute_hash(&hash_path, item.kind)?;
189            if disk_hash == item.installed_checksum.as_ref() {
190                "ok".to_string()
191            } else {
192                "modified".to_string()
193            }
194        };
195
196        entries.push(ListEntry {
197            source: item.source.to_string(),
198            item: dest_path.to_string(),
199            kind: item.kind.to_string(),
200            version: item.version.clone().unwrap_or_else(|| "-".to_string()),
201            status,
202        });
203    }
204
205    entries.sort_by(|a, b| (&a.source, &a.item).cmp(&(&b.source, &b.item)));
206    output::print_list(&entries, json);
207    Ok(0)
208}
209
210fn hash_path_for_kind(path: &Path, kind: ItemKind) -> std::path::PathBuf {
211    if kind == ItemKind::BootstrapDoc {
212        path.parent()
213            .map(Path::to_path_buf)
214            .unwrap_or_else(|| path.to_path_buf())
215    } else {
216        path.to_path_buf()
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use tempfile::TempDir;
224
225    #[test]
226    fn read_name_description_uses_provided_fallback_name() {
227        let dir = TempDir::new().unwrap();
228        let missing_skill_md = dir.path().join("skills/test-skill/SKILL.md");
229        let (name, description) = read_name_description(&missing_skill_md, "test-skill");
230        assert_eq!(name, "test-skill");
231        assert_eq!(description, "");
232    }
233}