1use 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#[derive(Debug, clap::Args)]
14pub struct ListArgs {
15 #[arg(long)]
17 pub source: Option<String>,
18
19 #[arg(long)]
21 pub kind: Option<String>,
22
23 #[arg(long)]
25 pub status: bool,
26}
27
28pub 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 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 if let Some(ref filter_source) = args.source
45 && item.source != *filter_source
46 {
47 continue;
48 }
49
50 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 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 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
120fn 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
143fn 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
152fn 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}