Skip to main content

aigent/builder/
mod.rs

1/// Deterministic (zero-config) skill generation heuristics.
2pub mod deterministic;
3/// LLM-enhanced skill generation and provider trait.
4pub mod llm;
5/// LLM provider implementations (Anthropic, OpenAI, Google, Ollama).
6pub mod providers;
7/// Template generation for `init` command.
8pub 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
22/// Write content to a file atomically, failing if the file already exists.
23///
24/// Uses `create_new(true)` to prevent TOCTOU races. Returns a descriptive
25/// error including the file path on failure.
26fn 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/// User input for skill generation.
52#[derive(Debug, Clone, Default)]
53pub struct SkillSpec {
54    /// Natural language description of what the skill should do.
55    pub purpose: String,
56    /// Explicit skill name override. If `None`, derived from `purpose`.
57    pub name: Option<String>,
58    /// Allowed tools (e.g., `"Bash, Read"`).
59    pub tools: Option<String>,
60    /// Compatibility string (e.g., `"Claude 3.5 and above"`).
61    pub compatibility: Option<String>,
62    /// License identifier (e.g., `"MIT"`).
63    pub license: Option<String>,
64    /// Additional files to write alongside SKILL.md, keyed by relative path.
65    pub extra_files: Option<HashMap<String, String>>,
66    /// Output directory override. If `None`, derived from the skill name.
67    pub output_dir: Option<PathBuf>,
68    /// Force deterministic mode (no LLM) regardless of environment.
69    pub no_llm: bool,
70    /// Skip scaffolding of `examples/` and `scripts/` directories.
71    pub minimal: bool,
72    /// Template variant for generating the skill structure.
73    pub template: SkillTemplate,
74}
75
76/// Result of skill generation.
77#[derive(Debug)]
78pub struct BuildResult {
79    /// Parsed properties from the generated SKILL.md frontmatter.
80    pub properties: SkillProperties,
81    /// All files written, keyed by relative path (includes `SKILL.md`).
82    pub files: HashMap<String, String>,
83    /// Directory where the skill was created.
84    pub output_dir: PathBuf,
85    /// Warnings collected during generation (e.g., LLM fallback notices).
86    ///
87    /// These replace the previous `eprintln!` calls, giving library consumers
88    /// structured access to non-fatal issues that occurred during the build.
89    pub warnings: Vec<String>,
90}
91
92/// Clarity assessment result.
93#[derive(Debug)]
94pub struct ClarityAssessment {
95    /// Whether the purpose description is clear enough for generation.
96    pub clear: bool,
97    /// Follow-up questions to ask if not clear (empty when `clear` is true).
98    pub questions: Vec<String>,
99}
100
101/// Build a complete skill from a specification.
102///
103/// Generates a SKILL.md with valid frontmatter and markdown body, creates the
104/// output directory, writes files, and validates the result. The output
105/// directory is determined from `spec.output_dir` (if provided) or derived
106/// from the skill name.
107///
108/// Returns `AigentError::Build` if the output directory already contains a
109/// SKILL.md or if the generated output fails validation.
110pub fn build_skill(spec: &SkillSpec) -> Result<BuildResult> {
111    // 0. Select provider (unless no_llm).
112    let provider: Option<Box<dyn LlmProvider>> = if spec.no_llm { None } else { detect_provider() };
113    let mut warnings = Vec::new();
114
115    // 1. Derive name (LLM with fallback to deterministic).
116    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    // 2. Determine output directory.
133    let output_dir = spec
134        .output_dir
135        .clone()
136        .unwrap_or_else(|| PathBuf::from(&name));
137
138    // 3. Generate description (LLM with fallback).
139    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    // 4. Construct SkillProperties directly.
154    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    // 5. Generate body (LLM with fallback).
164    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    // 6. Serialize SkillProperties to YAML frontmatter.
184    let yaml = serde_yaml_ng::to_string(&properties).map_err(|e| AigentError::Build {
185        message: format!("failed to serialize frontmatter: {e}"),
186    })?;
187
188    // 7. Assemble SKILL.md content.
189    let content = format!("---\n{yaml}---\n{body}");
190
191    // 8. Create output directory if needed.
192    std::fs::create_dir_all(&output_dir)?;
193
194    // 9. Write SKILL.md atomically (fails if file already exists).
195    let skill_md_path = output_dir.join("SKILL.md");
196    write_exclusive(&skill_md_path, content.as_bytes())?;
197
198    // 10. Write extra files if present.
199    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            // Reject absolute paths and path traversal components.
205            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    // 10b. Scaffold supporting directories unless minimal.
225    if !spec.minimal {
226        scaffold_dirs(&output_dir)?;
227    }
228
229    // 11. Validate output.
230    let diags = validate(&output_dir);
231    let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
232    if !errors.is_empty() {
233        // Best-effort cleanup of files we just wrote, to avoid leaving
234        // invalid artifacts on disk that block subsequent runs.
235        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    // 12. Return BuildResult.
252    Ok(BuildResult {
253        properties,
254        files,
255        output_dir,
256        warnings,
257    })
258}
259
260/// Derive a kebab-case skill name from a natural language description.
261///
262/// Uses deterministic heuristics: lowercase, remove filler words, apply
263/// gerund form, kebab-case, sanitize, and truncate to 64 characters.
264#[must_use]
265pub fn derive_name(purpose: &str) -> String {
266    deterministic::derive_name(purpose)
267}
268
269/// Evaluate if a purpose description is clear enough for autonomous generation.
270///
271/// Uses deterministic heuristics based on word count, question marks, and
272/// purpose structure.
273#[must_use]
274pub fn assess_clarity(purpose: &str) -> ClarityAssessment {
275    deterministic::assess_clarity(purpose)
276}
277
278/// Initialize a skill directory with a template SKILL.md.
279///
280/// Creates the directory if it doesn't exist. Returns an error if a SKILL.md
281/// (or skill.md) already exists in the target directory. The `tmpl` parameter
282/// selects the template variant; use `SkillTemplate::Minimal` for the default.
283///
284/// When `minimal` is false (default), also creates `examples/` and `scripts/`
285/// subdirectories with `.gitkeep` files, unless the template already populated them.
286pub fn init_skill(dir: &Path, tmpl: SkillTemplate, minimal: bool) -> Result<PathBuf> {
287    // Derive directory name for the template.
288    // Filter out "." and ".." which produce empty kebab-case names.
289    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            // Fall back to the current working directory's basename.
296            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    // Generate template files.
306    let files = template::template_files(tmpl, &dir_name);
307
308    // Create directory if needed.
309    std::fs::create_dir_all(dir)?;
310
311    // Write all template files.
312    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        // Use atomic exclusive creation for SKILL.md to prevent TOCTOU races.
319        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        // On Unix, set execute bit on shell scripts.
326        #[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    // Scaffold supporting directories unless --minimal.
343    if !minimal {
344        scaffold_dirs(dir)?;
345    }
346
347    Ok(dir.join("SKILL.md"))
348}
349
350/// Create `examples/` and `scripts/` subdirectories with `.gitkeep` files.
351///
352/// Only creates each directory if it doesn't already exist, so template-generated
353/// files (e.g., `scripts/run.sh`) take precedence.
354fn 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
365/// Run an interactive build session, prompting for confirmation at each step.
366///
367/// Uses the provided `reader` for input (stdin in production, `Cursor` in
368/// tests). Writes progress to stderr. Returns the same `BuildResult` as
369/// [`build_skill`] on success.
370///
371/// **Note**: Interactive mode always uses deterministic (template-based)
372/// generation regardless of the `no_llm` setting on the spec. This ensures
373/// the user sees exactly what will be written before confirming.
374///
375/// The flow is:
376/// 1. Assess clarity — if unclear, print questions and return error
377/// 2. Derive name — print and confirm
378/// 3. Generate description — print and confirm
379/// 4. Generate body preview — print first 20 lines
380/// 5. Confirm write
381/// 6. Build, validate, and report
382pub fn interactive_build(
383    spec: &SkillSpec,
384    reader: &mut dyn std::io::BufRead,
385) -> Result<BuildResult> {
386    // 1. Assess clarity.
387    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    // 2. Derive name.
399    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    // 3. Generate description.
411    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    // 4. Preview body.
420    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    // 5. Confirm write.
431    if !confirm("Write skill?", reader)? {
432        return Err(AigentError::Build {
433            message: "cancelled by user".to_string(),
434        });
435    }
436
437    // 6. Build (reuse standard build with forced deterministic mode).
438    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    // 7. Report.
449    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
464/// Read a yes/no confirmation from `reader`. Returns `true` for "y" or "yes".
465fn 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    // ── init_skill tests (24-29) ──────────────────────────────────────
483
484    #[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        // The file should be parseable.
506        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    // ── scaffolding tests ──────────────────────────────────────────────
565
566    #[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        // CodeSkill template creates scripts/run.sh
597        init_skill(&dir, SkillTemplate::CodeSkill, false).unwrap();
598        // scripts/ should exist (from template), but no .gitkeep since dir already populated
599        assert!(dir.join("scripts").is_dir());
600        assert!(dir.join("scripts/run.sh").exists());
601        // examples/ should be scaffolded since template didn't create it
602        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    // ── build_skill tests (30-38) ─────────────────────────────────────
640
641    #[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    // ── interactive_build tests ──────────────────────────────────────
809
810    #[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        // Simulate "y" for all three prompts (name, description, write).
822        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        // Simulate "n" at the name confirmation.
839        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        // Verify that the standard build path is unaffected.
865        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    // ── TOCTOU race fix tests ──────────────────────────────────────
879
880    #[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}