1use std::collections::{HashMap, HashSet};
9use std::path::{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 report = check_dir(&base)?;
54
55 if json {
56 output::print_json(&report);
57 } else {
58 println!(" {} agents, {} skills", report.agents, report.skills);
59 println!();
60
61 if report.errors.is_empty() && report.warnings.is_empty() {
62 output::print_success("all checks passed");
63 } else {
64 for e in &report.errors {
65 output::print_error(e);
66 }
67 for w in &report.warnings {
68 output::print_warn(w);
69 }
70 if !report.errors.is_empty() {
71 println!();
72 println!(" {} error(s) found", report.errors.len());
73 }
74 }
75 }
76
77 if report.errors.is_empty() {
78 Ok(0)
79 } else {
80 Ok(1)
81 }
82}
83
84fn check_dir(base: &Path) -> Result<CheckReport, MarsError> {
85 let skills_dir = base.join("skills");
86
87 let mut errors: Vec<String> = Vec::new();
88 let mut warnings: Vec<String> = Vec::new();
89
90 let discovered = discover::discover_resolved_source(base, None)?;
91
92 let mut agent_names: HashMap<String, PathBuf> = HashMap::new();
94 let mut agent_skill_refs: Vec<(String, Vec<String>)> = Vec::new();
95 let mut skill_names: HashMap<String, PathBuf> = HashMap::new();
96
97 for item in discovered {
98 let path = base.join(&item.source_path);
99 match item.id.kind {
100 crate::lock::ItemKind::Agent => {
101 if super::is_symlink(&path) {
102 let name = path
103 .file_stem()
104 .and_then(|n| n.to_str())
105 .unwrap_or_default();
106 warnings.push(format!(
107 "skipping symlinked agent `{name}` — source packages should not contain symlinks"
108 ));
109 continue;
110 }
111
112 let filename = path
113 .file_stem()
114 .and_then(|n| n.to_str())
115 .unwrap_or_default()
116 .to_string();
117
118 match std::fs::read_to_string(&path) {
119 Ok(content) => match frontmatter::parse(&content) {
120 Ok(fm) => {
121 let name = fm
122 .name()
123 .map(str::to_string)
124 .unwrap_or_else(|| filename.clone());
125
126 if fm.name().is_none() {
127 warnings.push(format!(
128 "agent `{filename}` has no `name` in frontmatter"
129 ));
130 }
131
132 if fm.get("description").and_then(|v| v.as_str()).is_none() {
133 warnings.push(format!("agent `{name}` has no `description`"));
134 }
135
136 if fm.name().is_some() && name != filename {
137 warnings.push(format!(
138 "agent filename `{filename}.md` doesn't match name `{name}` in frontmatter"
139 ));
140 }
141
142 if let Some(existing) = agent_names.get(&name) {
143 errors.push(format!(
144 "duplicate agent name `{name}` in {} and {}",
145 existing.display(),
146 path.display()
147 ));
148 } else {
149 agent_names.insert(name.clone(), path.clone());
150 }
151
152 let skills = fm.skills();
153 if !skills.is_empty() {
154 agent_skill_refs.push((name, skills));
155 }
156 }
157 Err(e) => {
158 errors.push(format!("agent `{filename}` has invalid frontmatter: {e}"));
159 }
160 },
161 Err(e) => {
162 errors.push(format!("cannot read {}: {e}", path.display()));
163 }
164 }
165 }
166 crate::lock::ItemKind::Skill => {
167 let (dirname, skill_md, duplicate_path) = if item.source_path
168 == std::path::Path::new(".")
169 {
170 let dirname = item.id.name.to_string();
171 (dirname, base.join("SKILL.md"), base.join("SKILL.md"))
172 } else {
173 if super::is_symlink(&path) {
174 let name = path
175 .file_name()
176 .and_then(|n| n.to_str())
177 .unwrap_or_default();
178 warnings.push(format!(
179 "skipping symlinked skill `{name}` — source packages should not contain symlinks"
180 ));
181 continue;
182 }
183 let dirname = path
184 .file_name()
185 .and_then(|n| n.to_str())
186 .unwrap_or_default()
187 .to_string();
188 (dirname, path.join("SKILL.md"), path.clone())
189 };
190
191 match std::fs::read_to_string(&skill_md) {
192 Ok(content) => match frontmatter::parse(&content) {
193 Ok(fm) => {
194 let name = fm
195 .name()
196 .map(str::to_string)
197 .unwrap_or_else(|| dirname.clone());
198
199 if fm.name().is_none() {
200 warnings.push(format!(
201 "skill `{dirname}` has no `name` in frontmatter"
202 ));
203 }
204
205 if fm.get("description").and_then(|v| v.as_str()).is_none() {
206 warnings.push(format!("skill `{name}` has no `description`"));
207 }
208
209 if fm.name().is_some() && name != dirname {
210 warnings.push(format!(
211 "skill dirname `{dirname}` doesn't match name `{name}` in frontmatter"
212 ));
213 }
214
215 if let Some(existing) = skill_names.get(&name) {
216 errors.push(format!(
217 "duplicate skill name `{name}` in {} and {}",
218 existing.display(),
219 duplicate_path.display()
220 ));
221 } else {
222 skill_names.insert(name, duplicate_path);
223 }
224 }
225 Err(e) => {
226 errors.push(format!("skill `{dirname}` has invalid frontmatter: {e}"));
227 }
228 },
229 Err(e) => {
230 errors.push(format!("cannot read {}: {e}", skill_md.display()));
231 }
232 }
233 }
234 }
235 }
236
237 if skills_dir.is_dir() {
240 let mut entries: Vec<_> = std::fs::read_dir(&skills_dir)?
241 .filter_map(|e| e.ok())
242 .filter(|e| e.path().is_dir())
243 .collect();
244 entries.sort_by_key(|e| e.file_name());
245 for entry in entries {
246 let path = entry.path();
247 let dirname = path
248 .file_name()
249 .and_then(|n| n.to_str())
250 .unwrap_or_default();
251 if !path.join("SKILL.md").exists() {
252 errors.push(format!("skill `{dirname}` is missing SKILL.md"));
253 }
254 }
255 }
256
257 let agent_count = agent_names.len();
258 let skill_count = skill_names.len();
259
260 if agent_count == 0 && skill_count == 0 {
262 errors.push("no agents or skills found — is this a mars source package?".to_string());
263 }
264
265 let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
267 let dependency_skills = dependency_skills_from_lock(base);
268 let mut external_deps: HashMap<String, Vec<String>> = HashMap::new();
269
270 for (agent_name, skills) in &agent_skill_refs {
271 for skill in skills {
272 if !available.contains(skill.as_str()) && !dependency_skills.contains(skill.as_str()) {
273 external_deps
274 .entry(skill.clone())
275 .or_default()
276 .push(agent_name.clone());
277 }
278 }
279 }
280
281 if !external_deps.is_empty() {
282 let mut sorted: Vec<_> = external_deps.iter().collect();
283 sorted.sort_by_key(|(name, _)| name.as_str());
284 for (skill, agents) in &sorted {
285 warnings.push(format!(
286 "external dependency: `{skill}` (referenced by: {})",
287 agents.join(", ")
288 ));
289 }
290 }
291
292 Ok(CheckReport {
294 agents: agent_count,
295 skills: skill_count,
296 errors,
297 warnings,
298 })
299}
300
301fn dependency_skills_from_lock(base: &Path) -> HashSet<String> {
302 let Ok(lock) = crate::lock::load(base) else {
303 return HashSet::new();
304 };
305
306 lock.items
307 .values()
308 .filter(|item| item.kind == crate::lock::ItemKind::Skill)
309 .filter_map(|item| skill_name_from_dest_path(item.dest_path.as_path()))
310 .collect()
311}
312
313fn skill_name_from_dest_path(dest_path: &Path) -> Option<String> {
314 let mut components = dest_path.components();
315 let prefix = components.next()?.as_os_str().to_str()?;
316 if prefix != "skills" {
317 return None;
318 }
319
320 components
321 .next()
322 .and_then(|c| c.as_os_str().to_str())
323 .map(str::to_string)
324}
325
326#[cfg(test)]
327mod tests {
328 use std::path::Path;
329
330 use crate::lock::{ItemKind, LockFile, LockedItem};
331 use crate::types::{ContentHash, DestPath, SourceName};
332 use tempfile::TempDir;
333
334 fn write_agent(path: &Path, filename: &str, skills: &[&str]) {
335 let agents = path.join("agents");
336 std::fs::create_dir_all(&agents).unwrap();
337 let skills = skills.join(", ");
338 std::fs::write(
339 agents.join(format!("{filename}.md")),
340 format!(
341 "---\nname: {filename}\ndescription: test agent\nskills: [{skills}]\n---\n# Agent"
342 ),
343 )
344 .unwrap();
345 }
346
347 fn write_lock_skill(path: &Path, skill_name: &str) {
348 let mut lock = LockFile::empty();
349 let dest_path = DestPath::from(format!("skills/{skill_name}"));
350 lock.items.insert(
351 dest_path.clone(),
352 LockedItem {
353 source: SourceName::from("dep-source"),
354 kind: ItemKind::Skill,
355 version: None,
356 source_checksum: ContentHash::from("source-hash"),
357 installed_checksum: ContentHash::from("installed-hash"),
358 dest_path,
359 },
360 );
361 crate::lock::write(path, &lock).unwrap();
362 }
363
364 #[test]
365 fn check_skips_symlinked_agent() {
366 let dir = TempDir::new().unwrap();
367 let agents = dir.path().join("agents");
368 std::fs::create_dir_all(&agents).unwrap();
369
370 std::fs::write(
372 agents.join("real.md"),
373 "---\nname: real\ndescription: real agent\n---\n# Real",
374 )
375 .unwrap();
376
377 std::os::unix::fs::symlink(agents.join("real.md"), agents.join("linked.md")).unwrap();
379
380 let args = super::CheckArgs {
381 path: Some(dir.path().to_path_buf()),
382 };
383 let code = super::run(&args, true).unwrap();
385 assert_eq!(code, 0);
387 }
388
389 #[test]
390 fn check_skips_symlinked_skill() {
391 let dir = TempDir::new().unwrap();
392 let skills = dir.path().join("skills");
393 let real_skill = skills.join("real-skill");
394 std::fs::create_dir_all(&real_skill).unwrap();
395 std::fs::write(
396 real_skill.join("SKILL.md"),
397 "---\nname: real-skill\ndescription: a skill\n---\n# Skill",
398 )
399 .unwrap();
400
401 std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
403
404 let agents = dir.path().join("agents");
406 std::fs::create_dir_all(&agents).unwrap();
407 std::fs::write(
408 agents.join("coder.md"),
409 "---\nname: coder\ndescription: agent\n---\n# Coder",
410 )
411 .unwrap();
412
413 let args = super::CheckArgs {
414 path: Some(dir.path().to_path_buf()),
415 };
416 let code = super::run(&args, true).unwrap();
417 assert_eq!(code, 0);
418 }
419
420 #[test]
421 fn check_accepts_flat_skill_repo() {
422 let dir = TempDir::new().unwrap();
423 std::fs::write(
424 dir.path().join("SKILL.md"),
425 "---\nname: flat-skill\ndescription: flat layout\n---\n# Flat skill",
426 )
427 .unwrap();
428
429 let args = super::CheckArgs {
430 path: Some(dir.path().to_path_buf()),
431 };
432 let code = super::run(&args, true).unwrap();
433 assert_eq!(code, 0);
434 }
435
436 #[test]
437 fn check_suppresses_warning_for_dependency_provided_skill() {
438 let dir = TempDir::new().unwrap();
439 write_agent(dir.path(), "coder", &["ext-skill"]);
440 write_lock_skill(dir.path(), "ext-skill");
441
442 let report = super::check_dir(dir.path()).unwrap();
443 let has_external_warning = report
444 .warnings
445 .iter()
446 .any(|w| w.contains("external dependency: `ext-skill`"));
447
448 assert!(
449 !has_external_warning,
450 "unexpected external dependency warning: {:?}",
451 report.warnings
452 );
453 }
454
455 #[test]
456 fn check_warns_for_truly_missing_external_skill() {
457 let dir = TempDir::new().unwrap();
458 write_agent(dir.path(), "coder", &["missing-skill"]);
459 write_lock_skill(dir.path(), "some-other-skill");
460
461 let report = super::check_dir(dir.path()).unwrap();
462 let has_missing_warning = report
463 .warnings
464 .iter()
465 .any(|w| w.contains("external dependency: `missing-skill`"));
466
467 assert!(
468 has_missing_warning,
469 "expected missing external dependency warning, got: {:?}",
470 report.warnings
471 );
472 }
473}