1pub mod deterministic;
3pub mod llm;
5pub mod providers;
7pub mod template;
9mod util;
10
11pub use llm::LlmProvider;
12pub use template::SkillTemplate;
13
14use std::collections::HashMap;
15use std::fs::OpenOptions;
16use std::io::Write as _;
17use std::path::{Path, PathBuf};
18
19use crate::errors::{AigentError, Result};
20use crate::models::SkillProperties;
21
22fn write_exclusive(path: &Path, content: &[u8]) -> Result<()> {
27 let mut file = OpenOptions::new()
28 .write(true)
29 .create_new(true)
30 .open(path)
31 .map_err(|e| {
32 if e.kind() == std::io::ErrorKind::AlreadyExists {
33 AigentError::AlreadyExists {
34 path: path.to_path_buf(),
35 }
36 } else {
37 AigentError::Build {
38 message: format!("cannot create {}: {e}", path.display()),
39 }
40 }
41 })?;
42 file.write_all(content).map_err(|e| AigentError::Build {
43 message: format!("cannot write {}: {e}", path.display()),
44 })
45}
46use crate::validator::validate;
47
48use deterministic::{generate_body, generate_description};
49use llm::{detect_provider, llm_derive_name, llm_generate_body, llm_generate_description};
50
51#[derive(Debug, Clone, Default)]
53pub struct SkillSpec {
54 pub purpose: String,
56 pub name: Option<String>,
58 pub tools: Option<String>,
60 pub compatibility: Option<String>,
62 pub license: Option<String>,
64 pub extra_files: Option<HashMap<String, String>>,
66 pub output_dir: Option<PathBuf>,
68 pub no_llm: bool,
70 pub minimal: bool,
72 pub template: SkillTemplate,
74}
75
76#[derive(Debug)]
78pub struct BuildResult {
79 pub properties: SkillProperties,
81 pub files: HashMap<String, String>,
83 pub output_dir: PathBuf,
85 pub warnings: Vec<String>,
90}
91
92#[derive(Debug)]
94pub struct ClarityAssessment {
95 pub clear: bool,
97 pub questions: Vec<String>,
99}
100
101pub fn build_skill(spec: &SkillSpec) -> Result<BuildResult> {
111 let provider: Option<Box<dyn LlmProvider>> = if spec.no_llm { None } else { detect_provider() };
113 let mut warnings = Vec::new();
114
115 let name = if let Some(explicit) = &spec.name {
117 explicit.clone()
118 } else if let Some(ref prov) = provider {
119 match llm_derive_name(prov.as_ref(), &spec.purpose) {
120 Ok(n) => n,
121 Err(e) => {
122 warnings.push(format!(
123 "LLM name derivation failed ({e}), using deterministic"
124 ));
125 deterministic::derive_name(&spec.purpose)
126 }
127 }
128 } else {
129 deterministic::derive_name(&spec.purpose)
130 };
131
132 let output_dir = spec
134 .output_dir
135 .clone()
136 .unwrap_or_else(|| PathBuf::from(&name));
137
138 let description = if let Some(ref prov) = provider {
140 match llm_generate_description(prov.as_ref(), &spec.purpose, &name) {
141 Ok(d) => d,
142 Err(e) => {
143 warnings.push(format!(
144 "LLM description generation failed ({e}), using deterministic"
145 ));
146 generate_description(&spec.purpose, &name)
147 }
148 }
149 } else {
150 generate_description(&spec.purpose, &name)
151 };
152
153 let properties = SkillProperties {
155 name: name.clone(),
156 description,
157 license: spec.license.clone(),
158 compatibility: spec.compatibility.clone(),
159 allowed_tools: spec.tools.clone(),
160 metadata: None,
161 };
162
163 let body = if let Some(ref prov) = provider {
165 match llm_generate_body(
166 prov.as_ref(),
167 &spec.purpose,
168 &properties.name,
169 &properties.description,
170 ) {
171 Ok(b) => b,
172 Err(e) => {
173 warnings.push(format!(
174 "LLM body generation failed ({e}), using deterministic"
175 ));
176 generate_body(&spec.purpose, &properties.name, &properties.description)
177 }
178 }
179 } else {
180 generate_body(&spec.purpose, &properties.name, &properties.description)
181 };
182
183 let yaml = serde_yaml_ng::to_string(&properties).map_err(|e| AigentError::Build {
185 message: format!("failed to serialize frontmatter: {e}"),
186 })?;
187
188 let content = format!("---\n{yaml}---\n{body}");
190
191 std::fs::create_dir_all(&output_dir)?;
193
194 let skill_md_path = output_dir.join("SKILL.md");
196 write_exclusive(&skill_md_path, content.as_bytes())?;
197
198 let mut files = HashMap::new();
200 files.insert("SKILL.md".to_string(), content);
201
202 if let Some(ref extra) = spec.extra_files {
203 for (rel_path, file_content) in extra {
204 let path = std::path::Path::new(rel_path);
206 if path.is_absolute()
207 || path
208 .components()
209 .any(|c| matches!(c, std::path::Component::ParentDir))
210 {
211 return Err(AigentError::Build {
212 message: format!("extra file path must be relative without '..': {rel_path}"),
213 });
214 }
215 let full_path = output_dir.join(rel_path);
216 if let Some(parent) = full_path.parent() {
217 std::fs::create_dir_all(parent)?;
218 }
219 std::fs::write(&full_path, file_content)?;
220 files.insert(rel_path.clone(), file_content.clone());
221 }
222 }
223
224 if !spec.minimal {
226 scaffold_dirs(&output_dir)?;
227 }
228
229 let diags = validate(&output_dir);
231 let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
232 if !errors.is_empty() {
233 let _ = std::fs::remove_file(&skill_md_path);
236 if let Some(ref extra) = spec.extra_files {
237 for rel_path in extra.keys() {
238 let full_path = output_dir.join(rel_path);
239 let _ = std::fs::remove_file(&full_path);
240 }
241 }
242 let error_msgs: Vec<String> = errors.iter().map(|d| d.to_string()).collect();
243 return Err(AigentError::Build {
244 message: format!(
245 "generated skill failed validation:\n{}",
246 error_msgs.join("\n")
247 ),
248 });
249 }
250
251 Ok(BuildResult {
253 properties,
254 files,
255 output_dir,
256 warnings,
257 })
258}
259
260#[must_use]
265pub fn derive_name(purpose: &str) -> String {
266 deterministic::derive_name(purpose)
267}
268
269#[must_use]
274pub fn assess_clarity(purpose: &str) -> ClarityAssessment {
275 deterministic::assess_clarity(purpose)
276}
277
278pub fn init_skill(dir: &Path, tmpl: SkillTemplate, minimal: bool) -> Result<PathBuf> {
287 let dir_name = dir
290 .file_name()
291 .and_then(|n| n.to_str())
292 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
293 .map(|name| name.to_string())
294 .or_else(|| {
295 std::env::current_dir().ok().and_then(|cwd| {
297 cwd.file_name()
298 .and_then(|n| n.to_str())
299 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
300 .map(|name| name.to_string())
301 })
302 })
303 .unwrap_or_else(|| "my-skill".to_string());
304
305 let files = template::template_files(tmpl, &dir_name);
307
308 std::fs::create_dir_all(dir)?;
310
311 for (rel_path, content) in &files {
313 let full_path = dir.join(rel_path);
314 if let Some(parent) = full_path.parent() {
315 std::fs::create_dir_all(parent)?;
316 }
317
318 if rel_path == "SKILL.md" {
320 write_exclusive(&full_path, content.as_bytes())?;
321 } else {
322 std::fs::write(&full_path, content)?;
323 }
324
325 #[cfg(unix)]
327 {
328 if full_path
329 .extension()
330 .and_then(|ext| ext.to_str())
331 .is_some_and(|ext| ext.eq_ignore_ascii_case("sh"))
332 {
333 use std::os::unix::fs::PermissionsExt;
334 let metadata = std::fs::metadata(&full_path)?;
335 let mut perms = metadata.permissions();
336 perms.set_mode(perms.mode() | 0o111);
337 std::fs::set_permissions(&full_path, perms)?;
338 }
339 }
340 }
341
342 if !minimal {
344 scaffold_dirs(dir)?;
345 }
346
347 Ok(dir.join("SKILL.md"))
348}
349
350fn scaffold_dirs(dir: &Path) -> Result<()> {
355 for subdir in &["examples", "scripts"] {
356 let path = dir.join(subdir);
357 if !path.exists() {
358 std::fs::create_dir_all(&path)?;
359 std::fs::write(path.join(".gitkeep"), "")?;
360 }
361 }
362 Ok(())
363}
364
365pub fn interactive_build(
383 spec: &SkillSpec,
384 reader: &mut dyn std::io::BufRead,
385) -> Result<BuildResult> {
386 let assessment = assess_clarity(&spec.purpose);
388 if !assessment.clear {
389 eprintln!("Purpose needs clarification:");
390 for q in &assessment.questions {
391 eprintln!(" - {q}");
392 }
393 return Err(AigentError::Build {
394 message: "purpose is not clear enough for generation".to_string(),
395 });
396 }
397
398 let name = spec
400 .name
401 .clone()
402 .unwrap_or_else(|| derive_name(&spec.purpose));
403 eprintln!("Name: {name}");
404 if !confirm("Continue?", reader)? {
405 return Err(AigentError::Build {
406 message: "cancelled by user".to_string(),
407 });
408 }
409
410 let description = deterministic::generate_description(&spec.purpose, &name);
412 eprintln!("Description: {description}");
413 if !confirm("Continue?", reader)? {
414 return Err(AigentError::Build {
415 message: "cancelled by user".to_string(),
416 });
417 }
418
419 let body = generate_body(&spec.purpose, &name, &description);
421 eprintln!("Body preview:");
422 for line in body.lines().take(20) {
423 eprintln!(" {line}");
424 }
425 let total_lines = body.lines().count();
426 if total_lines > 20 {
427 eprintln!(" ... ({} more lines)", total_lines - 20);
428 }
429
430 if !confirm("Write skill?", reader)? {
432 return Err(AigentError::Build {
433 message: "cancelled by user".to_string(),
434 });
435 }
436
437 let build_spec = SkillSpec {
439 purpose: spec.purpose.clone(),
440 name: Some(name),
441 no_llm: true,
442 output_dir: spec.output_dir.clone(),
443 template: spec.template,
444 ..Default::default()
445 };
446 let result = build_skill(&build_spec)?;
447
448 let diags = validate(&result.output_dir);
450 let error_count = diags.iter().filter(|d| d.is_error()).count();
451 let warning_count = diags.iter().filter(|d| d.is_warning()).count();
452 if error_count == 0 && warning_count == 0 {
453 eprintln!("Validation: passed");
454 } else {
455 eprintln!("Validation: {error_count} error(s), {warning_count} warning(s)");
456 for d in &diags {
457 eprintln!(" {d}");
458 }
459 }
460
461 Ok(result)
462}
463
464fn confirm(prompt: &str, reader: &mut dyn std::io::BufRead) -> Result<bool> {
466 eprint!("{prompt} [y/N] ");
467 let mut line = String::new();
468 reader
469 .read_line(&mut line)
470 .map_err(|e| AigentError::Build {
471 message: format!("failed to read input: {e}"),
472 })?;
473 let answer = line.trim().to_lowercase();
474 Ok(answer == "y" || answer == "yes")
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use tempfile::tempdir;
481
482 #[test]
485 fn init_creates_skill_md_in_empty_dir() {
486 let parent = tempdir().unwrap();
487 let dir = parent.path().join("my-skill");
488 let _ = init_skill(&dir, SkillTemplate::Minimal, false).unwrap();
489 assert!(dir.join("SKILL.md").exists());
490 }
491
492 #[test]
493 fn init_returns_path_to_created_file() {
494 let parent = tempdir().unwrap();
495 let dir = parent.path().join("my-skill");
496 let path = init_skill(&dir, SkillTemplate::Minimal, false).unwrap();
497 assert_eq!(path, dir.join("SKILL.md"));
498 }
499
500 #[test]
501 fn init_created_file_has_valid_frontmatter() {
502 let parent = tempdir().unwrap();
503 let dir = parent.path().join("my-skill");
504 init_skill(&dir, SkillTemplate::Minimal, false).unwrap();
505 let result = crate::read_properties(&dir);
507 assert!(
508 result.is_ok(),
509 "init output should be parseable: {result:?}"
510 );
511 }
512
513 #[test]
514 fn init_name_derived_from_directory() {
515 let parent = tempdir().unwrap();
516 let dir = parent.path().join("cool-tool");
517 init_skill(&dir, SkillTemplate::Minimal, false).unwrap();
518 let props = crate::read_properties(&dir).unwrap();
519 assert_eq!(props.name, "cool-tool");
520 }
521
522 #[test]
523 fn init_fails_if_skill_md_exists() {
524 let parent = tempdir().unwrap();
525 let dir = parent.path().join("my-skill");
526 std::fs::create_dir_all(&dir).unwrap();
527 std::fs::write(dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
528 let result = init_skill(&dir, SkillTemplate::Minimal, false);
529 assert!(result.is_err(), "should fail if SKILL.md already exists");
530 let err = result.unwrap_err().to_string();
531 assert!(
532 err.contains("already exists"),
533 "error should mention 'already exists': {err}"
534 );
535 }
536
537 #[test]
538 fn init_creates_directory_if_missing() {
539 let parent = tempdir().unwrap();
540 let dir = parent.path().join("nonexistent-dir");
541 assert!(!dir.exists());
542 init_skill(&dir, SkillTemplate::Minimal, false).unwrap();
543 assert!(dir.exists());
544 assert!(dir.join("SKILL.md").exists());
545 }
546
547 #[test]
548 #[cfg(unix)]
549 fn init_code_skill_script_is_executable() {
550 use std::os::unix::fs::PermissionsExt;
551 let parent = tempdir().unwrap();
552 let dir = parent.path().join("code-skill");
553 init_skill(&dir, SkillTemplate::CodeSkill, false).unwrap();
554 let script = dir.join("scripts/run.sh");
555 assert!(script.exists(), "scripts/run.sh should exist");
556 let perms = std::fs::metadata(&script).unwrap().permissions();
557 assert!(
558 perms.mode() & 0o111 != 0,
559 "scripts/run.sh should be executable, mode: {:o}",
560 perms.mode()
561 );
562 }
563
564 #[test]
567 fn init_creates_scaffolding_dirs_by_default() {
568 let parent = tempdir().unwrap();
569 let dir = parent.path().join("scaffold-skill");
570 init_skill(&dir, SkillTemplate::Minimal, false).unwrap();
571 assert!(dir.join("examples").is_dir(), "examples/ should be created");
572 assert!(
573 dir.join("examples/.gitkeep").exists(),
574 "examples/.gitkeep should exist"
575 );
576 assert!(dir.join("scripts").is_dir(), "scripts/ should be created");
577 assert!(
578 dir.join("scripts/.gitkeep").exists(),
579 "scripts/.gitkeep should exist"
580 );
581 }
582
583 #[test]
584 fn init_minimal_skips_scaffolding() {
585 let parent = tempdir().unwrap();
586 let dir = parent.path().join("minimal-skill");
587 init_skill(&dir, SkillTemplate::Minimal, true).unwrap();
588 assert!(!dir.join("examples").exists(), "examples/ should not exist");
589 assert!(!dir.join("scripts").exists(), "scripts/ should not exist");
590 }
591
592 #[test]
593 fn init_does_not_overwrite_template_dirs() {
594 let parent = tempdir().unwrap();
595 let dir = parent.path().join("code-skill-scaffold");
596 init_skill(&dir, SkillTemplate::CodeSkill, false).unwrap();
598 assert!(dir.join("scripts").is_dir());
600 assert!(dir.join("scripts/run.sh").exists());
601 assert!(dir.join("examples").is_dir());
603 assert!(dir.join("examples/.gitkeep").exists());
604 }
605
606 #[test]
607 fn build_creates_scaffolding_dirs_by_default() {
608 let parent = tempdir().unwrap();
609 let dir = parent.path().join("build-scaffold");
610 let spec = SkillSpec {
611 purpose: "Process PDF files".to_string(),
612 name: Some("build-scaffold".to_string()),
613 output_dir: Some(dir.clone()),
614 no_llm: true,
615 ..Default::default()
616 };
617 build_skill(&spec).unwrap();
618 assert!(dir.join("examples").is_dir());
619 assert!(dir.join("scripts").is_dir());
620 }
621
622 #[test]
623 fn build_minimal_skips_scaffolding() {
624 let parent = tempdir().unwrap();
625 let dir = parent.path().join("build-minimal");
626 let spec = SkillSpec {
627 purpose: "Process PDF files".to_string(),
628 name: Some("build-minimal".to_string()),
629 output_dir: Some(dir.clone()),
630 no_llm: true,
631 minimal: true,
632 ..Default::default()
633 };
634 build_skill(&spec).unwrap();
635 assert!(!dir.join("examples").exists());
636 assert!(!dir.join("scripts").exists());
637 }
638
639 #[test]
642 fn build_deterministic_creates_valid_skill_md() {
643 let parent = tempdir().unwrap();
644 let dir = parent.path().join("processing-pdf-files");
645 let spec = SkillSpec {
646 purpose: "Process PDF files".to_string(),
647 output_dir: Some(dir.clone()),
648 no_llm: true,
649 ..Default::default()
650 };
651 let result = build_skill(&spec).unwrap();
652 assert!(dir.join("SKILL.md").exists());
653 assert_eq!(result.properties.name, "processing-pdf-files");
654 }
655
656 #[test]
657 fn build_output_passes_validate() {
658 let parent = tempdir().unwrap();
659 let dir = parent.path().join("processing-pdf-files");
660 let spec = SkillSpec {
661 purpose: "Process PDF files".to_string(),
662 output_dir: Some(dir.clone()),
663 no_llm: true,
664 ..Default::default()
665 };
666 build_skill(&spec).unwrap();
667 let diags = crate::validate(&dir);
668 let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
669 assert!(
670 errors.is_empty(),
671 "validate should report no errors: {errors:?}"
672 );
673 }
674
675 #[test]
676 fn build_uses_name_override() {
677 let parent = tempdir().unwrap();
678 let dir = parent.path().join("my-custom-name");
679 let spec = SkillSpec {
680 purpose: "Process PDF files".to_string(),
681 name: Some("my-custom-name".to_string()),
682 output_dir: Some(dir),
683 no_llm: true,
684 ..Default::default()
685 };
686 let result = build_skill(&spec).unwrap();
687 assert_eq!(result.properties.name, "my-custom-name");
688 }
689
690 #[test]
691 fn build_derives_name_from_purpose() {
692 let parent = tempdir().unwrap();
693 let spec = SkillSpec {
694 purpose: "Process PDF files".to_string(),
695 output_dir: Some(parent.path().join("processing-pdf-files")),
696 no_llm: true,
697 ..Default::default()
698 };
699 let result = build_skill(&spec).unwrap();
700 assert!(
701 result.properties.name.starts_with("processing"),
702 "name should be derived from purpose: {}",
703 result.properties.name
704 );
705 }
706
707 #[test]
708 fn build_fails_if_skill_md_exists() {
709 let parent = tempdir().unwrap();
710 let dir = parent.path().join("existing-skill");
711 std::fs::create_dir_all(&dir).unwrap();
712 std::fs::write(dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
713 let spec = SkillSpec {
714 purpose: "Process PDF files".to_string(),
715 name: Some("existing-skill".to_string()),
716 output_dir: Some(dir),
717 no_llm: true,
718 ..Default::default()
719 };
720 let result = build_skill(&spec);
721 assert!(result.is_err(), "should fail if SKILL.md already exists");
722 }
723
724 #[test]
725 fn build_creates_output_dir_if_missing() {
726 let parent = tempdir().unwrap();
727 let dir = parent.path().join("new-skill-dir");
728 assert!(!dir.exists());
729 let spec = SkillSpec {
730 purpose: "Process PDF files".to_string(),
731 name: Some("new-skill-dir".to_string()),
732 output_dir: Some(dir.clone()),
733 no_llm: true,
734 ..Default::default()
735 };
736 build_skill(&spec).unwrap();
737 assert!(dir.exists());
738 }
739
740 #[test]
741 fn build_result_contains_skill_md_key() {
742 let parent = tempdir().unwrap();
743 let dir = parent.path().join("processing-pdf-files");
744 let spec = SkillSpec {
745 purpose: "Process PDF files".to_string(),
746 output_dir: Some(dir),
747 no_llm: true,
748 ..Default::default()
749 };
750 let result = build_skill(&spec).unwrap();
751 assert!(
752 result.files.contains_key("SKILL.md"),
753 "files should contain 'SKILL.md' key"
754 );
755 }
756
757 #[test]
758 fn build_extra_files_written() {
759 let parent = tempdir().unwrap();
760 let dir = parent.path().join("extras-skill");
761 let mut extra = HashMap::new();
762 extra.insert(
763 "examples/example.txt".to_string(),
764 "example content".to_string(),
765 );
766 let spec = SkillSpec {
767 purpose: "Process files".to_string(),
768 name: Some("extras-skill".to_string()),
769 output_dir: Some(dir.clone()),
770 no_llm: true,
771 extra_files: Some(extra),
772 ..Default::default()
773 };
774 let result = build_skill(&spec).unwrap();
775 assert!(dir.join("examples/example.txt").exists());
776 assert!(result.files.contains_key("examples/example.txt"));
777 }
778
779 #[test]
780 fn build_spec_with_all_optional_fields() {
781 let parent = tempdir().unwrap();
782 let dir = parent.path().join("full-skill");
783 let spec = SkillSpec {
784 purpose: "Process PDF files".to_string(),
785 name: Some("full-skill".to_string()),
786 tools: Some("Bash, Read".to_string()),
787 compatibility: Some("Claude 3.5 and above".to_string()),
788 license: Some("MIT".to_string()),
789 output_dir: Some(dir),
790 no_llm: true,
791 minimal: false,
792 extra_files: None,
793 template: SkillTemplate::Minimal,
794 };
795 let result = build_skill(&spec).unwrap();
796 assert_eq!(result.properties.name, "full-skill");
797 assert_eq!(result.properties.license.as_deref(), Some("MIT"));
798 assert_eq!(
799 result.properties.compatibility.as_deref(),
800 Some("Claude 3.5 and above")
801 );
802 assert_eq!(
803 result.properties.allowed_tools.as_deref(),
804 Some("Bash, Read")
805 );
806 }
807
808 #[test]
811 fn interactive_build_with_yes_answers() {
812 let parent = tempdir().unwrap();
813 let dir = parent.path().join("processing-pdf-files");
814 let spec = SkillSpec {
815 purpose: "Process PDF files and extract text content".to_string(),
816 name: Some("processing-pdf-files".to_string()),
817 output_dir: Some(dir.clone()),
818 no_llm: true,
819 ..Default::default()
820 };
821 let mut input = std::io::Cursor::new(b"y\ny\ny\n".to_vec());
823 let result = interactive_build(&spec, &mut input).unwrap();
824 assert!(dir.join("SKILL.md").exists());
825 assert!(!result.properties.name.is_empty());
826 }
827
828 #[test]
829 fn interactive_build_cancel_at_name() {
830 let parent = tempdir().unwrap();
831 let dir = parent.path().join("interactive-cancel");
832 let spec = SkillSpec {
833 purpose: "Process PDF files and extract text content".to_string(),
834 output_dir: Some(dir.clone()),
835 no_llm: true,
836 ..Default::default()
837 };
838 let mut input = std::io::Cursor::new(b"n\n".to_vec());
840 let result = interactive_build(&spec, &mut input);
841 assert!(result.is_err());
842 assert!(!dir.exists(), "no files should be created on cancel");
843 }
844
845 #[test]
846 fn interactive_build_unclear_purpose() {
847 let parent = tempdir().unwrap();
848 let dir = parent.path().join("unclear");
849 let spec = SkillSpec {
850 purpose: "do stuff".to_string(),
851 output_dir: Some(dir),
852 no_llm: true,
853 ..Default::default()
854 };
855 let mut input = std::io::Cursor::new(b"".to_vec());
856 let result = interactive_build(&spec, &mut input);
857 assert!(result.is_err());
858 let err = result.unwrap_err().to_string();
859 assert!(err.contains("not clear enough"));
860 }
861
862 #[test]
863 fn non_interactive_build_unchanged() {
864 let parent = tempdir().unwrap();
866 let dir = parent.path().join("processing-pdf-files");
867 let spec = SkillSpec {
868 purpose: "Process PDF files".to_string(),
869 output_dir: Some(dir.clone()),
870 no_llm: true,
871 ..Default::default()
872 };
873 let result = build_skill(&spec).unwrap();
874 assert!(dir.join("SKILL.md").exists());
875 assert_eq!(result.properties.name, "processing-pdf-files");
876 }
877
878 #[test]
881 fn build_existing_skill_md_error_contains_path() {
882 let parent = tempdir().unwrap();
883 let dir = parent.path().join("toctou-build");
884 std::fs::create_dir_all(&dir).unwrap();
885 std::fs::write(dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
886 let spec = SkillSpec {
887 purpose: "Process PDF files".to_string(),
888 name: Some("toctou-build".to_string()),
889 output_dir: Some(dir.clone()),
890 no_llm: true,
891 ..Default::default()
892 };
893 let result = build_skill(&spec);
894 assert!(result.is_err());
895 let err = result.unwrap_err();
896 assert!(
897 matches!(err, AigentError::AlreadyExists { .. }),
898 "expected AlreadyExists variant, got: {err}"
899 );
900 let msg = err.to_string();
901 assert!(
902 msg.contains(&dir.join("SKILL.md").display().to_string()),
903 "error should contain the file path: {msg}"
904 );
905 }
906
907 #[test]
908 fn init_existing_skill_md_error_contains_path() {
909 let parent = tempdir().unwrap();
910 let dir = parent.path().join("toctou-init");
911 std::fs::create_dir_all(&dir).unwrap();
912 std::fs::write(dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
913 let result = init_skill(&dir, SkillTemplate::Minimal, false);
914 assert!(result.is_err());
915 let err = result.unwrap_err();
916 assert!(
917 matches!(err, AigentError::AlreadyExists { .. }),
918 "expected AlreadyExists variant, got: {err}"
919 );
920 let msg = err.to_string();
921 assert!(
922 msg.contains(&dir.join("SKILL.md").display().to_string()),
923 "error should contain the file path: {msg}"
924 );
925 }
926
927 #[test]
928 fn build_does_not_overwrite_existing_skill_md() {
929 let parent = tempdir().unwrap();
930 let dir = parent.path().join("no-overwrite-build");
931 std::fs::create_dir_all(&dir).unwrap();
932 let original = "---\nname: original\n---\nOriginal content\n";
933 std::fs::write(dir.join("SKILL.md"), original).unwrap();
934 let spec = SkillSpec {
935 purpose: "Process PDF files".to_string(),
936 name: Some("no-overwrite-build".to_string()),
937 output_dir: Some(dir.clone()),
938 no_llm: true,
939 ..Default::default()
940 };
941 let _ = build_skill(&spec);
942 let content = std::fs::read_to_string(dir.join("SKILL.md")).unwrap();
943 assert_eq!(
944 content, original,
945 "existing SKILL.md must not be overwritten"
946 );
947 }
948
949 #[test]
950 fn init_does_not_overwrite_existing_skill_md() {
951 let parent = tempdir().unwrap();
952 let dir = parent.path().join("no-overwrite-init");
953 std::fs::create_dir_all(&dir).unwrap();
954 let original = "---\nname: original\n---\nOriginal content\n";
955 std::fs::write(dir.join("SKILL.md"), original).unwrap();
956 let _ = init_skill(&dir, SkillTemplate::Minimal, false);
957 let content = std::fs::read_to_string(dir.join("SKILL.md")).unwrap();
958 assert_eq!(
959 content, original,
960 "existing SKILL.md must not be overwritten"
961 );
962 }
963
964 #[test]
965 fn build_result_has_empty_warnings_on_deterministic() {
966 let parent = tempdir().unwrap();
967 let dir = parent.path().join("processing-pdf-files");
968 let spec = SkillSpec {
969 purpose: "Process PDF files".to_string(),
970 name: Some("processing-pdf-files".to_string()),
971 output_dir: Some(dir),
972 no_llm: true,
973 ..Default::default()
974 };
975 let result = build_skill(&spec).unwrap();
976 assert!(
977 result.warnings.is_empty(),
978 "deterministic build should produce no warnings: {:?}",
979 result.warnings
980 );
981 }
982}