From 85ce91a5b329f1e999eca87354e8b80e2ba74f3d Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 21 Feb 2026 00:34:08 -0800 Subject: [PATCH] refactor(core): move embedded system skills into codex-skills crate (#12435) ## Why `codex-core` was carrying the embedded system-skill sample assets (and a `build.rs` that walks those files to register rerun triggers). Those assets change infrequently, but any change under `codex-core` still ties them to `codex-core`'s build/cache lifecycle. This change moves the embedded system-skills packaging into a dedicated `codex-skills` crate so it can be cached independently. That reduces unnecessary invalidation/rebuild pressure on `codex-core` when the skills bundle is the only thing that changes. ## What Changed - Added a new `codex-rs/skills` crate (`codex-skills`) with: - `Cargo.toml` - `BUILD.bazel` - `build.rs` to track skill asset file changes for Cargo rebuilds - `src/lib.rs` containing the embedded system-skills install/cache logic previously in `codex-core` - Moved the embedded sample skill assets from `codex-rs/core/src/skills/assets/samples` to `codex-rs/skills/src/assets/samples`. - Updated `codex-rs/core/Cargo.toml` to depend on `codex-skills` and removed `codex-core`'s direct `include_dir` dependency. - Removed `codex-core`'s `build.rs`. - Replaced `codex-rs/core/src/skills/system.rs` implementation with a thin re-export wrapper to keep existing `codex-core` call sites unchanged. - Updated workspace manifests/lockfile (`codex-rs/Cargo.toml`, `codex-rs/Cargo.lock`) for the new crate. --- codex-rs/Cargo.lock | 11 +- codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 3 +- codex-rs/core/src/skills/system.rs | 198 +----------------- codex-rs/skills/BUILD.bazel | 15 ++ codex-rs/skills/Cargo.toml | 19 ++ codex-rs/{core => skills}/build.rs | 2 +- .../assets/samples/skill-creator/SKILL.md | 0 .../samples/skill-creator/agents/openai.yaml | 0 .../assets/skill-creator-small.svg | 0 .../skill-creator/assets/skill-creator.png | Bin .../assets/samples/skill-creator/license.txt | 0 .../skill-creator/references/openai_yaml.md | 0 .../scripts/generate_openai_yaml.py | 0 .../skill-creator/scripts/init_skill.py | 0 .../skill-creator/scripts/quick_validate.py | 0 .../samples/skill-installer/LICENSE.txt | 0 .../assets/samples/skill-installer/SKILL.md | 0 .../skill-installer/agents/openai.yaml | 0 .../assets/skill-installer-small.svg | 0 .../assets/skill-installer.png | Bin .../skill-installer/scripts/github_utils.py | 0 .../scripts/install-skill-from-github.py | 0 .../skill-installer/scripts/list-skills.py | 0 codex-rs/skills/src/lib.rs | 195 +++++++++++++++++ 25 files changed, 245 insertions(+), 200 deletions(-) create mode 100644 codex-rs/skills/BUILD.bazel create mode 100644 codex-rs/skills/Cargo.toml rename codex-rs/{core => skills}/build.rs (89%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/SKILL.md (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/agents/openai.yaml (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/assets/skill-creator-small.svg (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/assets/skill-creator.png (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/license.txt (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/references/openai_yaml.md (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/scripts/generate_openai_yaml.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/scripts/init_skill.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/scripts/quick_validate.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/LICENSE.txt (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/SKILL.md (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/agents/openai.yaml (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/assets/skill-installer-small.svg (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/assets/skill-installer.png (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/scripts/github_utils.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/scripts/install-skill-from-github.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/scripts/list-skills.py (100%) create mode 100644 codex-rs/skills/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 42521a48d..03276a7ad 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1644,6 +1644,7 @@ dependencies = [ "codex-rmcp-client", "codex-secrets", "codex-shell-command", + "codex-skills", "codex-state", "codex-utils-absolute-path", "codex-utils-cargo-bin", @@ -1663,7 +1664,6 @@ dependencies = [ "futures", "http 1.4.0", "image", - "include_dir", "indexmap 2.13.0", "indoc", "insta", @@ -2189,6 +2189,15 @@ dependencies = [ "which", ] +[[package]] +name = "codex-skills" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "include_dir", + "thiserror 2.0.18", +] + [[package]] name = "codex-state" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 8ff0c456b..d23ed3c27 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -17,6 +17,7 @@ members = [ "cli", "config", "shell-command", + "skills", "core", "hooks", "secrets", @@ -112,6 +113,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } +codex-skills = { path = "skills" } codex-state = { path = "state" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index bda19d78e..15f9d7d11 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -3,7 +3,6 @@ edition.workspace = true license.workspace = true name = "codex-core" version.workspace = true -build = "build.rs" [lib] doctest = false @@ -34,6 +33,7 @@ codex-async-utils = { workspace = true } codex-client = { workspace = true } codex-config = { workspace = true } codex-shell-command = { workspace = true } +codex-skills = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } @@ -58,7 +58,6 @@ env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } -include_dir = { workspace = true } indexmap = { workspace = true } indoc = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } diff --git a/codex-rs/core/src/skills/system.rs b/codex-rs/core/src/skills/system.rs index cf8404096..42b48ea3a 100644 --- a/codex-rs/core/src/skills/system.rs +++ b/codex-rs/core/src/skills/system.rs @@ -1,196 +1,2 @@ -use codex_utils_absolute_path::AbsolutePathBuf; -use include_dir::Dir; -use std::collections::hash_map::DefaultHasher; -use std::fs; -use std::hash::Hash; -use std::hash::Hasher; -use std::path::Path; -use std::path::PathBuf; - -use thiserror::Error; - -const SYSTEM_SKILLS_DIR: Dir = - include_dir::include_dir!("$CARGO_MANIFEST_DIR/src/skills/assets/samples"); - -const SYSTEM_SKILLS_DIR_NAME: &str = ".system"; -const SKILLS_DIR_NAME: &str = "skills"; -const SYSTEM_SKILLS_MARKER_FILENAME: &str = ".codex-system-skills.marker"; -const SYSTEM_SKILLS_MARKER_SALT: &str = "v1"; - -/// Returns the on-disk cache location for embedded system skills. -/// -/// This is typically located at `CODEX_HOME/skills/.system`. -pub(crate) fn system_cache_root_dir(codex_home: &Path) -> PathBuf { - AbsolutePathBuf::try_from(codex_home) - .and_then(|codex_home| system_cache_root_dir_abs(&codex_home)) - .map(AbsolutePathBuf::into_path_buf) - .unwrap_or_else(|_| { - codex_home - .join(SKILLS_DIR_NAME) - .join(SYSTEM_SKILLS_DIR_NAME) - }) -} - -fn system_cache_root_dir_abs(codex_home: &AbsolutePathBuf) -> std::io::Result { - codex_home - .join(SKILLS_DIR_NAME)? - .join(SYSTEM_SKILLS_DIR_NAME) -} - -/// Installs embedded system skills into `CODEX_HOME/skills/.system`. -/// -/// Clears any existing system skills directory first and then writes the embedded -/// skills directory into place. -/// -/// To avoid doing unnecessary work on every startup, a marker file is written -/// with a fingerprint of the embedded directory. When the marker matches, the -/// install is skipped. -pub(crate) fn install_system_skills(codex_home: &Path) -> Result<(), SystemSkillsError> { - let codex_home = AbsolutePathBuf::try_from(codex_home) - .map_err(|source| SystemSkillsError::io("normalize codex home dir", source))?; - let skills_root_dir = codex_home - .join(SKILLS_DIR_NAME) - .map_err(|source| SystemSkillsError::io("resolve skills root dir", source))?; - fs::create_dir_all(skills_root_dir.as_path()) - .map_err(|source| SystemSkillsError::io("create skills root dir", source))?; - - let dest_system = system_cache_root_dir_abs(&codex_home) - .map_err(|source| SystemSkillsError::io("resolve system skills cache root dir", source))?; - - let marker_path = dest_system - .join(SYSTEM_SKILLS_MARKER_FILENAME) - .map_err(|source| SystemSkillsError::io("resolve system skills marker path", source))?; - let expected_fingerprint = embedded_system_skills_fingerprint(); - if dest_system.as_path().is_dir() - && read_marker(&marker_path).is_ok_and(|marker| marker == expected_fingerprint) - { - return Ok(()); - } - - if dest_system.as_path().exists() { - fs::remove_dir_all(dest_system.as_path()) - .map_err(|source| SystemSkillsError::io("remove existing system skills dir", source))?; - } - - write_embedded_dir(&SYSTEM_SKILLS_DIR, &dest_system)?; - fs::write(marker_path.as_path(), format!("{expected_fingerprint}\n")) - .map_err(|source| SystemSkillsError::io("write system skills marker", source))?; - Ok(()) -} - -fn read_marker(path: &AbsolutePathBuf) -> Result { - Ok(fs::read_to_string(path.as_path()) - .map_err(|source| SystemSkillsError::io("read system skills marker", source))? - .trim() - .to_string()) -} - -fn embedded_system_skills_fingerprint() -> String { - let mut items = Vec::new(); - collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items); - items.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); - - let mut hasher = DefaultHasher::new(); - SYSTEM_SKILLS_MARKER_SALT.hash(&mut hasher); - for (path, contents_hash) in items { - path.hash(&mut hasher); - contents_hash.hash(&mut hasher); - } - format!("{:x}", hasher.finish()) -} - -fn collect_fingerprint_items(dir: &Dir<'_>, items: &mut Vec<(String, Option)>) { - for entry in dir.entries() { - match entry { - include_dir::DirEntry::Dir(subdir) => { - items.push((subdir.path().to_string_lossy().to_string(), None)); - collect_fingerprint_items(subdir, items); - } - include_dir::DirEntry::File(file) => { - let mut file_hasher = DefaultHasher::new(); - file.contents().hash(&mut file_hasher); - items.push(( - file.path().to_string_lossy().to_string(), - Some(file_hasher.finish()), - )); - } - } - } -} - -/// Writes the embedded `include_dir::Dir` to disk under `dest`. -/// -/// Preserves the embedded directory structure. -fn write_embedded_dir(dir: &Dir<'_>, dest: &AbsolutePathBuf) -> Result<(), SystemSkillsError> { - fs::create_dir_all(dest.as_path()) - .map_err(|source| SystemSkillsError::io("create system skills dir", source))?; - - for entry in dir.entries() { - match entry { - include_dir::DirEntry::Dir(subdir) => { - let subdir_dest = dest.join(subdir.path()).map_err(|source| { - SystemSkillsError::io("resolve system skills subdir", source) - })?; - fs::create_dir_all(subdir_dest.as_path()).map_err(|source| { - SystemSkillsError::io("create system skills subdir", source) - })?; - write_embedded_dir(subdir, dest)?; - } - include_dir::DirEntry::File(file) => { - let path = dest.join(file.path()).map_err(|source| { - SystemSkillsError::io("resolve system skills file", source) - })?; - if let Some(parent) = path.as_path().parent() { - fs::create_dir_all(parent).map_err(|source| { - SystemSkillsError::io("create system skills file parent", source) - })?; - } - fs::write(path.as_path(), file.contents()) - .map_err(|source| SystemSkillsError::io("write system skill file", source))?; - } - } - } - - Ok(()) -} - -#[derive(Debug, Error)] -pub(crate) enum SystemSkillsError { - #[error("io error while {action}: {source}")] - Io { - action: &'static str, - #[source] - source: std::io::Error, - }, -} - -impl SystemSkillsError { - fn io(action: &'static str, source: std::io::Error) -> Self { - Self::Io { action, source } - } -} - -#[cfg(test)] -mod tests { - use super::SYSTEM_SKILLS_DIR; - use super::collect_fingerprint_items; - - #[test] - fn fingerprint_traverses_nested_entries() { - let mut items = Vec::new(); - collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items); - let mut paths: Vec = items.into_iter().map(|(path, _)| path).collect(); - paths.sort_unstable(); - - assert!( - paths - .binary_search_by(|probe| probe.as_str().cmp("skill-creator/SKILL.md")) - .is_ok() - ); - assert!( - paths - .binary_search_by(|probe| probe.as_str().cmp("skill-creator/scripts/init_skill.py")) - .is_ok() - ); - } -} +pub(crate) use codex_skills::install_system_skills; +pub(crate) use codex_skills::system_cache_root_dir; diff --git a/codex-rs/skills/BUILD.bazel b/codex-rs/skills/BUILD.bazel new file mode 100644 index 000000000..1c3fc1695 --- /dev/null +++ b/codex-rs/skills/BUILD.bazel @@ -0,0 +1,15 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "skills", + crate_name = "codex_skills", + compile_data = glob( + include = ["**"], + exclude = [ + "**/* *", + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ), +) diff --git a/codex-rs/skills/Cargo.toml b/codex-rs/skills/Cargo.toml new file mode 100644 index 000000000..babb5a177 --- /dev/null +++ b/codex-rs/skills/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-skills" +version.workspace = true +build = "build.rs" + +[lib] +doctest = false +name = "codex_skills" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +include_dir = { workspace = true } +thiserror = { workspace = true } diff --git a/codex-rs/core/build.rs b/codex-rs/skills/build.rs similarity index 89% rename from codex-rs/core/build.rs rename to codex-rs/skills/build.rs index 587415a3f..db4ef6841 100644 --- a/codex-rs/core/build.rs +++ b/codex-rs/skills/build.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::Path; fn main() { - let samples_dir = Path::new("src/skills/assets/samples"); + let samples_dir = Path::new("src/assets/samples"); if !samples_dir.exists() { return; } diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/SKILL.md b/codex-rs/skills/src/assets/samples/skill-creator/SKILL.md similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/SKILL.md rename to codex-rs/skills/src/assets/samples/skill-creator/SKILL.md diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml b/codex-rs/skills/src/assets/samples/skill-creator/agents/openai.yaml similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml rename to codex-rs/skills/src/assets/samples/skill-creator/agents/openai.yaml diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg b/codex-rs/skills/src/assets/samples/skill-creator/assets/skill-creator-small.svg similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg rename to codex-rs/skills/src/assets/samples/skill-creator/assets/skill-creator-small.svg diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png b/codex-rs/skills/src/assets/samples/skill-creator/assets/skill-creator.png similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png rename to codex-rs/skills/src/assets/samples/skill-creator/assets/skill-creator.png diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/license.txt b/codex-rs/skills/src/assets/samples/skill-creator/license.txt similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/license.txt rename to codex-rs/skills/src/assets/samples/skill-creator/license.txt diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md b/codex-rs/skills/src/assets/samples/skill-creator/references/openai_yaml.md similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md rename to codex-rs/skills/src/assets/samples/skill-creator/references/openai_yaml.md diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py b/codex-rs/skills/src/assets/samples/skill-creator/scripts/generate_openai_yaml.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py rename to codex-rs/skills/src/assets/samples/skill-creator/scripts/generate_openai_yaml.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/init_skill.py b/codex-rs/skills/src/assets/samples/skill-creator/scripts/init_skill.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/scripts/init_skill.py rename to codex-rs/skills/src/assets/samples/skill-creator/scripts/init_skill.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/quick_validate.py b/codex-rs/skills/src/assets/samples/skill-creator/scripts/quick_validate.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/scripts/quick_validate.py rename to codex-rs/skills/src/assets/samples/skill-creator/scripts/quick_validate.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/LICENSE.txt b/codex-rs/skills/src/assets/samples/skill-installer/LICENSE.txt similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/LICENSE.txt rename to codex-rs/skills/src/assets/samples/skill-installer/LICENSE.txt diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/SKILL.md b/codex-rs/skills/src/assets/samples/skill-installer/SKILL.md similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/SKILL.md rename to codex-rs/skills/src/assets/samples/skill-installer/SKILL.md diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml b/codex-rs/skills/src/assets/samples/skill-installer/agents/openai.yaml similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml rename to codex-rs/skills/src/assets/samples/skill-installer/agents/openai.yaml diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg b/codex-rs/skills/src/assets/samples/skill-installer/assets/skill-installer-small.svg similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg rename to codex-rs/skills/src/assets/samples/skill-installer/assets/skill-installer-small.svg diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png b/codex-rs/skills/src/assets/samples/skill-installer/assets/skill-installer.png similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png rename to codex-rs/skills/src/assets/samples/skill-installer/assets/skill-installer.png diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/github_utils.py b/codex-rs/skills/src/assets/samples/skill-installer/scripts/github_utils.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/scripts/github_utils.py rename to codex-rs/skills/src/assets/samples/skill-installer/scripts/github_utils.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/install-skill-from-github.py b/codex-rs/skills/src/assets/samples/skill-installer/scripts/install-skill-from-github.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/scripts/install-skill-from-github.py rename to codex-rs/skills/src/assets/samples/skill-installer/scripts/install-skill-from-github.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py b/codex-rs/skills/src/assets/samples/skill-installer/scripts/list-skills.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py rename to codex-rs/skills/src/assets/samples/skill-installer/scripts/list-skills.py diff --git a/codex-rs/skills/src/lib.rs b/codex-rs/skills/src/lib.rs new file mode 100644 index 000000000..99cde06f6 --- /dev/null +++ b/codex-rs/skills/src/lib.rs @@ -0,0 +1,195 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use include_dir::Dir; +use std::collections::hash_map::DefaultHasher; +use std::fs; +use std::hash::Hash; +use std::hash::Hasher; +use std::path::Path; +use std::path::PathBuf; + +use thiserror::Error; + +const SYSTEM_SKILLS_DIR: Dir = include_dir::include_dir!("$CARGO_MANIFEST_DIR/src/assets/samples"); + +const SYSTEM_SKILLS_DIR_NAME: &str = ".system"; +const SKILLS_DIR_NAME: &str = "skills"; +const SYSTEM_SKILLS_MARKER_FILENAME: &str = ".codex-system-skills.marker"; +const SYSTEM_SKILLS_MARKER_SALT: &str = "v1"; + +/// Returns the on-disk cache location for embedded system skills. +/// +/// This is typically located at `CODEX_HOME/skills/.system`. +pub fn system_cache_root_dir(codex_home: &Path) -> PathBuf { + AbsolutePathBuf::try_from(codex_home) + .and_then(|codex_home| system_cache_root_dir_abs(&codex_home)) + .map(AbsolutePathBuf::into_path_buf) + .unwrap_or_else(|_| { + codex_home + .join(SKILLS_DIR_NAME) + .join(SYSTEM_SKILLS_DIR_NAME) + }) +} + +fn system_cache_root_dir_abs(codex_home: &AbsolutePathBuf) -> std::io::Result { + codex_home + .join(SKILLS_DIR_NAME)? + .join(SYSTEM_SKILLS_DIR_NAME) +} + +/// Installs embedded system skills into `CODEX_HOME/skills/.system`. +/// +/// Clears any existing system skills directory first and then writes the embedded +/// skills directory into place. +/// +/// To avoid doing unnecessary work on every startup, a marker file is written +/// with a fingerprint of the embedded directory. When the marker matches, the +/// install is skipped. +pub fn install_system_skills(codex_home: &Path) -> Result<(), SystemSkillsError> { + let codex_home = AbsolutePathBuf::try_from(codex_home) + .map_err(|source| SystemSkillsError::io("normalize codex home dir", source))?; + let skills_root_dir = codex_home + .join(SKILLS_DIR_NAME) + .map_err(|source| SystemSkillsError::io("resolve skills root dir", source))?; + fs::create_dir_all(skills_root_dir.as_path()) + .map_err(|source| SystemSkillsError::io("create skills root dir", source))?; + + let dest_system = system_cache_root_dir_abs(&codex_home) + .map_err(|source| SystemSkillsError::io("resolve system skills cache root dir", source))?; + + let marker_path = dest_system + .join(SYSTEM_SKILLS_MARKER_FILENAME) + .map_err(|source| SystemSkillsError::io("resolve system skills marker path", source))?; + let expected_fingerprint = embedded_system_skills_fingerprint(); + if dest_system.as_path().is_dir() + && read_marker(&marker_path).is_ok_and(|marker| marker == expected_fingerprint) + { + return Ok(()); + } + + if dest_system.as_path().exists() { + fs::remove_dir_all(dest_system.as_path()) + .map_err(|source| SystemSkillsError::io("remove existing system skills dir", source))?; + } + + write_embedded_dir(&SYSTEM_SKILLS_DIR, &dest_system)?; + fs::write(marker_path.as_path(), format!("{expected_fingerprint}\n")) + .map_err(|source| SystemSkillsError::io("write system skills marker", source))?; + Ok(()) +} + +fn read_marker(path: &AbsolutePathBuf) -> Result { + Ok(fs::read_to_string(path.as_path()) + .map_err(|source| SystemSkillsError::io("read system skills marker", source))? + .trim() + .to_string()) +} + +fn embedded_system_skills_fingerprint() -> String { + let mut items = Vec::new(); + collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items); + items.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); + + let mut hasher = DefaultHasher::new(); + SYSTEM_SKILLS_MARKER_SALT.hash(&mut hasher); + for (path, contents_hash) in items { + path.hash(&mut hasher); + contents_hash.hash(&mut hasher); + } + format!("{:x}", hasher.finish()) +} + +fn collect_fingerprint_items(dir: &Dir<'_>, items: &mut Vec<(String, Option)>) { + for entry in dir.entries() { + match entry { + include_dir::DirEntry::Dir(subdir) => { + items.push((subdir.path().to_string_lossy().to_string(), None)); + collect_fingerprint_items(subdir, items); + } + include_dir::DirEntry::File(file) => { + let mut file_hasher = DefaultHasher::new(); + file.contents().hash(&mut file_hasher); + items.push(( + file.path().to_string_lossy().to_string(), + Some(file_hasher.finish()), + )); + } + } + } +} + +/// Writes the embedded `include_dir::Dir` to disk under `dest`. +/// +/// Preserves the embedded directory structure. +fn write_embedded_dir(dir: &Dir<'_>, dest: &AbsolutePathBuf) -> Result<(), SystemSkillsError> { + fs::create_dir_all(dest.as_path()) + .map_err(|source| SystemSkillsError::io("create system skills dir", source))?; + + for entry in dir.entries() { + match entry { + include_dir::DirEntry::Dir(subdir) => { + let subdir_dest = dest.join(subdir.path()).map_err(|source| { + SystemSkillsError::io("resolve system skills subdir", source) + })?; + fs::create_dir_all(subdir_dest.as_path()).map_err(|source| { + SystemSkillsError::io("create system skills subdir", source) + })?; + write_embedded_dir(subdir, dest)?; + } + include_dir::DirEntry::File(file) => { + let path = dest.join(file.path()).map_err(|source| { + SystemSkillsError::io("resolve system skills file", source) + })?; + if let Some(parent) = path.as_path().parent() { + fs::create_dir_all(parent).map_err(|source| { + SystemSkillsError::io("create system skills file parent", source) + })?; + } + fs::write(path.as_path(), file.contents()) + .map_err(|source| SystemSkillsError::io("write system skill file", source))?; + } + } + } + + Ok(()) +} + +#[derive(Debug, Error)] +pub enum SystemSkillsError { + #[error("io error while {action}: {source}")] + Io { + action: &'static str, + #[source] + source: std::io::Error, + }, +} + +impl SystemSkillsError { + fn io(action: &'static str, source: std::io::Error) -> Self { + Self::Io { action, source } + } +} + +#[cfg(test)] +mod tests { + use super::SYSTEM_SKILLS_DIR; + use super::collect_fingerprint_items; + + #[test] + fn fingerprint_traverses_nested_entries() { + let mut items = Vec::new(); + collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items); + let mut paths: Vec = items.into_iter().map(|(path, _)| path).collect(); + paths.sort_unstable(); + + assert!( + paths + .binary_search_by(|probe| probe.as_str().cmp("skill-creator/SKILL.md")) + .is_ok() + ); + assert!( + paths + .binary_search_by(|probe| probe.as_str().cmp("skill-creator/scripts/init_skill.py")) + .is_ok() + ); + } +}