1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use starweaver_agent::{SkillPackage, SkillRegistry};
7
8#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
10pub struct SlashCommandDefinition {
11 pub name: String,
13 pub prompt: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub description: Option<String>,
18 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub aliases: Vec<String>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct ExpandedSlashCommand {
26 pub invoked_name: String,
28 pub command_name: String,
30 pub prompt: String,
32 pub args: String,
34 pub description: Option<String>,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct ExplicitSkillSelection {
41 pub invoked_name: String,
43 pub package: SkillPackage,
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct ExpandedExplicitSkills {
50 pub prompt: String,
52 pub skills: Vec<ExplicitSkillSelection>,
54}
55
56#[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
121pub 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#[must_use]
149pub fn normalize_command_name(name: &str) -> String {
150 name.trim().trim_start_matches('/').to_ascii_lowercase()
151}
152
153#[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(®istry, "/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(®istry, "/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(®istry, "/lark-cli /missing send a message").is_none());
308 assert!(expand_explicit_skills(®istry, "/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(®istry, "/help explain").is_none());
317 assert_eq!(
318 expand_explicit_skills(®istry, "@help explain")
319 .unwrap()
320 .prompt,
321 "explain"
322 );
323 }
324}