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    // The CLI is the operator-facing surface — show predicate state
200    // even when it's "always active". The split-view design (boot log
201    // shows full state, agent prompts/list shows filtered) lets
202    // operators debug "why isn't my skill firing?" by reading this
203    // output. We pass empty tool / extension state since `skills-list`
204    // runs without a live server; predicates relying on runtime state
205    // show as Unsatisfied/Unknown and are explicitly labelled.
206    let empty_tools = std::collections::HashSet::new();
207    let empty_ext = serde_json::Map::new();
208
209    let mut out = String::new();
210    let _ = writeln!(
211        out,
212        "{:<28}  {:<14}  {:<10}  description",
213        "name", "provenance", "status"
214    );
215    let _ = writeln!(
216        out,
217        "{:<28}  {:<14}  {:<10}  {}",
218        "-".repeat(28),
219        "-".repeat(14),
220        "-".repeat(10),
221        "-".repeat(40)
222    );
223    for name in registry.skill_names() {
224        let Some(skill) = registry.get(&name) else {
225            continue;
226        };
227        let prov = provenance_label(&skill.provenance);
228        let activation = registry.activation_for(skill, &empty_tools, &empty_ext);
229        let status = if activation.active {
230            "active"
231        } else {
232            "inactive"
233        };
234        let desc: String = skill.description().chars().take(60).collect();
235        let _ = writeln!(
236            out,
237            "{:<28}  {:<14}  {status:<10}  {desc}",
238            skill.name(),
239            prov
240        );
241        // For inactive skills, surface which predicate suppressed them
242        // so the operator can debug. Indented sub-lines keep the table
243        // readable while still giving full attribution.
244        if !activation.active {
245            for (clause, outcome) in &activation.clauses {
246                let mark = match outcome {
247                    crate::server::skills::PredicateOutcome::Satisfied => "ok",
248                    crate::server::skills::PredicateOutcome::Unsatisfied => "FAIL",
249                    crate::server::skills::PredicateOutcome::Unknown => "UNKNOWN",
250                };
251                let _ = writeln!(out, "    [{mark:>7}]  {clause}");
252            }
253        }
254    }
255    out
256}
257
258fn format_skill_body(skill: &Skill) -> String {
259    let prov = provenance_label(&skill.provenance);
260    let mut out = String::new();
261    let _ = writeln!(out, "# {} ({prov})", skill.name());
262    let _ = writeln!(out, "{}", skill.description());
263    let _ = writeln!(out);
264    out.push_str(&skill.body);
265    out
266}
267
268fn provenance_label(p: &SkillProvenance) -> String {
269    match p {
270        SkillProvenance::Project => "project".to_string(),
271        SkillProvenance::DomainPack(_) => "domain_pack".to_string(),
272        SkillProvenance::Bundled => "bundled".to_string(),
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::fs;
280
281    fn write_skill(dir: &Path, name: &str, body: &str) {
282        fs::write(
283            dir.join(format!("{name}.md")),
284            format!("---\nname: {name}\ndescription: A {name} skill.\n---\n\n{body}\n"),
285        )
286        .unwrap();
287    }
288
289    #[test]
290    fn skills_lint_reports_each_file() {
291        let dir = tempfile::tempdir().unwrap();
292        write_skill(dir.path(), "alpha", "Body alpha.");
293        write_skill(dir.path(), "beta", "Body beta.");
294        let report = skills_lint(dir.path()).unwrap();
295        assert!(!report.has_errors);
296        assert!(report.lines.iter().any(|l| l.contains("alpha")));
297        assert!(report.lines.iter().any(|l| l.contains("beta")));
298    }
299
300    #[test]
301    fn skills_lint_empty_dir_emits_friendly_line() {
302        let dir = tempfile::tempdir().unwrap();
303        let report = skills_lint(dir.path()).unwrap();
304        assert!(!report.has_errors);
305        assert!(report.lines.iter().any(|l| l.contains("no SKILL.md files")));
306    }
307
308    #[test]
309    fn skills_lint_invalid_dir_errors() {
310        let bogus = Path::new("/nonexistent/path/for/lint");
311        let result = skills_lint(bogus);
312        assert!(result.is_err());
313    }
314
315    #[test]
316    fn skills_lint_size_warning_at_4kb() {
317        let dir = tempfile::tempdir().unwrap();
318        let big = "x".repeat(5_000);
319        write_skill(dir.path(), "fat", &big);
320        let report = skills_lint(dir.path()).unwrap();
321        // Soft warn but not a hard error.
322        assert!(!report.has_errors);
323        assert!(report
324            .lines
325            .iter()
326            .any(|l| l.contains("WARN") && l.contains("4 KB")));
327    }
328
329    #[test]
330    fn skills_list_renders_table_for_resolved_set() {
331        let dir = tempfile::tempdir().unwrap();
332        let manifest = dir.path().join("test_mcp.yaml");
333        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
334        let skills_dir = dir.path().join("test_mcp.skills");
335        fs::create_dir(&skills_dir).unwrap();
336        write_skill(&skills_dir, "custom", "Custom body.");
337        let output = skills_list(&manifest, true).unwrap();
338        assert!(output.contains("custom"));
339        assert!(output.contains("grep"), "expected bundled grep in output");
340        assert!(output.contains("project"));
341        assert!(output.contains("bundled"));
342    }
343
344    #[test]
345    fn skills_list_without_bundled() {
346        let dir = tempfile::tempdir().unwrap();
347        let manifest = dir.path().join("test_mcp.yaml");
348        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
349        let skills_dir = dir.path().join("test_mcp.skills");
350        fs::create_dir(&skills_dir).unwrap();
351        write_skill(&skills_dir, "custom", "Custom body.");
352        let output = skills_list(&manifest, false).unwrap();
353        assert!(output.contains("custom"));
354        assert!(
355            !output.contains("\ngrep "),
356            "bundled grep should be excluded"
357        );
358    }
359
360    #[test]
361    fn skills_show_returns_body_with_header() {
362        let dir = tempfile::tempdir().unwrap();
363        let manifest = dir.path().join("test_mcp.yaml");
364        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
365        let skills_dir = dir.path().join("test_mcp.skills");
366        fs::create_dir(&skills_dir).unwrap();
367        write_skill(&skills_dir, "alpha", "ALPHA-BODY-MARKER");
368        let output = skills_show(&manifest, "alpha", false).unwrap();
369        assert!(output.starts_with("# alpha"));
370        assert!(output.contains("ALPHA-BODY-MARKER"));
371        assert!(output.contains("project"));
372    }
373
374    #[test]
375    fn skills_show_missing_skill_errors() {
376        let dir = tempfile::tempdir().unwrap();
377        let manifest = dir.path().join("test_mcp.yaml");
378        fs::write(&manifest, "name: t\n").unwrap();
379        let err = skills_show(&manifest, "nonexistent", false).unwrap_err();
380        assert!(err.contains("no skill named"));
381    }
382
383    #[test]
384    fn skills_list_no_skills_declared_is_empty() {
385        let dir = tempfile::tempdir().unwrap();
386        let manifest = dir.path().join("test_mcp.yaml");
387        fs::write(&manifest, "name: t\n").unwrap();
388        let output = skills_list(&manifest, false).unwrap();
389        assert!(output.contains("no skills resolved"));
390    }
391
392    #[test]
393    fn skills_new_scaffolds_into_a_directory() {
394        let dir = tempfile::tempdir().unwrap();
395        let dest = skills_new(dir.path(), "custom", "A short description.").unwrap();
396        assert_eq!(dest, dir.path().join("custom.md"));
397        let content = fs::read_to_string(&dest).unwrap();
398        assert!(content.contains("name: custom"));
399        assert!(content.contains("# `custom` methodology"));
400    }
401
402    #[test]
403    fn skills_new_rejects_empty_name() {
404        let dir = tempfile::tempdir().unwrap();
405        let err = skills_new(dir.path(), "", "A description.").unwrap_err();
406        assert!(err.contains("name must not be empty"));
407    }
408
409    #[test]
410    fn skills_new_rejects_empty_description() {
411        let dir = tempfile::tempdir().unwrap();
412        let err = skills_new(dir.path(), "custom", "   ").unwrap_err();
413        assert!(err.contains("description must not be empty"));
414    }
415
416    #[test]
417    fn skills_new_bubbles_write_errors() {
418        let dir = tempfile::tempdir().unwrap();
419        // Pre-create the file to trigger the AlreadyExists branch.
420        fs::write(dir.path().join("custom.md"), "x").unwrap();
421        let err = skills_new(dir.path(), "custom", "description").unwrap_err();
422        assert!(err.contains("template write failed"));
423    }
424}