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 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 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 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 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}