Skip to main content

starweaver_cli/
slash_commands.rs

1//! Config-backed slash command expansion.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use starweaver_agent::{SkillPackage, SkillRegistry};
7
8/// Config-defined slash command prompt.
9#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
10pub struct SlashCommandDefinition {
11    /// Canonical command name without a leading slash.
12    pub name: String,
13    /// Prompt submitted when the command is invoked.
14    pub prompt: String,
15    /// Human-readable description.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub description: Option<String>,
18    /// Additional aliases without a leading slash.
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub aliases: Vec<String>,
21}
22
23/// Expanded prompt produced by invoking a config-backed slash command.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct ExpandedSlashCommand {
26    /// Alias or command name typed by the user, normalized without `/`.
27    pub invoked_name: String,
28    /// Canonical command name, normalized without `/`.
29    pub command_name: String,
30    /// Prompt submitted to the agent.
31    pub prompt: String,
32    /// Free-form instruction text after the slash command name.
33    pub args: String,
34    /// Human-readable description.
35    pub description: Option<String>,
36}
37
38/// One skill explicitly selected through a leading `/skill` or `@skill` token.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct ExplicitSkillSelection {
41    /// Name typed by the user without the leading marker.
42    pub invoked_name: String,
43    /// Resolved skill package.
44    pub package: SkillPackage,
45}
46
47/// Prompt and ordered skill packages produced by explicit skill prefixes.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct ExpandedExplicitSkills {
50    /// User request after removing all recognized leading skill tokens.
51    pub prompt: String,
52    /// Selected skills in first-seen order, deduplicated by canonical name.
53    pub skills: Vec<ExplicitSkillSelection>,
54}
55
56/// Parse consecutive leading `/skill` or `@skill` tokens from `input`.
57///
58/// Every consecutive marker token must resolve to a loaded skill. Unknown tokens leave the input
59/// untouched by returning `None`, preserving ordinary slash-prefixed prompts and configured slash
60/// command precedence.
61#[must_use]
62pub fn expand_explicit_skills(
63    registry: &SkillRegistry,
64    input: &str,
65) -> Option<ExpandedExplicitSkills> {
66    let packages = registry.packages();
67    let mut rest = input.trim();
68    let mut skills = Vec::new();
69    let mut selected = BTreeSet::new();
70
71    while let Some(marker) = rest.chars().next().filter(|ch| matches!(ch, '/' | '@')) {
72        let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
73        let token = &rest[marker.len_utf8()..token_end];
74        if !valid_command_name(token) || (marker == '/' && reserved_explicit_slash_name(token)) {
75            return None;
76        }
77        let package = packages
78            .iter()
79            .find(|package| package.name == token)
80            .or_else(|| {
81                packages
82                    .iter()
83                    .find(|package| package.name.eq_ignore_ascii_case(token))
84            })?
85            .clone();
86        if selected.insert(package.name.clone()) {
87            skills.push(ExplicitSkillSelection {
88                invoked_name: token.to_string(),
89                package,
90            });
91        }
92        rest = rest[token_end..].trim_start();
93    }
94
95    (!skills.is_empty()).then(|| ExpandedExplicitSkills {
96        prompt: rest.trim_end().to_string(),
97        skills,
98    })
99}
100
101fn reserved_explicit_slash_name(name: &str) -> bool {
102    matches!(
103        normalize_command_name(name).as_str(),
104        "help"
105            | "config"
106            | "loop"
107            | "tasks"
108            | "session"
109            | "dump"
110            | "load"
111            | "clear"
112            | "cost"
113            | "exit"
114            | "model"
115            | "paste-image"
116            | "goal"
117            | "display"
118    )
119}
120
121/// Expand `input` when it invokes a configured slash command.
122pub fn expand_slash_command(
123    commands: &BTreeMap<String, SlashCommandDefinition>,
124    input: &str,
125) -> Option<ExpandedSlashCommand> {
126    let trimmed = input.trim();
127    let rest = trimmed.strip_prefix('/')?.trim_start();
128    if rest.is_empty() {
129        return None;
130    }
131    let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
132    let invoked_name = normalize_command_name(&rest[..name_end]);
133    if invoked_name.is_empty() {
134        return None;
135    }
136    let args = rest[name_end..].trim().to_string();
137    let definition = commands.get(&invoked_name)?;
138    Some(ExpandedSlashCommand {
139        invoked_name,
140        command_name: normalize_command_name(&definition.name),
141        prompt: prompt_with_args(&definition.prompt, &args),
142        args,
143        description: definition.description.clone(),
144    })
145}
146
147/// Normalize a slash command name for map storage and lookup.
148#[must_use]
149pub fn normalize_command_name(name: &str) -> String {
150    name.trim().trim_start_matches('/').to_ascii_lowercase()
151}
152
153/// Return whether a normalized slash command name can be invoked as one token.
154#[must_use]
155pub fn valid_command_name(name: &str) -> bool {
156    !name.is_empty()
157        && name
158            .chars()
159            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
160}
161
162const ARG_PLACEHOLDERS: [(&str, usize); 4] = [
163    ("{{instruction}}", "{{instruction}}".len()),
164    ("{{args}}", "{{args}}".len()),
165    ("{instruction}", "{instruction}".len()),
166    ("{args}", "{args}".len()),
167];
168
169fn next_args_placeholder(value: &str) -> Option<(usize, usize)> {
170    ARG_PLACEHOLDERS
171        .iter()
172        .filter_map(|(placeholder, len)| value.find(placeholder).map(|index| (index, *len)))
173        .min_by_key(|(index, _)| *index)
174}
175
176fn prompt_with_args(prompt: &str, args: &str) -> String {
177    if next_args_placeholder(prompt).is_some() {
178        let mut output = String::new();
179        let mut rest = prompt;
180        while let Some((index, placeholder_len)) = next_args_placeholder(rest) {
181            output.push_str(&rest[..index]);
182            output.push_str(args);
183            rest = &rest[index + placeholder_len..];
184        }
185        output.push_str(rest);
186        return output;
187    }
188    if args.is_empty() {
189        return prompt.to_string();
190    }
191    let mut expanded = prompt.trim_end().to_string();
192    if !expanded.is_empty() {
193        expanded.push_str("\n\n");
194    }
195    expanded.push_str("User instruction: ");
196    expanded.push_str(args);
197    expanded
198}
199
200#[cfg(test)]
201mod tests {
202    #![allow(clippy::unwrap_used)]
203
204    use super::*;
205
206    fn command(prompt: &str) -> SlashCommandDefinition {
207        SlashCommandDefinition {
208            name: "review".to_string(),
209            prompt: prompt.to_string(),
210            description: Some("Review changes".to_string()),
211            aliases: vec!["rv".to_string()],
212        }
213    }
214
215    #[test]
216    fn expands_command_alias_and_arguments() {
217        let mut commands = BTreeMap::new();
218        let definition = command("Review the changes.");
219        commands.insert("review".to_string(), definition.clone());
220        commands.insert("rv".to_string(), definition);
221
222        let expanded = expand_slash_command(&commands, " /RV src/lib.rs ").unwrap();
223        assert_eq!(expanded.invoked_name, "rv");
224        assert_eq!(expanded.command_name, "review");
225        assert_eq!(
226            expanded.prompt,
227            "Review the changes.\n\nUser instruction: src/lib.rs"
228        );
229    }
230
231    #[test]
232    fn replaces_args_placeholders_when_present() {
233        let mut commands = BTreeMap::new();
234        commands.insert("test".to_string(), command("Run tests for {{args}}."));
235
236        let expanded = expand_slash_command(&commands, "/test crates/starweaver-cli").unwrap();
237        assert_eq!(expanded.prompt, "Run tests for crates/starweaver-cli.");
238    }
239
240    #[test]
241    fn replaces_instruction_placeholders_when_present() {
242        let mut commands = BTreeMap::new();
243        commands.insert(
244            "commit".to_string(),
245            command("Create a commit using {instruction}."),
246        );
247
248        let expanded = expand_slash_command(&commands, "/commit staged fixes").unwrap();
249        assert_eq!(expanded.prompt, "Create a commit using staged fixes.");
250    }
251
252    #[test]
253    fn ignores_non_commands_and_unknown_commands() {
254        let commands = BTreeMap::new();
255        assert!(expand_slash_command(&commands, "hello").is_none());
256        assert!(expand_slash_command(&commands, "/missing args").is_none());
257    }
258
259    fn skill(name: &str) -> SkillPackage {
260        SkillPackage {
261            name: name.to_string(),
262            description: format!("Use {name}"),
263            path: format!("/skills/{name}/SKILL.md"),
264            body: Some(format!("# {name}")),
265            metadata: serde_json::Map::default(),
266        }
267    }
268
269    #[test]
270    fn expands_multiple_explicit_skills_in_user_order() {
271        let mut registry = SkillRegistry::new();
272        registry.insert(skill("lark-cli"));
273        registry.insert(skill("building-agent"));
274
275        let expanded =
276            expand_explicit_skills(&registry, "/lark-cli @building-agent create an agent").unwrap();
277
278        assert_eq!(expanded.prompt, "create an agent");
279        assert_eq!(
280            expanded
281                .skills
282                .iter()
283                .map(|skill| skill.package.name.as_str())
284                .collect::<Vec<_>>(),
285            ["lark-cli", "building-agent"]
286        );
287    }
288
289    #[test]
290    fn explicit_skills_are_case_insensitive_and_deduplicated() {
291        let mut registry = SkillRegistry::new();
292        registry.insert(skill("lark-cli"));
293
294        let expanded =
295            expand_explicit_skills(&registry, "/LARK-CLI @lark-cli send a message").unwrap();
296
297        assert_eq!(expanded.prompt, "send a message");
298        assert_eq!(expanded.skills.len(), 1);
299        assert_eq!(expanded.skills[0].invoked_name, "LARK-CLI");
300    }
301
302    #[test]
303    fn unknown_consecutive_skill_token_leaves_input_untouched() {
304        let mut registry = SkillRegistry::new();
305        registry.insert(skill("lark-cli"));
306
307        assert!(expand_explicit_skills(&registry, "/lark-cli /missing send a message").is_none());
308        assert!(expand_explicit_skills(&registry, "/missing send a message").is_none());
309    }
310
311    #[test]
312    fn reserved_slash_names_win_but_at_alias_can_activate_same_named_skill() {
313        let mut registry = SkillRegistry::new();
314        registry.insert(skill("help"));
315
316        assert!(expand_explicit_skills(&registry, "/help explain").is_none());
317        assert_eq!(
318            expand_explicit_skills(&registry, "@help explain")
319                .unwrap()
320                .prompt,
321            "explain"
322        );
323    }
324}