From 42fa4c237f2b70f07f8759a731455f9453daf363 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 15 Jan 2026 11:20:04 -0800 Subject: [PATCH] Support SKILL.toml file. (#9125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’re introducing a new SKILL.toml to hold skill metadata so Codex can deliver a richer Skills experience. Initial focus is the interface block: ``` [interface] display_name = "Optional user-facing name" short_description = "Optional user-facing description" icon_small = "./assets/small-400px.png" icon_large = "./assets/large-logo.svg" brand_color = "#3B82F6" default_prompt = "Optional surrounding prompt to use the skill with" ``` All fields are exposed via the app server API. display_name and short_description are consumed by the TUI. --- .../app-server-protocol/src/protocol/v2.rs | 39 +- .../app-server/src/codex_message_processor.rs | 10 + codex-rs/core/src/codex.rs | 12 + codex-rs/core/src/skills/loader.rs | 424 ++++++++++++++++-- codex-rs/core/src/skills/model.rs | 11 + codex-rs/protocol/src/protocol.rs | 22 +- codex-rs/tui/src/bottom_pane/skill_popup.rs | 36 +- codex-rs/tui/src/chatwidget.rs | 9 + codex-rs/tui2/src/bottom_pane/skill_popup.rs | 36 +- codex-rs/tui2/src/chatwidget.rs | 9 + 10 files changed, 558 insertions(+), 50 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e7adf6586..4ff129154 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -25,6 +25,7 @@ use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo; +use codex_protocol::protocol::SkillInterface as CoreSkillInterface; use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; @@ -1252,13 +1253,35 @@ pub enum SkillScope { pub struct SkillMetadata { pub name: String, pub description: String, - #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + /// Legacy short_description from SKILL.md. Prefer SKILL.toml interface.short_description. pub short_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub interface: Option, pub path: PathBuf, pub scope: SkillScope, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillInterface { + #[ts(optional)] + pub display_name: Option, + #[ts(optional)] + pub short_description: Option, + #[ts(optional)] + pub icon_small: Option, + #[ts(optional)] + pub icon_large: Option, + #[ts(optional)] + pub brand_color: Option, + #[ts(optional)] + pub default_prompt: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1282,12 +1305,26 @@ impl From for SkillMetadata { name: value.name, description: value.description, short_description: value.short_description, + interface: value.interface.map(SkillInterface::from), path: value.path, scope: value.scope.into(), } } } +impl From for SkillInterface { + fn from(value: CoreSkillInterface) -> Self { + Self { + display_name: value.display_name, + short_description: value.short_description, + brand_color: value.brand_color, + default_prompt: value.default_prompt, + icon_small: value.icon_small, + icon_large: value.icon_large, + } + } +} + impl From for SkillScope { fn from(value: CoreSkillScope) -> Self { match value { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0a64ed719..770e8de4e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3894,6 +3894,16 @@ fn skills_to_info( name: skill.name.clone(), description: skill.description.clone(), short_description: skill.short_description.clone(), + interface: skill.interface.clone().map(|interface| { + codex_app_server_protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } + }), path: skill.path.clone(), scope: skill.scope.into(), }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f2ec33424..e405076f0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -120,6 +120,7 @@ use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::SkillErrorInfo; +use crate::protocol::SkillInterface as ProtocolSkillInterface; use crate::protocol::SkillMetadata as ProtocolSkillMetadata; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; @@ -2480,6 +2481,17 @@ fn skills_to_info(skills: &[SkillMetadata]) -> Vec { name: skill.name.clone(), description: skill.description.clone(), short_description: skill.short_description.clone(), + interface: skill + .interface + .clone() + .map(|interface| ProtocolSkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + }), path: skill.path.clone(), scope: skill.scope, }) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index a7461dcf9..da51fea41 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,6 +1,7 @@ use crate::config::Config; use crate::config_loader::ConfigLayerStack; use crate::skills::model::SkillError; +use crate::skills::model::SkillInterface; use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; use crate::skills::system::system_cache_root_dir; @@ -13,6 +14,7 @@ use std::collections::VecDeque; use std::error::Error; use std::fmt; use std::fs; +use std::path::Component; use std::path::Path; use std::path::PathBuf; use tracing::error; @@ -31,11 +33,29 @@ struct SkillFrontmatterMetadata { short_description: Option, } +#[derive(Debug, Default, Deserialize)] +struct SkillToml { + #[serde(default)] + interface: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct Interface { + display_name: Option, + short_description: Option, + icon_small: Option, + icon_large: Option, + brand_color: Option, + default_prompt: Option, +} + const SKILLS_FILENAME: &str = "SKILL.md"; +const SKILLS_TOML_FILENAME: &str = "SKILL.toml"; const SKILLS_DIR_NAME: &str = "skills"; const MAX_NAME_LEN: usize = 64; const MAX_DESCRIPTION_LEN: usize = 1024; const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN; +const MAX_DEFAULT_PROMPT_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; @@ -195,7 +215,7 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil } } - // Follow symlinks for user, admin, and repo skills. System skills are written by Codex itself. + // 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 @@ -262,20 +282,6 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil continue; } - if metadata.is_file() && file_name == SKILLS_FILENAME { - match parse_skill_file(&path, scope) { - Ok(skill) => outcome.skills.push(skill), - Err(err) => { - if scope != SkillScope::System { - outcome.errors.push(SkillError { - path, - message: err.to_string(), - }); - } - } - } - } - continue; } @@ -336,11 +342,12 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Result Option { + // Fail open: optional SKILL.toml metadata should not block loading SKILL.md. + let skill_dir = skill_path.parent()?; + let interface_path = skill_dir.join(SKILLS_TOML_FILENAME); + if !interface_path.exists() { + return None; + } + + let contents = match fs::read_to_string(&interface_path) { + Ok(contents) => contents, + Err(error) => { + tracing::warn!( + "ignoring {path}: failed to read SKILL.toml: {error}", + path = interface_path.display() + ); + return None; + } + }; + let parsed: SkillToml = match toml::from_str(&contents) { + Ok(parsed) => parsed, + Err(error) => { + tracing::warn!( + "ignoring {path}: invalid TOML: {error}", + path = interface_path.display() + ); + return None; + } + }; + let interface = parsed.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, "interface.icon_small", interface.icon_small), + icon_large: resolve_asset_path(skill_dir, "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_asset_path( + skill_dir: &Path, + field: &'static str, + path: Option, +) -> Option { + // Icons must be relative paths under the skill's assets/ directory; otherwise return None. + 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 components = path.components().peekable(); + while matches!(components.peek(), Some(Component::CurDir)) { + components.next(); + } + match components.next() { + Some(Component::Normal(component)) if component == "assets" => {} + _ => { + tracing::warn!("ignoring {field}: icon path must be under assets/"); + return None; + } + } + if components.any(|component| matches!(component, Component::ParentDir)) { + tracing::warn!("ignoring {field}: icon path must not contain '..'"); + return None; + } + + Some(skill_dir.join(path)) +} + fn sanitize_single_line(raw: &str) -> String { raw.split_whitespace().collect::>().join(" ") } -fn validate_field( +fn validate_len( value: &str, max_len: usize, field_name: &'static str, @@ -379,6 +485,36 @@ fn validate_field( Ok(()) } +fn resolve_str(value: Option, max_len: usize, field: &'static str) -> Option { + 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_color_str(value: Option, field: &'static str) -> Option { + 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 { let mut lines = contents.lines(); if !matches!(lines.next(), Some(line) if line.trim() == "---") { @@ -528,6 +664,224 @@ mod tests { path } + fn write_skill_interface_at(skill_dir: &Path, contents: &str) -> PathBuf { + let path = skill_dir.join(SKILLS_TOML_FILENAME); + fs::write(&path, contents).unwrap(); + path + } + + #[tokio::test] + async fn loads_skill_interface_metadata_happy_path() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + + write_skill_interface_at( + skill_dir, + r##" +[interface] +display_name = "UI Skill" +short_description = " short desc " +icon_small = "./assets/small-400px.png" +icon_large = "./assets/large-logo.svg" +brand_color = "#3B82F6" +default_prompt = " default prompt " +"##, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from toml".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: Some("short desc".to_string()), + icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")), + icon_large: Some(normalized_skill_dir.join("./assets/large-logo.svg")), + brand_color: Some("#3B82F6".to_string()), + default_prompt: Some("default prompt".to_string()), + }), + path: normalized(&skill_path), + scope: SkillScope::User, + }] + ); + } + + #[tokio::test] + async fn accepts_icon_paths_under_assets_dir() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + + write_skill_interface_at( + skill_dir, + r#" +[interface] +display_name = "UI Skill" +icon_small = "assets/icon.png" +icon_large = "./assets/logo.svg" +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from toml".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: None, + icon_small: Some(normalized_skill_dir.join("assets/icon.png")), + icon_large: Some(normalized_skill_dir.join("./assets/logo.svg")), + brand_color: None, + default_prompt: None, + }), + path: normalized(&skill_path), + scope: SkillScope::User, + }] + ); + } + + #[tokio::test] + async fn ignores_invalid_brand_color() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_interface_at( + skill_dir, + r#" +[interface] +brand_color = "blue" +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from toml".to_string(), + short_description: None, + interface: None, + path: normalized(&skill_path), + scope: SkillScope::User, + }] + ); + } + + #[tokio::test] + async fn ignores_default_prompt_over_max_length() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); + + write_skill_interface_at( + skill_dir, + &format!( + r##" +[interface] +display_name = "UI Skill" +icon_small = "./assets/small-400px.png" +default_prompt = "{too_long}" +"## + ), + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from toml".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: None, + icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")), + icon_large: None, + brand_color: None, + default_prompt: None, + }), + path: normalized(&skill_path), + scope: SkillScope::User, + }] + ); + } + + #[tokio::test] + async fn drops_interface_when_icons_are_invalid() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_interface_at( + skill_dir, + r#" +[interface] +icon_small = "icon.png" +icon_large = "./assets/../logo.svg" +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from toml".to_string(), + short_description: None, + interface: None, + path: normalized(&skill_path), + scope: SkillScope::User, + }] + ); + } + #[cfg(unix)] fn symlink_dir(target: &Path, link: &Path) { std::os::unix::fs::symlink(target, link).unwrap(); @@ -563,6 +917,7 @@ mod tests { name: "linked-skill".to_string(), description: "from link".to_string(), short_description: None, + interface: None, path: normalized(&shared_skill_path), scope: SkillScope::User, }] @@ -571,7 +926,7 @@ mod tests { #[tokio::test] #[cfg(unix)] - async fn loads_skills_via_symlinked_skill_file_for_user_scope() { + async fn ignores_symlinked_skill_file_for_user_scope() { let codex_home = tempfile::tempdir().expect("tempdir"); let shared = tempfile::tempdir().expect("tempdir"); @@ -590,16 +945,7 @@ mod tests { "unexpected errors: {:?}", outcome.errors ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "linked-file-skill".to_string(), - description: "from link".to_string(), - short_description: None, - path: normalized(&shared_skill_path), - scope: SkillScope::User, - }] - ); + assert_eq!(outcome.skills, Vec::new()); } #[tokio::test] @@ -629,6 +975,7 @@ mod tests { name: "cycle-skill".to_string(), description: "still loads".to_string(), short_description: None, + interface: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -662,6 +1009,7 @@ mod tests { name: "admin-linked-skill".to_string(), description: "from link".to_string(), short_description: None, + interface: None, path: normalized(&shared_skill_path), scope: SkillScope::Admin, }] @@ -699,6 +1047,7 @@ mod tests { name: "repo-linked-skill".to_string(), description: "from link".to_string(), short_description: None, + interface: None, path: normalized(&linked_skill_path), scope: SkillScope::Repo, }] @@ -759,6 +1108,7 @@ mod tests { name: "within-depth-skill".to_string(), description: "loads".to_string(), short_description: None, + interface: None, path: normalized(&within_depth_path), scope: SkillScope::User, }] @@ -783,6 +1133,7 @@ mod tests { name: "demo-skill".to_string(), description: "does things carefully".to_string(), short_description: None, + interface: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -811,6 +1162,7 @@ mod tests { name: "demo-skill".to_string(), description: "long description".to_string(), short_description: Some("short summary".to_string()), + interface: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -920,6 +1272,7 @@ mod tests { name: "repo-skill".to_string(), description: "from repo".to_string(), short_description: None, + interface: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -970,6 +1323,7 @@ mod tests { name: "nested-skill".to_string(), description: "from nested".to_string(), short_description: None, + interface: None, path: normalized(&nested_skill_path), scope: SkillScope::Repo, }, @@ -977,6 +1331,7 @@ mod tests { name: "root-skill".to_string(), description: "from root".to_string(), short_description: None, + interface: None, path: normalized(&root_skill_path), scope: SkillScope::Repo, }, @@ -1013,6 +1368,7 @@ mod tests { name: "local-skill".to_string(), description: "from cwd".to_string(), short_description: None, + interface: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1050,6 +1406,7 @@ mod tests { name: "dupe-skill".to_string(), description: "from repo".to_string(), short_description: None, + interface: None, path: normalized(&repo_skill_path), scope: SkillScope::Repo, }] @@ -1077,6 +1434,7 @@ mod tests { name: "dupe-skill".to_string(), description: "from user".to_string(), short_description: None, + interface: None, path: normalized(&user_skill_path), scope: SkillScope::User, }] @@ -1145,6 +1503,7 @@ mod tests { name: "repo-skill".to_string(), description: "from repo".to_string(), short_description: None, + interface: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1200,6 +1559,7 @@ mod tests { name: "system-skill".to_string(), description: "from system".to_string(), short_description: None, + interface: None, path: normalized(&skill_path), scope: SkillScope::System, }] @@ -1254,6 +1614,7 @@ mod tests { name: "dupe-skill".to_string(), description: "from system".to_string(), short_description: None, + interface: None, path: normalized(&system_skill_path), scope: SkillScope::System, }] @@ -1283,6 +1644,7 @@ mod tests { name: "dupe-skill".to_string(), description: "from user".to_string(), short_description: None, + interface: None, path: normalized(&user_skill_path), scope: SkillScope::User, }] @@ -1321,6 +1683,7 @@ mod tests { name: "dupe-skill".to_string(), description: "from repo".to_string(), short_description: None, + interface: None, path: normalized(&repo_skill_path), scope: SkillScope::Repo, }] @@ -1371,6 +1734,7 @@ mod tests { name: "dupe-skill".to_string(), description: "from nested".to_string(), short_description: None, + interface: None, path: expected_path, scope: SkillScope::Repo, }], diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index 9063d7a25..fba00d271 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -7,10 +7,21 @@ pub struct SkillMetadata { pub name: String, pub description: String, pub short_description: Option, + pub interface: Option, pub path: PathBuf, pub scope: SkillScope, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillInterface { + pub display_name: Option, + pub short_description: Option, + pub icon_small: Option, + pub icon_large: Option, + pub brand_color: Option, + pub default_prompt: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SkillError { pub path: PathBuf, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6850c2ac1..df1b523cb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1962,13 +1962,33 @@ pub enum SkillScope { pub struct SkillMetadata { pub name: String, pub description: String, - #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + /// Legacy short_description from SKILL.md. Prefer SKILL.toml interface.short_description. pub short_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub interface: Option, pub path: PathBuf, pub scope: SkillScope, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)] +pub struct SkillInterface { + #[ts(optional)] + pub display_name: Option, + #[ts(optional)] + pub short_description: Option, + #[ts(optional)] + pub icon_small: Option, + #[ts(optional)] + pub icon_large: Option, + #[ts(optional)] + pub brand_color: Option, + #[ts(optional)] + pub default_prompt: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SkillErrorInfo { pub path: PathBuf, diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 8e894de45..867c10a8b 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -87,12 +87,8 @@ impl SkillPopup { .into_iter() .map(|(idx, indices, _score)| { let skill = &self.skills[idx]; - let name = truncate_text(&skill.name, 21); - let description = skill - .short_description - .as_ref() - .unwrap_or(&skill.description) - .clone(); + let name = truncate_text(skill_display_name(skill), 21); + let description = skill_description(skill).to_string(); GenericDisplayRow { name, match_indices: indices, @@ -117,15 +113,20 @@ impl SkillPopup { } for (idx, skill) in self.skills.iter().enumerate() { - if let Some((indices, score)) = fuzzy_match(&skill.name, filter) { + let display_name = skill_display_name(skill); + if let Some((indices, score)) = fuzzy_match(display_name, filter) { out.push((idx, Some(indices), score)); + } else if display_name != skill.name + && let Some((_indices, score)) = fuzzy_match(&skill.name, filter) + { + out.push((idx, None, score)); } } out.sort_by(|a, b| { a.2.cmp(&b.2).then_with(|| { - let an = &self.skills[a.0].name; - let bn = &self.skills[b.0].name; + let an = skill_display_name(&self.skills[a.0]); + let bn = skill_display_name(&self.skills[b.0]); an.cmp(bn) }) }); @@ -177,3 +178,20 @@ fn skill_popup_hint_line() -> Line<'static> { " to close".into(), ]) } + +fn skill_display_name(skill: &SkillMetadata) -> &str { + skill + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .unwrap_or(&skill.name) +} + +fn skill_description(skill: &SkillMetadata) -> &str { + skill + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()) + .or(skill.short_description.as_deref()) + .unwrap_or(&skill.description) +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index cb27e0a7a..dbec3b47f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -87,6 +87,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_core::skills::model::SkillInterface; use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; @@ -4594,6 +4595,14 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec Line<'static> { " to close".into(), ]) } + +fn skill_display_name(skill: &SkillMetadata) -> &str { + skill + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .unwrap_or(&skill.name) +} + +fn skill_description(skill: &SkillMetadata) -> &str { + skill + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()) + .or(skill.short_description.as_deref()) + .unwrap_or(&skill.description) +} diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 15914c102..80ec93628 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -85,6 +85,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_core::skills::model::SkillInterface; use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; @@ -4303,6 +4304,14 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec