Support skills shortDescription. (#8278)

Allow SKILL.md to specify a more human-readable short description as
skill metadata.
This commit is contained in:
xl-openai
2025-12-18 15:13:18 -08:00
committed by GitHub
Unverified
parent 1cd1cf17c6
commit 358a5baba0
11 changed files with 94 additions and 2 deletions
@@ -1057,6 +1057,9 @@ pub enum SkillScope {
pub struct SkillMetadata {
pub name: String,
pub description: String,
#[ts(optional)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short_description: Option<String>,
pub path: PathBuf,
pub scope: SkillScope,
}
@@ -1083,6 +1086,7 @@ impl From<CoreSkillMetadata> for SkillMetadata {
Self {
name: value.name,
description: value.description,
short_description: value.short_description,
path: value.path,
scope: value.scope.into(),
}
@@ -3319,6 +3319,7 @@ fn skills_to_info(
.map(|skill| codex_app_server_protocol::SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
path: skill.path.clone(),
scope: skill.scope.into(),
})
+1
View File
@@ -2187,6 +2187,7 @@ fn skills_to_info(skills: &[SkillMetadata]) -> Vec<ProtocolSkillMetadata> {
.map(|skill| ProtocolSkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
path: skill.path.clone(),
scope: skill.scope,
})
@@ -1,6 +1,8 @@
---
name: plan
description: Generate a plan for how an agent should accomplish a complex coding task. Use when a user asks for a plan, and optionally when they want to save, find, read, update, or delete plan files in $CODEX_HOME/plans (default ~/.codex/plans).
metadata:
short-description: Create and manage plan markdown files under $CODEX_HOME/plans.
---
# Plan
+70
View File
@@ -20,6 +20,14 @@ use tracing::error;
struct SkillFrontmatter {
name: String,
description: String,
#[serde(default)]
metadata: SkillFrontmatterMetadata,
}
#[derive(Debug, Default, Deserialize)]
struct SkillFrontmatterMetadata {
#[serde(default, rename = "short-description")]
short_description: Option<String>,
}
const SKILLS_FILENAME: &str = "SKILL.md";
@@ -27,6 +35,7 @@ const SKILLS_DIR_NAME: &str = "skills";
const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;
#[derive(Debug)]
enum SkillParseError {
@@ -218,15 +227,29 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
let name = sanitize_single_line(&parsed.name);
let description = sanitize_single_line(&parsed.description);
let short_description = parsed
.metadata
.short_description
.as_deref()
.map(sanitize_single_line)
.filter(|value| !value.is_empty());
validate_field(&name, MAX_NAME_LEN, "name")?;
validate_field(&description, MAX_DESCRIPTION_LEN, "description")?;
if let Some(short_description) = short_description.as_deref() {
validate_field(
short_description,
MAX_SHORT_DESCRIPTION_LEN,
"metadata.short-description",
)?;
}
let resolved_path = normalize_path(path).unwrap_or_else(|_| path.to_path_buf());
Ok(SkillMetadata {
name,
description,
short_description,
path: resolved_path,
scope,
})
@@ -345,6 +368,7 @@ mod tests {
let skill = &outcome.skills[0];
assert_eq!(skill.name, "demo-skill");
assert_eq!(skill.description, "does things carefully");
assert_eq!(skill.short_description, None);
let path_str = skill.path.to_string_lossy().replace('\\', "/");
assert!(
path_str.ends_with("skills/demo/SKILL.md"),
@@ -352,6 +376,52 @@ mod tests {
);
}
#[test]
fn loads_short_description_from_metadata() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_dir = codex_home.path().join("skills/demo");
fs::create_dir_all(&skill_dir).unwrap();
let contents = "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: short summary\n---\n\n# Body\n";
fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap();
let cfg = make_config(&codex_home);
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(
outcome.skills[0].short_description,
Some("short summary".to_string())
);
}
#[test]
fn enforces_short_description_length_limits() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_dir = codex_home.path().join("skills/demo");
fs::create_dir_all(&skill_dir).unwrap();
let too_long = "x".repeat(MAX_SHORT_DESCRIPTION_LEN + 1);
let contents = format!(
"---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: {too_long}\n---\n\n# Body\n"
);
fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap();
let cfg = make_config(&codex_home);
let outcome = load_skills(&cfg);
assert_eq!(outcome.skills.len(), 0);
assert_eq!(outcome.errors.len(), 1);
assert!(
outcome.errors[0]
.message
.contains("invalid metadata.short-description"),
"expected length error, got: {:?}",
outcome.errors
);
}
#[test]
fn skips_hidden_and_invalid() {
let codex_home = tempfile::tempdir().expect("tempdir");
+1
View File
@@ -6,6 +6,7 @@ use codex_protocol::protocol::SkillScope;
pub struct SkillMetadata {
pub name: String,
pub description: String,
pub short_description: Option<String>,
pub path: PathBuf,
pub scope: SkillScope,
}
+3
View File
@@ -1697,6 +1697,9 @@ pub enum SkillScope {
pub struct SkillMetadata {
pub name: String,
pub description: String,
#[ts(optional)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short_description: Option<String>,
pub path: PathBuf,
pub scope: SkillScope,
}
+5 -1
View File
@@ -86,7 +86,11 @@ impl SkillPopup {
.and_then(|n| n.to_str())
.unwrap_or(&skill.name);
let name = format!("{} ({slug})", skill.name);
let description = skill.description.clone();
let description = skill
.short_description
.as_ref()
.unwrap_or(&skill.description)
.clone();
GenericDisplayRow {
name,
match_indices: indices,
+1
View File
@@ -3634,6 +3634,7 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMe
.map(|skill| SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
path: skill.path.clone(),
scope: skill.scope,
})
+5 -1
View File
@@ -86,7 +86,11 @@ impl SkillPopup {
.and_then(|n| n.to_str())
.unwrap_or(&skill.name);
let name = format!("{} ({slug})", skill.name);
let description = skill.description.clone();
let description = skill
.short_description
.as_ref()
.unwrap_or(&skill.description)
.clone();
GenericDisplayRow {
name,
match_indices: indices,
+1
View File
@@ -3539,6 +3539,7 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMe
.map(|skill| SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
path: skill.path.clone(),
scope: skill.scope,
})