1mod builtin;
2mod store;
3
4pub use builtin::{CREATE_SKILL_ID, builtin_skills};
5pub use store::SkillStore;
6
7use crate::config::SkillsConfig;
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "camelCase")]
16pub enum SkillSource {
17 Builtin,
19 #[default]
21 Store,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct SkillManifest {
27 pub id: String,
29 pub name: String,
31 pub description: Option<String>,
33 pub version: Option<String>,
35 pub author: Option<String>,
37 pub tags: Vec<String>,
39 pub requires: Vec<String>,
41 #[serde(default)]
43 pub allow_tools: Vec<String>,
44 #[serde(default)]
46 pub deny_tools: Vec<String>,
47 pub path: PathBuf,
49 pub instructions: String,
51 #[serde(default)]
53 pub source: SkillSource,
54 #[serde(default)]
56 pub scope: SkillWriteScope,
57}
58
59pub fn discover_configured_skills(
61 config: &SkillsConfig,
62 project_dir: &Path,
63 data_dir: &Path,
64) -> Result<Vec<SkillManifest>> {
65 if !config.enabled {
66 return Ok(Vec::new());
67 }
68
69 let mut skills = builtin_skills();
70
71 if let Ok(store) = SkillStore::open(data_dir) {
72 let project_key = project_skill_key(project_dir);
73 match store.list_for_discovery(Some(&project_key)) {
74 Ok(stored) => skills.extend(stored),
75 Err(err) => tracing::warn!(error = %err, "failed to list skills from store"),
76 }
77 }
78
79 skills.sort_by(|a, b| a.id.cmp(&b.id));
80 skills.dedup_by(|a, b| a.id == b.id);
81 Ok(skills)
82}
83
84pub fn project_skill_key(project_dir: &Path) -> String {
86 let canon = project_dir
87 .canonicalize()
88 .unwrap_or_else(|_| project_dir.to_path_buf());
89 use std::collections::hash_map::DefaultHasher;
91 use std::hash::{Hash, Hasher};
92 let mut h = DefaultHasher::new();
93 canon.to_string_lossy().hash(&mut h);
94 format!("{:x}", h.finish())
95}
96
97pub fn skill_tool_allowlist(active: &[SkillManifest]) -> Option<Vec<String>> {
103 let with_allow: Vec<&SkillManifest> = active
104 .iter()
105 .filter(|s| !s.allow_tools.is_empty())
106 .collect();
107 if with_allow.is_empty() {
108 return None;
109 }
110 let mut set: HashSet<String> = with_allow[0].allow_tools.iter().cloned().collect();
111 for skill in with_allow.iter().skip(1) {
112 set.retain(|t| skill.allow_tools.iter().any(|a| a == t));
113 }
114 for skill in active {
115 for deny in &skill.deny_tools {
116 set.remove(deny);
117 }
118 }
119 let mut list: Vec<String> = set.into_iter().collect();
120 list.sort();
121 Some(list)
122}
123
124pub fn active_skills(
127 available: &[SkillManifest],
128 configured_active: &[String],
129 session_active: &[String],
130) -> Vec<SkillManifest> {
131 let requested = if session_active.is_empty() {
132 configured_active
133 } else {
134 session_active
135 };
136 if requested.is_empty() {
137 return Vec::new();
138 }
139
140 available
141 .iter()
142 .filter(|skill| {
143 requested
144 .iter()
145 .any(|name| name == &skill.id || name == &skill.name)
146 })
147 .cloned()
148 .collect()
149}
150
151pub fn render_active_skills(skills: &[SkillManifest]) -> Option<String> {
154 if skills.is_empty() {
155 return None;
156 }
157
158 let mut output = String::from("=== Active Skills ===\n");
159 for skill in skills {
160 output.push_str(&format!("- id: {}; name: {}\n", skill.id, skill.name));
161 if let Some(description) = &skill.description {
162 output.push_str(&format!(" description: {}\n", description.trim()));
163 }
164 if let Some(version) = &skill.version {
165 output.push_str(&format!(" version: {}\n", version));
166 }
167 if let Some(author) = &skill.author {
168 output.push_str(&format!(" author: {}\n", author));
169 }
170 if !skill.tags.is_empty() {
171 output.push_str(&format!(" tags: {}\n", skill.tags.join(", ")));
172 }
173 if !skill.requires.is_empty() {
174 output.push_str(&format!(" requires: {}\n", skill.requires.join(", ")));
175 }
176 output.push_str(skill.instructions.trim());
177 output.push_str("\n\n");
178 }
179 Some(output)
180}
181
182pub fn render_available_skills(skills: &[SkillManifest]) -> Option<String> {
185 if skills.is_empty() {
186 return None;
187 }
188
189 let mut output = String::from(
190 "=== Available Skills ===\nThese skills are available. Use the `load_skill` tool with a skill id when you decide a skill is relevant. The instruction text is not included here.\n",
191 );
192 for skill in skills {
193 output.push_str(&format!("- id: {}; name: {}\n", skill.id, skill.name));
194 if let Some(description) = &skill.description {
195 output.push_str(&format!(" description: {}\n", description.trim()));
196 }
197 if let Some(version) = &skill.version {
198 output.push_str(&format!(" version: {}\n", version));
199 }
200 if let Some(author) = &skill.author {
201 output.push_str(&format!(" author: {}\n", author));
202 }
203 if !skill.tags.is_empty() {
204 output.push_str(&format!(" tags: {}\n", skill.tags.join(", ")));
205 }
206 if !skill.requires.is_empty() {
207 output.push_str(&format!(" requires: {}\n", skill.requires.join(", ")));
208 }
209 }
210 Some(output)
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
215#[serde(rename_all = "camelCase")]
216pub enum SkillWriteScope {
217 #[default]
219 User,
220 Project,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct SkillWriteRequest {
228 #[serde(default)]
230 pub id: String,
231 pub name: String,
232 #[serde(default)]
233 pub description: Option<String>,
234 #[serde(default)]
235 pub version: Option<String>,
236 #[serde(default)]
237 pub author: Option<String>,
238 #[serde(default)]
239 pub tags: Vec<String>,
240 #[serde(default)]
241 pub requires: Vec<String>,
242 #[serde(default)]
244 pub allow_tools: Vec<String>,
245 #[serde(default)]
246 pub deny_tools: Vec<String>,
247 pub instructions: String,
249 #[serde(default)]
250 pub scope: SkillWriteScope,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct SkillWriteResult {
257 pub skill: SkillManifest,
258 pub path: PathBuf,
259 pub created: bool,
260}
261
262pub fn resolve_skill_id(request: &SkillWriteRequest) -> Result<String> {
264 let name = request.name.trim();
265 if name.is_empty() {
266 return Err(anyhow::anyhow!("skill name is required"));
267 }
268 let id = {
269 let raw = request.id.trim();
270 if raw.is_empty() {
271 slugify_skill_id(name)
272 } else {
273 slugify_skill_id(raw)
274 }
275 };
276 if id.is_empty() || id == "." || id == ".." || id.contains('/') || id.contains('\\') {
277 return Err(anyhow::anyhow!("invalid skill id"));
278 }
279 Ok(id)
280}
281
282pub fn slugify_skill_id(raw: &str) -> String {
284 let mut out = String::new();
285 let mut prev_dash = false;
286 for ch in raw.trim().chars() {
287 let c = ch.to_ascii_lowercase();
288 if c.is_ascii_alphanumeric() {
289 out.push(c);
290 prev_dash = false;
291 } else if matches!(c, '-' | '_' | ' ' | '/' | '.') && !prev_dash && !out.is_empty() {
292 out.push('-');
293 prev_dash = true;
294 }
295 }
296 while out.ends_with('-') {
297 out.pop();
298 }
299 if out.is_empty() { "skill".into() } else { out }
300}
301
302pub fn write_skill(
304 request: &SkillWriteRequest,
305 project_dir: &Path,
306 data_dir: &Path,
307) -> Result<SkillWriteResult> {
308 let store = SkillStore::open(data_dir)?;
309 let project_key = match request.scope {
310 SkillWriteScope::Project => Some(project_skill_key(project_dir)),
311 SkillWriteScope::User => None,
312 };
313 store.upsert(request, project_key.as_deref())
314}
315
316pub fn load_skill_by_id(
318 config: &SkillsConfig,
319 project_dir: &Path,
320 data_dir: &Path,
321 skill_id: &str,
322) -> Result<SkillManifest> {
323 let skills = discover_configured_skills(config, project_dir, data_dir)?;
324 skills
325 .into_iter()
326 .find(|s| s.id == skill_id || s.name == skill_id)
327 .ok_or_else(|| anyhow::anyhow!("skill `{skill_id}` not found"))
328}
329
330pub fn delete_skill(skill_id: &str, _project_dir: &Path, data_dir: &Path) -> Result<bool> {
332 let id = slugify_skill_id(skill_id);
333 if id.is_empty() {
334 return Err(anyhow::anyhow!("invalid skill id"));
335 }
336 if builtin_skills().iter().any(|s| s.id == id) {
337 return Err(anyhow::anyhow!("cannot delete built-in skill `{id}`"));
338 }
339 let store = SkillStore::open(data_dir)?;
340 store.delete(&id)
341}
342
343pub fn skill_is_editable(skill: &SkillManifest) -> bool {
345 matches!(skill.source, SkillSource::Store)
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 fn cfg() -> SkillsConfig {
353 SkillsConfig {
354 enabled: true,
355 active: Vec::new(),
356 }
357 }
358
359 #[test]
360 fn discovers_builtin_create_skill() {
361 let tempdir = tempfile::tempdir().expect("tempdir");
362 let skills =
363 discover_configured_skills(&cfg(), tempdir.path(), tempdir.path()).expect("skills");
364 assert!(skills.iter().any(|s| s.id == CREATE_SKILL_ID));
365 let create = skills.iter().find(|s| s.id == CREATE_SKILL_ID).unwrap();
366 assert!(!create.allow_tools.is_empty());
367 assert!(create.allow_tools.iter().any(|t| t == "skill_save"));
368 }
369
370 #[test]
371 fn write_and_discover_store_skill() {
372 let tempdir = tempfile::tempdir().expect("tempdir");
373 let data = tempdir.path().join("data");
374 let project = tempdir.path().join("proj");
375 std::fs::create_dir_all(&project).unwrap();
376
377 let result = write_skill(
378 &SkillWriteRequest {
379 id: String::new(),
380 name: "My Helper".into(),
381 description: Some("Helps with X".into()),
382 version: None,
383 author: None,
384 tags: vec!["util".into()],
385 requires: vec![],
386 allow_tools: vec!["read_file".into()],
387 deny_tools: vec![],
388 instructions: "Do the thing carefully.".into(),
389 scope: SkillWriteScope::User,
390 },
391 &project,
392 &data,
393 )
394 .expect("write");
395 assert!(result.created);
396 assert_eq!(result.skill.id, "my-helper");
397 assert_eq!(result.skill.source, SkillSource::Store);
398
399 let skills = discover_configured_skills(&cfg(), &project, &data).expect("discover");
400 assert!(skills.iter().any(|s| s.id == "my-helper"));
401 assert!(skills.iter().any(|s| s.id == CREATE_SKILL_ID));
402 }
403
404 #[test]
405 fn write_roundtrip_preserves_tool_policy() {
406 let tempdir = tempfile::tempdir().expect("tempdir");
407 write_skill(
408 &SkillWriteRequest {
409 id: "reviewer".into(),
410 name: "Code Reviewer".into(),
411 description: Some("Reviews PRs".into()),
412 version: Some("1.0.0".into()),
413 author: Some("NAVI".into()),
414 tags: vec!["code".into(), "review".into()],
415 requires: vec!["socratic".into()],
416 allow_tools: vec!["read_file".into(), "bash".into()],
417 deny_tools: vec![],
418 instructions: "Review thoroughly.".into(),
419 scope: SkillWriteScope::User,
420 },
421 tempdir.path(),
422 tempdir.path(),
423 )
424 .expect("write");
425 let loaded =
426 load_skill_by_id(&cfg(), tempdir.path(), tempdir.path(), "reviewer").expect("load");
427 assert_eq!(loaded.name, "Code Reviewer");
428 assert_eq!(loaded.allow_tools, vec!["read_file", "bash"]);
429 assert_eq!(loaded.requires, vec!["socratic"]);
430 }
431
432 #[test]
433 fn skill_tool_allowlist_intersects() {
434 let a = SkillManifest {
435 id: "a".into(),
436 name: "A".into(),
437 description: None,
438 version: None,
439 author: None,
440 tags: vec![],
441 requires: vec![],
442 allow_tools: vec!["read_file".into(), "bash".into()],
443 deny_tools: vec![],
444 path: PathBuf::from("a"),
445 instructions: "a".into(),
446 source: SkillSource::Store,
447 scope: SkillWriteScope::User,
448 };
449 let b = SkillManifest {
450 id: "b".into(),
451 name: "B".into(),
452 description: None,
453 version: None,
454 author: None,
455 tags: vec![],
456 requires: vec![],
457 allow_tools: vec!["read_file".into(), "skill_save".into()],
458 deny_tools: vec![],
459 path: PathBuf::from("b"),
460 instructions: "b".into(),
461 source: SkillSource::Store,
462 scope: SkillWriteScope::User,
463 };
464 assert_eq!(skill_tool_allowlist(&[a, b]).unwrap(), vec!["read_file"]);
465 }
466
467 #[test]
468 fn cannot_delete_builtin() {
469 let tempdir = tempfile::tempdir().expect("tempdir");
470 let err = delete_skill(CREATE_SKILL_ID, tempdir.path(), tempdir.path()).unwrap_err();
471 assert!(err.to_string().contains("built-in"));
472 }
473
474 #[test]
475 fn returns_empty_when_disabled() {
476 let tempdir = tempfile::tempdir().expect("tempdir");
477 write_skill(
478 &SkillWriteRequest {
479 id: "x".into(),
480 name: "X".into(),
481 description: None,
482 version: None,
483 author: None,
484 tags: vec![],
485 requires: vec![],
486 allow_tools: vec![],
487 deny_tools: vec![],
488 instructions: "body".into(),
489 scope: SkillWriteScope::User,
490 },
491 tempdir.path(),
492 tempdir.path(),
493 )
494 .unwrap();
495 let config = SkillsConfig {
496 enabled: false,
497 active: Vec::new(),
498 };
499 let skills =
500 discover_configured_skills(&config, tempdir.path(), tempdir.path()).expect("skills");
501 assert!(skills.is_empty());
502 }
503
504 #[test]
505 fn active_skills_match_by_id() {
506 let tempdir = tempfile::tempdir().expect("tempdir");
507 write_skill(
508 &SkillWriteRequest {
509 id: "socratic".into(),
510 name: "Socratic".into(),
511 description: None,
512 version: None,
513 author: None,
514 tags: vec![],
515 requires: vec![],
516 allow_tools: vec![],
517 deny_tools: vec![],
518 instructions: "Ask one question first.".into(),
519 scope: SkillWriteScope::User,
520 },
521 tempdir.path(),
522 tempdir.path(),
523 )
524 .unwrap();
525 let skills =
526 discover_configured_skills(&cfg(), tempdir.path(), tempdir.path()).expect("skills");
527 let active = active_skills(&skills, &["socratic".into()], &[]);
528 assert_eq!(active.len(), 1);
529 let rendered = render_active_skills(&active).unwrap();
530 assert!(rendered.contains("Ask one question first."));
531 }
532}