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)]
27pub(crate) struct CheckReport {
28 agents: usize,
29 skills: usize,
30 pub(crate) 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
84pub(crate) fn 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_str()))
310 .collect()
311}
312
313fn skill_name_from_dest_path(dest_path: &str) -> Option<String> {
314 let mut components = dest_path.split('/');
315 let prefix = components.next()?;
316 if prefix != "skills" {
317 return None;
318 }
319
320 components.next().map(str::to_string)
321}
322
323#[cfg(test)]
324mod tests {
325 use std::path::Path;
326
327 use crate::lock::{ItemKind, LockFile, LockedItem};
328 use crate::types::{ContentHash, DestPath, SourceName};
329 use tempfile::TempDir;
330
331 fn write_agent(path: &Path, filename: &str, skills: &[&str]) {
332 let agents = path.join("agents");
333 std::fs::create_dir_all(&agents).unwrap();
334 let skills = skills.join(", ");
335 std::fs::write(
336 agents.join(format!("{filename}.md")),
337 format!(
338 "---\nname: {filename}\ndescription: test agent\nskills: [{skills}]\n---\n# Agent"
339 ),
340 )
341 .unwrap();
342 }
343
344 fn write_lock_skill(path: &Path, skill_name: &str) {
345 let mut lock = LockFile::empty();
346 let dest_path = DestPath::from(format!("skills/{skill_name}"));
347 lock.items.insert(
348 dest_path.clone(),
349 LockedItem {
350 source: SourceName::from("dep-source"),
351 kind: ItemKind::Skill,
352 version: None,
353 source_checksum: ContentHash::from("source-hash"),
354 installed_checksum: ContentHash::from("installed-hash"),
355 dest_path,
356 },
357 );
358 crate::lock::write(path, &lock).unwrap();
359 }
360
361 #[cfg(unix)]
362 #[test]
363 fn check_skips_symlinked_agent() {
364 let dir = TempDir::new().unwrap();
365 let agents = dir.path().join("agents");
366 std::fs::create_dir_all(&agents).unwrap();
367
368 std::fs::write(
370 agents.join("real.md"),
371 "---\nname: real\ndescription: real agent\n---\n# Real",
372 )
373 .unwrap();
374
375 std::os::unix::fs::symlink(agents.join("real.md"), agents.join("linked.md")).unwrap();
377
378 let args = super::CheckArgs {
379 path: Some(dir.path().to_path_buf()),
380 };
381 let code = super::run(&args, true).unwrap();
383 assert_eq!(code, 0);
385 }
386
387 #[cfg(unix)]
388 #[test]
389 fn check_skips_symlinked_skill() {
390 let dir = TempDir::new().unwrap();
391 let skills = dir.path().join("skills");
392 let real_skill = skills.join("real-skill");
393 std::fs::create_dir_all(&real_skill).unwrap();
394 std::fs::write(
395 real_skill.join("SKILL.md"),
396 "---\nname: real-skill\ndescription: a skill\n---\n# Skill",
397 )
398 .unwrap();
399
400 std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
402
403 let agents = dir.path().join("agents");
405 std::fs::create_dir_all(&agents).unwrap();
406 std::fs::write(
407 agents.join("coder.md"),
408 "---\nname: coder\ndescription: agent\n---\n# Coder",
409 )
410 .unwrap();
411
412 let args = super::CheckArgs {
413 path: Some(dir.path().to_path_buf()),
414 };
415 let code = super::run(&args, true).unwrap();
416 assert_eq!(code, 0);
417 }
418
419 #[test]
420 fn check_accepts_flat_skill_repo() {
421 let dir = TempDir::new().unwrap();
422 std::fs::write(
423 dir.path().join("SKILL.md"),
424 "---\nname: flat-skill\ndescription: flat layout\n---\n# Flat skill",
425 )
426 .unwrap();
427
428 let args = super::CheckArgs {
429 path: Some(dir.path().to_path_buf()),
430 };
431 let code = super::run(&args, true).unwrap();
432 assert_eq!(code, 0);
433 }
434
435 #[test]
436 fn check_suppresses_warning_for_dependency_provided_skill() {
437 let dir = TempDir::new().unwrap();
438 write_agent(dir.path(), "coder", &["ext-skill"]);
439 write_lock_skill(dir.path(), "ext-skill");
440
441 let report = super::check_dir(dir.path()).unwrap();
442 let has_external_warning = report
443 .warnings
444 .iter()
445 .any(|w| w.contains("external dependency: `ext-skill`"));
446
447 assert!(
448 !has_external_warning,
449 "unexpected external dependency warning: {:?}",
450 report.warnings
451 );
452 }
453
454 #[test]
455 fn check_warns_for_truly_missing_external_skill() {
456 let dir = TempDir::new().unwrap();
457 write_agent(dir.path(), "coder", &["missing-skill"]);
458 write_lock_skill(dir.path(), "some-other-skill");
459
460 let report = super::check_dir(dir.path()).unwrap();
461 let has_missing_warning = report
462 .warnings
463 .iter()
464 .any(|w| w.contains("external dependency: `missing-skill`"));
465
466 assert!(
467 has_missing_warning,
468 "expected missing external dependency warning, got: {:?}",
469 report.warnings
470 );
471 }
472}