1use 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#[derive(Debug)]
31pub struct LintReport {
32 pub lines: Vec<String>,
34 pub has_errors: bool,
38}
39
40impl LintReport {
41 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
61pub 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
129pub 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(®istry))
139}
140
141pub 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
164pub 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 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 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}