Skip to main content

stakpak_api/
commands.rs

1//! Predefined Slash Commands
2//!
3//! Loads Stakpak-shipped slash commands from `.md` files embedded at compile time.
4//! Any `.md` file placed in `libs/api/src/commands/` automatically becomes a
5//! predefined slash command that appears in the TUI dropdown.
6//!
7//! Files are named `<command-name>.v<version>.md`. The command name is derived
8//! by stripping the `.v<N>` version suffix (e.g., `review.v1.md` → `review`).
9//! If multiple versions of the same command exist, the highest version wins.
10//!
11//! Optional YAML front matter provides a description:
12//! ```markdown
13//! ---
14//! description: Review code changes
15//! ---
16//!
17//! You are a code reviewer...
18//! ```
19//!
20//! If no front matter is present, the first non-empty line (truncated to 60 chars)
21//! is used as the description.
22
23use include_dir::{Dir, include_dir};
24use std::collections::HashMap;
25
26/// The embedded commands directory, baked into the binary at compile time.
27static COMMANDS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/commands");
28
29/// Maximum description length when derived from the first line of content.
30const MAX_DESCRIPTION_LEN: usize = 60;
31
32/// Load all predefined commands from the embedded `commands/` directory.
33///
34/// Returns a vec of `(name, description, prompt_content)` tuples.
35/// - `name`: the slash command name without `/` prefix (e.g., `"review"`)
36/// - `description`: human-readable description for the dropdown
37/// - `prompt_content`: the full prompt body sent as a user message
38///
39/// When multiple versions of a command exist (e.g., `review.v1.md` and
40/// `review.v2.md`), only the highest version is returned.
41pub fn load_predefined_commands() -> Vec<(String, String, String)> {
42    // Collect all versioned entries, keeping only the highest version per name
43    let mut best: HashMap<String, (u32, String, String)> = HashMap::new();
44
45    for file in COMMANDS_DIR.files() {
46        let path = file.path();
47
48        // Only process .md files
49        let ext = path.extension().and_then(|e| e.to_str());
50        if ext != Some("md") {
51            continue;
52        }
53
54        let stem = match path.file_stem().and_then(|s| s.to_str()) {
55            Some(s) => s,
56            None => continue,
57        };
58
59        // Parse "name.vN" → (name, version)
60        let (name, version) = parse_versioned_name(stem);
61
62        // Validate command name
63        if !is_valid_command_name(&name) {
64            continue;
65        }
66
67        let content = match file.contents_utf8() {
68            Some(c) => c.trim(),
69            None => continue,
70        };
71
72        if content.is_empty() {
73            continue;
74        }
75
76        // Only keep the highest version of each command
77        if let Some(existing) = best.get(&name)
78            && existing.0 >= version
79        {
80            continue;
81        }
82
83        let (description, prompt_body) = extract_front_matter(content);
84
85        let description = description.unwrap_or_else(|| {
86            let first_line = prompt_body
87                .lines()
88                .find(|l| !l.trim().is_empty())
89                .unwrap_or("Predefined command");
90            let first_line = first_line.trim();
91            if first_line.len() > MAX_DESCRIPTION_LEN {
92                let truncated: String = first_line.chars().take(MAX_DESCRIPTION_LEN).collect();
93                format!("{truncated}...")
94            } else {
95                first_line.to_string()
96            }
97        });
98
99        best.insert(name, (version, description, prompt_body.to_string()));
100    }
101
102    let mut commands: Vec<(String, String, String)> = best
103        .into_iter()
104        .map(|(name, (_, desc, content))| (name, desc, content))
105        .collect();
106
107    // Sort by name for stable ordering in the dropdown
108    commands.sort_by(|a, b| a.0.cmp(&b.0));
109
110    commands
111}
112
113/// Parse a versioned filename stem like `"review.v1"` into `("review", 1)`.
114///
115/// If no version suffix is found, returns version `0`.
116///
117/// Filenames are ASCII-only (validated by `is_valid_command_name` + `.vN`),
118/// so `rfind('.')` returns a safe char boundary.
119#[allow(clippy::string_slice)] // dot_pos from rfind('.') on same ASCII string
120fn parse_versioned_name(stem: &str) -> (String, u32) {
121    if let Some(dot_pos) = stem.rfind('.')
122        && let Some(suffix) = stem.get(dot_pos + 1..)
123        && let Some(num_str) = suffix.strip_prefix('v')
124        && let Ok(version) = num_str.parse::<u32>()
125    {
126        // dot_pos is from rfind on the same string, always a valid boundary
127        return (stem[..dot_pos].to_string(), version);
128    }
129    (stem.to_string(), 0)
130}
131
132/// Validate that a command name contains only allowed characters.
133///
134/// Allowed: `a-z`, `A-Z`, `0-9`, `-`, `_`
135fn is_valid_command_name(name: &str) -> bool {
136    !name.is_empty()
137        && name
138            .chars()
139            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
140}
141
142/// Extract YAML front matter from markdown content.
143///
144/// Front matter is delimited by `---` at the start:
145/// ```text
146/// ---
147/// description: Some description
148/// ---
149/// Body content here
150/// ```
151///
152/// Returns `(Option<description>, body_content)`.
153#[allow(clippy::string_slice)] // indices from starts_with/match_indices on "---" (ASCII) are safe
154fn extract_front_matter(content: &str) -> (Option<String>, &str) {
155    // Must start with "---"
156    if !content.starts_with("---") {
157        return (None, content);
158    }
159
160    // Find the closing "---" that appears at the start of a line.
161    let after_first = &content[3..];
162    let closing = after_first
163        .match_indices("---")
164        .find(|(pos, _)| {
165            *pos == 0 || after_first.as_bytes().get(pos.wrapping_sub(1)) == Some(&b'\n')
166        })
167        .map(|(pos, _)| pos);
168
169    match closing {
170        Some(pos) => {
171            let front_matter = after_first[..pos].trim();
172            let body = after_first[pos + 3..].trim();
173
174            // Parse description from front matter
175            let description = front_matter.lines().find_map(|line| {
176                let line = line.trim();
177                if let Some(value) = line.strip_prefix("description:") {
178                    let value = value.trim();
179                    // Remove surrounding quotes if present
180                    let value = value
181                        .strip_prefix('"')
182                        .and_then(|v| v.strip_suffix('"'))
183                        .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
184                        .unwrap_or(value);
185                    if !value.is_empty() {
186                        Some(value.to_string())
187                    } else {
188                        None
189                    }
190                } else {
191                    None
192                }
193            });
194
195            if body.is_empty() {
196                (description, content)
197            } else {
198                (description, body)
199            }
200        }
201        None => (None, content),
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_parse_versioned_name() {
211        assert_eq!(parse_versioned_name("review.v1"), ("review".into(), 1));
212        assert_eq!(parse_versioned_name("claw.v2"), ("claw".into(), 2));
213        assert_eq!(
214            parse_versioned_name("my-command.v10"),
215            ("my-command".into(), 10)
216        );
217        assert_eq!(parse_versioned_name("plain"), ("plain".into(), 0));
218        assert_eq!(
219            parse_versioned_name("dotted.name.v3"),
220            ("dotted.name".into(), 3)
221        );
222    }
223
224    #[test]
225    fn test_is_valid_command_name() {
226        assert!(is_valid_command_name("hello"));
227        assert!(is_valid_command_name("hello-world"));
228        assert!(is_valid_command_name("hello_world"));
229        assert!(is_valid_command_name("Hello123"));
230        assert!(!is_valid_command_name(""));
231        assert!(!is_valid_command_name("hello world"));
232        assert!(!is_valid_command_name("hello.world"));
233        assert!(!is_valid_command_name("hello/world"));
234    }
235
236    #[test]
237    fn test_extract_front_matter_with_description() {
238        let content = "---\ndescription: Run a security audit\n---\n\nPerform a comprehensive security audit.";
239        let (desc, body) = extract_front_matter(content);
240        assert_eq!(desc, Some("Run a security audit".into()));
241        assert_eq!(body, "Perform a comprehensive security audit.");
242    }
243
244    #[test]
245    fn test_extract_front_matter_with_quoted_description() {
246        let content = "---\ndescription: \"Check health status\"\n---\n\nCheck the health.";
247        let (desc, body) = extract_front_matter(content);
248        assert_eq!(desc, Some("Check health status".into()));
249        assert_eq!(body, "Check the health.");
250    }
251
252    #[test]
253    fn test_extract_front_matter_no_front_matter() {
254        let content = "Just a plain prompt.";
255        let (desc, body) = extract_front_matter(content);
256        assert_eq!(desc, None);
257        assert_eq!(body, content);
258    }
259
260    #[test]
261    fn test_load_predefined_commands_returns_known_commands() {
262        let commands = load_predefined_commands();
263        let names: Vec<&str> = commands.iter().map(|(n, _, _)| n.as_str()).collect();
264        assert!(names.contains(&"claw"), "Expected 'claw' in {names:?}");
265        assert!(names.contains(&"review"), "Expected 'review' in {names:?}");
266    }
267
268    #[test]
269    fn test_predefined_commands_have_descriptions() {
270        let commands = load_predefined_commands();
271        for (name, desc, _) in &commands {
272            assert!(
273                !desc.is_empty(),
274                "Command '{name}' has an empty description"
275            );
276        }
277    }
278
279    #[test]
280    fn test_predefined_commands_have_content() {
281        let commands = load_predefined_commands();
282        for (name, _, content) in &commands {
283            assert!(
284                !content.is_empty(),
285                "Command '{name}' has empty prompt content"
286            );
287        }
288    }
289}