From a39093d2222c6e3a24c61ad07a13855e8974da0d Mon Sep 17 00:00:00 2001 From: Gav Verma Date: Mon, 24 Nov 2025 23:52:58 -0800 Subject: [PATCH] Introduce in-memory model for Skills --- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/skills/mod.rs | 1 + codex-rs/core/src/skills/model.rs | 160 ++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 codex-rs/core/src/skills/mod.rs create mode 100644 codex-rs/core/src/skills/model.rs diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 805943a2e7..bc4fa46aea 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -72,6 +72,7 @@ mod rollout; pub(crate) mod safety; pub mod seatbelt; pub mod shell; +pub mod skills; pub mod spawn; pub mod terminal; mod tools; diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs new file mode 100644 index 0000000000..65880be0e3 --- /dev/null +++ b/codex-rs/core/src/skills/mod.rs @@ -0,0 +1 @@ +pub mod model; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs new file mode 100644 index 0000000000..b79ba7ec16 --- /dev/null +++ b/codex-rs/core/src/skills/model.rs @@ -0,0 +1,160 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +/// Origin of a skill, or the location it was loaded from. +/// - Public: fetched from the global, public location. +/// - Private: fetched from the current session's private location. +/// - Byos: Bring Your Own Skills; untracked, local folder attached by user using prompt. +/// +/// TODO: In v0, all entries from `~/.codex/.skills` are treated as `Public`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillScope { + Public, + Private, + Byos, +} + +/// In-memory representation of a single skill loaded from disk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Skill { + pub scope: SkillScope, + pub name: String, + pub description: String, + pub tags: Vec, + pub path: PathBuf, + pub content: String, + pub license: Option, + pub version: Option, +} + +impl Skill { + /// Deterministic identifier combining scope, name, and normalized path. + /// Format: `::` where path uses forward slashes. + pub fn id(&self) -> String { + let scope = match self.scope { + SkillScope::Public => "public", + SkillScope::Private => "private", + SkillScope::Byos => "byos", + }; + let path_str = self.path.to_string_lossy().replace('\\', "/"); + format!("{scope}:{}:{path_str}", self.name) + } +} + +/// In-memory representation of a collection of skills available to the session. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SkillCatalog { + pub skills: HashMap, +} + +#[cfg(test)] +mod tests { + use super::Skill; + use super::SkillCatalog; + use super::SkillScope; + use std::collections::HashMap; + use std::path::PathBuf; + + #[test] + fn constructs_skill_and_catalog() { + let skill = Skill { + scope: SkillScope::Public, + name: "example-skill".to_string(), + description: "Demo skill for testing".to_string(), + tags: vec!["demo".to_string(), "testing".to_string()], + path: PathBuf::from("/tmp/skills/example-skill"), + content: "# Example Skill\n\nBody.".to_string(), + license: Some("Apache-2.0".to_string()), + version: Some("abc123".to_string()), + }; + + let id = skill.id(); + let mut skills = HashMap::new(); + skills.insert(id.clone(), skill); + + let catalog = SkillCatalog { skills }; + + assert_eq!(catalog.skills.len(), 1); + let retrieved = catalog.skills.get(&id).unwrap(); + assert_eq!(retrieved.name, "example-skill"); + assert_eq!(retrieved.scope, SkillScope::Public); + assert_eq!(retrieved.version.as_deref(), Some("abc123")); + } + + #[test] + fn id_composition_is_stable_and_path_sensitive() { + struct TestCase { + name: &'static str, + scope: SkillScope, + skill_name: &'static str, + path: PathBuf, + expected: &'static str, + } + + let cases = vec![ + TestCase { + name: "public simple", + scope: SkillScope::Public, + skill_name: "data-viz", + path: PathBuf::from("/data-viz"), + expected: "public:data-viz:/data-viz", + }, + TestCase { + name: "private simple", + scope: SkillScope::Private, + skill_name: "lint", + path: PathBuf::from(".skills/lint"), + expected: "private:lint:.skills/lint", + }, + TestCase { + name: "byos simple", + scope: SkillScope::Byos, + skill_name: "custom-skill", + path: PathBuf::from("/my-skills/custom-skill"), + expected: "byos:custom-skill:/my-skills/custom-skill", + }, + TestCase { + name: "public same name different path", + scope: SkillScope::Public, + skill_name: "data-viz", + path: PathBuf::from("/tmp/skills/community/data-viz"), + expected: "public:data-viz:/tmp/skills/community/data-viz", + }, + TestCase { + name: "public name and path folder don't match", + scope: SkillScope::Public, + skill_name: "data-viz-1", + path: PathBuf::from("/tmp/skills/community/data-viz-2"), + expected: "public:data-viz-1:/tmp/skills/community/data-viz-2", + }, + TestCase { + name: "byos windows separators with raw string as input", + scope: SkillScope::Byos, + skill_name: "custom-skill", + path: PathBuf::from(r"C:\skills\custom-skill"), + expected: "byos:custom-skill:C:/skills/custom-skill", + }, + TestCase { + name: "byos windows separators with escaped string as input", + scope: SkillScope::Byos, + skill_name: "custom-skill", + path: PathBuf::from("C:\\skills\\custom-skill"), + expected: "byos:custom-skill:C:/skills/custom-skill", + }, + ]; + + for case in cases { + let skill = Skill { + scope: case.scope, + name: case.skill_name.to_string(), + description: "d".to_string(), + tags: vec![], + path: case.path, + content: "".to_string(), + license: None, + version: None, + }; + assert_eq!(skill.id(), case.expected, "case {}", case.name); + } + } +}