Skip to main content

retro_core/projection/
skill.rs

1use crate::analysis::backend::AnalysisBackend;
2use crate::analysis::claude_cli::ClaudeCliBackend;
3use crate::errors::CoreError;
4use crate::models::{
5    KnowledgeNode, NodeType, Pattern, PatternStatus, PatternType, SkillDraft, SkillValidation,
6    SuggestedTarget,
7};
8use crate::util;
9
10const MAX_RETRIES: usize = 2;
11
12/// Convert a v2 KnowledgeNode to a v1 Pattern for skill generation.
13pub fn node_to_pattern(node: &KnowledgeNode) -> Pattern {
14    let pattern_type = match node.node_type {
15        NodeType::Skill => PatternType::WorkflowPattern,
16        NodeType::Rule | NodeType::Directive => PatternType::RepetitiveInstruction,
17        NodeType::Pattern => PatternType::RecurringMistake,
18        NodeType::Preference | NodeType::Memory => PatternType::WorkflowPattern,
19    };
20    let suggested_target = match node.node_type {
21        NodeType::Skill => SuggestedTarget::Skill,
22        NodeType::Rule
23        | NodeType::Directive
24        | NodeType::Pattern
25        | NodeType::Preference
26        | NodeType::Memory => SuggestedTarget::ClaudeMd,
27    };
28
29    Pattern {
30        id: node.id.clone(),
31        pattern_type,
32        description: node.content.clone(),
33        confidence: node.confidence,
34        times_seen: 1,
35        first_seen: node.created_at,
36        last_seen: node.updated_at,
37        last_projected: None,
38        status: PatternStatus::Active,
39        source_sessions: vec![],
40        related_files: vec![],
41        suggested_content: node.content.clone(),
42        suggested_target,
43        project: node.project_id.clone(),
44        generation_failed: false,
45    }
46}
47
48/// JSON schema for constrained decoding of skill validation responses.
49const SKILL_VALIDATION_SCHEMA: &str = r#"{"type":"object","properties":{"valid":{"type":"boolean"},"feedback":{"type":"string"}},"required":["valid","feedback"],"additionalProperties":false}"#;
50
51/// Generate a skill with retry logic. Returns Err if all attempts fail.
52pub fn generate_with_retry(
53    backend: &dyn AnalysisBackend,
54    pattern: &Pattern,
55    max_retries: usize,
56) -> Result<SkillDraft, CoreError> {
57    let mut feedback = String::new();
58    let retries = max_retries.min(MAX_RETRIES);
59
60    for attempt in 0..=retries {
61        let prompt = build_generation_prompt(pattern, if attempt > 0 { Some(&feedback) } else { None });
62        let response = backend.execute(&prompt, None)?;
63        let content = util::strip_code_fences(&response.text);
64
65        let name = match parse_skill_name(&content) {
66            Some(n) => n,
67            None => {
68                feedback = "The skill must have valid YAML frontmatter with a 'name' field.".to_string();
69                continue;
70            }
71        };
72
73        let draft = SkillDraft {
74            name,
75            content: content.clone(),
76            pattern_id: pattern.id.clone(),
77        };
78
79        // Validate
80        let validation_prompt = build_validation_prompt(&content, pattern);
81        match backend.execute(&validation_prompt, Some(SKILL_VALIDATION_SCHEMA)) {
82            Ok(val_response) => {
83                match parse_validation(&val_response.text) {
84                    Some(v) if v.valid => return Ok(draft),
85                    Some(v) => {
86                        feedback = v.feedback;
87                    }
88                    None => {
89                        // Validation parse failed — accept the draft if it has valid structure
90                        if has_valid_frontmatter(&content) {
91                            return Ok(draft);
92                        }
93                        feedback = "Skill validation response was unparseable.".to_string();
94                    }
95                }
96            }
97            Err(_) => {
98                // Validation call failed — accept draft if structurally valid
99                if has_valid_frontmatter(&content) {
100                    return Ok(draft);
101                }
102                feedback = "Skill validation call failed.".to_string();
103            }
104        }
105    }
106
107    Err(CoreError::Analysis(format!(
108        "skill generation failed after {} retries for pattern {}",
109        retries, pattern.id
110    )))
111}
112
113fn build_generation_prompt(pattern: &Pattern, feedback: Option<&str>) -> String {
114    let feedback_section = match feedback {
115        Some(fb) => format!(
116            "\n\n## Previous Attempt Feedback\n\nYour previous attempt was rejected: {fb}\nPlease address this feedback in your new attempt.\n"
117        ),
118        None => String::new(),
119    };
120
121    let related = if pattern.related_files.is_empty() {
122        "None".to_string()
123    } else {
124        pattern.related_files.join(", ")
125    };
126
127    format!(
128        r#"You are an expert at writing Claude Code skills. A skill is a reusable instruction file that Claude Code discovers and applies automatically.
129
130Generate a skill for the following discovered pattern:
131
132**Pattern Type:** {pattern_type}
133**Description:** {description}
134**Suggested Content:** {suggested_content}
135**Related Files:** {related}
136**Times Seen:** {times_seen}
137{feedback_section}
138## Skill Format
139
140The skill MUST follow this exact format:
141
142```
143---
144name: lowercase-letters-numbers-hyphens-only
145description: Use when [specific triggering conditions]. Include keywords like error messages, tool names, symptoms.
146---
147
148[Skill body: Clear, actionable instructions with specific commands and file paths.]
149```
150
151## Examples
152
153Example 1:
154```
155---
156name: run-tests-after-rust-changes
157description: Use when modifying .rs files in src/, when making code changes that could break functionality, or when the user mentions testing.
158---
159
160After modifying any Rust source file (.rs), always run the test suite:
161
1621. Run `cargo test` in the workspace root
1632. If tests fail, fix the failing tests before proceeding
1643. Run `cargo clippy` to check for warnings
165```
166
167Example 2:
168```
169---
170name: python-uv-package-management
171description: Use when installing Python packages, setting up virtual environments, seeing pip-related errors, or when pyproject.toml is present.
172---
173
174Always use `uv` for Python package management instead of `pip`:
175
1761. Install packages: `uv pip install <package>`
1772. Create virtual environments: `uv venv`
1783. Sync from requirements: `uv pip sync requirements.txt`
1794. Never use bare `pip install`
180```
181
182## Requirements
183
184- **name**: lowercase letters, numbers, and hyphens only. Descriptive of the skill's purpose.
185- **description**: MUST start with "Use when...". Describe TRIGGERING CONDITIONS, not what the skill does. Include relevant keywords (error messages, tool names, file types). Total YAML frontmatter must be under 1024 characters.
186- **body**: Actionable, specific instructions. Use numbered steps for procedures. Reference concrete commands and paths.
187
188Return ONLY the skill content (YAML frontmatter + body), no explanation or wrapping."#,
189        pattern_type = pattern.pattern_type,
190        description = pattern.description,
191        suggested_content = pattern.suggested_content,
192        related = related,
193        times_seen = pattern.times_seen,
194    )
195}
196
197fn build_validation_prompt(skill_content: &str, pattern: &Pattern) -> String {
198    format!(
199        r#"You are a quality reviewer for Claude Code skills. Review the following skill and determine if it meets quality standards.
200
201## Skill Content
202
203```
204{skill_content}
205```
206
207## Original Pattern
208
209**Description:** {description}
210**Suggested Content:** {suggested_content}
211
212## Quality Criteria
213
2141. **name** field: lowercase letters, numbers, and hyphens only
2152. **description**: Starts with "Use when..."
2163. **description**: Describes triggering conditions, NOT what the skill does
2174. **Total YAML frontmatter**: Under 1024 characters
2185. **Body**: Actionable and specific instructions
2196. **Relevance**: Skill actually addresses the original pattern
220
221Return ONLY a JSON object (no markdown wrapping):
222{{"valid": true, "feedback": ""}}
223or
224{{"valid": false, "feedback": "explanation of what needs to be fixed"}}"#,
225        skill_content = skill_content,
226        description = pattern.description,
227        suggested_content = pattern.suggested_content,
228    )
229}
230
231/// Parse the skill name from YAML frontmatter.
232pub fn parse_skill_name(content: &str) -> Option<String> {
233    let lines: Vec<&str> = content.lines().collect();
234    if lines.is_empty() || lines[0].trim() != "---" {
235        return None;
236    }
237
238    for line in &lines[1..] {
239        let trimmed = line.trim();
240        if trimmed == "---" {
241            break;
242        }
243        if let Some(rest) = trimmed.strip_prefix("name:") {
244            let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
245            if !name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
246                return Some(name);
247            }
248        }
249    }
250    None
251}
252
253/// Check if the content has valid frontmatter structure.
254fn has_valid_frontmatter(content: &str) -> bool {
255    let lines: Vec<&str> = content.lines().collect();
256    if lines.is_empty() || lines[0].trim() != "---" {
257        return false;
258    }
259    // Find closing ---
260    lines[1..].iter().any(|line| line.trim() == "---")
261}
262
263/// Parse the validation response JSON.
264/// With `--json-schema` constrained decoding, the response is guaranteed valid JSON.
265fn parse_validation(text: &str) -> Option<SkillValidation> {
266    serde_json::from_str(text.trim()).ok()
267}
268
269/// Determine the skill file path: {project}/.claude/skills/{name}/SKILL.md
270pub fn skill_path(project_root: &str, name: &str) -> String {
271    format!("{project_root}/.claude/skills/{name}/SKILL.md")
272}
273
274/// Find the writing-skills SKILL.md content from the plugins cache directory.
275/// Testable helper — takes the plugins directory as a parameter.
276///
277/// Glob pattern: `{plugins_dir}/cache/*/superpowers/*/skills/writing-skills/SKILL.md`
278/// Picks the last match (highest version when sorted ascending by path).
279/// Concatenates SKILL.md with companion .md files (everything except SKILL.md).
280fn find_writing_skills_in_plugins_dir(plugins_dir: &std::path::Path) -> Option<String> {
281    let pattern = plugins_dir
282        .join("cache")
283        .join("*")
284        .join("superpowers")
285        .join("*")
286        .join("skills")
287        .join("writing-skills")
288        .join("SKILL.md");
289
290    let pattern_str = pattern.to_string_lossy();
291    let mut matches: Vec<std::path::PathBuf> = glob::glob(&pattern_str)
292        .ok()?
293        .filter_map(|r| r.ok())
294        .collect();
295
296    if matches.is_empty() {
297        return None;
298    }
299
300    matches.sort();
301    let skill_path = matches.last()?;
302    let skill_dir = skill_path.parent()?;
303
304    let skill_content = std::fs::read_to_string(skill_path).ok()?;
305
306    // Read companion .md files from the same directory (everything except SKILL.md)
307    let mut companions: Vec<std::path::PathBuf> = std::fs::read_dir(skill_dir)
308        .ok()?
309        .filter_map(|entry| entry.ok())
310        .map(|entry| entry.path())
311        .filter(|p| {
312            p.extension().map(|ext| ext == "md").unwrap_or(false)
313                && p.file_name().map(|n| n != "SKILL.md").unwrap_or(false)
314        })
315        .collect();
316    companions.sort();
317
318    let mut result = skill_content;
319    for companion in &companions {
320        if let Ok(companion_content) = std::fs::read_to_string(companion) {
321            let filename = companion
322                .file_name()
323                .map(|n| n.to_string_lossy().into_owned())
324                .unwrap_or_default();
325            result.push_str(&format!(
326                "\n\n---\n## Companion: {filename}\n\n{companion_content}"
327            ));
328        }
329    }
330
331    Some(result)
332}
333
334/// Find writing-skills content from the global Claude plugins cache.
335/// Reads `~/.claude/plugins` and delegates to `find_writing_skills_in_plugins_dir`.
336pub fn find_writing_skills_content() -> Option<String> {
337    let home = std::env::var("HOME").ok()?;
338    let plugins_dir = std::path::PathBuf::from(home).join(".claude").join("plugins");
339    find_writing_skills_in_plugins_dir(&plugins_dir)
340}
341
342/// Check if superpowers plugin is installed by examining a specific plugins file.
343/// Testable helper — takes the file path as a parameter.
344fn check_superpowers_in_file(path: &std::path::Path) -> bool {
345    let Ok(content) = std::fs::read_to_string(path) else { return false };
346    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else { return false };
347    json.get("plugins")
348        .and_then(|p| p.as_object())
349        .map(|plugins| plugins.keys().any(|k| k.contains("superpowers")))
350        .unwrap_or(false)
351}
352
353/// Check if the superpowers plugin is installed globally.
354/// Reads `~/.claude/plugins/installed_plugins.json`.
355pub fn is_superpowers_installed() -> bool {
356    let home = std::env::var("HOME").unwrap_or_default();
357    let path = std::path::PathBuf::from(home)
358        .join(".claude")
359        .join("plugins")
360        .join("installed_plugins.json");
361    check_superpowers_in_file(&path)
362}
363
364/// Generate a kebab-case slug from node content for use as a skill directory name.
365/// Splits on all non-alphanumeric characters (including hyphens), filters words >= 2 chars,
366/// takes first 4, joins with hyphens, and lowercases the result.
367pub fn generate_skill_slug(content: &str) -> String {
368    content
369        .split(|c: char| !c.is_alphanumeric())
370        .filter(|w| w.len() >= 2)
371        .take(4)
372        .collect::<Vec<_>>()
373        .join("-")
374        .to_lowercase()
375}
376
377/// Check if a skill file already exists at the expected target path.
378/// Uses the slug generated from node content to check `{skills_dir}/{slug}/SKILL.md`.
379pub fn skill_exists_at_target(skills_dir: &std::path::Path, node_content: &str) -> bool {
380    let slug = generate_skill_slug(node_content);
381    skills_dir.join(&slug).join("SKILL.md").exists()
382}
383
384/// Determine the parent `skills/` directory for a skill node based on its scope.
385/// Global → `~/.claude/skills/`, Project → `{project_path}/.claude/skills/`.
386pub fn skill_target_dir(node: &KnowledgeNode, project_path: Option<&str>) -> std::path::PathBuf {
387    match node.scope {
388        crate::models::NodeScope::Global => {
389            let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
390            std::path::PathBuf::from(home).join(".claude").join("skills")
391        }
392        crate::models::NodeScope::Project => {
393            std::path::PathBuf::from(project_path.unwrap_or("."))
394                .join(".claude")
395                .join("skills")
396        }
397    }
398}
399
400/// Result of an agentic skill generation attempt.
401pub struct SkillGenerationResult {
402    /// Whether the skill file was created successfully.
403    pub created: bool,
404    /// The path where the skill was expected/created.
405    pub skill_path: std::path::PathBuf,
406    /// Token usage from the agentic call.
407    pub input_tokens: u64,
408    pub output_tokens: u64,
409}
410
411/// Build the agentic prompt for skill generation, optionally including writing-skills instructions.
412fn build_agentic_skill_prompt_with_instructions(
413    node_content: &str,
414    confidence: f64,
415    target_dir: &str,
416    writing_skills_content: Option<&str>,
417) -> String {
418    let instructions_section = match writing_skills_content {
419        Some(content) => format!(
420            "\n---BEGIN WRITING-SKILLS INSTRUCTIONS---\n{content}\n---END WRITING-SKILLS INSTRUCTIONS---\n"
421        ),
422        None => String::new(),
423    };
424
425    format!(
426        r#"You are an expert at writing Claude Code skills. A skill is a reusable instruction file that Claude Code discovers and applies automatically.
427{instructions_section}
428## Task
429
430Create a skill based on the following observed pattern (confidence: {confidence}):
431
432{node_content}
433
434## Instructions
435
4361. Choose a descriptive kebab-case skill name (lowercase letters, numbers, hyphens only).
4372. Write the skill to: `{target_dir}/{{skill-name}}/SKILL.md`
438   - Replace `{{skill-name}}` with the actual name you choose.
4393. The skill file MUST have YAML frontmatter with:
440   - `name`: the kebab-case skill name
441   - `description`: MUST start with "Use when..." and describe triggering conditions
4424. The body should be concise, actionable instructions.
443
444## Format Example
445
446```
447---
448name: run-tests-before-commit
449description: Use when making code changes, modifying source files, or preparing to commit.
450---
451
452Always run the test suite before committing:
453
4541. Run `cargo test` in the workspace root
4552. Fix any failing tests before proceeding
456```
457
458Write the skill file now using your file writing tools."#,
459        confidence = confidence,
460        node_content = node_content,
461        target_dir = target_dir,
462    )
463}
464
465/// Snapshot all existing SKILL.md files under a skills directory.
466fn snapshot_existing_skills(skills_dir: &std::path::Path) -> std::collections::HashSet<std::path::PathBuf> {
467    let pattern = skills_dir.join("*").join("SKILL.md");
468    let pattern_str = pattern.to_string_lossy();
469    glob::glob(&pattern_str)
470        .map(|paths| paths.filter_map(|r| r.ok()).collect())
471        .unwrap_or_default()
472}
473
474/// Find a newly created SKILL.md that wasn't in the pre-call snapshot.
475fn find_created_skill(
476    skills_dir: &std::path::Path,
477    before: &std::collections::HashSet<std::path::PathBuf>,
478) -> Option<std::path::PathBuf> {
479    let pattern = skills_dir.join("*").join("SKILL.md");
480    let pattern_str = pattern.to_string_lossy();
481    glob::glob(&pattern_str)
482        .ok()?
483        .filter_map(|r| r.ok())
484        .find(|p| !before.contains(p))
485}
486
487/// Generate a skill agentically: spawn the Claude CLI with full tool access so it can
488/// write the skill file directly.
489pub fn generate_skill_agentic(
490    backend: &ClaudeCliBackend,
491    node: &KnowledgeNode,
492    project_path: Option<&str>,
493) -> Result<SkillGenerationResult, CoreError> {
494    // Step 1: Try to find writing-skills instructions (None is fine)
495    let writing_skills_content = find_writing_skills_content();
496
497    // Step 2: Determine target skills directory
498    let target_dir = skill_target_dir(node, project_path);
499
500    // Step 3: Ensure the target directory exists
501    std::fs::create_dir_all(&target_dir).map_err(|e| {
502        CoreError::Analysis(format!(
503            "failed to create skills directory {}: {e}",
504            target_dir.display()
505        ))
506    })?;
507
508    // Check if skill already exists at the expected path
509    if skill_exists_at_target(&target_dir, &node.content) {
510        let slug = generate_skill_slug(&node.content);
511        let existing_path = target_dir.join(&slug).join("SKILL.md");
512        return Ok(SkillGenerationResult {
513            created: true, // Already exists counts as "created"
514            skill_path: existing_path,
515            input_tokens: 0,
516            output_tokens: 0,
517        });
518    }
519
520    // Step 4: Build the prompt
521    let target_dir_str = target_dir.to_string_lossy();
522    let prompt = build_agentic_skill_prompt_with_instructions(
523        &node.content,
524        node.confidence,
525        &target_dir_str,
526        writing_skills_content.as_deref(),
527    );
528
529    // Step 5: Determine working directory (project scope → use project_path, global → None)
530    let cwd = match node.scope {
531        crate::models::NodeScope::Project => project_path,
532        crate::models::NodeScope::Global => None,
533    };
534
535    // Step 6: Snapshot existing skills before the agentic call
536    let before = snapshot_existing_skills(&target_dir);
537
538    // Step 7: Execute agentic call
539    let response = backend.execute_agentic(&prompt, cwd)?;
540
541    // Step 8: Check if a NEW skill file was created (not pre-existing)
542    let found = find_created_skill(&target_dir, &before);
543    let created = found.is_some();
544    let skill_path = found.unwrap_or_else(|| target_dir.join("unknown").join("SKILL.md"));
545
546    Ok(SkillGenerationResult {
547        created,
548        skill_path,
549        input_tokens: response.input_tokens,
550        output_tokens: response.output_tokens,
551    })
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn test_find_writing_skills_in_dir() {
560        let dir = tempfile::TempDir::new().unwrap();
561        let skill_dir = dir.path()
562            .join("cache")
563            .join("marketplace")
564            .join("superpowers")
565            .join("1.0.0")
566            .join("skills")
567            .join("writing-skills");
568        std::fs::create_dir_all(&skill_dir).unwrap();
569        std::fs::write(skill_dir.join("SKILL.md"), "# Writing Skills\nMain content here.").unwrap();
570        std::fs::write(skill_dir.join("best-practices.md"), "# Best Practices\nCompanion content.").unwrap();
571
572        let result = find_writing_skills_in_plugins_dir(dir.path());
573        assert!(result.is_some());
574        let content = result.unwrap();
575        assert!(content.contains("Main content here."));
576        assert!(content.contains("Companion content."));
577    }
578
579    #[test]
580    fn test_find_writing_skills_in_dir_missing() {
581        let dir = tempfile::TempDir::new().unwrap();
582        let result = find_writing_skills_in_plugins_dir(dir.path());
583        assert!(result.is_none());
584    }
585
586    #[test]
587    fn test_find_writing_skills_picks_latest_version() {
588        let dir = tempfile::TempDir::new().unwrap();
589        for version in &["1.0.0", "2.0.0"] {
590            let skill_dir = dir.path()
591                .join("cache")
592                .join("mkt")
593                .join("superpowers")
594                .join(version)
595                .join("skills")
596                .join("writing-skills");
597            std::fs::create_dir_all(&skill_dir).unwrap();
598            std::fs::write(skill_dir.join("SKILL.md"), format!("version {version}")).unwrap();
599        }
600        let result = find_writing_skills_in_plugins_dir(dir.path());
601        assert!(result.is_some());
602        let content = result.unwrap();
603        // Should pick 2.0.0 (last when sorted ascending by path)
604        assert!(content.contains("version 2.0.0"));
605    }
606
607    #[test]
608    fn test_parse_skill_name_valid() {
609        let content = "---\nname: run-tests-after-changes\ndescription: Use when modifying files\n---\n\nBody here.";
610        assert_eq!(parse_skill_name(content), Some("run-tests-after-changes".to_string()));
611    }
612
613    #[test]
614    fn test_parse_skill_name_quoted() {
615        let content = "---\nname: \"my-skill\"\ndescription: Use when stuff\n---\n\nBody.";
616        assert_eq!(parse_skill_name(content), Some("my-skill".to_string()));
617    }
618
619    #[test]
620    fn test_parse_skill_name_invalid_chars() {
621        let content = "---\nname: My Skill Name\ndescription: test\n---\n";
622        assert_eq!(parse_skill_name(content), None);
623    }
624
625    #[test]
626    fn test_parse_skill_name_no_frontmatter() {
627        let content = "Just some text";
628        assert_eq!(parse_skill_name(content), None);
629    }
630
631    #[test]
632    fn test_has_valid_frontmatter() {
633        assert!(has_valid_frontmatter("---\nname: test\n---\nbody"));
634        assert!(!has_valid_frontmatter("no frontmatter"));
635        assert!(!has_valid_frontmatter("---\nno closing delimiter"));
636    }
637
638    #[test]
639    fn test_parse_validation_valid() {
640        let text = r#"{"valid": true, "feedback": ""}"#;
641        let v = parse_validation(text).unwrap();
642        assert!(v.valid);
643        assert!(v.feedback.is_empty());
644    }
645
646    #[test]
647    fn test_parse_validation_invalid() {
648        let text = r#"{"valid": false, "feedback": "description doesn't start with Use when"}"#;
649        let v = parse_validation(text).unwrap();
650        assert!(!v.valid);
651        assert!(v.feedback.contains("Use when"));
652    }
653
654    #[test]
655    fn test_skill_validation_schema_is_valid_json() {
656        let value: serde_json::Value = serde_json::from_str(SKILL_VALIDATION_SCHEMA)
657            .expect("SKILL_VALIDATION_SCHEMA must be valid JSON");
658        assert_eq!(value["type"], "object");
659        assert!(value["properties"]["valid"].is_object());
660        assert!(value["properties"]["feedback"].is_object());
661    }
662
663    #[test]
664    fn test_skill_path() {
665        assert_eq!(
666            skill_path("/home/user/project", "run-tests"),
667            "/home/user/project/.claude/skills/run-tests/SKILL.md"
668        );
669    }
670
671    #[test]
672    fn test_node_to_pattern() {
673        use crate::models::*;
674        use chrono::Utc;
675        let node = KnowledgeNode {
676            id: "node-1".to_string(),
677            node_type: NodeType::Skill,
678            scope: NodeScope::Global,
679            project_id: None,
680            content: "Pre-PR checklist: run tests, lint, format, commit".to_string(),
681            confidence: 0.78,
682            status: NodeStatus::Active,
683            created_at: Utc::now(),
684            updated_at: Utc::now(),
685            projected_at: None,
686            pr_url: None,
687        };
688
689        let pattern = node_to_pattern(&node);
690        assert_eq!(pattern.id, "node-1");
691        assert_eq!(pattern.description, node.content);
692        assert_eq!(pattern.suggested_content, node.content);
693        assert_eq!(pattern.confidence, 0.78);
694        assert_eq!(pattern.suggested_target, SuggestedTarget::Skill);
695    }
696
697    #[test]
698    fn test_is_superpowers_installed_with_valid_json() {
699        let dir = tempfile::TempDir::new().unwrap();
700        let plugins_path = dir.path().join("installed_plugins.json");
701        std::fs::write(&plugins_path, r#"{"version":2,"plugins":{"superpowers@marketplace":[{"scope":"user"}]}}"#).unwrap();
702        assert!(check_superpowers_in_file(&plugins_path));
703    }
704
705    #[test]
706    fn test_is_superpowers_installed_no_superpowers() {
707        let dir = tempfile::TempDir::new().unwrap();
708        let plugins_path = dir.path().join("installed_plugins.json");
709        std::fs::write(&plugins_path, r#"{"version":2,"plugins":{"other-plugin@marketplace":[]}}"#).unwrap();
710        assert!(!check_superpowers_in_file(&plugins_path));
711    }
712
713    #[test]
714    fn test_is_superpowers_installed_missing_file() {
715        let dir = tempfile::TempDir::new().unwrap();
716        let plugins_path = dir.path().join("nonexistent.json");
717        assert!(!check_superpowers_in_file(&plugins_path));
718    }
719
720    #[test]
721    fn test_is_superpowers_installed_invalid_json() {
722        let dir = tempfile::TempDir::new().unwrap();
723        let plugins_path = dir.path().join("installed_plugins.json");
724        std::fs::write(&plugins_path, "not json").unwrap();
725        assert!(!check_superpowers_in_file(&plugins_path));
726    }
727
728    #[test]
729    fn test_generate_skill_slug_basic() {
730        assert_eq!(
731            generate_skill_slug("Pre-PR checklist: run tests, lint, format"),
732            "pre-pr-checklist-run"
733        );
734    }
735
736    #[test]
737    fn test_generate_skill_slug_short_words() {
738        assert_eq!(
739            generate_skill_slug("CI check failures before merging"),
740            "ci-check-failures-before"
741        );
742    }
743
744    #[test]
745    fn test_generate_skill_slug_single_char_filtered() {
746        assert_eq!(
747            generate_skill_slug("Run a test before commit"),
748            "run-test-before-commit"
749        );
750    }
751
752    #[test]
753    fn test_generate_skill_slug_uppercase() {
754        assert_eq!(
755            generate_skill_slug("Rust Error Handling Pattern"),
756            "rust-error-handling-pattern"
757        );
758    }
759
760    #[test]
761    fn test_generate_skill_slug_already_kebab() {
762        assert_eq!(
763            generate_skill_slug("pre-commit-hook"),
764            "pre-commit-hook"
765        );
766    }
767
768    #[test]
769    fn test_skill_target_dir_global() {
770        use crate::models::*;
771        let node = KnowledgeNode {
772            id: "n1".to_string(),
773            node_type: NodeType::Skill,
774            scope: NodeScope::Global,
775            project_id: None,
776            content: "test".to_string(),
777            confidence: 0.8,
778            status: NodeStatus::Active,
779            created_at: chrono::Utc::now(),
780            updated_at: chrono::Utc::now(),
781            projected_at: None,
782            pr_url: None,
783        };
784        let dir = skill_target_dir(&node, None);
785        assert!(dir.to_string_lossy().ends_with(".claude/skills"));
786    }
787
788    #[test]
789    fn test_skill_target_dir_project() {
790        use crate::models::*;
791        let node = KnowledgeNode {
792            id: "n2".to_string(),
793            node_type: NodeType::Skill,
794            scope: NodeScope::Project,
795            project_id: Some("my-project".to_string()),
796            content: "test".to_string(),
797            confidence: 0.8,
798            status: NodeStatus::Active,
799            created_at: chrono::Utc::now(),
800            updated_at: chrono::Utc::now(),
801            projected_at: None,
802            pr_url: None,
803        };
804        let dir = skill_target_dir(&node, Some("/home/user/my-project"));
805        assert_eq!(dir, std::path::PathBuf::from("/home/user/my-project/.claude/skills"));
806    }
807
808    #[test]
809    fn test_build_agentic_skill_prompt_with_instructions() {
810        let prompt = build_agentic_skill_prompt_with_instructions(
811            "Always run tests before committing",
812            0.85,
813            "/home/user/.claude/skills",
814            Some("# Writing Skills Instructions\nDo this and that."),
815        );
816        assert!(prompt.contains("---BEGIN WRITING-SKILLS INSTRUCTIONS---"));
817        assert!(prompt.contains("---END WRITING-SKILLS INSTRUCTIONS---"));
818        assert!(prompt.contains("Always run tests before committing"));
819        assert!(prompt.contains("0.85"));
820        assert!(prompt.contains("/home/user/.claude/skills"));
821        assert!(prompt.contains("Do this and that."));
822    }
823
824    #[test]
825    fn test_build_agentic_skill_prompt_without_instructions() {
826        let prompt = build_agentic_skill_prompt_with_instructions(
827            "test content",
828            0.7,
829            "/tmp/skills",
830            None,
831        );
832        assert!(prompt.contains("test content"));
833        assert!(prompt.contains("/tmp/skills"));
834        assert!(!prompt.contains("WRITING-SKILLS INSTRUCTIONS"));
835    }
836
837    #[test]
838    fn test_find_created_skill_finds_new_file() {
839        let dir = tempfile::TempDir::new().unwrap();
840        // Pre-existing skill
841        let skill1_dir = dir.path().join("skill-one");
842        std::fs::create_dir_all(&skill1_dir).unwrap();
843        std::fs::write(skill1_dir.join("SKILL.md"), "skill one content").unwrap();
844
845        // Take snapshot
846        let before = snapshot_existing_skills(dir.path());
847        assert_eq!(before.len(), 1);
848
849        // Create a new skill after snapshot
850        let skill2_dir = dir.path().join("skill-two");
851        std::fs::create_dir_all(&skill2_dir).unwrap();
852        std::fs::write(skill2_dir.join("SKILL.md"), "skill two content").unwrap();
853
854        let result = find_created_skill(dir.path(), &before);
855        assert!(result.is_some());
856        let found = result.unwrap();
857        assert!(found.to_string_lossy().contains("skill-two"));
858    }
859
860    #[test]
861    fn test_find_created_skill_ignores_preexisting() {
862        let dir = tempfile::TempDir::new().unwrap();
863        // Pre-existing skill
864        let skill1_dir = dir.path().join("skill-one");
865        std::fs::create_dir_all(&skill1_dir).unwrap();
866        std::fs::write(skill1_dir.join("SKILL.md"), "skill one content").unwrap();
867
868        // Take snapshot, then no new skills created
869        let before = snapshot_existing_skills(dir.path());
870        let result = find_created_skill(dir.path(), &before);
871        assert!(result.is_none());
872    }
873
874    #[test]
875    fn test_find_created_skill_returns_none_when_empty() {
876        let dir = tempfile::TempDir::new().unwrap();
877        let before = snapshot_existing_skills(dir.path());
878        let result = find_created_skill(dir.path(), &before);
879        assert!(result.is_none());
880    }
881
882    #[test]
883    fn test_skill_already_exists_at_target() {
884        let dir = tempfile::TempDir::new().unwrap();
885        let skills_dir = dir.path().join(".claude").join("skills");
886        // generate_skill_slug("Pre-PR checklist: run tests") == "pre-pr-checklist-run"
887        let slug_dir = skills_dir.join("pre-pr-checklist-run");
888        std::fs::create_dir_all(&slug_dir).unwrap();
889        std::fs::write(slug_dir.join("SKILL.md"), "---\nname: pre-pr-checklist-run\n---\nExisting skill.").unwrap();
890
891        assert!(skill_exists_at_target(&skills_dir, "Pre-PR checklist: run tests"));
892    }
893
894    #[test]
895    fn test_skill_does_not_exist_at_target() {
896        let dir = tempfile::TempDir::new().unwrap();
897        let skills_dir = dir.path().join(".claude").join("skills");
898        std::fs::create_dir_all(&skills_dir).unwrap();
899
900        assert!(!skill_exists_at_target(&skills_dir, "Pre-PR checklist: run tests"));
901    }
902}