Skip to content

Commit

Permalink
shell platform developer extension
Browse files Browse the repository at this point in the history
  • Loading branch information
Kvadratni committed Feb 3, 2025
1 parent ead6fd3 commit 63d432f
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 76 deletions.
3 changes: 3 additions & 0 deletions crates/goose-mcp/src/developer/lang.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ pub fn get_language_identifier(path: &Path) -> &'static str {
Some("toml") => "toml",
Some("yaml") | Some("yml") => "yaml",
Some("sh") => "bash",
Some("ps1") => "powershell",
Some("bat") | Some("cmd") => "batch",
Some("vbs") => "vbscript",
Some("go") => "go",
Some("md") => "markdown",
Some("html") => "html",
Expand Down
130 changes: 54 additions & 76 deletions crates/goose-mcp/src/developer/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod lang;
mod shell;

use anyhow::Result;
use base64::Engine;
Expand Down Expand Up @@ -31,6 +32,11 @@ use std::process::Stdio;
use std::sync::{Arc, Mutex};
use xcap::{Monitor, Window};

use self::shell::{
expand_path, format_command_for_platform, get_shell_config, is_absolute_path,
normalize_line_endings,
};

pub struct DeveloperRouter {
tools: Vec<Tool>,
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
Expand Down Expand Up @@ -161,7 +167,14 @@ impl DeveloperRouter {
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.
You can use the shell tool to run any command that would work on the relevant operating system:
- Windows: PowerShell or CMD commands
- Unix: Bash commands
When using paths, remember:
- Windows: Use backslashes or forward slashes (both work)
- Unix: Use 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
Expand Down Expand Up @@ -199,15 +212,15 @@ impl DeveloperRouter {
}
}

// Helper method to resolve a path relative to cwd
// 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 = shellexpand::tilde(path_str);
let path = Path::new(expanded.as_ref());
let expanded = expand_path(path_str);
let path = Path::new(&expanded);

let suggestion = cwd.join(path);

match path.is_absolute() {
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 {}?",
Expand All @@ -217,7 +230,7 @@ impl DeveloperRouter {
}
}

// Implement bash tool functionality
// Shell command execution with platform-specific handling
async fn bash(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let command =
params
Expand All @@ -227,19 +240,17 @@ impl DeveloperRouter {
"The command string is required".to_string(),
))?;

// TODO consider command suggestions and safety rails

// TODO be more careful about backgrounding, revisit interleave
// Redirect stderr to stdout to interleave outputs
let cmd_with_redirect = format!("{} 2>&1", command);
// Get platform-specific shell configuration
let shell_config = get_shell_config();
let cmd_with_redirect = format_command_for_platform(command);

// Execute the command
let child = Command::new("bash")
.stdout(Stdio::piped()) // These two pipes required to capture output later.
// 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) // Critical so that the command is killed when the agent.reply stream is interrupted.
.arg("-c")
.kill_on_drop(true)
.arg(&shell_config.arg)
.arg(cmd_with_redirect)
.spawn()
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Expand Down Expand Up @@ -393,12 +404,15 @@ impl DeveloperRouter {
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, file_text)
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 = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
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
Expand Down Expand Up @@ -454,13 +468,14 @@ impl DeveloperRouter {
// Save history for undo
self.save_file_history(path)?;

// Replace and write back
// Replace and write back with platform-specific line endings
let new_content = content.replace(old_str, new_str);
std::fs::write(path, &new_content)
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 = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
let language = lang::get_language_identifier(path);

// Show a snippet of the changed content with context
const SNIPPET_LINES: usize = 4;
Expand Down Expand Up @@ -753,65 +768,28 @@ mod tests {

#[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
#[cfg(windows)]
async fn test_windows_specific_commands() {
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 PowerShell command
let result = router
.call_tool(
"shell",
json!({
"command": "Get-ChildItem"
}),
)
.await;
assert!(result.is_ok());

// 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"));
}
// Test Windows path handling
let result = router.resolve_path("C:\\Windows\\System32");
assert!(result.is_ok());

// Let temp_dir drop naturally at end of scope
// Test UNC path handling
let result = router.resolve_path("\\\\server\\share");
assert!(result.is_ok());
}

#[tokio::test]
Expand Down
90 changes: 90 additions & 0 deletions crates/goose-mcp/src/developer/shell.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use std::env;

#[derive(Debug, Clone)]
pub struct ShellConfig {
pub executable: String,
pub arg: String,
pub redirect_syntax: String,
}

impl Default for ShellConfig {
fn default() -> Self {
if cfg!(windows) {
// Prefer PowerShell over cmd.exe for better functionality
if std::process::Command::new("powershell.exe")
.arg("-Command")
.arg("exit")
.output()
.is_ok()
{
Self {
executable: "powershell.exe".to_string(),
arg: "-Command".to_string(),
redirect_syntax: "2>&1".to_string(), // PowerShell supports Unix-style redirection
}
} else {
Self {
executable: "cmd.exe".to_string(),
arg: "/C".to_string(),
redirect_syntax: "2>&1".to_string(), // cmd.exe also supports this syntax
}
}
} else {
Self {
executable: "bash".to_string(),
arg: "-c".to_string(),
redirect_syntax: "2>&1".to_string(),
}
}
}
}

pub fn get_shell_config() -> ShellConfig {
ShellConfig::default()
}

pub fn format_command_for_platform(command: &str) -> String {
let config = get_shell_config();
if cfg!(windows) && config.executable == "powershell.exe" {
// For Windows PowerShell, wrap the command in braces
format!("{{ {} }} {}", command, config.redirect_syntax)
} else {
// For cmd.exe and Unix shells, no braces needed
format!("{} {}", command, config.redirect_syntax)
}
}

pub fn expand_path(path_str: &str) -> String {
if cfg!(windows) {
// Expand Windows environment variables (%VAR%)
let with_userprofile = path_str.replace(
"%USERPROFILE%",
&env::var("USERPROFILE").unwrap_or_default(),
);
// Add more Windows environment variables as needed
with_userprofile.replace("%APPDATA%", &env::var("APPDATA").unwrap_or_default())
} else {
// Unix-style expansion
shellexpand::tilde(path_str).into_owned()
}
}

pub fn is_absolute_path(path_str: &str) -> bool {
if cfg!(windows) {
// Check for Windows absolute paths (drive letters and UNC)
path_str.contains(":\\") || path_str.starts_with("\\\\")
} else {
// Unix absolute paths start with /
path_str.starts_with('/')
}
}

pub fn normalize_line_endings(text: &str) -> String {
if cfg!(windows) {
// Ensure CRLF line endings on Windows
text.replace("\r\n", "\n").replace("\n", "\r\n")
} else {
// Ensure LF line endings on Unix
text.replace("\r\n", "\n")
}
}

0 comments on commit 63d432f

Please sign in to comment.