feat: experimental support for skills.md (#7412)

This change prototypes support for Skills with the CLI. This is an
**experimental** feature for internal testing.

---------

Co-authored-by: Gav Verma <gverma@openai.com>
This commit is contained in:
Thibault Sottiaux
2025-12-01 20:22:35 -08:00
committed by GitHub
parent 32e4a3a4d7
commit a8d5ad37b8
15 changed files with 795 additions and 9 deletions

View File

@@ -14,6 +14,9 @@
//! 3. We do **not** walk past the Git root.
use crate::config::Config;
use crate::features::Feature;
use crate::skills::load_skills;
use crate::skills::render_skills_section;
use dunce::canonicalize as normalize_path;
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
@@ -31,18 +34,47 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
/// string of instructions.
pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
match read_project_docs(config).await {
Ok(Some(project_doc)) => match &config.user_instructions {
Some(original_instructions) => Some(format!(
"{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}"
)),
None => Some(project_doc),
},
Ok(None) => config.user_instructions.clone(),
let skills_section = if config.features.enabled(Feature::Skills) {
let skills_outcome = load_skills(config);
for err in &skills_outcome.errors {
error!(
"failed to load skill {}: {}",
err.path.display(),
err.message
);
}
render_skills_section(&skills_outcome.skills)
} else {
None
};
let project_docs = match read_project_docs(config).await {
Ok(docs) => docs,
Err(e) => {
error!("error trying to find project doc: {e:#}");
config.user_instructions.clone()
return config.user_instructions.clone();
}
};
let combined_project_docs = merge_project_docs_with_skills(project_docs, skills_section);
let mut parts: Vec<String> = Vec::new();
if let Some(instructions) = config.user_instructions.clone() {
parts.push(instructions);
}
if let Some(project_doc) = combined_project_docs {
if !parts.is_empty() {
parts.push(PROJECT_DOC_SEPARATOR.to_string());
}
parts.push(project_doc);
}
if parts.is_empty() {
None
} else {
Some(parts.concat())
}
}
@@ -195,12 +227,25 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
names
}
fn merge_project_docs_with_skills(
project_doc: Option<String>,
skills_section: Option<String>,
) -> Option<String> {
match (project_doc, skills_section) {
(Some(doc), Some(skills)) => Some(format!("{doc}\n\n{skills}")),
(Some(doc), None) => Some(doc),
(None, Some(skills)) => Some(skills),
(None, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
/// Helper that returns a `Config` pointing at `root` and using `limit` as
@@ -219,6 +264,7 @@ mod tests {
config.cwd = root.path().to_path_buf();
config.project_doc_max_bytes = limit;
config.features.enable(Feature::Skills);
config.user_instructions = instructions.map(ToOwned::to_owned);
config
@@ -447,4 +493,58 @@ mod tests {
.eq(DEFAULT_PROJECT_DOC_FILENAME)
);
}
#[tokio::test]
async fn skills_are_appended_to_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();
let cfg = make_config(&tmp, 4096, None);
create_skill(
cfg.codex_home.clone(),
"pdf-processing",
"extract from pdfs",
);
let res = get_user_instructions(&cfg)
.await
.expect("instructions expected");
let expected_path = dunce::canonicalize(
cfg.codex_home
.join("skills/pdf-processing/SKILL.md")
.as_path(),
)
.unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md"));
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
let expected = format!(
"base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})"
);
assert_eq!(res, expected);
}
#[tokio::test]
async fn skills_render_without_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg = make_config(&tmp, 4096, None);
create_skill(cfg.codex_home.clone(), "linting", "run clippy");
let res = get_user_instructions(&cfg)
.await
.expect("instructions expected");
let expected_path =
dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path())
.unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md"));
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
let expected = format!(
"## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})"
);
assert_eq!(res, expected);
}
fn create_skill(codex_home: PathBuf, name: &str, description: &str) {
let skill_dir = codex_home.join(format!("skills/{name}"));
fs::create_dir_all(&skill_dir).unwrap();
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
}
}