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    if args.status {
33        return run_status(args, &ctx.managed_root, &lock, json);
34    }
35
36    // Default: catalog view (name + description from frontmatter)
37    let mut agents = Vec::new();
38    let mut skills = Vec::new();
39
40    for (dest_path, item) in &lock.items {
41        // Filter by source
42        if let Some(ref filter_source) = args.source
43            && &item.source != filter_source
44        {
45            continue;
46        }
47
48        // Filter by kind
49        if let Some(ref filter_kind) = args.kind {
50            let kind_str = match item.kind {
51                ItemKind::Agent => "agents",
52                ItemKind::Skill => "skills",
53            };
54            if kind_str != filter_kind && &item.kind.to_string() != filter_kind {
55                continue;
56            }
57        }
58
59        // Read frontmatter for name + description
60        let disk_path = ctx.managed_root.join(dest_path);
61        let content_path = match item.kind {
62            ItemKind::Agent => disk_path.clone(),
63            ItemKind::Skill => disk_path.join("SKILL.md"),
64        };
65
66        let fallback_name = path_to_name(&disk_path);
67        let (name, description) = read_name_description(&content_path, &fallback_name);
68
69        let entry = CatalogEntry {
70            name,
71            description,
72            kind: item.kind.to_string(),
73        };
74
75        match item.kind {
76            ItemKind::Agent => agents.push(entry),
77            ItemKind::Skill => skills.push(entry),
78        }
79    }
80
81    agents.sort_by(|a, b| a.name.cmp(&b.name));
82    skills.sort_by(|a, b| a.name.cmp(&b.name));
83
84    if json {
85        output::print_json(&serde_json::json!({
86            "agents": agents,
87            "skills": skills,
88        }));
89    } else {
90        output::print_catalog(&agents, &skills, args.kind.as_deref());
91    }
92
93    Ok(0)
94}
95
96/// Read name and description from a file's frontmatter.
97fn read_name_description(path: &Path, fallback_name: &str) -> (String, String) {
98    let content = match std::fs::read_to_string(path) {
99        Ok(c) => c,
100        Err(_) => return (fallback_name.to_string(), String::new()),
101    };
102    match frontmatter::parse(&content) {
103        Ok(fm) => {
104            let name = fm
105                .name()
106                .map(str::to_string)
107                .unwrap_or_else(|| fallback_name.to_string());
108            let description = fm
109                .get("description")
110                .and_then(|v| v.as_str())
111                .unwrap_or_default()
112                .to_string();
113            (name, description)
114        }
115        Err(_) => (fallback_name.to_string(), String::new()),
116    }
117}
118
119/// Derive a display name from a file path.
120fn path_to_name(path: &Path) -> String {
121    path.file_stem()
122        .or_else(|| path.parent().and_then(|p| p.file_name()))
123        .and_then(|n| n.to_str())
124        .unwrap_or("unknown")
125        .to_string()
126}
127
128/// Status view (original table with source, version, hash check).
129fn run_status(
130    args: &ListArgs,
131    root: &Path,
132    lock: &crate::lock::LockFile,
133    json: bool,
134) -> Result<i32, MarsError> {
135    let mut entries = Vec::new();
136
137    for (dest_path, item) in &lock.items {
138        if let Some(ref filter_source) = args.source
139            && &item.source != filter_source
140        {
141            continue;
142        }
143
144        if let Some(ref filter_kind) = args.kind {
145            let kind_str = match item.kind {
146                ItemKind::Agent => "agents",
147                ItemKind::Skill => "skills",
148            };
149            if kind_str != filter_kind && &item.kind.to_string() != filter_kind {
150                continue;
151            }
152        }
153
154        let disk_path = root.join(dest_path);
155        let status = if !disk_path.exists() {
156            "missing".to_string()
157        } else if has_conflict_markers(&disk_path) {
158            "conflicted".to_string()
159        } else {
160            let disk_hash = hash::compute_hash(&disk_path, item.kind)?;
161            if disk_hash == item.installed_checksum {
162                "ok".to_string()
163            } else {
164                "modified".to_string()
165            }
166        };
167
168        entries.push(ListEntry {
169            source: item.source.to_string(),
170            item: dest_path.to_string(),
171            kind: item.kind.to_string(),
172            version: item.version.clone().unwrap_or_else(|| "-".to_string()),
173            status,
174        });
175    }
176
177    entries.sort_by(|a, b| (&a.source, &a.item).cmp(&(&b.source, &b.item)));
178    output::print_list(&entries, json);
179    Ok(0)
180}
181
182/// Quick check for conflict markers.
183fn has_conflict_markers(path: &Path) -> bool {
184    std::fs::read_to_string(path)
185        .map(|content| content.contains("<<<<<<<") && content.contains(">>>>>>>"))
186        .unwrap_or(false)
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use tempfile::TempDir;
193
194    #[test]
195    fn read_name_description_uses_provided_fallback_name() {
196        let dir = TempDir::new().unwrap();
197        let missing_skill_md = dir.path().join("skills/test-skill/SKILL.md");
198        let (name, description) = read_name_description(&missing_skill_md, "test-skill");
199        assert_eq!(name, "test-skill");
200        assert_eq!(description, "");
201    }
202}