1use crate::skills::command_skills::is_model_catalog_eligible;
2use crate::skills::model::SkillMetadata;
3
4pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
5 if skills.is_empty() {
6 return None;
7 }
8
9 let (mut lines, overflow) = render_skill_index(
10 skills,
11 "These skills are discovered at startup from multiple local sources. Each entry includes a description and file path so you can open the source for full instructions.",
12 );
13
14 if overflow > 0 {
15 lines.push(format!("(+{} more skills available)", overflow));
16 }
17
18 lines.push(render_skills_usage_rules().to_string());
19
20 Some(lines.join("\n"))
21}
22
23pub fn render_prompt_skills_section(skills: &[SkillMetadata]) -> Option<String> {
24 let visible_skills = skills
25 .iter()
26 .filter(|skill| is_model_catalog_eligible(skill))
27 .collect::<Vec<_>>();
28 if visible_skills.is_empty() {
29 return None;
30 }
31
32 let mut lines = Vec::new();
33 lines.push("## Skills".to_string());
34 lines.push(
35 "Use a skill only when the user names it or the task clearly matches. Load details on demand."
36 .to_string(),
37 );
38
39 let mut sorted_skills = visible_skills;
40 sorted_skills.sort_by(|left, right| left.name.cmp(&right.name));
41 let overflow = sorted_skills.len().saturating_sub(5);
42 if overflow > 0 {
43 sorted_skills.truncate(5);
44 }
45
46 for skill in sorted_skills {
47 lines.push(render_prompt_skill_line(skill));
48 }
49
50 if overflow > 0 {
51 lines.push(format!("(+{} more skills available)", overflow));
52 }
53
54 Some(lines.join("\n"))
55}
56
57fn render_skills_usage_rules() -> &'static str {
60 r###"- Discovery: Available skills are listed in project docs and may also appear in a runtime "## Skills" section (name + description + file path). Skill bodies live on disk at the listed paths.
61- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
62- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
63- How to use a skill (progressive disclosure):
64 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
65 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
66 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
67 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.
68- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal. If unsure, ask a brief clarification before proceeding.
69- Coordination and sequencing:
70 - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
71 - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
72- Context hygiene:
73 - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
74 - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.
75 - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
76- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
77}
78
79fn render_skill_index(skills: &[SkillMetadata], intro: &str) -> (Vec<String>, usize) {
80 let mut lines = Vec::new();
81 lines.push("## Skills".to_string());
82 lines.push(intro.to_string());
83
84 let mut sorted_skills = skills.iter().collect::<Vec<_>>();
85 sorted_skills.sort_by(|left, right| left.name.cmp(&right.name));
86 let overflow = sorted_skills.len().saturating_sub(10);
87 if overflow > 0 {
88 sorted_skills.truncate(10);
89 }
90
91 for skill in sorted_skills {
92 lines.push(render_skill_line(skill));
93 }
94
95 (lines, overflow)
96}
97
98fn render_skill_line(skill: &SkillMetadata) -> String {
99 let path_str = skill.path.to_string_lossy().replace('\\', "/");
100 let name = skill.name.as_str();
101 let description = skill.description.as_str();
102 let scope = match skill.scope {
103 crate::skills::model::SkillScope::User => "user",
104 crate::skills::model::SkillScope::Repo => "repo",
105 crate::skills::model::SkillScope::System => "system",
106 crate::skills::model::SkillScope::Admin => "admin",
107 };
108 format!("- {name}: {description} (file: {path_str}, scope: {scope})")
109}
110
111fn render_prompt_skill_line(skill: &SkillMetadata) -> String {
112 format!(
113 "- {}: {}",
114 skill.name,
115 prompt_skill_summary(skill).trim_end_matches('.')
116 )
117}
118
119fn prompt_skill_summary(skill: &SkillMetadata) -> String {
120 let summary = skill
121 .short_description
122 .as_deref()
123 .filter(|text| !text.trim().is_empty())
124 .unwrap_or(skill.description.as_str())
125 .trim();
126
127 if summary.chars().count() <= 32 {
128 return summary.to_string();
129 }
130
131 let truncated = summary.chars().take(32).collect::<String>();
132 format!("{}...", truncated.trim_end())
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use std::path::PathBuf;
139
140 #[test]
141 fn test_render_skills_section_empty() {
142 let skills: Vec<SkillMetadata> = vec![];
143 let result = render_skills_section(&skills);
144 assert_eq!(result, None);
145 }
146
147 #[test]
148 fn test_render_skills_section_single() {
149 let skill = SkillMetadata {
150 name: "test-skill".to_string(),
151 description: "A test skill".to_string(),
152 short_description: None,
153 path: PathBuf::from("/path/to/skill"),
154 scope: crate::skills::model::SkillScope::User,
155 manifest: None,
156 };
157 let skills = vec![skill];
158 let result = render_skills_section(&skills);
159
160 assert!(result.is_some());
161 let output = result.unwrap();
162 assert!(output.contains("## Skills"));
163 assert!(output.contains("- test-skill: A test skill (file: /path/to/skill, scope: user)"));
164 assert!(output.contains("Discovery: Available skills are listed"));
166 assert!(output.contains("Description as trigger"));
167 }
168
169 #[test]
170 fn test_render_skills_section_multiple() {
171 let skill1 = SkillMetadata {
172 name: "skill-one".to_string(),
173 description: "First skill".to_string(),
174 short_description: None,
175 path: PathBuf::from("/path/to/skill1"),
176 scope: crate::skills::model::SkillScope::User,
177 manifest: None,
178 };
179 let skill2 = SkillMetadata {
180 name: "skill-two".to_string(),
181 description: "Second skill".to_string(),
182 short_description: None,
183 path: PathBuf::from("\\path\\to\\skill2"), scope: crate::skills::model::SkillScope::Repo,
185 manifest: None,
186 };
187 let skills = vec![skill1, skill2];
188 let result = render_skills_section(&skills);
189
190 assert!(result.is_some());
191 let output = result.unwrap();
192 assert!(output.contains("## Skills"));
193 assert!(output.contains("- skill-one: First skill (file: /path/to/skill1, scope: user)"));
194 assert!(output.contains("- skill-two: Second skill (file: /path/to/skill2, scope: repo)")); assert!(output.contains("Context hygiene"));
196 }
197
198 #[test]
199 fn test_render_prompt_skills_section_stays_lean() {
200 let skill = SkillMetadata {
201 name: "test-skill".to_string(),
202 description: "A test skill with a longer description".to_string(),
203 short_description: None,
204 path: PathBuf::from("/path/to/skill"),
205 scope: crate::skills::model::SkillScope::User,
206 manifest: None,
207 };
208 let output = render_prompt_skills_section(&[skill]).expect("prompt skills section");
209
210 assert!(output.contains("## Skills"));
211 assert!(output.contains("Use a skill only when the user names it"));
212 assert!(output.contains("- test-skill:"));
213 assert!(output.contains("A test skill with a longer"));
214 assert!(!output.contains("Discovery: Available skills are listed"));
215 assert!(!output.contains("Description as trigger"));
216 assert!(!output.contains("scope:"));
217 assert!(!output.contains("/path/to/skill"));
218 }
219
220 #[test]
221 fn test_render_prompt_skills_section_hides_command_skills() {
222 let hidden_skill = SkillMetadata {
223 name: "hidden-skill".to_string(),
224 description: "Hidden from model activation".to_string(),
225 short_description: None,
226 path: PathBuf::from("/path/to/hidden-skill"),
227 scope: crate::skills::model::SkillScope::System,
228 manifest: Some(
229 crate::skills::types::SkillManifest {
230 name: "hidden-skill".to_string(),
231 description: "Hidden from model activation".to_string(),
232 disable_model_invocation: Some(true),
233 ..Default::default()
234 }
235 .into(),
236 ),
237 };
238 let normal_skill = SkillMetadata {
239 name: "repo-skill".to_string(),
240 description: "A repo skill".to_string(),
241 short_description: None,
242 path: PathBuf::from("/path/to/repo-skill"),
243 scope: crate::skills::model::SkillScope::Repo,
244 manifest: None,
245 };
246
247 let output = render_prompt_skills_section(&[hidden_skill, normal_skill])
248 .expect("prompt skills section");
249
250 assert!(output.contains("repo-skill"));
251 assert!(!output.contains("hidden-skill"));
252 }
253
254 #[test]
255 fn test_render_prompt_skills_section_prefers_short_description() {
256 let skill = SkillMetadata {
257 name: "codemod".to_string(),
258 description: "Long description that should not be emitted in the prompt".to_string(),
259 short_description: Some("Codemods and migrations".to_string()),
260 path: PathBuf::from("/path/to/codemod"),
261 scope: crate::skills::model::SkillScope::Repo,
262 manifest: None,
263 };
264
265 let output = render_prompt_skills_section(&[skill]).expect("prompt skills section");
266
267 assert!(output.contains("- codemod: Codemods and migrations"));
268 assert!(!output.contains("Long description"));
269 }
270
271 #[test]
272 fn test_render_prompt_skills_section_stays_within_budget() {
273 let skills = (0..8)
274 .map(|index| SkillMetadata {
275 name: format!("skill-{index}"),
276 description: format!("Long description {index} that should be truncated in prompt"),
277 short_description: Some(format!("Short skill {index}")),
278 path: PathBuf::from(format!("/path/to/skill-{index}")),
279 scope: crate::skills::model::SkillScope::Repo,
280 manifest: None,
281 })
282 .collect::<Vec<_>>();
283
284 let output = render_prompt_skills_section(&skills).expect("prompt skills section");
285 let approx_tokens = output.len() / 4;
286
287 assert!(approx_tokens < 110, "got ~{} tokens", approx_tokens);
288 assert!(!output.contains("/path/to/skill"));
289 assert!(output.contains("(+3 more skills available)"));
290 }
291
292 #[test]
293 fn test_render_skills_usage_rules() {
294 let rules = render_skills_usage_rules();
295 assert!(rules.contains("Description as trigger"));
296 assert!(rules.contains("progressive disclosure"));
297 assert!(rules.contains("Coordination and sequencing"));
298 assert!(rules.contains("Context hygiene"));
299 assert!(rules.contains("Safety and fallback"));
300 }
301}