diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs index d92c683902..0b4230d742 100644 --- a/codex-rs/core/src/custom_prompts.rs +++ b/codex-rs/core/src/custom_prompts.rs @@ -25,53 +25,74 @@ pub async fn discover_prompts_in_excluding( exclude: &HashSet, ) -> Vec { let mut out: Vec = Vec::new(); - let mut entries = match fs::read_dir(dir).await { - Ok(entries) => entries, - Err(_) => return out, - }; + let mut stack: Vec<(PathBuf, Option)> = vec![(dir.to_path_buf(), None)]; - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let is_file = entry - .file_type() - .await - .map(|ft| ft.is_file()) - .unwrap_or(false); - if !is_file { - continue; - } - // Only include Markdown files with a .md extension. - let is_md = path - .extension() - .and_then(|s| s.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("md")) - .unwrap_or(false); - if !is_md { - continue; - } - let Some(name) = path - .file_stem() - .and_then(|s| s.to_str()) - .map(str::to_string) - else { - continue; - }; - if exclude.contains(&name) { - continue; - } - let content = match fs::read_to_string(&path).await { - Ok(s) => s, + while let Some((current_dir, prefix)) = stack.pop() { + let mut entries = match fs::read_dir(¤t_dir).await { + Ok(entries) => entries, Err(_) => continue, }; - let (description, argument_hint, body) = parse_frontmatter(&content); - out.push(CustomPrompt { - name, - path, - content: body, - description, - argument_hint, - }); + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + if file_type.is_dir() { + let Some(dir_name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let next_prefix = if let Some(parent) = &prefix { + format!("{parent}::{dir_name}") + } else { + dir_name + }; + stack.push((path, Some(next_prefix))); + continue; + } + + if !file_type.is_file() { + continue; + } + + let Some(ext) = path.extension().and_then(|s| s.to_str()) else { + continue; + }; + if !ext.eq_ignore_ascii_case("md") { + continue; + } + + let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + + let name = if let Some(parent) = &prefix { + format!("{parent}::{file_stem}") + } else { + file_stem.to_string() + }; + + if exclude.contains(&name) { + continue; + } + + let content = match fs::read_to_string(&path).await { + Ok(s) => s, + Err(_) => continue, + }; + + let (description, argument_hint, body) = parse_frontmatter(&content); + out.push(CustomPrompt { + name, + path, + content: body, + description, + argument_hint, + }); + } } + out.sort_by(|a, b| a.name.cmp(&b.name)); out } @@ -166,9 +187,22 @@ mod tests { fs::write(dir.join("b.md"), b"b").unwrap(); fs::write(dir.join("a.md"), b"a").unwrap(); fs::create_dir(dir.join("subdir")).unwrap(); + fs::write(dir.join("subdir").join("c.md"), b"c").unwrap(); let found = discover_prompts_in(dir).await; let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["a", "b"]); + assert_eq!(names, vec!["a", "b", "subdir::c"]); + } + + #[tokio::test] + async fn discovers_nested_directories() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + fs::create_dir_all(dir.join("bmad/agents")).unwrap(); + fs::write(dir.join("bmad/agents/dev.md"), b"hi").unwrap(); + fs::write(dir.join("bmad/root.md"), b"root").unwrap(); + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["bmad::agents::dev", "bmad::root"]); } #[tokio::test] @@ -184,6 +218,25 @@ mod tests { assert_eq!(names, vec!["foo"]); } + #[tokio::test] + async fn exclude_matches_full_prompt_names() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + fs::write(dir.join("init.md"), b"ignored").unwrap(); + fs::create_dir_all(dir.join("nested")).expect("create nested directory"); + fs::write(dir.join("nested").join("init.md"), b"allowed").unwrap(); + fs::create_dir_all(dir.join("skipme")).expect("create directory to skip"); + fs::write(dir.join("skipme").join("inner.md"), b"ignored").unwrap(); + + let mut exclude = HashSet::new(); + exclude.insert("init".to_string()); + exclude.insert("skipme::inner".to_string()); + + let found = discover_prompts_in_excluding(dir, &exclude).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["nested::init"]); + } + #[tokio::test] async fn skips_non_utf8_files() { let tmp = tempdir().expect("create TempDir"); diff --git a/docs/prompts.md b/docs/prompts.md index 5157c4ecea..b73494d185 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -4,7 +4,10 @@ Save frequently used prompts as Markdown files and reuse them quickly from the s - Location: Put files in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). - File type: Only Markdown files with the `.md` extension are recognized. -- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`. +- Name: The filename without the `.md` extension becomes the slash entry. Nested directories are joined with `::` in the order they appear in the filesystem. +- Nested directories: + - Every path segment between `prompts/` and the Markdown file is included in the slash command. For example, `~/.codex/prompts/team/review/high-priority.md` shows up as `/prompts:team::review::high-priority`. + - You can create deeper hierarchies (such as `~/.codex/prompts/bmad/agents/dev.md`) and access them as `/prompts:bmad::agents::dev`. - Content: The file contents are sent as your message when you select the item in the slash popup and press Enter. - Arguments: Local prompts support placeholders in their content: - `$1..$9` expand to the first nine positional arguments typed after the slash name