use crate::model::SkillDependencies;use crate::model::SkillError;use crate::model::SkillFileSystemsByPath;use crate::model::SkillInterface;use crate::model::SkillLoadOutcome;use crate::model::SkillMetadata;use crate::model::SkillPolicy;use crate::model::SkillToolDependency;use crate::system::system_cache_root_dir;use codex_app_server_protocol::ConfigLayerSource;use codex_config::ConfigLayerStack;use codex_config::ConfigLayerStackOrdering;use codex_config::default_project_root_markers;use codex_config::merge_toml_values;use codex_config::project_root_markers_from_config;use codex_exec_server::ExecutorFileSystem;use codex_exec_server::LOCAL_FS;use codex_protocol::protocol::Product;use codex_protocol::protocol::SkillScope;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_absolute_path::AbsolutePathBufGuard;use codex_utils_path_uri::PathUri;use codex_utils_plugins::PluginSkillRoot;use codex_utils_plugins::plugin_namespace_for_skill_path;use dirs::home_dir;use serde::Deserialize;use std::collections::HashMap;use std::collections::HashSet;use std::collections::VecDeque;use std::error::Error;use std::fmt;use std::io;use std::path::Component;use std::path::Path;use std::path::PathBuf;use std::sync::Arc;use toml::Value as TomlValue;use tracing::error;#[derive(Debug, Deserialize)]struct SkillFrontmatter { #[serde(default)] name: Option<String>, #[serde(default)] description: Option<String>, #[serde(default)] metadata: SkillFrontmatterMetadata,}#[derive(Debug, Default, Deserialize)]struct SkillFrontmatterMetadata { #[serde(default, rename = "short-description")] short_description: Option<String>,}#[derive(Debug, Default, Deserialize)]struct SkillMetadataFile { #[serde(default)] interface: Option<Interface>, #[serde(default)] dependencies: Option<Dependencies>, #[serde(default)] policy: Option<Policy>,}#[derive(Default)]struct LoadedSkillMetadata { interface: Option<SkillInterface>, dependencies: Option<SkillDependencies>, policy: Option<SkillPolicy>,}#[derive(Debug, Default, Deserialize)]struct Interface { display_name: Option<String>, short_description: Option<String>, icon_small: Option<PathBuf>, icon_large: Option<PathBuf>, brand_color: Option<String>, default_prompt: Option<String>,}#[derive(Debug, Default, Deserialize)]struct Dependencies { #[serde(default)] tools: Vec<DependencyTool>,}#[derive(Debug, Deserialize)]struct Policy { #[serde(default)] allow_implicit_invocation: Option<bool>, #[serde(default)] products: Vec<Product>,}#[derive(Debug, Default, Deserialize)]struct DependencyTool { #[serde(rename = "type")] kind: Option<String>, value: Option<String>, description: Option<String>, transport: Option<String>, command: Option<String>, url: Option<String>,}const SKILLS_FILENAME: &str = "SKILL.md";const AGENTS_DIR_NAME: &str = ".agents";const SKILLS_METADATA_DIR: &str = "agents";const SKILLS_METADATA_FILENAME: &str = "openai.yaml";const SKILLS_DIR_NAME: &str = "skills";const MAX_NAME_LEN: usize = 64;const MAX_QUALIFIED_NAME_LEN: usize = 128;const MAX_DESCRIPTION_LEN: usize = 1024;const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;const MAX_DEFAULT_PROMPT_LEN: usize = MAX_DESCRIPTION_LEN;const MAX_DEPENDENCY_TYPE_LEN: usize = MAX_NAME_LEN;const MAX_DEPENDENCY_TRANSPORT_LEN: usize = MAX_NAME_LEN;const MAX_DEPENDENCY_VALUE_LEN: usize = MAX_DESCRIPTION_LEN;const MAX_DEPENDENCY_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;const MAX_DEPENDENCY_COMMAND_LEN: usize = MAX_DESCRIPTION_LEN;const MAX_DEPENDENCY_URL_LEN: usize = MAX_DESCRIPTION_LEN;// Traversal depth from the skills root.const MAX_SCAN_DEPTH: usize = 6;const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000;#[derive(Debug)]enum SkillParseError { Read(std::io::Error), MissingFrontmatter, InvalidYaml(serde_yaml::Error), MissingField(&'static str), InvalidField { field: &'static str, reason: String },}impl fmt::Display for SkillParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { SkillParseError::Read(e) => write!(f, "failed to read file: {e}"), SkillParseError::MissingFrontmatter => { write!(f, "missing YAML frontmatter delimited by ---") } SkillParseError::InvalidYaml(e) => write!(f, "invalid YAML: {e}"), SkillParseError::MissingField(field) => write!(f, "missing field `{field}`"), SkillParseError::InvalidField { field, reason } => { write!(f, "invalid {field}: {reason}") } } }}impl Error for SkillParseError {}pub struct SkillRoot { pub path: AbsolutePathBuf, pub scope: SkillScope, pub file_system: Arc<dyn ExecutorFileSystem>, pub plugin_id: Option<String>, pub plugin_root: Option<AbsolutePathBuf>,}pub async fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcomewhere I: IntoIterator<Item = SkillRoot>,{ let mut outcome = SkillLoadOutcome::default(); let mut skill_roots: Vec<AbsolutePathBuf> = Vec::new(); let mut skill_root_by_path: HashMap<AbsolutePathBuf, AbsolutePathBuf> = HashMap::new(); let mut file_systems_by_skill_path: HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>> = HashMap::new(); for root in roots { let fs = root.file_system; let root_path = canonicalize_for_skill_identity(fs.as_ref(), &root.path).await; let skills_before_root = outcome.skills.len(); discover_skills_under_root( fs.as_ref(), &root_path, root.scope, root.plugin_id.as_deref(), root.plugin_root.as_ref(), &mut outcome, ) .await; for skill in &outcome.skills[skills_before_root..] { if !skill_roots.contains(&root_path) { skill_roots.push(root_path.clone()); } skill_root_by_path .entry(skill.path_to_skills_md.clone()) .or_insert_with(|| root_path.clone()); file_systems_by_skill_path .entry(skill.path_to_skills_md.clone()) .or_insert_with(|| Arc::clone(&fs)); } } let mut seen: HashSet<AbsolutePathBuf> = HashSet::new(); outcome .skills .retain(|skill| seen.insert(skill.path_to_skills_md.clone())); let retained_skill_paths: HashSet<AbsolutePathBuf> = outcome .skills .iter() .map(|skill| skill.path_to_skills_md.clone()) .collect(); skill_root_by_path.retain(|path, _| retained_skill_paths.contains(path)); let used_roots: HashSet<AbsolutePathBuf> = skill_root_by_path.values().cloned().collect(); skill_roots.retain(|root| used_roots.contains(root)); file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path)); outcome.skill_roots = skill_roots; outcome.skill_root_by_path = Arc::new(skill_root_by_path); outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path); fn scope_rank(scope: SkillScope) -> u8 { // Higher-priority scopes first (matches root scan order for dedupe). match scope { SkillScope::Repo => 0, SkillScope::User => 1, SkillScope::System => 2, SkillScope::Admin => 3, } } outcome.skills.sort_by(|a, b| { scope_rank(a.scope) .cmp(&scope_rank(b.scope)) .then_with(|| a.name.cmp(&b.name)) .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md)) }); outcome}pub(crate) async fn skill_roots( fs: Option<Arc<dyn ExecutorFileSystem>>, config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, plugin_skill_roots: Vec<PluginSkillRoot>, extra_skill_roots: Vec<AbsolutePathBuf>,) -> Vec<SkillRoot> { let home_dir = home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok()); skill_roots_with_home_dir( fs, config_layer_stack, cwd, home_dir.as_ref(), plugin_skill_roots, extra_skill_roots, ) .await}async fn skill_roots_with_home_dir( fs: Option<Arc<dyn ExecutorFileSystem>>, config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, home_dir: Option<&AbsolutePathBuf>, plugin_skill_roots: Vec<PluginSkillRoot>, extra_skill_roots: Vec<AbsolutePathBuf>,) -> Vec<SkillRoot> { let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone()); roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot { path: root.path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(root.plugin_id), plugin_root: Some(root.plugin_root), })); roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot { path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, plugin_root: None, })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); dedupe_skill_roots_by_path(&mut roots); roots}fn skill_roots_from_layer_stack_inner( config_layer_stack: &ConfigLayerStack, home_dir: Option<&AbsolutePathBuf>, repo_fs: Option<Arc<dyn ExecutorFileSystem>>,) -> Vec<SkillRoot> { let mut roots = Vec::new(); for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::HighestPrecedenceFirst, /*include_disabled*/ true, ) { let Some(config_folder) = layer.config_folder() else { continue; }; match &layer.name { ConfigLayerSource::Project { .. } => { if let Some(repo_fs) = &repo_fs { roots.push(SkillRoot { path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), plugin_id: None, plugin_root: None, }); } } ConfigLayerSource::User { .. } => { // Deprecated user skills location (`$CODEX_HOME/skills`), kept for backward // compatibility. roots.push(SkillRoot { path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, plugin_root: None, }); // `$HOME/.agents/skills` (user-installed skills). if let Some(home_dir) = home_dir { roots.push(SkillRoot { path: home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, plugin_root: None, }); } // Embedded system skills are cached under `$CODEX_HOME/skills/.system` and are a // special case (not a config layer). roots.push(SkillRoot { path: system_cache_root_dir(&config_folder), scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, plugin_root: None, }); } ConfigLayerSource::System { .. } => { // The system config layer lives under `/etc/codex/` on Unix, so treat // `/etc/codex/skills` as admin-scoped skills. roots.push(SkillRoot { path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, plugin_root: None, }); } ConfigLayerSource::Mdm { .. } | ConfigLayerSource::EnterpriseManaged { .. } | ConfigLayerSource::SessionFlags | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {} } } roots}async fn repo_agents_skill_roots( fs: Option<Arc<dyn ExecutorFileSystem>>, config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf,) -> Vec<SkillRoot> { let Some(fs) = fs else { return Vec::new(); }; let project_root_markers = project_root_markers_from_stack(config_layer_stack); let project_root = find_project_root(fs.as_ref(), cwd, &project_root_markers).await; let dirs = dirs_between_project_root_and_cwd(cwd, &project_root); let mut roots = Vec::new(); for dir in dirs { let agents_skills = dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME); let agents_skills_uri = PathUri::from_abs_path(&agents_skills); match fs.get_metadata(&agents_skills_uri, /*sandbox*/ None).await { Ok(metadata) if metadata.is_directory => roots.push(SkillRoot { path: agents_skills, scope: SkillScope::Repo, file_system: Arc::clone(&fs), plugin_id: None, plugin_root: None, }), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} Err(err) => { tracing::warn!( "failed to stat repo skills root {}: {err:#}", agents_skills.display() ); } } } roots}fn project_root_markers_from_stack(config_layer_stack: &ConfigLayerStack) -> Vec<String> { let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, ) { if matches!(layer.name, ConfigLayerSource::Project { .. }) { continue; } merge_toml_values(&mut merged, &layer.config); } match project_root_markers_from_config(&merged) { Ok(Some(markers)) => markers, Ok(None) => default_project_root_markers(), Err(err) => { tracing::warn!("invalid project_root_markers: {err}"); default_project_root_markers() } }}async fn find_project_root( fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, project_root_markers: &[String],) -> AbsolutePathBuf { if project_root_markers.is_empty() { return cwd.clone(); } for ancestor in cwd.ancestors() { for marker in project_root_markers { let marker_path = ancestor.join(marker); let marker_path_uri = PathUri::from_abs_path(&marker_path); match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await { Ok(_) => return ancestor, Err(err) if err.kind() == io::ErrorKind::NotFound => {} Err(err) => { tracing::warn!( "failed to stat project root marker {}: {err:#}", marker_path.display() ); } } } } cwd.clone()}fn dirs_between_project_root_and_cwd( cwd: &AbsolutePathBuf, project_root: &AbsolutePathBuf,) -> Vec<AbsolutePathBuf> { let mut dirs = cwd .ancestors() .scan(false, |done, dir| { if *done { None } else { if &dir == project_root { *done = true; } Some(dir) } }) .collect::<Vec<_>>(); dirs.reverse(); dirs}fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) { let mut seen: HashSet<AbsolutePathBuf> = HashSet::new(); roots.retain(|root| seen.insert(root.path.clone()));}async fn canonicalize_for_skill_identity( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf,) -> AbsolutePathBuf { let path_uri = PathUri::from_abs_path(path); fs.canonicalize(&path_uri, /*sandbox*/ None) .await .and_then(|path| path.to_abs_path()) .unwrap_or_else(|_| path.clone())}async fn discover_skills_under_root( fs: &dyn ExecutorFileSystem, root: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, plugin_root: Option<&AbsolutePathBuf>, outcome: &mut SkillLoadOutcome,) { let root = root.clone(); let plugin_root = match plugin_root { Some(plugin_root) => Some(canonicalize_for_skill_identity(fs, plugin_root).await), None => None, }; let root_uri = PathUri::from_abs_path(&root); match fs.get_metadata(&root_uri, /*sandbox*/ None).await { Ok(metadata) if metadata.is_directory => {} Ok(_) => return, Err(err) if err.kind() == io::ErrorKind::NotFound => return, Err(err) => { error!("failed to stat skills root {}: {err:#}", root.display()); return; } } fn enqueue_dir( queue: &mut VecDeque<(AbsolutePathBuf, usize)>, visited_dirs: &mut HashSet<AbsolutePathBuf>, truncated_by_dir_limit: &mut bool, path: AbsolutePathBuf, depth: usize, ) { if depth > MAX_SCAN_DEPTH { return; } if visited_dirs.len() >= MAX_SKILLS_DIRS_PER_ROOT { *truncated_by_dir_limit = true; return; } if visited_dirs.insert(path.clone()) { queue.push_back((path, depth)); } } // Follow symlinked directories for user, admin, and repo skills. System skills are written by Codex itself. let follow_symlinks = matches!( scope, SkillScope::Repo | SkillScope::User | SkillScope::Admin ); let mut visited_dirs: HashSet<AbsolutePathBuf> = HashSet::new(); visited_dirs.insert(root.clone()); let mut queue: VecDeque<(AbsolutePathBuf, usize)> = VecDeque::from([(root.clone(), 0)]); let mut truncated_by_dir_limit = false; while let Some((dir, depth)) = queue.pop_front() { let dir_uri = PathUri::from_abs_path(&dir); let entries = match fs.read_directory(&dir_uri, /*sandbox*/ None).await { Ok(entries) => entries, Err(e) => { error!("failed to read skills dir {}: {e:#}", dir.display()); continue; } }; for entry in entries { let file_name = entry.file_name; if file_name.starts_with('.') { continue; } let path = dir.join(&file_name); let path_uri = PathUri::from_abs_path(&path); let metadata = match fs.get_metadata(&path_uri, /*sandbox*/ None).await { Ok(metadata) => metadata, Err(e) => { error!("failed to stat skills path {}: {e:#}", path.display()); continue; } }; if metadata.is_symlink { if !follow_symlinks { continue; } match fs.read_directory(&path_uri, /*sandbox*/ None).await { Ok(_) => { let resolved_dir = canonicalize_for_skill_identity(fs, &path).await; enqueue_dir( &mut queue, &mut visited_dirs, &mut truncated_by_dir_limit, resolved_dir, depth + 1, ); } Err(err) if matches!( err.kind(), io::ErrorKind::NotADirectory | io::ErrorKind::NotFound ) => {} Err(err) => { error!( "failed to read skills symlink dir {}: {err:#}", path.display() ); } } continue; } if metadata.is_directory { let resolved_dir = canonicalize_for_skill_identity(fs, &path).await; enqueue_dir( &mut queue, &mut visited_dirs, &mut truncated_by_dir_limit, resolved_dir, depth + 1, ); continue; } if metadata.is_file && file_name == SKILLS_FILENAME { match parse_skill_file(fs, &path, scope, plugin_id, plugin_root.as_ref()).await { Ok(skill) => { outcome.skills.push(skill); } Err(err) => { if scope != SkillScope::System { outcome.errors.push(SkillError { path: path.clone(), message: err.to_string(), }); } } } } } } if truncated_by_dir_limit { tracing::warn!( "skills scan truncated after {} directories (root: {})", MAX_SKILLS_DIRS_PER_ROOT, root.display() ); }}async fn parse_skill_file( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, plugin_root: Option<&AbsolutePathBuf>,) -> Result<SkillMetadata, SkillParseError> { let path_uri = PathUri::from_abs_path(path); let contents = fs .read_file_text(&path_uri, /*sandbox*/ None) .await .map_err(SkillParseError::Read)?; let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?; let parsed: SkillFrontmatter = serde_yaml::from_str(&frontmatter).map_err(SkillParseError::InvalidYaml)?; let base_name = parsed .name .as_deref() .map(sanitize_single_line) .filter(|value| !value.is_empty()) .unwrap_or_else(|| default_skill_name(path)); let name = namespaced_skill_name(fs, path, &base_name).await; let description = parsed .description .as_deref() .map(sanitize_single_line) .unwrap_or_default(); let short_description = parsed .metadata .short_description .as_deref() .map(sanitize_single_line) .filter(|value| !value.is_empty()); let LoadedSkillMetadata { interface, dependencies, policy, } = load_skill_metadata(fs, path, plugin_root).await; validate_len(&base_name, MAX_NAME_LEN, "name")?; validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?; validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; if let Some(short_description) = short_description.as_deref() { validate_len( short_description, MAX_SHORT_DESCRIPTION_LEN, "metadata.short-description", )?; } let resolved_path = canonicalize_for_skill_identity(fs, path).await; Ok(SkillMetadata { name, description, short_description, interface, dependencies, policy, path_to_skills_md: resolved_path, scope, plugin_id: plugin_id.map(str::to_string), })}fn default_skill_name(path: &AbsolutePathBuf) -> String { path.parent() .and_then(|parent| { parent .file_name() .and_then(|name| name.to_str()) .map(sanitize_single_line) }) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "skill".to_string())}async fn namespaced_skill_name( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, base_name: &str,) -> String { plugin_namespace_for_skill_path(fs, path) .await .map(|namespace| format!("{namespace}:{base_name}")) .unwrap_or_else(|| base_name.to_string())}async fn load_skill_metadata( fs: &dyn ExecutorFileSystem, skill_path: &AbsolutePathBuf, plugin_root: Option<&AbsolutePathBuf>,) -> LoadedSkillMetadata { // Fail open: optional metadata should not block loading SKILL.md. let Some(skill_dir) = skill_path.parent() else { return LoadedSkillMetadata::default(); }; let metadata_path = skill_dir .join(SKILLS_METADATA_DIR) .join(SKILLS_METADATA_FILENAME); let metadata_path_uri = PathUri::from_abs_path(&metadata_path); match fs.get_metadata(&metadata_path_uri, /*sandbox*/ None).await { Ok(metadata) if metadata.is_file => {} Ok(_) => return LoadedSkillMetadata::default(), Err(error) if error.kind() == io::ErrorKind::NotFound => { return LoadedSkillMetadata::default(); } Err(error) => { tracing::warn!( "ignoring {path}: failed to stat {label}: {error}", path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); return LoadedSkillMetadata::default(); } } let contents = match fs .read_file_text(&metadata_path_uri, /*sandbox*/ None) .await { Ok(contents) => contents, Err(error) => { tracing::warn!( "ignoring {path}: failed to read {label}: {error}", path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); return LoadedSkillMetadata::default(); } }; let parsed: SkillMetadataFile = { let _guard = AbsolutePathBufGuard::new(skill_dir.as_path()); match serde_yaml::from_str(&contents) { Ok(parsed) => parsed, Err(error) => { tracing::warn!( "ignoring {path}: invalid {label}: {error}", path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); return LoadedSkillMetadata::default(); } } }; let SkillMetadataFile { interface, dependencies, policy, } = parsed; LoadedSkillMetadata { interface: resolve_interface(interface, &skill_dir, plugin_root), dependencies: resolve_dependencies(dependencies), policy: resolve_policy(policy), }}fn resolve_interface( interface: Option<Interface>, skill_dir: &AbsolutePathBuf, plugin_root: Option<&AbsolutePathBuf>,) -> Option<SkillInterface> { let interface = interface?; let interface = SkillInterface { display_name: resolve_str( interface.display_name, MAX_NAME_LEN, "interface.display_name", ), short_description: resolve_str( interface.short_description, MAX_SHORT_DESCRIPTION_LEN, "interface.short_description", ), icon_small: resolve_asset_path( skill_dir, plugin_root, "interface.icon_small", interface.icon_small, ), icon_large: resolve_asset_path( skill_dir, plugin_root, "interface.icon_large", interface.icon_large, ), brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"), default_prompt: resolve_str( interface.default_prompt, MAX_DEFAULT_PROMPT_LEN, "interface.default_prompt", ), }; let has_fields = interface.display_name.is_some() || interface.short_description.is_some() || interface.icon_small.is_some() || interface.icon_large.is_some() || interface.brand_color.is_some() || interface.default_prompt.is_some(); if has_fields { Some(interface) } else { None }}fn resolve_dependencies(dependencies: Option<Dependencies>) -> Option<SkillDependencies> { let dependencies = dependencies?; let tools: Vec<SkillToolDependency> = dependencies .tools .into_iter() .filter_map(resolve_dependency_tool) .collect(); if tools.is_empty() { None } else { Some(SkillDependencies { tools }) }}fn resolve_policy(policy: Option<Policy>) -> Option<SkillPolicy> { policy.map(|policy| SkillPolicy { allow_implicit_invocation: policy.allow_implicit_invocation, products: policy.products, })}fn resolve_dependency_tool(tool: DependencyTool) -> Option<SkillToolDependency> { let r#type = resolve_required_str( tool.kind, MAX_DEPENDENCY_TYPE_LEN, "dependencies.tools.type", )?; let value = resolve_required_str( tool.value, MAX_DEPENDENCY_VALUE_LEN, "dependencies.tools.value", )?; let description = resolve_str( tool.description, MAX_DEPENDENCY_DESCRIPTION_LEN, "dependencies.tools.description", ); let transport = resolve_str( tool.transport, MAX_DEPENDENCY_TRANSPORT_LEN, "dependencies.tools.transport", ); let command = resolve_str( tool.command, MAX_DEPENDENCY_COMMAND_LEN, "dependencies.tools.command", ); let url = resolve_str(tool.url, MAX_DEPENDENCY_URL_LEN, "dependencies.tools.url"); Some(SkillToolDependency { r#type, value, description, transport, command, url, })}fn resolve_asset_path( skill_dir: &AbsolutePathBuf, plugin_root: Option<&AbsolutePathBuf>, field: &'static str, path: Option<PathBuf>,) -> Option<AbsolutePathBuf> { // Icons must stay under the skill's assets directory. Plugin skills may // also share icons from the plugin-level assets directory. let path = path?; if path.as_os_str().is_empty() { return None; } let assets_dir = skill_dir.join("assets"); if path.is_absolute() { tracing::warn!( "ignoring {field}: icon must be a relative assets path (not {})", assets_dir.display() ); return None; } let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::CurDir => {} Component::Normal(component) => normalized.push(component), Component::ParentDir => { return resolve_plugin_shared_asset_path(skill_dir, plugin_root, field, &path); } _ => { tracing::warn!("ignoring {field}: icon path must be under assets/"); return None; } } } let mut components = normalized.components(); match components.next() { Some(Component::Normal(component)) if component == "assets" => {} _ => { tracing::warn!("ignoring {field}: icon path must be under assets/"); return None; } } Some(skill_dir.join(normalized))}fn resolve_plugin_shared_asset_path( skill_dir: &AbsolutePathBuf, plugin_root: Option<&AbsolutePathBuf>, field: &'static str, path: &Path,) -> Option<AbsolutePathBuf> { let Some(plugin_root) = plugin_root else { tracing::warn!("ignoring {field}: icon path must not contain '..'"); return None; }; let plugin_assets_dir = lexically_normalize(plugin_root.join("assets").as_path()); let resolved = lexically_normalize(skill_dir.join(path).as_path()); if !resolved.starts_with(&plugin_assets_dir) { tracing::warn!("ignoring {field}: icon path with '..' must resolve under plugin assets/"); return None; } AbsolutePathBuf::try_from(resolved) .map_err(|err| { tracing::warn!("ignoring {field}: icon path must resolve to an absolute path: {err}"); err }) .ok()}fn lexically_normalize(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::CurDir => {} Component::ParentDir => { normalized.pop(); } Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { normalized.push(component.as_os_str()); } } } normalized}fn sanitize_single_line(raw: &str) -> String { raw.split_whitespace().collect::<Vec<_>>().join(" ")}fn validate_len( value: &str, max_len: usize, field_name: &'static str,) -> Result<(), SkillParseError> { if value.is_empty() { return Err(SkillParseError::MissingField(field_name)); } if value.chars().count() > max_len { return Err(SkillParseError::InvalidField { field: field_name, reason: format!("exceeds maximum length of {max_len} characters"), }); } Ok(())}fn resolve_str(value: Option<String>, max_len: usize, field: &'static str) -> Option<String> { let value = value?; let value = sanitize_single_line(&value); if value.is_empty() { tracing::warn!("ignoring {field}: value is empty"); return None; } if value.chars().count() > max_len { tracing::warn!("ignoring {field}: exceeds maximum length of {max_len} characters"); return None; } Some(value)}fn resolve_required_str( value: Option<String>, max_len: usize, field: &'static str,) -> Option<String> { let Some(value) = value else { tracing::warn!("ignoring {field}: value is missing"); return None; }; resolve_str(Some(value), max_len, field)}fn resolve_color_str(value: Option<String>, field: &'static str) -> Option<String> { let value = value?; let value = value.trim(); if value.is_empty() { tracing::warn!("ignoring {field}: value is empty"); return None; } let mut chars = value.chars(); if value.len() == 7 && chars.next() == Some('#') && chars.all(|c| c.is_ascii_hexdigit()) { Some(value.to_string()) } else { tracing::warn!("ignoring {field}: expected #RRGGBB, got {value}"); None }}fn extract_frontmatter(contents: &str) -> Option<String> { let mut lines = contents.lines(); if !matches!(lines.next(), Some(line) if line.trim() == "---") { return None; } let mut frontmatter_lines: Vec<&str> = Vec::new(); let mut found_closing = false; for line in lines.by_ref() { if line.trim() == "---" { found_closing = true; break; } frontmatter_lines.push(line); } if frontmatter_lines.is_empty() || !found_closing { return None; } Some(frontmatter_lines.join("\n"))}#[cfg(test)]pub(crate) async fn skill_roots_from_layer_stack( fs: Arc<dyn ExecutorFileSystem>, config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, home_dir: Option<&AbsolutePathBuf>,) -> Vec<SkillRoot> { skill_roots_with_home_dir( Some(fs), config_layer_stack, cwd, home_dir, Vec::new(), Vec::new(), ) .await}#[cfg(test)]#[path = "loader_tests.rs"]mod tests;