Read paths from an interactive & login shell (#5774)

This commit is contained in:
Jack Amadeo
2025-11-18 19:24:49 -05:00
committed by GitHub
parent 662d79b97e
commit 5ba636f45f
10 changed files with 249 additions and 71 deletions
Generated
-1
View File
@@ -2787,7 +2787,6 @@ dependencies = [
"docx-rs",
"etcetera",
"glob",
"goose",
"http-body-util",
"hyper 1.6.0",
"ignore",
+19 -9
View File
@@ -2,6 +2,10 @@ use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use goose::config::{Config, ExtensionConfig};
use goose_mcp::mcp_server_runner::{serve, McpCommand};
use goose_mcp::{
AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer,
};
use crate::commands::acp::run_acp_agent;
use crate::commands::bench::agent_generator;
@@ -420,7 +424,10 @@ enum Command {
/// Manage system prompts and behaviors
#[command(about = "Run one of the mcp servers bundled with goose")]
Mcp { name: String },
Mcp {
#[arg(value_parser = clap::value_parser!(McpCommand))]
server: McpCommand,
},
/// Run goose as an ACP (Agent Client Protocol) agent
#[command(about = "Run goose as an ACP agent server on stdio")]
@@ -877,15 +884,18 @@ pub async fn cli() -> anyhow::Result<()> {
);
match cli.command {
Some(Command::Configure {}) => {
handle_configure().await?;
}
Some(Command::Info { verbose }) => {
handle_info(verbose)?;
}
Some(Command::Mcp { name }) => {
Some(Command::Configure {}) => handle_configure().await?,
Some(Command::Info { verbose }) => handle_info(verbose)?,
Some(Command::Mcp { server }) => {
let name = server.name();
crate::logging::setup_logging(Some(&format!("mcp-{name}")), None)?;
goose_mcp::mcp_server_runner::run_mcp_server(&name).await?;
match server {
McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?,
McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?,
McpCommand::Memory => serve(MemoryServer::new()).await?,
McpCommand::Tutorial => serve(TutorialServer::new()).await?,
McpCommand::Developer => serve(DeveloperServer::new()).await?,
}
}
Some(Command::Acp {}) => {
run_acp_agent().await?;
+1 -2
View File
@@ -11,7 +11,6 @@ description.workspace = true
workspace = true
[dependencies]
goose = { path = "../goose" }
rmcp = { version = "0.8.1", features = ["server", "client", "transport-io", "macros"] }
anyhow = "1.0.94"
tokio = { version = "1", features = ["full"] }
@@ -77,13 +76,13 @@ libc = "0.2"
# ~1000 downloads). Pinned to exact version to prevent supply chain attacks.
mpatch = "=0.2.0"
tokio-util = "0.7.16"
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
serial_test = "3.0.0"
sysinfo = "0.32.1"
temp-env = "0.3.6"
clap = { version = "4", features = ["derive"] }
colored = "2"
[features]
@@ -2,11 +2,19 @@ use crate::developer::analyze::types::{
AnalysisMode, AnalysisResult, CallChain, EntryType, FocusedAnalysisData,
};
use crate::developer::lang;
use goose::utils::safe_truncate;
use rmcp::model::{Content, Role};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
fn safe_truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect();
format!("{}...", truncated)
}
}
pub struct Formatter;
impl Formatter {
+1
View File
@@ -1,6 +1,7 @@
pub mod analyze;
mod editor_models;
mod lang;
pub mod paths;
mod shell;
mod text_editor;
+112
View File
@@ -0,0 +1,112 @@
use anyhow::Result;
use std::env;
use std::path::PathBuf;
use tokio::process::Command;
use tokio::sync::OnceCell;
static SHELL_PATH_DIRS: OnceCell<Result<Vec<PathBuf>, anyhow::Error>> = OnceCell::const_new();
pub async fn get_shell_path_dirs() -> Result<&'static Vec<PathBuf>> {
let result = SHELL_PATH_DIRS
.get_or_init(|| async {
get_shell_path_async()
.await
.map(|path| env::split_paths(&path).collect())
})
.await;
match result {
Ok(dirs) => Ok(dirs),
Err(e) => Err(anyhow::anyhow!(
"Failed to get shell PATH directories: {}",
e
)),
}
}
async fn get_shell_path_async() -> Result<String> {
let shell = env::var("SHELL").unwrap_or_else(|_| {
if cfg!(windows) {
"cmd".to_string()
} else {
"/bin/bash".to_string()
}
});
if cfg!(windows) {
get_windows_path_async(&shell).await
} else {
get_unix_path_async(&shell).await
}
.or_else(|e| {
tracing::warn!(
"Failed to get PATH from shell ({}), falling back to current PATH",
e
);
env::var("PATH").map_err(|_| anyhow::anyhow!("No PATH variable available"))
})
}
async fn get_unix_path_async(shell: &str) -> Result<String> {
let output = Command::new(shell)
.args(["-l", "-i", "-c", "echo $PATH"])
.output()
.await
.map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Shell command failed: {}", stderr));
}
let path = String::from_utf8(output.stdout)
.map_err(|e| anyhow::anyhow!("Invalid UTF-8 in shell output: {}", e))?
.trim()
.to_string();
if path.is_empty() {
return Err(anyhow::anyhow!("Shell returned empty PATH"));
}
Ok(path)
}
async fn get_windows_path_async(shell: &str) -> Result<String> {
let shell_name = std::path::Path::new(shell)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("cmd");
let output = match shell_name {
"pwsh" | "powershell" => {
Command::new(shell)
.args(["-NoLogo", "-Command", "$env:PATH"])
.output()
.await
}
_ => {
Command::new(shell)
.args(["/c", "echo %PATH%"])
.output()
.await
}
};
let output = output.map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Shell command failed: {}", stderr));
}
let path = String::from_utf8(output.stdout)
.map_err(|e| anyhow::anyhow!("Invalid UTF-8 in shell output: {}", e))?
.trim()
.to_string();
if path.is_empty() {
return Err(anyhow::anyhow!("Shell returned empty PATH"));
}
Ok(path)
}
@@ -1,3 +1,4 @@
use anyhow::anyhow;
use base64::Engine;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use include_dir::{include_dir, Dir};
@@ -17,6 +18,8 @@ use rmcp::{
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
env::join_paths,
ffi::OsString,
future::Future,
io::Cursor,
path::{Path, PathBuf},
@@ -31,11 +34,11 @@ use tokio::{
use tokio_stream::{wrappers::SplitStream, StreamExt as _};
use tokio_util::sync::CancellationToken;
use crate::developer::{paths::get_shell_path_dirs, shell::ShellConfig};
use super::analyze::{types::AnalyzeParams, CodeAnalyzer};
use super::editor_models::{create_editor_model, EditorModel};
use super::shell::{
configure_shell_command, expand_path, get_shell_config, is_absolute_path, kill_process_group,
};
use super::shell::{configure_shell_command, expand_path, is_absolute_path, kill_process_group};
use super::text_editor::{
text_editor_insert, text_editor_replace, text_editor_undo, text_editor_view, text_editor_write,
};
@@ -179,6 +182,8 @@ pub struct DeveloperServer {
pub running_processes: Arc<RwLock<HashMap<String, CancellationToken>>>,
#[cfg(not(test))]
running_processes: Arc<RwLock<HashMap<String, CancellationToken>>>,
bash_env_file: Option<PathBuf>,
extend_path_with_shell: bool,
}
#[tool_handler(router = self.tool_router)]
@@ -549,9 +554,21 @@ impl DeveloperServer {
prompts: load_prompt_files(),
code_analyzer: CodeAnalyzer::new(),
running_processes: Arc::new(RwLock::new(HashMap::new())),
extend_path_with_shell: false,
bash_env_file: None,
}
}
pub fn extend_path_with_shell(mut self, value: bool) -> Self {
self.extend_path_with_shell = value;
self
}
pub fn bash_env_file(mut self, value: Option<PathBuf>) -> Self {
self.bash_env_file = value;
self
}
/// List all available windows 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.
@@ -942,10 +959,34 @@ impl DeveloperServer {
peer: &rmcp::service::Peer<RoleServer>,
cancellation_token: CancellationToken,
) -> Result<String, ErrorData> {
// Get platform-specific shell configuration
let shell_config = get_shell_config();
let mut shell_config = ShellConfig::default();
let shell_name = std::path::Path::new(&shell_config.executable)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("bash");
let mut child = configure_shell_command(&shell_config, command)
if let Some(ref env_file) = self.bash_env_file {
if shell_name == "bash" {
shell_config.envs.push((
OsString::from("BASH_ENV"),
env_file.clone().into_os_string(),
))
}
}
let mut command = configure_shell_command(&shell_config, command);
if self.extend_path_with_shell {
if let Err(e) = get_shell_path_dirs()
.await
.and_then(|dirs| join_paths(dirs).map_err(|e| anyhow!(e)))
.map(|path| command.env("PATH", path))
{
tracing::error!("Failed to extend PATH with shell directories: {}", e)
}
}
let mut child = command
.spawn()
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
+1 -20
View File
@@ -1,6 +1,5 @@
use std::{env, ffi::OsString, process::Stdio};
use goose::config::paths::Paths;
#[cfg(unix)]
#[allow(unused_imports)] // False positive: trait is used for process_group method
use std::os::unix::process::CommandExt;
@@ -22,24 +21,10 @@ impl Default for ShellConfig {
#[cfg(not(windows))]
{
let shell = env::var("SHELL").unwrap_or_else(|_| "bash".to_string());
// Get just the shell name from the path (e.g., /bin/zsh -> zsh)
let shell_name = std::path::Path::new(&shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("bash");
// Configure environment based on shell type
let envs = if shell_name == "bash" {
let bash_env = Paths::config_dir().join(".bash_env").into_os_string();
vec![(OsString::from("BASH_ENV"), bash_env)]
} else {
vec![]
};
Self {
executable: shell,
args: vec!["-c".to_string()], // -c is standard across bash/zsh/fish
envs,
envs: vec![],
}
}
}
@@ -82,10 +67,6 @@ impl ShellConfig {
}
}
pub fn get_shell_config() -> ShellConfig {
ShellConfig::default()
}
pub fn expand_path(path_str: &str) -> String {
if cfg!(windows) {
// Expand Windows environment variables (%VAR%)
+34 -26
View File
@@ -1,37 +1,45 @@
use crate::{
AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer,
};
use anyhow::{anyhow, Result};
use std::str::FromStr;
use anyhow::Result;
use rmcp::{transport::stdio, ServiceExt};
/// Run an MCP server by name
///
/// This function handles the common logic for starting MCP servers.
/// The caller is responsible for setting up logging before calling this function.
pub async fn run_mcp_server(name: &str) -> Result<()> {
if name == "googledrive" || name == "google_drive" {
return Err(anyhow!(
"the built-in Google Drive extension has been removed"
));
}
#[derive(Clone, Debug)]
pub enum McpCommand {
AutoVisualiser,
ComputerController,
Developer,
Memory,
Tutorial,
}
tracing::info!("Starting MCP server");
impl FromStr for McpCommand {
type Err = String;
match name.to_lowercase().replace(' ', "").as_str() {
"autovisualiser" => serve_and_wait(AutoVisualiserRouter::new()).await,
"computercontroller" => serve_and_wait(ComputerControllerServer::new()).await,
"developer" => serve_and_wait(DeveloperServer::new()).await,
"memory" => serve_and_wait(MemoryServer::new()).await,
"tutorial" => serve_and_wait(TutorialServer::new()).await,
_ => {
tracing::warn!("Unknown MCP server name: {}", name);
Err(anyhow!("Unknown MCP server name: {}", name))
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().replace(' ', "").as_str() {
"autovisualiser" => Ok(McpCommand::AutoVisualiser),
"computercontroller" => Ok(McpCommand::ComputerController),
"developer" => Ok(McpCommand::Developer),
"memory" => Ok(McpCommand::Memory),
"tutorial" => Ok(McpCommand::Tutorial),
_ => Err(format!("Invalid command: {}", s)),
}
}
}
/// Helper function to run any MCP server with common error handling
async fn serve_and_wait<S>(server: S) -> Result<()>
impl McpCommand {
pub fn name(&self) -> &str {
match self {
McpCommand::AutoVisualiser => "autovisualiser",
McpCommand::ComputerController => "computercontroller",
McpCommand::Developer => "developer",
McpCommand::Memory => "memory",
McpCommand::Tutorial => "tutorial",
}
}
}
pub async fn serve<S>(server: S) -> Result<()>
where
S: rmcp::ServerHandler,
{
+25 -6
View File
@@ -7,6 +7,11 @@ mod routes;
mod state;
use clap::{Parser, Subcommand};
use goose::config::paths::Paths;
use goose_mcp::{
mcp_server_runner::{serve, McpCommand},
AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer,
};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
@@ -22,8 +27,8 @@ enum Commands {
Agent,
/// Run the MCP server
Mcp {
/// Name of the MCP server type
name: String,
#[arg(value_parser = clap::value_parser!(McpCommand))]
server: McpCommand,
},
}
@@ -31,13 +36,27 @@ enum Commands {
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match &cli.command {
match cli.command {
Commands::Agent => {
commands::agent::run().await?;
}
Commands::Mcp { name } => {
logging::setup_logging(Some(&format!("mcp-{name}")))?;
goose_mcp::mcp_server_runner::run_mcp_server(name).await?;
Commands::Mcp { server } => {
logging::setup_logging(Some(&format!("mcp-{}", server.name())))?;
match server {
McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?,
McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?,
McpCommand::Memory => serve(MemoryServer::new()).await?,
McpCommand::Tutorial => serve(TutorialServer::new()).await?,
McpCommand::Developer => {
let bash_env = Paths::config_dir().join(".bash_env");
serve(
DeveloperServer::new()
.extend_path_with_shell(true)
.bash_env_file(Some(bash_env)),
)
.await?
}
}
}
}