diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e0083d7742..5ffafb7bbf 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -13,6 +13,7 @@ use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::project::{handle_project_default, handle_projects_interactive}; use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; +use crate::commands::term::{handle_term_init, handle_term_log, handle_term_run}; use crate::commands::schedule::{ handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove, @@ -34,6 +35,14 @@ use std::io::Read; use std::path::PathBuf; use tracing::warn; +fn non_empty_string(s: &str) -> Result { + if s.trim().is_empty() { + Err("Prompt cannot be empty".to_string()) + } else { + Ok(s.to_string()) + } +} + #[derive(Parser)] #[command(author, version, display_name = "", about, long_about = None)] struct Cli { @@ -829,6 +838,61 @@ enum Command { #[arg(long, help = "Authentication token to secure the web interface")] auth_token: Option, }, + + /// Terminal-integrated session (one session per terminal) + #[command( + about = "Terminal-integrated goose session", + long_about = "Runs a goose session tied to your terminal window.\n\ + Each terminal maintains its own persistent session that resumes automatically.\n\n\ + Setup:\n \ + eval \"$(goose term init zsh)\" # Add to ~/.zshrc\n\n\ + Usage:\n \ + goose term run \"list files in this directory\"\n \ + gt \"create a python script\" # using alias" + )] + Term { + #[command(subcommand)] + command: TermCommand, + }, +} + +#[derive(Subcommand)] +enum TermCommand { + /// Print shell initialization script + #[command( + about = "Print shell initialization script", + long_about = "Prints shell configuration to set up terminal-integrated sessions.\n\ + Each terminal gets a persistent goose session that automatically resumes.\n\n\ + Setup:\n \ + echo 'eval \"$(goose term init zsh)\"' >> ~/.zshrc\n \ + source ~/.zshrc" + )] + Init { + /// Shell type (bash, zsh, fish, powershell) + #[arg(value_enum)] + shell: Shell, + }, + + /// Log a shell command (called by shell hook) + #[command(about = "Log a shell command to the session", hide = true)] + Log { + /// The command that was executed + command: String, + }, + + /// Run a prompt in the terminal session + #[command( + about = "Run a prompt in the terminal session", + long_about = "Run a prompt in the terminal-integrated session.\n\n\ + Examples:\n \ + goose term run \"list files in this directory\"\n \ + goose term run \"create a python script that prints hello world\"" + )] + Run { + /// The prompt to send to goose + #[arg(value_parser = non_empty_string)] + prompt: String, + }, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -838,6 +902,14 @@ enum CliProviderVariant { Ollama, } +#[derive(clap::ValueEnum, Clone, Debug)] +enum Shell { + Bash, + Zsh, + Fish, + Powershell, +} + #[derive(Debug)] pub struct InputConfig { pub contents: Option, @@ -874,6 +946,7 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Bench { .. }) => "bench", Some(Command::Recipe { .. }) => "recipe", Some(Command::Web { .. }) => "web", + Some(Command::Term { .. }) => "term", None => "default_session", }; @@ -1387,6 +1460,26 @@ pub async fn cli() -> anyhow::Result<()> { crate::commands::web::handle_web(port, host, open, auth_token).await?; return Ok(()); } + Some(Command::Term { command }) => { + match command { + TermCommand::Init { shell } => { + let shell_str = match shell { + Shell::Bash => "bash", + Shell::Zsh => "zsh", + Shell::Fish => "fish", + Shell::Powershell => "powershell", + }; + handle_term_init(shell_str)?; + } + TermCommand::Log { command } => { + handle_term_log(command).await?; + } + TermCommand::Run { prompt } => { + handle_term_run(prompt).await?; + } + } + return Ok(()); + } None => { return if !Config::global().exists() { handle_configure().await?; diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 698fd02fdf..e0d54e9678 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -6,5 +6,6 @@ pub mod project; pub mod recipe; pub mod schedule; pub mod session; +pub mod term; pub mod update; pub mod web; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs new file mode 100644 index 0000000000..685447a842 --- /dev/null +++ b/crates/goose-cli/src/commands/term.rs @@ -0,0 +1,192 @@ +use anyhow::{anyhow, Result}; +use goose::session::session_manager::SessionType; +use goose::session::SessionManager; +use uuid::Uuid; + +use crate::session::{build_session, SessionBuilderConfig}; + +const TERMINAL_SESSION_PREFIX: &str = "term:"; + +/// Handle `goose term init ` - print shell initialization script +pub fn handle_term_init(shell: &str) -> Result<()> { + let terminal_id = Uuid::new_v4().to_string(); + + // Get the path to the current goose binary + let goose_bin = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "goose".to_string()); + + let script = match shell.to_lowercase().as_str() { + "bash" => { + format!( + r#"export GOOSE_TERMINAL_ID="{terminal_id}" +alias gt='{goose_bin} term run' + +# Log commands to goose (runs silently in background) +goose_preexec() {{ + [[ "$1" =~ ^goose\ term ]] && return + [[ "$1" =~ ^gt\ ]] && return + ("{goose_bin}" term log "$1" &) 2>/dev/null +}} + +# Install preexec hook for bash +if [[ -z "$goose_preexec_installed" ]]; then + goose_preexec_installed=1 + trap 'goose_preexec "$BASH_COMMAND"' DEBUG +fi"# + ) + } + "zsh" => { + format!( + r#"export GOOSE_TERMINAL_ID="{terminal_id}" +alias gt='{goose_bin} term run' + +# Log commands to goose (runs silently in background) +goose_preexec() {{ + [[ "$1" =~ ^goose\ term ]] && return + [[ "$1" =~ ^gt\ ]] && return + ("{goose_bin}" term log "$1" &) 2>/dev/null +}} + +# Install preexec hook for zsh +autoload -Uz add-zsh-hook +add-zsh-hook preexec goose_preexec"# + ) + } + "fish" => { + format!( + r#"set -gx GOOSE_TERMINAL_ID "{terminal_id}" +alias gt='{goose_bin} term run' + +# Log commands to goose +function goose_preexec --on-event fish_preexec + string match -q -r '^goose term' -- $argv[1]; and return + string match -q -r '^gt ' -- $argv[1]; and return + {goose_bin} term log $argv[1] 2>/dev/null & +end"# + ) + } + "powershell" | "pwsh" => { + format!( + r#"$env:GOOSE_TERMINAL_ID = "{terminal_id}" +Set-Alias -Name gt -Value {{ {goose_bin} term run $args }} + +# Log commands to goose +Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ + $line = $null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) + if ($line -notmatch '^goose term' -and $line -notmatch '^gt ') {{ + Start-Job -ScriptBlock {{ {goose_bin} term log $using:line }} | Out-Null + }} + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() +}}"# + ) + } + _ => { + return Err(anyhow!( + "Unsupported shell: {}. Supported shells: bash, zsh, fish, powershell", + shell + )); + } + }; + + println!("{}", script); + Ok(()) +} + +/// Handle `goose term log ` - log a shell command to the database +pub async fn handle_term_log(command: String) -> Result<()> { + let terminal_id = std::env::var("GOOSE_TERMINAL_ID") + .map_err(|_| anyhow!("GOOSE_TERMINAL_ID not set. Run 'goose term init ' first."))?; + + let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); + let working_dir = std::env::current_dir()?; + + // Create session if it doesn't exist (so we can log commands before first run) + if SessionManager::get_session(&session_name, false) + .await + .is_err() + { + let session = SessionManager::create_session_with_id( + session_name.clone(), + working_dir.clone(), + session_name.clone(), + SessionType::User, + ) + .await?; + + SessionManager::update_session(&session.id) + .user_provided_name(session_name.clone()) + .apply() + .await?; + } + + SessionManager::add_shell_command(&session_name, &command, &working_dir).await?; + + Ok(()) +} + +/// Handle `goose term run ` - run a prompt in the terminal session +pub async fn handle_term_run(prompt: String) -> Result<()> { + let terminal_id = std::env::var("GOOSE_TERMINAL_ID").map_err(|_| { + anyhow!( + "GOOSE_TERMINAL_ID not set.\n\n\ + Add to your shell config (~/.zshrc or ~/.bashrc):\n \ + eval \"$(goose term init zsh)\"\n\n\ + Then restart your terminal or run: source ~/.zshrc" + ) + })?; + + let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); + + let session_id = match SessionManager::get_session(&session_name, false).await { + Ok(_) => { + SessionManager::update_session(&session_name) + .working_dir(std::env::current_dir()?) + .apply() + .await?; + session_name.clone() + } + Err(_) => { + let session = SessionManager::create_session_with_id( + session_name.clone(), + std::env::current_dir()?, + session_name.clone(), + SessionType::User, + ) + .await?; + + // Mark with user-provided name so session persists across restarts + SessionManager::update_session(&session.id) + .user_provided_name(session_name) + .apply() + .await?; + + session.id + } + }; + + let commands = SessionManager::get_shell_commands_since_last_message(&session_id).await?; + let prompt_with_context = if commands.is_empty() { + prompt + } else { + format!( + "\n{}\n\n\n{}", + commands.join("\n"), + prompt + ) + }; + + let config = SessionBuilderConfig { + session_id: Some(session_id), + resume: true, + interactive: false, + quiet: true, + ..Default::default() + }; + + let mut session = build_session(config).await; + session.headless(prompt_with_context).await?; + + Ok(()) +} diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7d63e665b4..f3fc3b621b 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -19,7 +19,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -const CURRENT_SCHEMA_VERSION: i32 = 6; +const CURRENT_SCHEMA_VERSION: i32 = 7; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; @@ -261,6 +261,18 @@ impl SessionManager { .await } + pub async fn create_session_with_id( + id: String, + working_dir: PathBuf, + name: String, + session_type: SessionType, + ) -> Result { + Self::instance() + .await? + .create_session_with_id(id, working_dir, name, session_type) + .await + } + pub async fn get_session(id: &str, include_messages: bool) -> Result { Self::instance() .await? @@ -361,6 +373,24 @@ impl SessionManager { .search_chat_history(query, limit, after_date, before_date, exclude_session_id) .await } + + pub async fn add_shell_command( + session_id: &str, + command: &str, + working_dir: &Path, + ) -> Result<()> { + Self::instance() + .await? + .add_shell_command(session_id, command, working_dir) + .await + } + + pub async fn get_shell_commands_since_last_message(session_id: &str) -> Result> { + Self::instance() + .await? + .get_shell_commands_since_last_message(session_id) + .await + } } pub struct SessionStorage { @@ -598,6 +628,29 @@ impl SessionStorage { .execute(&pool) .await?; + sqlx::query( + r#" + CREATE TABLE shell_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + command TEXT NOT NULL, + working_dir TEXT NOT NULL, + created_timestamp INTEGER NOT NULL + ) + "#, + ) + .execute(&pool) + .await?; + + sqlx::query("CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)") + .execute(&pool) + .await?; + sqlx::query( + "CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)", + ) + .execute(&pool) + .await?; + Ok(Self { pool }) } @@ -836,6 +889,31 @@ impl SessionStorage { .execute(&self.pool) .await?; } + 7 => { + sqlx::query( + r#" + CREATE TABLE shell_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + command TEXT NOT NULL, + working_dir TEXT NOT NULL, + created_timestamp INTEGER NOT NULL + ) + "#, + ) + .execute(&self.pool) + .await?; + + sqlx::query( + "CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)", + ) + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)") + .execute(&self.pool) + .await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } @@ -883,6 +961,48 @@ impl SessionStorage { Ok(session) } + async fn create_session_with_id( + &self, + id: String, + working_dir: PathBuf, + name: String, + session_type: SessionType, + ) -> Result { + let mut tx = self.pool.begin().await?; + + // Use INSERT OR IGNORE to handle race conditions where multiple processes + // might try to create the same session simultaneously + sqlx::query( + r#" + INSERT OR IGNORE INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data) + VALUES (?, ?, FALSE, ?, ?, '{}') + "#, + ) + .bind(&id) + .bind(&name) + .bind(session_type.to_string()) + .bind(working_dir.to_string_lossy().as_ref()) + .execute(&mut *tx) + .await?; + + let session = sqlx::query_as::<_, Session>( + r#" + SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data, + total_tokens, input_tokens, output_tokens, + accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, + schedule_id, recipe_json, user_recipe_values_json, + provider_name, model_config_json + FROM sessions WHERE id = ? + "#, + ) + .bind(&id) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + Ok(session) + } + async fn get_session(&self, id: &str, include_messages: bool) -> Result { let mut session = sqlx::query_as::<_, Session>( r#" @@ -1286,6 +1406,55 @@ impl SessionStorage { .execute() .await } + + async fn add_shell_command( + &self, + session_id: &str, + command: &str, + working_dir: &Path, + ) -> Result<()> { + // Use seconds to match messages table timestamp format + let timestamp = chrono::Utc::now().timestamp(); + + sqlx::query( + r#" + INSERT INTO shell_commands (session_id, command, working_dir, created_timestamp) + VALUES (?, ?, ?, ?) + "#, + ) + .bind(session_id) + .bind(command) + .bind(working_dir.to_string_lossy().as_ref()) + .bind(timestamp) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn get_shell_commands_since_last_message(&self, session_id: &str) -> Result> { + let last_message_timestamp = sqlx::query_scalar::<_, Option>( + "SELECT MAX(created_timestamp) FROM messages WHERE session_id = ?", + ) + .bind(session_id) + .fetch_one(&self.pool) + .await? + .unwrap_or(0); + + let commands = sqlx::query_scalar::<_, String>( + r#" + SELECT command FROM shell_commands + WHERE session_id = ? AND created_timestamp > ? + ORDER BY created_timestamp ASC + "#, + ) + .bind(session_id) + .bind(last_message_timestamp) + .fetch_all(&self.pool) + .await?; + + Ok(commands) + } } #[cfg(test)] @@ -1492,4 +1661,102 @@ mod tests { assert!(imported.user_set_name); assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); } + + #[tokio::test] + async fn test_create_session_with_id_race_condition() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_race.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let session_id = "test-race-session"; + let mut handles = vec![]; + + // Spawn multiple tasks trying to create the same session simultaneously + for _ in 0..10 { + let storage = Arc::clone(&storage); + let id = session_id.to_string(); + handles.push(tokio::spawn(async move { + storage + .create_session_with_id( + id.clone(), + PathBuf::from("/tmp/test"), + id, + SessionType::User, + ) + .await + })); + } + + // All should succeed without UNIQUE constraint errors + for handle in handles { + let result = handle.await.unwrap(); + assert!( + result.is_ok(), + "create_session_with_id failed: {:?}", + result + ); + } + + // Should only have one session with this ID + let session = storage.get_session(session_id, false).await.unwrap(); + assert_eq!(session.id, session_id); + } + + #[tokio::test] + async fn test_shell_commands_since_last_message() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_shell.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let session = storage + .create_session( + PathBuf::from("/tmp/test"), + "test".to_string(), + SessionType::User, + ) + .await + .unwrap(); + + // Add some shell commands + storage + .add_shell_command(&session.id, "ls -la", &PathBuf::from("/tmp")) + .await + .unwrap(); + storage + .add_shell_command(&session.id, "cd foo", &PathBuf::from("/tmp")) + .await + .unwrap(); + + // Should get both commands (no messages yet) + let commands = storage + .get_shell_commands_since_last_message(&session.id) + .await + .unwrap(); + assert_eq!(commands.len(), 2); + assert_eq!(commands[0], "ls -la"); + assert_eq!(commands[1], "cd foo"); + + // Add a message with timestamp in the future to ensure it's after shell commands + let future_timestamp = chrono::Utc::now().timestamp() + 100; + storage + .add_message( + &session.id, + &Message { + id: None, + role: Role::User, + created: future_timestamp, + content: vec![MessageContent::text("test")], + metadata: Default::default(), + }, + ) + .await + .unwrap(); + + // Commands before the message should not be returned + let commands = storage + .get_shell_commands_since_last_message(&session.id) + .await + .unwrap(); + assert_eq!(commands.len(), 0); + } } diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md new file mode 100644 index 0000000000..c9b809d230 --- /dev/null +++ b/documentation/docs/guides/terminal-integration.md @@ -0,0 +1,75 @@ +# Terminal Integration + +The `goose term` commands let you talk to goose directly from your shell prompt. Instead of switching to a separate REPL session, you stay in your terminal and call goose when you need it. + +```bash +gt "what does this error mean?" +``` + +Goose responds, you read the answer, and you're back at your prompt. The conversation lives alongside your work, not in a separate window you have to manage. + +## Command History Awareness + +The real power comes from shell integration. Once set up, goose tracks the commands you run, so when you ask a question, it already knows what you've been doing. + +No more copy-pasting error messages or explaining "I ran these commands...". Just work normally, then ask goose for help. + +## Setup + +Add one line to your shell config: + +**zsh** (`~/.zshrc`) +```bash +eval "$(goose term init zsh)" +``` + +**bash** (`~/.bashrc`) +```bash +eval "$(goose term init bash)" +``` + +**fish** (`~/.config/fish/config.fish`) +```fish +goose term init fish | source +``` + +**PowerShell** (`$PROFILE`) +```powershell +Invoke-Expression (goose term init powershell) +``` + +Then restart your terminal or source the config. + +## Usage + +Once set up, your terminal gets a session ID. All commands you run are logged to that session. + +To talk to goose about what you've been doing: + +```bash +gt "why did that fail?" +``` + +`gt` is just an alias for `goose term run`. It opens goose with your command history already loaded. + +## What Gets Logged + +Every command you type gets stored with its timestamp and working directory. Goose sees commands you ran since your last message to it. + +Commands starting with `goose term` or `gt` are not logged (to avoid noise). + +## Performance + +- **Shell startup**: adds ~10ms +- **Per command**: ~10ms, runs in background (non-blocking) + +You won't notice any delay. The logging happens asynchronously after your command starts executing. + +## How It Works + +`goose term init` outputs shell code that: +1. Sets a `GOOSE_TERMINAL_ID` environment variable +2. Creates the `gt` alias +3. Installs a preexec hook that calls `goose term log` for each command + +The hook runs `goose term log &` in the background, which writes to a local SQLite database. When you run `gt`, goose queries that database for commands since your last message.