Skip to main content

kiss_coding/
prompts.rs

1//! Prompt templates: Markdown files whose name becomes a `/command`, with
2//! positional-argument expansion.
3
4use crate::skills::split_frontmatter;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq)]
8pub struct PromptTemplate {
9    pub name: String,
10    pub description: String,
11    pub argument_hint: Option<String>,
12    pub body: String,
13    pub path: PathBuf,
14}
15
16fn parse_template(path: &Path) -> Option<PromptTemplate> {
17    let text = std::fs::read_to_string(path).ok()?;
18    let (frontmatter, body) = split_frontmatter(&text);
19    let name = path.file_stem()?.to_str()?.to_string();
20    let mut description = None;
21    let mut argument_hint = None;
22    if text.starts_with("---") && frontmatter.is_none() {
23        eprintln!(
24            "warning: invalid prompt frontmatter in {}: missing closing ---",
25            path.display()
26        );
27        return None;
28    }
29    if let Some(fm) = frontmatter {
30        let value = match serde_yaml::from_str::<serde_yaml::Value>(fm) {
31            Ok(value) => value,
32            Err(error) => {
33                eprintln!(
34                    "warning: invalid prompt frontmatter in {}: {error}",
35                    path.display()
36                );
37                return None;
38            }
39        };
40        description = value["description"].as_str().map(String::from);
41        argument_hint = value["argument-hint"].as_str().map(String::from);
42    }
43    let description = description
44        .or_else(|| {
45            body.lines()
46                .find(|l| !l.trim().is_empty())
47                .map(|l| l.trim().to_string())
48        })
49        .unwrap_or_default();
50    Some(PromptTemplate {
51        name,
52        description,
53        argument_hint,
54        body: body.to_string(),
55        path: path.to_path_buf(),
56    })
57}
58
59fn scan(dir: &Path, out: &mut Vec<PromptTemplate>) {
60    let Ok(entries) = std::fs::read_dir(dir) else {
61        return;
62    };
63    for entry in entries.flatten() {
64        let path = entry.path();
65        if path.extension().and_then(|e| e.to_str()) == Some("md")
66            && let Some(t) = parse_template(&path)
67        {
68            out.push(t);
69        }
70    }
71}
72
73pub fn discover(cwd: &Path, project_trusted: bool, extra_paths: &[PathBuf]) -> Vec<PromptTemplate> {
74    let mut out = Vec::new();
75    if let Some(home) = dirs::home_dir() {
76        scan(&home.join(".kiss/agent/prompts"), &mut out);
77    }
78    if project_trusted {
79        scan(&cwd.join(".kiss/prompts"), &mut out);
80    }
81    for path in extra_paths {
82        if path.is_dir() {
83            scan(path, &mut out);
84        } else if let Some(t) = parse_template(path) {
85            out.push(t);
86        }
87    }
88    let mut seen = std::collections::HashSet::new();
89    out.retain(|t| seen.insert(t.name.clone()));
90    out
91}
92
93/// Expand `$1..$n`, `$@`/`$ARGUMENTS`, and `${n:-default}` in the body.
94pub fn expand(body: &str, args: &[&str]) -> String {
95    let joined = args.join(" ");
96    let mut out = String::with_capacity(body.len());
97    let mut chars = body.chars().peekable();
98    while let Some(c) = chars.next() {
99        if c != '$' {
100            out.push(c);
101            continue;
102        }
103        match chars.peek() {
104            Some('@') => {
105                chars.next();
106                out.push_str(&joined);
107            }
108            Some('{') => {
109                chars.next();
110                let mut inner = String::new();
111                for ic in chars.by_ref() {
112                    if ic == '}' {
113                        break;
114                    }
115                    inner.push(ic);
116                }
117                // ${n:-default}
118                let (num, default) = match inner.split_once(":-") {
119                    Some((n, d)) => (n, Some(d)),
120                    None => (inner.as_str(), None),
121                };
122                if let Ok(index) = num.parse::<usize>() {
123                    match args.get(index.wrapping_sub(1)) {
124                        Some(v) if !v.is_empty() => out.push_str(v),
125                        _ => out.push_str(default.unwrap_or("")),
126                    }
127                } else {
128                    out.push_str("${");
129                    out.push_str(&inner);
130                    out.push('}');
131                }
132            }
133            Some(d) if d.is_ascii_digit() => {
134                let mut num = String::new();
135                while let Some(d) = chars.peek() {
136                    if d.is_ascii_digit() {
137                        num.push(*d);
138                        chars.next();
139                    } else {
140                        break;
141                    }
142                }
143                let index: usize = num.parse().unwrap_or(0);
144                if let Some(v) = args.get(index.wrapping_sub(1)) {
145                    out.push_str(v);
146                }
147            }
148            Some('A') => {
149                // $ARGUMENTS
150                let rest: String = chars.clone().take(9).collect();
151                if rest == "ARGUMENTS" {
152                    for _ in 0..9 {
153                        chars.next();
154                    }
155                    out.push_str(&joined);
156                } else {
157                    out.push('$');
158                }
159            }
160            _ => out.push('$'),
161        }
162    }
163    out
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn expansion() {
172        assert_eq!(
173            expand("fix $1 in $2", &["bug", "file.rs"]),
174            "fix bug in file.rs"
175        );
176        assert_eq!(expand("all: $@", &["a", "b"]), "all: a b");
177        assert_eq!(expand("all: $ARGUMENTS", &["a", "b"]), "all: a b");
178        assert_eq!(expand("x ${1:-default}", &[]), "x default");
179        assert_eq!(expand("x ${1:-default}", &["given"]), "x given");
180        assert_eq!(expand("$3 missing", &["a"]), " missing");
181    }
182
183    #[test]
184    fn malformed_frontmatter_rejects_the_template() {
185        let dir = tempfile::tempdir().unwrap();
186        let path = dir.path().join("broken.md");
187        std::fs::write(&path, "---\ndescription: [broken\n---\nbody").unwrap();
188        assert!(parse_template(&path).is_none());
189    }
190
191    #[test]
192    fn template_parse_uses_first_line_fallback() {
193        let dir = tempfile::tempdir().unwrap();
194        let path = dir.path().join("review.md");
195        std::fs::write(&path, "Review the staged changes carefully.\nMore text.").unwrap();
196        let t = parse_template(&path).unwrap();
197        assert_eq!(t.name, "review");
198        assert_eq!(t.description, "Review the staged changes carefully.");
199    }
200}