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();
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 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 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 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 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
292fn 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 assert!(!report.has_errors);
389 assert!(report
390 .lines
391 .iter()
392 .any(|l| l.contains("WARN") && l.contains("4 KB")));
393 }
394
395 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 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 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 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 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 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}