diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ca7e29dda2..adbb2fa775 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1186,6 +1186,7 @@ dependencies = [ "seccompiler", "serde", "serde_json", + "serde_yaml", "serial_test", "sha1", "sha2", @@ -5773,6 +5774,19 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial2" version = "0.2.31" @@ -6987,6 +7001,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index caaa13068a..2339cd4e67 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -178,6 +178,7 @@ seccompiler = "0.5.0" sentry = "0.34.0" serde = "1" serde_json = "1" +serde_yaml = "0.9" serde_with = "3.16" serial_test = "3.2.0" sha1 = "0.10.6" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 7bd2d652e1..8a329d0672 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -52,6 +52,7 @@ regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_yaml = { workspace = true } sha1 = { workspace = true } sha2 = { workspace = true } shlex = { workspace = true } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 0c67c9ff5c..14f0404b35 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -51,6 +51,8 @@ pub enum Feature { ShellTool, /// Allow model to call multiple tools in parallel (only for models supporting it). ParallelToolCalls, + /// Experimental skills injection (CLI flag-driven). + Skills, } impl Feature { @@ -326,4 +328,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::Skills, + key: "skills", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 7a9440eb28..a6a7d7541e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -72,6 +72,7 @@ mod rollout; pub(crate) mod safety; pub mod seatbelt; pub mod shell; +pub mod skills; pub mod spawn; pub mod terminal; mod tools; diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index c8076aa822..ee3148e7e7 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -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 { - 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 = 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, + skills_section: Option, +) -> Option { + 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(); + } } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs new file mode 100644 index 0000000000..a9ea156f02 --- /dev/null +++ b/codex-rs/core/src/skills/loader.rs @@ -0,0 +1,291 @@ +use crate::config::Config; +use crate::skills::model::SkillError; +use crate::skills::model::SkillLoadOutcome; +use crate::skills::model::SkillMetadata; +use dunce::canonicalize as normalize_path; +use serde::Deserialize; +use std::collections::VecDeque; +use std::error::Error; +use std::fmt; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use tracing::error; + +#[derive(Debug, Deserialize)] +struct SkillFrontmatter { + name: String, + description: String, +} + +const SKILLS_FILENAME: &str = "SKILL.md"; +const SKILLS_DIR_NAME: &str = "skills"; +const MAX_NAME_LEN: usize = 100; +const MAX_DESCRIPTION_LEN: usize = 500; + +#[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 fn load_skills(config: &Config) -> SkillLoadOutcome { + let mut outcome = SkillLoadOutcome::default(); + let roots = skill_roots(config); + for root in roots { + discover_skills_under_root(&root, &mut outcome); + } + + outcome + .skills + .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path))); + + outcome +} + +fn skill_roots(config: &Config) -> Vec { + vec![config.codex_home.join(SKILLS_DIR_NAME)] +} + +fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { + let Ok(root) = normalize_path(root) else { + return; + }; + + if !root.is_dir() { + return; + } + + let mut queue: VecDeque = VecDeque::from([root]); + while let Some(dir) = queue.pop_front() { + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) => { + error!("failed to read skills dir {}: {e:#}", dir.display()); + continue; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_name = match path.file_name().and_then(|f| f.to_str()) { + Some(name) => name, + None => continue, + }; + + if file_name.starts_with('.') { + continue; + } + + let Ok(file_type) = entry.file_type() else { + continue; + }; + + if file_type.is_symlink() { + continue; + } + + if file_type.is_dir() { + queue.push_back(path); + continue; + } + + if file_type.is_file() && file_name == SKILLS_FILENAME { + match parse_skill_file(&path) { + Ok(skill) => outcome.skills.push(skill), + Err(err) => outcome.errors.push(SkillError { + path, + message: err.to_string(), + }), + } + } + } + } +} + +fn parse_skill_file(path: &Path) -> Result { + let contents = fs::read_to_string(path).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 name = sanitize_single_line(&parsed.name); + let description = sanitize_single_line(&parsed.description); + + validate_field(&name, MAX_NAME_LEN, "name")?; + validate_field(&description, MAX_DESCRIPTION_LEN, "description")?; + + let resolved_path = normalize_path(path).unwrap_or_else(|_| path.to_path_buf()); + + Ok(SkillMetadata { + name, + description, + path: resolved_path, + }) +} + +fn sanitize_single_line(raw: &str) -> String { + raw.split_whitespace().collect::>().join(" ") +} + +fn validate_field( + value: &str, + max_len: usize, + field_name: &'static str, +) -> Result<(), SkillParseError> { + if value.is_empty() { + return Err(SkillParseError::MissingField(field_name)); + } + if value.len() > max_len { + return Err(SkillParseError::InvalidField { + field: field_name, + reason: format!("exceeds maximum length of {max_len} characters"), + }); + } + Ok(()) +} + +fn extract_frontmatter(contents: &str) -> Option { + 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)] +mod tests { + use super::*; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use tempfile::TempDir; + + fn make_config(codex_home: &TempDir) -> Config { + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("defaults for test should always succeed"); + + config.cwd = codex_home.path().to_path_buf(); + config + } + + fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { + let skill_dir = codex_home.path().join(format!("skills/{dir}")); + fs::create_dir_all(&skill_dir).unwrap(); + let indented_description = description.replace('\n', "\n "); + let content = format!( + "---\nname: {name}\ndescription: |-\n {indented_description}\n---\n\n# Body\n" + ); + let path = skill_dir.join(SKILLS_FILENAME); + fs::write(&path, content).unwrap(); + path + } + + #[test] + fn loads_valid_skill() { + let codex_home = tempfile::tempdir().expect("tempdir"); + write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully"); + 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); + let skill = &outcome.skills[0]; + assert_eq!(skill.name, "demo-skill"); + assert_eq!(skill.description, "does things carefully"); + let path_str = skill.path.to_string_lossy().replace('\\', "/"); + assert!( + path_str.ends_with("skills/demo/SKILL.md"), + "unexpected path {path_str}" + ); + } + + #[test] + fn skips_hidden_and_invalid() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let hidden_dir = codex_home.path().join("skills/.hidden"); + fs::create_dir_all(&hidden_dir).unwrap(); + fs::write( + hidden_dir.join(SKILLS_FILENAME), + "---\nname: hidden\ndescription: hidden\n---\n", + ) + .unwrap(); + + // Invalid because missing closing frontmatter. + let invalid_dir = codex_home.path().join("skills/invalid"); + fs::create_dir_all(&invalid_dir).unwrap(); + fs::write(invalid_dir.join(SKILLS_FILENAME), "---\nname: bad").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("missing YAML frontmatter"), + "expected frontmatter error" + ); + } + + #[test] + fn enforces_length_limits() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let long_desc = "a".repeat(MAX_DESCRIPTION_LEN + 1); + write_skill(&codex_home, "too-long", "toolong", &long_desc); + 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 description"), + "expected length error" + ); + } +} diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs new file mode 100644 index 0000000000..ebb1490c99 --- /dev/null +++ b/codex-rs/core/src/skills/mod.rs @@ -0,0 +1,9 @@ +pub mod loader; +pub mod model; +pub mod render; + +pub use loader::load_skills; +pub use model::SkillError; +pub use model::SkillLoadOutcome; +pub use model::SkillMetadata; +pub use render::render_skills_section; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs new file mode 100644 index 0000000000..83a3317d57 --- /dev/null +++ b/codex-rs/core/src/skills/model.rs @@ -0,0 +1,20 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillMetadata { + pub name: String, + pub description: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillError { + pub path: PathBuf, + pub message: String, +} + +#[derive(Debug, Clone, Default)] +pub struct SkillLoadOutcome { + pub skills: Vec, + pub errors: Vec, +} diff --git a/codex-rs/core/src/skills/render.rs b/codex-rs/core/src/skills/render.rs new file mode 100644 index 0000000000..d547e21c28 --- /dev/null +++ b/codex-rs/core/src/skills/render.rs @@ -0,0 +1,21 @@ +use crate::skills::model::SkillMetadata; + +pub fn render_skills_section(skills: &[SkillMetadata]) -> Option { + if skills.is_empty() { + return None; + } + + let mut lines: Vec = Vec::new(); + lines.push("## Skills".to_string()); + lines.push("These 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.".to_string()); + + for skill in skills { + let path_str = skill.path.to_string_lossy().replace('\\', "/"); + lines.push(format!( + "- {}: {} (file: {})", + skill.name, skill.description, path_str + )); + } + + Some(lines.join("\n")) +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 71bcd5192d..e074d29755 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -15,6 +15,7 @@ use codex_core::WireApi; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; use codex_core::error::CodexErr; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -34,6 +35,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use dunce::canonicalize as normalize_path; use futures::StreamExt; use serde_json::json; use std::io::Write; @@ -620,6 +622,74 @@ async fn includes_user_instructions_message_in_request() { assert_message_ends_with(&request_body["input"][1], ""); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skills_append_to_instructions_when_feature_enabled() { + skip_if_no_network!(); + let server = MockServer::start().await; + + let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let codex_home = TempDir::new().unwrap(); + let skill_dir = codex_home.path().join("skills/demo"); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: demo\ndescription: build charts\n---\n\n# body\n", + ) + .expect("write skill"); + + let mut config = load_default_config_for_test(&codex_home); + config.model_provider = model_provider; + config.features.enable(Feature::Skills); + config.cwd = codex_home.path().to_path_buf(); + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = resp_mock.single_request(); + let request_body = request.body_json(); + + assert_message_role(&request_body["input"][0], "user"); + let instructions_text = request_body["input"][0]["content"][0]["text"] + .as_str() + .expect("instructions text"); + assert!( + instructions_text.contains("## Skills"), + "expected skills section present" + ); + assert!( + instructions_text.contains("demo: build charts"), + "expected skill summary" + ); + let expected_path = normalize_path(skill_dir.join("SKILL.md")).unwrap(); + let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + assert!( + instructions_text.contains(&expected_path_str), + "expected path {expected_path_str} in instructions" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn includes_configured_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d0e057102c..2ba7e4d871 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -14,6 +14,8 @@ use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; +use crate::skill_error_prompt::SkillErrorPromptOutcome; +use crate::skill_error_prompt::run_skill_error_prompt; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -36,6 +38,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_core::skills::load_skills; use codex_protocol::ConversationId; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -267,6 +270,20 @@ impl App { SessionSource::Cli, )); + let skills_outcome = load_skills(&config); + if !skills_outcome.errors.is_empty() { + match run_skill_error_prompt(tui, &skills_outcome.errors).await { + SkillErrorPromptOutcome::Exit => { + return Ok(AppExitInfo { + token_usage: TokenUsage::default(), + conversation_id: None, + update_action: None, + }); + } + SkillErrorPromptOutcome::Continue => {} + } + } + let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match resume_selection { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 33bd18c437..15e9ccb92b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -67,6 +67,7 @@ mod resume_picker; mod selection_list; mod session_log; mod shimmer; +mod skill_error_prompt; mod slash_command; mod status; mod status_indicator_widget; diff --git a/codex-rs/tui/src/skill_error_prompt.rs b/codex-rs/tui/src/skill_error_prompt.rs new file mode 100644 index 0000000000..33d3b5dce1 --- /dev/null +++ b/codex-rs/tui/src/skill_error_prompt.rs @@ -0,0 +1,164 @@ +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use codex_core::skills::SkillError; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use tokio_stream::StreamExt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SkillErrorPromptOutcome { + Continue, + Exit, +} + +pub(crate) async fn run_skill_error_prompt( + tui: &mut Tui, + errors: &[SkillError], +) -> SkillErrorPromptOutcome { + struct AltScreenGuard<'a> { + tui: &'a mut Tui, + } + impl<'a> AltScreenGuard<'a> { + fn enter(tui: &'a mut Tui) -> Self { + let _ = tui.enter_alt_screen(); + Self { tui } + } + } + impl Drop for AltScreenGuard<'_> { + fn drop(&mut self) { + let _ = self.tui.leave_alt_screen(); + } + } + + let alt = AltScreenGuard::enter(tui); + let mut screen = SkillErrorScreen::new(alt.tui.frame_requester(), errors); + + let _ = alt.tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + }); + + let events = alt.tui.event_stream(); + tokio::pin!(events); + + while !screen.is_done() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Paste(_) => {} + TuiEvent::Draw => { + let _ = alt.tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + }); + } + } + } else { + screen.confirm_continue(); + break; + } + } + + screen.outcome() +} + +struct SkillErrorScreen { + request_frame: FrameRequester, + lines: Vec>, + done: bool, + exit: bool, +} + +impl SkillErrorScreen { + fn new(request_frame: FrameRequester, errors: &[SkillError]) -> Self { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("Skill validation errors detected".bold())); + lines.push(Line::from( + "Fix these SKILL.md files and restart. Invalid skills are ignored until resolved. Press enter or esc to continue, Ctrl+C or Ctrl+D to exit.", + )); + lines.push(Line::from("")); + + for error in errors { + let message = format!("- {}: {}", error.path.display(), error.message); + lines.push(Line::from(message)); + } + + Self { + request_frame, + lines, + done: false, + exit: false, + } + } + + fn is_done(&self) -> bool { + self.done + } + + fn confirm_continue(&mut self) { + self.done = true; + self.exit = false; + self.request_frame.schedule_frame(); + } + + fn confirm_exit(&mut self) { + self.done = true; + self.exit = true; + self.request_frame.schedule_frame(); + } + + fn outcome(&self) -> SkillErrorPromptOutcome { + if self.exit { + SkillErrorPromptOutcome::Exit + } else { + SkillErrorPromptOutcome::Continue + } + } + + fn handle_key(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + if key_event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::META) + && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) + { + self.confirm_exit(); + return; + } + + match key_event.code { + KeyCode::Enter | KeyCode::Esc | KeyCode::Char(' ') | KeyCode::Char('q') => { + self.confirm_continue(); + } + _ => {} + } + } +} + +impl WidgetRef for &SkillErrorScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let block = Block::default() + .title("Skill errors".bold()) + .borders(Borders::ALL); + Paragraph::new(self.lines.clone()) + .block(block) + .wrap(Wrap { trim: true }) + .render(area, buf); + } +} diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000000..81bc278797 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,62 @@ +# Skills (experimental) + +> **Warning:** This is an experimental and non-stable feature. If you depend on it, please expect breaking changes over the coming weeks and understand that there is currently no guarantee that this works well. Use at your own risk! + +Codex can automatically discover reusable "skills" you keep on disk. A skill is a small bundle with a name, a short description (what it does and when to use it), and an optional body of instructions you can open when needed. Codex injects only the name, description, and file path into the runtime context; the body stays on disk. + +## Where skills live + +- Location (v1): `~/.codex/skills/**/SKILL.md` (recursive). Hidden entries and symlinks are skipped. Only files named exactly `SKILL.md` count. +- Sorting: rendered by name, then path for stability. + +## File format + +- YAML frontmatter + body. + - Required: + - `name` (non-empty, ≤100 chars, sanitized to one line) + - `description` (non-empty, ≤500 chars, sanitized to one line) + - Extra keys are ignored. The body can contain any Markdown; it is not injected into context. + +## Loading and rendering + +- Loaded once at startup. +- If valid skills exist, Codex appends a runtime-only `## Skills` section after `AGENTS.md`, one bullet per skill: `- : (file: /absolute/path/to/SKILL.md)`. +- If no valid skills exist, the section is omitted. On-disk files are never modified. + +## Validation and errors + +- Invalid skills (missing/invalid YAML, empty/over-length fields) trigger a blocking, dismissible startup modal in the TUI that lists each path and error. Errors are also logged. You can dismiss to continue (invalid skills are ignored) or exit. Fix SKILL.md files and restart to clear the modal. + +## Create a skill + +1. Create `~/.codex/skills//`. +2. Add `SKILL.md`: + + ``` + --- + name: your-skill-name + description: what it does and when to use it (<=500 chars) + --- + + # Optional body + Add instructions, references, examples, or scripts (kept on disk). + ``` + +3. Keep `name`/`description` within the limits; avoid newlines in those fields. +4. Restart Codex to load the new skill. + +## Example + +``` +mkdir -p ~/.codex/skills/pdf-processing +cat <<'SKILL_EXAMPLE' > ~/.codex/skills/pdf-processing/SKILL.md +--- +name: pdf-processing +description: Extract text and tables from PDFs; use when PDFs, forms, or document extraction are mentioned. +--- + +# PDF Processing +- Use pdfplumber to extract text. +- For form filling, see FORMS.md. +SKILL_EXAMPLE +```