Files
goose/crates/goose-mcp/src/developer/mod.rs

1499 lines
52 KiB
Rust

mod lang;
mod shell;
use anyhow::Result;
use base64::Engine;
use etcetera::{choose_app_strategy, AppStrategy};
use indoc::formatdoc;
use serde_json::{json, Value};
use std::{
collections::HashMap,
future::Future,
io::Cursor,
path::{Path, PathBuf},
pin::Pin,
};
use tokio::process::Command;
use url::Url;
use include_dir::{include_dir, Dir};
use mcp_core::prompt::{Prompt, PromptArgument, PromptTemplate};
use mcp_core::{
handler::{PromptError, ResourceError, ToolError},
protocol::ServerCapabilities,
resource::Resource,
tool::Tool,
};
use mcp_server::router::CapabilitiesBuilder;
use mcp_server::Router;
use mcp_core::content::Content;
use mcp_core::role::Role;
use self::shell::{
expand_path, format_command_for_platform, get_shell_config, is_absolute_path,
normalize_line_endings,
};
use indoc::indoc;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use xcap::{Monitor, Window};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
// Embeds the prompts directory to the build
static PROMPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/developer/prompts");
/// Loads prompt files from the embedded PROMPTS_DIR and returns a HashMap of prompts.
/// Ensures that each prompt name is unique.
pub fn load_prompt_files() -> HashMap<String, Prompt> {
let mut prompts = HashMap::new();
for entry in PROMPTS_DIR.files() {
let prompt_str = String::from_utf8_lossy(entry.contents()).into_owned();
let template: PromptTemplate = match serde_json::from_str(&prompt_str) {
Ok(t) => t,
Err(e) => {
eprintln!(
"Failed to parse prompt template in {}: {}",
entry.path().display(),
e
);
continue; // Skip invalid prompt file
}
};
let arguments = template
.arguments
.into_iter()
.map(|arg| PromptArgument {
name: arg.name,
description: arg.description,
required: arg.required,
})
.collect::<Vec<PromptArgument>>();
let prompt = Prompt::new(&template.id, Some(&template.template), Some(arguments));
if prompts.contains_key(&prompt.name) {
eprintln!("Duplicate prompt name '{}' found. Skipping.", prompt.name);
continue; // Skip duplicate prompt name
}
prompts.insert(prompt.name.clone(), prompt);
}
prompts
}
pub struct DeveloperRouter {
tools: Vec<Tool>,
prompts: Arc<HashMap<String, Prompt>>,
instructions: String,
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
ignore_patterns: Arc<Gitignore>,
}
impl Default for DeveloperRouter {
fn default() -> Self {
Self::new()
}
}
impl DeveloperRouter {
pub fn new() -> Self {
// TODO consider rust native search tools, we could use
// https://docs.rs/ignore/latest/ignore/
// Get OS-specific shell tool description
let shell_tool_desc = match std::env::consts::OS {
"windows" => indoc! {r#"
Execute a command in the shell.
This will return the output and error concatenated into a single string, as
you would see from running on the command line. There will also be an indication
of if the command succeeded or failed.
Avoid commands that produce a large amount of output, and consider piping those outputs to files.
**Important**: For searching files and code:
Preferred: Use ripgrep (`rg`) when available - it respects .gitignore and is fast:
- To locate a file by name: `rg --files | rg example.py`
- To locate content inside files: `rg 'class Example'`
Alternative Windows commands (if ripgrep is not installed):
- To locate a file by name: `dir /s /b example.py`
- To locate content inside files: `findstr /s /i "class Example" *.py`
Note: Alternative commands may show ignored/hidden files that should be excluded.
"#},
_ => indoc! {r#"
Execute a command in the shell.
This will return the output and error concatenated into a single string, as
you would see from running on the command line. There will also be an indication
of if the command succeeded or failed.
Avoid commands that produce a large amount of output, and consider piping those outputs to files.
If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that
this tool does not run indefinitely.
**Important**: Each shell command runs in its own process. Things like directory changes or
sourcing files do not persist between tool calls. So you may need to repeat them each time by
stringing together commands, e.g. `cd example && ls` or `source env/bin/activate && pip install numpy`
**Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions
may show ignored or hidden files. For example *do not* use `find` or `ls -r`
- List files by name: `rg --files | rg <filename>`
- List files that contain a regex: `rg '<regex>' -l`
"#},
};
let bash_tool = Tool::new(
"shell".to_string(),
shell_tool_desc.to_string(),
json!({
"type": "object",
"required": ["command"],
"properties": {
"command": {"type": "string"}
}
}),
);
let text_editor_tool = Tool::new(
"text_editor".to_string(),
indoc! {r#"
Perform text editing operations on files.
The `command` parameter specifies the operation to perform. Allowed options are:
- `view`: View the content of a file.
- `write`: Create or overwrite a file with the given content
- `str_replace`: Replace a string in a file with a new string.
- `undo_edit`: Undo the last edit made to a file.
To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with
existing files! This is a full overwrite, so you must include everything - not just sections you are modifying.
To use the str_replace command, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one
unique section of the original file, including any whitespace. Make sure to include enough context that the match is not
ambiguous. The entire original string will be replaced with `new_str`.
"#}.to_string(),
json!({
"type": "object",
"required": ["command", "path"],
"properties": {
"path": {
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.",
"type": "string"
},
"command": {
"type": "string",
"enum": ["view", "write", "str_replace", "undo_edit"],
"description": "Allowed options are: `view`, `write`, `str_replace`, undo_edit`."
},
"old_str": {"type": "string"},
"new_str": {"type": "string"},
"file_text": {"type": "string"}
}
}),
);
let list_windows_tool = Tool::new(
"list_windows",
indoc! {r#"
List all available window titles that can be used with screen_capture.
Returns a list of window titles that can be used with the window_title parameter
of the screen_capture tool.
"#},
json!({
"type": "object",
"required": [],
"properties": {}
}),
);
let screen_capture_tool = Tool::new(
"screen_capture",
indoc! {r#"
Capture a screenshot of a specified display or window.
You can capture either:
1. A full display (monitor) using the display parameter
2. A specific window by its title using the window_title parameter
Only one of display or window_title should be specified.
"#},
json!({
"type": "object",
"required": [],
"properties": {
"display": {
"type": "integer",
"default": 0,
"description": "The display number to capture (0 is main display)"
},
"window_title": {
"type": "string",
"default": null,
"description": "Optional: the exact title of the window to capture. use the list_windows tool to find the available windows."
}
}
}),
);
// Get base instructions and working directory
let cwd = std::env::current_dir().expect("should have a current working dir");
let os = std::env::consts::OS;
let base_instructions = match os {
"windows" => formatdoc! {r#"
The developer extension gives you the capabilities to edit code files and run shell commands,
and can be used to solve a wide range of problems.
You can use the shell tool to run Windows commands (PowerShell or CMD).
When using paths, you can use either backslashes or forward slashes.
Use the shell tool as needed to locate files or interact with the project.
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
prompted to, but you can mention they are available if they are relevant.
operating system: {os}
current directory: {cwd}
"#,
os=os,
cwd=cwd.to_string_lossy(),
},
_ => formatdoc! {r#"
The developer extension gives you the capabilities to edit code files and run shell commands,
and can be used to solve a wide range of problems.
You can use the shell tool to run any command that would work on the relevant operating system.
Use the shell tool as needed to locate files or interact with the project.
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
prompted to, but you can mention they are available if they are relevant.
operating system: {os}
current directory: {cwd}
"#,
os=os,
cwd=cwd.to_string_lossy(),
},
};
// choose_app_strategy().config_dir()
// - macOS/Linux: ~/.config/goose/
// - Windows: ~\AppData\Roaming\Block\goose\config\
// keep previous behavior of expanding ~/.config in case this fails
let global_hints_path = choose_app_strategy(crate::APP_STRATEGY.clone())
.map(|strategy| strategy.in_config_dir(".goosehints"))
.unwrap_or_else(|_| {
PathBuf::from(shellexpand::tilde("~/.config/goose/.goosehints").to_string())
});
// Create the directory if it doesn't exist
let _ = std::fs::create_dir_all(global_hints_path.parent().unwrap());
// Check for local hints in current directory
let local_hints_path = cwd.join(".goosehints");
// Read global hints if they exist
let mut hints = String::new();
if global_hints_path.is_file() {
if let Ok(global_hints) = std::fs::read_to_string(&global_hints_path) {
hints.push_str("\n### Global Hints\nThe developer extension includes some global hints that apply to all projects & directories.\n");
hints.push_str(&global_hints);
}
}
// Read local hints if they exist
if local_hints_path.is_file() {
if let Ok(local_hints) = std::fs::read_to_string(&local_hints_path) {
if !hints.is_empty() {
hints.push_str("\n\n");
}
hints.push_str("### Project Hints\nThe developer extension includes some hints for working on the project in this directory.\n");
hints.push_str(&local_hints);
}
}
// Return base instructions directly when no hints are found
let instructions = if hints.is_empty() {
base_instructions
} else {
format!("{base_instructions}\n{hints}")
};
let mut builder = GitignoreBuilder::new(cwd.clone());
let mut has_ignore_file = false;
// Initialize ignore patterns
// - macOS/Linux: ~/.config/goose/
// - Windows: ~\AppData\Roaming\Block\goose\config\
let global_ignore_path = choose_app_strategy(crate::APP_STRATEGY.clone())
.map(|strategy| strategy.in_config_dir(".gooseignore"))
.unwrap_or_else(|_| {
PathBuf::from(shellexpand::tilde("~/.config/goose/.gooseignore").to_string())
});
// Create the directory if it doesn't exist
let _ = std::fs::create_dir_all(global_ignore_path.parent().unwrap());
// Read global ignores if they exist
if global_ignore_path.is_file() {
let _ = builder.add(global_ignore_path);
has_ignore_file = true;
}
// Check for local ignores in current directory
let local_ignore_path = cwd.join(".gooseignore");
// Read local ignores if they exist
if local_ignore_path.is_file() {
let _ = builder.add(local_ignore_path);
has_ignore_file = true;
}
// Only use default patterns if no .gooseignore files were found
// If the file is empty, we will not ignore any file
if !has_ignore_file {
// Add some sensible defaults
let _ = builder.add_line(None, "**/.env");
let _ = builder.add_line(None, "**/.env.*");
let _ = builder.add_line(None, "**/secrets.*");
}
let ignore_patterns = builder.build().expect("Failed to build ignore patterns");
Self {
tools: vec![
bash_tool,
text_editor_tool,
list_windows_tool,
screen_capture_tool,
],
prompts: Arc::new(load_prompt_files()),
instructions,
file_history: Arc::new(Mutex::new(HashMap::new())),
ignore_patterns: Arc::new(ignore_patterns),
}
}
// Helper method to check if a path should be ignored
fn is_ignored(&self, path: &Path) -> bool {
self.ignore_patterns.matched(path, false).is_ignore()
}
// Helper method to resolve a path relative to cwd with platform-specific handling
fn resolve_path(&self, path_str: &str) -> Result<PathBuf, ToolError> {
let cwd = std::env::current_dir().expect("should have a current working dir");
let expanded = expand_path(path_str);
let path = Path::new(&expanded);
let suggestion = cwd.join(path);
match is_absolute_path(&expanded) {
true => Ok(path.to_path_buf()),
false => Err(ToolError::InvalidParameters(format!(
"The path {} is not an absolute path, did you possibly mean {}?",
path_str,
suggestion.to_string_lossy(),
))),
}
}
// Shell command execution with platform-specific handling
async fn bash(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let command =
params
.get("command")
.and_then(|v| v.as_str())
.ok_or(ToolError::InvalidParameters(
"The command string is required".to_string(),
))?;
// Check if command might access ignored files and return early if it does
let cmd_parts: Vec<&str> = command.split_whitespace().collect();
for arg in &cmd_parts[1..] {
// Skip command flags
if arg.starts_with('-') {
continue;
}
// Skip invalid paths
let path = Path::new(arg);
if !path.exists() {
continue;
}
if self.is_ignored(path) {
return Err(ToolError::ExecutionError(format!(
"The command attempts to access '{}' which is restricted by .gooseignore",
arg
)));
}
}
// Get platform-specific shell configuration
let shell_config = get_shell_config();
let cmd_with_redirect = format_command_for_platform(command);
// Execute the command using platform-specific shell
let child = Command::new(&shell_config.executable)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.kill_on_drop(true)
.arg(&shell_config.arg)
.arg(cmd_with_redirect)
.spawn()
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Wait for the command to complete and get output
let output = child
.wait_with_output()
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let output_str = String::from_utf8_lossy(&output.stdout);
// Check the character count of the output
const MAX_CHAR_COUNT: usize = 400_000; // 409600 chars = 400KB
let char_count = output_str.chars().count();
if char_count > MAX_CHAR_COUNT {
return Err(ToolError::ExecutionError(format!(
"Shell output from command '{}' has too many characters ({}). Maximum character count is {}.",
command,
char_count,
MAX_CHAR_COUNT
)));
}
Ok(vec![
Content::text(output_str.clone()).with_audience(vec![Role::Assistant]),
Content::text(output_str)
.with_audience(vec![Role::User])
.with_priority(0.0),
])
}
async fn text_editor(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let command = params
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("Missing 'command' parameter".to_string())
})?;
let path_str = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("Missing 'path' parameter".into()))?;
let path = self.resolve_path(path_str)?;
// Check if file is ignored before proceeding with any text editor operation
if self.is_ignored(&path) {
return Err(ToolError::ExecutionError(format!(
"Access to '{}' is restricted by .gooseignore",
path.display()
)));
}
match command {
"view" => self.text_editor_view(&path).await,
"write" => {
let file_text = params
.get("file_text")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("Missing 'file_text' parameter".into())
})?;
self.text_editor_write(&path, file_text).await
}
"str_replace" => {
let old_str = params
.get("old_str")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("Missing 'old_str' parameter".into())
})?;
let new_str = params
.get("new_str")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("Missing 'new_str' parameter".into())
})?;
self.text_editor_replace(&path, old_str, new_str).await
}
"undo_edit" => self.text_editor_undo(&path).await,
_ => Err(ToolError::InvalidParameters(format!(
"Unknown command '{}'",
command
))),
}
}
async fn text_editor_view(&self, path: &PathBuf) -> Result<Vec<Content>, ToolError> {
if path.is_file() {
// Check file size first (400KB limit)
const MAX_FILE_SIZE: u64 = 400 * 1024; // 400KB in bytes
const MAX_CHAR_COUNT: usize = 400_000; // 409600 chars = 400KB
let file_size = std::fs::metadata(path)
.map_err(|e| {
ToolError::ExecutionError(format!("Failed to get file metadata: {}", e))
})?
.len();
if file_size > MAX_FILE_SIZE {
return Err(ToolError::ExecutionError(format!(
"File '{}' is too large ({:.2}KB). Maximum size is 400KB to prevent memory issues.",
path.display(),
file_size as f64 / 1024.0
)));
}
let uri = Url::from_file_path(path)
.map_err(|_| ToolError::ExecutionError("Invalid file path".into()))?
.to_string();
let content = std::fs::read_to_string(path)
.map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?;
let char_count = content.chars().count();
if char_count > MAX_CHAR_COUNT {
return Err(ToolError::ExecutionError(format!(
"File '{}' has too many characters ({}). Maximum character count is {}.",
path.display(),
char_count,
MAX_CHAR_COUNT
)));
}
let language = lang::get_language_identifier(path);
let formatted = formatdoc! {"
### {path}
```{language}
{content}
```
",
path=path.display(),
language=language,
content=content,
};
// The LLM gets just a quick update as we expect the file to view in the status
// but we send a low priority message for the human
Ok(vec![
Content::embedded_text(uri, content).with_audience(vec![Role::Assistant]),
Content::text(formatted)
.with_audience(vec![Role::User])
.with_priority(0.0),
])
} else {
Err(ToolError::ExecutionError(format!(
"The path '{}' does not exist or is not a file.",
path.display()
)))
}
}
async fn text_editor_write(
&self,
path: &PathBuf,
file_text: &str,
) -> Result<Vec<Content>, ToolError> {
// Normalize line endings based on platform
let normalized_text = normalize_line_endings(file_text);
// Write to the file
std::fs::write(path, normalized_text)
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
// Try to detect the language from the file extension
let language = lang::get_language_identifier(path);
// The assistant output does not show the file again because the content is already in the tool request
// but we do show it to the user here
Ok(vec![
Content::text(format!("Successfully wrote to {}", path.display()))
.with_audience(vec![Role::Assistant]),
Content::text(formatdoc! {r#"
### {path}
```{language}
{content}
```
"#,
path=path.display(),
language=language,
content=file_text,
})
.with_audience(vec![Role::User])
.with_priority(0.2),
])
}
async fn text_editor_replace(
&self,
path: &PathBuf,
old_str: &str,
new_str: &str,
) -> Result<Vec<Content>, ToolError> {
// Check if file exists and is active
if !path.exists() {
return Err(ToolError::InvalidParameters(format!(
"File '{}' does not exist, you can write a new file with the `write` command",
path.display()
)));
}
// Read content
let content = std::fs::read_to_string(path)
.map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?;
// Ensure 'old_str' appears exactly once
if content.matches(old_str).count() > 1 {
return Err(ToolError::InvalidParameters(
"'old_str' must appear exactly once in the file, but it appears multiple times"
.into(),
));
}
if content.matches(old_str).count() == 0 {
return Err(ToolError::InvalidParameters(
"'old_str' must appear exactly once in the file, but it does not appear in the file. Make sure the string exactly matches existing file content, including whitespace!".into(),
));
}
// Save history for undo
self.save_file_history(path)?;
// Replace and write back with platform-specific line endings
let new_content = content.replace(old_str, new_str);
let normalized_content = normalize_line_endings(&new_content);
std::fs::write(path, &normalized_content)
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
// Try to detect the language from the file extension
let language = lang::get_language_identifier(path);
// Show a snippet of the changed content with context
const SNIPPET_LINES: usize = 4;
// Count newlines before the replacement to find the line number
let replacement_line = content
.split(old_str)
.next()
.expect("should split on already matched content")
.matches('\n')
.count();
// Calculate start and end lines for the snippet
let start_line = replacement_line.saturating_sub(SNIPPET_LINES);
let end_line = replacement_line + SNIPPET_LINES + new_str.matches('\n').count();
// Get the relevant lines for our snippet
let lines: Vec<&str> = new_content.lines().collect();
let snippet = lines
.iter()
.skip(start_line)
.take(end_line - start_line + 1)
.cloned()
.collect::<Vec<&str>>()
.join("\n");
let output = formatdoc! {r#"
```{language}
{snippet}
```
"#,
language=language,
snippet=snippet
};
let success_message = formatdoc! {r#"
The file {} has been edited, and the section now reads:
{}
Review the changes above for errors. Undo and edit the file again if necessary!
"#,
path.display(),
output
};
Ok(vec![
Content::text(success_message).with_audience(vec![Role::Assistant]),
Content::text(output)
.with_audience(vec![Role::User])
.with_priority(0.2),
])
}
async fn text_editor_undo(&self, path: &PathBuf) -> Result<Vec<Content>, ToolError> {
let mut history = self.file_history.lock().unwrap();
if let Some(contents) = history.get_mut(path) {
if let Some(previous_content) = contents.pop() {
// Write previous content back to file
std::fs::write(path, previous_content).map_err(|e| {
ToolError::ExecutionError(format!("Failed to write file: {}", e))
})?;
Ok(vec![Content::text("Undid the last edit")])
} else {
Err(ToolError::InvalidParameters(
"No edit history available to undo".into(),
))
}
} else {
Err(ToolError::InvalidParameters(
"No edit history available to undo".into(),
))
}
}
fn save_file_history(&self, path: &PathBuf) -> Result<(), ToolError> {
let mut history = self.file_history.lock().unwrap();
let content = if path.exists() {
std::fs::read_to_string(path)
.map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?
} else {
String::new()
};
history.entry(path.clone()).or_default().push(content);
Ok(())
}
async fn list_windows(&self, _params: Value) -> Result<Vec<Content>, ToolError> {
let windows = Window::all()
.map_err(|_| ToolError::ExecutionError("Failed to list windows".into()))?;
let window_titles: Vec<String> =
windows.into_iter().map(|w| w.title().to_string()).collect();
Ok(vec![
Content::text(format!("Available windows:\n{}", window_titles.join("\n")))
.with_audience(vec![Role::Assistant]),
Content::text(format!("Available windows:\n{}", window_titles.join("\n")))
.with_audience(vec![Role::User])
.with_priority(0.0),
])
}
async fn screen_capture(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let mut image = if let Some(window_title) =
params.get("window_title").and_then(|v| v.as_str())
{
// Try to find and capture the specified window
let windows = Window::all()
.map_err(|_| ToolError::ExecutionError("Failed to list windows".into()))?;
let window = windows
.into_iter()
.find(|w| w.title() == window_title)
.ok_or_else(|| {
ToolError::ExecutionError(format!(
"No window found with title '{}'",
window_title
))
})?;
window.capture_image().map_err(|e| {
ToolError::ExecutionError(format!(
"Failed to capture window '{}': {}",
window_title, e
))
})?
} else {
// Default to display capture if no window title is specified
let display = params.get("display").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let monitors = Monitor::all()
.map_err(|_| ToolError::ExecutionError("Failed to access monitors".into()))?;
let monitor = monitors.get(display).ok_or_else(|| {
ToolError::ExecutionError(format!(
"{} was not an available monitor, {} found.",
display,
monitors.len()
))
})?;
monitor.capture_image().map_err(|e| {
ToolError::ExecutionError(format!("Failed to capture display {}: {}", display, e))
})?
};
// Resize the image to a reasonable width while maintaining aspect ratio
let max_width = 768;
if image.width() > max_width {
let scale = max_width as f32 / image.width() as f32;
let new_height = (image.height() as f32 * scale) as u32;
image = xcap::image::imageops::resize(
&image,
max_width,
new_height,
xcap::image::imageops::FilterType::Lanczos3,
)
};
let mut bytes: Vec<u8> = Vec::new();
image
.write_to(&mut Cursor::new(&mut bytes), xcap::image::ImageFormat::Png)
.map_err(|e| {
ToolError::ExecutionError(format!("Failed to write image buffer {}", e))
})?;
// Convert to base64
let data = base64::prelude::BASE64_STANDARD.encode(bytes);
Ok(vec![
Content::text("Screenshot captured").with_audience(vec![Role::Assistant]),
Content::image(data, "image/png").with_priority(0.0),
])
}
}
impl Router for DeveloperRouter {
fn name(&self) -> String {
"developer".to_string()
}
fn instructions(&self) -> String {
self.instructions.clone()
}
fn capabilities(&self) -> ServerCapabilities {
CapabilitiesBuilder::new()
.with_tools(false)
.with_prompts(false)
.build()
}
fn list_tools(&self) -> Vec<Tool> {
self.tools.clone()
}
fn call_tool(
&self,
tool_name: &str,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Vec<Content>, ToolError>> + Send + 'static>> {
let this = self.clone();
let tool_name = tool_name.to_string();
Box::pin(async move {
match tool_name.as_str() {
"shell" => this.bash(arguments).await,
"text_editor" => this.text_editor(arguments).await,
"list_windows" => this.list_windows(arguments).await,
"screen_capture" => this.screen_capture(arguments).await,
_ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))),
}
})
}
// TODO see if we can make it easy to skip implementing these
fn list_resources(&self) -> Vec<Resource> {
Vec::new()
}
fn read_resource(
&self,
_uri: &str,
) -> Pin<Box<dyn Future<Output = Result<String, ResourceError>> + Send + 'static>> {
Box::pin(async move { Ok("".to_string()) })
}
fn list_prompts(&self) -> Vec<Prompt> {
self.prompts.values().cloned().collect()
}
fn get_prompt(
&self,
prompt_name: &str,
) -> Pin<Box<dyn Future<Output = Result<String, PromptError>> + Send + 'static>> {
let prompt_name = prompt_name.trim().to_owned();
// Validate prompt name is not empty
if prompt_name.is_empty() {
return Box::pin(async move {
Err(PromptError::InvalidParameters(
"Prompt name cannot be empty".to_string(),
))
});
}
let prompts = Arc::clone(&self.prompts);
Box::pin(async move {
match prompts.get(&prompt_name) {
Some(prompt) => Ok(prompt.description.clone().unwrap_or_default()),
None => Err(PromptError::NotFound(format!(
"Prompt '{prompt_name}' not found"
))),
}
})
}
}
impl Clone for DeveloperRouter {
fn clone(&self) -> Self {
Self {
tools: self.tools.clone(),
prompts: Arc::clone(&self.prompts),
instructions: self.instructions.clone(),
file_history: Arc::clone(&self.file_history),
ignore_patterns: Arc::clone(&self.ignore_patterns),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use serial_test::serial;
use std::fs;
use tempfile::TempDir;
use tokio::sync::OnceCell;
#[test]
#[serial]
fn test_global_goosehints() {
// if ~/.config/goose/.goosehints exists, it should be included in the instructions
// copy the existing global hints file to a .bak file
let global_hints_path =
PathBuf::from(shellexpand::tilde("~/.config/goose/.goosehints").to_string());
let global_hints_bak_path =
PathBuf::from(shellexpand::tilde("~/.config/goose/.goosehints.bak").to_string());
let mut globalhints_existed = false;
if global_hints_path.is_file() {
globalhints_existed = true;
fs::copy(&global_hints_path, &global_hints_bak_path).unwrap();
}
fs::write(&global_hints_path, "These are my global goose hints.").unwrap();
let dir = TempDir::new().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let router = DeveloperRouter::new();
let instructions = router.instructions();
assert!(instructions.contains("### Global Hints"));
assert!(instructions.contains("my global goose hints."));
// restore backup if globalhints previously existed
if globalhints_existed {
fs::copy(&global_hints_bak_path, &global_hints_path).unwrap();
fs::remove_file(&global_hints_bak_path).unwrap();
}
}
#[test]
#[serial]
fn test_goosehints_when_present() {
let dir = TempDir::new().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
fs::write(".goosehints", "Test hint content").unwrap();
let router = DeveloperRouter::new();
let instructions = router.instructions();
assert!(instructions.contains("Test hint content"));
}
#[test]
#[serial]
fn test_goosehints_when_missing() {
let dir = TempDir::new().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let router = DeveloperRouter::new();
let instructions = router.instructions();
assert!(!instructions.contains("Project Hints"));
}
static DEV_ROUTER: OnceCell<DeveloperRouter> = OnceCell::const_new();
async fn get_router() -> &'static DeveloperRouter {
DEV_ROUTER
.get_or_init(|| async { DeveloperRouter::new() })
.await
}
#[tokio::test]
#[serial]
async fn test_shell_missing_parameters() {
let temp_dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
let router = get_router().await;
let result = router.call_tool("shell", json!({})).await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::InvalidParameters(_)));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
#[cfg(windows)]
async fn test_windows_specific_commands() {
let router = get_router().await;
// Test PowerShell command
let result = router
.call_tool(
"shell",
json!({
"command": "Get-ChildItem"
}),
)
.await;
assert!(result.is_ok());
// Test Windows path handling
let result = router.resolve_path("C:\\Windows\\System32");
assert!(result.is_ok());
// Test UNC path handling
let result = router.resolve_path("\\\\server\\share");
assert!(result.is_ok());
}
#[tokio::test]
#[serial]
async fn test_text_editor_size_limits() {
// Create temp directory first so it stays in scope for the whole test
let temp_dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Get router after setting current directory
let router = get_router().await;
// Test file size limit
{
let large_file_path = temp_dir.path().join("large.txt");
let large_file_str = large_file_path.to_str().unwrap();
// Create a file larger than 2MB
let content = "x".repeat(3 * 1024 * 1024); // 3MB
std::fs::write(&large_file_path, content).unwrap();
let result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": large_file_str
}),
)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::ExecutionError(_)));
assert!(err.to_string().contains("too large"));
}
// Test character count limit
{
let many_chars_path = temp_dir.path().join("many_chars.txt");
let many_chars_str = many_chars_path.to_str().unwrap();
// Create a file with more than 400K characters but less than 400KB
let content = "x".repeat(405_000);
std::fs::write(&many_chars_path, content).unwrap();
let result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": many_chars_str
}),
)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, ToolError::ExecutionError(_)));
assert!(err.to_string().contains("too many characters"));
}
// Let temp_dir drop naturally at end of scope
}
#[tokio::test]
#[serial]
async fn test_text_editor_write_and_view_file() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a new file
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": "Hello, world!"
}),
)
.await
.unwrap();
// View the file
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str
}),
)
.await
.unwrap();
assert!(!view_result.is_empty());
let text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
assert!(text.contains("Hello, world!"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_str_replace() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a new file
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": "Hello, world!"
}),
)
.await
.unwrap();
// Replace string
let replace_result = router
.call_tool(
"text_editor",
json!({
"command": "str_replace",
"path": file_path_str,
"old_str": "world",
"new_str": "Rust"
}),
)
.await
.unwrap();
let text = replace_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::Assistant))
})
.unwrap()
.as_text()
.unwrap();
assert!(text.contains("has been edited, and the section now reads"));
// View the file to verify the change
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str
}),
)
.await
.unwrap();
let text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
assert!(text.contains("Hello, Rust!"));
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_undo_edit() {
let router = get_router().await;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let file_path_str = file_path.to_str().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a new file
router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": file_path_str,
"file_text": "First line"
}),
)
.await
.unwrap();
// Replace string
router
.call_tool(
"text_editor",
json!({
"command": "str_replace",
"path": file_path_str,
"old_str": "First line",
"new_str": "Second line"
}),
)
.await
.unwrap();
// Undo the edit
let undo_result = router
.call_tool(
"text_editor",
json!({
"command": "undo_edit",
"path": file_path_str
}),
)
.await
.unwrap();
let text = undo_result.first().unwrap().as_text().unwrap();
assert!(text.contains("Undid the last edit"));
// View the file to verify the undo
let view_result = router
.call_tool(
"text_editor",
json!({
"command": "view",
"path": file_path_str
}),
)
.await
.unwrap();
let text = view_result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::User))
})
.unwrap()
.as_text()
.unwrap();
assert!(text.contains("First line"));
temp_dir.close().unwrap();
}
// Test GooseIgnore pattern matching
#[tokio::test]
#[serial]
async fn test_goose_ignore_basic_patterns() {
let temp_dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a DeveloperRouter with custom ignore patterns
let mut builder = GitignoreBuilder::new(temp_dir.path().to_path_buf());
builder.add_line(None, "secret.txt").unwrap();
builder.add_line(None, "*.env").unwrap();
let ignore_patterns = builder.build().unwrap();
let router = DeveloperRouter {
tools: vec![],
prompts: Arc::new(HashMap::new()),
instructions: String::new(),
file_history: Arc::new(Mutex::new(HashMap::new())),
ignore_patterns: Arc::new(ignore_patterns),
};
// Test basic file matching
assert!(
router.is_ignored(Path::new("secret.txt")),
"secret.txt should be ignored"
);
assert!(
router.is_ignored(Path::new("./secret.txt")),
"./secret.txt should be ignored"
);
assert!(
!router.is_ignored(Path::new("not_secret.txt")),
"not_secret.txt should not be ignored"
);
// Test pattern matching
assert!(
router.is_ignored(Path::new("test.env")),
"*.env pattern should match test.env"
);
assert!(
router.is_ignored(Path::new("./test.env")),
"*.env pattern should match ./test.env"
);
assert!(
!router.is_ignored(Path::new("test.txt")),
"*.env pattern should not match test.txt"
);
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_text_editor_respects_ignore_patterns() {
let temp_dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a DeveloperRouter with custom ignore patterns
let mut builder = GitignoreBuilder::new(temp_dir.path().to_path_buf());
builder.add_line(None, "secret.txt").unwrap();
let ignore_patterns = builder.build().unwrap();
let router = DeveloperRouter {
tools: DeveloperRouter::new().tools, // Reuse default tools
prompts: Arc::new(HashMap::new()),
instructions: String::new(),
file_history: Arc::new(Mutex::new(HashMap::new())),
ignore_patterns: Arc::new(ignore_patterns),
};
// Try to write to an ignored file
let result = router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": temp_dir.path().join("secret.txt").to_str().unwrap(),
"file_text": "test content"
}),
)
.await;
assert!(
result.is_err(),
"Should not be able to write to ignored file"
);
assert!(matches!(result.unwrap_err(), ToolError::ExecutionError(_)));
// Try to write to a non-ignored file
let result = router
.call_tool(
"text_editor",
json!({
"command": "write",
"path": temp_dir.path().join("allowed.txt").to_str().unwrap(),
"file_text": "test content"
}),
)
.await;
assert!(
result.is_ok(),
"Should be able to write to non-ignored file"
);
temp_dir.close().unwrap();
}
#[tokio::test]
#[serial]
async fn test_bash_respects_ignore_patterns() {
let temp_dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
// Create a DeveloperRouter with custom ignore patterns
let mut builder = GitignoreBuilder::new(temp_dir.path().to_path_buf());
builder.add_line(None, "secret.txt").unwrap();
let ignore_patterns = builder.build().unwrap();
let router = DeveloperRouter {
tools: DeveloperRouter::new().tools, // Reuse default tools
prompts: Arc::new(HashMap::new()),
instructions: String::new(),
file_history: Arc::new(Mutex::new(HashMap::new())),
ignore_patterns: Arc::new(ignore_patterns),
};
// Create an ignored file
let secret_file_path = temp_dir.path().join("secret.txt");
std::fs::write(&secret_file_path, "secret content").unwrap();
// Try to cat the ignored file
let result = router
.call_tool(
"shell",
json!({
"command": format!("cat {}", secret_file_path.to_str().unwrap())
}),
)
.await;
assert!(result.is_err(), "Should not be able to cat ignored file");
assert!(matches!(result.unwrap_err(), ToolError::ExecutionError(_)));
// Try to cat a non-ignored file
let allowed_file_path = temp_dir.path().join("allowed.txt");
std::fs::write(&allowed_file_path, "allowed content").unwrap();
let result = router
.call_tool(
"shell",
json!({
"command": format!("cat {}", allowed_file_path.to_str().unwrap())
}),
)
.await;
assert!(result.is_ok(), "Should be able to cat non-ignored file");
temp_dir.close().unwrap();
}
}