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