Skip to main content

mars_agents/cli/
check.rs

1//! `mars check [PATH]` — validate a source package before publishing.
2//!
3//! Scans a directory as a mars source package
4//! (`agents/*.md`, `skills/*/SKILL.md`, or a flat root `SKILL.md`)
5//! and validates structure, frontmatter, and internal skill dependencies.
6//! No config or lock file needed — works on raw source directories.
7
8use 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/// Arguments for `mars check`.
20#[derive(Debug, clap::Args)]
21pub struct CheckArgs {
22    /// Directory to validate as a source package (default: current directory).
23    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
34/// Run `mars check`.
35pub 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    // ── Validate discovered agents/skills ────────────────────────────
96    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            // New kinds not yet subject to source-package checks.
238            crate::lock::ItemKind::Hook
239            | crate::lock::ItemKind::McpServer
240            | crate::lock::ItemKind::BootstrapDoc => {}
241        }
242    }
243
244    // Structural validation for nested skill layout:
245    // if skills/* directories exist, each must contain SKILL.md.
246    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    // ── Empty package check ──────────────────────────────────────────
268    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    // ── Skill dependency check ───────────────────────────────────────
273    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    // ── Output ───────────────────────────────────────────────────────
300    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        // Real agent
380        std::fs::write(
381            agents.join("real.md"),
382            "---\nname: real\ndescription: real agent\n---\n# Real",
383        )
384        .unwrap();
385
386        // Symlinked agent pointing to the real one
387        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        // Should succeed (the symlink is warned, not errored)
393        let code = super::run(&args, true).unwrap();
394        // No structural errors — the real agent is valid
395        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        // Symlinked skill dir
412        std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
413
414        // Also add an agent so the package isn't empty
415        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}