use std::collections::HashMap;
use std::path::{Path, PathBuf};
use include_dir::{Dir, include_dir};
static EMBEDDED: Dir = include_dir!("$CARGO_MANIFEST_DIR/prompts");
#[derive(Debug, Clone, Default)]
pub struct Prompt {
pub body: String,
pub deny_tools: Vec<String>,
pub description: Option<String>,
}
fn parse_frontmatter(raw: &str) -> Prompt {
let Some(after_open) = raw
.strip_prefix("---\n")
.or_else(|| raw.strip_prefix("---\r\n"))
else {
return Prompt {
body: raw.to_string(),
..Prompt::default()
};
};
let close_marker = "\n---\n";
let close_marker_crlf = "\r\n---\r\n";
let (front, body) = if let Some(pos) = after_open.find(close_marker) {
(&after_open[..pos], &after_open[pos + close_marker.len()..])
} else if let Some(pos) = after_open.find(close_marker_crlf) {
(
&after_open[..pos],
&after_open[pos + close_marker_crlf.len()..],
)
} else {
return Prompt {
body: raw.to_string(),
..Prompt::default()
};
};
let mut deny_tools: Vec<String> = Vec::new();
let mut description: Option<String> = None;
let lines: Vec<&str> = front.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
i += 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"deny_tools" => {
if value.starts_with('[') {
let stripped = value.trim_start_matches('[').trim_end_matches(']');
deny_tools = stripped
.split(',')
.map(|s| s.trim().trim_matches(|c| c == '"' || c == '\''))
.filter(|s| !s.is_empty())
.map(|s| {
let mut owned = s.to_string();
owned.make_ascii_lowercase();
owned
})
.collect();
} else if value.is_empty() && i < lines.len() && lines[i].trim().starts_with('-') {
let mut items: Vec<String> = Vec::new();
while i < lines.len() {
let next = lines[i].trim();
if let Some(rest) = next.strip_prefix('-') {
let name = rest.trim().trim_matches(|c| c == '"' || c == '\'');
if !name.is_empty() {
let mut owned = name.to_string();
owned.make_ascii_lowercase();
items.push(owned);
}
i += 1;
} else if next.is_empty() || next.starts_with('#') {
i += 1; } else {
break;
}
}
deny_tools = items;
} else {
}
}
"description" => {
let v = value.trim_matches(|c| c == '"' || c == '\'');
if !v.is_empty() {
description = Some(v.to_string());
}
}
_ => {}
}
}
Prompt {
body: body.to_string(),
deny_tools,
description,
}
}
pub fn global_prompts_dir() -> PathBuf {
crate::session::storage::config_path().join("prompts")
}
fn warn_unknown_deny_tools(prompt_name: &str, deny: &[String]) {
let known: &[&str] = crate::agent::tools::BUILTIN_TOOL_NAMES;
for t in deny {
if !known.iter().any(|k| k.eq_ignore_ascii_case(t)) {
eprintln!(
"warning: prompt '{}' deny_tools entry {:?} doesn't match any known built-in. \
If this is an MCP tool name, ignore this warning; if it's a typo, fix the .md. \
Known tools: {}",
prompt_name,
t,
known.join(", "),
);
}
}
}
pub fn load() -> HashMap<String, Prompt> {
let mut prompts: HashMap<String, Prompt> = HashMap::new();
for file in EMBEDDED.files() {
if file.path().extension().is_some_and(|e| e == "md")
&& let Some(name) = file.path().file_stem().and_then(|s| s.to_str())
&& let Some(content) = file.contents_utf8()
{
prompts
.entry(name.to_string())
.or_insert_with(|| parse_frontmatter(content));
}
}
let global = global_prompts_dir();
if global.exists()
&& let Ok(entries) = std::fs::read_dir(&global)
{
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "md")
&& let Some(name) = path.file_stem().and_then(|s| s.to_str())
&& let Ok(content) = std::fs::read_to_string(&path)
{
prompts.insert(name.to_string(), parse_frontmatter(&content));
}
}
}
let local = PathBuf::from("prompts");
if local.exists()
&& let Ok(entries) = std::fs::read_dir(&local)
{
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "md")
&& let Some(name) = path.file_stem().and_then(|s| s.to_str())
&& let Ok(content) = std::fs::read_to_string(&path)
{
prompts.insert(name.to_string(), parse_frontmatter(&content));
}
}
}
for (name, p) in &prompts {
if !p.deny_tools.is_empty() {
warn_unknown_deny_tools(name, &p.deny_tools);
}
}
prompts
}
pub fn ensure_global() -> anyhow::Result<()> {
let dir = global_prompts_dir();
if !dir.exists() {
std::fs::create_dir_all(&dir)?;
copy_embedded(&dir)?;
}
Ok(())
}
pub fn regen() -> anyhow::Result<()> {
let dir = global_prompts_dir();
std::fs::create_dir_all(&dir)?;
copy_embedded(&dir)
}
fn copy_embedded(dest: &Path) -> anyhow::Result<()> {
for file in EMBEDDED.files() {
if let Some(name) = file.path().file_name().and_then(|s| s.to_str()) {
let dest_path = dest.join(name);
if let Some(content) = file.contents_utf8() {
std::fs::write(&dest_path, content)?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_frontmatter_loads_whole_file_as_body() {
let raw = "You are dirge.\n\nDo the thing.\n";
let p = parse_frontmatter(raw);
assert_eq!(p.body, raw);
assert!(p.deny_tools.is_empty());
assert!(p.description.is_none());
}
#[test]
fn frontmatter_extracts_deny_tools_and_description() {
let raw = "---\ndeny_tools: [edit, write, apply_patch, bash]\ndescription: Read-only plan mode\n---\nYou are dirge.\n";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit", "write", "apply_patch", "bash"]);
assert_eq!(p.description.as_deref(), Some("Read-only plan mode"));
assert_eq!(p.body, "You are dirge.\n");
}
#[test]
fn plan_prompt_locks_down_all_mutation_and_exec_tools() {
let raw = EMBEDDED
.get_file("plan.md")
.and_then(|f| f.contents_utf8())
.expect("embedded plan.md present");
let p = parse_frontmatter(raw);
for required in [
"edit",
"write",
"apply_patch",
"edit_lines",
"edit_minified",
"bash",
"webfetch",
"task",
"mcp_tool",
"plugin_tool",
"debug",
"spec",
] {
assert!(
p.deny_tools
.iter()
.any(|d| d.eq_ignore_ascii_case(required)),
"plan mode must deny {required:?}; deny_tools = {:?}",
p.deny_tools,
);
}
for d in &p.deny_tools {
assert!(
crate::agent::tools::BUILTIN_TOOL_NAMES
.iter()
.any(|k| k.eq_ignore_ascii_case(d)),
"plan deny_tools entry {d:?} isn't a known built-in (would warn at load)",
);
}
}
#[test]
fn frontmatter_tolerates_quoted_tool_names_and_whitespace() {
let raw = "---\ndeny_tools: [ \"edit\" , 'write' , bash ]\n---\nbody\n";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit", "write", "bash"]);
}
#[test]
fn malformed_frontmatter_falls_back_to_whole_body() {
let raw = "---\ndeny_tools: [edit]\n\nbody without close\n";
let p = parse_frontmatter(raw);
assert_eq!(p.body, raw);
assert!(p.deny_tools.is_empty());
}
#[test]
fn unknown_keys_are_ignored() {
let raw = "---\ndeny_tools: [edit]\nunknown_key: whatever\nfuture_thing: 42\n---\nbody\n";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit"]);
assert_eq!(p.body, "body\n");
}
#[test]
fn block_form_deny_tools_is_parsed() {
let raw = "\
---
deny_tools:
- edit
- write
- apply_patch
- bash
description: Plan mode
---
body
";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit", "write", "apply_patch", "bash"]);
assert_eq!(p.description.as_deref(), Some("Plan mode"));
}
#[test]
fn block_form_deny_tools_with_quotes() {
let raw = "\
---
deny_tools:
- edit
- \"write\"
- 'bash'
description: Mixed quotes
---
body
";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit", "write", "bash"]);
}
}