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, so every runtime-state predicate
205    // (`tool_registered:`, `extension_enabled:`) necessarily evaluates
206    // Unsatisfied here — that verdict is an artefact of the offline
207    // evaluation, not a real suppression. Those clauses are therefore
208    // reported as `[RUNTIME]` and the skill as `conditional`, never as
209    // `[   FAIL]` / `inactive`. Only predicates the CLI can actually
210    // decide from the manifest + skill files (the domain predicates)
211    // are allowed to mark a skill `inactive`.
212    let empty_tools = std::collections::HashSet::new();
213    let empty_ext = serde_json::Map::new();
214
215    let mut out = String::new();
216    let _ = writeln!(
217        out,
218        "{:<28}  {:<14}  {:<12}  description",
219        "name", "provenance", "status"
220    );
221    let _ = writeln!(
222        out,
223        "{:<28}  {:<14}  {:<12}  {}",
224        "-".repeat(28),
225        "-".repeat(14),
226        "-".repeat(12),
227        "-".repeat(40)
228    );
229    for name in registry.skill_names() {
230        let Some(skill) = registry.get(&name) else {
231            continue;
232        };
233        let prov = provenance_label(&skill.provenance);
234        let activation = registry.activation_for(skill, &empty_tools, &empty_ext);
235        // Clauses this CLI run cannot decide offline, keyed by the exact
236        // label `activation_for` renders for them.
237        let runtime = runtime_clause_notes(skill);
238        let runtime_note = |clause: &String| -> Option<&'static str> {
239            runtime
240                .iter()
241                .find(|(label, _)| label == clause)
242                .map(|(_, note)| *note)
243        };
244        // A skill is only truly suppressed when a predicate the CLI can
245        // decide came out false; runtime-state clauses don't count.
246        let decidable_failure = activation.clauses.iter().any(|(clause, outcome)| {
247            *outcome != crate::server::skills::PredicateOutcome::Satisfied
248                && runtime_note(clause).is_none()
249        });
250        let status = if activation.active {
251            "active"
252        } else if decidable_failure {
253            "inactive"
254        } else {
255            "conditional"
256        };
257        let desc: String = skill.description().chars().take(60).collect();
258        let _ = writeln!(
259            out,
260            "{:<28}  {:<14}  {status:<12}  {desc}",
261            skill.name(),
262            prov
263        );
264        // For skills that aren't unconditionally active, surface each
265        // predicate so the operator can debug. Indented sub-lines keep
266        // the table readable while still giving full attribution.
267        if !activation.active {
268            for (clause, outcome) in &activation.clauses {
269                let unresolved_runtime =
270                    *outcome != crate::server::skills::PredicateOutcome::Satisfied;
271                match runtime_note(clause).filter(|_| unresolved_runtime) {
272                    Some(note) => {
273                        // "RUNTIME" is exactly the 7-wide mark column
274                        // the other marks are right-aligned into.
275                        let _ = writeln!(out, "    [RUNTIME]  {clause} — {note}");
276                    }
277                    None => {
278                        let mark = match outcome {
279                            crate::server::skills::PredicateOutcome::Satisfied => "ok",
280                            crate::server::skills::PredicateOutcome::Unsatisfied => "FAIL",
281                            crate::server::skills::PredicateOutcome::Unknown => "UNKNOWN",
282                        };
283                        let _ = writeln!(out, "    [{mark:>7}]  {clause}");
284                    }
285                }
286            }
287        }
288    }
289    out
290}
291
292/// The `activation.clauses` labels that come from *runtime-state*
293/// predicates, each paired with the note explaining where the real
294/// answer comes from.
295///
296/// `skills-list` has no live server, so these predicates cannot be
297/// evaluated offline; rendering their forced `Unsatisfied` verdict as a
298/// failure would tell operators a correctly-configured skill is
299/// suppressed. The labels are rebuilt from the skill's typed
300/// frontmatter using the same `format!` shapes
301/// [`Registry::activation_for`](crate::server::skills::Registry::activation_for)
302/// uses, so classification keys off the parsed predicate set rather
303/// than sniffing the prefix of a display string.
304fn runtime_clause_notes(skill: &Skill) -> Vec<(String, &'static str)> {
305    let mut notes = Vec::new();
306    let Some(applies_when) = skill.frontmatter.applies_when.as_ref() else {
307        return notes;
308    };
309    if let Some(tool) = applies_when.tool_registered.as_ref() {
310        notes.push((
311            format!("tool_registered: {tool}"),
312            "resolved against the live tool router at boot",
313        ));
314    }
315    if let Some(key) = applies_when.extension_enabled.as_ref() {
316        notes.push((
317            format!("extension_enabled: {key}"),
318            "resolved against the live manifest extensions at boot",
319        ));
320    }
321    notes
322}
323
324fn format_skill_body(skill: &Skill) -> String {
325    let prov = provenance_label(&skill.provenance);
326    let mut out = String::new();
327    let _ = writeln!(out, "# {} ({prov})", skill.name());
328    let _ = writeln!(out, "{}", skill.description());
329    let _ = writeln!(out);
330    out.push_str(&skill.body);
331    out
332}
333
334fn provenance_label(p: &SkillProvenance) -> String {
335    match p {
336        SkillProvenance::Project => "project".to_string(),
337        SkillProvenance::DomainPack(_) => "domain_pack".to_string(),
338        SkillProvenance::Bundled => "bundled".to_string(),
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::fs;
346
347    fn write_skill(dir: &Path, name: &str, body: &str) {
348        fs::write(
349            dir.join(format!("{name}.md")),
350            format!("---\nname: {name}\ndescription: A {name} skill.\n---\n\n{body}\n"),
351        )
352        .unwrap();
353    }
354
355    #[test]
356    fn skills_lint_reports_each_file() {
357        let dir = tempfile::tempdir().unwrap();
358        write_skill(dir.path(), "alpha", "Body alpha.");
359        write_skill(dir.path(), "beta", "Body beta.");
360        let report = skills_lint(dir.path()).unwrap();
361        assert!(!report.has_errors);
362        assert!(report.lines.iter().any(|l| l.contains("alpha")));
363        assert!(report.lines.iter().any(|l| l.contains("beta")));
364    }
365
366    #[test]
367    fn skills_lint_empty_dir_emits_friendly_line() {
368        let dir = tempfile::tempdir().unwrap();
369        let report = skills_lint(dir.path()).unwrap();
370        assert!(!report.has_errors);
371        assert!(report.lines.iter().any(|l| l.contains("no SKILL.md files")));
372    }
373
374    #[test]
375    fn skills_lint_invalid_dir_errors() {
376        let bogus = Path::new("/nonexistent/path/for/lint");
377        let result = skills_lint(bogus);
378        assert!(result.is_err());
379    }
380
381    #[test]
382    fn skills_lint_size_warning_at_4kb() {
383        let dir = tempfile::tempdir().unwrap();
384        let big = "x".repeat(5_000);
385        write_skill(dir.path(), "fat", &big);
386        let report = skills_lint(dir.path()).unwrap();
387        // Soft warn but not a hard error.
388        assert!(!report.has_errors);
389        assert!(report
390            .lines
391            .iter()
392            .any(|l| l.contains("WARN") && l.contains("4 KB")));
393    }
394
395    /// Write a skill whose frontmatter carries an `applies_when:`
396    /// block. `applies_when` is the already-indented YAML body of the
397    /// block (each line prefixed with two spaces, newline-terminated).
398    fn write_gated_skill(dir: &Path, name: &str, applies_when: &str) {
399        fs::write(
400            dir.join(format!("{name}.md")),
401            format!(
402                "---\nname: {name}\ndescription: A {name} skill.\napplies_when:\n{applies_when}---\n\nBody {name}.\n"
403            ),
404        )
405        .unwrap();
406    }
407
408    /// Answers the domain predicates with a hard "no" so the CLI has a
409    /// clause it can actually decide offline.
410    struct DenyGraphPredicates;
411
412    impl crate::server::skills::SkillPredicateEvaluator for DenyGraphPredicates {
413        fn evaluate(&self, clause: &crate::server::skills::PredicateClause<'_>) -> Option<bool> {
414            use crate::server::skills::PredicateClause;
415            match clause {
416                PredicateClause::GraphHasNodeType(_) | PredicateClause::GraphHasProperty { .. } => {
417                    Some(false)
418                }
419                _ => None,
420            }
421        }
422    }
423
424    /// Build a registry from the project layer next to `manifest`,
425    /// with the domain-predicate evaluator attached — `skills_list`
426    /// can't register one, and we need a decidable failing clause.
427    fn project_registry_with_evaluator(manifest: &Path) -> ResolvedRegistry {
428        Registry::new()
429            .with_predicate_evaluator(DenyGraphPredicates)
430            .auto_detect_project_layer(manifest)
431            .finalise()
432            .unwrap()
433    }
434
435    fn line_for<'a>(output: &'a str, name: &str) -> &'a str {
436        output
437            .lines()
438            .find(|l| l.starts_with(name))
439            .unwrap_or_else(|| panic!("no row for {name} in:\n{output}"))
440    }
441
442    #[test]
443    fn skills_list_reports_runtime_gated_skills_as_conditional() {
444        // `skills-list` runs without a live server, so `tool_registered:`
445        // is unknowable offline. It must not be rendered as a failure —
446        // the bundled github/workspace skills are gated on their own
447        // tool and would otherwise report as suppressed in *every*
448        // deployment, including ones where the tool registers at boot.
449        let dir = tempfile::tempdir().unwrap();
450        let manifest = dir.path().join("test_mcp.yaml");
451        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
452        let output = skills_list(&manifest, true).unwrap();
453
454        for name in ["github_issues", "repo_management"] {
455            let row = line_for(&output, name);
456            assert!(
457                row.contains("conditional"),
458                "expected `{name}` to be conditional, got: {row}"
459            );
460            assert!(
461                !row.contains("inactive"),
462                "`{name}` must not be reported inactive offline, got: {row}"
463            );
464            assert!(
465                output.contains(&format!(
466                    "    [RUNTIME]  tool_registered: {name} — resolved against the live tool router at boot"
467                )),
468                "expected a RUNTIME clause line for `{name}` in:\n{output}"
469            );
470        }
471        assert!(
472            !output.contains("FAIL"),
473            "no bundled skill may render as FAIL offline:\n{output}"
474        );
475    }
476
477    #[test]
478    fn skills_list_extension_gate_is_runtime_too() {
479        let dir = tempfile::tempdir().unwrap();
480        let manifest = dir.path().join("test_mcp.yaml");
481        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
482        let skills_dir = dir.path().join("test_mcp.skills");
483        fs::create_dir(&skills_dir).unwrap();
484        write_gated_skill(
485            &skills_dir,
486            "gated",
487            "  extension_enabled: csv_http_server\n",
488        );
489        let output = skills_list(&manifest, false).unwrap();
490        assert!(line_for(&output, "gated").contains("conditional"));
491        assert!(output.contains(
492            "    [RUNTIME]  extension_enabled: csv_http_server — resolved against the live manifest extensions at boot"
493        ), "got:\n{output}");
494    }
495
496    #[test]
497    fn format_skill_list_keeps_decidable_failures_inactive() {
498        let dir = tempfile::tempdir().unwrap();
499        let manifest = dir.path().join("test_mcp.yaml");
500        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
501        let skills_dir = dir.path().join("test_mcp.skills");
502        fs::create_dir(&skills_dir).unwrap();
503        write_gated_skill(&skills_dir, "domain", "  graph_has_node_type: [Function]\n");
504        let output = format_skill_list(&project_registry_with_evaluator(&manifest));
505        assert!(
506            line_for(&output, "domain").contains("inactive"),
507            "got:\n{output}"
508        );
509        assert!(
510            output.contains("    [   FAIL]  graph_has_node_type: [\"Function\"]"),
511            "got:\n{output}"
512        );
513    }
514
515    #[test]
516    fn format_skill_list_decidable_failure_wins_over_runtime_clause() {
517        let dir = tempfile::tempdir().unwrap();
518        let manifest = dir.path().join("test_mcp.yaml");
519        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
520        let skills_dir = dir.path().join("test_mcp.skills");
521        fs::create_dir(&skills_dir).unwrap();
522        write_gated_skill(
523            &skills_dir,
524            "mixed",
525            "  graph_has_node_type: [Function]\n  tool_registered: cypher_query\n",
526        );
527        let output = format_skill_list(&project_registry_with_evaluator(&manifest));
528        // A clause the CLI *can* decide came out false — that's a real
529        // suppression, so `inactive` wins over `conditional`.
530        assert!(
531            line_for(&output, "mixed").contains("inactive"),
532            "got:\n{output}"
533        );
534        assert!(
535            output.contains("    [   FAIL]  graph_has_node_type"),
536            "got:\n{output}"
537        );
538        assert!(
539            output.contains("    [RUNTIME]  tool_registered: cypher_query — resolved against the live tool router at boot"),
540            "got:\n{output}"
541        );
542    }
543
544    #[test]
545    fn format_skill_list_ungated_skill_stays_active_without_clause_lines() {
546        let dir = tempfile::tempdir().unwrap();
547        let manifest = dir.path().join("test_mcp.yaml");
548        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
549        let skills_dir = dir.path().join("test_mcp.skills");
550        fs::create_dir(&skills_dir).unwrap();
551        write_skill(&skills_dir, "plain", "Plain body.");
552        let output = skills_list(&manifest, false).unwrap();
553        let row = line_for(&output, "plain");
554        assert!(row.contains("active"), "got: {row}");
555        assert!(!row.contains("conditional") && !row.contains("inactive"));
556        assert!(
557            !output.contains("    ["),
558            "no clause lines expected:\n{output}"
559        );
560    }
561
562    #[test]
563    fn skills_list_renders_table_for_resolved_set() {
564        let dir = tempfile::tempdir().unwrap();
565        let manifest = dir.path().join("test_mcp.yaml");
566        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
567        let skills_dir = dir.path().join("test_mcp.skills");
568        fs::create_dir(&skills_dir).unwrap();
569        write_skill(&skills_dir, "custom", "Custom body.");
570        let output = skills_list(&manifest, true).unwrap();
571        assert!(output.contains("custom"));
572        assert!(output.contains("grep"), "expected bundled grep in output");
573        assert!(output.contains("project"));
574        assert!(output.contains("bundled"));
575    }
576
577    #[test]
578    fn skills_list_without_bundled() {
579        let dir = tempfile::tempdir().unwrap();
580        let manifest = dir.path().join("test_mcp.yaml");
581        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
582        let skills_dir = dir.path().join("test_mcp.skills");
583        fs::create_dir(&skills_dir).unwrap();
584        write_skill(&skills_dir, "custom", "Custom body.");
585        let output = skills_list(&manifest, false).unwrap();
586        assert!(output.contains("custom"));
587        assert!(
588            !output.contains("\ngrep "),
589            "bundled grep should be excluded"
590        );
591    }
592
593    #[test]
594    fn skills_show_returns_body_with_header() {
595        let dir = tempfile::tempdir().unwrap();
596        let manifest = dir.path().join("test_mcp.yaml");
597        fs::write(&manifest, "name: t\nskills: true\n").unwrap();
598        let skills_dir = dir.path().join("test_mcp.skills");
599        fs::create_dir(&skills_dir).unwrap();
600        write_skill(&skills_dir, "alpha", "ALPHA-BODY-MARKER");
601        let output = skills_show(&manifest, "alpha", false).unwrap();
602        assert!(output.starts_with("# alpha"));
603        assert!(output.contains("ALPHA-BODY-MARKER"));
604        assert!(output.contains("project"));
605    }
606
607    #[test]
608    fn skills_show_missing_skill_errors() {
609        let dir = tempfile::tempdir().unwrap();
610        let manifest = dir.path().join("test_mcp.yaml");
611        fs::write(&manifest, "name: t\n").unwrap();
612        let err = skills_show(&manifest, "nonexistent", false).unwrap_err();
613        assert!(err.contains("no skill named"));
614    }
615
616    #[test]
617    fn skills_list_no_skills_declared_is_empty() {
618        let dir = tempfile::tempdir().unwrap();
619        let manifest = dir.path().join("test_mcp.yaml");
620        fs::write(&manifest, "name: t\n").unwrap();
621        let output = skills_list(&manifest, false).unwrap();
622        assert!(output.contains("no skills resolved"));
623    }
624
625    #[test]
626    fn skills_new_scaffolds_into_a_directory() {
627        let dir = tempfile::tempdir().unwrap();
628        let dest = skills_new(dir.path(), "custom", "A short description.").unwrap();
629        assert_eq!(dest, dir.path().join("custom.md"));
630        let content = fs::read_to_string(&dest).unwrap();
631        assert!(content.contains("name: custom"));
632        assert!(content.contains("# `custom` methodology"));
633    }
634
635    #[test]
636    fn skills_new_rejects_empty_name() {
637        let dir = tempfile::tempdir().unwrap();
638        let err = skills_new(dir.path(), "", "A description.").unwrap_err();
639        assert!(err.contains("name must not be empty"));
640    }
641
642    #[test]
643    fn skills_new_rejects_empty_description() {
644        let dir = tempfile::tempdir().unwrap();
645        let err = skills_new(dir.path(), "custom", "   ").unwrap_err();
646        assert!(err.contains("description must not be empty"));
647    }
648
649    #[test]
650    fn skills_new_bubbles_write_errors() {
651        let dir = tempfile::tempdir().unwrap();
652        // Pre-create the file to trigger the AlreadyExists branch.
653        fs::write(dir.path().join("custom.md"), "x").unwrap();
654        let err = skills_new(dir.path(), "custom", "description").unwrap_err();
655        assert!(err.contains("template write failed"));
656    }
657}