1use 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
93pub 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 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 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}