Skip to main content

bamboo_engine/skills/store/
mod.rs

1//! Skill store with in-memory cache and markdown persistence.
2//!
3//! This module provides the central storage and management system for skills.
4//! Skills are loaded from Markdown files on disk and cached in memory for
5//! fast access during agent execution.
6//!
7//! # Architecture
8//!
9//! The skill store uses a dual-layer architecture:
10//! 1. **Disk Storage**: Skills are persisted as Markdown files in the skills directory
11//! 2. **In-Memory Cache**: Loaded skills are cached in a `RwLock<HashMap>` for fast access
12//!
13//! # Skill Discovery
14//!
15//! On initialization, the store:
16//! 1. Scans the skills directory for `SKILL.md` files
17//! 2. Parses frontmatter and content for each skill
18//! 3. Loads skills into the in-memory cache
19//! 4. Creates built-in skills if the directory is empty
20//!
21//! # Read-Only Design
22//!
23//! Skills are designed to be edited as Markdown files directly, not through
24//! the API. All modification methods return `SkillError::ReadOnly`.
25//!
26//! # Example
27//!
28//! ```rust,ignore
29//! use bamboo_agent::skill::{SkillStore, SkillStoreConfig};
30//! use std::path::PathBuf;
31//!
32//! #[tokio::main]
33//! async fn main() {
34//!     let config = SkillStoreConfig {
35//!         skills_dir: PathBuf::from("./skills"),
36//!         ..Default::default()
37//!     };
38//!
39//!     let store = SkillStore::new(config);
40//!     store.initialize().await.expect("Failed to initialize");
41//!
42//!     // List all skills
43//!     let skills = store.list_skills(None, false).await;
44//!     for skill in skills {
45//!         println!("{}: {}", skill.name, skill.description);
46//!     }
47//! }
48//! ```
49
50pub mod builtin;
51pub mod parser;
52pub mod storage;
53
54use std::collections::HashMap;
55use std::path::{Path, PathBuf};
56
57use tokio::sync::RwLock;
58use tracing::info;
59
60use crate::skills::store::builtin::load_builtin_skill_bundles;
61use crate::skills::store::parser::render_skill_markdown;
62use crate::skills::store::storage::{
63    ensure_skills_dir, load_skills_from_discovery_dirs, write_skill_file, SkillDirectorySource,
64    SkillDiscoveryDir,
65};
66use crate::skills::types::{
67    SkillDefinition, SkillError, SkillFilter, SkillId, SkillResult, SkillStoreConfig,
68};
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71struct SkillCandidateMeta {
72    source: SkillDirectorySource,
73    mode: Option<String>,
74}
75
76/// Persistent storage for skills with in-memory caching.
77///
78/// Manages a collection of skills loaded from Markdown files on disk.
79/// Uses a `RwLock<HashMap>` for thread-safe concurrent access.
80///
81/// # Thread Safety
82///
83/// All operations use async/await with `RwLock` to allow multiple readers
84/// or a single writer, ensuring safe concurrent access from multiple tasks.
85///
86/// # Example
87///
88/// ```rust,ignore
89/// let store = SkillStore::new(SkillStoreConfig::default());
90/// store.initialize().await?;
91///
92/// // Get a specific skill
93/// let skill = store.get_skill("my-skill").await?;
94/// println!("Skill: {}", skill.name);
95/// ```
96pub struct SkillStore {
97    /// In-memory cache of loaded skills, keyed by skill ID.
98    skills: RwLock<HashMap<SkillId, SkillDefinition>>,
99    /// Root directory of each loaded skill (keyed by skill ID).
100    skill_roots: RwLock<HashMap<SkillId, PathBuf>>,
101
102    /// Configuration specifying the skills directory path.
103    config: SkillStoreConfig,
104}
105
106impl SkillStore {
107    fn normalize_mode(raw_mode: Option<&str>) -> Option<String> {
108        let raw = raw_mode?.trim();
109        if raw.is_empty() {
110            return None;
111        }
112
113        let normalized = raw.to_ascii_lowercase();
114        if !normalized.chars().all(|character| {
115            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
116        }) {
117            tracing::warn!(
118                "Ignoring invalid skill mode '{}' (allowed: lowercase letters, digits, hyphen)",
119                raw
120            );
121            return None;
122        }
123
124        Some(normalized)
125    }
126
127    fn effective_mode(&self, mode_override: Option<&str>) -> Option<String> {
128        Self::normalize_mode(mode_override)
129            .or_else(|| Self::normalize_mode(self.config.active_mode.as_deref()))
130    }
131
132    fn sibling_skills_mode_dir(base_skills_dir: &Path, mode: &str) -> PathBuf {
133        let parent = base_skills_dir
134            .parent()
135            .map(Path::to_path_buf)
136            .unwrap_or_else(|| PathBuf::from("."));
137        parent.join(format!("skills-{mode}"))
138    }
139
140    fn project_skills_dir(project_dir: &Path) -> PathBuf {
141        project_dir.join(".bamboo").join("skills")
142    }
143
144    fn project_skills_mode_dir(project_dir: &Path, mode: &str) -> PathBuf {
145        project_dir.join(".bamboo").join(format!("skills-{mode}"))
146    }
147
148    fn discovery_dirs_for_mode(&self, mode_override: Option<&str>) -> Vec<SkillDiscoveryDir> {
149        let mut dirs = Vec::new();
150        let active_mode = self.effective_mode(mode_override);
151
152        dirs.push(SkillDiscoveryDir {
153            dir: self.config.skills_dir.clone(),
154            source: SkillDirectorySource::Global,
155            mode: None,
156        });
157        if let Some(mode) = active_mode.as_ref() {
158            dirs.push(SkillDiscoveryDir {
159                dir: Self::sibling_skills_mode_dir(&self.config.skills_dir, mode),
160                source: SkillDirectorySource::Global,
161                mode: Some(mode.clone()),
162            });
163        }
164
165        if let Some(project_dir) = self.config.project_dir.as_ref() {
166            dirs.push(SkillDiscoveryDir {
167                dir: Self::project_skills_dir(project_dir),
168                source: SkillDirectorySource::Project,
169                mode: None,
170            });
171            if let Some(mode) = active_mode.as_ref() {
172                dirs.push(SkillDiscoveryDir {
173                    dir: Self::project_skills_mode_dir(project_dir, mode),
174                    source: SkillDirectorySource::Project,
175                    mode: Some(mode.clone()),
176                });
177            }
178        }
179
180        dirs
181    }
182
183    fn resolve_from_loaded_records(
184        loaded_records: Vec<crate::skills::store::storage::LoadedSkillRecord>,
185    ) -> (HashMap<SkillId, SkillDefinition>, HashMap<SkillId, PathBuf>) {
186        let mut resolved_skills: HashMap<SkillId, SkillDefinition> = HashMap::new();
187        let mut resolved_roots: HashMap<SkillId, PathBuf> = HashMap::new();
188        let mut resolved_meta: HashMap<SkillId, SkillCandidateMeta> = HashMap::new();
189
190        for record in loaded_records {
191            let skill_id = record.skill.id.clone();
192            let candidate_meta = SkillCandidateMeta {
193                source: record.source,
194                mode: record.mode.clone(),
195            };
196
197            let should_replace = resolved_meta
198                .get(&skill_id)
199                .is_some_and(|existing| Self::should_override_skill(existing, &candidate_meta));
200            let should_keep_existing = resolved_meta.contains_key(&skill_id) && !should_replace;
201
202            if should_keep_existing {
203                tracing::debug!(
204                    "Keeping existing skill '{}' over candidate from {:?} (mode={})",
205                    skill_id,
206                    candidate_meta.source,
207                    candidate_meta.mode.as_deref().unwrap_or("generic")
208                );
209                continue;
210            }
211
212            if should_replace {
213                tracing::info!(
214                    "Skill '{}' overridden by {:?} (mode={})",
215                    skill_id,
216                    candidate_meta.source,
217                    candidate_meta.mode.as_deref().unwrap_or("generic")
218                );
219            }
220
221            resolved_skills.insert(skill_id.clone(), record.skill);
222            resolved_roots.insert(skill_id.clone(), record.skill_root);
223            resolved_meta.insert(skill_id, candidate_meta);
224        }
225
226        (resolved_skills, resolved_roots)
227    }
228
229    async fn resolve_skills_maps_for_mode(
230        &self,
231        mode_override: Option<&str>,
232    ) -> SkillResult<(HashMap<SkillId, SkillDefinition>, HashMap<SkillId, PathBuf>)> {
233        let loaded_records =
234            load_skills_from_discovery_dirs(&self.discovery_dirs_for_mode(mode_override)).await?;
235        Ok(Self::resolve_from_loaded_records(loaded_records))
236    }
237
238    fn should_override_skill(
239        existing: &SkillCandidateMeta,
240        candidate: &SkillCandidateMeta,
241    ) -> bool {
242        match (existing.source, candidate.source) {
243            (SkillDirectorySource::Global, SkillDirectorySource::Project) => return true,
244            (SkillDirectorySource::Project, SkillDirectorySource::Global) => return false,
245            _ => {}
246        }
247
248        match (existing.mode.is_some(), candidate.mode.is_some()) {
249            (false, true) => true,
250            (true, false) => false,
251            _ => false,
252        }
253    }
254
255    /// Create a new skill store with the given configuration.
256    ///
257    /// The store is created empty and must be initialized using [`initialize`](Self::initialize)
258    /// before it can be used.
259    ///
260    /// # Arguments
261    ///
262    /// * `config` - Configuration specifying the skills directory path.
263    ///
264    /// # Example
265    ///
266    /// ```rust,ignore
267    /// use bamboo_agent::skill::{SkillStore, SkillStoreConfig};
268    /// use std::path::PathBuf;
269    ///
270    /// let config = SkillStoreConfig {
271    ///     skills_dir: PathBuf::from("./skills"),
272    ///     ..Default::default()
273    /// };
274    /// let store = SkillStore::new(config);
275    /// ```
276    pub fn new(config: SkillStoreConfig) -> Self {
277        Self {
278            skills: RwLock::new(HashMap::new()),
279            skill_roots: RwLock::new(HashMap::new()),
280            config,
281        }
282    }
283
284    /// Initialize the store, loading skills from disk.
285    ///
286    /// This method performs the following steps:
287    /// 1. Creates the skills directory if it doesn't exist.
288    /// 2. Syncs built-in skill bundles from compile-time embedded files (overwrites built-ins).
289    /// 3. Reloads all skills into memory after synchronization.
290    ///
291    /// # Returns
292    ///
293    /// `Ok(())` on successful initialization.
294    ///
295    /// # Errors
296    ///
297    /// Returns `SkillError` if:
298    /// - The skills directory cannot be created.
299    /// - Skill files cannot be read or parsed.
300    /// - Built-in skills cannot be written.
301    ///
302    /// # Example
303    ///
304    /// ```rust,ignore
305    /// let store = SkillStore::new(SkillStoreConfig::default());
306    /// store.initialize().await.expect("Failed to initialize");
307    /// ```
308    pub async fn initialize(&self) -> SkillResult<()> {
309        info!("Initializing skill store...");
310        ensure_skills_dir(&self.config.skills_dir).await?;
311        self.create_builtin_skills().await?;
312        self.load().await?;
313
314        info!("Skill store initialized");
315        Ok(())
316    }
317
318    /// Load skills from disk into the in-memory cache.
319    ///
320    /// Scans the skills directory for all `SKILL.md` files, parses them,
321    /// and loads them into the internal HashMap cache.
322    ///
323    /// # Returns
324    ///
325    /// The number of skills successfully loaded.
326    ///
327    /// # Errors
328    ///
329    /// Returns `SkillError` if the skills directory cannot be read.
330    async fn load(&self) -> SkillResult<usize> {
331        let (resolved_skills, resolved_roots) = self.resolve_skills_maps_for_mode(None).await?;
332        let count = resolved_skills.len();
333        let mut skills = self.skills.write().await;
334        let mut roots = self.skill_roots.write().await;
335        *skills = resolved_skills;
336        *roots = resolved_roots;
337
338        Ok(count)
339    }
340
341    /// Create built-in skills on disk.
342    ///
343    /// Generates default skills that ship with Bamboo (e.g., skill-creator).
344    /// For each built-in skill, this method:
345    /// 1. Loads built-in skill bundles from compile-time embedded files.
346    /// 2. Writes the skill definition to disk (overwriting previous built-in content).
347    /// 3. Writes bundled files (scripts/references/assets/agents/etc.) under each skill dir.
348    /// 4. Sets executable permissions on Unix systems.
349    ///
350    /// # Returns
351    ///
352    /// `Ok(())` on success.
353    ///
354    /// # Errors
355    ///
356    /// Returns `SkillError` if file operations fail.
357    async fn create_builtin_skills(&self) -> SkillResult<()> {
358        for bundle in load_builtin_skill_bundles()? {
359            let skill_id = bundle.skill.id.clone();
360            write_skill_file(&self.config.skills_dir, &bundle.skill).await?;
361
362            for (relative_path, content) in bundle.files {
363                let full_path = self.config.skills_dir.join(&skill_id).join(&relative_path);
364                if let Some(parent) = full_path.parent() {
365                    tokio::fs::create_dir_all(parent).await?;
366                }
367                tokio::fs::write(&full_path, content).await?;
368                // Make script files executable on Unix
369                #[cfg(unix)]
370                {
371                    if relative_path.starts_with("scripts/") {
372                        use std::os::unix::fs::PermissionsExt;
373                        let mut perms = tokio::fs::metadata(&full_path).await?.permissions();
374                        perms.set_mode(0o755);
375                        tokio::fs::set_permissions(&full_path, perms).await?;
376                    }
377                }
378            }
379        }
380
381        Ok(())
382    }
383
384    /// Reload skills from disk into the in-memory cache.
385    ///
386    /// This is useful when skills have been modified on disk and you want
387    /// to pick up the changes without restarting the application.
388    ///
389    /// # Returns
390    ///
391    /// The number of skills loaded.
392    ///
393    /// # Example
394    ///
395    /// ```rust,ignore
396    /// // After editing a skill file externally
397    /// let count = store.reload().await?;
398    /// println!("Loaded {} skills", count);
399    /// ```
400    pub async fn reload(&self) -> SkillResult<usize> {
401        info!("Reloading skills from disk...");
402        self.load().await
403    }
404
405    /// List all skills with optional filtering.
406    ///
407    /// Returns a sorted list of skills matching the specified filter criteria.
408    /// Optionally refreshes the cache from disk before listing.
409    ///
410    /// # Arguments
411    ///
412    /// * `filter` - Optional filter criteria.
413    /// * `refresh` - If true, reload skills from disk before listing.
414    ///
415    /// # Returns
416    ///
417    /// A vector of matching skills, sorted alphabetically by name.
418    ///
419    /// # Example
420    ///
421    /// ```rust,ignore
422    /// // List skills matching a search query, refreshing from disk
423    /// let filter = SkillFilter::new().with_search("dashboard");
424    /// let skills = store.list_skills(Some(filter), true).await;
425    /// ```
426    pub async fn list_skills(
427        &self,
428        filter: Option<SkillFilter>,
429        refresh: bool,
430    ) -> Vec<SkillDefinition> {
431        // Optionally reload from disk to pick up new/updated skills
432        if refresh {
433            if let Err(e) = self.reload().await {
434                tracing::warn!("Failed to reload skills: {}", e);
435            }
436        }
437
438        let skills = self.skills.read().await;
439
440        let mut result: Vec<SkillDefinition> = skills
441            .values()
442            .filter(|skill| match &filter {
443                Some(active_filter) => active_filter.matches(skill),
444                None => true,
445            })
446            .cloned()
447            .collect();
448
449        result.sort_by_key(|s| s.name.clone());
450        result
451    }
452
453    /// List skills with an optional mode override (without mutating in-memory cache).
454    pub async fn list_skills_for_mode(
455        &self,
456        filter: Option<SkillFilter>,
457        mode_override: Option<&str>,
458    ) -> Vec<SkillDefinition> {
459        let (skills, _) = match self.resolve_skills_maps_for_mode(mode_override).await {
460            Ok(maps) => maps,
461            Err(error) => {
462                tracing::warn!(
463                    "Failed to resolve skills for mode {:?}: {}",
464                    mode_override,
465                    error
466                );
467                return Vec::new();
468            }
469        };
470
471        let mut result: Vec<SkillDefinition> = skills
472            .values()
473            .filter(|skill| match &filter {
474                Some(active_filter) => active_filter.matches(skill),
475                None => true,
476            })
477            .cloned()
478            .collect();
479        result.sort_by_key(|s| s.name.clone());
480        result
481    }
482
483    /// Get a single skill by its ID.
484    ///
485    /// Retrieves a skill from the in-memory cache by its unique identifier.
486    ///
487    /// # Arguments
488    ///
489    /// * `id` - The skill ID (e.g., "skill-creator").
490    ///
491    /// # Returns
492    ///
493    /// The matching `SkillDefinition` if found.
494    ///
495    /// # Errors
496    ///
497    /// Returns `SkillError::NotFound` if no skill matches the given ID.
498    ///
499    /// # Example
500    ///
501    /// ```rust,ignore
502    /// let skill = store.get_skill("my-skill").await?;
503    /// println!("Description: {}", skill.description);
504    /// ```
505    pub async fn get_skill(&self, id: &str) -> SkillResult<SkillDefinition> {
506        let skills = self.skills.read().await;
507        skills
508            .get(id)
509            .cloned()
510            .ok_or_else(|| SkillError::NotFound(id.to_string()))
511    }
512
513    /// Get a skill by id with an optional mode override.
514    pub async fn get_skill_for_mode(
515        &self,
516        id: &str,
517        mode_override: Option<&str>,
518    ) -> SkillResult<SkillDefinition> {
519        if mode_override.is_none() {
520            return self.get_skill(id).await;
521        }
522
523        let (skills, _) = self.resolve_skills_maps_for_mode(mode_override).await?;
524        skills
525            .get(id)
526            .cloned()
527            .ok_or_else(|| SkillError::NotFound(id.to_string()))
528    }
529
530    /// Get the root directory path for a loaded skill.
531    pub async fn get_skill_root(&self, id: &str) -> SkillResult<PathBuf> {
532        let roots = self.skill_roots.read().await;
533        roots
534            .get(id)
535            .cloned()
536            .ok_or_else(|| SkillError::NotFound(id.to_string()))
537    }
538
539    /// Get the root directory path for a loaded skill with an optional mode override.
540    pub async fn get_skill_root_for_mode(
541        &self,
542        id: &str,
543        mode_override: Option<&str>,
544    ) -> SkillResult<PathBuf> {
545        if mode_override.is_none() {
546            return self.get_skill_root(id).await;
547        }
548
549        let (_, roots) = self.resolve_skills_maps_for_mode(mode_override).await?;
550        roots
551            .get(id)
552            .cloned()
553            .ok_or_else(|| SkillError::NotFound(id.to_string()))
554    }
555
556    /// Create a new skill (not supported - read-only mode).
557    ///
558    /// Skills must be created by writing Markdown files directly to the
559    /// skills directory. This method always returns an error.
560    ///
561    /// # Errors
562    ///
563    /// Always returns `SkillError::ReadOnly`.
564    pub async fn create_skill(&self, _skill: SkillDefinition) -> SkillResult<SkillDefinition> {
565        Err(SkillError::ReadOnly(
566            "Skills are read-only and must be edited as Markdown files".to_string(),
567        ))
568    }
569
570    /// Update an existing skill (not supported - read-only mode).
571    ///
572    /// Skills must be edited by modifying Markdown files directly.
573    /// This method always returns an error.
574    ///
575    /// # Errors
576    ///
577    /// Always returns `SkillError::ReadOnly`.
578    pub async fn update_skill(
579        &self,
580        _id: &str,
581        _updates: SkillUpdate,
582    ) -> SkillResult<SkillDefinition> {
583        Err(SkillError::ReadOnly(
584            "Skills are read-only and must be edited as Markdown files".to_string(),
585        ))
586    }
587
588    /// Delete a skill (not supported - read-only mode).
589    ///
590    /// Skills must be deleted by removing their Markdown files directly.
591    /// This method always returns an error.
592    ///
593    /// # Errors
594    ///
595    /// Always returns `SkillError::ReadOnly`.
596    pub async fn delete_skill(&self, _id: &str) -> SkillResult<()> {
597        Err(SkillError::ReadOnly(
598            "Skills are read-only and must be edited as Markdown files".to_string(),
599        ))
600    }
601
602    /// Enable a skill globally (not supported - read-only mode).
603    ///
604    /// Skill enablement is controlled outside this read-only store.
605    /// This method always returns an error.
606    ///
607    /// # Errors
608    ///
609    /// Always returns `SkillError::ReadOnly`.
610    pub async fn enable_skill_global(&self, _id: &str) -> SkillResult<()> {
611        Err(SkillError::ReadOnly(
612            "Skills are read-only and must be edited as Markdown files".to_string(),
613        ))
614    }
615
616    /// Disable a skill globally (not supported - read-only mode).
617    ///
618    /// Skill enablement is controlled outside this read-only store.
619    /// This method always returns an error.
620    ///
621    /// # Errors
622    ///
623    /// Always returns `SkillError::ReadOnly`.
624    pub async fn disable_skill_global(&self, _id: &str) -> SkillResult<()> {
625        Err(SkillError::ReadOnly(
626            "Skills are read-only and must be edited as Markdown files".to_string(),
627        ))
628    }
629
630    /// Enable a skill for a specific chat (not supported - read-only mode).
631    ///
632    /// Skill chat associations are managed externally, not through this API.
633    /// This method always returns an error.
634    ///
635    /// # Errors
636    ///
637    /// Always returns `SkillError::ReadOnly`.
638    pub async fn enable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
639        Err(SkillError::ReadOnly(
640            "Skills are read-only and must be edited as Markdown files".to_string(),
641        ))
642    }
643
644    /// Disable a skill for a specific chat (not supported - read-only mode).
645    ///
646    /// Skill chat associations are managed externally, not through this API.
647    /// This method always returns an error.
648    ///
649    /// # Errors
650    ///
651    /// Always returns `SkillError::ReadOnly`.
652    pub async fn disable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
653        Err(SkillError::ReadOnly(
654            "Skills are read-only and must be edited as Markdown files".to_string(),
655        ))
656    }
657
658    /// Get all skills from the cache.
659    ///
660    /// Returns all loaded skills, sorted alphabetically by name.
661    /// This is a convenience method equivalent to `list_skills(None, false)`.
662    ///
663    /// # Returns
664    ///
665    /// A vector of all skills in the store.
666    ///
667    /// # Example
668    ///
669    /// ```rust,ignore
670    /// let skills = store.get_all_skills().await;
671    /// println!("Total skills: {}", skills.len());
672    /// ```
673    pub async fn get_all_skills(&self) -> Vec<SkillDefinition> {
674        let mut skills: Vec<SkillDefinition> = self.skills.read().await.values().cloned().collect();
675        skills.sort_by_key(|s| s.name.clone());
676        skills
677    }
678
679    /// Get the path to the skills directory.
680    ///
681    /// Returns the configured directory where skill Markdown files are stored.
682    ///
683    /// # Returns
684    ///
685    /// Reference to the skills directory path.
686    pub fn skills_dir(&self) -> &PathBuf {
687        &self.config.skills_dir
688    }
689
690    /// Export skills to Markdown format.
691    ///
692    /// Renders one or more skills as Markdown documents with YAML frontmatter.
693    /// Useful for creating backups or sharing skills.
694    ///
695    /// # Arguments
696    ///
697    /// * `skill_ids` - Optional list of skill IDs to export.
698    ///   If `None`, exports all skills.
699    ///
700    /// # Returns
701    ///
702    /// A Markdown string containing all exported skills, separated by blank lines.
703    ///
704    /// # Errors
705    ///
706    /// Returns `SkillError` if Markdown rendering fails.
707    ///
708    /// # Example
709    ///
710    /// ```rust,ignore
711    /// // Export specific skills
712    /// let markdown = store.export_to_markdown(
713    ///     Some(vec!["skill-creator".to_string()])
714    /// ).await?;
715    /// println!("{}", markdown);
716    ///
717    /// // Export all skills
718    /// let all_markdown = store.export_to_markdown(None).await?;
719    /// ```
720    pub async fn export_to_markdown(&self, skill_ids: Option<Vec<String>>) -> SkillResult<String> {
721        let skills = self.skills.read().await;
722
723        let selected_skills: Vec<&SkillDefinition> = match skill_ids {
724            Some(ids) => ids.iter().filter_map(|id| skills.get(id)).collect(),
725            None => skills.values().collect(),
726        };
727
728        let mut chunks = Vec::new();
729        for skill in selected_skills {
730            chunks.push(render_skill_markdown(skill)?);
731        }
732
733        Ok(chunks.join("\n\n"))
734    }
735}
736
737impl Default for SkillStore {
738    fn default() -> Self {
739        Self::new(SkillStoreConfig::default())
740    }
741}
742
743/// Update fields for skill modification.
744///
745/// This struct is used to specify which fields of a skill should be updated.
746/// All fields are optional - only provided fields will be changed.
747///
748/// Note: This is currently not used as skills are read-only, but is kept
749/// for future API compatibility and documentation purposes.
750///
751/// # Example
752///
753/// ```ignore
754/// let update = SkillUpdate::new()
755///     .with_name("New Name")
756///     .with_description("Updated description")
757///     .with_tool_refs(vec!["read_file".to_string()]);
758/// ```
759#[derive(Debug, Clone, Default)]
760pub struct SkillUpdate {
761    /// New name for the skill.
762    pub name: Option<String>,
763
764    /// New description for the skill.
765    pub description: Option<String>,
766
767    /// New prompt template for the skill.
768    pub prompt: Option<String>,
769
770    /// New list of tool references for the skill.
771    pub tool_refs: Option<Vec<String>>,
772
773    /// New license for the skill.
774    pub license: Option<String>,
775
776    /// New compatibility notes for the skill.
777    pub compatibility: Option<String>,
778
779    /// New metadata payload for the skill.
780    pub metadata: Option<serde_json::Value>,
781}
782
783impl SkillUpdate {
784    /// Create a new empty update struct.
785    ///
786    /// All fields will be `None`, indicating no changes.
787    pub fn new() -> Self {
788        Self::default()
789    }
790
791    /// Set the name field.
792    ///
793    /// # Arguments
794    ///
795    /// * `name` - The new name for the skill.
796    pub fn with_name(mut self, name: impl Into<String>) -> Self {
797        self.name = Some(name.into());
798        self
799    }
800
801    /// Set the description field.
802    ///
803    /// # Arguments
804    ///
805    /// * `description` - The new description for the skill.
806    pub fn with_description(mut self, description: impl Into<String>) -> Self {
807        self.description = Some(description.into());
808        self
809    }
810
811    /// Set the prompt field.
812    ///
813    /// # Arguments
814    ///
815    /// * `prompt` - The new prompt template for the skill.
816    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
817        self.prompt = Some(prompt.into());
818        self
819    }
820
821    /// Set the tool references field.
822    ///
823    /// # Arguments
824    ///
825    /// * `tool_refs` - The new list of tool references for the skill.
826    pub fn with_tool_refs(mut self, tool_refs: Vec<String>) -> Self {
827        self.tool_refs = Some(tool_refs);
828        self
829    }
830
831    /// Set the license field.
832    ///
833    /// # Arguments
834    ///
835    /// * `license` - The new license string for the skill.
836    pub fn with_license(mut self, license: impl Into<String>) -> Self {
837        self.license = Some(license.into());
838        self
839    }
840
841    /// Set the compatibility field.
842    ///
843    /// # Arguments
844    ///
845    /// * `compatibility` - The new compatibility notes for the skill.
846    pub fn with_compatibility(mut self, compatibility: impl Into<String>) -> Self {
847        self.compatibility = Some(compatibility.into());
848        self
849    }
850
851    /// Set the metadata field.
852    ///
853    /// # Arguments
854    ///
855    /// * `metadata` - The new metadata payload for the skill.
856    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
857        self.metadata = Some(metadata);
858        self
859    }
860}
861
862#[cfg(test)]
863mod tests {
864    use std::path::{Path, PathBuf};
865
866    use tokio::fs;
867
868    use super::SkillStore;
869    use crate::skills::types::SkillStoreConfig;
870
871    async fn write_skill(
872        skills_root: &Path,
873        id: &str,
874        description: &str,
875        prompt: &str,
876    ) -> std::io::Result<PathBuf> {
877        let skill_dir = skills_root.join(id);
878        fs::create_dir_all(&skill_dir).await?;
879        let skill_file = skill_dir.join("SKILL.md");
880        let content = format!(
881            "---\nname: {id}\ndescription: {description}\n---\n{prompt}\n",
882            id = id,
883            description = description,
884            prompt = prompt
885        );
886        fs::write(&skill_file, content).await?;
887        Ok(skill_dir)
888    }
889
890    #[tokio::test]
891    async fn load_markdown_skills() {
892        let directory = tempfile::tempdir().expect("tempdir");
893        let skills_dir = directory.path().join("skills");
894        fs::create_dir_all(&skills_dir).await.expect("create dir");
895
896        let content = r#"---
897name: test-skill
898description: A test skill
899allowed-tools:
900  - read_file
901---
902Use this skill for testing.
903"#;
904
905        let skill_dir = skills_dir.join("test-skill");
906        fs::create_dir_all(&skill_dir)
907            .await
908            .expect("create skill dir");
909        let skill_file = skill_dir.join("SKILL.md");
910        fs::write(&skill_file, content).await.expect("write");
911
912        let config = SkillStoreConfig {
913            skills_dir,
914            ..Default::default()
915        };
916        let store = SkillStore::new(config);
917        store.initialize().await.expect("initialize");
918
919        let skills = store.list_skills(None, false).await;
920        assert!(skills.iter().any(|skill| skill.id == "test-skill"));
921        assert!(skills.iter().any(|skill| skill.id == "skill-creator"));
922    }
923
924    #[tokio::test]
925    async fn create_builtin_skills_when_empty() {
926        let directory = tempfile::tempdir().expect("tempdir");
927        let config = SkillStoreConfig {
928            skills_dir: directory.path().join("skills"),
929            ..Default::default()
930        };
931        let store = SkillStore::new(config);
932        store.initialize().await.expect("initialize");
933
934        let skills = store.list_skills(None, false).await;
935        assert!(skills.iter().any(|skill| skill.id == "skill-creator"));
936    }
937
938    #[tokio::test]
939    async fn project_skill_overrides_global_skill() {
940        let directory = tempfile::tempdir().expect("tempdir");
941        let data_dir = directory.path().join("data");
942        let workspace_dir = directory.path().join("workspace");
943        let global_skills_dir = data_dir.join("skills");
944        let project_skills_dir = workspace_dir.join(".bamboo").join("skills");
945
946        fs::create_dir_all(&global_skills_dir)
947            .await
948            .expect("create global skills dir");
949        fs::create_dir_all(&project_skills_dir)
950            .await
951            .expect("create project skills dir");
952
953        write_skill(
954            &global_skills_dir,
955            "override-skill",
956            "global version",
957            "Global prompt",
958        )
959        .await
960        .expect("write global skill");
961        let project_skill_root = write_skill(
962            &project_skills_dir,
963            "override-skill",
964            "project version",
965            "Project prompt",
966        )
967        .await
968        .expect("write project skill");
969
970        let config = SkillStoreConfig {
971            skills_dir: global_skills_dir,
972            project_dir: Some(workspace_dir),
973            active_mode: None,
974        };
975        let store = SkillStore::new(config);
976        store.initialize().await.expect("initialize");
977
978        let skill = store
979            .get_skill("override-skill")
980            .await
981            .expect("override skill must exist");
982        assert_eq!(skill.description, "project version");
983
984        let resolved_root = store
985            .get_skill_root("override-skill")
986            .await
987            .expect("skill root");
988        let resolved_root = fs::canonicalize(resolved_root)
989            .await
990            .expect("canonical resolved root");
991        let expected_root = fs::canonicalize(project_skill_root)
992            .await
993            .expect("canonical expected root");
994        assert_eq!(resolved_root, expected_root);
995    }
996
997    #[tokio::test]
998    async fn mode_specific_skill_overrides_generic_for_same_source() {
999        let directory = tempfile::tempdir().expect("tempdir");
1000        let data_dir = directory.path().join("data");
1001        let global_skills_dir = data_dir.join("skills");
1002        let global_mode_skills_dir = data_dir.join("skills-code");
1003
1004        fs::create_dir_all(&global_skills_dir)
1005            .await
1006            .expect("create global skills dir");
1007        fs::create_dir_all(&global_mode_skills_dir)
1008            .await
1009            .expect("create global mode skills dir");
1010
1011        write_skill(
1012            &global_skills_dir,
1013            "mode-target-skill",
1014            "generic version",
1015            "Generic prompt",
1016        )
1017        .await
1018        .expect("write generic skill");
1019        write_skill(
1020            &global_mode_skills_dir,
1021            "mode-target-skill",
1022            "mode version",
1023            "Mode prompt",
1024        )
1025        .await
1026        .expect("write mode skill");
1027
1028        let config = SkillStoreConfig {
1029            skills_dir: global_skills_dir,
1030            project_dir: None,
1031            active_mode: Some("code".to_string()),
1032        };
1033        let store = SkillStore::new(config);
1034        store.initialize().await.expect("initialize");
1035
1036        let skill = store
1037            .get_skill("mode-target-skill")
1038            .await
1039            .expect("mode-target-skill must exist");
1040        assert_eq!(skill.description, "mode version");
1041    }
1042
1043    #[tokio::test]
1044    async fn mode_specific_skill_is_ignored_without_active_mode() {
1045        let directory = tempfile::tempdir().expect("tempdir");
1046        let data_dir = directory.path().join("data");
1047        let global_skills_dir = data_dir.join("skills");
1048        let global_mode_skills_dir = data_dir.join("skills-code");
1049
1050        fs::create_dir_all(&global_skills_dir)
1051            .await
1052            .expect("create global skills dir");
1053        fs::create_dir_all(&global_mode_skills_dir)
1054            .await
1055            .expect("create global mode skills dir");
1056
1057        write_skill(
1058            &global_skills_dir,
1059            "mode-target-skill",
1060            "generic version",
1061            "Generic prompt",
1062        )
1063        .await
1064        .expect("write generic skill");
1065        write_skill(
1066            &global_mode_skills_dir,
1067            "mode-target-skill",
1068            "mode version",
1069            "Mode prompt",
1070        )
1071        .await
1072        .expect("write mode skill");
1073
1074        let config = SkillStoreConfig {
1075            skills_dir: global_skills_dir,
1076            project_dir: None,
1077            active_mode: None,
1078        };
1079        let store = SkillStore::new(config);
1080        store.initialize().await.expect("initialize");
1081
1082        let skill = store
1083            .get_skill("mode-target-skill")
1084            .await
1085            .expect("mode-target-skill must exist");
1086        assert_eq!(skill.description, "generic version");
1087    }
1088
1089    #[tokio::test]
1090    async fn get_skill_for_mode_overrides_cached_generic_selection() {
1091        let directory = tempfile::tempdir().expect("tempdir");
1092        let data_dir = directory.path().join("data");
1093        let global_skills_dir = data_dir.join("skills");
1094        let global_mode_skills_dir = data_dir.join("skills-code");
1095
1096        fs::create_dir_all(&global_skills_dir)
1097            .await
1098            .expect("create global skills dir");
1099        fs::create_dir_all(&global_mode_skills_dir)
1100            .await
1101            .expect("create global mode skills dir");
1102
1103        write_skill(
1104            &global_skills_dir,
1105            "mode-target-skill",
1106            "generic version",
1107            "Generic prompt",
1108        )
1109        .await
1110        .expect("write generic skill");
1111        write_skill(
1112            &global_mode_skills_dir,
1113            "mode-target-skill",
1114            "mode version",
1115            "Mode prompt",
1116        )
1117        .await
1118        .expect("write mode skill");
1119
1120        let config = SkillStoreConfig {
1121            skills_dir: global_skills_dir,
1122            project_dir: None,
1123            active_mode: None,
1124        };
1125        let store = SkillStore::new(config);
1126        store.initialize().await.expect("initialize");
1127
1128        // Cached default view stays generic because no active_mode is configured.
1129        let generic = store
1130            .get_skill("mode-target-skill")
1131            .await
1132            .expect("generic skill exists");
1133        assert_eq!(generic.description, "generic version");
1134
1135        // Per-call mode override should resolve the mode-specific variant.
1136        let mode_specific = store
1137            .get_skill_for_mode("mode-target-skill", Some("code"))
1138            .await
1139            .expect("mode-specific skill exists");
1140        assert_eq!(mode_specific.description, "mode version");
1141    }
1142}