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