1use std::collections::{HashMap, HashSet};
9use std::path::PathBuf;
10
11use serde::Serialize;
12
13use crate::discover;
14use crate::error::MarsError;
15use crate::frontmatter;
16
17use super::output;
18
19#[derive(Debug, clap::Args)]
21pub struct CheckArgs {
22 pub path: Option<PathBuf>,
24}
25
26#[derive(Debug, Serialize)]
27struct CheckReport {
28 agents: usize,
29 skills: usize,
30 errors: Vec<String>,
31 warnings: Vec<String>,
32}
33
34pub fn run(args: &CheckArgs, json: bool) -> Result<i32, MarsError> {
36 let base = match &args.path {
37 Some(p) => {
38 if p.is_absolute() {
39 p.clone()
40 } else {
41 std::env::current_dir()?.join(p)
42 }
43 }
44 None => std::env::current_dir()?,
45 };
46
47 if !base.is_dir() {
48 return Err(MarsError::Config(crate::error::ConfigError::Invalid {
49 message: format!("{} is not a directory", base.display()),
50 }));
51 }
52
53 let skills_dir = base.join("skills");
54
55 let mut errors: Vec<String> = Vec::new();
56 let mut warnings: Vec<String> = Vec::new();
57
58 let discovered = discover::discover_source(&base, None)?;
59
60 let mut agent_names: HashMap<String, PathBuf> = HashMap::new();
62 let mut agent_skill_refs: Vec<(String, Vec<String>)> = Vec::new();
63 let mut skill_names: HashMap<String, PathBuf> = HashMap::new();
64
65 for item in discovered {
66 let path = base.join(&item.source_path);
67 match item.id.kind {
68 crate::lock::ItemKind::Agent => {
69 if super::is_symlink(&path) {
70 let name = path
71 .file_stem()
72 .and_then(|n| n.to_str())
73 .unwrap_or_default();
74 warnings.push(format!(
75 "skipping symlinked agent `{name}` — source packages should not contain symlinks"
76 ));
77 continue;
78 }
79
80 let filename = path
81 .file_stem()
82 .and_then(|n| n.to_str())
83 .unwrap_or_default()
84 .to_string();
85
86 match std::fs::read_to_string(&path) {
87 Ok(content) => match frontmatter::parse(&content) {
88 Ok(fm) => {
89 let name = fm
90 .name()
91 .map(str::to_string)
92 .unwrap_or_else(|| filename.clone());
93
94 if fm.name().is_none() {
95 warnings.push(format!(
96 "agent `{filename}` has no `name` in frontmatter"
97 ));
98 }
99
100 if fm.get("description").and_then(|v| v.as_str()).is_none() {
101 warnings.push(format!("agent `{name}` has no `description`"));
102 }
103
104 if fm.name().is_some() && name != filename {
105 warnings.push(format!(
106 "agent filename `{filename}.md` doesn't match name `{name}` in frontmatter"
107 ));
108 }
109
110 if let Some(existing) = agent_names.get(&name) {
111 errors.push(format!(
112 "duplicate agent name `{name}` in {} and {}",
113 existing.display(),
114 path.display()
115 ));
116 } else {
117 agent_names.insert(name.clone(), path.clone());
118 }
119
120 let skills = fm.skills();
121 if !skills.is_empty() {
122 agent_skill_refs.push((name, skills));
123 }
124 }
125 Err(e) => {
126 errors.push(format!("agent `{filename}` has invalid frontmatter: {e}"));
127 }
128 },
129 Err(e) => {
130 errors.push(format!("cannot read {}: {e}", path.display()));
131 }
132 }
133 }
134 crate::lock::ItemKind::Skill => {
135 let (dirname, skill_md, duplicate_path) = if item.source_path
136 == std::path::Path::new(".")
137 {
138 let dirname = item.id.name.to_string();
139 (dirname, base.join("SKILL.md"), base.join("SKILL.md"))
140 } else {
141 if super::is_symlink(&path) {
142 let name = path
143 .file_name()
144 .and_then(|n| n.to_str())
145 .unwrap_or_default();
146 warnings.push(format!(
147 "skipping symlinked skill `{name}` — source packages should not contain symlinks"
148 ));
149 continue;
150 }
151 let dirname = path
152 .file_name()
153 .and_then(|n| n.to_str())
154 .unwrap_or_default()
155 .to_string();
156 (dirname, path.join("SKILL.md"), path.clone())
157 };
158
159 match std::fs::read_to_string(&skill_md) {
160 Ok(content) => match frontmatter::parse(&content) {
161 Ok(fm) => {
162 let name = fm
163 .name()
164 .map(str::to_string)
165 .unwrap_or_else(|| dirname.clone());
166
167 if fm.name().is_none() {
168 warnings.push(format!(
169 "skill `{dirname}` has no `name` in frontmatter"
170 ));
171 }
172
173 if fm.get("description").and_then(|v| v.as_str()).is_none() {
174 warnings.push(format!("skill `{name}` has no `description`"));
175 }
176
177 if fm.name().is_some() && name != dirname {
178 warnings.push(format!(
179 "skill dirname `{dirname}` doesn't match name `{name}` in frontmatter"
180 ));
181 }
182
183 if let Some(existing) = skill_names.get(&name) {
184 errors.push(format!(
185 "duplicate skill name `{name}` in {} and {}",
186 existing.display(),
187 duplicate_path.display()
188 ));
189 } else {
190 skill_names.insert(name, duplicate_path);
191 }
192 }
193 Err(e) => {
194 errors.push(format!("skill `{dirname}` has invalid frontmatter: {e}"));
195 }
196 },
197 Err(e) => {
198 errors.push(format!("cannot read {}: {e}", skill_md.display()));
199 }
200 }
201 }
202 }
203 }
204
205 if skills_dir.is_dir() {
208 let mut entries: Vec<_> = std::fs::read_dir(&skills_dir)?
209 .filter_map(|e| e.ok())
210 .filter(|e| e.path().is_dir())
211 .collect();
212 entries.sort_by_key(|e| e.file_name());
213 for entry in entries {
214 let path = entry.path();
215 let dirname = path
216 .file_name()
217 .and_then(|n| n.to_str())
218 .unwrap_or_default();
219 if !path.join("SKILL.md").exists() {
220 errors.push(format!("skill `{dirname}` is missing SKILL.md"));
221 }
222 }
223 }
224
225 let agent_count = agent_names.len();
226 let skill_count = skill_names.len();
227
228 if agent_count == 0 && skill_count == 0 {
230 errors.push("no agents or skills found — is this a mars source package?".to_string());
231 }
232
233 let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
235 let mut external_deps: HashMap<String, Vec<String>> = HashMap::new();
236
237 for (agent_name, skills) in &agent_skill_refs {
238 for skill in skills {
239 if !available.contains(skill.as_str()) {
240 external_deps
241 .entry(skill.clone())
242 .or_default()
243 .push(agent_name.clone());
244 }
245 }
246 }
247
248 if !external_deps.is_empty() {
249 let mut sorted: Vec<_> = external_deps.iter().collect();
250 sorted.sort_by_key(|(name, _)| name.as_str());
251 for (skill, agents) in &sorted {
252 warnings.push(format!(
253 "external dependency: `{skill}` (referenced by: {})",
254 agents.join(", ")
255 ));
256 }
257 }
258
259 let report = CheckReport {
261 agents: agent_count,
262 skills: skill_count,
263 errors: errors.clone(),
264 warnings: warnings.clone(),
265 };
266
267 if json {
268 output::print_json(&report);
269 } else {
270 println!(" {} agents, {} skills", agent_count, skill_count);
271 println!();
272
273 if errors.is_empty() && warnings.is_empty() {
274 output::print_success("all checks passed");
275 } else {
276 for e in &errors {
277 output::print_error(e);
278 }
279 for w in &warnings {
280 output::print_warn(w);
281 }
282 if !errors.is_empty() {
283 println!();
284 println!(" {} error(s) found", errors.len());
285 }
286 }
287 }
288
289 if errors.is_empty() { Ok(0) } else { Ok(1) }
290}
291
292#[cfg(test)]
293mod tests {
294 use tempfile::TempDir;
295
296 #[test]
297 fn check_skips_symlinked_agent() {
298 let dir = TempDir::new().unwrap();
299 let agents = dir.path().join("agents");
300 std::fs::create_dir_all(&agents).unwrap();
301
302 std::fs::write(
304 agents.join("real.md"),
305 "---\nname: real\ndescription: real agent\n---\n# Real",
306 )
307 .unwrap();
308
309 std::os::unix::fs::symlink(agents.join("real.md"), agents.join("linked.md")).unwrap();
311
312 let args = super::CheckArgs {
313 path: Some(dir.path().to_path_buf()),
314 };
315 let code = super::run(&args, true).unwrap();
317 assert_eq!(code, 0);
319 }
320
321 #[test]
322 fn check_skips_symlinked_skill() {
323 let dir = TempDir::new().unwrap();
324 let skills = dir.path().join("skills");
325 let real_skill = skills.join("real-skill");
326 std::fs::create_dir_all(&real_skill).unwrap();
327 std::fs::write(
328 real_skill.join("SKILL.md"),
329 "---\nname: real-skill\ndescription: a skill\n---\n# Skill",
330 )
331 .unwrap();
332
333 std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
335
336 let agents = dir.path().join("agents");
338 std::fs::create_dir_all(&agents).unwrap();
339 std::fs::write(
340 agents.join("coder.md"),
341 "---\nname: coder\ndescription: agent\n---\n# Coder",
342 )
343 .unwrap();
344
345 let args = super::CheckArgs {
346 path: Some(dir.path().to_path_buf()),
347 };
348 let code = super::run(&args, true).unwrap();
349 assert_eq!(code, 0);
350 }
351
352 #[test]
353 fn check_accepts_flat_skill_repo() {
354 let dir = TempDir::new().unwrap();
355 std::fs::write(
356 dir.path().join("SKILL.md"),
357 "---\nname: flat-skill\ndescription: flat layout\n---\n# Flat skill",
358 )
359 .unwrap();
360
361 let args = super::CheckArgs {
362 path: Some(dir.path().to_path_buf()),
363 };
364 let code = super::run(&args, true).unwrap();
365 assert_eq!(code, 0);
366 }
367}