1use crate::analysis::backend::AnalysisBackend;
2use crate::errors::CoreError;
3use crate::models::{
4 KnowledgeNode, NodeType, Pattern, PatternStatus, PatternType, SkillDraft, SkillValidation,
5 SuggestedTarget,
6};
7use crate::util;
8
9const MAX_RETRIES: usize = 2;
10
11pub fn node_to_pattern(node: &KnowledgeNode) -> Pattern {
13 let pattern_type = match node.node_type {
14 NodeType::Skill => PatternType::WorkflowPattern,
15 NodeType::Rule | NodeType::Directive => PatternType::RepetitiveInstruction,
16 NodeType::Pattern => PatternType::RecurringMistake,
17 NodeType::Preference | NodeType::Memory => PatternType::WorkflowPattern,
18 };
19 let suggested_target = match node.node_type {
20 NodeType::Skill => SuggestedTarget::Skill,
21 NodeType::Rule
22 | NodeType::Directive
23 | NodeType::Pattern
24 | NodeType::Preference
25 | NodeType::Memory => SuggestedTarget::ClaudeMd,
26 };
27
28 Pattern {
29 id: node.id.clone(),
30 pattern_type,
31 description: node.content.clone(),
32 confidence: node.confidence,
33 times_seen: 1,
34 first_seen: node.created_at,
35 last_seen: node.updated_at,
36 last_projected: None,
37 status: PatternStatus::Active,
38 source_sessions: vec![],
39 related_files: vec![],
40 suggested_content: node.content.clone(),
41 suggested_target,
42 project: node.project_id.clone(),
43 generation_failed: false,
44 }
45}
46
47const SKILL_VALIDATION_SCHEMA: &str = r#"{"type":"object","properties":{"valid":{"type":"boolean"},"feedback":{"type":"string"}},"required":["valid","feedback"],"additionalProperties":false}"#;
49
50pub fn generate_with_retry(
52 backend: &dyn AnalysisBackend,
53 pattern: &Pattern,
54 max_retries: usize,
55) -> Result<SkillDraft, CoreError> {
56 let mut feedback = String::new();
57 let retries = max_retries.min(MAX_RETRIES);
58
59 for attempt in 0..=retries {
60 let prompt = build_generation_prompt(pattern, if attempt > 0 { Some(&feedback) } else { None });
61 let response = backend.execute(&prompt, None)?;
62 let content = util::strip_code_fences(&response.text);
63
64 let name = match parse_skill_name(&content) {
65 Some(n) => n,
66 None => {
67 feedback = "The skill must have valid YAML frontmatter with a 'name' field.".to_string();
68 continue;
69 }
70 };
71
72 let draft = SkillDraft {
73 name,
74 content: content.clone(),
75 pattern_id: pattern.id.clone(),
76 };
77
78 let validation_prompt = build_validation_prompt(&content, pattern);
80 match backend.execute(&validation_prompt, Some(SKILL_VALIDATION_SCHEMA)) {
81 Ok(val_response) => {
82 match parse_validation(&val_response.text) {
83 Some(v) if v.valid => return Ok(draft),
84 Some(v) => {
85 feedback = v.feedback;
86 }
87 None => {
88 if has_valid_frontmatter(&content) {
90 return Ok(draft);
91 }
92 feedback = "Skill validation response was unparseable.".to_string();
93 }
94 }
95 }
96 Err(_) => {
97 if has_valid_frontmatter(&content) {
99 return Ok(draft);
100 }
101 feedback = "Skill validation call failed.".to_string();
102 }
103 }
104 }
105
106 Err(CoreError::Analysis(format!(
107 "skill generation failed after {} retries for pattern {}",
108 retries, pattern.id
109 )))
110}
111
112fn build_generation_prompt(pattern: &Pattern, feedback: Option<&str>) -> String {
113 let feedback_section = match feedback {
114 Some(fb) => format!(
115 "\n\n## Previous Attempt Feedback\n\nYour previous attempt was rejected: {fb}\nPlease address this feedback in your new attempt.\n"
116 ),
117 None => String::new(),
118 };
119
120 let related = if pattern.related_files.is_empty() {
121 "None".to_string()
122 } else {
123 pattern.related_files.join(", ")
124 };
125
126 format!(
127 r#"You are an expert at writing Claude Code skills. A skill is a reusable instruction file that Claude Code discovers and applies automatically.
128
129Generate a skill for the following discovered pattern:
130
131**Pattern Type:** {pattern_type}
132**Description:** {description}
133**Suggested Content:** {suggested_content}
134**Related Files:** {related}
135**Times Seen:** {times_seen}
136{feedback_section}
137## Skill Format
138
139The skill MUST follow this exact format:
140
141```
142---
143name: lowercase-letters-numbers-hyphens-only
144description: Use when [specific triggering conditions]. Include keywords like error messages, tool names, symptoms.
145---
146
147[Skill body: Clear, actionable instructions with specific commands and file paths.]
148```
149
150## Examples
151
152Example 1:
153```
154---
155name: run-tests-after-rust-changes
156description: Use when modifying .rs files in src/, when making code changes that could break functionality, or when the user mentions testing.
157---
158
159After modifying any Rust source file (.rs), always run the test suite:
160
1611. Run `cargo test` in the workspace root
1622. If tests fail, fix the failing tests before proceeding
1633. Run `cargo clippy` to check for warnings
164```
165
166Example 2:
167```
168---
169name: python-uv-package-management
170description: Use when installing Python packages, setting up virtual environments, seeing pip-related errors, or when pyproject.toml is present.
171---
172
173Always use `uv` for Python package management instead of `pip`:
174
1751. Install packages: `uv pip install <package>`
1762. Create virtual environments: `uv venv`
1773. Sync from requirements: `uv pip sync requirements.txt`
1784. Never use bare `pip install`
179```
180
181## Requirements
182
183- **name**: lowercase letters, numbers, and hyphens only. Descriptive of the skill's purpose.
184- **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.
185- **body**: Actionable, specific instructions. Use numbered steps for procedures. Reference concrete commands and paths.
186
187Return ONLY the skill content (YAML frontmatter + body), no explanation or wrapping."#,
188 pattern_type = pattern.pattern_type,
189 description = pattern.description,
190 suggested_content = pattern.suggested_content,
191 related = related,
192 times_seen = pattern.times_seen,
193 )
194}
195
196fn build_validation_prompt(skill_content: &str, pattern: &Pattern) -> String {
197 format!(
198 r#"You are a quality reviewer for Claude Code skills. Review the following skill and determine if it meets quality standards.
199
200## Skill Content
201
202```
203{skill_content}
204```
205
206## Original Pattern
207
208**Description:** {description}
209**Suggested Content:** {suggested_content}
210
211## Quality Criteria
212
2131. **name** field: lowercase letters, numbers, and hyphens only
2142. **description**: Starts with "Use when..."
2153. **description**: Describes triggering conditions, NOT what the skill does
2164. **Total YAML frontmatter**: Under 1024 characters
2175. **Body**: Actionable and specific instructions
2186. **Relevance**: Skill actually addresses the original pattern
219
220Return ONLY a JSON object (no markdown wrapping):
221{{"valid": true, "feedback": ""}}
222or
223{{"valid": false, "feedback": "explanation of what needs to be fixed"}}"#,
224 skill_content = skill_content,
225 description = pattern.description,
226 suggested_content = pattern.suggested_content,
227 )
228}
229
230pub fn parse_skill_name(content: &str) -> Option<String> {
232 let lines: Vec<&str> = content.lines().collect();
233 if lines.is_empty() || lines[0].trim() != "---" {
234 return None;
235 }
236
237 for line in &lines[1..] {
238 let trimmed = line.trim();
239 if trimmed == "---" {
240 break;
241 }
242 if let Some(rest) = trimmed.strip_prefix("name:") {
243 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
244 if !name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
245 return Some(name);
246 }
247 }
248 }
249 None
250}
251
252fn has_valid_frontmatter(content: &str) -> bool {
254 let lines: Vec<&str> = content.lines().collect();
255 if lines.is_empty() || lines[0].trim() != "---" {
256 return false;
257 }
258 lines[1..].iter().any(|line| line.trim() == "---")
260}
261
262fn parse_validation(text: &str) -> Option<SkillValidation> {
265 serde_json::from_str(text.trim()).ok()
266}
267
268pub fn skill_path(project_root: &str, name: &str) -> String {
270 format!("{project_root}/.claude/skills/{name}/SKILL.md")
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_parse_skill_name_valid() {
279 let content = "---\nname: run-tests-after-changes\ndescription: Use when modifying files\n---\n\nBody here.";
280 assert_eq!(parse_skill_name(content), Some("run-tests-after-changes".to_string()));
281 }
282
283 #[test]
284 fn test_parse_skill_name_quoted() {
285 let content = "---\nname: \"my-skill\"\ndescription: Use when stuff\n---\n\nBody.";
286 assert_eq!(parse_skill_name(content), Some("my-skill".to_string()));
287 }
288
289 #[test]
290 fn test_parse_skill_name_invalid_chars() {
291 let content = "---\nname: My Skill Name\ndescription: test\n---\n";
292 assert_eq!(parse_skill_name(content), None);
293 }
294
295 #[test]
296 fn test_parse_skill_name_no_frontmatter() {
297 let content = "Just some text";
298 assert_eq!(parse_skill_name(content), None);
299 }
300
301 #[test]
302 fn test_has_valid_frontmatter() {
303 assert!(has_valid_frontmatter("---\nname: test\n---\nbody"));
304 assert!(!has_valid_frontmatter("no frontmatter"));
305 assert!(!has_valid_frontmatter("---\nno closing delimiter"));
306 }
307
308 #[test]
309 fn test_parse_validation_valid() {
310 let text = r#"{"valid": true, "feedback": ""}"#;
311 let v = parse_validation(text).unwrap();
312 assert!(v.valid);
313 assert!(v.feedback.is_empty());
314 }
315
316 #[test]
317 fn test_parse_validation_invalid() {
318 let text = r#"{"valid": false, "feedback": "description doesn't start with Use when"}"#;
319 let v = parse_validation(text).unwrap();
320 assert!(!v.valid);
321 assert!(v.feedback.contains("Use when"));
322 }
323
324 #[test]
325 fn test_skill_validation_schema_is_valid_json() {
326 let value: serde_json::Value = serde_json::from_str(SKILL_VALIDATION_SCHEMA)
327 .expect("SKILL_VALIDATION_SCHEMA must be valid JSON");
328 assert_eq!(value["type"], "object");
329 assert!(value["properties"]["valid"].is_object());
330 assert!(value["properties"]["feedback"].is_object());
331 }
332
333 #[test]
334 fn test_skill_path() {
335 assert_eq!(
336 skill_path("/home/user/project", "run-tests"),
337 "/home/user/project/.claude/skills/run-tests/SKILL.md"
338 );
339 }
340
341 #[test]
342 fn test_node_to_pattern() {
343 use crate::models::*;
344 use chrono::Utc;
345 let node = KnowledgeNode {
346 id: "node-1".to_string(),
347 node_type: NodeType::Skill,
348 scope: NodeScope::Global,
349 project_id: None,
350 content: "Pre-PR checklist: run tests, lint, format, commit".to_string(),
351 confidence: 0.78,
352 status: NodeStatus::Active,
353 created_at: Utc::now(),
354 updated_at: Utc::now(),
355 projected_at: None,
356 pr_url: None,
357 };
358
359 let pattern = node_to_pattern(&node);
360 assert_eq!(pattern.id, "node-1");
361 assert_eq!(pattern.description, node.content);
362 assert_eq!(pattern.suggested_content, node.content);
363 assert_eq!(pattern.confidence, 0.78);
364 assert_eq!(pattern.suggested_target, SuggestedTarget::Skill);
365 }
366}