Skip to main content

mcp_methods/server/
cli.rs

1//! Reusable helpers for skills-related CLI subcommands.
2//!
3//! Downstream binaries (`mcp-server`, `kglite-mcp-server`, …) plug
4//! these into their own `clap` setup to offer `skills-lint`,
5//! `skills-list`, and `skills-show` without re-implementing the
6//! load / resolve / format flow. Each helper returns a string ready
7//! to print, plus an exit-code indicator where relevant.
8//!
9//! ```ignore
10//! use mcp_methods::server::cli;
11//! match cli::skills_lint(&dir) {
12//!     Ok(report) => println!("{report}"),
13//!     Err(e) => { eprintln!("{e}"); std::process::exit(2); }
14//! }
15//! ```
16
17use std::fmt::Write as _;
18use std::path::Path;
19
20use std::path::PathBuf;
21
22use crate::server::manifest::load as load_manifest;
23use crate::server::skills::{
24    load_skill_from_file, write_skill_template, Registry, ResolvedRegistry, Skill, SkillError,
25    SkillProvenance,
26};
27
28/// Result of [`skills_lint`] — a one-line report per file and a
29/// boolean indicating whether any error was found.
30#[derive(Debug)]
31pub struct LintReport {
32    /// Per-file lines, ordered by file path.
33    pub lines: Vec<String>,
34    /// True when at least one file in `dir` failed to parse or
35    /// violated a hard constraint (size limit, missing required
36    /// field).
37    pub has_errors: bool,
38}
39
40impl LintReport {
41    /// Render the report as a single string suitable for stdout.
42    pub fn format(&self) -> String {
43        let mut out = String::new();
44        for line in &self.lines {
45            let _ = writeln!(out, "{line}");
46        }
47        let _ = writeln!(
48            out,
49            "\n{} file(s) checked; {}.",
50            self.lines.len(),
51            if self.has_errors {
52                "errors found"
53            } else {
54                "clean"
55            }
56        );
57        out
58    }
59}
60
61/// Walk `dir` for `*.md` files, parse each as a skill, and report
62/// per-file status. Soft warnings (size 4–16 KB) annotate the line
63/// but don't flip `has_errors`. Hard failures (missing frontmatter,
64/// missing required field, >16 KB) emit an `ERROR` line and flip
65/// `has_errors` so operators can wire a non-zero exit on lint failure.
66///
67/// Errors at the directory level (path missing, not a directory)
68/// surface as `Err`.
69pub fn skills_lint(dir: &Path) -> Result<LintReport, SkillError> {
70    use std::path::PathBuf;
71    if !dir.exists() {
72        return Err(SkillError::PathNotFound {
73            raw: dir.display().to_string(),
74            resolved: dir.to_path_buf(),
75        });
76    }
77    if !dir.is_dir() {
78        return Err(SkillError::PathNotFound {
79            raw: dir.display().to_string(),
80            resolved: dir.to_path_buf(),
81        });
82    }
83
84    let entries = std::fs::read_dir(dir).map_err(|e| SkillError::Io {
85        path: dir.to_path_buf(),
86        source: e,
87    })?;
88
89    let mut lines: Vec<String> = Vec::new();
90    let mut has_errors = false;
91    let provenance = SkillProvenance::DomainPack(PathBuf::from("lint"));
92    let mut any_md = false;
93    for entry in entries.flatten() {
94        let path = entry.path();
95        if path.extension().map(|e| e == "md").unwrap_or(false) {
96            any_md = true;
97            match load_skill_from_file(&path, provenance.clone()) {
98                Ok(skill) => {
99                    let size = skill.body.len();
100                    let warn = if size > 4096 {
101                        format!(" [WARN: {size} bytes exceeds 4 KB soft limit]")
102                    } else {
103                        String::new()
104                    };
105                    lines.push(format!(
106                        "  OK     {:<28}  {} bytes{warn}",
107                        skill.name(),
108                        size
109                    ));
110                }
111                Err(e) => {
112                    has_errors = true;
113                    let basename = path
114                        .file_name()
115                        .map(|n| n.to_string_lossy().into_owned())
116                        .unwrap_or_else(|| path.display().to_string());
117                    lines.push(format!("  ERROR  {basename:<28}  {e}"));
118                }
119            }
120        }
121    }
122    if !any_md {
123        lines.push("  (no SKILL.md files found)".to_string());
124    }
125    lines.sort();
126    Ok(LintReport { lines, has_errors })
127}
128
129/// Build a registry from a manifest YAML and return a one-line-per-
130/// skill summary suitable for stdout. Output columns: name, provenance,
131/// description (truncated).
132///
133/// `include_bundled` controls whether the framework defaults are
134/// merged before the operator-declared layers. Defaults to `true`
135/// for CLI use.
136pub fn skills_list(manifest_path: &Path, include_bundled: bool) -> Result<String, String> {
137    let registry = build_registry(manifest_path, include_bundled)?;
138    Ok(format_skill_list(&registry))
139}
140
141/// Scaffold a starter SKILL.md at `dest` and return the resolved
142/// path written. Thin wrapper around
143/// [`write_skill_template`](crate::server::skills::write_skill_template)
144/// that bubbles errors as `String` for symmetric handling alongside
145/// [`skills_list`] / [`skills_show`].
146///
147/// `description` is required — Anthropic's published guidance is that
148/// skills with weak descriptions undertrigger badly, so the template
149/// makes the operator commit to one rather than leaving a `<TODO>`
150/// placeholder in the discovery-critical field.
151pub fn skills_new(dest: &Path, name: &str, description: &str) -> Result<PathBuf, String> {
152    if name.trim().is_empty() {
153        return Err("skill name must not be empty".to_string());
154    }
155    if description.trim().is_empty() {
156        return Err(
157            "description must not be empty — it's the agent's only signal for triggering"
158                .to_string(),
159        );
160    }
161    write_skill_template(dest, name, description).map_err(|e| format!("template write failed: {e}"))
162}
163
164/// Look up a single skill by name and return its full body, prefixed
165/// with a header line showing the name and provenance. Returns `Err`
166/// if the skill is not present in the resolved set.
167pub fn skills_show(
168    manifest_path: &Path,
169    name: &str,
170    include_bundled: bool,
171) -> Result<String, String> {
172    let registry = build_registry(manifest_path, include_bundled)?;
173    let skill = registry
174        .get(name)
175        .ok_or_else(|| format!("no skill named '{name}' resolved from {manifest_path:?}"))?;
176    Ok(format_skill_body(skill))
177}
178
179fn build_registry(manifest_path: &Path, include_bundled: bool) -> Result<ResolvedRegistry, String> {
180    let manifest =
181        load_manifest(manifest_path).map_err(|e| format!("manifest load failed: {e}"))?;
182    let mut builder = Registry::new();
183    if include_bundled {
184        builder = builder.merge_framework_defaults();
185    }
186    builder = builder.auto_detect_project_layer(manifest_path);
187    builder = builder
188        .layer_dirs(&manifest.skills, manifest_path)
189        .map_err(|e| format!("skill layer load failed: {e}"))?;
190    builder
191        .finalise()
192        .map_err(|e| format!("registry finalise failed: {e}"))
193}
194
195fn format_skill_list(registry: &ResolvedRegistry) -> String {
196    if registry.is_empty() {
197        return "(no skills resolved)\n".to_string();
198    }
199    let mut out = String::new();
200    let _ = writeln!(out, "{:<28}  {:<14}  description", "name", "provenance");
201    let _ = writeln!(
202        out,
203        "{:<28}  {:<14}  {}",
204        "-".repeat(28),
205        "-".repeat(14),
206        "-".repeat(40)
207    );
208    for name in registry.skill_names() {
209        let Some(skill) = registry.get(&name) else {
210            continue;
211        };
212        let prov = provenance_label(&skill.provenance);
213        let desc: String = skill.description().chars().take(60).collect();
214        let _ = writeln!(out, "{:<28}  {:<14}  {desc}", skill.name(), prov);
215    }
216    out
217}
218
219fn format_skill_body(skill: &Skill) -> String {
220    let prov = provenance_label(&skill.provenance);
221    let mut out = String::new();
222    let _ = writeln!(out, "# {} ({prov})", skill.name());
223    let _ = writeln!(out, "{}", skill.description());
224    let _ = writeln!(out);
225    out.push_str(&skill.body);
226    out
227}
228
229fn provenance_label(p: &SkillProvenance) -> String {
230    match p {
231        SkillProvenance::Project => "project".to_string(),
232        SkillProvenance::DomainPack(_) => "domain_pack".to_string(),
233        SkillProvenance::Bundled => "bundled".to_string(),
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use std::fs;
241
242    fn write_skill(dir: &Path, name: &str, body: &str) {
243        fs::write(
244            dir.join(format!("{name}.md")),
245            format!("---\nname: {name}\ndescription: A {name} skill.\n---\n\n{body}\n"),
246        )
247        .unwrap();
248    }
249
250    #[test]
251    fn skills_lint_reports_each_file() {
252        let dir = tempfile::tempdir().unwrap();
253        write_skill(dir.path(), "alpha", "Body alpha.");
254        write_skill(dir.path(), "beta", "Body beta.");
255        let report = skills_lint(dir.path()).unwrap();
256        assert!(!report.has_errors);
257        assert!(report.lines.iter().any(|l| l.contains("alpha")));
258        assert!(report.lines.iter().any(|l| l.contains("beta")));
259    }
260
261    #[test]
262    fn skills_lint_empty_dir_emits_friendly_line() {
263        let dir = tempfile::tempdir().unwrap();
264        let report = skills_lint(dir.path()).unwrap();
265        assert!(!report.has_errors);
266        assert!(report.lines.iter().any(|l| l.contains("no SKILL.md files")));
267    }
268
269    #[test]
270    fn skills_lint_invalid_dir_errors() {
271        let bogus = Path::new("/nonexistent/path/for/lint");
272        let result = skills_lint(bogus);
273        assert!(result.is_err());
274    }
275
276    #[test]
277    fn skills_lint_size_warning_at_4kb() {
278        let dir = tempfile::tempdir().unwrap();
279        let big = "x".repeat(5_000);
280        write_skill(dir.path(), "fat", &big);
281        let report = skills_lint(dir.path()).unwrap();
282        // Soft warn but not a hard error.
283        assert!(!report.has_errors);
284        assert!(report
285            .lines
286            .iter()
287            .any(|l| l.contains("WARN") && l.contains("4 KB")));
288    }
289
290    #[test]
291    fn skills_list_renders_table_for_resolved_set() {
292        let dir = tempfile::tempdir().unwrap();
293        let manifest = dir.path().join("test_mcp.yaml");
294        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
295        let skills_dir = dir.path().join("test_mcp.skills");
296        fs::create_dir(&skills_dir).unwrap();
297        write_skill(&skills_dir, "custom", "Custom body.");
298        let output = skills_list(&manifest, true).unwrap();
299        assert!(output.contains("custom"));
300        assert!(output.contains("grep"), "expected bundled grep in output");
301        assert!(output.contains("project"));
302        assert!(output.contains("bundled"));
303    }
304
305    #[test]
306    fn skills_list_without_bundled() {
307        let dir = tempfile::tempdir().unwrap();
308        let manifest = dir.path().join("test_mcp.yaml");
309        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
310        let skills_dir = dir.path().join("test_mcp.skills");
311        fs::create_dir(&skills_dir).unwrap();
312        write_skill(&skills_dir, "custom", "Custom body.");
313        let output = skills_list(&manifest, false).unwrap();
314        assert!(output.contains("custom"));
315        assert!(
316            !output.contains("\ngrep "),
317            "bundled grep should be excluded"
318        );
319    }
320
321    #[test]
322    fn skills_show_returns_body_with_header() {
323        let dir = tempfile::tempdir().unwrap();
324        let manifest = dir.path().join("test_mcp.yaml");
325        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
326        let skills_dir = dir.path().join("test_mcp.skills");
327        fs::create_dir(&skills_dir).unwrap();
328        write_skill(&skills_dir, "alpha", "ALPHA-BODY-MARKER");
329        let output = skills_show(&manifest, "alpha", false).unwrap();
330        assert!(output.starts_with("# alpha"));
331        assert!(output.contains("ALPHA-BODY-MARKER"));
332        assert!(output.contains("project"));
333    }
334
335    #[test]
336    fn skills_show_missing_skill_errors() {
337        let dir = tempfile::tempdir().unwrap();
338        let manifest = dir.path().join("test_mcp.yaml");
339        fs::write(&manifest, "name: t\n").unwrap();
340        let err = skills_show(&manifest, "nonexistent", false).unwrap_err();
341        assert!(err.contains("no skill named"));
342    }
343
344    #[test]
345    fn skills_list_no_skills_declared_is_empty() {
346        let dir = tempfile::tempdir().unwrap();
347        let manifest = dir.path().join("test_mcp.yaml");
348        fs::write(&manifest, "name: t\n").unwrap();
349        let output = skills_list(&manifest, false).unwrap();
350        assert!(output.contains("no skills resolved"));
351    }
352
353    #[test]
354    fn skills_new_scaffolds_into_a_directory() {
355        let dir = tempfile::tempdir().unwrap();
356        let dest = skills_new(dir.path(), "custom", "A short description.").unwrap();
357        assert_eq!(dest, dir.path().join("custom.md"));
358        let content = fs::read_to_string(&dest).unwrap();
359        assert!(content.contains("name: custom"));
360        assert!(content.contains("# `custom` methodology"));
361    }
362
363    #[test]
364    fn skills_new_rejects_empty_name() {
365        let dir = tempfile::tempdir().unwrap();
366        let err = skills_new(dir.path(), "", "A description.").unwrap_err();
367        assert!(err.contains("name must not be empty"));
368    }
369
370    #[test]
371    fn skills_new_rejects_empty_description() {
372        let dir = tempfile::tempdir().unwrap();
373        let err = skills_new(dir.path(), "custom", "   ").unwrap_err();
374        assert!(err.contains("description must not be empty"));
375    }
376
377    #[test]
378    fn skills_new_bubbles_write_errors() {
379        let dir = tempfile::tempdir().unwrap();
380        // Pre-create the file to trigger the AlreadyExists branch.
381        fs::write(dir.path().join("custom.md"), "x").unwrap();
382        let err = skills_new(dir.path(), "custom", "description").unwrap_err();
383        assert!(err.contains("template write failed"));
384    }
385}