Better search paths and handling of CLI providers (#5554)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jack Amadeo
2025-11-07 19:35:26 -08:00
committed by GitHub
parent 65b4b2bb18
commit 25dfd768e5
27 changed files with 721 additions and 538 deletions
+6 -10
View File
@@ -34,10 +34,11 @@ use super::types::SharedProvider;
use crate::agents::extension::{Envs, ProcessExit};
use crate::agents::extension_malware_check;
use crate::agents::mcp_client::{McpClient, McpClientTrait};
use crate::config::search_path::search_path_var;
use crate::config::search_path::SearchPaths;
use crate::config::{get_all_extensions, Config};
use crate::oauth::oauth_flow;
use crate::prompt_template;
use crate::subprocess::configure_command_no_window;
use rmcp::model::{
CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, ResourceContents,
ServerInfo, Tool,
@@ -129,9 +130,6 @@ impl ResourceItem {
}
}
#[cfg(windows)]
const CREATE_NO_WINDOW_FLAG: u32 = 0x08000000;
/// Sanitizes a string by replacing invalid characters with underscores.
/// Valid characters match [a-zA-Z0-9_-]
fn normalize(input: String) -> String {
@@ -185,13 +183,11 @@ async fn child_process_client(
) -> ExtensionResult<McpClient> {
#[cfg(unix)]
command.process_group(0);
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW_FLAG);
configure_command_no_window(&mut command);
command.env(
"PATH",
search_path_var().map_err(|e| ExtensionError::ConfigError(format!("{}", e)))?,
);
if let Ok(path) = SearchPaths::builder().path() {
command.env("PATH", path);
}
let (transport, mut stderr) = TokioChildProcess::builder(command)
.stderr(Stdio::piped())
+77 -12
View File
@@ -8,6 +8,7 @@ use serde_json::Value;
use serde_yaml::Mapping;
use std::collections::HashMap;
use std::env;
use std::ffi::OsString;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
@@ -138,17 +139,77 @@ impl Default for Config {
}
}
macro_rules! declare_param {
($param_name:ident, $param_type:ty) => {
paste::paste! {
pub fn [<get_ $param_name:lower>](&self) -> Result<$param_type, ConfigError> {
self.get_param(stringify!($param_name))
pub trait ConfigValue {
const KEY: &'static str;
const DEFAULT: &'static str;
}
macro_rules! config_value {
($key:ident, $type:ty) => {
impl Config {
paste::paste! {
pub fn [<get_ $key:lower>](&self) -> Result<$type, ConfigError> {
self.get_param(stringify!($key))
}
}
paste::paste! {
pub fn [<set_ $key:lower>](&self, v: impl Into<$type>) -> Result<(), ConfigError> {
self.set_param(stringify!($key), &v.into())
}
}
}
};
($key:ident, $inner:ty, $default:expr) => {
paste::paste! {
pub fn [<set_ $param_name:lower>](&self, v: impl Into<$param_type>) -> Result<(), ConfigError> {
self.set_param(stringify!($param_name), &v.into())
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct [<$key:camel>]($inner);
impl ConfigValue for [<$key:camel>] {
const KEY: &'static str = stringify!($key);
const DEFAULT: &'static str = $default;
}
impl Default for [<$key:camel>] {
fn default() -> Self {
[<$key:camel>]($default.into())
}
}
impl std::ops::Deref for [<$key:camel>] {
type Target = $inner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for [<$key:camel>] {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl std::fmt::Display for [<$key:camel>] {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.0)
}
}
impl From<$inner> for [<$key:camel>] {
fn from(value: $inner) -> Self {
[<$key:camel>](value)
}
}
impl From<[<$key:camel>]> for $inner {
fn from(value: [<$key:camel>]) -> $inner {
value.0
}
}
config_value!($key, [<$key:camel>]);
}
};
}
@@ -738,13 +799,17 @@ impl Config {
};
Ok(())
}
declare_param!(GOOSE_SEARCH_PATHS, Vec<String>);
declare_param!(GOOSE_MODE, GooseMode);
declare_param!(GOOSE_PROVIDER, String);
declare_param!(GOOSE_MODEL, String);
}
config_value!(CLAUDE_CODE_COMMAND, OsString, "claude");
config_value!(GEMINI_CLI_COMMAND, OsString, "gemini");
config_value!(CURSOR_AGENT_COMMAND, OsString, "cursor-agent");
config_value!(GOOSE_SEARCH_PATHS, Vec<String>);
config_value!(GOOSE_MODE, GooseMode);
config_value!(GOOSE_PROVIDER, String);
config_value!(GOOSE_MODEL, String);
/// Load init-config.yaml from workspace root if it exists.
/// This function is shared between the config recovery and the init_config endpoint.
pub fn load_init_config_from_workspace() -> Result<Mapping, ConfigError> {
+119 -21
View File
@@ -1,25 +1,123 @@
use std::{env, ffi::OsString, path::PathBuf};
use std::{
env::{self},
ffi::{OsStr, OsString},
path::PathBuf,
};
use crate::config::{Config, ConfigError};
use anyhow::{Context, Result};
pub fn search_path_var() -> Result<OsString, ConfigError> {
let paths = Config::global()
.get_goose_search_paths()
.or_else(|err| match err {
ConfigError::NotFound(_) => Ok(vec![]),
err => Err(err),
})?
.into_iter()
.map(|s| PathBuf::from(shellexpand::tilde(&s).as_ref()));
use crate::config::Config;
env::join_paths(
paths.chain(
env::var_os("PATH")
.as_ref()
.map(env::split_paths)
.into_iter()
.flatten(),
),
)
.map_err(|e| ConfigError::DeserializeError(format!("{}", e)))
pub struct SearchPaths {
paths: Vec<PathBuf>,
}
impl SearchPaths {
pub fn builder() -> Self {
let mut paths = Config::global()
.get_goose_search_paths()
.unwrap_or_default();
paths.push("~/.local/bin".into());
#[cfg(unix)]
{
paths.push("/usr/local/bin".into());
}
if cfg!(target_os = "macos") {
paths.push("/opt/homebrew/bin".into());
paths.push("/opt/local/bin".into());
}
Self {
paths: paths
.into_iter()
.map(|s| PathBuf::from(shellexpand::tilde(&s).as_ref()))
.collect(),
}
}
pub fn with_npm(mut self) -> Self {
if cfg!(windows) {
if let Some(appdata) = dirs::data_dir() {
self.paths.push(appdata.join("npm"));
}
} else if let Some(home) = dirs::home_dir() {
self.paths.push(home.join(".npm-global/bin"));
}
self
}
pub fn path(self) -> Result<OsString> {
env::join_paths(
self.paths.into_iter().chain(
env::var_os("PATH")
.as_ref()
.map(env::split_paths)
.into_iter()
.flatten(),
),
)
.map_err(Into::into)
}
pub fn resolve<N>(self, name: N) -> Result<PathBuf>
where
N: AsRef<OsStr>,
{
which::which_in_global(name.as_ref(), Some(self.path()?))?
.next()
.with_context(|| {
format!(
"could not resolve command '{}': file does not exist",
name.as_ref().to_string_lossy()
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_preserves_existing_path() {
let search_paths = SearchPaths::builder();
let combined_path = search_paths.path().unwrap();
if let Some(existing_path) = env::var_os("PATH") {
let combined_str = combined_path.to_string_lossy();
let existing_str = existing_path.to_string_lossy();
assert!(combined_str.contains(&existing_str.to_string()));
}
}
#[test]
fn test_resolve_nonexistent_executable() {
let search_paths = SearchPaths::builder();
let result = search_paths.resolve("nonexistent_executable_12345_abcdef");
assert!(
result.is_err(),
"Resolving nonexistent executable should return an error"
);
}
#[test]
fn test_resolve_common_executable() {
let search_paths = SearchPaths::builder();
#[cfg(unix)]
let test_executable = "sh";
#[cfg(windows)]
let test_executable = "cmd";
search_paths
.resolve(test_executable)
.expect("should resolve sh (or cmd on Windows)");
}
}
+1
View File
@@ -19,6 +19,7 @@ pub mod scheduler_trait;
pub mod security;
pub mod session;
pub mod session_context;
pub mod subprocess;
pub mod token_counter;
pub mod tool_inspection;
pub mod tool_monitor;
+11
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use super::errors::ProviderError;
use super::retry::RetryConfig;
use crate::config::base::ConfigValue;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::model::ModelConfig;
@@ -200,6 +201,16 @@ impl ConfigKey {
}
}
pub fn from_value_type<T: ConfigValue>(required: bool, secret: bool) -> Self {
Self {
name: T::KEY.to_string(),
required,
secret,
default: Some(T::DEFAULT.to_string()),
oauth_flow: false,
}
}
/// Create a new ConfigKey that uses OAuth device code flow for configuration
///
/// This is used for providers that support OAuth authentication instead of manual API key entry.
+16 -110
View File
@@ -2,6 +2,7 @@ use anyhow::Result;
use async_trait::async_trait;
use rmcp::model::Role;
use serde_json::{json, Value};
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
@@ -10,9 +11,12 @@ use tokio::process::Command;
use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::utils::{filter_extensions_from_system_prompt, RequestLog};
use crate::config::base::ClaudeCodeCommand;
use crate::config::search_path::SearchPaths;
use crate::config::{Config, GooseMode};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::subprocess::configure_command_no_window;
use rmcp::model::Tool;
pub const CLAUDE_CODE_DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
@@ -21,7 +25,7 @@ pub const CLAUDE_CODE_DOC_URL: &str = "https://code.claude.com/docs/en/setup";
#[derive(Debug, serde::Serialize)]
pub struct ClaudeCodeProvider {
command: String,
command: PathBuf,
model: ModelConfig,
#[serde(skip)]
name: String,
@@ -30,15 +34,8 @@ pub struct ClaudeCodeProvider {
impl ClaudeCodeProvider {
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = crate::config::Config::global();
let command: String = config
.get_param("CLAUDE_CODE_COMMAND")
.unwrap_or_else(|_| "claude".to_string());
let resolved_command = if !command.contains('/') {
Self::find_claude_executable(&command).unwrap_or(command)
} else {
command
};
let command: OsString = config.get_claude_code_command().unwrap_or_default().into();
let resolved_command = SearchPaths::builder().with_npm().resolve(command)?;
Ok(Self {
command: resolved_command,
@@ -47,61 +44,6 @@ impl ClaudeCodeProvider {
})
}
/// Search for claude executable in common installation locations
fn find_claude_executable(command_name: &str) -> Option<String> {
let home = std::env::var("HOME").ok()?;
let search_paths = vec![
format!("{}/.claude/local/{}", home, command_name),
format!("{}/.local/bin/{}", home, command_name),
format!("{}/bin/{}", home, command_name),
format!("/usr/local/bin/{}", command_name),
format!("/usr/bin/{}", command_name),
format!("/opt/claude/{}", command_name),
];
for path in search_paths {
let path_buf = PathBuf::from(&path);
if path_buf.exists() && path_buf.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(&path_buf) {
let permissions = metadata.permissions();
if permissions.mode() & 0o111 != 0 {
tracing::info!("Found claude executable at: {}", path);
return Some(path);
}
}
}
#[cfg(not(unix))]
{
tracing::info!("Found claude executable at: {}", path);
return Some(path);
}
}
}
if let Ok(path_var) = std::env::var("PATH") {
#[cfg(unix)]
let path_separator = ':';
#[cfg(windows)]
let path_separator = ';';
for dir in path_var.split(path_separator) {
let path_buf = PathBuf::from(dir).join(command_name);
if path_buf.exists() && path_buf.is_file() {
let full_path = path_buf.to_string_lossy().to_string();
tracing::info!("Found claude executable in PATH at: {}", full_path);
return Some(full_path);
}
}
}
tracing::warn!("Could not find claude executable in common locations");
None
}
/// Convert goose messages to the format expected by claude CLI
fn messages_to_claude_format(&self, _system: &str, messages: &[Message]) -> Result<Value> {
let mut claude_messages = Vec::new();
@@ -312,7 +254,7 @@ impl ClaudeCodeProvider {
if std::env::var("GOOSE_CLAUDE_CODE_DEBUG").is_ok() {
println!("=== CLAUDE CODE PROVIDER DEBUG ===");
println!("Command: {}", self.command);
println!("Command: {:?}", self.command);
println!("Original system prompt length: {} chars", system.len());
println!(
"Filtered system prompt length: {} chars",
@@ -328,6 +270,7 @@ impl ClaudeCodeProvider {
}
let mut cmd = Command::new(&self.command);
configure_command_no_window(&mut cmd);
cmd.arg("-p")
.arg(messages_json.to_string())
.arg("--system-prompt")
@@ -345,18 +288,12 @@ impl ClaudeCodeProvider {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| ProviderError::RequestFailed(format!(
"\n\n ## Unable to find Claude Code CLI on the path.\n\n\
**Error details:** Failed to spawn command '{}': {}\n\n\
**Please ensure:**\n\
- Claude Code CLI is installed and logged in\n\
- The command is in your PATH, or set `CLAUDE_CODE_COMMAND` in your config\n\n\
**For full features, please use the Anthropic provider with an API key if possible.**\n\n\
Visit {} for installation instructions.",
self.command, e, CLAUDE_CODE_DOC_URL
)))?;
let mut child = cmd.spawn().map_err(|e| {
ProviderError::RequestFailed(format!(
"Failed to spawn Claude CLI command '{:?}': {}.",
self.command, e
))
})?;
let stdout = child
.stdout
@@ -461,12 +398,7 @@ impl Provider for ClaudeCodeProvider {
CLAUDE_CODE_DEFAULT_MODEL,
CLAUDE_CODE_KNOWN_MODELS.to_vec(),
CLAUDE_CODE_DOC_URL,
vec![ConfigKey::new(
"CLAUDE_CODE_COMMAND",
false,
false,
Some("claude"),
)],
vec![ConfigKey::from_value_type::<ClaudeCodeCommand>(true, false)],
)
}
@@ -521,29 +453,3 @@ impl Provider for ClaudeCodeProvider {
))
}
}
#[cfg(test)]
mod tests {
use super::ModelConfig;
use super::*;
#[tokio::test]
async fn test_claude_code_invalid_model_no_fallback() {
// Test that an invalid model is kept as-is (no fallback)
let invalid_model = ModelConfig::new_or_fail("invalid-model");
let provider = ClaudeCodeProvider::from_env(invalid_model).await.unwrap();
let config = provider.get_model_config();
assert_eq!(config.model_name, "invalid-model");
}
#[tokio::test]
async fn test_claude_code_valid_model() {
// Test that a valid model is preserved
let valid_model = ModelConfig::new_or_fail("sonnet");
let provider = ClaudeCodeProvider::from_env(valid_model).await.unwrap();
let config = provider.get_model_config();
assert_eq!(config.model_name, "sonnet");
}
}
+17 -88
View File
@@ -2,6 +2,7 @@ use anyhow::Result;
use async_trait::async_trait;
use rmcp::model::Role;
use serde_json::{json, Value};
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
@@ -10,8 +11,11 @@ use tokio::process::Command;
use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::utils::{filter_extensions_from_system_prompt, RequestLog};
use crate::config::base::CursorAgentCommand;
use crate::config::search_path::SearchPaths;
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::subprocess::configure_command_no_window;
use rmcp::model::Tool;
pub const CURSOR_AGENT_DEFAULT_MODEL: &str = "auto";
@@ -21,7 +25,7 @@ pub const CURSOR_AGENT_DOC_URL: &str = "https://docs.cursor.com/en/cli/overview"
#[derive(Debug, serde::Serialize)]
pub struct CursorAgentProvider {
command: String,
command: PathBuf,
model: ModelConfig,
#[serde(skip)]
name: String,
@@ -30,15 +34,8 @@ pub struct CursorAgentProvider {
impl CursorAgentProvider {
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = crate::config::Config::global();
let command: String = config
.get_param("CURSOR_AGENT_COMMAND")
.unwrap_or_else(|_| "cursor-agent".to_string());
let resolved_command = if !command.contains('/') {
Self::find_cursor_agent_executable(&command).unwrap_or(command)
} else {
command
};
let command: OsString = config.get_cursor_agent_command().unwrap_or_default().into();
let resolved_command = SearchPaths::builder().with_npm().resolve(command)?;
Ok(Self {
command: resolved_command,
@@ -58,60 +55,6 @@ impl CursorAgentProvider {
.unwrap_or(false)
}
/// Search for cursor-agent executable in common installation locations
fn find_cursor_agent_executable(command_name: &str) -> Option<String> {
let home = std::env::var("HOME").ok()?;
let search_paths = vec![
format!("/opt/homebrew/bin/{}", command_name),
format!("/usr/bin/{}", command_name),
format!("/usr/local/bin/{}", command_name),
format!("{}/.local/bin/{}", home, command_name),
format!("{}/bin/{}", home, command_name),
];
for path in search_paths {
let path_buf = PathBuf::from(&path);
if path_buf.exists() && path_buf.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(&path_buf) {
let permissions = metadata.permissions();
if permissions.mode() & 0o111 != 0 {
tracing::info!("Found cursor-agent executable at: {}", path);
return Some(path);
}
}
}
#[cfg(not(unix))]
{
tracing::info!("Found cursor-agent executable at: {}", path);
return Some(path);
}
}
}
if let Ok(path_var) = std::env::var("PATH") {
#[cfg(unix)]
let path_separator = ':';
#[cfg(windows)]
let path_separator = ';';
for dir in path_var.split(path_separator) {
let path_buf = PathBuf::from(dir).join(command_name);
if path_buf.exists() && path_buf.is_file() {
let full_path = path_buf.to_string_lossy().to_string();
tracing::info!("Found cursor-agent executable in PATH at: {}", full_path);
return Some(full_path);
}
}
}
tracing::warn!("Could not find cursor-agent executable in common locations");
None
}
/// Convert goose messages to a simple prompt format for cursor-agent CLI
fn messages_to_cursor_agent_format(&self, system: &str, messages: &[Message]) -> String {
let mut full_prompt = String::new();
@@ -240,7 +183,7 @@ impl CursorAgentProvider {
if std::env::var("GOOSE_CURSOR_AGENT_DEBUG").is_ok() {
println!("=== CURSOR AGENT PROVIDER DEBUG ===");
println!("Command: {}", self.command);
println!("Command: {:?}", self.command);
println!("Original system prompt length: {} chars", system.len());
println!(
"Filtered system prompt length: {} chars",
@@ -252,6 +195,11 @@ impl CursorAgentProvider {
}
let mut cmd = Command::new(&self.command);
configure_command_no_window(&mut cmd);
if let Ok(path) = SearchPaths::builder().with_npm().path() {
cmd.env("PATH", path);
}
// Only pass model parameter if it's in the known models list
if CURSOR_AGENT_KNOWN_MODELS.contains(&self.model.model_name.as_str()) {
@@ -269,8 +217,8 @@ impl CursorAgentProvider {
let mut child = cmd
.spawn()
.map_err(|e| ProviderError::RequestFailed(format!(
"Failed to spawn cursor-agent CLI command '{}': {}. \
Make sure the cursor-agent CLI is installed and in your PATH, or set CURSOR_AGENT_COMMAND in your config to the correct path.",
"Failed to spawn cursor-agent CLI command '{:?}': {}. \
Make sure the cursor-agent CLI is installed and available in the configured search paths, or set CURSOR_AGENT_COMMAND in your config to the correct path.",
self.command, e
)))?;
@@ -382,11 +330,8 @@ impl Provider for CursorAgentProvider {
CURSOR_AGENT_DEFAULT_MODEL,
CURSOR_AGENT_KNOWN_MODELS.to_vec(),
CURSOR_AGENT_DOC_URL,
vec![ConfigKey::new(
"CURSOR_AGENT_COMMAND",
false,
false,
Some("cursor-agent"),
vec![ConfigKey::from_value_type::<CursorAgentCommand>(
true, false,
)],
)
}
@@ -442,19 +387,3 @@ impl Provider for CursorAgentProvider {
))
}
}
#[cfg(test)]
mod tests {
use super::ModelConfig;
use super::*;
#[tokio::test]
async fn test_cursor_agent_valid_model() {
// Test that a valid model is preserved
let valid_model = ModelConfig::new_or_fail("gpt-5");
let provider = CursorAgentProvider::from_env(valid_model).await.unwrap();
let config = provider.get_model_config();
assert_eq!(config.model_name, "gpt-5");
}
}
+21 -11
View File
@@ -24,9 +24,12 @@ use super::{
venice::VeniceProvider,
xai::XaiProvider,
};
use crate::config::declarative_providers::register_declarative_providers;
use crate::model::ModelConfig;
use crate::providers::base::ProviderType;
use crate::{
config::declarative_providers::register_declarative_providers,
providers::provider_registry::ProviderEntry,
};
use anyhow::Result;
use tokio::sync::OnceCell;
@@ -111,6 +114,15 @@ pub async fn refresh_custom_providers() -> Result<()> {
Ok(())
}
async fn get_from_registry(name: &str) -> Result<ProviderEntry> {
let guard = get_registry().await.read().unwrap();
guard
.entries
.get(name)
.ok_or_else(|| anyhow::anyhow!("Unknown provider: {}", name))
.cloned()
}
pub async fn create(name: &str, model: ModelConfig) -> Result<Arc<dyn Provider>> {
let config = crate::config::Config::global();
@@ -119,19 +131,17 @@ pub async fn create(name: &str, model: ModelConfig) -> Result<Arc<dyn Provider>>
return create_lead_worker_from_env(name, &model, &lead_model_name).await;
}
let registry = get_registry().await;
let constructor = {
let guard = registry.read().unwrap();
guard
.entries
.get(name)
.ok_or_else(|| anyhow::anyhow!("Unknown provider: {}", name))?
.constructor
.clone()
};
let constructor = get_from_registry(name).await?.constructor.clone();
constructor(model).await
}
pub async fn create_with_default_model(name: impl AsRef<str>) -> Result<Arc<dyn Provider>> {
get_from_registry(name.as_ref())
.await?
.create_with_default_model()
.await
}
pub async fn create_with_named_model(
provider_name: &str,
model_name: &str,
+19 -96
View File
@@ -1,6 +1,7 @@
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
@@ -9,9 +10,13 @@ use tokio::process::Command;
use super::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::utils::{filter_extensions_from_system_prompt, RequestLog};
use crate::config::base::GeminiCliCommand;
use crate::config::search_path::SearchPaths;
use crate::config::Config;
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::base::ConfigKey;
use crate::subprocess::configure_command_no_window;
use rmcp::model::Role;
use rmcp::model::Tool;
@@ -22,7 +27,7 @@ pub const GEMINI_CLI_DOC_URL: &str = "https://ai.google.dev/gemini-api/docs";
#[derive(Debug, serde::Serialize)]
pub struct GeminiCliProvider {
command: String,
command: PathBuf,
model: ModelConfig,
#[serde(skip)]
name: String,
@@ -30,16 +35,9 @@ pub struct GeminiCliProvider {
impl GeminiCliProvider {
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = crate::config::Config::global();
let command: String = config
.get_param("GEMINI_CLI_COMMAND")
.unwrap_or_else(|_| "gemini".to_string());
let resolved_command = if !command.contains('/') {
Self::find_gemini_executable(&command).unwrap_or(command)
} else {
command
};
let config = Config::global();
let command: OsString = config.get_gemini_cli_command().unwrap_or_default().into();
let resolved_command = SearchPaths::builder().with_npm().resolve(command)?;
Ok(Self {
command: resolved_command,
@@ -48,61 +46,6 @@ impl GeminiCliProvider {
})
}
/// Search for gemini executable in common installation locations
fn find_gemini_executable(command_name: &str) -> Option<String> {
let home = std::env::var("HOME").ok()?;
// Common locations where gemini might be installed
let search_paths = vec![
format!("{}/.gemini/local/{}", home, command_name),
format!("{}/.local/bin/{}", home, command_name),
format!("{}/bin/{}", home, command_name),
format!("/usr/local/bin/{}", command_name),
format!("/usr/bin/{}", command_name),
format!("/opt/gemini/{}", command_name),
format!("/opt/google/{}", command_name),
];
for path in search_paths {
let path_buf = PathBuf::from(&path);
if path_buf.exists() && path_buf.is_file() {
// Check if it's executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(&path_buf) {
let permissions = metadata.permissions();
if permissions.mode() & 0o111 != 0 {
tracing::info!("Found gemini executable at: {}", path);
return Some(path);
}
}
}
#[cfg(not(unix))]
{
// On non-Unix systems, just check if file exists
tracing::info!("Found gemini executable at: {}", path);
return Some(path);
}
}
}
// If not found in common locations, check if it's in PATH
if let Ok(path_var) = std::env::var("PATH") {
for dir in path_var.split(':') {
let full_path = format!("{}/{}", dir, command_name);
let path_buf = PathBuf::from(&full_path);
if path_buf.exists() && path_buf.is_file() {
tracing::info!("Found gemini executable in PATH at: {}", full_path);
return Some(full_path);
}
}
}
tracing::warn!("Could not find gemini executable in common locations");
None
}
/// Execute gemini CLI command with simple text prompt
async fn execute_command(
&self,
@@ -138,12 +81,17 @@ impl GeminiCliProvider {
if std::env::var("GOOSE_GEMINI_CLI_DEBUG").is_ok() {
println!("=== GEMINI CLI PROVIDER DEBUG ===");
println!("Command: {}", self.command);
println!("Command: {:?}", self.command);
println!("Full prompt: {}", full_prompt);
println!("================================");
}
let mut cmd = Command::new(&self.command);
configure_command_no_window(&mut cmd);
if let Ok(path) = SearchPaths::builder().with_npm().path() {
cmd.env("PATH", path);
}
// Only pass model parameter if it's in the known models list
if GEMINI_CLI_KNOWN_MODELS.contains(&self.model.model_name.as_str()) {
@@ -156,8 +104,8 @@ impl GeminiCliProvider {
let mut child = cmd.spawn().map_err(|e| {
ProviderError::RequestFailed(format!(
"Failed to spawn Gemini CLI command '{}': {}. \
Make sure the Gemini CLI is installed and in your PATH.",
"Failed to spawn Gemini CLI command '{:?}': {}. \
Make sure the Gemini CLI is installed and available in the configured search paths.",
self.command, e
))
})?;
@@ -287,7 +235,7 @@ impl Provider for GeminiCliProvider {
GEMINI_CLI_DEFAULT_MODEL,
GEMINI_CLI_KNOWN_MODELS.to_vec(),
GEMINI_CLI_DOC_URL,
vec![], // No configuration needed
vec![ConfigKey::from_value_type::<GeminiCliCommand>(true, false)],
)
}
@@ -347,28 +295,3 @@ impl Provider for GeminiCliProvider {
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_gemini_cli_invalid_model_no_fallback() {
// Test that an invalid model is kept as-is (no fallback)
let invalid_model = ModelConfig::new_or_fail("invalid-model");
let provider = GeminiCliProvider::from_env(invalid_model).await.unwrap();
let config = provider.get_model_config();
assert_eq!(config.model_name, "invalid-model");
}
#[tokio::test]
async fn test_gemini_cli_valid_model() {
// Test that a valid model is preserved
let valid_model = ModelConfig::new_or_fail(GEMINI_CLI_DEFAULT_MODEL);
let provider = GeminiCliProvider::from_env(valid_model).await.unwrap();
let config = provider.get_model_config();
assert_eq!(config.model_name, GEMINI_CLI_DEFAULT_MODEL);
}
}
+4 -1
View File
@@ -24,6 +24,7 @@ pub mod openai;
pub mod openrouter;
pub mod pricing;
pub mod provider_registry;
pub mod provider_test;
mod retry;
pub mod sagemaker_tgi;
pub mod snowflake;
@@ -36,4 +37,6 @@ pub mod utils_universal_openai_stream;
pub mod venice;
pub mod xai;
pub use factory::{create, create_with_named_model, providers, refresh_custom_providers};
pub use factory::{
create, create_with_default_model, create_with_named_model, providers, refresh_custom_providers,
};
@@ -9,12 +9,21 @@ use std::sync::Arc;
type ProviderConstructor =
Arc<dyn Fn(ModelConfig) -> BoxFuture<'static, Result<Arc<dyn Provider>>> + Send + Sync>;
#[derive(Clone)]
pub struct ProviderEntry {
metadata: ProviderMetadata,
pub(crate) constructor: ProviderConstructor,
provider_type: ProviderType,
}
impl ProviderEntry {
pub async fn create_with_default_model(&self) -> Result<Arc<dyn Provider>> {
let default_model = &self.metadata.default_model;
let model_config = ModelConfig::new(default_model.as_str())?;
(self.constructor)(model_config).await
}
}
#[derive(Default)]
pub struct ProviderRegistry {
pub(crate) entries: HashMap<String, ProviderEntry>,
@@ -0,0 +1,58 @@
use crate::{conversation::message::Message, model::ModelConfig, providers::create};
use anyhow::Result;
use rmcp::model::ToolAnnotations;
use rmcp::{model::Tool, object};
pub async fn test_provider_configuration(
provider_name: &str,
model: &str,
toolshim_enabled: bool,
toolshim_model: Option<String>,
) -> Result<()> {
let model_config = ModelConfig::new(model)?
.with_max_tokens(Some(50))
.with_toolshim(toolshim_enabled)
.with_toolshim_model(toolshim_model);
let provider = create(provider_name, model_config).await?;
let messages =
vec![Message::user().with_text("What is the weather like in San Francisco today?")];
let tools = if !toolshim_enabled {
vec![create_sample_weather_tool()]
} else {
vec![]
};
let _result = provider
.complete(
"You are an AI agent called goose. You use tools of connected extensions to solve problems.",
&messages,
&tools.into_iter().collect::<Vec<_>>()
)
.await?;
Ok(())
}
fn create_sample_weather_tool() -> Tool {
Tool::new(
"get_weather".to_string(),
"Get current temperature for a given location.".to_string(),
object!({
"type": "object",
"required": ["location"],
"properties": {
"location": {"type": "string"}
}
}),
)
.annotate(ToolAnnotations {
title: Some("Get weather".to_string()),
read_only_hint: Some(true),
destructive_hint: Some(false),
idempotent_hint: Some(false),
open_world_hint: Some(false),
})
}
+10
View File
@@ -0,0 +1,10 @@
use tokio::process::Command;
#[cfg(windows)]
const CREATE_NO_WINDOW_FLAG: u32 = 0x08000000;
#[allow(unused_variables)]
pub fn configure_command_no_window(command: &mut Command) {
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW_FLAG);
}