feat: V1.0 (#734)
Co-authored-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: Wendy Tang <wendytang@squareup.com> Co-authored-by: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Co-authored-by: Alex Hancock <alex.hancock@example.com> Co-authored-by: Alex Hancock <alexhancock@block.xyz> Co-authored-by: Lifei Zhou <lifei@squareup.com> Co-authored-by: Wes <141185334+wesrblock@users.noreply.github.com> Co-authored-by: Max Novich <maksymstepanenko1990@gmail.com> Co-authored-by: Zaki Ali <zaki@squareup.com> Co-authored-by: Salman Mohammed <smohammed@squareup.com> Co-authored-by: Kalvin C <kalvinnchau@users.noreply.github.com> Co-authored-by: Alec Thomas <alec@swapoff.org> Co-authored-by: lily-de <119957291+lily-de@users.noreply.github.com> Co-authored-by: kalvinnchau <kalvin@block.xyz> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Rizel Scarlett <rizel@squareup.com> Co-authored-by: bwrage <bwrage@squareup.com> Co-authored-by: Kalvin Chau <kalvin@squareup.com> Co-authored-by: Alice Hau <110418948+ahau-square@users.noreply.github.com> Co-authored-by: Alistair Gray <ajgray@stripe.com> Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com> Co-authored-by: Alex Hancock <alexhancock@squareup.com> Co-authored-by: Nahiyan Khan <nahiyan@squareup.com> Co-authored-by: marcelle <1852848+laanak08@users.noreply.github.com> Co-authored-by: Yingjie He <yingjiehe@block.xyz> Co-authored-by: Yingjie He <yingjiehe@squareup.com> Co-authored-by: Lily Delalande <ldelalande@block.xyz> Co-authored-by: Adewale Abati <acekyd01@gmail.com> Co-authored-by: Ebony Louis <ebony774@gmail.com> Co-authored-by: Angie Jones <jones.angie@gmail.com> Co-authored-by: Ebony Louis <55366651+EbonyLouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use goose::agents::AgentFactory;
|
||||
use std::fmt::Write;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct AgentCommand {}
|
||||
|
||||
impl AgentCommand {
|
||||
pub fn run(&self) -> Result<()> {
|
||||
let mut output = String::new();
|
||||
writeln!(output, "Available agent versions:")?;
|
||||
|
||||
let versions = AgentFactory::available_versions();
|
||||
let default_version = AgentFactory::default_version();
|
||||
|
||||
for version in versions {
|
||||
if version == default_version {
|
||||
writeln!(output, "* {} (default)", version)?;
|
||||
} else {
|
||||
writeln!(output, " {}", version)?;
|
||||
}
|
||||
}
|
||||
|
||||
print!("{}", output);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
use cliclack::spinner;
|
||||
use console::style;
|
||||
use goose::agents::{extension::Envs, ExtensionConfig};
|
||||
use goose::config::{Config, ExtensionEntry, ExtensionManager};
|
||||
use goose::message::Message;
|
||||
use goose::providers::{create, providers};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
|
||||
pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
|
||||
let config = Config::global();
|
||||
|
||||
if !config.exists() {
|
||||
// First time setup flow
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
style("Welcome to goose! Let's get you set up with a provider.").dim()
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
style(" you can rerun this command later to update your configuration").dim()
|
||||
);
|
||||
println!();
|
||||
cliclack::intro(style(" goose-configure ").on_cyan().black())?;
|
||||
if configure_provider_dialog().await? {
|
||||
println!(
|
||||
"\n {}: Run '{}' again to adjust your config or add extensions",
|
||||
style("Tip").green().italic(),
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
// Since we are setting up for the first time, we'll also enable the developer system
|
||||
ExtensionManager::set(ExtensionEntry {
|
||||
enabled: true,
|
||||
config: ExtensionConfig::Builtin {
|
||||
name: "developer".to_string(),
|
||||
},
|
||||
})?;
|
||||
} else {
|
||||
let _ = config.clear();
|
||||
println!(
|
||||
"\n {}: We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect",
|
||||
style("Warning").yellow().italic(),
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
style("This will update your existing config file").dim()
|
||||
);
|
||||
println!(
|
||||
"{} {}",
|
||||
style(" if you prefer, you can edit it directly at").dim(),
|
||||
config.path()
|
||||
);
|
||||
println!();
|
||||
|
||||
cliclack::intro(style(" goose-configure ").on_cyan().black())?;
|
||||
let action = cliclack::select("What would you like to configure?")
|
||||
.item(
|
||||
"providers",
|
||||
"Configure Providers",
|
||||
"Change provider or update credentials",
|
||||
)
|
||||
.item(
|
||||
"toggle",
|
||||
"Toggle Extensions",
|
||||
"Enable or disable connected extensions",
|
||||
)
|
||||
.item("add", "Add Extension", "Connect to a new extension")
|
||||
.interact()?;
|
||||
|
||||
match action {
|
||||
"toggle" => toggle_extensions_dialog(),
|
||||
"add" => configure_extensions_dialog(),
|
||||
"providers" => configure_provider_dialog().await.and(Ok(())),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog for configuring the AI provider and model
|
||||
pub async fn configure_provider_dialog() -> Result<bool, Box<dyn Error>> {
|
||||
// Get global config instance
|
||||
let config = Config::global();
|
||||
|
||||
// Get all available providers and their metadata
|
||||
let available_providers = providers();
|
||||
|
||||
// Create selection items from provider metadata
|
||||
let provider_items: Vec<(&String, &str, &str)> = available_providers
|
||||
.iter()
|
||||
.map(|p| (&p.name, p.display_name.as_str(), p.description.as_str()))
|
||||
.collect();
|
||||
|
||||
// Get current default provider if it exists
|
||||
let current_provider: Option<String> = config.get("GOOSE_PROVIDER").ok();
|
||||
let default_provider = current_provider.unwrap_or_default();
|
||||
|
||||
// Select provider
|
||||
let provider_name = cliclack::select("Which model provider should we use?")
|
||||
.initial_value(&default_provider)
|
||||
.items(&provider_items)
|
||||
.interact()?;
|
||||
|
||||
// Get the selected provider's metadata
|
||||
let provider_meta = available_providers
|
||||
.iter()
|
||||
.find(|p| &p.name == provider_name)
|
||||
.expect("Selected provider must exist in metadata");
|
||||
|
||||
// Configure required provider keys
|
||||
for key in &provider_meta.config_keys {
|
||||
if !key.required {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First check if the value is set via environment variable
|
||||
let from_env = std::env::var(&key.name).ok();
|
||||
|
||||
match from_env {
|
||||
Some(env_value) => {
|
||||
let _ =
|
||||
cliclack::log::info(format!("{} is set via environment variable", key.name));
|
||||
if cliclack::confirm("Would you like to save this value to your config file?")
|
||||
.initial_value(true)
|
||||
.interact()?
|
||||
{
|
||||
if key.secret {
|
||||
config.set_secret(&key.name, Value::String(env_value))?;
|
||||
} else {
|
||||
config.set(&key.name, Value::String(env_value))?;
|
||||
}
|
||||
let _ = cliclack::log::info(format!("Saved {} to config file", key.name));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// No env var, check config/secret storage
|
||||
let existing: Result<String, _> = if key.secret {
|
||||
config.get_secret(&key.name)
|
||||
} else {
|
||||
config.get(&key.name)
|
||||
};
|
||||
|
||||
match existing {
|
||||
Ok(_) => {
|
||||
let _ = cliclack::log::info(format!("{} is already configured", key.name));
|
||||
if cliclack::confirm("Would you like to update this value?").interact()? {
|
||||
let new_value: String = if key.secret {
|
||||
cliclack::password(format!("Enter new value for {}", key.name))
|
||||
.mask('▪')
|
||||
.interact()?
|
||||
} else {
|
||||
cliclack::input(format!("Enter new value for {}", key.name))
|
||||
.interact()?
|
||||
};
|
||||
|
||||
if key.secret {
|
||||
config.set_secret(&key.name, Value::String(new_value))?;
|
||||
} else {
|
||||
config.set(&key.name, Value::String(new_value))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let value: String = if key.secret {
|
||||
cliclack::password(format!(
|
||||
"Provider {} requires {}, please enter a value",
|
||||
provider_meta.display_name, key.name
|
||||
))
|
||||
.mask('▪')
|
||||
.interact()?
|
||||
} else {
|
||||
cliclack::input(format!(
|
||||
"Provider {} requires {}, please enter a value",
|
||||
provider_meta.display_name, key.name
|
||||
))
|
||||
.interact()?
|
||||
};
|
||||
|
||||
if key.secret {
|
||||
config.set_secret(&key.name, Value::String(value))?;
|
||||
} else {
|
||||
config.set(&key.name, Value::String(value))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select model, defaulting to the provider's recommended model
|
||||
let default_model = config
|
||||
.get("GOOSE_MODEL")
|
||||
.unwrap_or(provider_meta.default_model.clone());
|
||||
let model: String = cliclack::input("Enter a model from that provider:")
|
||||
.default_input(&default_model)
|
||||
.interact()?;
|
||||
|
||||
// Update config with new values
|
||||
config.set("GOOSE_PROVIDER", Value::String(provider_name.to_string()))?;
|
||||
config.set("GOOSE_MODEL", Value::String(model.clone()))?;
|
||||
|
||||
// Test the configuration
|
||||
let spin = spinner();
|
||||
spin.start("Checking your configuration...");
|
||||
|
||||
let model_config = goose::model::ModelConfig::new(model.clone());
|
||||
let provider = create(provider_name, model_config)?;
|
||||
|
||||
let message = Message::user().with_text(
|
||||
"Please give a nice welcome message (one sentence) and let them know they are all set to use this agent"
|
||||
);
|
||||
|
||||
let result = provider
|
||||
.complete(
|
||||
"You are an AI agent called Goose. You use tools of connected extensions to solve problems.",
|
||||
&[message],
|
||||
&[]
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok((message, _usage)) => {
|
||||
if let Some(content) = message.content.first() {
|
||||
if let Some(text) = content.as_text() {
|
||||
spin.stop(text);
|
||||
} else {
|
||||
spin.stop("No response text available");
|
||||
}
|
||||
} else {
|
||||
spin.stop("No response content available");
|
||||
}
|
||||
|
||||
cliclack::outro("Configuration saved successfully")?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{:?}", e);
|
||||
spin.stop("We could not connect!");
|
||||
let _ = cliclack::outro("The provider configuration was invalid");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure extensions that can be used with goose
|
||||
/// Dialog for toggling which extensions are enabled/disabled
|
||||
pub fn toggle_extensions_dialog() -> Result<(), Box<dyn Error>> {
|
||||
let extensions = ExtensionManager::get_all()?;
|
||||
|
||||
if extensions.is_empty() {
|
||||
cliclack::outro(
|
||||
"No extensions configured yet. Run configure and add some extensions first.",
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create a list of extension names and their enabled status
|
||||
let extension_status: Vec<(String, bool)> = extensions
|
||||
.iter()
|
||||
.map(|entry| (entry.config.name().to_string(), entry.enabled))
|
||||
.collect();
|
||||
|
||||
// Get currently enabled extensions for the selection
|
||||
let enabled_extensions: Vec<&String> = extension_status
|
||||
.iter()
|
||||
.filter(|(_, enabled)| *enabled)
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
|
||||
// Let user toggle extensions
|
||||
let selected = cliclack::multiselect(
|
||||
"enable extensions: (use \"space\" to toggle and \"enter\" to submit)",
|
||||
)
|
||||
.required(false)
|
||||
.items(
|
||||
&extension_status
|
||||
.iter()
|
||||
.map(|(name, _)| (name, name.as_str(), ""))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.initial_values(enabled_extensions)
|
||||
.interact()?;
|
||||
|
||||
// Update enabled status for each extension
|
||||
for name in extension_status.iter().map(|(name, _)| name) {
|
||||
ExtensionManager::set_enabled(name, selected.iter().any(|s| s.as_str() == name))?;
|
||||
}
|
||||
|
||||
cliclack::outro("Extension settings updated successfully")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
|
||||
let extension_type = cliclack::select("What type of extension would you like to add?")
|
||||
.item(
|
||||
"built-in",
|
||||
"Built-in Extension",
|
||||
"Use an extension that comes with Goose",
|
||||
)
|
||||
.item(
|
||||
"stdio",
|
||||
"Command-line Extension",
|
||||
"Run a local command or script",
|
||||
)
|
||||
.item(
|
||||
"sse",
|
||||
"Remote Extension",
|
||||
"Connect to a remote extension via SSE",
|
||||
)
|
||||
.interact()?;
|
||||
|
||||
match extension_type {
|
||||
// TODO we'll want a place to collect all these options, maybe just an enum in goose-mcp
|
||||
"built-in" => {
|
||||
let extension = cliclack::select("Which built-in extension would you like to enable?")
|
||||
.item(
|
||||
"developer",
|
||||
"Developer Tools",
|
||||
"Code editing and shell access",
|
||||
)
|
||||
.item(
|
||||
"nondeveloper",
|
||||
"Non Developer",
|
||||
"AI driven scripting for non developers",
|
||||
)
|
||||
.item(
|
||||
"google_drive",
|
||||
"Google Drive",
|
||||
"Search and read content from google drive - additional config required",
|
||||
)
|
||||
.item(
|
||||
"memory",
|
||||
"Memory",
|
||||
"Tools to save and retrieve durable memories",
|
||||
)
|
||||
.item("jetbrains", "JetBrains", "Connect to jetbrains IDEs")
|
||||
.interact()?
|
||||
.to_string();
|
||||
|
||||
ExtensionManager::set(ExtensionEntry {
|
||||
enabled: true,
|
||||
config: ExtensionConfig::Builtin {
|
||||
name: extension.clone(),
|
||||
},
|
||||
})?;
|
||||
|
||||
cliclack::outro(format!("Enabled {} extension", style(extension).green()))?;
|
||||
}
|
||||
"stdio" => {
|
||||
let extensions = ExtensionManager::get_all_names()?;
|
||||
let name: String = cliclack::input("What would you like to call this extension?")
|
||||
.placeholder("my-extension")
|
||||
.validate(move |input: &String| {
|
||||
if input.is_empty() {
|
||||
Err("Please enter a name")
|
||||
} else if extensions.contains(input) {
|
||||
Err("An extension with this name already exists")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.interact()?;
|
||||
|
||||
let command_str: String = cliclack::input("What command should be run?")
|
||||
.placeholder("npx -y @block/gdrive")
|
||||
.validate(|input: &String| {
|
||||
if input.is_empty() {
|
||||
Err("Please enter a command")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.interact()?;
|
||||
|
||||
// Split the command string into command and args
|
||||
let mut parts = command_str.split_whitespace();
|
||||
let cmd = parts.next().unwrap_or("").to_string();
|
||||
let args: Vec<String> = parts.map(String::from).collect();
|
||||
|
||||
let add_env =
|
||||
cliclack::confirm("Would you like to add environment variables?").interact()?;
|
||||
|
||||
let mut envs = HashMap::new();
|
||||
if add_env {
|
||||
loop {
|
||||
let key: String = cliclack::input("Environment variable name:")
|
||||
.placeholder("API_KEY")
|
||||
.interact()?;
|
||||
|
||||
let value: String = cliclack::password("Environment variable value:")
|
||||
.mask('▪')
|
||||
.interact()?;
|
||||
|
||||
envs.insert(key, value);
|
||||
|
||||
if !cliclack::confirm("Add another environment variable?").interact()? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExtensionManager::set(ExtensionEntry {
|
||||
enabled: true,
|
||||
config: ExtensionConfig::Stdio {
|
||||
name: name.clone(),
|
||||
cmd,
|
||||
args,
|
||||
envs: Envs::new(envs),
|
||||
},
|
||||
})?;
|
||||
|
||||
cliclack::outro(format!("Added {} extension", style(name).green()))?;
|
||||
}
|
||||
"sse" => {
|
||||
let extensions = ExtensionManager::get_all_names()?;
|
||||
let name: String = cliclack::input("What would you like to call this extension?")
|
||||
.placeholder("my-remote-extension")
|
||||
.validate(move |input: &String| {
|
||||
if input.is_empty() {
|
||||
Err("Please enter a name")
|
||||
} else if extensions.contains(input) {
|
||||
Err("An extension with this name already exists")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.interact()?;
|
||||
|
||||
let uri: String = cliclack::input("What is the SSE endpoint URI?")
|
||||
.placeholder("http://localhost:8000/events")
|
||||
.validate(|input: &String| {
|
||||
if input.is_empty() {
|
||||
Err("Please enter a URI")
|
||||
} else if !input.starts_with("http") {
|
||||
Err("URI should start with http:// or https://")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.interact()?;
|
||||
|
||||
let add_env =
|
||||
cliclack::confirm("Would you like to add environment variables?").interact()?;
|
||||
|
||||
let mut envs = HashMap::new();
|
||||
if add_env {
|
||||
loop {
|
||||
let key: String = cliclack::input("Environment variable name:")
|
||||
.placeholder("API_KEY")
|
||||
.interact()?;
|
||||
|
||||
let value: String = cliclack::password("Environment variable value:")
|
||||
.mask('▪')
|
||||
.interact()?;
|
||||
|
||||
envs.insert(key, value);
|
||||
|
||||
if !cliclack::confirm("Add another environment variable?").interact()? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExtensionManager::set(ExtensionEntry {
|
||||
enabled: true,
|
||||
config: ExtensionConfig::Sse {
|
||||
name: name.clone(),
|
||||
uri,
|
||||
envs: Envs::new(envs),
|
||||
},
|
||||
})?;
|
||||
|
||||
cliclack::outro(format!("Added {} extension", style(name).green()))?;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use anyhow::Result;
|
||||
use goose_mcp::{
|
||||
DeveloperRouter, GoogleDriveRouter, JetBrainsRouter, MemoryRouter, NonDeveloperRouter,
|
||||
};
|
||||
use mcp_server::router::RouterService;
|
||||
use mcp_server::{BoundedService, ByteTransport, Server};
|
||||
use tokio::io::{stdin, stdout};
|
||||
|
||||
pub async fn run_server(name: &str) -> Result<()> {
|
||||
// Initialize logging
|
||||
crate::logging::setup_logging(Some(&format!("mcp-{name}")))?;
|
||||
|
||||
tracing::info!("Starting MCP server");
|
||||
|
||||
let router: Option<Box<dyn BoundedService>> = match name {
|
||||
"developer" => Some(Box::new(RouterService(DeveloperRouter::new()))),
|
||||
"nondeveloper" => Some(Box::new(RouterService(NonDeveloperRouter::new()))),
|
||||
"jetbrains" => Some(Box::new(RouterService(JetBrainsRouter::new()))),
|
||||
"google_drive" => {
|
||||
let router = GoogleDriveRouter::new().await;
|
||||
Some(Box::new(RouterService(router)))
|
||||
}
|
||||
"memory" => Some(Box::new(RouterService(MemoryRouter::new()))),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Create and run the server
|
||||
let server = Server::new(router.unwrap_or_else(|| panic!("Unknown server requested {}", name)));
|
||||
let transport = ByteTransport::new(stdin(), stdout());
|
||||
|
||||
tracing::info!("Server initialized and ready to handle requests");
|
||||
Ok(server.run(transport).await?)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod agent_version;
|
||||
pub mod configure;
|
||||
pub mod mcp;
|
||||
pub mod session;
|
||||
pub mod version;
|
||||
@@ -0,0 +1,178 @@
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use std::process;
|
||||
|
||||
use crate::prompt::rustyline::RustylinePrompt;
|
||||
use crate::session::{ensure_session_dir, get_most_recent_session, Session};
|
||||
use console::style;
|
||||
use goose::agents::extension::{Envs, ExtensionError};
|
||||
use goose::agents::AgentFactory;
|
||||
use goose::config::{Config, ExtensionConfig, ExtensionManager};
|
||||
use goose::providers::create;
|
||||
use std::path::Path;
|
||||
|
||||
use mcp_client::transport::Error as McpClientError;
|
||||
|
||||
pub async fn build_session(
|
||||
name: Option<String>,
|
||||
resume: bool,
|
||||
extension: Option<String>,
|
||||
builtin: Option<String>,
|
||||
) -> Session<'static> {
|
||||
// Load config and get provider/model
|
||||
let config = Config::global();
|
||||
|
||||
let provider_name: String = config
|
||||
.get("GOOSE_PROVIDER")
|
||||
.expect("No provider configured. Run 'goose configure' first");
|
||||
let session_dir = ensure_session_dir().expect("Failed to create session directory");
|
||||
|
||||
let model: String = config
|
||||
.get("GOOSE_MODEL")
|
||||
.expect("No model configured. Run 'goose configure' first");
|
||||
let model_config = goose::model::ModelConfig::new(model.clone());
|
||||
let provider = create(&provider_name, model_config).expect("Failed to create provider");
|
||||
|
||||
// Create the agent
|
||||
let agent_version: Option<String> = config.get("GOOSE_AGENT").ok();
|
||||
let mut agent = match agent_version {
|
||||
Some(version) => AgentFactory::create(&version, provider),
|
||||
None => AgentFactory::create(AgentFactory::default_version(), provider),
|
||||
}
|
||||
.expect("Failed to create agent");
|
||||
|
||||
// Setup extensions for the agent
|
||||
for extension in ExtensionManager::get_all().expect("should load extensions") {
|
||||
if extension.enabled {
|
||||
let config = extension.config.clone();
|
||||
agent
|
||||
.add_extension(config.clone())
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
let err = match e {
|
||||
ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => {
|
||||
inner
|
||||
}
|
||||
_ => e.to_string(),
|
||||
};
|
||||
println!("Failed to start extension: {}, {:?}", config.name(), err);
|
||||
println!(
|
||||
"Please check extension configuration for {}.",
|
||||
config.name()
|
||||
);
|
||||
process::exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add extension if provided
|
||||
if let Some(extension_str) = extension {
|
||||
let mut parts: Vec<&str> = extension_str.split_whitespace().collect();
|
||||
let mut envs = std::collections::HashMap::new();
|
||||
|
||||
// Parse environment variables (format: KEY=value)
|
||||
while let Some(part) = parts.first() {
|
||||
if !part.contains('=') {
|
||||
break;
|
||||
}
|
||||
let env_part = parts.remove(0);
|
||||
let (key, value) = env_part.split_once('=').unwrap();
|
||||
envs.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
eprintln!("No command provided in extension string");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let cmd = parts.remove(0).to_string();
|
||||
//this is an ephemeral extension so name does not matter
|
||||
let name = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(8)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
let config = ExtensionConfig::Stdio {
|
||||
name,
|
||||
cmd,
|
||||
args: parts.iter().map(|s| s.to_string()).collect(),
|
||||
envs: Envs::new(envs),
|
||||
};
|
||||
|
||||
agent.add_extension(config).await.unwrap_or_else(|e| {
|
||||
eprintln!("Failed to start extension: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Add builtin extension if provided
|
||||
if let Some(name) = builtin {
|
||||
let config = ExtensionConfig::Builtin { name };
|
||||
agent.add_extension(config).await.unwrap_or_else(|e| {
|
||||
eprintln!("Failed to start builtin extension: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// If resuming, try to find the session
|
||||
if resume {
|
||||
if let Some(ref session_name) = name {
|
||||
// Try to resume specific session
|
||||
let session_file = session_dir.join(format!("{}.jsonl", session_name));
|
||||
if session_file.exists() {
|
||||
let prompt = Box::new(RustylinePrompt::new());
|
||||
return Session::new(agent, prompt, session_file);
|
||||
} else {
|
||||
eprintln!("Session '{}' not found, starting new session", session_name);
|
||||
}
|
||||
} else {
|
||||
// Try to resume most recent session
|
||||
if let Ok(session_file) = get_most_recent_session() {
|
||||
let prompt = Box::new(RustylinePrompt::new());
|
||||
return Session::new(agent, prompt, session_file);
|
||||
} else {
|
||||
eprintln!("No previous sessions found, starting new session");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate session name if not provided
|
||||
let name = name.unwrap_or_else(|| {
|
||||
rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(8)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
});
|
||||
|
||||
let session_file = session_dir.join(format!("{}.jsonl", name));
|
||||
if session_file.exists() {
|
||||
eprintln!("Session '{}' already exists", name);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let prompt = Box::new(RustylinePrompt::new());
|
||||
|
||||
display_session_info(resume, &provider_name, &model, &session_file);
|
||||
Session::new(agent, prompt, session_file)
|
||||
}
|
||||
|
||||
fn display_session_info(resume: bool, provider: &str, model: &str, session_file: &Path) {
|
||||
let start_session_msg = if resume {
|
||||
"resuming session |"
|
||||
} else {
|
||||
"starting session |"
|
||||
};
|
||||
println!(
|
||||
"{} {} {} {} {}",
|
||||
style(start_session_msg).dim(),
|
||||
style("provider:").dim(),
|
||||
style(provider).cyan().dim(),
|
||||
style("model:").dim(),
|
||||
style(model).cyan().dim(),
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
style("logging to").dim(),
|
||||
style(session_file.display()).dim().cyan(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub fn print_version() {
|
||||
println!(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use goose::providers::base::ProviderUsage;
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct SessionLog {
|
||||
session_file: String,
|
||||
usage: Vec<ProviderUsage>,
|
||||
}
|
||||
|
||||
pub fn log_usage(session_file: String, usage: Vec<ProviderUsage>) {
|
||||
let log = SessionLog {
|
||||
session_file,
|
||||
usage,
|
||||
};
|
||||
|
||||
// Ensure log directory exists
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
let log_dir = home_dir.join(".config").join("goose").join("logs");
|
||||
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
||||
eprintln!("Failed to create log directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let log_file = log_dir.join("goose.log");
|
||||
let serialized = match serde_json::to_string(&log) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to serialize usage log: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Append to log file
|
||||
if let Err(e) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_file)
|
||||
.and_then(|mut file| {
|
||||
std::io::Write::write_all(&mut file, serialized.as_bytes())?;
|
||||
std::io::Write::write_all(&mut file, b"\n")?;
|
||||
Ok(())
|
||||
})
|
||||
{
|
||||
eprintln!("Failed to write to usage log file: {}", e);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Failed to write to usage log file: Failed to determine home directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use goose::providers::base::{ProviderUsage, Usage};
|
||||
|
||||
use crate::{
|
||||
log_usage::{log_usage, SessionLog},
|
||||
test_helpers::run_with_tmp_dir,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_session_logging() {
|
||||
run_with_tmp_dir(|| {
|
||||
let home_dir = dirs::home_dir().unwrap();
|
||||
let log_file = home_dir
|
||||
.join(".config")
|
||||
.join("goose")
|
||||
.join("logs")
|
||||
.join("goose.log");
|
||||
|
||||
log_usage(
|
||||
"path.txt".to_string(),
|
||||
vec![ProviderUsage::new(
|
||||
"model".to_string(),
|
||||
Usage::new(Some(10), Some(20), Some(30)),
|
||||
)],
|
||||
);
|
||||
|
||||
// Check if log file exists and contains the expected content
|
||||
assert!(log_file.exists(), "Log file should exist");
|
||||
|
||||
let log_content = std::fs::read_to_string(&log_file).unwrap();
|
||||
let log: SessionLog = serde_json::from_str(&log_content).unwrap();
|
||||
|
||||
assert!(log.session_file.contains("path.txt"));
|
||||
assert_eq!(log.usage[0].usage.input_tokens, Some(10));
|
||||
assert_eq!(log.usage[0].usage.output_tokens, Some(20));
|
||||
assert_eq!(log.usage[0].usage.total_tokens, Some(30));
|
||||
assert_eq!(log.usage[0].model, "model");
|
||||
|
||||
// Remove the log file after test
|
||||
std::fs::remove_file(&log_file).ok();
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tracing_appender::rolling::Rotation;
|
||||
use tracing_subscriber::{
|
||||
filter::LevelFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
|
||||
Registry,
|
||||
};
|
||||
|
||||
use goose::tracing::langfuse_layer;
|
||||
|
||||
/// Returns the directory where log files should be stored.
|
||||
/// Creates the directory structure if it doesn't exist.
|
||||
fn get_log_directory() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").context("HOME environment variable not set")?;
|
||||
let base_log_dir = PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("goose")
|
||||
.join("logs")
|
||||
.join("cli"); // Add cli-specific subdirectory
|
||||
|
||||
// Create date-based subdirectory
|
||||
let now = chrono::Local::now();
|
||||
let date_dir = base_log_dir.join(now.format("%Y-%m-%d").to_string());
|
||||
|
||||
// Ensure log directory exists
|
||||
fs::create_dir_all(&date_dir).context("Failed to create log directory")?;
|
||||
|
||||
Ok(date_dir)
|
||||
}
|
||||
|
||||
/// Sets up the logging infrastructure for the application.
|
||||
/// This includes:
|
||||
/// - File-based logging with JSON formatting (DEBUG level)
|
||||
/// - Console output for development (INFO level)
|
||||
/// - Optional Langfuse integration (DEBUG level)
|
||||
pub fn setup_logging(name: Option<&str>) -> Result<()> {
|
||||
// Set up file appender for goose module logs
|
||||
let log_dir = get_log_directory()?;
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
|
||||
// Create log file name by prefixing with timestamp
|
||||
let log_filename = if name.is_some() {
|
||||
format!("{}-{}.log", timestamp, name.unwrap())
|
||||
} else {
|
||||
format!("{}.log", timestamp)
|
||||
};
|
||||
|
||||
// Create non-rolling file appender for detailed logs
|
||||
let file_appender =
|
||||
tracing_appender::rolling::RollingFileAppender::new(Rotation::NEVER, log_dir, log_filename);
|
||||
|
||||
// Create JSON file logging layer with all logs (DEBUG and above)
|
||||
let file_layer = fmt::layer()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_writer(file_appender)
|
||||
.with_ansi(false)
|
||||
.with_file(true)
|
||||
.pretty();
|
||||
|
||||
// Create console logging layer for development - INFO and above only
|
||||
let console_layer = fmt::layer()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_ansi(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.pretty();
|
||||
|
||||
// Base filter
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
// Set default levels for different modules
|
||||
EnvFilter::new("")
|
||||
// Set mcp-server module to DEBUG
|
||||
.add_directive("mcp_server=debug".parse().unwrap())
|
||||
// Set mcp-client to DEBUG
|
||||
.add_directive("mcp_client=debug".parse().unwrap())
|
||||
// Set goose module to DEBUG
|
||||
.add_directive("goose=debug".parse().unwrap())
|
||||
// Set goose-cli to INFO
|
||||
.add_directive("goose_cli=info".parse().unwrap())
|
||||
// Set everything else to WARN
|
||||
.add_directive(LevelFilter::WARN.into())
|
||||
});
|
||||
|
||||
// Build the subscriber with required layers
|
||||
let subscriber = Registry::default()
|
||||
.with(file_layer.with_filter(env_filter)) // Gets all logs
|
||||
.with(console_layer.with_filter(LevelFilter::WARN)); // Controls log levels
|
||||
|
||||
// Initialize with Langfuse if available
|
||||
if let Some(langfuse) = langfuse_layer::create_langfuse_observer() {
|
||||
subscriber
|
||||
.with(langfuse.with_filter(LevelFilter::DEBUG))
|
||||
.try_init()
|
||||
.context("Failed to set global subscriber")?;
|
||||
} else {
|
||||
subscriber
|
||||
.try_init()
|
||||
.context("Failed to set global subscriber")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
fn setup_temp_home() -> TempDir {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
temp_dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_directory_creation() {
|
||||
let _temp_dir = setup_temp_home();
|
||||
let log_dir = get_log_directory().unwrap();
|
||||
assert!(log_dir.exists());
|
||||
assert!(log_dir.is_dir());
|
||||
|
||||
// Verify directory structure
|
||||
let path_components: Vec<_> = log_dir.components().collect();
|
||||
assert!(path_components.iter().any(|c| c.as_os_str() == "goose"));
|
||||
assert!(path_components.iter().any(|c| c.as_os_str() == "logs"));
|
||||
assert!(path_components.iter().any(|c| c.as_os_str() == "cli"));
|
||||
}
|
||||
|
||||
#[test_case(Some("test_session") ; "with session name")]
|
||||
#[test_case(None ; "without session name")]
|
||||
fn test_log_file_name(session_name: Option<&str>) {
|
||||
let _rt = Runtime::new().unwrap();
|
||||
let _temp_dir = setup_temp_home();
|
||||
|
||||
// Create a test-specific log directory and file
|
||||
let log_dir = get_log_directory().unwrap();
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let file_name = format!("{}.log", session_name.unwrap_or(×tamp));
|
||||
|
||||
// Create the log file
|
||||
let file_path = log_dir.join(&file_name);
|
||||
fs::write(&file_path, "test").unwrap();
|
||||
|
||||
// Verify the file exists and has the correct name
|
||||
let entries = fs::read_dir(log_dir).unwrap();
|
||||
let log_files: Vec<_> = entries
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| e.path().extension().map_or(false, |ext| ext == "log"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(log_files.len(), 1, "Expected exactly one log file");
|
||||
|
||||
let log_file_name = log_files[0].file_name().to_string_lossy().into_owned();
|
||||
println!("Log file name: {}", log_file_name);
|
||||
|
||||
if let Some(name) = session_name {
|
||||
assert_eq!(log_file_name, format!("{}.log", name));
|
||||
} else {
|
||||
// Extract just the filename without extension for comparison
|
||||
let name_without_ext = log_file_name.trim_end_matches(".log");
|
||||
// Verify it's a valid timestamp format
|
||||
assert_eq!(
|
||||
name_without_ext.len(),
|
||||
15,
|
||||
"Expected 15 characters (YYYYMMDD_HHMMSS)"
|
||||
);
|
||||
assert!(
|
||||
name_without_ext[8..9].contains('_'),
|
||||
"Expected underscore at position 8"
|
||||
);
|
||||
assert!(
|
||||
name_without_ext
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == '_'),
|
||||
"Expected only digits and underscore"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_langfuse_layer_creation() {
|
||||
let _temp_dir = setup_temp_home();
|
||||
|
||||
// Store original environment variables (both sets)
|
||||
let original_vars = [
|
||||
("LANGFUSE_PUBLIC_KEY", env::var("LANGFUSE_PUBLIC_KEY").ok()),
|
||||
("LANGFUSE_SECRET_KEY", env::var("LANGFUSE_SECRET_KEY").ok()),
|
||||
("LANGFUSE_HOST", env::var("LANGFUSE_HOST").ok()),
|
||||
(
|
||||
"LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
|
||||
env::var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY").ok(),
|
||||
),
|
||||
(
|
||||
"LANGFUSE_INIT_PROJECT_SECRET_KEY",
|
||||
env::var("LANGFUSE_INIT_PROJECT_SECRET_KEY").ok(),
|
||||
),
|
||||
];
|
||||
|
||||
// Clear all Langfuse environment variables
|
||||
for (var, _) in &original_vars {
|
||||
env::remove_var(var);
|
||||
}
|
||||
|
||||
// Test without any environment variables
|
||||
assert!(langfuse_layer::create_langfuse_observer().is_none());
|
||||
|
||||
// Test with standard Langfuse variables
|
||||
env::set_var("LANGFUSE_PUBLIC_KEY", "test_public_key");
|
||||
env::set_var("LANGFUSE_SECRET_KEY", "test_secret_key");
|
||||
assert!(langfuse_layer::create_langfuse_observer().is_some());
|
||||
|
||||
// Clear and test with init project variables
|
||||
env::remove_var("LANGFUSE_PUBLIC_KEY");
|
||||
env::remove_var("LANGFUSE_SECRET_KEY");
|
||||
env::set_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY", "test_public_key");
|
||||
env::set_var("LANGFUSE_INIT_PROJECT_SECRET_KEY", "test_secret_key");
|
||||
assert!(langfuse_layer::create_langfuse_observer().is_some());
|
||||
|
||||
// Test fallback behavior
|
||||
env::remove_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY");
|
||||
assert!(langfuse_layer::create_langfuse_observer().is_none());
|
||||
|
||||
// Restore original environment variables
|
||||
for (var, value) in original_vars {
|
||||
match value {
|
||||
Some(val) => env::set_var(var, val),
|
||||
None => env::remove_var(var),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use anyhow::Result;
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
|
||||
mod commands;
|
||||
mod log_usage;
|
||||
mod logging;
|
||||
mod prompt;
|
||||
mod session;
|
||||
|
||||
use commands::agent_version::AgentCommand;
|
||||
use commands::configure::handle_configure;
|
||||
use commands::mcp::run_server;
|
||||
use commands::session::build_session;
|
||||
use commands::version::print_version;
|
||||
use console::style;
|
||||
use goose::config::Config;
|
||||
use logging::setup_logging;
|
||||
use std::io::{self, Read};
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_helpers;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, about, long_about = None)]
|
||||
struct Cli {
|
||||
#[arg(short = 'v', long = "version")]
|
||||
version: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Configure Goose settings
|
||||
#[command(about = "Configure Goose settings")]
|
||||
Configure {},
|
||||
|
||||
/// Manage system prompts and behaviors
|
||||
#[command(about = "Run one of the mcp servers bundled with goose")]
|
||||
Mcp { name: String },
|
||||
|
||||
/// Start or resume interactive chat sessions
|
||||
#[command(about = "Start or resume interactive chat sessions", alias = "s")]
|
||||
Session {
|
||||
/// Name for the chat session
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "NAME",
|
||||
help = "Name for the chat session (e.g., 'project-x')",
|
||||
long_help = "Specify a name for your chat session. When used with --resume, will resume this specific session if it exists."
|
||||
)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Resume a previous session
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
help = "Resume a previous session (last used or specified by --session)",
|
||||
long_help = "Continue from a previous chat session. If --session is provided, resumes that specific session. Otherwise resumes the last used session."
|
||||
)]
|
||||
resume: bool,
|
||||
|
||||
/// Add a stdio extension with environment variables and command
|
||||
#[arg(
|
||||
long = "with-extension",
|
||||
value_name = "COMMAND",
|
||||
help = "Add a stdio extension (e.g., 'GITHUB_TOKEN=xyz npx -y @modelcontextprotocol/server-github')",
|
||||
long_help = "Add a stdio extension from a full command with environment variables. Format: 'ENV1=val1 ENV2=val2 command args...'"
|
||||
)]
|
||||
extension: Option<String>,
|
||||
|
||||
/// Add a builtin extension by name
|
||||
#[arg(
|
||||
long = "with-builtin",
|
||||
value_name = "NAME",
|
||||
help = "Add a builtin extension by name (e.g., 'developer')",
|
||||
long_help = "Add a builtin extension that is bundled with goose by specifying its name"
|
||||
)]
|
||||
builtin: Option<String>,
|
||||
},
|
||||
|
||||
/// Execute commands from an instruction file
|
||||
#[command(about = "Execute commands from an instruction file or stdin")]
|
||||
Run {
|
||||
/// Path to instruction file containing commands
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "FILE",
|
||||
help = "Path to instruction file containing commands",
|
||||
conflicts_with = "input_text"
|
||||
)]
|
||||
instructions: Option<String>,
|
||||
|
||||
/// Input text containing commands
|
||||
#[arg(
|
||||
short = 't',
|
||||
long = "text",
|
||||
value_name = "TEXT",
|
||||
help = "Input text to provide to Goose directly",
|
||||
long_help = "Input text containing commands for Goose. Use this in lieu of the instructions argument.",
|
||||
conflicts_with = "instructions"
|
||||
)]
|
||||
input_text: Option<String>,
|
||||
|
||||
/// Name for this run session
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "NAME",
|
||||
help = "Name for this run session (e.g., 'daily-tasks')",
|
||||
long_help = "Specify a name for this run session. This helps identify and resume specific runs later."
|
||||
)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Resume a previous run
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
action = clap::ArgAction::SetTrue,
|
||||
help = "Resume from a previous run",
|
||||
long_help = "Continue from a previous run, maintaining the execution state and context."
|
||||
)]
|
||||
resume: bool,
|
||||
|
||||
/// Add a stdio extension with environment variables and command
|
||||
#[arg(
|
||||
long = "with-extension",
|
||||
value_name = "COMMAND",
|
||||
help = "Add a stdio extension with environment variables and command (e.g., 'GITHUB_TOKEN=xyz npx -y @modelcontextprotocol/server-github')",
|
||||
long_help = "Add a stdio extension with environment variables and command. Format: 'ENV1=val1 ENV2=val2 command args...'"
|
||||
)]
|
||||
extension: Option<String>,
|
||||
|
||||
/// Add a builtin extension by name
|
||||
#[arg(
|
||||
long = "with-builtin",
|
||||
value_name = "NAME",
|
||||
help = "Add a builtin extension by name (e.g., 'developer')",
|
||||
long_help = "Add a builtin extension that is compiled into goose by specifying its name"
|
||||
)]
|
||||
builtin: Option<String>,
|
||||
},
|
||||
|
||||
/// List available agent versions
|
||||
Agents(AgentCommand),
|
||||
}
|
||||
|
||||
#[derive(clap::ValueEnum, Clone, Debug)]
|
||||
enum CliProviderVariant {
|
||||
OpenAi,
|
||||
Databricks,
|
||||
Ollama,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.version {
|
||||
print_version();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
Some(Command::Configure {}) => {
|
||||
let _ = handle_configure().await;
|
||||
return Ok(());
|
||||
}
|
||||
Some(Command::Mcp { name }) => {
|
||||
let _ = run_server(&name).await;
|
||||
}
|
||||
Some(Command::Session {
|
||||
name,
|
||||
resume,
|
||||
extension,
|
||||
builtin,
|
||||
}) => {
|
||||
let mut session = build_session(name, resume, extension, builtin).await;
|
||||
setup_logging(session.session_file().file_stem().and_then(|s| s.to_str()))?;
|
||||
|
||||
let _ = session.start().await;
|
||||
return Ok(());
|
||||
}
|
||||
Some(Command::Run {
|
||||
instructions,
|
||||
input_text,
|
||||
name,
|
||||
resume,
|
||||
extension,
|
||||
builtin,
|
||||
}) => {
|
||||
// Validate that we have some input source
|
||||
if instructions.is_none() && input_text.is_none() {
|
||||
eprintln!("Error: Must provide either --instructions or --text");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let contents = if let Some(file_name) = instructions {
|
||||
let file_path = std::path::Path::new(&file_name);
|
||||
std::fs::read_to_string(file_path).expect("Failed to read the instruction file")
|
||||
} else if let Some(input_text) = input_text {
|
||||
input_text
|
||||
} else {
|
||||
let mut stdin = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut stdin)
|
||||
.expect("Failed to read from stdin");
|
||||
stdin
|
||||
};
|
||||
let mut session = build_session(name, resume, extension, builtin).await;
|
||||
let _ = session.headless_start(contents.clone()).await;
|
||||
return Ok(());
|
||||
}
|
||||
Some(Command::Agents(cmd)) => {
|
||||
cmd.run()?;
|
||||
return Ok(());
|
||||
}
|
||||
None => {
|
||||
Cli::command().print_help()?;
|
||||
println!();
|
||||
if !Config::global().exists() {
|
||||
println!(
|
||||
"\n {}: Run '{}' to setup goose for the first time",
|
||||
style("Tip").green().italic(),
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use anyhow::Result;
|
||||
use goose::message::Message;
|
||||
|
||||
pub mod renderer;
|
||||
pub mod rustyline;
|
||||
pub mod thinking;
|
||||
|
||||
pub trait Prompt {
|
||||
fn render(&mut self, message: Box<Message>);
|
||||
fn get_input(&mut self) -> Result<Input>;
|
||||
fn show_busy(&mut self);
|
||||
fn hide_busy(&self);
|
||||
fn close(&self);
|
||||
/// Load the user's message history into the prompt for command history navigation. First message is the oldest message.
|
||||
/// When history is supported by the prompt.
|
||||
fn load_user_message_history(&mut self, _messages: Vec<Message>) {}
|
||||
fn goose_ready(&self) {
|
||||
println!("\n");
|
||||
println!("Goose is running! Enter your instructions, or try asking what goose can do.");
|
||||
println!("\n");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Input {
|
||||
pub input_type: InputType,
|
||||
pub content: Option<String>, // Optional content as sometimes the user may be issuing a command eg. (Exit)
|
||||
}
|
||||
|
||||
pub enum InputType {
|
||||
AskAgain, // Ask the user for input again. Control flow command.
|
||||
Message, // User sent a message
|
||||
Exit, // User wants to exit the session
|
||||
}
|
||||
|
||||
pub enum Theme {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bat::WrappingMode;
|
||||
use console::style;
|
||||
use goose::message::{Message, MessageContent, ToolRequest, ToolResponse};
|
||||
use mcp_core::role::Role;
|
||||
use mcp_core::{content::Content, tool::ToolCall};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::Theme;
|
||||
|
||||
const MAX_STRING_LENGTH: usize = 40;
|
||||
const MAX_PATH_LENGTH: usize = 60;
|
||||
const INDENT: &str = " ";
|
||||
|
||||
/// Shortens a path string by abbreviating directory names while keeping the last two components intact.
|
||||
/// If the path starts with the user's home directory, it will be replaced with ~.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// let path = "/Users/alice/Development/very/long/path/to/file.txt";
|
||||
/// assert_eq!(
|
||||
/// shorten_path(path),
|
||||
/// "~/D/v/l/p/to/file.txt"
|
||||
/// );
|
||||
/// ```
|
||||
fn shorten_path(path: &str) -> String {
|
||||
let path = PathBuf::from(path);
|
||||
|
||||
// First try to convert to ~ if it's in home directory
|
||||
let home = dirs::home_dir();
|
||||
let path_str = if let Some(home) = home {
|
||||
if let Ok(stripped) = path.strip_prefix(home) {
|
||||
format!("~/{}", stripped.display())
|
||||
} else {
|
||||
path.display().to_string()
|
||||
}
|
||||
} else {
|
||||
path.display().to_string()
|
||||
};
|
||||
|
||||
// If path is already short enough, return as is
|
||||
if path_str.len() <= MAX_PATH_LENGTH {
|
||||
return path_str;
|
||||
}
|
||||
|
||||
let parts: Vec<_> = path_str.split('/').collect();
|
||||
|
||||
// If we have 3 or fewer parts, return as is
|
||||
if parts.len() <= 3 {
|
||||
return path_str;
|
||||
}
|
||||
|
||||
// Keep the first component (empty string before root / or ~) and last two components intact
|
||||
let mut shortened = vec![parts[0].to_string()];
|
||||
|
||||
// Shorten middle components to their first letter
|
||||
for component in &parts[1..parts.len() - 2] {
|
||||
if !component.is_empty() {
|
||||
shortened.push(component.chars().next().unwrap_or('?').to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last two components
|
||||
shortened.push(parts[parts.len() - 2].to_string());
|
||||
shortened.push(parts[parts.len() - 1].to_string());
|
||||
|
||||
shortened.join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_shorten_path() {
|
||||
// Test a long path without home directory
|
||||
let long_path = "/Users/test/Development/this/is/a/very/long/nested/deeply/example.txt";
|
||||
let shortened = shorten_path(long_path);
|
||||
assert!(
|
||||
shortened.len() < long_path.len(),
|
||||
"Shortened path '{}' should be shorter than original '{}'",
|
||||
shortened,
|
||||
long_path
|
||||
);
|
||||
assert!(
|
||||
shortened.ends_with("deeply/example.txt"),
|
||||
"Shortened path '{}' should end with 'deeply/example.txt'",
|
||||
shortened
|
||||
);
|
||||
|
||||
// Test a short path (shouldn't be modified)
|
||||
assert_eq!(shorten_path("/usr/local/bin"), "/usr/local/bin");
|
||||
|
||||
// Test path with less than 3 components
|
||||
assert_eq!(shorten_path("/usr/local"), "/usr/local");
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement the ToolRenderer trait for each tool that you want to render in the prompt.
|
||||
pub trait ToolRenderer: ToolRendererClone {
|
||||
fn tool_name(&self) -> String;
|
||||
fn request(&self, tool_request: &ToolRequest, theme: &str);
|
||||
fn response(&self, tool_response: &ToolResponse, theme: &str);
|
||||
}
|
||||
|
||||
// Helper trait for cloning boxed ToolRenderer objects
|
||||
pub trait ToolRendererClone {
|
||||
fn clone_box(&self) -> Box<dyn ToolRenderer>;
|
||||
}
|
||||
|
||||
// Implement the helper trait for any type that implements ToolRenderer and Clone
|
||||
impl<T> ToolRendererClone for T
|
||||
where
|
||||
T: 'static + ToolRenderer + Clone,
|
||||
{
|
||||
fn clone_box(&self) -> Box<dyn ToolRenderer> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// Make Box<dyn ToolRenderer> clonable
|
||||
impl Clone for Box<dyn ToolRenderer> {
|
||||
fn clone(&self) -> Box<dyn ToolRenderer> {
|
||||
self.clone_box()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DefaultRenderer;
|
||||
|
||||
impl ToolRenderer for DefaultRenderer {
|
||||
fn tool_name(&self) -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
fn request(&self, tool_request: &ToolRequest, theme: &str) {
|
||||
match &tool_request.tool_call {
|
||||
Ok(call) => {
|
||||
default_print_request_header(call);
|
||||
|
||||
// Format and print the parameters
|
||||
print_params(&call.arguments, 0);
|
||||
print_newline();
|
||||
}
|
||||
Err(e) => print_markdown(&e.to_string(), theme),
|
||||
}
|
||||
}
|
||||
|
||||
fn response(&self, tool_response: &ToolResponse, theme: &str) {
|
||||
default_response_renderer(tool_response, theme);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TextEditorRenderer;
|
||||
|
||||
impl ToolRenderer for TextEditorRenderer {
|
||||
fn tool_name(&self) -> String {
|
||||
"developer__text_editor".to_string()
|
||||
}
|
||||
|
||||
fn request(&self, tool_request: &ToolRequest, theme: &str) {
|
||||
match &tool_request.tool_call {
|
||||
Ok(call) => {
|
||||
default_print_request_header(call);
|
||||
|
||||
// Print path first with special formatting
|
||||
if let Some(Value::String(path)) = call.arguments.get("path") {
|
||||
println!(
|
||||
"{}: {}",
|
||||
style("path").dim(),
|
||||
style(shorten_path(path)).green()
|
||||
);
|
||||
}
|
||||
|
||||
// Print other arguments normally, excluding path
|
||||
if let Some(args) = call.arguments.as_object() {
|
||||
let mut other_args = serde_json::Map::new();
|
||||
for (k, v) in args {
|
||||
if k != "path" {
|
||||
other_args.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
print_params(&Value::Object(other_args), 0);
|
||||
}
|
||||
print_newline();
|
||||
}
|
||||
Err(e) => print_markdown(&e.to_string(), theme),
|
||||
}
|
||||
}
|
||||
|
||||
fn response(&self, tool_response: &ToolResponse, theme: &str) {
|
||||
default_response_renderer(tool_response, theme);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BashDeveloperExtensionRenderer;
|
||||
|
||||
impl ToolRenderer for BashDeveloperExtensionRenderer {
|
||||
fn tool_name(&self) -> String {
|
||||
"developer__shell".to_string()
|
||||
}
|
||||
|
||||
fn request(&self, tool_request: &ToolRequest, theme: &str) {
|
||||
match &tool_request.tool_call {
|
||||
Ok(call) => {
|
||||
default_print_request_header(call);
|
||||
|
||||
match call.arguments.get("command") {
|
||||
Some(Value::String(s)) => {
|
||||
println!("{}: {}", style("command").dim(), style(s).green());
|
||||
}
|
||||
_ => print_params(&call.arguments, 0),
|
||||
}
|
||||
print_newline();
|
||||
}
|
||||
Err(e) => print_markdown(&e.to_string(), theme),
|
||||
}
|
||||
}
|
||||
|
||||
fn response(&self, tool_response: &ToolResponse, theme: &str) {
|
||||
default_response_renderer(tool_response, theme);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(message: &Message, theme: &Theme, renderers: HashMap<String, Box<dyn ToolRenderer>>) {
|
||||
let theme = match theme {
|
||||
Theme::Light => "GitHub",
|
||||
Theme::Dark => "zenburn",
|
||||
};
|
||||
|
||||
let mut last_tool_name: &str = "default";
|
||||
for message_content in &message.content {
|
||||
match message_content {
|
||||
MessageContent::Text(text) => print_markdown(&text.text, theme),
|
||||
MessageContent::ToolRequest(tool_request) => match &tool_request.tool_call {
|
||||
Ok(call) => {
|
||||
last_tool_name = &call.name;
|
||||
renderers
|
||||
.get(&call.name)
|
||||
.or_else(|| renderers.get("default"))
|
||||
.unwrap()
|
||||
.request(tool_request, theme);
|
||||
}
|
||||
Err(_) => renderers
|
||||
.get("default")
|
||||
.unwrap()
|
||||
.request(tool_request, theme),
|
||||
},
|
||||
MessageContent::ToolResponse(tool_response) => renderers
|
||||
.get(last_tool_name)
|
||||
.or_else(|| renderers.get("default"))
|
||||
.unwrap()
|
||||
.response(tool_response, theme),
|
||||
MessageContent::Image(image) => {
|
||||
println!("Image: [data: {}, type: {}]", image.data, image.mime_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print_newline();
|
||||
io::stdout().flush().expect("Failed to flush stdout");
|
||||
}
|
||||
|
||||
pub fn default_response_renderer(tool_response: &ToolResponse, theme: &str) {
|
||||
match &tool_response.tool_result {
|
||||
Ok(contents) => {
|
||||
for content in contents {
|
||||
if content
|
||||
.audience()
|
||||
.is_some_and(|audience| !audience.contains(&Role::User))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let min_priority = std::env::var("GOOSE_CLI_MIN_PRIORITY")
|
||||
.ok()
|
||||
.and_then(|val| val.parse::<f32>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// if priority is not set OR less than or equal to min_priority, do not render
|
||||
if content
|
||||
.priority()
|
||||
.is_some_and(|priority| priority <= min_priority)
|
||||
|| content.priority().is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Content::Text(text) = content {
|
||||
print_markdown(&text.text, theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => print_markdown(&e.to_string(), theme),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_print_request_header(call: &ToolCall) {
|
||||
// Print the tool name with an emoji
|
||||
|
||||
// use rsplit to handle any prefixed tools with more underscores
|
||||
// unicode gets converted to underscores during sanitization
|
||||
let parts: Vec<_> = call.name.rsplit("__").collect();
|
||||
|
||||
let tool_header = format!(
|
||||
"─── {} | {} ──────────────────────────",
|
||||
style(parts.first().unwrap_or(&"unknown")),
|
||||
style(
|
||||
parts
|
||||
.split_first()
|
||||
// client name is the rest of the split, reversed
|
||||
// reverse the iterator and re-join on __
|
||||
.map(|(_, s)| s.iter().rev().copied().collect::<Vec<_>>().join("__"))
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
)
|
||||
.magenta()
|
||||
.dim(),
|
||||
);
|
||||
print_newline();
|
||||
println!("{}", tool_header);
|
||||
}
|
||||
|
||||
pub fn print_markdown(content: &str, theme: &str) {
|
||||
bat::PrettyPrinter::new()
|
||||
.input(bat::Input::from_bytes(content.as_bytes()))
|
||||
.theme(theme)
|
||||
.language("Markdown")
|
||||
.wrapping_mode(WrappingMode::Character)
|
||||
.print()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Format and print parameters recursively with proper indentation and colors
|
||||
pub fn print_params(value: &Value, depth: usize) {
|
||||
let indent = INDENT.repeat(depth);
|
||||
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for (key, val) in map {
|
||||
match val {
|
||||
Value::Object(_) => {
|
||||
println!("{}{}:", indent, style(key).dim());
|
||||
print_params(val, depth + 1);
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
println!("{}{}:", indent, style(key).dim());
|
||||
for item in arr.iter() {
|
||||
println!("{}{}- ", indent, INDENT);
|
||||
print_params(item, depth + 2);
|
||||
}
|
||||
}
|
||||
Value::String(s) => {
|
||||
if s.len() > MAX_STRING_LENGTH {
|
||||
println!("{}{}: {}", indent, style(key).dim(), style("...").dim());
|
||||
} else {
|
||||
println!("{}{}: {}", indent, style(key).dim(), style(s).green());
|
||||
}
|
||||
}
|
||||
Value::Number(n) => {
|
||||
println!("{}{}: {}", indent, style(key).dim(), style(n).blue());
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
println!("{}{}: {}", indent, style(key).dim(), style(b).blue());
|
||||
}
|
||||
Value::Null => {
|
||||
println!("{}{}: {}", indent, style(key).dim(), style("null").dim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
println!("{}{}.", indent, i + 1);
|
||||
print_params(item, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::String(s) => {
|
||||
if s.len() > MAX_STRING_LENGTH {
|
||||
println!(
|
||||
"{}{}",
|
||||
indent,
|
||||
style(format!("[REDACTED: {} chars]", s.len())).yellow()
|
||||
);
|
||||
} else {
|
||||
println!("{}{}", indent, style(s).green());
|
||||
}
|
||||
}
|
||||
Value::Number(n) => {
|
||||
println!("{}{}", indent, style(n).yellow());
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
println!("{}{}", indent, style(b).yellow());
|
||||
}
|
||||
Value::Null => {
|
||||
println!("{}{}", indent, style("null").dim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_newline() {
|
||||
println!();
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
renderer::{
|
||||
render, BashDeveloperExtensionRenderer, DefaultRenderer, TextEditorRenderer, ToolRenderer,
|
||||
},
|
||||
thinking::get_random_thinking_message,
|
||||
Input, InputType, Prompt, Theme,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use cliclack::spinner;
|
||||
use goose::message::Message;
|
||||
use mcp_core::Role;
|
||||
use rustyline::{DefaultEditor, EventHandler, KeyCode, KeyEvent, Modifiers};
|
||||
|
||||
const PROMPT: &str = "\x1b[1m\x1b[38;5;30m( O)> \x1b[0m";
|
||||
|
||||
pub struct RustylinePrompt {
|
||||
spinner: cliclack::ProgressBar,
|
||||
theme: Theme,
|
||||
renderers: HashMap<String, Box<dyn ToolRenderer>>,
|
||||
editor: DefaultEditor,
|
||||
}
|
||||
|
||||
impl RustylinePrompt {
|
||||
pub fn new() -> Self {
|
||||
let mut renderers: HashMap<String, Box<dyn ToolRenderer>> = HashMap::new();
|
||||
let default_renderer = DefaultRenderer;
|
||||
renderers.insert(default_renderer.tool_name(), Box::new(default_renderer));
|
||||
|
||||
let bash_dev_extension_renderer = BashDeveloperExtensionRenderer;
|
||||
renderers.insert(
|
||||
bash_dev_extension_renderer.tool_name(),
|
||||
Box::new(bash_dev_extension_renderer),
|
||||
);
|
||||
|
||||
let text_editor_renderer = TextEditorRenderer;
|
||||
renderers.insert(
|
||||
text_editor_renderer.tool_name(),
|
||||
Box::new(text_editor_renderer),
|
||||
);
|
||||
|
||||
let mut editor = DefaultEditor::new().expect("Failed to create editor");
|
||||
editor.bind_sequence(
|
||||
KeyEvent(KeyCode::Char('j'), Modifiers::CTRL),
|
||||
EventHandler::Simple(rustyline::Cmd::Newline),
|
||||
);
|
||||
|
||||
RustylinePrompt {
|
||||
spinner: spinner(),
|
||||
theme: std::env::var("GOOSE_CLI_THEME")
|
||||
.ok()
|
||||
.map(|val| {
|
||||
if val.eq_ignore_ascii_case("light") {
|
||||
Theme::Light
|
||||
} else {
|
||||
Theme::Dark
|
||||
}
|
||||
})
|
||||
.unwrap_or(Theme::Dark),
|
||||
renderers,
|
||||
editor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Prompt for RustylinePrompt {
|
||||
fn render(&mut self, message: Box<Message>) {
|
||||
render(&message, &self.theme, self.renderers.clone());
|
||||
}
|
||||
|
||||
fn show_busy(&mut self) {
|
||||
self.spinner = spinner();
|
||||
self.spinner
|
||||
.start(format!("{}...", get_random_thinking_message()));
|
||||
}
|
||||
|
||||
fn hide_busy(&self) {
|
||||
self.spinner.stop("");
|
||||
}
|
||||
|
||||
fn get_input(&mut self) -> Result<Input> {
|
||||
let input = self.editor.readline(PROMPT);
|
||||
let mut message_text = match input {
|
||||
Ok(text) => {
|
||||
// Add valid input to history
|
||||
if let Err(e) = self.editor.add_history_entry(text.as_str()) {
|
||||
eprintln!("Failed to add to history: {}", e);
|
||||
}
|
||||
text
|
||||
}
|
||||
Err(e) => {
|
||||
match e {
|
||||
rustyline::error::ReadlineError::Interrupted => (),
|
||||
_ => eprintln!("Input error: {}", e),
|
||||
}
|
||||
return Ok(Input {
|
||||
input_type: InputType::Exit,
|
||||
content: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
message_text = message_text.trim().to_string();
|
||||
|
||||
if message_text.eq_ignore_ascii_case("/exit")
|
||||
|| message_text.eq_ignore_ascii_case("/quit")
|
||||
|| message_text.eq_ignore_ascii_case("exit")
|
||||
|| message_text.eq_ignore_ascii_case("quit")
|
||||
{
|
||||
Ok(Input {
|
||||
input_type: InputType::Exit,
|
||||
content: None,
|
||||
})
|
||||
} else if message_text.eq_ignore_ascii_case("/t") {
|
||||
self.theme = match self.theme {
|
||||
Theme::Light => {
|
||||
println!("Switching to Dark theme");
|
||||
Theme::Dark
|
||||
}
|
||||
Theme::Dark => {
|
||||
println!("Switching to Light theme");
|
||||
Theme::Light
|
||||
}
|
||||
};
|
||||
return Ok(Input {
|
||||
input_type: InputType::AskAgain,
|
||||
content: None,
|
||||
});
|
||||
} else if message_text.eq_ignore_ascii_case("/?")
|
||||
|| message_text.eq_ignore_ascii_case("/help")
|
||||
{
|
||||
println!("Commands:");
|
||||
println!("/exit - Exit the session");
|
||||
println!("/t - Toggle Light/Dark theme");
|
||||
println!("/? | /help - Display this help message");
|
||||
println!("Ctrl+C - Interrupt goose (resets the interaction to before the interrupted user request)");
|
||||
println!("Ctrl+j - Adds a newline");
|
||||
println!("Use Up/Down arrow keys to navigate through command history");
|
||||
return Ok(Input {
|
||||
input_type: InputType::AskAgain,
|
||||
content: None,
|
||||
});
|
||||
} else {
|
||||
return Ok(Input {
|
||||
input_type: InputType::Message,
|
||||
content: Some(message_text.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn load_user_message_history(&mut self, messages: Vec<Message>) {
|
||||
for message in messages.into_iter().filter(|m| m.role == Role::User) {
|
||||
for content in message.content {
|
||||
if let Some(text) = content.as_text() {
|
||||
if let Err(e) = self.editor.add_history_entry(text) {
|
||||
eprintln!("Failed to add to history: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
// No cleanup required
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
/// Extended list of playful thinking messages including both goose and general AI actions
|
||||
pub const THINKING_MESSAGES: &[&str] = &[
|
||||
"Thinking",
|
||||
"Thinking hard",
|
||||
// Include all goose actions
|
||||
"Spreading wings",
|
||||
"Honking thoughtfully",
|
||||
"Waddling to conclusions",
|
||||
"Flapping wings excitedly",
|
||||
"Preening code feathers",
|
||||
"Gathering digital breadcrumbs",
|
||||
"Paddling through data",
|
||||
"Migrating thoughts",
|
||||
"Nesting ideas",
|
||||
"Squawking calculations",
|
||||
"Ruffling algorithmic feathers",
|
||||
"Pecking at problems",
|
||||
"Stretching webbed feet",
|
||||
"Foraging for solutions",
|
||||
"Grooming syntax",
|
||||
"Building digital nest",
|
||||
"Patrolling the codebase",
|
||||
"Gosling about",
|
||||
"Strutting with purpose",
|
||||
"Diving for answers",
|
||||
"Herding bytes",
|
||||
"Molting old code",
|
||||
"Swimming through streams",
|
||||
"Goose-stepping through logic",
|
||||
"Synchronizing flock algorithms",
|
||||
"Navigating code marshes",
|
||||
"Incubating brilliant ideas",
|
||||
"Arranging feathers recursively",
|
||||
"Gliding through branches",
|
||||
"Migrating to better solutions",
|
||||
"Nesting functions carefully",
|
||||
"Hatching clever solutions",
|
||||
"Preening parse trees",
|
||||
"Flying through functions",
|
||||
"Gathering syntax seeds",
|
||||
"Webbing connections",
|
||||
"Flocking to optimizations",
|
||||
"Paddling through protocols",
|
||||
"Honking success signals",
|
||||
"Waddling through workflows",
|
||||
"Nesting in neural networks",
|
||||
// AI thinking actions
|
||||
"Consulting the digital oracle",
|
||||
"Summoning binary spirits",
|
||||
"Reticulating splines",
|
||||
"Calculating meaning of life",
|
||||
"Traversing neural pathways",
|
||||
"Untangling spaghetti code",
|
||||
"Mining thought gems",
|
||||
"Defragmenting brain bits",
|
||||
"Compiling wisdom",
|
||||
"Debugging reality",
|
||||
"Optimizing thought processes",
|
||||
"Scanning parallel universes",
|
||||
"Reorganizing bits and bytes",
|
||||
"Calibrating neural networks",
|
||||
"Charging creativity cells",
|
||||
"Indexing imagination",
|
||||
"Parsing possibilities",
|
||||
"Buffering brilliance",
|
||||
"Loading clever responses",
|
||||
"Generating witty remarks",
|
||||
"Synthesizing solutions",
|
||||
"Applying machine learning",
|
||||
"Calculating quantum states",
|
||||
"Analyzing algorithms",
|
||||
"Decoding human intent",
|
||||
"Exploring solution space",
|
||||
"Gathering computational momentum",
|
||||
"Initializing clever mode",
|
||||
"Juggling variables",
|
||||
"Knitting neural networks",
|
||||
"Learning at light speed",
|
||||
"Navigating knowledge graphs",
|
||||
"Orchestrating outputs",
|
||||
"Pondering possibilities",
|
||||
"Reading between the lines",
|
||||
"Searching solution space",
|
||||
"Training thought vectors",
|
||||
"Unfolding understanding",
|
||||
"Validating variables",
|
||||
"Weaving wisdom web",
|
||||
"Yielding insights",
|
||||
"Zooming through zettabytes",
|
||||
"Baking fresh ideas",
|
||||
"Charging creativity crystals",
|
||||
"Dancing with data",
|
||||
"Enchanting electrons",
|
||||
"Folding thought origami",
|
||||
"Growing solution trees",
|
||||
"Harmonizing heuristics",
|
||||
"Inspiring innovations",
|
||||
"Jazzing up algorithms",
|
||||
"Kindling knowledge",
|
||||
"Levitating logic gates",
|
||||
"Manifesting solutions",
|
||||
"Nurturing neural nets",
|
||||
"Optimizing outcomes",
|
||||
"Painting with pixels",
|
||||
"Questioning bits",
|
||||
"Recycling random thoughts",
|
||||
"Serenading semiconductors",
|
||||
"Taming tensors",
|
||||
"Unlocking understanding",
|
||||
"Visualizing vectors",
|
||||
"Wrangling widgets",
|
||||
"Yodeling yaml",
|
||||
"Aligning artificial awarenesses",
|
||||
"Bootstrapping brain bytes",
|
||||
"Contemplating code conundrums",
|
||||
"Distilling digital dreams",
|
||||
"Energizing electron engines",
|
||||
"Fabricating future frameworks",
|
||||
"Generating genius guidelines",
|
||||
"Harmonizing hardware helpers",
|
||||
"Illuminating input insights",
|
||||
"Kindling knowledge kernels",
|
||||
"Linking logical lattices",
|
||||
"Materializing memory maps",
|
||||
"Navigating neural nodes",
|
||||
"Orchestrating output oracles",
|
||||
"Pioneering program paths",
|
||||
"Quantifying quantum queries",
|
||||
"Refactoring reality routines",
|
||||
"Synchronizing system states",
|
||||
"Transforming thought threads",
|
||||
"Unifying understanding units",
|
||||
"Vectorizing virtual visions",
|
||||
"Weaving wisdom wavelengths",
|
||||
"Yielding yaml yearnings",
|
||||
"Brewing binary brilliance",
|
||||
"Crafting code crystals",
|
||||
"Designing data dreams",
|
||||
"Encoding ethereal elements",
|
||||
"Filtering function flows",
|
||||
"Gathering gigabyte galaxies",
|
||||
"Hashing hope hypotheses",
|
||||
"Igniting innovation ions",
|
||||
"Joining joy journals",
|
||||
"Knitting knowledge knots",
|
||||
"Launching logic loops",
|
||||
"Merging memory matrices",
|
||||
"Nourishing neural networks",
|
||||
"Ordering output orbits",
|
||||
"Processing pattern particles",
|
||||
"Rendering reality rays",
|
||||
"Streaming syntax stars",
|
||||
"Threading thought theories",
|
||||
"Updating understanding units",
|
||||
"Validating virtual vectors",
|
||||
"Warming wisdom waves",
|
||||
"Examining electron echoes",
|
||||
"Yoking yesterday yields",
|
||||
"Assembling algorithm arrays",
|
||||
"Balancing binary bridges",
|
||||
"Calculating cosmic codes",
|
||||
"Debugging dream drivers",
|
||||
"Encrypting ethereal edges",
|
||||
"Formatting future frames",
|
||||
"Growing gradient gardens",
|
||||
"Harvesting hash harmonies",
|
||||
"Importing insight ions",
|
||||
"Keeping kernel keys",
|
||||
"Linking lambda loops",
|
||||
"Mapping memory mazes",
|
||||
"Normalizing neural nodes",
|
||||
"Organizing output oceans",
|
||||
"Parsing pattern paths",
|
||||
"Sampling syntax streams",
|
||||
"Testing thought threads",
|
||||
"Validating virtual vectors",
|
||||
"Examining electron echoes",
|
||||
"Accelerating abstract algebras",
|
||||
"Buffering binary bubbles",
|
||||
"Caching cosmic calculations",
|
||||
"Deploying digital dreams",
|
||||
"Evolving ethereal entities",
|
||||
"Calculating response probabilities",
|
||||
"Updating knowledge graphs",
|
||||
"Processing neural feedback",
|
||||
"Exploring decision trees",
|
||||
"Measuring semantic distance",
|
||||
"Connecting synaptic pathways",
|
||||
"Evaluating response options",
|
||||
"Scanning memory banks",
|
||||
"Simulating future outcomes",
|
||||
"Adjusting confidence weights",
|
||||
"Mapping context vectors",
|
||||
"Balancing response parameters",
|
||||
"Running inference engines",
|
||||
"Optimizing memory usage",
|
||||
"Merging knowledge streams",
|
||||
"Calibrating response tone",
|
||||
"Analyzing input patterns",
|
||||
"Processing feedback loops",
|
||||
"Measuring response quality",
|
||||
"Scanning information matrices",
|
||||
"Processing user intent",
|
||||
"Measuring response coherence",
|
||||
"Exploring solution paths",
|
||||
"Processing context clues",
|
||||
"Scanning memory circuits",
|
||||
"Building response chains",
|
||||
"Analyzing conversation flow",
|
||||
"Processing temporal data",
|
||||
"Exploring concept spaces",
|
||||
"Processing memory streams",
|
||||
"Evaluating logical paths",
|
||||
"Building thought graphs",
|
||||
"Scanning neural pathways",
|
||||
];
|
||||
|
||||
/// Returns a random thinking message from the extended list
|
||||
pub fn get_random_thinking_message() -> &'static str {
|
||||
THINKING_MESSAGES
|
||||
.choose(&mut rand::thread_rng())
|
||||
.unwrap_or(&THINKING_MESSAGES[0])
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use anyhow::Result;
|
||||
use core::panic;
|
||||
use futures::StreamExt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::log_usage::log_usage;
|
||||
use crate::prompt::{InputType, Prompt};
|
||||
use goose::agents::Agent;
|
||||
use goose::message::{Message, MessageContent};
|
||||
use mcp_core::handler::ToolError;
|
||||
use mcp_core::role::Role;
|
||||
|
||||
// File management functions
|
||||
pub fn ensure_session_dir() -> Result<PathBuf> {
|
||||
let home_dir = dirs::home_dir().ok_or(anyhow::anyhow!("Could not determine home directory"))?;
|
||||
let config_dir = home_dir.join(".config").join("goose").join("sessions");
|
||||
|
||||
if !config_dir.exists() {
|
||||
fs::create_dir_all(&config_dir)?;
|
||||
}
|
||||
|
||||
Ok(config_dir)
|
||||
}
|
||||
|
||||
pub fn get_most_recent_session() -> Result<PathBuf> {
|
||||
let session_dir = ensure_session_dir()?;
|
||||
let mut entries = fs::read_dir(&session_dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if entries.is_empty() {
|
||||
return Err(anyhow::anyhow!("No session files found"));
|
||||
}
|
||||
|
||||
// Sort by modification time, most recent first
|
||||
entries.sort_by(|a, b| {
|
||||
b.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
.cmp(
|
||||
&a.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
|
||||
)
|
||||
});
|
||||
|
||||
Ok(entries[0].path())
|
||||
}
|
||||
|
||||
pub fn readable_session_file(session_file: &PathBuf) -> Result<File> {
|
||||
match fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(session_file)
|
||||
{
|
||||
Ok(file) => Ok(file),
|
||||
Err(e) => Err(anyhow::anyhow!("Failed to open session file: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn persist_messages(session_file: &PathBuf, messages: &[Message]) -> Result<()> {
|
||||
let file = fs::File::create(session_file)?; // Create or truncate the file
|
||||
persist_messages_internal(file, messages)
|
||||
}
|
||||
|
||||
fn persist_messages_internal(session_file: File, messages: &[Message]) -> Result<()> {
|
||||
let mut writer = std::io::BufWriter::new(session_file);
|
||||
|
||||
for message in messages {
|
||||
serde_json::to_writer(&mut writer, &message)?;
|
||||
writeln!(writer)?;
|
||||
}
|
||||
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deserialize_messages(file: File) -> Result<Vec<Message>> {
|
||||
let reader = io::BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
messages.push(serde_json::from_str::<Message>(&line?)?);
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
// Session management
|
||||
pub struct Session<'a> {
|
||||
agent: Box<dyn Agent>,
|
||||
prompt: Box<dyn Prompt + 'a>,
|
||||
session_file: PathBuf,
|
||||
messages: Vec<Message>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl<'a> Session<'a> {
|
||||
pub fn new(
|
||||
agent: Box<dyn Agent>,
|
||||
mut prompt: Box<dyn Prompt + 'a>,
|
||||
session_file: PathBuf,
|
||||
) -> Self {
|
||||
let messages = match readable_session_file(&session_file) {
|
||||
Ok(file) => deserialize_messages(file).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"Failed to read messages from session file. Starting fresh.\n{}",
|
||||
e
|
||||
);
|
||||
Vec::<Message>::new()
|
||||
}),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to load session file. Starting fresh.\n{}", e);
|
||||
Vec::<Message>::new()
|
||||
}
|
||||
};
|
||||
|
||||
prompt.load_user_message_history(messages.clone());
|
||||
|
||||
Session {
|
||||
agent,
|
||||
prompt,
|
||||
session_file,
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.prompt.goose_ready();
|
||||
|
||||
loop {
|
||||
let input = self.prompt.get_input().unwrap();
|
||||
match input.input_type {
|
||||
InputType::Message => {
|
||||
if let Some(content) = &input.content {
|
||||
self.messages.push(Message::user().with_text(content));
|
||||
persist_messages(&self.session_file, &self.messages)?;
|
||||
}
|
||||
}
|
||||
InputType::Exit => break,
|
||||
InputType::AskAgain => continue,
|
||||
}
|
||||
|
||||
self.prompt.show_busy();
|
||||
self.agent_process_messages().await;
|
||||
self.prompt.hide_busy();
|
||||
}
|
||||
self.close_session().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn headless_start(
|
||||
&mut self,
|
||||
initial_message: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.messages
|
||||
.push(Message::user().with_text(initial_message.as_str()));
|
||||
persist_messages(&self.session_file, &self.messages)?;
|
||||
|
||||
self.agent_process_messages().await;
|
||||
|
||||
self.close_session().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn agent_process_messages(&mut self) {
|
||||
let mut stream = match self.agent.reply(&self.messages).await {
|
||||
Ok(stream) => stream,
|
||||
Err(e) => {
|
||||
eprintln!("Error starting reply stream: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
tokio::select! {
|
||||
response = stream.next() => {
|
||||
match response {
|
||||
Some(Ok(message)) => {
|
||||
self.messages.push(message.clone());
|
||||
persist_messages(&self.session_file, &self.messages).unwrap_or_else(|e| eprintln!("Failed to persist messages: {}", e));
|
||||
self.prompt.hide_busy();
|
||||
self.prompt.render(Box::new(message.clone()));
|
||||
self.prompt.show_busy();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
eprintln!("Error: {}", e);
|
||||
drop(stream);
|
||||
self.rewind_messages();
|
||||
self.prompt.render(raw_message(r#"
|
||||
The error above was an exception we were not able to handle.\n\n
|
||||
These errors are often related to connection or authentication\n
|
||||
We've removed the conversation up to the most recent user message
|
||||
- depending on the error you may be able to continue"#));
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
// Kill any running processes when the client disconnects
|
||||
// TODO is this used? I suspect post MCP this is on the server instead
|
||||
// goose::process_store::kill_processes();
|
||||
drop(stream);
|
||||
self.handle_interrupted_messages();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewind the messages to before the last user message (they have cancelled it).
|
||||
fn rewind_messages(&mut self) {
|
||||
if self.messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove messages until we find the last user 'Text' message (not a tool response).
|
||||
while let Some(message) = self.messages.last() {
|
||||
if message.role == Role::User
|
||||
&& message
|
||||
.content
|
||||
.iter()
|
||||
.any(|c| matches!(c, MessageContent::Text(_)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
self.messages.pop();
|
||||
}
|
||||
|
||||
// Remove the last user text message we found.
|
||||
if !self.messages.is_empty() {
|
||||
self.messages.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_interrupted_messages(&mut self) {
|
||||
// First, get any tool requests from the last message if it exists
|
||||
let tool_requests = self
|
||||
.messages
|
||||
.last()
|
||||
.filter(|msg| msg.role == Role::Assistant)
|
||||
.map_or(Vec::new(), |msg| {
|
||||
msg.content
|
||||
.iter()
|
||||
.filter_map(|content| {
|
||||
if let MessageContent::ToolRequest(req) = content {
|
||||
Some((req.id.clone(), req.tool_call.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
if !tool_requests.is_empty() {
|
||||
// Interrupted during a tool request
|
||||
// Create tool responses for all interrupted tool requests
|
||||
let mut response_message = Message::user();
|
||||
let last_tool_name = tool_requests
|
||||
.last()
|
||||
.and_then(|(_, tool_call)| tool_call.as_ref().ok().map(|tool| tool.name.clone()))
|
||||
.unwrap_or_else(|| "tool".to_string());
|
||||
|
||||
for (req_id, _) in &tool_requests {
|
||||
response_message.content.push(MessageContent::tool_response(
|
||||
req_id.clone(),
|
||||
Err(ToolError::ExecutionError(
|
||||
"Interrupted by the user to make a correction".to_string(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
self.messages.push(response_message);
|
||||
|
||||
let prompt_response = &format!(
|
||||
"We interrupted the existing call to {}. How would you like to proceed?",
|
||||
last_tool_name
|
||||
);
|
||||
self.messages
|
||||
.push(Message::assistant().with_text(prompt_response));
|
||||
self.prompt.render(raw_message(prompt_response));
|
||||
} else {
|
||||
// An interruption occurred outside of a tool request-response.
|
||||
if let Some(last_msg) = self.messages.last() {
|
||||
if last_msg.role == Role::User {
|
||||
match last_msg.content.first() {
|
||||
Some(MessageContent::ToolResponse(_)) => {
|
||||
// Interruption occurred after a tool had completed but not assistant reply
|
||||
let prompt_response = "We interrupted the existing calls to tools. How would you like to proceed?";
|
||||
self.messages
|
||||
.push(Message::assistant().with_text(prompt_response));
|
||||
self.prompt.render(raw_message(prompt_response));
|
||||
}
|
||||
Some(_) => {
|
||||
// A real users message
|
||||
self.messages.pop();
|
||||
let prompt_response = "We interrupted before the model replied and removed the last message.";
|
||||
self.prompt.render(raw_message(prompt_response));
|
||||
}
|
||||
None => panic!("No content in last message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_session(&mut self) {
|
||||
self.prompt.render(raw_message(
|
||||
format!(
|
||||
"Closing session. Recorded to {}\n",
|
||||
self.session_file.display()
|
||||
)
|
||||
.as_str(),
|
||||
));
|
||||
self.prompt.close();
|
||||
let usage = self.agent.usage().await;
|
||||
log_usage(self.session_file.to_string_lossy().to_string(), usage);
|
||||
}
|
||||
|
||||
pub fn session_file(&self) -> PathBuf {
|
||||
self.session_file.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_message(content: &str) -> Box<Message> {
|
||||
Box::new(Message::assistant().with_text(content))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Helper function to set up a temporary home directory for testing, returns path of that temp dir.
|
||||
/// Also creates a default profiles.json to avoid obscure test failures when there are no profiles.
|
||||
#[cfg(test)]
|
||||
pub fn run_with_tmp_dir<F: FnOnce() -> T, T>(func: F) -> T {
|
||||
use std::ffi::OsStr;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let temp_dir_path = temp_dir.path().to_path_buf();
|
||||
setup_profile(&temp_dir_path, None);
|
||||
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("HOME", Some(temp_dir_path.as_os_str())),
|
||||
("DATABRICKS_HOST", Some(OsStr::new("tmp_host_url"))),
|
||||
],
|
||||
func,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
pub async fn run_with_tmp_dir_async<F, Fut, T>(func: F) -> T
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
use std::ffi::OsStr;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let temp_dir_path = temp_dir.path().to_path_buf();
|
||||
setup_profile(&temp_dir_path, None);
|
||||
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
("HOME", Some(temp_dir_path.as_os_str())),
|
||||
("DATABRICKS_HOST", Some(OsStr::new("tmp_host_url"))),
|
||||
],
|
||||
func(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(test)]
|
||||
/// Setup a goose profile for testing, and an optional profile string
|
||||
fn setup_profile(temp_dir_path: &Path, profile_string: Option<&str>) {
|
||||
use std::fs;
|
||||
|
||||
let profile_path = temp_dir_path
|
||||
.join(".config")
|
||||
.join("goose")
|
||||
.join("profiles.json");
|
||||
fs::create_dir_all(profile_path.parent().unwrap()).unwrap();
|
||||
let default_profile = r#"
|
||||
{
|
||||
"profile_items": {
|
||||
"default": {
|
||||
"provider": "databricks",
|
||||
"model": "claude-3-5-sonnet-2",
|
||||
"additional_extensions": []
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&profile_path, profile_string.unwrap_or(default_profile)).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user