feat: slash commands (built-in, skill, recipe) in acp server (#9238)
This commit is contained in:
@@ -1117,7 +1117,7 @@ fn configure_stdio_extension() -> anyhow::Result<()> {
|
||||
|
||||
let timeout = prompt_extension_timeout()?;
|
||||
|
||||
let mut parts = crate::session::split_quoted(&command_str)?;
|
||||
let mut parts = goose::utils::split_command_args(&command_str)?;
|
||||
let cmd = if parts.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
|
||||
@@ -239,36 +239,6 @@ pub async fn classify_planner_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split_quoted(input: &str) -> Result<Vec<String>> {
|
||||
let mut parts = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_double_quote = false;
|
||||
let mut in_single_quote = false;
|
||||
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'"' if !in_single_quote => in_double_quote = !in_double_quote,
|
||||
'\'' if !in_double_quote => in_single_quote = !in_single_quote,
|
||||
c if c.is_whitespace() && !in_double_quote && !in_single_quote => {
|
||||
if !current.is_empty() {
|
||||
parts.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
_ => current.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
if in_double_quote || in_single_quote {
|
||||
return Err(anyhow::anyhow!("Unmatched quote in command"));
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
parts.push(current);
|
||||
}
|
||||
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
impl CliSession {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn new(
|
||||
@@ -311,7 +281,7 @@ impl CliSession {
|
||||
/// Parse a stdio extension command string into an ExtensionConfig
|
||||
/// Format: "ENV1=val1 ENV2=val2 command args..."
|
||||
pub fn parse_stdio_extension(extension_command: &str) -> Result<ExtensionConfig> {
|
||||
let mut parts = split_quoted(extension_command)?;
|
||||
let mut parts = goose::utils::split_command_args(extension_command)?;
|
||||
let mut envs = HashMap::new();
|
||||
|
||||
while let Some(part) = parts.first() {
|
||||
@@ -2250,20 +2220,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_quoted_windows_paths() {
|
||||
fn test_split_command_args_windows_paths() {
|
||||
assert_eq!(
|
||||
split_quoted(r"C:\tools\mcp.exe --arg value").unwrap(),
|
||||
goose::utils::split_command_args(r"C:\tools\mcp.exe --arg value").unwrap(),
|
||||
vec![r"C:\tools\mcp.exe", "--arg", "value"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_quoted(r#""C:\Program Files\server\mcp.exe" --arg"#).unwrap(),
|
||||
goose::utils::split_command_args(r#""C:\Program Files\server\mcp.exe" --arg"#).unwrap(),
|
||||
vec![r"C:\Program Files\server\mcp.exe", "--arg"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_quoted_unmatched_quote() {
|
||||
assert!(split_quoted(r#""unmatched"#).is_err());
|
||||
fn test_split_command_args_unmatched_quote() {
|
||||
assert!(goose::utils::split_command_args(r#""unmatched"#).is_err());
|
||||
}
|
||||
|
||||
#[test_case(
|
||||
|
||||
@@ -23,7 +23,7 @@ use goose::providers::create_with_default_model;
|
||||
use goose::providers::providers as get_providers;
|
||||
use goose::{
|
||||
agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel,
|
||||
slash_commands,
|
||||
slash_commands::recipe_slash_command,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -469,7 +469,7 @@ pub struct SlashCommandsQuery {
|
||||
pub async fn get_slash_commands(
|
||||
axum::extract::Query(query): axum::extract::Query<SlashCommandsQuery>,
|
||||
) -> Result<Json<SlashCommandsResponse>, ErrorResponse> {
|
||||
let mut commands: Vec<_> = slash_commands::list_commands()
|
||||
let mut commands: Vec<_> = recipe_slash_command::list_commands()
|
||||
.iter()
|
||||
.map(|command| SlashCommand {
|
||||
command: command.command.clone(),
|
||||
|
||||
@@ -9,7 +9,7 @@ use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
||||
use goose::recipe::local_recipes;
|
||||
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
|
||||
use goose::recipe::{strip_error_location, Recipe};
|
||||
use goose::{recipe_deeplink, slash_commands};
|
||||
use goose::{recipe_deeplink, slash_commands::recipe_slash_command};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -321,7 +321,7 @@ async fn list_recipes(
|
||||
.map(|j| (PathBuf::from(j.source), j.cron))
|
||||
.collect();
|
||||
|
||||
let all_commands = slash_commands::list_commands();
|
||||
let all_commands = recipe_slash_command::list_commands();
|
||||
let slash_map: HashMap<_, _> = all_commands
|
||||
.into_iter()
|
||||
.map(|sc| (PathBuf::from(sc.recipe_path), sc.command))
|
||||
@@ -422,7 +422,7 @@ async fn set_recipe_slash_command(
|
||||
Err(err) => return Err(err.status),
|
||||
};
|
||||
|
||||
match slash_commands::set_recipe_slash_command(file_path, request.slash_command) {
|
||||
match recipe_slash_command::set_recipe_slash_command(file_path, request.slash_command) {
|
||||
Ok(_) => Ok(StatusCode::OK),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to set slash command: {}", e);
|
||||
|
||||
+39
-102
@@ -1164,6 +1164,34 @@ fn validate_absolute_cwd(cwd: &Path) -> Result<(), agent_client_protocol::Error>
|
||||
}
|
||||
|
||||
impl GooseAcpAgent {
|
||||
fn available_commands_update(working_dir: &std::path::Path) -> AvailableCommandsUpdate {
|
||||
let commands = crate::slash_commands::slash_command::list_acp_commands(Some(working_dir))
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let mut command = AvailableCommand::new(entry.name, entry.description);
|
||||
if let Some(input_hint) = entry.input_hint {
|
||||
command = command.input(AvailableCommandInput::Unstructured(
|
||||
UnstructuredCommandInput::new(input_hint),
|
||||
));
|
||||
}
|
||||
command
|
||||
})
|
||||
.collect();
|
||||
|
||||
AvailableCommandsUpdate::new(commands)
|
||||
}
|
||||
|
||||
fn send_available_commands_update(
|
||||
cx: &ConnectionTo<Client>,
|
||||
session_id: &SessionId,
|
||||
working_dir: &std::path::Path,
|
||||
) -> Result<(), agent_client_protocol::Error> {
|
||||
cx.send_notification(SessionNotification::new(
|
||||
session_id.clone(),
|
||||
SessionUpdate::AvailableCommandsUpdate(Self::available_commands_update(working_dir)),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn permission_manager(&self) -> Arc<PermissionManager> {
|
||||
Arc::clone(&self.permission_manager)
|
||||
}
|
||||
@@ -2301,92 +2329,6 @@ impl GooseAcpAgent {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn input_hint_for_recipe(
|
||||
params: Option<&Vec<crate::recipe::RecipeParameter>>,
|
||||
) -> Option<String> {
|
||||
let params = params?;
|
||||
|
||||
params
|
||||
.iter()
|
||||
.find(|p| p.key == "args")
|
||||
.or_else(|| params.iter().find(|p| p.default.is_none()))
|
||||
.or_else(|| params.first())
|
||||
.map(|p| p.description.clone())
|
||||
}
|
||||
|
||||
async fn build_available_commands_from_slash_commands() -> Vec<AvailableCommand> {
|
||||
let mut commands = Vec::new();
|
||||
|
||||
for mapping in crate::slash_commands::list_commands() {
|
||||
if Self::is_builtin_agent_command(&mapping.command) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let recipe_path = std::path::PathBuf::from(&mapping.recipe_path);
|
||||
|
||||
if !recipe_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(recipe_content) = tokio::fs::read_to_string(&recipe_path).await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(recipe_dir) = recipe_path.parent() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let recipe_dir_str = recipe_dir.display().to_string();
|
||||
|
||||
let Ok(validation_result) =
|
||||
crate::recipe::validate_recipe::validate_recipe_template_from_content(
|
||||
&recipe_content,
|
||||
Some(recipe_dir_str),
|
||||
)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let required_param_count = validation_result
|
||||
.parameters
|
||||
.as_ref()
|
||||
.map(|params| params.iter().filter(|p| p.default.is_none()).count())
|
||||
.unwrap_or(0);
|
||||
|
||||
if required_param_count > 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut command =
|
||||
AvailableCommand::new(mapping.command, validation_result.description.clone());
|
||||
|
||||
if let Some(hint) = Self::input_hint_for_recipe(validation_result.parameters.as_ref()) {
|
||||
command = command.input(AvailableCommandInput::Unstructured(
|
||||
UnstructuredCommandInput::new(hint),
|
||||
));
|
||||
}
|
||||
|
||||
commands.push(command);
|
||||
}
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
async fn send_available_commands_update(
|
||||
&self,
|
||||
cx: &ConnectionTo<Client>,
|
||||
session_id: &SessionId,
|
||||
) -> Result<(), agent_client_protocol::Error> {
|
||||
let commands = Self::build_available_commands_from_slash_commands().await;
|
||||
|
||||
cx.send_notification(SessionNotification::new(
|
||||
session_id.clone(),
|
||||
SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
|
||||
))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_builtin_agent_command(command: &str) -> bool {
|
||||
let normalized = command.trim_start_matches('/');
|
||||
|
||||
@@ -2647,6 +2589,8 @@ impl GooseAcpAgent {
|
||||
.prepare_session_init_config(&resolved, &mode_state, &goose_session)
|
||||
.await;
|
||||
|
||||
let working_dir = goose_session.working_dir.clone();
|
||||
|
||||
self.spawn_agent_setup(
|
||||
cx,
|
||||
agent_tx,
|
||||
@@ -2672,10 +2616,7 @@ impl GooseAcpAgent {
|
||||
SessionUpdate::UsageUpdate(usage_update),
|
||||
))?;
|
||||
}
|
||||
|
||||
self.send_available_commands_update(cx, &acp_session_id)
|
||||
.await?;
|
||||
|
||||
Self::send_available_commands_update(cx, &acp_session_id, &working_dir)?;
|
||||
debug!(
|
||||
target: "perf",
|
||||
sid = %sid,
|
||||
@@ -3046,10 +2987,7 @@ impl GooseAcpAgent {
|
||||
SessionUpdate::UsageUpdate(usage_update),
|
||||
))?;
|
||||
}
|
||||
|
||||
self.send_available_commands_update(cx, &args.session_id)
|
||||
.await?;
|
||||
|
||||
Self::send_available_commands_update(cx, &args.session_id, &args.cwd)?;
|
||||
debug!(
|
||||
target: "perf",
|
||||
sid = %sid,
|
||||
@@ -3082,7 +3020,9 @@ impl GooseAcpAgent {
|
||||
|
||||
if !Self::is_builtin_agent_command(parsed.command) {
|
||||
if let Some(recipe_path) =
|
||||
crate::slash_commands::get_recipe_for_command(&full_command)
|
||||
crate::slash_commands::recipe_slash_command::get_recipe_for_command(
|
||||
&full_command,
|
||||
)
|
||||
{
|
||||
if recipe_path.exists() {
|
||||
cx.send_notification(SessionNotification::new(
|
||||
@@ -3573,11 +3513,13 @@ impl GooseAcpAgent {
|
||||
.prepare_session_init_config(&resolved, &mode_state, &goose_session)
|
||||
.await;
|
||||
|
||||
let acp_session_id = SessionId::new(new_session_id.clone());
|
||||
|
||||
self.spawn_agent_setup(
|
||||
cx,
|
||||
agent_tx,
|
||||
AgentSetupRequest {
|
||||
session_id: SessionId::new(new_session_id.clone()),
|
||||
session_id: acp_session_id.clone(),
|
||||
goose_session,
|
||||
mcp_servers: args.mcp_servers,
|
||||
resolved_provider: resolved.ok(),
|
||||
@@ -3587,8 +3529,6 @@ impl GooseAcpAgent {
|
||||
|
||||
let meta = session_meta(&new_session);
|
||||
|
||||
let acp_session_id = SessionId::new(new_session_id);
|
||||
|
||||
let mut response = ForkSessionResponse::new(acp_session_id.clone())
|
||||
.modes(mode_state)
|
||||
.meta(meta);
|
||||
@@ -3599,10 +3539,7 @@ impl GooseAcpAgent {
|
||||
if let Some(co) = config_options {
|
||||
response = response.config_options(co);
|
||||
}
|
||||
|
||||
self.send_available_commands_update(cx, &acp_session_id)
|
||||
.await?;
|
||||
|
||||
Self::send_available_commands_update(cx, &acp_session_id, &args.cwd)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -1403,17 +1403,6 @@ impl Agent {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Track custom slash command usage (don't track command name for privacy)
|
||||
if message_text.trim().starts_with('/') {
|
||||
let command = message_text.split_whitespace().next();
|
||||
if let Some(cmd) = command {
|
||||
if crate::slash_commands::get_recipe_for_command(cmd).is_some() {
|
||||
#[cfg(feature = "telemetry")]
|
||||
crate::posthog::emit_custom_slash_command_used();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let command_result = self
|
||||
.execute_command(&message_text, &session_config.id)
|
||||
.await;
|
||||
|
||||
@@ -4,7 +4,7 @@ use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::context_mgmt::compact_messages;
|
||||
use crate::conversation::message::{Message, SystemNotificationType};
|
||||
use crate::recipe::build_recipe::build_recipe_from_template_with_positional_params;
|
||||
use crate::slash_commands::{recipe_slash_command, skill_slash_command};
|
||||
|
||||
use super::Agent;
|
||||
|
||||
@@ -113,7 +113,16 @@ impl Agent {
|
||||
"goal" => self.handle_goal_command(params_str).await,
|
||||
"grind" => self.handle_grind_command(params_str).await,
|
||||
_ => {
|
||||
self.handle_recipe_command(command, params_str, session_id)
|
||||
if let Some(message) = self
|
||||
.handle_recipe_command(command, params_str, session_id)
|
||||
.await?
|
||||
{
|
||||
#[cfg(feature = "telemetry")]
|
||||
crate::posthog::emit_custom_slash_command_used();
|
||||
return Ok(Some(message));
|
||||
}
|
||||
|
||||
self.handle_skill_command(command, params_str, session_id)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -170,9 +179,6 @@ impl Agent {
|
||||
}
|
||||
|
||||
async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> {
|
||||
use crate::skills::list_installed_skills;
|
||||
use goose_sdk::custom_requests::SourceType;
|
||||
|
||||
let working_dir = self
|
||||
.config
|
||||
.session_manager
|
||||
@@ -180,34 +186,7 @@ impl Agent {
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.working_dir);
|
||||
let sources = list_installed_skills(working_dir.as_deref());
|
||||
let skills: Vec<_> = sources
|
||||
.iter()
|
||||
.filter(|s| matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill))
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
if skills.is_empty() {
|
||||
output.push_str("No skills installed.\n\n");
|
||||
output.push_str("Skills are loaded from SKILL.md files in:\n");
|
||||
output.push_str(" - ~/.agents/skills/ (global)\n");
|
||||
output.push_str(" - ~/.agents/plugins/*/skills/ (installed plugins)\n");
|
||||
output.push_str(" - .agents/skills/ (in current project)\n");
|
||||
} else {
|
||||
output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len()));
|
||||
for skill in &skills {
|
||||
let kind_label = if skill.source_type == SourceType::BuiltinSkill {
|
||||
" *(builtin)*"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
output.push_str(&format!(
|
||||
"- **{}**{}: {}\n",
|
||||
skill.name, kind_label, skill.description
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let output = skill_slash_command::format_installed_skills(working_dir.as_deref());
|
||||
Ok(Some(Message::assistant().with_text(output)))
|
||||
}
|
||||
|
||||
@@ -366,110 +345,35 @@ impl Agent {
|
||||
params_str: &str,
|
||||
_session_id: &str,
|
||||
) -> Result<Option<Message>> {
|
||||
let full_command = format!("/{}", command);
|
||||
let recipe_path = match crate::slash_commands::get_recipe_for_command(&full_command) {
|
||||
Some(path) => path,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if !recipe_path.exists() {
|
||||
return Ok(None);
|
||||
match recipe_slash_command::resolve_command(command, params_str) {
|
||||
Ok(None) => Ok(None),
|
||||
Ok(Some((response, prompt))) => {
|
||||
self.apply_recipe_components(response, true).await;
|
||||
Ok(Some(Message::user().with_text(prompt)))
|
||||
}
|
||||
Err(text) => Ok(Some(Message::assistant().with_text(text))),
|
||||
}
|
||||
}
|
||||
|
||||
let recipe_content = std::fs::read_to_string(&recipe_path)
|
||||
.map_err(|e| anyhow!("Failed to read recipe file: {}", e))?;
|
||||
async fn handle_skill_command(
|
||||
&self,
|
||||
command: &str,
|
||||
params_str: &str,
|
||||
session_id: &str,
|
||||
) -> Result<Option<Message>> {
|
||||
let working_dir = self
|
||||
.config
|
||||
.session_manager
|
||||
.get_session(session_id, false)
|
||||
.await
|
||||
.ok()
|
||||
.map(|session| session.working_dir);
|
||||
|
||||
let recipe_dir = recipe_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("Recipe path has no parent directory"))?;
|
||||
|
||||
let recipe_dir_str = recipe_dir.display().to_string();
|
||||
let validation_result =
|
||||
crate::recipe::validate_recipe::validate_recipe_template_from_content(
|
||||
&recipe_content,
|
||||
Some(recipe_dir_str),
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to parse recipe: {}", e))?;
|
||||
|
||||
let param_values: Vec<String> = if params_str.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
let params_without_default = validation_result
|
||||
.parameters
|
||||
.as_ref()
|
||||
.map(|params| params.iter().filter(|p| p.default.is_none()).count())
|
||||
.unwrap_or(0);
|
||||
|
||||
if params_without_default <= 1 {
|
||||
vec![params_str.to_string()]
|
||||
} else {
|
||||
let param_names: Vec<String> = validation_result
|
||||
.parameters
|
||||
.as_ref()
|
||||
.map(|params| {
|
||||
params
|
||||
.iter()
|
||||
.filter(|p| p.default.is_none())
|
||||
.map(|p| p.key.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let error_message = format!(
|
||||
"The /{} recipe requires {} parameters: {}.\n\n\
|
||||
Slash command recipes only support 1 parameter.\n\n\
|
||||
**To use this recipe:**\n\
|
||||
• **CLI:** `goose run --recipe {} {}`\n\
|
||||
• **Desktop:** Launch from the recipes sidebar to fill in parameters",
|
||||
command,
|
||||
params_without_default,
|
||||
param_names
|
||||
.iter()
|
||||
.map(|name| format!("**{}**", name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
command,
|
||||
param_names
|
||||
.iter()
|
||||
.map(|name| format!("--params {}=\"...\"", name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
);
|
||||
|
||||
return Err(anyhow!(error_message));
|
||||
}
|
||||
};
|
||||
|
||||
let param_values_len = param_values.len();
|
||||
|
||||
let recipe = match build_recipe_from_template_with_positional_params(
|
||||
recipe_content,
|
||||
recipe_dir,
|
||||
param_values,
|
||||
None::<fn(&str, &str) -> Result<String>>,
|
||||
) {
|
||||
Ok(recipe) => recipe,
|
||||
Err(crate::recipe::build_recipe::RecipeError::MissingParams { parameters }) => {
|
||||
return Ok(Some(Message::assistant().with_text(format!(
|
||||
"Recipe requires {} parameter(s): {}. Provided: {}",
|
||||
parameters.len(),
|
||||
parameters.join(", "),
|
||||
param_values_len
|
||||
))));
|
||||
}
|
||||
Err(e) => return Err(anyhow!("Failed to build recipe: {}", e)),
|
||||
};
|
||||
|
||||
self.apply_recipe_components(recipe.response.clone(), true)
|
||||
.await;
|
||||
|
||||
let prompt = [recipe.instructions.as_deref(), recipe.prompt.as_deref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
|
||||
Ok(Some(Message::user().with_text(prompt)))
|
||||
match skill_slash_command::resolve_command(command, params_str, working_dir.as_deref()) {
|
||||
Ok(None) => Ok(None),
|
||||
Ok(Some(prompt)) => Ok(Some(Message::user().with_text(prompt))),
|
||||
Err(text) => Ok(Some(Message::assistant().with_text(text))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_goal_command(&self, params_str: &str) -> Result<Option<Message>> {
|
||||
|
||||
@@ -75,46 +75,6 @@ where
|
||||
Ok(recipe)
|
||||
}
|
||||
|
||||
pub fn build_recipe_from_template_with_positional_params<F>(
|
||||
recipe_content: String,
|
||||
recipe_dir: &Path,
|
||||
params: Vec<String>,
|
||||
user_prompt_fn: Option<F>,
|
||||
) -> Result<Recipe, RecipeError>
|
||||
where
|
||||
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
|
||||
{
|
||||
let recipe_dir_str = recipe_dir.display().to_string();
|
||||
|
||||
let recipe_parameters =
|
||||
validate_recipe_template_from_content(&recipe_content, Some(recipe_dir_str.clone()))
|
||||
.map_err(|source| RecipeError::Invalid { source })?
|
||||
.parameters;
|
||||
|
||||
let param_pairs: Vec<(String, String)> = if let Some(recipe_params) = &recipe_parameters {
|
||||
let required_count = recipe_params.iter().filter(|p| p.default.is_none()).count();
|
||||
if params.len() < required_count {
|
||||
let required_keys: Vec<String> = recipe_params
|
||||
.iter()
|
||||
.filter(|p| p.default.is_none())
|
||||
.map(|p| p.key.clone())
|
||||
.collect();
|
||||
return Err(RecipeError::MissingParams {
|
||||
parameters: required_keys,
|
||||
});
|
||||
}
|
||||
recipe_params
|
||||
.iter()
|
||||
.zip(params.iter())
|
||||
.map(|(rp, p)| (rp.key.clone(), p.clone()))
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
build_recipe_from_template(recipe_content, recipe_dir, param_pairs, user_prompt_fn)
|
||||
}
|
||||
|
||||
pub fn apply_values_to_parameters<F>(
|
||||
user_params: &[(String, String)],
|
||||
recipe_parameters: Option<Vec<RecipeParameter>>,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
use crate::utils::split_command_args;
|
||||
use anyhow::Result;
|
||||
use regex::{Captures, Regex};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"\$ARGUMENTS\[(?P<idx>\d+)\]|\$ARGUMENTS\b|\$(?P<pos>\d+)|\$(?P<name>[A-Za-z_][A-Za-z0-9_-]*)",
|
||||
)
|
||||
.expect("skill argument regex should compile")
|
||||
});
|
||||
|
||||
fn is_resolvable(caps: &Captures<'_>, names: &[String]) -> bool {
|
||||
caps.name("name")
|
||||
.map(|m| names.iter().any(|n| n == m.as_str()))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub(super) fn apply_skill_arguments(
|
||||
content: &str,
|
||||
raw_args: &str,
|
||||
argument_names: &[String],
|
||||
) -> Result<String> {
|
||||
if !PLACEHOLDER_RE
|
||||
.captures_iter(content)
|
||||
.any(|caps| is_resolvable(&caps, argument_names))
|
||||
{
|
||||
return Ok(format!("{content}\n\nARGUMENTS: {raw_args}"));
|
||||
}
|
||||
|
||||
let tokens = split_command_args(raw_args)?;
|
||||
let nth = |i: usize| tokens.get(i).cloned().unwrap_or_default();
|
||||
|
||||
let rendered = PLACEHOLDER_RE.replace_all(content, |caps: &Captures<'_>| {
|
||||
if let Some(n) = caps.name("idx") {
|
||||
return nth(n.as_str().parse().unwrap_or(usize::MAX));
|
||||
}
|
||||
if let Some(n) = caps.name("pos") {
|
||||
let p: usize = n.as_str().parse().unwrap_or(0);
|
||||
return p.checked_sub(1).map_or_else(String::new, nth);
|
||||
}
|
||||
if let Some(name) = caps.name("name") {
|
||||
return argument_names
|
||||
.iter()
|
||||
.position(|n| n == name.as_str())
|
||||
.map_or_else(|| caps[0].to_string(), nth);
|
||||
}
|
||||
raw_args.to_string()
|
||||
});
|
||||
|
||||
Ok(rendered.into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn names(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_placeholder_is_replaced_with_raw_args() {
|
||||
let out = apply_skill_arguments("Run $ARGUMENTS now.", "alpha beta", &[]).unwrap();
|
||||
assert_eq!(out, "Run alpha beta now.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_is_one_indexed() {
|
||||
let out = apply_skill_arguments("first=$1, second=$2, third=$3", "alpha beta gamma", &[])
|
||||
.unwrap();
|
||||
assert_eq!(out, "first=alpha, second=beta, third=gamma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_out_of_range_is_empty() {
|
||||
let out = apply_skill_arguments("[$5]", "only-one", &[]).unwrap();
|
||||
assert_eq!(out, "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_index_is_zero_indexed() {
|
||||
let out = apply_skill_arguments(
|
||||
"a=$ARGUMENTS[0], b=$ARGUMENTS[1], c=$ARGUMENTS[2]",
|
||||
"one two three",
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, "a=one, b=two, c=three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_index_out_of_range_is_empty() {
|
||||
let out = apply_skill_arguments("[$ARGUMENTS[9]]", "one two", &[]).unwrap();
|
||||
assert_eq!(out, "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_arg_maps_to_position() {
|
||||
let out = apply_skill_arguments(
|
||||
"Migrate $component from $from to $to.",
|
||||
"Button old-lib new-lib",
|
||||
&names(&["component", "from", "to"]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, "Migrate Button from old-lib to new-lib.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_named_arg_stays_literal() {
|
||||
let out =
|
||||
apply_skill_arguments("Hello $first $missing", "Andy", &names(&["first"])).unwrap();
|
||||
assert_eq!(out, "Hello Andy $missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_token_groups_multiple_words() {
|
||||
let out = apply_skill_arguments(
|
||||
"name=$name, addr=$addr",
|
||||
r#"Andy "57 Collins""#,
|
||||
&names(&["name", "addr"]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, "name=Andy, addr=57 Collins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apostrophes_in_unquoted_arguments_stay_literal() {
|
||||
let out = apply_skill_arguments(
|
||||
"author=$author, contraction=$contraction",
|
||||
"O'Reilly don't",
|
||||
&names(&["author", "contraction"]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out, "author=O'Reilly, contraction=don't");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_keeps_raw_quotes_while_positional_strips_them() {
|
||||
let out =
|
||||
apply_skill_arguments("raw=$ARGUMENTS pos=$1", r#""hello world" tail"#, &[]).unwrap();
|
||||
assert_eq!(out, r#"raw="hello world" tail pos=hello world"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_placeholder_appends_arguments_marker() {
|
||||
let out = apply_skill_arguments("Just static instructions.", "src/foo.rs", &[]).unwrap();
|
||||
assert_eq!(out, "Just static instructions.\n\nARGUMENTS: src/foo.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_named_only_does_not_count_as_placeholder() {
|
||||
let out =
|
||||
apply_skill_arguments("Just $missing here.", "src/foo.rs", &names(&["other"])).unwrap();
|
||||
assert_eq!(out, "Just $missing here.\n\nARGUMENTS: src/foo.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_quote_returns_error_when_placeholders_present() {
|
||||
let result = apply_skill_arguments("$1", r#""unterminated"#, &[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_quote_is_fine_when_no_placeholders_present() {
|
||||
let out = apply_skill_arguments("no placeholders", r#""unterminated"#, &[]).unwrap();
|
||||
assert!(out.ends_with(r#"ARGUMENTS: "unterminated"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_followed_by_non_index_bracket_is_replaced() {
|
||||
let out = apply_skill_arguments("$ARGUMENTS[abc]", "x y", &[]).unwrap();
|
||||
assert_eq!(out, "x y[abc]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_tokens_beyond_named_args_reachable_via_positional() {
|
||||
let out =
|
||||
apply_skill_arguments("$first / $1 / $2 / $3", "a b c", &names(&["first"])).unwrap();
|
||||
assert_eq!(out, "a / a / b / c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_name_without_token_substitutes_empty() {
|
||||
let out = apply_skill_arguments("[$first][$second]", "only", &names(&["first", "second"]))
|
||||
.unwrap();
|
||||
assert_eq!(out, "[only][]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollar_sign_with_no_match_stays_literal() {
|
||||
let out = apply_skill_arguments("price is $100USD or $$ or $", "ignored", &[]).unwrap();
|
||||
assert!(out.contains("price is "));
|
||||
assert!(out.contains("$$"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_args_with_arguments_placeholder_substitutes_empty() {
|
||||
let out = apply_skill_arguments("Run $ARGUMENTS done.", "", &[]).unwrap();
|
||||
assert_eq!(out, "Run done.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_args_with_no_placeholder_still_appends_marker() {
|
||||
let out = apply_skill_arguments("Just instructions.", "", &[]).unwrap();
|
||||
assert_eq!(out, "Just instructions.\n\nARGUMENTS: ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_positional_substitutes_every_occurrence() {
|
||||
let out = apply_skill_arguments("$1 $1 $1", "alpha", &[]).unwrap();
|
||||
assert_eq!(out, "alpha alpha alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_arguments_placeholders_substitute_every_occurrence() {
|
||||
let out = apply_skill_arguments("first=$ARGUMENTS / again=$ARGUMENTS", "a b", &[]).unwrap();
|
||||
assert_eq!(out, "first=a b / again=a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_windows_backslash_paths() {
|
||||
let out = apply_skill_arguments("Path: $path", r#""C:\path\""#, &names(&["path"])).unwrap();
|
||||
assert_eq!(out, r"Path: C:\path\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_positional_placeholders_substitute_independently() {
|
||||
let out = apply_skill_arguments("$1$2", "alpha beta", &[]).unwrap();
|
||||
assert_eq!(out, "alphabeta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphenated_and_underscored_named_args_resolve() {
|
||||
let out = apply_skill_arguments(
|
||||
"$my-arg / $_internal",
|
||||
"foo bar",
|
||||
&names(&["my-arg", "_internal"]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, "foo / bar");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::discover_skills;
|
||||
use super::loaded_skill_context_with_args;
|
||||
use crate::agents::extension::PlatformExtensionContext;
|
||||
use crate::agents::mcp_client::{Error, McpClientTrait};
|
||||
use crate::agents::ToolCallContext;
|
||||
@@ -49,6 +50,10 @@ impl McpClientTrait for SkillsClient {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the skill to load. Use \"skill-name/path\" to load a supporting file."
|
||||
},
|
||||
"args": {
|
||||
"type": "string",
|
||||
"description": "Optional arguments to provide when loading the skill."
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -60,6 +65,7 @@ impl McpClientTrait for SkillsClient {
|
||||
load it first to get the detailed instructions.\n\n\
|
||||
Examples:\n\
|
||||
- load_skill(name: \"gdrive\") → Loads the gdrive skill instructions\n\
|
||||
- load_skill(name: \"my-skill\", args: \"the arguments for the skill\") → Loads a skill with arguments\n\
|
||||
- load_skill(name: \"my-skill/template.md\") → Loads a supporting file"
|
||||
.to_string(),
|
||||
schema.as_object().unwrap().clone(),
|
||||
@@ -97,36 +103,21 @@ impl McpClientTrait for SkillsClient {
|
||||
"Missing required parameter: name",
|
||||
)]));
|
||||
}
|
||||
let args = arguments
|
||||
.as_ref()
|
||||
.and_then(|args| args.get("args"))
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
let skills = discover_skills(Some(&self.working_dir));
|
||||
|
||||
if let Some(skill) = skills.iter().find(|s| s.name == skill_name) {
|
||||
let mut output = format!(
|
||||
"# Loaded Skill: {} ({})\n\n{}\n",
|
||||
skill.name,
|
||||
skill.source_type,
|
||||
skill.to_load_text()
|
||||
);
|
||||
|
||||
if !skill.supporting_files.is_empty() {
|
||||
let skill_dir = Path::new(&skill.path);
|
||||
output.push_str(&format!(
|
||||
"\n## Supporting Files\n\nSkill directory: {}\n\n",
|
||||
skill.path
|
||||
));
|
||||
for file in &skill.supporting_files {
|
||||
if let Ok(relative) = Path::new(file).strip_prefix(skill_dir) {
|
||||
let rel_str = relative.to_string_lossy().replace('\\', "/");
|
||||
output.push_str(&format!(
|
||||
"- {} → load_skill(name: \"{}/{}\")\n",
|
||||
rel_str, skill.name, rel_str
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str("\n---\nThis knowledge is now available in your context.");
|
||||
return Ok(CallToolResult::success(vec![Content::text(output)]));
|
||||
return match loaded_skill_context_with_args(skill, args) {
|
||||
Ok(rendered) => Ok(CallToolResult::success(vec![Content::text(rendered)])),
|
||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
||||
"Failed to parse skill arguments: {}",
|
||||
e
|
||||
))])),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some((parent_skill_name, raw_relative_path)) = skill_name.split_once('/') {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! built-ins) and the runtime MCP client (`client` submodule). User-facing
|
||||
//! CRUD lives in `crate::sources`, which generalizes across source types.
|
||||
|
||||
mod arguments;
|
||||
mod builtin;
|
||||
pub mod client;
|
||||
|
||||
@@ -11,6 +12,8 @@ use crate::config::paths::Paths;
|
||||
use crate::plugins::installed_plugin_skill_dirs;
|
||||
use crate::sources::parse_frontmatter;
|
||||
use agent_client_protocol::Error;
|
||||
use anyhow::Result;
|
||||
use arguments::apply_skill_arguments;
|
||||
use goose_sdk::custom_requests::{SourceEntry, SourceType};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
@@ -96,6 +99,68 @@ pub(crate) fn validate_skill_name(name: &str) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn loaded_skill_context(skill: &SourceEntry, content: &str) -> String {
|
||||
let title = format!("{} ({})", skill.name, skill.source_type);
|
||||
let mut output = format!(
|
||||
"# Loaded Skill: {title}\n\n{}\n\n## Content\n\n{}\n",
|
||||
skill.description, content
|
||||
);
|
||||
|
||||
if !skill.supporting_files.is_empty() {
|
||||
let skill_dir = Path::new(&skill.path);
|
||||
output.push_str(&format!(
|
||||
"\n## Supporting Files\n\nSkill directory: {}\n\n",
|
||||
skill.path
|
||||
));
|
||||
for file in &skill.supporting_files {
|
||||
if let Ok(relative) = Path::new(file).strip_prefix(skill_dir) {
|
||||
let rel_str = relative.to_string_lossy().replace('\\', "/");
|
||||
output.push_str(&format!(
|
||||
"- {} → load_skill(name: \"{}/{}\")\n",
|
||||
rel_str, skill.name, rel_str
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn loaded_skill_context_with_args(skill: &SourceEntry, args: Option<&str>) -> Result<String> {
|
||||
let content = if let Some(args) = args {
|
||||
apply_skill_arguments(&skill.content, args, &skill_argument_names(skill))?
|
||||
} else {
|
||||
skill.content.clone()
|
||||
};
|
||||
|
||||
Ok(loaded_skill_context(skill, &content))
|
||||
}
|
||||
|
||||
pub fn skill_argument_hint(skill: &SourceEntry) -> Option<String> {
|
||||
skill
|
||||
.properties
|
||||
.get("argument-hint")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|hint| !hint.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn skill_argument_names(skill: &SourceEntry) -> Vec<String> {
|
||||
skill
|
||||
.properties
|
||||
.get("arguments")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn canonicalize_or_original(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
@@ -425,3 +490,45 @@ pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec<SourceEntry> {
|
||||
};
|
||||
discover_skills(wd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn skill_with_content(content: &str) -> SourceEntry {
|
||||
SourceEntry {
|
||||
source_type: SourceType::Skill,
|
||||
name: "test-skill".to_string(),
|
||||
description: "Test skill".to_string(),
|
||||
content: content.to_string(),
|
||||
path: String::new(),
|
||||
global: false,
|
||||
writable: true,
|
||||
supporting_files: Vec::new(),
|
||||
properties: HashMap::from([(
|
||||
"arguments".to_string(),
|
||||
json!(["component", "from", "to"]),
|
||||
)]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_skill_context_with_args_replaces_arguments_placeholder_with_raw_args() {
|
||||
let skill = skill_with_content("Review $ARGUMENTS carefully.");
|
||||
|
||||
let rendered = loaded_skill_context_with_args(&skill, Some("src/foo.rs --strict")).unwrap();
|
||||
|
||||
assert!(rendered.contains("Review src/foo.rs --strict carefully."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_skill_context_with_args_uses_context_without_args() {
|
||||
let skill = skill_with_content("Review the code carefully.");
|
||||
|
||||
let rendered = loaded_skill_context_with_args(&skill, None).unwrap();
|
||||
|
||||
assert!(rendered.contains("# Loaded Skill: test-skill (skill)"));
|
||||
assert!(rendered.contains("## Content\n\nReview the code carefully."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::recipe::Recipe;
|
||||
|
||||
const SLASH_COMMANDS_CONFIG_KEY: &str = "slash_commands";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlashCommandMapping {
|
||||
pub command: String,
|
||||
pub recipe_path: String,
|
||||
}
|
||||
|
||||
pub fn list_commands() -> Vec<SlashCommandMapping> {
|
||||
Config::global()
|
||||
.get_param(SLASH_COMMANDS_CONFIG_KEY)
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"Failed to load {}: {}. Falling back to empty list.",
|
||||
SLASH_COMMANDS_CONFIG_KEY, err
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
fn save_slash_commands(commands: Vec<SlashCommandMapping>) -> Result<()> {
|
||||
Config::global()
|
||||
.set_param(SLASH_COMMANDS_CONFIG_KEY, &commands)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save slash commands: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_recipe_slash_command(recipe_path: PathBuf, command: Option<String>) -> Result<()> {
|
||||
let recipe_path_str = recipe_path.to_string_lossy().to_string();
|
||||
|
||||
let mut commands = list_commands();
|
||||
commands.retain(|mapping| mapping.recipe_path != recipe_path_str);
|
||||
|
||||
if let Some(cmd) = command {
|
||||
let normalized_cmd = cmd.trim_start_matches('/').to_lowercase();
|
||||
if !normalized_cmd.is_empty() {
|
||||
commands.push(SlashCommandMapping {
|
||||
command: normalized_cmd,
|
||||
recipe_path: recipe_path_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
save_slash_commands(commands)
|
||||
}
|
||||
|
||||
pub fn get_recipe_for_command(command: &str) -> Option<PathBuf> {
|
||||
let normalized = command.trim_start_matches('/').to_lowercase();
|
||||
let commands = list_commands();
|
||||
commands
|
||||
.into_iter()
|
||||
.find(|mapping| mapping.command == normalized)
|
||||
.map(|mapping| PathBuf::from(mapping.recipe_path))
|
||||
}
|
||||
|
||||
pub fn resolve_slash_command(command: &str) -> Option<Recipe> {
|
||||
let recipe_path = get_recipe_for_command(command)?;
|
||||
|
||||
if !recipe_path.exists() {
|
||||
return None;
|
||||
}
|
||||
let recipe_content = std::fs::read_to_string(&recipe_path).ok()?;
|
||||
let recipe = Recipe::from_content(&recipe_content).ok()?;
|
||||
|
||||
Some(recipe)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod recipe_slash_command;
|
||||
pub mod skill_slash_command;
|
||||
pub mod slash_command;
|
||||
pub mod types;
|
||||
pub mod util;
|
||||
@@ -0,0 +1,628 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use super::types::{SlashCommandEntry, SlashCommandSource};
|
||||
use super::util::normalize_command_name;
|
||||
use crate::config::Config;
|
||||
use crate::recipe::build_recipe::{build_recipe_from_template, RecipeError};
|
||||
use crate::recipe::{RecipeParameter, RecipeParameterRequirement, Response};
|
||||
|
||||
const SLASH_COMMANDS_CONFIG_KEY: &str = "slash_commands";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlashCommandMapping {
|
||||
pub command: String,
|
||||
pub recipe_path: String,
|
||||
}
|
||||
|
||||
pub fn list_commands() -> Vec<SlashCommandMapping> {
|
||||
Config::global()
|
||||
.get_param(SLASH_COMMANDS_CONFIG_KEY)
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"Failed to load {}: {}. Falling back to empty list.",
|
||||
SLASH_COMMANDS_CONFIG_KEY, err
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
fn save_slash_commands(commands: Vec<SlashCommandMapping>) -> Result<()> {
|
||||
Config::global()
|
||||
.set_param(SLASH_COMMANDS_CONFIG_KEY, &commands)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save slash commands: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_recipe_slash_command(recipe_path: PathBuf, command: Option<String>) -> Result<()> {
|
||||
let recipe_path_str = recipe_path.to_string_lossy().to_string();
|
||||
|
||||
let mut commands = list_commands();
|
||||
commands.retain(|mapping| mapping.recipe_path != recipe_path_str);
|
||||
|
||||
if let Some(cmd) = command {
|
||||
let normalized_cmd = cmd.trim_start_matches('/').to_lowercase();
|
||||
if !normalized_cmd.is_empty() {
|
||||
commands.push(SlashCommandMapping {
|
||||
command: normalized_cmd,
|
||||
recipe_path: recipe_path_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
save_slash_commands(commands)
|
||||
}
|
||||
|
||||
pub fn get_recipe_for_command(command: &str) -> Option<PathBuf> {
|
||||
let normalized = command.trim_start_matches('/').to_lowercase();
|
||||
let commands = list_commands();
|
||||
commands
|
||||
.into_iter()
|
||||
.find(|mapping| mapping.command == normalized)
|
||||
.map(|mapping| PathBuf::from(mapping.recipe_path))
|
||||
}
|
||||
|
||||
pub(super) fn commands_from_mappings(mappings: Vec<SlashCommandMapping>) -> Vec<SlashCommandEntry> {
|
||||
mappings
|
||||
.into_iter()
|
||||
.filter_map(|mapping| {
|
||||
let name = normalize_command_name(&mapping.command);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let metadata = recipe_entry(&mapping.recipe_path)?;
|
||||
|
||||
Some(SlashCommandEntry {
|
||||
name,
|
||||
description: metadata.description,
|
||||
source: SlashCommandSource::Recipe,
|
||||
input_hint: metadata.input_hint,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct RecipeCommandMetadata {
|
||||
description: String,
|
||||
input_hint: Option<String>,
|
||||
}
|
||||
|
||||
fn recipe_entry(recipe_path: &str) -> Option<RecipeCommandMetadata> {
|
||||
let recipe_path = PathBuf::from(recipe_path);
|
||||
if !recipe_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let recipe_content = std::fs::read_to_string(&recipe_path).ok()?;
|
||||
let recipe_dir = recipe_path.parent()?;
|
||||
let recipe_dir_str = recipe_dir.display().to_string();
|
||||
let validation_result = crate::recipe::validate_recipe::validate_recipe_template_from_content(
|
||||
&recipe_content,
|
||||
Some(recipe_dir_str),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
Some(RecipeCommandMetadata {
|
||||
description: validation_result.description,
|
||||
input_hint: input_hint_for_recipe(validation_result.parameters.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
fn input_hint_for_recipe(params: Option<&Vec<RecipeParameter>>) -> Option<String> {
|
||||
let params = params?;
|
||||
if params.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut required = Vec::new();
|
||||
let mut optional = Vec::new();
|
||||
|
||||
for p in params {
|
||||
match p.requirement {
|
||||
RecipeParameterRequirement::Required | RecipeParameterRequirement::UserPrompt => {
|
||||
required.push(format!("<{}>", p.key));
|
||||
}
|
||||
RecipeParameterRequirement::Optional => {
|
||||
optional.push(format!("[--{} <{}>]", p.key, p.key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(
|
||||
required
|
||||
.into_iter()
|
||||
.chain(optional)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
fn invalid_recipe_msg(command: &str, reason: impl std::fmt::Display) -> String {
|
||||
format!("Recipe /{} is not valid: {}", command, reason)
|
||||
}
|
||||
|
||||
pub fn resolve_command(
|
||||
command: &str,
|
||||
params_str: &str,
|
||||
) -> Result<Option<(Option<Response>, String)>, String> {
|
||||
let full_command = format!("/{}", command);
|
||||
let Some(recipe_path) = get_recipe_for_command(&full_command) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if !recipe_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let recipe_content =
|
||||
std::fs::read_to_string(&recipe_path).map_err(|e| invalid_recipe_msg(command, e))?;
|
||||
|
||||
let recipe_dir = recipe_path
|
||||
.parent()
|
||||
.ok_or_else(|| invalid_recipe_msg(command, "unable to resolve recipe directory"))?;
|
||||
|
||||
let recipe_dir_str = recipe_dir.display().to_string();
|
||||
let validation_result = crate::recipe::validate_recipe::validate_recipe_template_from_content(
|
||||
&recipe_content,
|
||||
Some(recipe_dir_str),
|
||||
)
|
||||
.map_err(|e| invalid_recipe_msg(command, e))?;
|
||||
|
||||
let empty_params: Vec<RecipeParameter> = Vec::new();
|
||||
let all_params = validation_result
|
||||
.parameters
|
||||
.as_ref()
|
||||
.unwrap_or(&empty_params);
|
||||
let required: Vec<&RecipeParameter> = all_params
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
matches!(
|
||||
p.requirement,
|
||||
RecipeParameterRequirement::Required | RecipeParameterRequirement::UserPrompt
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let optional: Vec<&RecipeParameter> = all_params
|
||||
.iter()
|
||||
.filter(|p| matches!(p.requirement, RecipeParameterRequirement::Optional))
|
||||
.collect();
|
||||
|
||||
let param_values: Vec<(String, String)> = if params_str.is_empty() {
|
||||
vec![]
|
||||
} else if required.len() == 1 && optional.is_empty() {
|
||||
vec![(required[0].key.clone(), params_str.to_string())]
|
||||
} else {
|
||||
parse_recipe_args(params_str, &required, &optional)
|
||||
.map_err(|e| format!("Recipe /{}: {}", command, e))?
|
||||
};
|
||||
|
||||
let recipe = build_recipe_from_template(
|
||||
recipe_content,
|
||||
recipe_dir,
|
||||
param_values,
|
||||
None::<fn(&str, &str) -> Result<String>>,
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
RecipeError::MissingParams { parameters } => invalid_recipe_msg(
|
||||
command,
|
||||
format!("requires parameter(s): {}.", parameters.join(", "),),
|
||||
),
|
||||
other => invalid_recipe_msg(command, other),
|
||||
})?;
|
||||
|
||||
let prompt = [recipe.instructions.as_deref(), recipe.prompt.as_deref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
|
||||
Ok(Some((recipe.response, prompt)))
|
||||
}
|
||||
|
||||
fn parse_recipe_args(
|
||||
params_str: &str,
|
||||
required: &[&RecipeParameter],
|
||||
optional: &[&RecipeParameter],
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let tokens = crate::utils::split_command_args(params_str)?;
|
||||
let required_keys: HashSet<&str> = required.iter().map(|p| p.key.as_str()).collect();
|
||||
let optional_keys: HashSet<&str> = optional.iter().map(|p| p.key.as_str()).collect();
|
||||
|
||||
let mut positionals: Vec<String> = Vec::new();
|
||||
let mut flags: Vec<(String, String)> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < tokens.len() {
|
||||
let token = &tokens[i];
|
||||
if let Some(flag) = token.strip_prefix("--") {
|
||||
if required_keys.contains(flag) {
|
||||
return Err(anyhow!(
|
||||
"Parameter '{}' is required; pass it positionally, not as --{}",
|
||||
flag,
|
||||
flag
|
||||
));
|
||||
}
|
||||
if !optional_keys.contains(flag) {
|
||||
return Err(anyhow!("Unknown parameter: --{}", flag));
|
||||
}
|
||||
let value = tokens
|
||||
.get(i + 1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
.ok_or_else(|| anyhow!("Missing value for --{}", flag))?;
|
||||
flags.push((flag.to_string(), value.clone()));
|
||||
i += 2;
|
||||
} else {
|
||||
positionals.push(token.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
if required.len() == 1 && !positionals.is_empty() {
|
||||
result.push((required[0].key.clone(), positionals.join(" ")));
|
||||
} else {
|
||||
for (idx, value) in positionals.into_iter().enumerate() {
|
||||
if idx >= required.len() {
|
||||
return Err(anyhow!("Unexpected positional argument: {}", value));
|
||||
}
|
||||
result.push((required[idx].key.clone(), value));
|
||||
}
|
||||
}
|
||||
result.extend(flags);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::recipe::RecipeParameterInputType;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn required_param(key: &str) -> RecipeParameter {
|
||||
RecipeParameter {
|
||||
key: key.to_string(),
|
||||
input_type: RecipeParameterInputType::String,
|
||||
requirement: RecipeParameterRequirement::Required,
|
||||
description: format!("{key} parameter"),
|
||||
default: None,
|
||||
options: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_param(key: &str) -> RecipeParameter {
|
||||
RecipeParameter {
|
||||
key: key.to_string(),
|
||||
input_type: RecipeParameterInputType::String,
|
||||
requirement: RecipeParameterRequirement::Optional,
|
||||
description: format!("{key} parameter"),
|
||||
default: Some("default".to_string()),
|
||||
options: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_maps_required_positionals_and_optional_flags() {
|
||||
let component = required_param("component");
|
||||
let from = required_param("from");
|
||||
let to = optional_param("to");
|
||||
let scope = optional_param("scope");
|
||||
let required = vec![&component, &from];
|
||||
let optional = vec![&to, &scope];
|
||||
|
||||
let parsed = parse_recipe_args(
|
||||
r#""Button Group" old-lib --to new-lib"#,
|
||||
&required,
|
||||
&optional,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
("component".to_string(), "Button Group".to_string()),
|
||||
("from".to_string(), "old-lib".to_string()),
|
||||
("to".to_string(), "new-lib".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_greedy_captures_multi_word_required_with_optional_flag() {
|
||||
let location = required_param("location");
|
||||
let theme = optional_param("theme");
|
||||
let required = vec![&location];
|
||||
let optional = vec![&theme];
|
||||
|
||||
let parsed =
|
||||
parse_recipe_args("Melbourne weather --theme culture", &required, &optional).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
("location".to_string(), "Melbourne weather".to_string()),
|
||||
("theme".to_string(), "culture".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_keeps_apostrophes_in_unquoted_values() {
|
||||
let topic = required_param("topic");
|
||||
let theme = optional_param("theme");
|
||||
let required = vec![&topic];
|
||||
let optional = vec![&theme];
|
||||
|
||||
let parsed = parse_recipe_args(
|
||||
"O'Reilly's guide --theme author's-pick",
|
||||
&required,
|
||||
&optional,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
("topic".to_string(), "O'Reilly's guide".to_string()),
|
||||
("theme".to_string(), "author's-pick".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_greedy_captures_multi_word_required_without_flags() {
|
||||
let location = required_param("location");
|
||||
let theme = optional_param("theme");
|
||||
let required = vec![&location];
|
||||
let optional = vec![&theme];
|
||||
|
||||
let parsed = parse_recipe_args("Melbourne weather", &required, &optional).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![("location".to_string(), "Melbourne weather".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_greedy_still_accepts_quoted_required_value() {
|
||||
let location = required_param("location");
|
||||
let theme = optional_param("theme");
|
||||
let required = vec![&location];
|
||||
let optional = vec![&theme];
|
||||
|
||||
let parsed = parse_recipe_args(
|
||||
r#""Melbourne weather" --theme culture"#,
|
||||
&required,
|
||||
&optional,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
("location".to_string(), "Melbourne weather".to_string()),
|
||||
("theme".to_string(), "culture".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_greedy_handles_flag_before_required_positional() {
|
||||
let location = required_param("location");
|
||||
let theme = optional_param("theme");
|
||||
let required = vec![&location];
|
||||
let optional = vec![&theme];
|
||||
|
||||
let parsed =
|
||||
parse_recipe_args("--theme culture Melbourne weather", &required, &optional).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
("location".to_string(), "Melbourne weather".to_string()),
|
||||
("theme".to_string(), "culture".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_allows_values_containing_equals() {
|
||||
let component = required_param("component");
|
||||
let note = optional_param("note");
|
||||
let required = vec![&component];
|
||||
let optional = vec![¬e];
|
||||
|
||||
let parsed = parse_recipe_args(r#"Button --note "a=b""#, &required, &optional).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
("component".to_string(), "Button".to_string()),
|
||||
("note".to_string(), "a=b".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_errors_when_flag_value_is_another_flag() {
|
||||
let component = required_param("component");
|
||||
let from = required_param("from");
|
||||
let to = optional_param("to");
|
||||
let scope = optional_param("scope");
|
||||
let required = vec![&component, &from];
|
||||
let optional = vec![&to, &scope];
|
||||
|
||||
let err =
|
||||
parse_recipe_args("Button old-lib --to --scope all", &required, &optional).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Missing value for --to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_errors_on_extra_positionals() {
|
||||
let component = required_param("component");
|
||||
let from = required_param("from");
|
||||
let required = vec![&component, &from];
|
||||
|
||||
let err = parse_recipe_args("Button old-lib extra", &required, &[]).unwrap_err();
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("Unexpected positional argument: extra"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_rejects_required_param_passed_as_flag() {
|
||||
let component = required_param("component");
|
||||
let from = required_param("from");
|
||||
let required = vec![&component, &from];
|
||||
|
||||
let err = parse_recipe_args("--component Button old-lib", &required, &[]).unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("'component' is required") && msg.contains("--component"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recipe_args_errors_on_unknown_flag() {
|
||||
let component = required_param("component");
|
||||
let required = vec![&component];
|
||||
|
||||
let err = parse_recipe_args("Button --unknown value", &required, &[]).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Unknown parameter: --unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_mappings_use_recipe_description() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let recipe_path = tmp.path().join("review.yaml");
|
||||
std::fs::write(
|
||||
&recipe_path,
|
||||
"version: 1.0.0\ntitle: Review Recipe\ndescription: Review with a recipe\ninstructions: Review the change\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let commands = commands_from_mappings(vec![SlashCommandMapping {
|
||||
command: "/review".to_string(),
|
||||
recipe_path: recipe_path.to_string_lossy().to_string(),
|
||||
}]);
|
||||
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].name, "review");
|
||||
assert_eq!(commands[0].description, "Review with a recipe");
|
||||
assert_eq!(commands[0].source, SlashCommandSource::Recipe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_mappings_omit_hint_for_no_param_recipe() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let recipe_path = tmp.path().join("status.yaml");
|
||||
std::fs::write(
|
||||
&recipe_path,
|
||||
"version: 1.0.0\ntitle: Status\ndescription: Check status\ninstructions: Check status\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let commands = commands_from_mappings(vec![SlashCommandMapping {
|
||||
command: "status".to_string(),
|
||||
recipe_path: recipe_path.to_string_lossy().to_string(),
|
||||
}]);
|
||||
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].input_hint, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_mappings_render_one_required_param_hint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let recipe_path = tmp.path().join("review.yaml");
|
||||
std::fs::write(
|
||||
&recipe_path,
|
||||
"version: 1.0.0\ntitle: Review\ndescription: Review target\ninstructions: \"Review {{ target }}\"\nparameters:\n - key: target\n input_type: string\n requirement: required\n description: Target\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let commands = commands_from_mappings(vec![SlashCommandMapping {
|
||||
command: "review".to_string(),
|
||||
recipe_path: recipe_path.to_string_lossy().to_string(),
|
||||
}]);
|
||||
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].input_hint.as_deref(), Some("<target>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_mappings_do_not_special_case_args_hint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let recipe_path = tmp.path().join("deploy.yaml");
|
||||
std::fs::write(
|
||||
&recipe_path,
|
||||
"version: 1.0.0\ntitle: Deploy\ndescription: Deploy\ninstructions: \"Deploy {{ component }} with {{ args }}\"\nparameters:\n - key: component\n input_type: string\n requirement: required\n description: Component\n - key: args\n input_type: string\n requirement: optional\n default: default args\n description: Args\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let commands = commands_from_mappings(vec![SlashCommandMapping {
|
||||
command: "deploy".to_string(),
|
||||
recipe_path: recipe_path.to_string_lossy().to_string(),
|
||||
}]);
|
||||
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(
|
||||
commands[0].input_hint.as_deref(),
|
||||
Some("<component> [--args <args>]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_mappings_skip_missing_and_invalid_recipes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let invalid_recipe_path = tmp.path().join("invalid.yaml");
|
||||
std::fs::write(&invalid_recipe_path, "not: a recipe").unwrap();
|
||||
|
||||
let commands = commands_from_mappings(vec![
|
||||
SlashCommandMapping {
|
||||
command: "missing".to_string(),
|
||||
recipe_path: tmp
|
||||
.path()
|
||||
.join("missing.yaml")
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
},
|
||||
SlashCommandMapping {
|
||||
command: "invalid".to_string(),
|
||||
recipe_path: invalid_recipe_path.to_string_lossy().to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_mappings_render_multi_param_hint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let recipe_path = tmp.path().join("deploy.yaml");
|
||||
std::fs::write(
|
||||
&recipe_path,
|
||||
"version: 1.0.0\ntitle: Deploy\ndescription: Deploy a service\ninstructions: \"Deploy {{ component }} from {{ from }} to {{ to }} scope {{ scope }}\"\nparameters:\n - key: component\n input_type: string\n requirement: required\n description: Component\n - key: from\n input_type: string\n requirement: required\n description: From\n - key: to\n input_type: string\n requirement: optional\n default: prod\n description: To\n - key: scope\n input_type: string\n requirement: optional\n default: all\n description: Scope\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let commands = commands_from_mappings(vec![SlashCommandMapping {
|
||||
command: "deploy".to_string(),
|
||||
recipe_path: recipe_path.to_string_lossy().to_string(),
|
||||
}]);
|
||||
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(
|
||||
commands[0].input_hint.as_deref(),
|
||||
Some("<component> <from> [--to <to>] [--scope <scope>]")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use std::path::Path;
|
||||
|
||||
use goose_sdk::custom_requests::{SourceEntry, SourceType};
|
||||
|
||||
use super::types::{SlashCommandEntry, SlashCommandSource};
|
||||
use super::util::normalize_command_name;
|
||||
|
||||
pub fn list_commands(working_dir: Option<&Path>) -> Vec<SlashCommandEntry> {
|
||||
commands_from_sources(crate::skills::list_installed_skills(working_dir))
|
||||
}
|
||||
|
||||
pub fn format_installed_skills(working_dir: Option<&Path>) -> String {
|
||||
let sources = crate::skills::list_installed_skills(working_dir);
|
||||
let skills: Vec<_> = sources
|
||||
.iter()
|
||||
.filter(|s| matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill))
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
if skills.is_empty() {
|
||||
output.push_str("No skills installed.\n\n");
|
||||
output.push_str("Skills are loaded from SKILL.md files in:\n");
|
||||
output.push_str(" - ~/.agents/skills/ (global)\n");
|
||||
output.push_str(" - ~/.agents/plugins/*/skills/ (installed plugins)\n");
|
||||
output.push_str(" - .agents/skills/ (in current project)\n");
|
||||
} else {
|
||||
output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len()));
|
||||
for skill in &skills {
|
||||
let kind_label = if skill.source_type == SourceType::BuiltinSkill {
|
||||
" *(builtin)*"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
output.push_str(&format!(
|
||||
"- **{}**{}: {}\n",
|
||||
skill.name, kind_label, skill.description
|
||||
));
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn resolve_command(
|
||||
command: &str,
|
||||
params_str: &str,
|
||||
working_dir: Option<&Path>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(skill) = crate::skills::list_installed_skills(working_dir)
|
||||
.into_iter()
|
||||
.find(|skill| skill.name.eq_ignore_ascii_case(command))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let args = (!params_str.is_empty()).then_some(params_str);
|
||||
let prompt = crate::skills::loaded_skill_context_with_args(&skill, args)
|
||||
.map_err(|e| format!("Skill /{}: {}", command, e))?;
|
||||
|
||||
Ok(Some(prompt))
|
||||
}
|
||||
|
||||
pub(super) fn commands_from_sources(sources: Vec<SourceEntry>) -> Vec<SlashCommandEntry> {
|
||||
sources
|
||||
.into_iter()
|
||||
.filter_map(|source| {
|
||||
let name = normalize_command_name(&source.name);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let input_hint = crate::skills::skill_argument_hint(&source);
|
||||
|
||||
Some(SlashCommandEntry {
|
||||
name,
|
||||
description: source.description,
|
||||
source: SlashCommandSource::Skill,
|
||||
input_hint,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use goose_sdk::custom_requests::SourceType;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn commands_from_sources_marks_entries_as_skill() {
|
||||
let commands = commands_from_sources(vec![
|
||||
source_entry(SourceType::Skill, "review", "Review code"),
|
||||
source_entry(SourceType::Skill, "summarize", "Summarize text"),
|
||||
]);
|
||||
|
||||
let names: Vec<_> = commands.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["review", "summarize"]);
|
||||
assert!(commands
|
||||
.iter()
|
||||
.all(|c| c.source == SlashCommandSource::Skill));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_sources_normalizes_names() {
|
||||
let commands = commands_from_sources(vec![source_entry(
|
||||
SourceType::Skill,
|
||||
"/Code-Review",
|
||||
"Review",
|
||||
)]);
|
||||
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].name, "code-review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_sources_skips_empty_names() {
|
||||
let commands =
|
||||
commands_from_sources(vec![source_entry(SourceType::Skill, "/", "Empty name")]);
|
||||
|
||||
assert!(commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_commands_loads_project_skill_from_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp
|
||||
.path()
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("code-review");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: code-review\ndescription: Review changed code\nmetadata:\n argument-hint: \"[task]\"\n arguments:\n - task\n---\nReview the diff.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let commands = list_commands(Some(tmp.path()));
|
||||
let command = commands
|
||||
.iter()
|
||||
.find(|command| command.name == "code-review")
|
||||
.expect("project skill should be listed");
|
||||
|
||||
assert_eq!(command.description, "Review changed code");
|
||||
assert_eq!(command.source, SlashCommandSource::Skill);
|
||||
assert_eq!(command.input_hint.as_deref(), Some("[task]"));
|
||||
}
|
||||
|
||||
fn source_entry(source_type: SourceType, name: &str, description: &str) -> SourceEntry {
|
||||
SourceEntry {
|
||||
source_type,
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
content: String::new(),
|
||||
path: String::new(),
|
||||
global: false,
|
||||
writable: false,
|
||||
supporting_files: Vec::new(),
|
||||
properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use super::types::{SlashCommandEntry, SlashCommandSource};
|
||||
use super::util::normalize_command_name;
|
||||
|
||||
pub fn list_builtin_commands() -> Vec<SlashCommandEntry> {
|
||||
crate::agents::execute_commands::list_commands()
|
||||
.iter()
|
||||
.map(|command| SlashCommandEntry {
|
||||
name: command.name.to_string(),
|
||||
description: command.description.to_string(),
|
||||
source: SlashCommandSource::Builtin,
|
||||
input_hint: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_acp_commands(working_dir: Option<&Path>) -> Vec<SlashCommandEntry> {
|
||||
merge_command_sources(
|
||||
list_builtin_commands(),
|
||||
super::recipe_slash_command::commands_from_mappings(
|
||||
super::recipe_slash_command::list_commands(),
|
||||
),
|
||||
super::skill_slash_command::list_commands(working_dir),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn merge_command_sources(
|
||||
builtins: Vec<SlashCommandEntry>,
|
||||
recipes: Vec<SlashCommandEntry>,
|
||||
skills: Vec<SlashCommandEntry>,
|
||||
) -> Vec<SlashCommandEntry> {
|
||||
let mut commands = builtins;
|
||||
let mut reserved_names: HashSet<String> = commands
|
||||
.iter()
|
||||
.map(|command| normalize_command_name(&command.name))
|
||||
.collect();
|
||||
|
||||
for command in recipes {
|
||||
if reserved_names.insert(normalize_command_name(&command.name)) {
|
||||
commands.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
commands.extend(
|
||||
skills
|
||||
.into_iter()
|
||||
.filter(|command| !reserved_names.contains(&normalize_command_name(&command.name))),
|
||||
);
|
||||
commands
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lists_acp_safe_builtin_commands() {
|
||||
let commands = list_builtin_commands();
|
||||
let names: Vec<_> = commands
|
||||
.iter()
|
||||
.map(|command| command.name.as_str())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["prompts", "prompt", "compact", "clear", "skills", "doctor", "goal", "grind"]
|
||||
);
|
||||
assert!(commands
|
||||
.iter()
|
||||
.all(|command| command.source == SlashCommandSource::Builtin));
|
||||
}
|
||||
|
||||
fn entry(name: &str, source: SlashCommandSource) -> SlashCommandEntry {
|
||||
SlashCommandEntry {
|
||||
name: name.to_string(),
|
||||
description: format!("{name} description"),
|
||||
source,
|
||||
input_hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_recipe_wins_over_skill_on_name_collision() {
|
||||
let merged = merge_command_sources(
|
||||
vec![entry("compact", SlashCommandSource::Builtin)],
|
||||
vec![entry("review", SlashCommandSource::Recipe)],
|
||||
vec![entry("review", SlashCommandSource::Skill)],
|
||||
);
|
||||
|
||||
let review: Vec<_> = merged.iter().filter(|c| c.name == "review").collect();
|
||||
assert_eq!(review.len(), 1);
|
||||
assert_eq!(review[0].source, SlashCommandSource::Recipe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_builtin_wins_over_recipe_and_skill() {
|
||||
let merged = merge_command_sources(
|
||||
vec![entry("compact", SlashCommandSource::Builtin)],
|
||||
vec![entry("compact", SlashCommandSource::Recipe)],
|
||||
vec![entry("compact", SlashCommandSource::Skill)],
|
||||
);
|
||||
|
||||
let compact: Vec<_> = merged.iter().filter(|c| c.name == "compact").collect();
|
||||
assert_eq!(compact.len(), 1);
|
||||
assert_eq!(compact[0].source, SlashCommandSource::Builtin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_dedupes_by_normalized_name() {
|
||||
let merged = merge_command_sources(
|
||||
vec![entry("Compact", SlashCommandSource::Builtin)],
|
||||
vec![entry("/compact", SlashCommandSource::Recipe)],
|
||||
vec![entry("COMPACT", SlashCommandSource::Skill)],
|
||||
);
|
||||
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].source, SlashCommandSource::Builtin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SlashCommandSource {
|
||||
Builtin,
|
||||
Recipe,
|
||||
Skill,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SlashCommandEntry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub source: SlashCommandSource,
|
||||
pub input_hint: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub fn normalize_command_name(name: &str) -> String {
|
||||
name.trim_start_matches('/').to_lowercase()
|
||||
}
|
||||
@@ -47,6 +47,38 @@ pub fn is_token_cancelled(cancellation_token: &Option<CancellationToken>) -> boo
|
||||
.is_some_and(|t| t.is_cancelled())
|
||||
}
|
||||
|
||||
pub fn split_command_args(input: &str) -> anyhow::Result<Vec<String>> {
|
||||
let mut parts = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_double_quote = false;
|
||||
let mut in_single_quote = false;
|
||||
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'"' if !in_single_quote => in_double_quote = !in_double_quote,
|
||||
'\'' if !in_double_quote && (in_single_quote || current.is_empty()) => {
|
||||
in_single_quote = !in_single_quote
|
||||
}
|
||||
c if c.is_whitespace() && !in_double_quote && !in_single_quote => {
|
||||
if !current.is_empty() {
|
||||
parts.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
_ => current.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
if in_double_quote || in_single_quote {
|
||||
return Err(anyhow::anyhow!("Unmatched quote in command"));
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
parts.push(current);
|
||||
}
|
||||
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -125,4 +157,46 @@ mod tests {
|
||||
assert_eq!(safe_truncate(mixed, 20), mixed);
|
||||
assert_eq!(safe_truncate(mixed, 8), "Hello...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_command_args_windows_paths() {
|
||||
assert_eq!(
|
||||
split_command_args(r"C:\tools\mcp.exe --arg value").unwrap(),
|
||||
vec![r"C:\tools\mcp.exe", "--arg", "value"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_command_args(r#""C:\Program Files\server\mcp.exe" --arg"#).unwrap(),
|
||||
vec![r"C:\Program Files\server\mcp.exe", "--arg"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_command_args(r#""C:\path\" next"#).unwrap(),
|
||||
vec![r"C:\path\", "next"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_command_args_quotes() {
|
||||
assert_eq!(
|
||||
split_command_args(r#""Button Group" old-lib new-lib"#).unwrap(),
|
||||
vec!["Button Group", "old-lib", "new-lib"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_command_args(r#"'my name "abc"' second third"#).unwrap(),
|
||||
vec![r#"my name "abc""#, "second", "third"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_command_args_apostrophes_in_unquoted_words() {
|
||||
assert_eq!(
|
||||
split_command_args("O'Reilly wrote don't split").unwrap(),
|
||||
vec!["O'Reilly", "wrote", "don't", "split"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_command_args_unmatched_quote() {
|
||||
assert!(split_command_args(r#""unmatched"#).is_err());
|
||||
assert!(split_command_args("'unmatched").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user