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