Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions code-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,7 @@ mod tests {
let skills = vec![SkillMetadata {
name: "manual-skill".to_string(),
description: "Manual skill".to_string(),
short_description: None,
path: PathBuf::from("/tmp/manual-skill/SKILL.md"),
scope: SkillScope::User,
content: "manual body".to_string(),
Expand Down
2 changes: 1 addition & 1 deletion code-rs/core/src/codex/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1292,7 +1292,7 @@ pub(super) async fn submission_loop(
.map(|skill| code_protocol::protocol::SkillMetadata {
name: skill.name,
description: skill.description,
short_description: None,
short_description: skill.short_description,
interface: None,
dependencies: None,
path: skill.path,
Expand Down
1 change: 1 addition & 0 deletions code-rs/core/src/project_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ mod tests {
let skills = vec![SkillMetadata {
name: "demo".to_string(),
description: "Demo skill".to_string(),
short_description: None,
path: skill_path,
scope: SkillScope::User,
content: String::new(),
Expand Down
122 changes: 122 additions & 0 deletions code-rs/core/src/skills/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,17 @@ struct SkillFrontmatter {
name: String,
description: String,
#[serde(default)]
metadata: Option<SkillFrontmatterMetadata>,
#[serde(default)]
policy: Option<SkillFrontmatterPolicy>,
}

#[derive(Debug, Deserialize)]
struct SkillFrontmatterMetadata {
#[serde(default, rename = "short-description")]
short_description: Option<String>,
}

#[derive(Debug, Deserialize)]
struct SkillFrontmatterPolicy {
#[serde(default)]
Expand All @@ -40,6 +48,7 @@ const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
const ADMIN_SKILLS_ROOT: &str = "/etc/codex/skills";
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
const MAX_SHORT_DESCRIPTION_LEN: usize = 160;

#[derive(Debug)]
enum SkillParseError {
Expand Down Expand Up @@ -317,15 +326,28 @@ 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
.and_then(|metadata| metadata.short_description)
.map(|value| sanitize_single_line(&value))
.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,
content: contents,
Expand Down Expand Up @@ -456,6 +478,26 @@ mod tests {
skill_path
}

fn write_skill_with_short_description_at(
skills_root: &Path,
dir: &str,
name: &str,
description: &str,
short_description: &str,
) -> PathBuf {
let skill_dir = skills_root.join(dir);
fs::create_dir_all(&skill_dir).expect("create skill dir");
let skill_path = skill_dir.join(SKILLS_FILENAME);
fs::write(
&skill_path,
format!(
"---\nname: {name}\ndescription: {description}\nmetadata:\n short-description: {short_description}\n---\n\n# {name}\n"
),
)
.expect("write skill file");
skill_path
}

fn write_manual_skill_at(
skills_root: &Path,
dir: &str,
Expand Down Expand Up @@ -511,6 +553,86 @@ mod tests {
assert!(!skill.allow_implicit_invocation());
}

#[test]
fn loads_optional_short_description_from_metadata_frontmatter() {
let skills_root = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill_with_short_description_at(
skills_root.path(),
"compact",
"compact-skill",
"Long model-visible trigger description",
"Compact UI summary",
);

let outcome = load_skills_from_roots(vec![SkillRoot {
path: skills_root.path().to_path_buf(),
scope: SkillScope::User,
}]);

assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
let skill = &outcome.skills[0];
assert_eq!(skill.path, normalized(&skill_path));
assert_eq!(skill.description, "Long model-visible trigger description");
assert_eq!(skill.short_description.as_deref(), Some("Compact UI summary"));
}

#[test]
fn ignores_empty_short_description_metadata() {
let skills_root = tempfile::tempdir().expect("tempdir");
write_skill_with_short_description_at(
skills_root.path(),
"empty-compact",
"empty-compact-skill",
"Full description",
" ",
);

let outcome = load_skills_from_roots(vec![SkillRoot {
path: skills_root.path().to_path_buf(),
scope: SkillScope::User,
}]);

assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].short_description, None);
}

#[test]
fn rejects_too_long_short_description_metadata() {
let skills_root = tempfile::tempdir().expect("tempdir");
write_skill_with_short_description_at(
skills_root.path(),
"long-compact",
"long-compact-skill",
"Full description",
&"x".repeat(MAX_SHORT_DESCRIPTION_LEN + 1),
);

let outcome = load_skills_from_roots(vec![SkillRoot {
path: skills_root.path().to_path_buf(),
scope: SkillScope::User,
}]);

assert!(outcome.skills.is_empty());
assert_eq!(outcome.errors.len(), 1);
assert!(
outcome.errors[0]
.message
.contains("invalid metadata.short-description"),
"unexpected error: {:?}",
outcome.errors
);
}

#[test]
fn loads_skills_from_agents_dir_without_codex_dir() {
let code_home = tempfile::tempdir().expect("tempdir");
Expand Down
1 change: 1 addition & 0 deletions code-rs/core/src/skills/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub enum SkillScope {
pub struct SkillMetadata {
pub name: String,
pub description: String,
pub short_description: Option<String>,
pub path: PathBuf,
pub scope: SkillScope,
pub content: String,
Expand Down
13 changes: 13 additions & 0 deletions code-rs/core/src/skills/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ mod tests {
SkillMetadata {
name: name.to_string(),
description: format!("{name} description"),
short_description: None,
path: PathBuf::from(format!("/tmp/{name}/SKILL.md")),
scope: SkillScope::User,
content: String::new(),
Expand Down Expand Up @@ -84,4 +85,16 @@ mod tests {

assert!(rendered.is_none());
}

#[test]
fn render_skills_section_uses_full_description_for_model_context() {
let mut skill = skill("compact", None);
skill.description = "full trigger description".to_string();
skill.short_description = Some("compact UI summary".to_string());

let rendered = render_skills_section(&[skill]).expect("skill should render");

assert!(rendered.contains("- compact: full trigger description"));
assert!(!rendered.contains("compact UI summary"));
}
}
3 changes: 3 additions & 0 deletions code-rs/protocol/src/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use ts_rs::TS;
pub struct Skill {
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub short_description: Option<String>,
pub path: PathBuf,
pub scope: SkillScope,
pub content: String,
Expand Down
100 changes: 99 additions & 1 deletion code-rs/tui/src/bottom_pane/skills_settings_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,14 @@ impl SkillsSettingsView {
};
let name_span = Span::styled(format!("{arrow} {name}", name = skill.name), name_style);
let scope_span = Span::styled(scope_text, Style::default().fg(colors::text_dim()));
let display_description = skill
.short_description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(skill.description.as_str());
let desc_span = Span::styled(
format!(" {desc}", desc = skill.description),
format!(" {display_description}"),
Style::default().fg(colors::text_dim()),
);
lines.push(Line::from(vec![name_span, scope_span, desc_span]));
Expand Down Expand Up @@ -421,13 +427,15 @@ impl SkillsSettingsView {

let description = frontmatter_value(&body, "description")
.unwrap_or_else(|| "No description".to_string());
let short_description = frontmatter_metadata_short_description(&body);
let display_name = frontmatter_value(&body, "name").unwrap_or_else(|| name.clone());

let mut updated = self.skills.clone();
let new_entry = Skill {
name: display_name,
path,
description,
short_description,
scope: SkillScope::User,
content: body.clone(),
};
Expand Down Expand Up @@ -527,3 +535,93 @@ fn frontmatter_value(body: &str, key: &str) -> Option<String> {
}
None
}

fn frontmatter_metadata_short_description(body: &str) -> Option<String> {
let frontmatter = extract_frontmatter(body)?;
let mut in_metadata = false;
for line in frontmatter.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if !line.starts_with(' ') && !line.starts_with('\t') {
in_metadata = trimmed == "metadata:";
continue;
}
if in_metadata {
let child = trimmed;
if let Some(rest) = child.strip_prefix("short-description:") {
let value = rest.trim().trim_matches('"');
if !value.is_empty() {
return Some(value.to_string());
}
}
}
}
None
}

#[cfg(test)]
mod tests {
use super::*;
use code_protocol::skills::SkillScope;
use std::path::PathBuf;
use std::sync::mpsc;

fn skill(name: &str, description: &str, short_description: Option<&str>) -> Skill {
Skill {
name: name.to_string(),
description: description.to_string(),
short_description: short_description.map(str::to_string),
path: PathBuf::from(format!("/tmp/{name}/SKILL.md")),
scope: SkillScope::User,
content: String::new(),
}
}

#[test]
fn list_prefers_short_description_when_present() {
let (tx, _rx) = mpsc::channel();
let view = SkillsSettingsView::new(
vec![skill(
"compact",
"Long model-visible trigger description",
Some("Compact UI summary"),
)],
AppEventSender::new(tx),
);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5));

view.render(Rect::new(0, 0, 80, 5), &mut buf);
let rendered = buf_to_string(&buf);

assert!(rendered.contains("Compact UI summary"), "{rendered}");
assert!(
!rendered.contains("Long model-visible trigger description"),
"{rendered}"
);
}

#[test]
fn reads_nested_frontmatter_short_description() {
let body = "---\nname: Demo\ndescription: Full trigger\nmetadata:\n short-description: Compact summary\n---\nBody";

assert_eq!(
frontmatter_metadata_short_description(body).as_deref(),
Some("Compact summary")
);
}

fn buf_to_string(buf: &Buffer) -> String {
let area = buf.area;
let mut lines = Vec::new();
for y in area.y..area.y + area.height {
let mut line = String::new();
for x in area.x..area.x + area.width {
line.push_str(buf[(x, y)].symbol());
}
lines.push(line.trim_end().to_string());
}
lines.join("\n")
}
}
1 change: 1 addition & 0 deletions code-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15929,6 +15929,7 @@ impl ChatWidget<'_> {
skills.push(code_core::protocol::Skill {
name: meta.name,
description: meta.description,
short_description: meta.short_description,
path: meta.path,
scope,
content,
Expand Down