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.managed_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 (name, description) = read_name_description(&content_path);
67
68        let entry = CatalogEntry {
69            name,
70            description,
71            kind: item.kind.to_string(),
72        };
73
74        match item.kind {
75            ItemKind::Agent => agents.push(entry),
76            ItemKind::Skill => skills.push(entry),
77        }
78    }
79
80    agents.sort_by(|a, b| a.name.cmp(&b.name));
81    skills.sort_by(|a, b| a.name.cmp(&b.name));
82
83    if json {
84        output::print_json(&serde_json::json!({
85            "agents": agents,
86            "skills": skills,
87        }));
88    } else {
89        output::print_catalog(&agents, &skills, args.kind.as_deref());
90    }
91
92    Ok(0)
93}
94
95/// Read name and description from a file's frontmatter.
96fn read_name_description(path: &Path) -> (String, String) {
97    let content = match std::fs::read_to_string(path) {
98        Ok(c) => c,
99        Err(_) => return (path_to_name(path), String::new()),
100    };
101    match frontmatter::parse(&content) {
102        Ok(fm) => {
103            let name = fm
104                .name()
105                .map(str::to_string)
106                .unwrap_or_else(|| path_to_name(path));
107            let description = fm
108                .get("description")
109                .and_then(|v| v.as_str())
110                .unwrap_or_default()
111                .to_string();
112            (name, description)
113        }
114        Err(_) => (path_to_name(path), String::new()),
115    }
116}
117
118/// Derive a display name from a file path.
119fn path_to_name(path: &Path) -> String {
120    path.file_stem()
121        .or_else(|| path.parent().and_then(|p| p.file_name()))
122        .and_then(|n| n.to_str())
123        .unwrap_or("unknown")
124        .to_string()
125}
126
127/// Status view (original table with source, version, hash check).
128fn run_status(
129    args: &ListArgs,
130    root: &Path,
131    lock: &crate::lock::LockFile,
132    json: bool,
133) -> Result<i32, MarsError> {
134    let mut entries = Vec::new();
135
136    for (dest_path, item) in &lock.items {
137        if let Some(ref filter_source) = args.source
138            && &item.source != filter_source
139        {
140            continue;
141        }
142
143        if let Some(ref filter_kind) = args.kind {
144            let kind_str = match item.kind {
145                ItemKind::Agent => "agents",
146                ItemKind::Skill => "skills",
147            };
148            if kind_str != filter_kind && &item.kind.to_string() != filter_kind {
149                continue;
150            }
151        }
152
153        let disk_path = root.join(dest_path);
154        let status = if !disk_path.exists() {
155            "missing".to_string()
156        } else if has_conflict_markers(&disk_path) {
157            "conflicted".to_string()
158        } else {
159            let disk_hash = hash::compute_hash(&disk_path, item.kind)?;
160            if disk_hash == item.installed_checksum {
161                "ok".to_string()
162            } else {
163                "modified".to_string()
164            }
165        };
166
167        entries.push(ListEntry {
168            source: item.source.to_string(),
169            item: dest_path.to_string(),
170            kind: item.kind.to_string(),
171            version: item.version.clone().unwrap_or_else(|| "-".to_string()),
172            status,
173        });
174    }
175
176    entries.sort_by(|a, b| (&a.source, &a.item).cmp(&(&b.source, &b.item)));
177    output::print_list(&entries, json);
178    Ok(0)
179}
180
181/// Quick check for conflict markers.
182fn has_conflict_markers(path: &Path) -> bool {
183    std::fs::read_to_string(path)
184        .map(|content| content.contains("<<<<<<<") && content.contains(">>>>>>>"))
185        .unwrap_or(false)
186}