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:
Bradley Axen
2025-01-24 13:04:43 -08:00
committed by GitHub
parent eccb1b2261
commit 1c9a7c0b05
688 changed files with 71147 additions and 19132 deletions
@@ -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(())
}
}
+486
View File
@@ -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(())
}
+33
View File
@@ -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?)
}
+5
View File
@@ -0,0 +1,5 @@
pub mod agent_version;
pub mod configure;
pub mod mcp;
pub mod session;
pub mod version;
+178
View File
@@ -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(),
);
}
+3
View File
@@ -0,0 +1,3 @@
pub fn print_version() {
println!(env!("CARGO_PKG_VERSION"))
}
+93
View File
@@ -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();
})
}
}
+237
View File
@@ -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(&timestamp));
// 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),
}
}
}
}
+234
View File
@@ -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(())
}
+38
View File
@@ -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,
}
+407
View File
@@ -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!();
}
+167
View File
@@ -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
}
}
+225
View File
@@ -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])
}
+331
View File
@@ -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))
}
+70
View File
@@ -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();
}