use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use starweaver_agent::{SkillPackage, SkillRegistry};
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct SlashCommandDefinition {
pub name: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExpandedSlashCommand {
pub invoked_name: String,
pub command_name: String,
pub prompt: String,
pub args: String,
pub description: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExplicitSkillSelection {
pub invoked_name: String,
pub package: SkillPackage,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExpandedExplicitSkills {
pub prompt: String,
pub skills: Vec<ExplicitSkillSelection>,
}
#[must_use]
pub fn expand_explicit_skills(
registry: &SkillRegistry,
input: &str,
) -> Option<ExpandedExplicitSkills> {
let packages = registry.packages();
let mut rest = input.trim();
let mut skills = Vec::new();
let mut selected = BTreeSet::new();
while let Some(marker) = rest.chars().next().filter(|ch| matches!(ch, '/' | '@')) {
let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let token = &rest[marker.len_utf8()..token_end];
if !valid_command_name(token) || (marker == '/' && reserved_explicit_slash_name(token)) {
return None;
}
let package = packages
.iter()
.find(|package| package.name == token)
.or_else(|| {
packages
.iter()
.find(|package| package.name.eq_ignore_ascii_case(token))
})?
.clone();
if selected.insert(package.name.clone()) {
skills.push(ExplicitSkillSelection {
invoked_name: token.to_string(),
package,
});
}
rest = rest[token_end..].trim_start();
}
(!skills.is_empty()).then(|| ExpandedExplicitSkills {
prompt: rest.trim_end().to_string(),
skills,
})
}
fn reserved_explicit_slash_name(name: &str) -> bool {
matches!(
normalize_command_name(name).as_str(),
"help"
| "config"
| "loop"
| "tasks"
| "session"
| "dump"
| "load"
| "clear"
| "cost"
| "exit"
| "model"
| "paste-image"
| "goal"
| "display"
)
}
pub fn expand_slash_command(
commands: &BTreeMap<String, SlashCommandDefinition>,
input: &str,
) -> Option<ExpandedSlashCommand> {
let trimmed = input.trim();
let rest = trimmed.strip_prefix('/')?.trim_start();
if rest.is_empty() {
return None;
}
let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let invoked_name = normalize_command_name(&rest[..name_end]);
if invoked_name.is_empty() {
return None;
}
let args = rest[name_end..].trim().to_string();
let definition = commands.get(&invoked_name)?;
Some(ExpandedSlashCommand {
invoked_name,
command_name: normalize_command_name(&definition.name),
prompt: prompt_with_args(&definition.prompt, &args),
args,
description: definition.description.clone(),
})
}
#[must_use]
pub fn normalize_command_name(name: &str) -> String {
name.trim().trim_start_matches('/').to_ascii_lowercase()
}
#[must_use]
pub fn valid_command_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
}
const ARG_PLACEHOLDERS: [(&str, usize); 4] = [
("{{instruction}}", "{{instruction}}".len()),
("{{args}}", "{{args}}".len()),
("{instruction}", "{instruction}".len()),
("{args}", "{args}".len()),
];
fn next_args_placeholder(value: &str) -> Option<(usize, usize)> {
ARG_PLACEHOLDERS
.iter()
.filter_map(|(placeholder, len)| value.find(placeholder).map(|index| (index, *len)))
.min_by_key(|(index, _)| *index)
}
fn prompt_with_args(prompt: &str, args: &str) -> String {
if next_args_placeholder(prompt).is_some() {
let mut output = String::new();
let mut rest = prompt;
while let Some((index, placeholder_len)) = next_args_placeholder(rest) {
output.push_str(&rest[..index]);
output.push_str(args);
rest = &rest[index + placeholder_len..];
}
output.push_str(rest);
return output;
}
if args.is_empty() {
return prompt.to_string();
}
let mut expanded = prompt.trim_end().to_string();
if !expanded.is_empty() {
expanded.push_str("\n\n");
}
expanded.push_str("User instruction: ");
expanded.push_str(args);
expanded
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn command(prompt: &str) -> SlashCommandDefinition {
SlashCommandDefinition {
name: "review".to_string(),
prompt: prompt.to_string(),
description: Some("Review changes".to_string()),
aliases: vec!["rv".to_string()],
}
}
#[test]
fn expands_command_alias_and_arguments() {
let mut commands = BTreeMap::new();
let definition = command("Review the changes.");
commands.insert("review".to_string(), definition.clone());
commands.insert("rv".to_string(), definition);
let expanded = expand_slash_command(&commands, " /RV src/lib.rs ").unwrap();
assert_eq!(expanded.invoked_name, "rv");
assert_eq!(expanded.command_name, "review");
assert_eq!(
expanded.prompt,
"Review the changes.\n\nUser instruction: src/lib.rs"
);
}
#[test]
fn replaces_args_placeholders_when_present() {
let mut commands = BTreeMap::new();
commands.insert("test".to_string(), command("Run tests for {{args}}."));
let expanded = expand_slash_command(&commands, "/test crates/starweaver-cli").unwrap();
assert_eq!(expanded.prompt, "Run tests for crates/starweaver-cli.");
}
#[test]
fn replaces_instruction_placeholders_when_present() {
let mut commands = BTreeMap::new();
commands.insert(
"commit".to_string(),
command("Create a commit using {instruction}."),
);
let expanded = expand_slash_command(&commands, "/commit staged fixes").unwrap();
assert_eq!(expanded.prompt, "Create a commit using staged fixes.");
}
#[test]
fn ignores_non_commands_and_unknown_commands() {
let commands = BTreeMap::new();
assert!(expand_slash_command(&commands, "hello").is_none());
assert!(expand_slash_command(&commands, "/missing args").is_none());
}
fn skill(name: &str) -> SkillPackage {
SkillPackage {
name: name.to_string(),
description: format!("Use {name}"),
path: format!("/skills/{name}/SKILL.md"),
body: Some(format!("# {name}")),
metadata: serde_json::Map::default(),
}
}
#[test]
fn expands_multiple_explicit_skills_in_user_order() {
let mut registry = SkillRegistry::new();
registry.insert(skill("lark-cli"));
registry.insert(skill("building-agent"));
let expanded =
expand_explicit_skills(®istry, "/lark-cli @building-agent create an agent").unwrap();
assert_eq!(expanded.prompt, "create an agent");
assert_eq!(
expanded
.skills
.iter()
.map(|skill| skill.package.name.as_str())
.collect::<Vec<_>>(),
["lark-cli", "building-agent"]
);
}
#[test]
fn explicit_skills_are_case_insensitive_and_deduplicated() {
let mut registry = SkillRegistry::new();
registry.insert(skill("lark-cli"));
let expanded =
expand_explicit_skills(®istry, "/LARK-CLI @lark-cli send a message").unwrap();
assert_eq!(expanded.prompt, "send a message");
assert_eq!(expanded.skills.len(), 1);
assert_eq!(expanded.skills[0].invoked_name, "LARK-CLI");
}
#[test]
fn unknown_consecutive_skill_token_leaves_input_untouched() {
let mut registry = SkillRegistry::new();
registry.insert(skill("lark-cli"));
assert!(expand_explicit_skills(®istry, "/lark-cli /missing send a message").is_none());
assert!(expand_explicit_skills(®istry, "/missing send a message").is_none());
}
#[test]
fn reserved_slash_names_win_but_at_alias_can_activate_same_named_skill() {
let mut registry = SkillRegistry::new();
registry.insert(skill("help"));
assert!(expand_explicit_skills(®istry, "/help explain").is_none());
assert_eq!(
expand_explicit_skills(®istry, "@help explain")
.unwrap()
.prompt,
"explain"
);
}
}