1use crate::parser::ParsedToolFile;
7use crate::types::{GenerateSkillResult, SkillCategory, SkillTool, ToolExample};
8use std::collections::HashMap;
9
10#[must_use]
33pub fn build_skill_context(
34 server_id: &str,
35 tools: &[ParsedToolFile],
36 use_case_hints: Option<&[String]>,
37) -> GenerateSkillResult {
38 let tool_count = tools.len();
39
40 let categories = group_by_category(tools);
42
43 let example_tools = select_example_tools(tools, 5);
45
46 let skill_name = format!("{server_id}-progressive");
48
49 let output_path = format!("~/.claude/skills/{server_id}/SKILL.md");
51
52 let generation_prompt = build_generation_prompt(
54 server_id,
55 &skill_name,
56 &categories,
57 &example_tools,
58 use_case_hints,
59 );
60
61 GenerateSkillResult {
62 server_id: server_id.to_string(),
63 skill_name,
64 server_description: infer_server_description(tools),
65 categories,
66 tool_count,
67 example_tools,
68 generation_prompt,
69 output_path,
70 }
71}
72
73fn group_by_category(tools: &[ParsedToolFile]) -> Vec<SkillCategory> {
77 let mut category_map: HashMap<String, Vec<SkillTool>> = HashMap::new();
78
79 for tool in tools {
80 let category = tool
81 .category
82 .clone()
83 .unwrap_or_else(|| "uncategorized".to_string());
84
85 let skill_tool = SkillTool {
86 name: tool.name.clone(),
87 typescript_name: tool.typescript_name.clone(),
88 description: tool
89 .description
90 .clone()
91 .unwrap_or_else(|| format!("{} tool", tool.name)),
92 keywords: tool.keywords.clone(),
93 required_params: tool
94 .parameters
95 .iter()
96 .filter(|p| p.required)
97 .map(|p| p.name.clone())
98 .collect(),
99 optional_params: tool
100 .parameters
101 .iter()
102 .filter(|p| !p.required)
103 .map(|p| p.name.clone())
104 .collect(),
105 };
106
107 category_map.entry(category).or_default().push(skill_tool);
108 }
109
110 let mut categories: Vec<SkillCategory> = category_map
112 .into_iter()
113 .map(|(name, tools)| {
114 let display_name = humanize_category(&name);
115 SkillCategory {
116 name,
117 display_name,
118 tools,
119 }
120 })
121 .collect();
122
123 categories.sort_by(|a, b| {
125 if a.name == "uncategorized" {
126 std::cmp::Ordering::Greater
127 } else if b.name == "uncategorized" {
128 std::cmp::Ordering::Less
129 } else {
130 a.name.cmp(&b.name)
131 }
132 });
133
134 categories
135}
136
137fn humanize_category(name: &str) -> String {
139 name.split('-')
140 .map(|word| {
141 let mut chars = word.chars();
142 chars.next().map_or_else(String::new, |first| {
143 first.to_uppercase().chain(chars).collect()
144 })
145 })
146 .collect::<Vec<_>>()
147 .join(" ")
148}
149
150fn select_example_tools(tools: &[ParsedToolFile], max_examples: usize) -> Vec<ToolExample> {
154 let priority_prefixes = ["create", "list", "get", "search", "update"];
156
157 let mut examples = Vec::new();
158 let mut seen_categories = std::collections::HashSet::new();
159
160 for prefix in priority_prefixes {
162 if examples.len() >= max_examples {
163 break;
164 }
165
166 for tool in tools {
167 if examples.len() >= max_examples {
168 break;
169 }
170
171 let category = tool.category.as_deref().unwrap_or("uncategorized");
172
173 if tool.name.starts_with(prefix) && !seen_categories.contains(category) {
174 examples.push(build_tool_example(tool));
175 seen_categories.insert(category.to_string());
176 }
177 }
178 }
179
180 for tool in tools {
182 if examples.len() >= max_examples {
183 break;
184 }
185
186 let category = tool.category.as_deref().unwrap_or("uncategorized");
187
188 if !seen_categories.contains(category) {
189 examples.push(build_tool_example(tool));
190 seen_categories.insert(category.to_string());
191 }
192 }
193
194 examples
195}
196
197fn build_tool_example(tool: &ParsedToolFile) -> ToolExample {
199 let params: HashMap<&str, &str> = tool
201 .parameters
202 .iter()
203 .filter(|p| p.required)
204 .map(|p| (p.name.as_str(), get_example_value(&p.typescript_type)))
205 .collect();
206
207 let params_json = serde_json::to_string_pretty(¶ms).unwrap_or_else(|_| "{}".to_string());
208
209 let cli_command = format!(
211 "node ~/.claude/servers/{}/{}.ts '{}'",
212 tool.server_id,
213 tool.typescript_name,
214 params_json.replace('\n', " ").replace(" ", "")
215 );
216
217 ToolExample {
218 tool_name: tool.name.clone(),
219 description: tool
220 .description
221 .clone()
222 .unwrap_or_else(|| format!("Execute {}", tool.name)),
223 cli_command,
224 params_json,
225 }
226}
227
228fn get_example_value(ts_type: &str) -> &'static str {
230 match ts_type.trim() {
231 "string" => "\"example\"",
232 "number" => "42",
233 "boolean" => "true",
234 t if t.starts_with("string[]") => "[\"item1\", \"item2\"]",
235 t if t.starts_with("number[]") => "[1, 2, 3]",
236 _ => "\"...\"",
237 }
238}
239
240fn infer_server_description(tools: &[ParsedToolFile]) -> Option<String> {
242 if tools.is_empty() {
243 return None;
244 }
245
246 let categories: std::collections::HashSet<_> =
248 tools.iter().filter_map(|t| t.category.as_ref()).collect();
249
250 if categories.is_empty() {
251 return Some(format!("MCP server with {} tools", tools.len()));
252 }
253
254 let category_list: Vec<_> = categories.iter().map(|s| s.as_str()).collect();
255 Some(format!(
256 "MCP server for {} operations ({} tools)",
257 category_list.join(", "),
258 tools.len()
259 ))
260}
261
262#[allow(clippy::format_push_string)]
264fn build_generation_prompt(
265 server_id: &str,
266 skill_name: &str,
267 categories: &[SkillCategory],
268 examples: &[ToolExample],
269 use_case_hints: Option<&[String]>,
270) -> String {
271 let estimated_size = 500 + (categories.len() * 100) + (examples.len() * 200);
274 let mut prompt = String::with_capacity(estimated_size);
275
276 prompt.push_str(&format!(
277 r#"You are generating a Claude Code skill file (SKILL.md) for the "{server_id}" MCP server.
278
279## Context
280
281**Server ID**: {server_id}
282**Skill Name**: {skill_name}
283**Total Tools**: {}
284
285### Categories and Tools
286
287"#,
288 categories.iter().map(|c| c.tools.len()).sum::<usize>()
289 ));
290
291 for category in categories {
292 prompt.push_str(&format!(
293 "#### {} ({} tools)\n",
294 category.display_name,
295 category.tools.len()
296 ));
297
298 for tool in &category.tools {
299 prompt.push_str(&format!("- **{}**: {}\n", tool.name, tool.description));
300
301 if !tool.keywords.is_empty() {
302 prompt.push_str(&format!(" - Keywords: {}\n", tool.keywords.join(", ")));
303 }
304
305 if !tool.required_params.is_empty() {
306 prompt.push_str(&format!(
307 " - Required params: {}\n",
308 tool.required_params.join(", ")
309 ));
310 }
311 }
312
313 prompt.push('\n');
314 }
315
316 prompt.push_str("### Example Tool Usages\n\n");
317
318 for example in examples {
319 prompt.push_str(&format!(
320 "**{}**\n```bash\n{}\n```\n\n",
321 example.description, example.cli_command
322 ));
323 }
324
325 if let Some(hints) = use_case_hints {
326 prompt.push_str("### Use Case Hints\n\n");
327 for hint in hints {
328 prompt.push_str(&format!("- {hint}\n"));
329 }
330 prompt.push('\n');
331 }
332
333 prompt.push_str(GENERATION_INSTRUCTIONS);
334
335 prompt
336}
337
338const GENERATION_INSTRUCTIONS: &str = r#"
339## Instructions
340
341Generate a SKILL.md file with the following structure:
342
3431. **YAML Frontmatter** (required):
344 ```yaml
345 ---
346 name: {skill_name}
347 description: [One-sentence description of what this skill enables]
348 ---
349 ```
350
3512. **Introduction** (1-2 paragraphs):
352 - What this server/skill does
353 - Key capabilities in bullet points
354 - When to use this skill
355
3563. **Quick Start** (numbered steps):
357 - How to discover available tools
358 - How to execute a tool
359 - Example with a common use case
360
3614. **Common Tasks** (3-5 sections):
362 - Organize by USE CASE, not by tool
363 - Each section should solve a real problem
364 - Include natural language examples that trigger tool usage
365 - Show CLI commands where helpful
366
3675. **Tool Reference** (organized by category):
368 - List all tools by category
369 - Brief description of each
370 - Key parameters
371
3726. **Troubleshooting** (3-5 items):
373 - Common errors and solutions
374 - Authentication issues
375 - Connection problems
376
377## Guidelines
378
379- Write for AI agents (Claude), not humans
380- Focus on WHEN to use tools, not just HOW
381- Use natural language examples: "Create an issue about the login bug"
382- Keep descriptions concise but informative
383- Include path references: ~/.claude/servers/{server_id}/
384
385## Output Format
386
387Return ONLY the SKILL.md content, starting with the YAML frontmatter.
388Do not include any explanation or commentary outside the file content.
389"#;
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::parser::ParsedParameter;
395
396 fn create_test_tool(name: &str, category: Option<&str>) -> ParsedToolFile {
397 ParsedToolFile {
398 name: name.to_string(),
399 typescript_name: name.to_string(),
400 server_id: "test".to_string(),
401 category: category.map(ToString::to_string),
402 keywords: vec!["test".to_string()],
403 description: Some(format!("{name} description")),
404 parameters: vec![ParsedParameter {
405 name: "param1".to_string(),
406 typescript_type: "string".to_string(),
407 required: true,
408 description: None,
409 }],
410 }
411 }
412
413 #[test]
414 fn test_build_skill_context() {
415 let tools = vec![
416 create_test_tool("create_issue", Some("issues")),
417 create_test_tool("list_repos", Some("repos")),
418 ];
419
420 let context = build_skill_context("github", &tools, None);
421
422 assert_eq!(context.server_id, "github");
423 assert_eq!(context.skill_name, "github-progressive");
424 assert_eq!(context.tool_count, 2);
425 assert_eq!(context.categories.len(), 2);
426 assert!(!context.generation_prompt.is_empty());
427 }
428
429 #[test]
430 fn test_group_by_category() {
431 let tools = vec![
432 create_test_tool("tool1", Some("cat-a")),
433 create_test_tool("tool2", Some("cat-b")),
434 create_test_tool("tool3", Some("cat-a")),
435 create_test_tool("tool4", None),
436 ];
437
438 let categories = group_by_category(&tools);
439
440 assert_eq!(categories.len(), 3);
441
442 let cat_a = categories.iter().find(|c| c.name == "cat-a").unwrap();
444 assert_eq!(cat_a.tools.len(), 2);
445
446 assert_eq!(categories.last().unwrap().name, "uncategorized");
448 }
449
450 #[test]
451 fn test_humanize_category() {
452 assert_eq!(humanize_category("issues"), "Issues");
453 assert_eq!(humanize_category("pull-requests"), "Pull Requests");
454 assert_eq!(humanize_category("user-management"), "User Management");
455 }
456
457 #[test]
458 fn test_select_example_tools() {
459 let tools = vec![
460 create_test_tool("create_issue", Some("issues")),
461 create_test_tool("list_repos", Some("repos")),
462 create_test_tool("get_user", Some("users")),
463 create_test_tool("update_pr", Some("prs")),
464 create_test_tool("delete_branch", Some("branches")),
465 ];
466
467 let examples = select_example_tools(&tools, 3);
468
469 assert_eq!(examples.len(), 3);
470 assert!(examples.iter().any(|e| e.tool_name == "create_issue"));
472 assert!(examples.iter().any(|e| e.tool_name == "list_repos"));
473 assert!(examples.iter().any(|e| e.tool_name == "get_user"));
474 }
475
476 #[test]
477 fn test_get_example_value() {
478 assert_eq!(get_example_value("string"), "\"example\"");
479 assert_eq!(get_example_value("number"), "42");
480 assert_eq!(get_example_value("boolean"), "true");
481 assert_eq!(get_example_value("string[]"), "[\"item1\", \"item2\"]");
482 }
483}