Skip to main content

bamboo_agent/agent/skill/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//! ```no_run
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//!     };
37//!
38//!     let store = SkillStore::new(config);
39//!     store.initialize().await.expect("Failed to initialize");
40//!
41//!     // List all skills
42//!     let skills = store.list_skills(None, false).await;
43//!     for skill in skills {
44//!         println!("{}: {}", skill.name, skill.description);
45//!     }
46//! }
47//! ```
48
49pub mod builtin;
50pub mod parser;
51pub mod storage;
52
53use std::collections::HashMap;
54use std::path::PathBuf;
55
56use log::info;
57use tokio::sync::RwLock;
58
59use crate::agent::skill::store::builtin::{create_builtin_skills, get_builtin_scripts};
60use crate::agent::skill::store::parser::render_skill_markdown;
61use crate::agent::skill::store::storage::{
62    ensure_skills_dir, load_skills_from_dir, skill_path, write_skill_file,
63};
64use crate::agent::skill::types::{
65    SkillDefinition, SkillError, SkillFilter, SkillId, SkillResult, SkillStoreConfig,
66    SkillVisibility,
67};
68
69/// Persistent storage for skills with in-memory caching.
70///
71/// Manages a collection of skills loaded from Markdown files on disk.
72/// Uses a `RwLock<HashMap>` for thread-safe concurrent access.
73///
74/// # Thread Safety
75///
76/// All operations use async/await with `RwLock` to allow multiple readers
77/// or a single writer, ensuring safe concurrent access from multiple tasks.
78///
79/// # Example
80///
81/// ```rust,ignore
82/// let store = SkillStore::new(SkillStoreConfig::default());
83/// store.initialize().await?;
84///
85/// // Get a specific skill
86/// let skill = store.get_skill("my-skill").await?;
87/// println!("Skill: {}", skill.name);
88/// ```
89pub struct SkillStore {
90    /// In-memory cache of loaded skills, keyed by skill ID.
91    skills: RwLock<HashMap<SkillId, SkillDefinition>>,
92
93    /// Configuration specifying the skills directory path.
94    config: SkillStoreConfig,
95}
96
97impl SkillStore {
98    /// Create a new skill store with the given configuration.
99    ///
100    /// The store is created empty and must be initialized using [`initialize`](Self::initialize)
101    /// before it can be used.
102    ///
103    /// # Arguments
104    ///
105    /// * `config` - Configuration specifying the skills directory path.
106    ///
107    /// # Example
108    ///
109    /// ```no_run
110    /// use bamboo_agent::skill::{SkillStore, SkillStoreConfig};
111    /// use std::path::PathBuf;
112    ///
113    /// let config = SkillStoreConfig {
114    ///     skills_dir: PathBuf::from("./skills"),
115    /// };
116    /// let store = SkillStore::new(config);
117    /// ```
118    pub fn new(config: SkillStoreConfig) -> Self {
119        Self {
120            skills: RwLock::new(HashMap::new()),
121            config,
122        }
123    }
124
125    /// Initialize the store, loading skills from disk.
126    ///
127    /// This method performs the following steps:
128    /// 1. Creates the skills directory if it doesn't exist.
129    /// 2. Attempts to load existing skills from disk.
130    /// 3. Creates built-in skills if no existing skills are found.
131    /// 4. Reloads skills into memory after initialization.
132    ///
133    /// # Returns
134    ///
135    /// `Ok(())` on successful initialization.
136    ///
137    /// # Errors
138    ///
139    /// Returns `SkillError` if:
140    /// - The skills directory cannot be created.
141    /// - Skill files cannot be read or parsed.
142    /// - Built-in skills cannot be written.
143    ///
144    /// # Example
145    ///
146    /// ```rust,ignore
147    /// let store = SkillStore::new(SkillStoreConfig::default());
148    /// store.initialize().await.expect("Failed to initialize");
149    /// ```
150    pub async fn initialize(&self) -> SkillResult<()> {
151        info!("Initializing skill store...");
152        ensure_skills_dir(&self.config.skills_dir).await?;
153
154        let loaded = self.load().await?;
155        if loaded == 0 {
156            info!("No existing skills found, creating built-in skills");
157            self.create_builtin_skills().await?;
158            self.load().await?;
159        }
160
161        info!("Skill store initialized");
162        Ok(())
163    }
164
165    /// Load skills from disk into the in-memory cache.
166    ///
167    /// Scans the skills directory for all `SKILL.md` files, parses them,
168    /// and loads them into the internal HashMap cache.
169    ///
170    /// # Returns
171    ///
172    /// The number of skills successfully loaded.
173    ///
174    /// # Errors
175    ///
176    /// Returns `SkillError` if the skills directory cannot be read.
177    async fn load(&self) -> SkillResult<usize> {
178        let loaded = load_skills_from_dir(&self.config.skills_dir).await?;
179        let count = loaded.len();
180
181        let mut skills = self.skills.write().await;
182        *skills = loaded;
183
184        Ok(count)
185    }
186
187    /// Create built-in skills on disk.
188    ///
189    /// Generates default skills that ship with Bamboo (e.g., skill-creator).
190    /// For each built-in skill, this method:
191    /// 1. Checks if the skill already exists (skips if so).
192    /// 2. Writes the skill definition to disk.
193    /// 3. Extracts and writes any embedded scripts.
194    /// 4. Sets executable permissions on Unix systems.
195    ///
196    /// # Returns
197    ///
198    /// `Ok(())` on success.
199    ///
200    /// # Errors
201    ///
202    /// Returns `SkillError` if file operations fail.
203    async fn create_builtin_skills(&self) -> SkillResult<()> {
204        for skill in create_builtin_skills() {
205            let path = skill_path(&self.config.skills_dir, &skill.id);
206            if path.exists() {
207                continue;
208            }
209            write_skill_file(&self.config.skills_dir, &skill).await?;
210
211            // Write embedded scripts for builtin skills (e.g., skill-creator)
212            let scripts = get_builtin_scripts(&skill.id);
213            for (script_path, content) in scripts {
214                let full_path = self.config.skills_dir.join(&skill.id).join(script_path);
215                if let Some(parent) = full_path.parent() {
216                    tokio::fs::create_dir_all(parent).await?;
217                }
218                tokio::fs::write(&full_path, content).await?;
219                // Make scripts executable on Unix
220                #[cfg(unix)]
221                {
222                    use std::os::unix::fs::PermissionsExt;
223                    let mut perms = tokio::fs::metadata(&full_path).await?.permissions();
224                    perms.set_mode(0o755);
225                    tokio::fs::set_permissions(&full_path, perms).await?;
226                }
227            }
228        }
229
230        Ok(())
231    }
232
233    /// Reload skills from disk into the in-memory cache.
234    ///
235    /// This is useful when skills have been modified on disk and you want
236    /// to pick up the changes without restarting the application.
237    ///
238    /// # Returns
239    ///
240    /// The number of skills loaded.
241    ///
242    /// # Example
243    ///
244    /// ```rust,ignore
245    /// // After editing a skill file externally
246    /// let count = store.reload().await?;
247    /// println!("Loaded {} skills", count);
248    /// ```
249    pub async fn reload(&self) -> SkillResult<usize> {
250        info!("Reloading skills from disk...");
251        self.load().await
252    }
253
254    /// List all skills with optional filtering.
255    ///
256    /// Returns a sorted list of skills matching the specified filter criteria.
257    /// Optionally refreshes the cache from disk before listing.
258    ///
259    /// # Arguments
260    ///
261    /// * `filter` - Optional filter criteria (by category, tags, visibility, etc.).
262    /// * `refresh` - If true, reload skills from disk before listing.
263    ///
264    /// # Returns
265    ///
266    /// A vector of matching skills, sorted alphabetically by name.
267    ///
268    /// # Example
269    ///
270    /// ```rust,ignore
271    /// // List all public skills, refreshing from disk
272    /// let filter = SkillFilter {
273    ///     visibility: Some(SkillVisibility::Public),
274    ///     ..Default::default()
275    /// };
276    /// let skills = store.list_skills(Some(filter), true).await;
277    /// ```
278    pub async fn list_skills(
279        &self,
280        filter: Option<SkillFilter>,
281        refresh: bool,
282    ) -> Vec<SkillDefinition> {
283        // Optionally reload from disk to pick up new/updated skills
284        if refresh {
285            if let Err(e) = self.reload().await {
286                log::warn!("Failed to reload skills: {}", e);
287            }
288        }
289
290        let skills = self.skills.read().await;
291
292        let mut result: Vec<SkillDefinition> = skills
293            .values()
294            .filter(|skill| match &filter {
295                Some(active_filter) => active_filter.matches(skill),
296                None => true,
297            })
298            .cloned()
299            .collect();
300
301        result.sort_by(|left, right| left.name.cmp(&right.name));
302        result
303    }
304
305    /// Get a single skill by its ID.
306    ///
307    /// Retrieves a skill from the in-memory cache by its unique identifier.
308    ///
309    /// # Arguments
310    ///
311    /// * `id` - The skill ID (e.g., "skill-creator").
312    ///
313    /// # Returns
314    ///
315    /// The matching `SkillDefinition` if found.
316    ///
317    /// # Errors
318    ///
319    /// Returns `SkillError::NotFound` if no skill matches the given ID.
320    ///
321    /// # Example
322    ///
323    /// ```rust,ignore
324    /// let skill = store.get_skill("my-skill").await?;
325    /// println!("Description: {}", skill.description);
326    /// ```
327    pub async fn get_skill(&self, id: &str) -> SkillResult<SkillDefinition> {
328        let skills = self.skills.read().await;
329        skills
330            .get(id)
331            .cloned()
332            .ok_or_else(|| SkillError::NotFound(id.to_string()))
333    }
334
335    /// Create a new skill (not supported - read-only mode).
336    ///
337    /// Skills must be created by writing Markdown files directly to the
338    /// skills directory. This method always returns an error.
339    ///
340    /// # Errors
341    ///
342    /// Always returns `SkillError::ReadOnly`.
343    pub async fn create_skill(&self, _skill: SkillDefinition) -> SkillResult<SkillDefinition> {
344        Err(SkillError::ReadOnly(
345            "Skills are read-only and must be edited as Markdown files".to_string(),
346        ))
347    }
348
349    /// Update an existing skill (not supported - read-only mode).
350    ///
351    /// Skills must be edited by modifying Markdown files directly.
352    /// This method always returns an error.
353    ///
354    /// # Errors
355    ///
356    /// Always returns `SkillError::ReadOnly`.
357    pub async fn update_skill(
358        &self,
359        _id: &str,
360        _updates: SkillUpdate,
361    ) -> SkillResult<SkillDefinition> {
362        Err(SkillError::ReadOnly(
363            "Skills are read-only and must be edited as Markdown files".to_string(),
364        ))
365    }
366
367    /// Delete a skill (not supported - read-only mode).
368    ///
369    /// Skills must be deleted by removing their Markdown files directly.
370    /// This method always returns an error.
371    ///
372    /// # Errors
373    ///
374    /// Always returns `SkillError::ReadOnly`.
375    pub async fn delete_skill(&self, _id: &str) -> SkillResult<()> {
376        Err(SkillError::ReadOnly(
377            "Skills are read-only and must be edited as Markdown files".to_string(),
378        ))
379    }
380
381    /// Enable a skill globally (not supported - read-only mode).
382    ///
383    /// Skill visibility must be configured in the Markdown file's frontmatter.
384    /// This method always returns an error.
385    ///
386    /// # Errors
387    ///
388    /// Always returns `SkillError::ReadOnly`.
389    pub async fn enable_skill_global(&self, _id: &str) -> SkillResult<()> {
390        Err(SkillError::ReadOnly(
391            "Skills are read-only and must be edited as Markdown files".to_string(),
392        ))
393    }
394
395    /// Disable a skill globally (not supported - read-only mode).
396    ///
397    /// Skill visibility must be configured in the Markdown file's frontmatter.
398    /// This method always returns an error.
399    ///
400    /// # Errors
401    ///
402    /// Always returns `SkillError::ReadOnly`.
403    pub async fn disable_skill_global(&self, _id: &str) -> SkillResult<()> {
404        Err(SkillError::ReadOnly(
405            "Skills are read-only and must be edited as Markdown files".to_string(),
406        ))
407    }
408
409    /// Enable a skill for a specific chat (not supported - read-only mode).
410    ///
411    /// Skill chat associations are managed externally, not through this API.
412    /// This method always returns an error.
413    ///
414    /// # Errors
415    ///
416    /// Always returns `SkillError::ReadOnly`.
417    pub async fn enable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
418        Err(SkillError::ReadOnly(
419            "Skills are read-only and must be edited as Markdown files".to_string(),
420        ))
421    }
422
423    /// Disable a skill for a specific chat (not supported - read-only mode).
424    ///
425    /// Skill chat associations are managed externally, not through this API.
426    /// This method always returns an error.
427    ///
428    /// # Errors
429    ///
430    /// Always returns `SkillError::ReadOnly`.
431    pub async fn disable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
432        Err(SkillError::ReadOnly(
433            "Skills are read-only and must be edited as Markdown files".to_string(),
434        ))
435    }
436
437    /// Get all skills from the cache.
438    ///
439    /// Returns all loaded skills, sorted alphabetically by name.
440    /// This is a convenience method equivalent to `list_skills(None, false)`.
441    ///
442    /// # Returns
443    ///
444    /// A vector of all skills in the store.
445    ///
446    /// # Example
447    ///
448    /// ```rust,ignore
449    /// let skills = store.get_all_skills().await;
450    /// println!("Total skills: {}", skills.len());
451    /// ```
452    pub async fn get_all_skills(&self) -> Vec<SkillDefinition> {
453        let mut skills: Vec<SkillDefinition> = self.skills.read().await.values().cloned().collect();
454        skills.sort_by(|left, right| left.name.cmp(&right.name));
455        skills
456    }
457
458    /// Get the path to the skills directory.
459    ///
460    /// Returns the configured directory where skill Markdown files are stored.
461    ///
462    /// # Returns
463    ///
464    /// Reference to the skills directory path.
465    pub fn skills_dir(&self) -> &PathBuf {
466        &self.config.skills_dir
467    }
468
469    /// Get all unique categories across all skills.
470    ///
471    /// Scans all loaded skills and extracts their category values,
472    /// returning a deduplicated and sorted list.
473    ///
474    /// # Returns
475    ///
476    /// A sorted vector of unique category names.
477    ///
478    /// # Example
479    ///
480    /// ```rust,ignore
481    /// let categories = store.get_categories().await;
482    /// for category in categories {
483    ///     println!("Category: {}", category);
484    /// }
485    /// ```
486    pub async fn get_categories(&self) -> Vec<String> {
487        let mut categories: Vec<String> = self
488            .skills
489            .read()
490            .await
491            .values()
492            .map(|skill| skill.category.clone())
493            .collect::<std::collections::HashSet<_>>()
494            .into_iter()
495            .collect();
496        categories.sort();
497        categories
498    }
499
500    /// Get all unique tags across all skills.
501    ///
502    /// Scans all loaded skills and aggregates their tags,
503    /// returning a deduplicated and sorted list.
504    ///
505    /// # Returns
506    ///
507    /// A sorted vector of unique tag names.
508    ///
509    /// # Example
510    ///
511    /// ```rust,ignore
512    /// let tags = store.get_all_tags().await;
513    /// println!("Available tags: {:?}", tags);
514    /// ```
515    pub async fn get_all_tags(&self) -> Vec<String> {
516        let mut tags: Vec<String> = self
517            .skills
518            .read()
519            .await
520            .values()
521            .flat_map(|skill| skill.tags.clone())
522            .collect::<std::collections::HashSet<_>>()
523            .into_iter()
524            .collect();
525        tags.sort();
526        tags
527    }
528
529    /// Export skills to Markdown format.
530    ///
531    /// Renders one or more skills as Markdown documents with YAML frontmatter.
532    /// Useful for creating backups or sharing skills.
533    ///
534    /// # Arguments
535    ///
536    /// * `skill_ids` - Optional list of skill IDs to export.
537    ///   If `None`, exports all skills.
538    ///
539    /// # Returns
540    ///
541    /// A Markdown string containing all exported skills, separated by blank lines.
542    ///
543    /// # Errors
544    ///
545    /// Returns `SkillError` if Markdown rendering fails.
546    ///
547    /// # Example
548    ///
549    /// ```rust,ignore
550    /// // Export specific skills
551    /// let markdown = store.export_to_markdown(
552    ///     Some(vec!["skill-creator".to_string()])
553    /// ).await?;
554    /// println!("{}", markdown);
555    ///
556    /// // Export all skills
557    /// let all_markdown = store.export_to_markdown(None).await?;
558    /// ```
559    pub async fn export_to_markdown(&self, skill_ids: Option<Vec<String>>) -> SkillResult<String> {
560        let skills = self.skills.read().await;
561
562        let selected_skills: Vec<&SkillDefinition> = match skill_ids {
563            Some(ids) => ids.iter().filter_map(|id| skills.get(id)).collect(),
564            None => skills.values().collect(),
565        };
566
567        let mut chunks = Vec::new();
568        for skill in selected_skills {
569            chunks.push(render_skill_markdown(skill)?);
570        }
571
572        Ok(chunks.join("\n\n"))
573    }
574}
575
576impl Default for SkillStore {
577    fn default() -> Self {
578        Self::new(SkillStoreConfig::default())
579    }
580}
581
582/// Update fields for skill modification.
583///
584/// This struct is used to specify which fields of a skill should be updated.
585/// All fields are optional - only provided fields will be changed.
586///
587/// Note: This is currently not used as skills are read-only, but is kept
588/// for future API compatibility and documentation purposes.
589///
590/// # Example
591///
592/// ```ignore
593/// let update = SkillUpdate::new()
594///     .with_name("New Name")
595///     .with_description("Updated description")
596///     .with_tags(vec!["new-tag".to_string()]);
597/// ```
598#[derive(Debug, Clone, Default)]
599pub struct SkillUpdate {
600    /// New name for the skill.
601    pub name: Option<String>,
602
603    /// New description for the skill.
604    pub description: Option<String>,
605
606    /// New category for the skill.
607    pub category: Option<String>,
608
609    /// New list of tags for the skill.
610    pub tags: Option<Vec<String>>,
611
612    /// New prompt template for the skill.
613    pub prompt: Option<String>,
614
615    /// New list of tool references for the skill.
616    pub tool_refs: Option<Vec<String>>,
617
618    /// New list of workflow references for the skill.
619    pub workflow_refs: Option<Vec<String>>,
620
621    /// New visibility setting for the skill.
622    pub visibility: Option<SkillVisibility>,
623
624    /// New version string for the skill.
625    pub version: Option<String>,
626}
627
628impl SkillUpdate {
629    /// Create a new empty update struct.
630    ///
631    /// All fields will be `None`, indicating no changes.
632    pub fn new() -> Self {
633        Self::default()
634    }
635
636    /// Set the name field.
637    ///
638    /// # Arguments
639    ///
640    /// * `name` - The new name for the skill.
641    pub fn with_name(mut self, name: impl Into<String>) -> Self {
642        self.name = Some(name.into());
643        self
644    }
645
646    /// Set the description field.
647    ///
648    /// # Arguments
649    ///
650    /// * `description` - The new description for the skill.
651    pub fn with_description(mut self, description: impl Into<String>) -> Self {
652        self.description = Some(description.into());
653        self
654    }
655
656    /// Set the category field.
657    ///
658    /// # Arguments
659    ///
660    /// * `category` - The new category for the skill.
661    pub fn with_category(mut self, category: impl Into<String>) -> Self {
662        self.category = Some(category.into());
663        self
664    }
665
666    /// Set the tags field.
667    ///
668    /// # Arguments
669    ///
670    /// * `tags` - The new list of tags for the skill.
671    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
672        self.tags = Some(tags);
673        self
674    }
675
676    /// Set the prompt field.
677    ///
678    /// # Arguments
679    ///
680    /// * `prompt` - The new prompt template for the skill.
681    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
682        self.prompt = Some(prompt.into());
683        self
684    }
685
686    /// Set the tool references field.
687    ///
688    /// # Arguments
689    ///
690    /// * `tool_refs` - The new list of tool references for the skill.
691    pub fn with_tool_refs(mut self, tool_refs: Vec<String>) -> Self {
692        self.tool_refs = Some(tool_refs);
693        self
694    }
695
696    /// Set the workflow references field.
697    ///
698    /// # Arguments
699    ///
700    /// * `workflow_refs` - The new list of workflow references for the skill.
701    pub fn with_workflow_refs(mut self, workflow_refs: Vec<String>) -> Self {
702        self.workflow_refs = Some(workflow_refs);
703        self
704    }
705
706    /// Set the visibility field.
707    ///
708    /// # Arguments
709    ///
710    /// * `visibility` - The new visibility setting for the skill.
711    pub fn with_visibility(mut self, visibility: SkillVisibility) -> Self {
712        self.visibility = Some(visibility);
713        self
714    }
715
716    /// Set the version field.
717    ///
718    /// # Arguments
719    ///
720    /// * `version` - The new version string for the skill.
721    pub fn with_version(mut self, version: impl Into<String>) -> Self {
722        self.version = Some(version.into());
723        self
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use tokio::fs;
730
731    use super::SkillStore;
732    use crate::agent::skill::types::SkillStoreConfig;
733
734    #[tokio::test]
735    async fn load_markdown_skills() {
736        let directory = tempfile::tempdir().expect("tempdir");
737        let skills_dir = directory.path().join("skills");
738        fs::create_dir_all(&skills_dir).await.expect("create dir");
739
740        let content = r#"---
741id: test-skill
742name: Test Skill
743description: A test skill
744category: test
745tags:
746  - demo
747tool_refs:
748  - read_file
749workflow_refs: []
750visibility: public
751version: 1.0.0
752created_at: "2026-02-01T00:00:00Z"
753updated_at: "2026-02-01T00:00:00Z"
754---
755Use this skill for testing.
756"#;
757
758        let skill_dir = skills_dir.join("test-skill");
759        fs::create_dir_all(&skill_dir)
760            .await
761            .expect("create skill dir");
762        let skill_file = skill_dir.join("SKILL.md");
763        fs::write(&skill_file, content).await.expect("write");
764
765        let config = SkillStoreConfig { skills_dir };
766        let store = SkillStore::new(config);
767        store.initialize().await.expect("initialize");
768
769        let skills = store.list_skills(None, false).await;
770        assert_eq!(skills.len(), 1);
771        assert_eq!(skills[0].id, "test-skill");
772    }
773
774    #[tokio::test]
775    async fn create_builtin_skills_when_empty() {
776        let directory = tempfile::tempdir().expect("tempdir");
777        let config = SkillStoreConfig {
778            skills_dir: directory.path().join("skills"),
779        };
780        let store = SkillStore::new(config);
781        store.initialize().await.expect("initialize");
782
783        let skills = store.list_skills(None, false).await;
784        assert!(!skills.is_empty());
785    }
786}