feat(cli): add mcp prompt support via slash commands (#1323)
This commit is contained in:
@@ -47,6 +47,7 @@ chrono = "0.4"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] }
|
||||
tracing-appender = "0.2"
|
||||
once_cell = "1.20.2"
|
||||
shlex = "1.3.0"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { version = "0.3", features = ["wincred"] }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use rustyline::Editor;
|
||||
use shlex;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InputResult {
|
||||
@@ -9,6 +11,15 @@ pub enum InputResult {
|
||||
AddBuiltin(String),
|
||||
ToggleTheme,
|
||||
Retry,
|
||||
ListPrompts,
|
||||
PromptCommand(PromptCommandOptions),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PromptCommandOptions {
|
||||
pub name: String,
|
||||
pub info: bool,
|
||||
pub arguments: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn get_input(
|
||||
@@ -59,12 +70,67 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
||||
Some(InputResult::Retry)
|
||||
}
|
||||
"/t" => Some(InputResult::ToggleTheme),
|
||||
"/prompts" => Some(InputResult::ListPrompts),
|
||||
s if s.starts_with("/prompt") => {
|
||||
if s == "/prompt" {
|
||||
// No arguments case
|
||||
Some(InputResult::PromptCommand(PromptCommandOptions {
|
||||
name: String::new(), // Empty name will trigger the error message in the rendering
|
||||
info: false,
|
||||
arguments: HashMap::new(),
|
||||
}))
|
||||
} else if let Some(stripped) = s.strip_prefix("/prompt ") {
|
||||
// Has arguments case
|
||||
parse_prompt_command(stripped)
|
||||
} else {
|
||||
// Handle invalid cases like "/promptxyz"
|
||||
None
|
||||
}
|
||||
}
|
||||
s if s.starts_with("/extension ") => Some(InputResult::AddExtension(s[11..].to_string())),
|
||||
s if s.starts_with("/builtin ") => Some(InputResult::AddBuiltin(s[9..].to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_prompt_command(args: &str) -> Option<InputResult> {
|
||||
let parts: Vec<String> = shlex::split(args).unwrap_or_default();
|
||||
|
||||
// set name to empty and error out in the rendering
|
||||
let mut options = PromptCommandOptions {
|
||||
name: parts.first().cloned().unwrap_or_default(),
|
||||
info: false,
|
||||
arguments: HashMap::new(),
|
||||
};
|
||||
|
||||
// handle info at any point in the command
|
||||
if parts.iter().any(|part| part == "--info") {
|
||||
options.info = true;
|
||||
}
|
||||
|
||||
// Parse remaining arguments
|
||||
let mut i = 1;
|
||||
|
||||
while i < parts.len() {
|
||||
let part = &parts[i];
|
||||
|
||||
// Skip flag arguments
|
||||
if part == "--info" {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process key=value pairs - removed redundant contains check
|
||||
if let Some((key, value)) = part.split_once('=') {
|
||||
options.arguments.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Some(InputResult::PromptCommand(options))
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"Available commands:
|
||||
@@ -72,6 +138,8 @@ fn print_help() {
|
||||
/t - Toggle Light/Dark/Ansi theme
|
||||
/extension <command> - Add a stdio extension (format: ENV1=val1 command args...)
|
||||
/builtin <names> - Add builtin extensions by name (comma-separated)
|
||||
/prompts - List all available prompts by name
|
||||
/prompt <name> [--info] [key=value...] - Get prompt info or execute a prompt
|
||||
/? or /help - Display this help message
|
||||
|
||||
Navigation:
|
||||
@@ -131,6 +199,33 @@ mod tests {
|
||||
assert!(handle_slash_command("/unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_command() {
|
||||
// Test basic prompt info command
|
||||
if let Some(InputResult::PromptCommand(opts)) =
|
||||
handle_slash_command("/prompt test-prompt --info")
|
||||
{
|
||||
assert_eq!(opts.name, "test-prompt");
|
||||
assert!(opts.info);
|
||||
assert!(opts.arguments.is_empty());
|
||||
} else {
|
||||
panic!("Expected PromptCommand");
|
||||
}
|
||||
|
||||
// Test prompt with arguments
|
||||
if let Some(InputResult::PromptCommand(opts)) =
|
||||
handle_slash_command("/prompt test-prompt arg1=val1 arg2=val2")
|
||||
{
|
||||
assert_eq!(opts.name, "test-prompt");
|
||||
assert!(!opts.info);
|
||||
assert_eq!(opts.arguments.len(), 2);
|
||||
assert_eq!(opts.arguments.get("arg1"), Some(&"val1".to_string()));
|
||||
assert_eq!(opts.arguments.get("arg2"), Some(&"val2".to_string()));
|
||||
} else {
|
||||
panic!("Expected PromptCommand");
|
||||
}
|
||||
}
|
||||
|
||||
// Test whitespace handling
|
||||
#[test]
|
||||
fn test_whitespace_handling() {
|
||||
@@ -149,4 +244,73 @@ mod tests {
|
||||
panic!("Expected AddBuiltin");
|
||||
}
|
||||
}
|
||||
|
||||
// Test prompt with no arguments
|
||||
#[test]
|
||||
fn test_prompt_no_args() {
|
||||
// Test just "/prompt" with no arguments
|
||||
if let Some(InputResult::PromptCommand(opts)) = handle_slash_command("/prompt") {
|
||||
assert_eq!(opts.name, "");
|
||||
assert!(!opts.info);
|
||||
assert!(opts.arguments.is_empty());
|
||||
} else {
|
||||
panic!("Expected PromptCommand");
|
||||
}
|
||||
|
||||
// Test invalid prompt command
|
||||
assert!(handle_slash_command("/promptxyz").is_none());
|
||||
}
|
||||
|
||||
// Test quoted arguments
|
||||
#[test]
|
||||
fn test_quoted_arguments() {
|
||||
// Test prompt with quoted arguments
|
||||
if let Some(InputResult::PromptCommand(opts)) = handle_slash_command(
|
||||
r#"/prompt test-prompt arg1="value with spaces" arg2="another value""#,
|
||||
) {
|
||||
assert_eq!(opts.name, "test-prompt");
|
||||
assert_eq!(opts.arguments.len(), 2);
|
||||
assert_eq!(
|
||||
opts.arguments.get("arg1"),
|
||||
Some(&"value with spaces".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
opts.arguments.get("arg2"),
|
||||
Some(&"another value".to_string())
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PromptCommand");
|
||||
}
|
||||
|
||||
// Test prompt with mixed quoted and unquoted arguments
|
||||
if let Some(InputResult::PromptCommand(opts)) = handle_slash_command(
|
||||
r#"/prompt test-prompt simple=value quoted="value with \"nested\" quotes""#,
|
||||
) {
|
||||
assert_eq!(opts.name, "test-prompt");
|
||||
assert_eq!(opts.arguments.len(), 2);
|
||||
assert_eq!(opts.arguments.get("simple"), Some(&"value".to_string()));
|
||||
assert_eq!(
|
||||
opts.arguments.get("quoted"),
|
||||
Some(&r#"value with "nested" quotes"#.to_string())
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PromptCommand");
|
||||
}
|
||||
}
|
||||
|
||||
// Test invalid arguments
|
||||
#[test]
|
||||
fn test_invalid_arguments() {
|
||||
// Test prompt with invalid arguments
|
||||
if let Some(InputResult::PromptCommand(opts)) =
|
||||
handle_slash_command(r#"/prompt test-prompt valid=value invalid_arg another_invalid"#)
|
||||
{
|
||||
assert_eq!(opts.name, "test-prompt");
|
||||
assert_eq!(opts.arguments.len(), 1);
|
||||
assert_eq!(opts.arguments.get("valid"), Some(&"value".to_string()));
|
||||
// Invalid arguments are ignored but logged
|
||||
} else {
|
||||
panic!("Expected PromptCommand");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ use goose::agents::extension::{Envs, ExtensionConfig};
|
||||
use goose::agents::Agent;
|
||||
use goose::message::{Message, MessageContent};
|
||||
use mcp_core::handler::ToolError;
|
||||
use mcp_core::prompt::PromptMessage;
|
||||
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio;
|
||||
|
||||
@@ -104,6 +108,40 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_prompts(&mut self) -> HashMap<String, Vec<String>> {
|
||||
let prompts = self.agent.list_extension_prompts().await;
|
||||
prompts
|
||||
.into_iter()
|
||||
.map(|(extension, prompt_list)| {
|
||||
let names = prompt_list.into_iter().map(|p| p.name).collect();
|
||||
(extension, names)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_prompt_info(&mut self, name: &str) -> Result<Option<output::PromptInfo>> {
|
||||
let prompts = self.agent.list_extension_prompts().await;
|
||||
|
||||
// Find which extension has this prompt
|
||||
for (extension, prompt_list) in prompts {
|
||||
if let Some(prompt) = prompt_list.iter().find(|p| p.name == name) {
|
||||
return Ok(Some(output::PromptInfo {
|
||||
name: prompt.name.clone(),
|
||||
description: prompt.description.clone(),
|
||||
arguments: prompt.arguments.clone(),
|
||||
extension: Some(extension),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn get_prompt(&mut self, name: &str, arguments: Value) -> Result<Vec<PromptMessage>> {
|
||||
let result = self.agent.get_prompt(name, arguments).await?;
|
||||
Ok(result.messages)
|
||||
}
|
||||
|
||||
/// Process a single message and get the response
|
||||
async fn process_message(&mut self, message: String) -> Result<()> {
|
||||
self.messages.push(Message::user().with_text(&message));
|
||||
@@ -179,6 +217,68 @@ impl Session {
|
||||
continue;
|
||||
}
|
||||
input::InputResult::Retry => continue,
|
||||
input::InputResult::ListPrompts => {
|
||||
output::render_prompts(&self.list_prompts().await)
|
||||
}
|
||||
input::InputResult::PromptCommand(opts) => {
|
||||
// name is required
|
||||
if opts.name.is_empty() {
|
||||
output::render_error("Prompt name argument is required");
|
||||
continue;
|
||||
}
|
||||
|
||||
if opts.info {
|
||||
match self.get_prompt_info(&opts.name).await? {
|
||||
Some(info) => output::render_prompt_info(&info),
|
||||
None => {
|
||||
output::render_error(&format!("Prompt '{}' not found", opts.name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Convert the arguments HashMap to a Value
|
||||
let arguments = serde_json::to_value(opts.arguments)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to serialize arguments: {}", e))?;
|
||||
|
||||
match self.get_prompt(&opts.name, arguments).await {
|
||||
Ok(messages) => {
|
||||
let start_len = self.messages.len();
|
||||
let mut valid = true;
|
||||
for (i, prompt_message) in messages.into_iter().enumerate() {
|
||||
let msg = Message::from(prompt_message);
|
||||
// ensure we get a User - Assistant - User type pattern
|
||||
let expected_role = if i % 2 == 0 {
|
||||
mcp_core::Role::User
|
||||
} else {
|
||||
mcp_core::Role::Assistant
|
||||
};
|
||||
|
||||
if msg.role != expected_role {
|
||||
output::render_error(&format!(
|
||||
"Expected {:?} message at position {}, but found {:?}",
|
||||
expected_role, i, msg.role
|
||||
));
|
||||
valid = false;
|
||||
// get rid of everything we added to messages
|
||||
self.messages.truncate(start_len);
|
||||
break;
|
||||
}
|
||||
|
||||
if msg.role == mcp_core::Role::User {
|
||||
output::render_message(&msg);
|
||||
}
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
if valid {
|
||||
output::show_thinking();
|
||||
self.process_agent_response(true).await?;
|
||||
output::hide_thinking();
|
||||
}
|
||||
}
|
||||
Err(e) => output::render_error(&e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ use bat::WrappingMode;
|
||||
use console::style;
|
||||
use goose::config::Config;
|
||||
use goose::message::{Message, MessageContent, ToolRequest, ToolResponse};
|
||||
use mcp_core::prompt::PromptArgument;
|
||||
use mcp_core::tool::ToolCall;
|
||||
use serde_json::Value;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
// Re-export theme for use in main
|
||||
@@ -73,6 +75,14 @@ impl ThinkingIndicator {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PromptInfo {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub arguments: Option<Vec<PromptArgument>>,
|
||||
pub extension: Option<String>,
|
||||
}
|
||||
|
||||
// Global thinking indicator
|
||||
thread_local! {
|
||||
static THINKING: RefCell<ThinkingIndicator> = RefCell::new(ThinkingIndicator::default());
|
||||
@@ -154,6 +164,51 @@ pub fn render_error(message: &str) {
|
||||
println!("\n {} {}\n", style("error:").red().bold(), message);
|
||||
}
|
||||
|
||||
pub fn render_prompts(prompts: &HashMap<String, Vec<String>>) {
|
||||
println!();
|
||||
for (extension, prompts) in prompts {
|
||||
println!(" {}", style(extension).green());
|
||||
for prompt in prompts {
|
||||
println!(" - {}", style(prompt).cyan());
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
pub fn render_prompt_info(info: &PromptInfo) {
|
||||
println!();
|
||||
|
||||
if let Some(ext) = &info.extension {
|
||||
println!(" {}: {}", style("Extension").green(), ext);
|
||||
}
|
||||
|
||||
println!(" Prompt: {}", style(&info.name).cyan().bold());
|
||||
|
||||
if let Some(desc) = &info.description {
|
||||
println!("\n {}", desc);
|
||||
}
|
||||
|
||||
if let Some(args) = &info.arguments {
|
||||
println!("\n Arguments:");
|
||||
for arg in args {
|
||||
let required = arg.required.unwrap_or(false);
|
||||
let req_str = if required {
|
||||
style("(required)").red()
|
||||
} else {
|
||||
style("(optional)").dim()
|
||||
};
|
||||
|
||||
println!(
|
||||
" {} {} {}",
|
||||
style(&arg.name).yellow(),
|
||||
req_str,
|
||||
arg.description.as_deref().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
pub fn render_extension_success(name: &str) {
|
||||
println!();
|
||||
println!(
|
||||
|
||||
Reference in New Issue
Block a user